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.
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.
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!
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.
Amazing post! This not only drastically elevated my understanding of Compose, but also got the gears spinning in my head about other ways that the Snapshot system can be used... brilliant.
Also, I have been stuck on one part of this in particular and @zachklipp it would be really great if you have a chance to help with this.
In the part where you are talking about SnapshotStateObserver you say the following:
Call observeReads() one or more times, passing it a function to observe reads from as well as a callback to execute when any of the read values is changed. Every time the callback is invoked, the set of states being observed is cleared and observeReads() must be called again in order to continue tracking changes.
And you have the following code:
funonChanged(scope:Int){println("something was changed from pass $scope")println("performing next read pass")observer.observeReads(scope=scope+1,onValueChangedForScope=::onChanged,block=::blockToObserve)}
However, I have been staring at the source code for the SnapshotStateObserver for hours, and can not understand why this should be done. As far as I can see, the SnapshotStateObserver will keep observing the same values as long as you don't touch it. So, if you changed it to just:
funonChanged(scope:Int){println("something was changed from pass $scope")println("performing next read pass")}
This should be fine. I will note though, that I guess this won't account for new values that need to be read, like if there is a mutating list of MutableState objects.
I have tested this, and confirmed my understanding that calling observeReads is not necessary. Even without that, the listener continued to work.
I think I see where you might have made the mistake when you said:
the set of states being observed is cleared and observeReads() must be called again in order to continue tracking changes.
That is, I am guessing maybe you interpreted this from reading ObservedScopeMap.notifyInvalidatedScopes which at the bottom calls invalidated.clear() every time. Did you perhaps misread this as meaning that the states that were invalidated are "cleared" as in "listeners removed"? I assumed that was the case too based on what you wrote here, but after digging deeper it seems like invalidated.clear() just marks the states that were previously marked as invalid, as valid again. I don't think it removes any listeners.
I see a lot of "clear" methods in the class, but most of them are not called from other methods in the class, so it seems like clearing registered states is not an automatic process as you suggest?
I also realize Compose is still in development, and maybe it was different at the time that you wrote this?
Also, what is the advantage of SnapshotStateListener, which seems relatively complex, over snapshotFlow which seems more robust and elegant in so many ways?
Top comments (25)
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.
neverEqualPolicy
is always conflicting.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 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
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:
SnapshotMutationPolicy
).MutableStateFlow.update()
does), for example:Composable
s won't see any partial updates), because Compose wraps compositions in snapshots.Correct?
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.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!
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!
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.
why compose don't take a snapshot when call
Side Effects
api and not apply it if the effect not run successfully, consider I have LaunchEffect:shouldn't we roll back state1 and state2 if this effect cancel before it modify state3?
Amazing post! This not only drastically elevated my understanding of Compose, but also got the gears spinning in my head about other ways that the Snapshot system can be used... brilliant.
Also, I have been stuck on one part of this in particular and @zachklipp it would be really great if you have a chance to help with this.
In the part where you are talking about
SnapshotStateObserver
you say the following:And you have the following code:
However, I have been staring at the source code for the
SnapshotStateObserver
for hours, and can not understand why this should be done. As far as I can see, theSnapshotStateObserver
will keep observing the same values as long as you don't touch it. So, if you changed it to just:This should be fine. I will note though, that I guess this won't account for new values that need to be read, like if there is a mutating list of
MutableState
objects.I have tested this, and confirmed my understanding that calling
observeReads
is not necessary. Even without that, the listener continued to work.I think I see where you might have made the mistake when you said:
That is, I am guessing maybe you interpreted this from reading
ObservedScopeMap.notifyInvalidatedScopes
which at the bottom callsinvalidated.clear()
every time. Did you perhaps misread this as meaning that the states that were invalidated are "cleared" as in "listeners removed"? I assumed that was the case too based on what you wrote here, but after digging deeper it seems likeinvalidated.clear()
just marks the states that were previously marked as invalid, as valid again. I don't think it removes any listeners.I see a lot of "clear" methods in the class, but most of them are not called from other methods in the class, so it seems like clearing registered states is not an automatic process as you suggest?
I also realize Compose is still in development, and maybe it was different at the time that you wrote this?
Also, what is the advantage of SnapshotStateListener, which seems relatively complex, over
snapshotFlow
which seems more robust and elegant in so many ways?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?
Good read :)
Great Post! Enjoyed it. Thanks