Hey, you forgot to do snapshot.apply() before println("name after applying: " + dog.name.value) in 8th code example.
BTW great article - so far - I'm in the middle :-)
This article just blew my mind, I had no idea all this stuff is happening under the hood! It's so well explained here, I've got so many ideas of where this snapshot API could be used in some of my open source work!
One question though, what Gradle dependency would I need to play around with this API? Or, do you have a sample repo with the Gradle setup and some of these examples?
It’s all in the compose runtime artifact. But you don’t need to enable the compose compiler to use the snapshot apis. That’s a good question, I’ll add this information to the post directly.
Amazing post, can't wait for a next part about deep dive into inner workings! It still looks magical, like calling static methods and everything is somehow hooked up under the hoods. Unless your examples were intentionally simplified? I'm wondering how the entry point to all of this looks like. Anyways thank you for taking time and writing this down!
The samples are a bit contrived, and as noted I omitted dispose calls for all but the first, but other than that this is all working code you can run. There’s no other entry point - this is the entry point!
One of the reasons it looks like magic is because it’s using thread locals to pass information implicitly down the call stack.
I found a typo: mutableSnapshotOf -> mutableStateOf, but more importantly: I don't understand what do you mean by "all writes are considered non-conflicting" in standard equality policies. I guess you've shown the write conflict in the example above. I also don't get how custom merge should report a failure. By returning null? But what if the mutable state type is nullable and we want to succeed with null result.
BTW if you're hesitating about next parts of this awesome series how about an example of custom simplified text "UI"? Sth like github.com/JakeWharton/mosaic but simplified to fit the blog post(s). It would be great to see how this snapshot system (and some custom applier I guess) should be used in such cases and why. Especially the "why". I find your explanations of intentions behind the compose parts especially useful.
I don't understand what do you mean by "all writes are considered non-conflicting" in standard equality policies. … I also don't get how custom merge should report a failure. By returning null? But what if the mutable state type is nullable and we want to succeed with null result.
I am not sure of the answer to this unfortunately. Since the default return value is null, and in that case conflicting writes do fail, the wording "all writes are non-conflicting" is definitely confusing. I'll dig into this a bit more and try to clarify.
Equality policies docs have been clarified since then. Looks like "structural" & "referential" are non-conflicting when new value is equal to an old one. neverEqualPolicy is always conflicting.
Super useful and insightful. Looking forward to more on the inner workings of this mechanism!
The part about advancing global snapshots had me a little confused. Are global snapshots "automatically" advanced only when also paired with Compose UI (where composables + UI frames are involved)? i.e. manipulating mutable states in View toolkit world would require using something like withMutableSnapshot {} ?
Yes. If you're not using the rest of the Compose UI Android runtime, then nothing is advancing the global snapshot for you. Using withMutableSnapshot is one approach, although it's easy to forget to do that and you'd need to use it even in event handlers which would probably feel pretty boilerplatey. It would probably be better to set up your own callback to advance the global snapshot on every frame, similar to how Compose UI Android works under the hood.
It would probably be better to set up your own callback to advance the global snapshot on every frame, similar to how Compose UI Android works under the hood
Ahh yes perfect, this was the missing piece for me. Trying to experiment with using these APIs in Fragments/Android Views for some interop cases. Thank you!
Code that needs consistency needs to take snapshots, but in most cases snapshots can be ignored
So, if we change two pieces of State one after another on the main thread, these changes are guaranteed to become visible to observers "atomically" (in the same recomposition), but to achieve the same result from background thread we need something like withMutableSnapshot, right?
@zachklipp Could you please clarify a bit on this point? If snapshot system has these atomicity guarantees, we can replace StateFlow<SomeDataClass>.update { it.copy(...) } with several separate States, greatly simplifying ViewModel code without sacrificing behavior correctness.
BTW, will upcoming parallel (multi-threaded) composition change anything in this regard?
Generally, yes. It can often be a lot more natural and simpler to express multiple parts of state as separate properties vs having to wrap them all up into one immutable thing to shove inside a MutableStateFlow.
Note that there are some subtle differences since update handles contention by simply re-running the function, whereas the snapshot system can sometimes resolve conflicts without re-running anything by merging conflicting values in a type-aware way. That said, in mobile apps contention is actually probably quite rare.
Multi-threaded composition will probably have lots of interesting implications, but thread safety is thread safety – it shouldn't require any significant changes to how snapshots work.
Thanks for the detailed reply and for the warning about contention. It may be rare, but that means better chances of not catching it in tests and then having a rare crash with SnapshotApplyConflictException in production. ;)
So, to recap:
For atomic updates of single value (e.g. counter increments) from background thread we should use conflict-free data types and custom conflict resolver (SnapshotMutationPolicy).
To update atomically multiple related parts of state from background thread we should take an explicit snapshot and retry until success (like MutableStateFlow.update() does), for example:
fun <R> withSnapshotRetrying(block: () -> R): R {
while (true) {
try {
return Snapshot.withMutableSnapshot(block)
} catch (e: Exception) {
if (e !is SnapshotApplyConflictException) throw e
}
}
}
But for doing the same things from the main thread we don't need an explicit snapshot, the changes will be atomic anyway (because global snapshot advancing happens on the main thread).
In case of multi-threaded composition all of the above remains true (Composables won't see any partial updates), because Compose wraps compositions in snapshots.
Oldest comments (25)
Hey, you forgot to do snapshot.apply() before println("name after applying: " + dog.name.value) in 8th code example.
BTW great article - so far - I'm in the middle :-)
Oops, thanks! Fixed it.
This article just blew my mind, I had no idea all this stuff is happening under the hood! It's so well explained here, I've got so many ideas of where this snapshot API could be used in some of my open source work!
One question though, what Gradle dependency would I need to play around with this API? Or, do you have a sample repo with the Gradle setup and some of these examples?
It’s all in the compose runtime artifact. But you don’t need to enable the compose compiler to use the snapshot apis. That’s a good question, I’ll add this information to the post directly.
Done!
Good read :)
Amazing post, can't wait for a next part about deep dive into inner workings! It still looks magical, like calling static methods and everything is somehow hooked up under the hoods. Unless your examples were intentionally simplified? I'm wondering how the entry point to all of this looks like. Anyways thank you for taking time and writing this down!
The samples are a bit contrived, and as noted I omitted dispose calls for all but the first, but other than that this is all working code you can run. There’s no other entry point - this is the entry point!
One of the reasons it looks like magic is because it’s using thread locals to pass information implicitly down the call stack.
I found a typo: mutableSnapshotOf -> mutableStateOf, but more importantly: I don't understand what do you mean by "all writes are considered non-conflicting" in standard equality policies. I guess you've shown the write conflict in the example above. I also don't get how custom merge should report a failure. By returning null? But what if the mutable state type is nullable and we want to succeed with null result.
BTW if you're hesitating about next parts of this awesome series how about an example of custom simplified text "UI"? Sth like github.com/JakeWharton/mosaic but simplified to fit the blog post(s). It would be great to see how this snapshot system (and some custom applier I guess) should be used in such cases and why. Especially the "why". I find your explanations of intentions behind the compose parts especially useful.
Thanks, fixed the typo!
I am not sure of the answer to this unfortunately. Since the default return value is
null, and in that case conflicting writes do fail, the wording "all writes are non-conflicting" is definitely confusing. I'll dig into this a bit more and try to clarify.Equality policies docs have been clarified since then. Looks like "structural" & "referential" are non-conflicting when new value is equal to an old one.
neverEqualPolicyis always conflicting.Great Post! Enjoyed it. Thanks
Super useful and insightful. Looking forward to more on the inner workings of this mechanism!
The part about advancing global snapshots had me a little confused. Are global snapshots "automatically" advanced only when also paired with Compose UI (where composables + UI frames are involved)? i.e. manipulating mutable states in View toolkit world would require using something like withMutableSnapshot {} ?
Yes. If you're not using the rest of the Compose UI Android runtime, then nothing is advancing the global snapshot for you. Using
withMutableSnapshotis one approach, although it's easy to forget to do that and you'd need to use it even in event handlers which would probably feel pretty boilerplatey. It would probably be better to set up your own callback to advance the global snapshot on every frame, similar to how Compose UI Android works under the hood.true
Ahh yes perfect, this was the missing piece for me. Trying to experiment with using these APIs in Fragments/Android Views for some interop cases. Thank you!
What about non-JVM platforms?
JavaScript doesn’t have threads, and idk about Native but I’m guessing it uses some form of thread locals there as well.
So, if we change two pieces of
Stateone after another on the main thread, these changes are guaranteed to become visible to observers "atomically" (in the same recomposition), but to achieve the same result from background thread we need something likewithMutableSnapshot, right?@zachklipp Could you please clarify a bit on this point? If snapshot system has these atomicity guarantees, we can replace
StateFlow<SomeDataClass>.update { it.copy(...) }with several separateStates, greatly simplifying ViewModel code without sacrificing behavior correctness.BTW, will upcoming parallel (multi-threaded) composition change anything in this regard?
Generally, yes. It can often be a lot more natural and simpler to express multiple parts of state as separate properties vs having to wrap them all up into one immutable thing to shove inside a
MutableStateFlow.Note that there are some subtle differences since
updatehandles contention by simply re-running the function, whereas the snapshot system can sometimes resolve conflicts without re-running anything by merging conflicting values in a type-aware way. That said, in mobile apps contention is actually probably quite rare.Multi-threaded composition will probably have lots of interesting implications, but thread safety is thread safety – it shouldn't require any significant changes to how snapshots work.
Thanks for the detailed reply and for the warning about contention. It may be rare, but that means better chances of not catching it in tests and then having a rare crash with
SnapshotApplyConflictExceptionin production. ;)So, to recap:
SnapshotMutationPolicy).MutableStateFlow.update()does), for example:Composables won't see any partial updates), because Compose wraps compositions in snapshots.Correct?
A great article! I have another doubt that 'How compose kotlin compiler plugin sense/observe State changes?'.
Does Android Compose leverage 'Bytecode enhancement' such as cglib to intercept State.getValue?