DEV Community

Zach Klippenstein
Zach Klippenstein

Posted on • Edited on • Originally published at blog.zachklipp.com

Introduction to the Compose Snapshot system

This post has moved to blog.zachklipp.com.

Top comments (25)

The discussion has been locked. New comments can't be added.
Post has moved to https://blog.zachklipp.com/introduction-to-the-compose-snapshot-system/
Collapse
 
langara profile image
Marek Langiewicz

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.

Collapse
 
zachklipp profile image
Zach Klippenstein

Thanks, fixed the typo!

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.

Collapse
 
gmk57 profile image
gmk57

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.

Collapse
 
gmk57 profile image
gmk57 • Edited

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?

Collapse
 
gmk57 profile image
gmk57

@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?

Collapse
 
zachklipp profile image
Zach Klippenstein

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.

 
gmk57 profile image
gmk57

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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.

Correct?

Collapse
 
drinkthestars profile image
Tasha Ramesh

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 {} ?

Collapse
 
zachklipp profile image
Zach Klippenstein

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.

Collapse
 
drinkthestars profile image
Tasha Ramesh

although it's easy to forget to do

true

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!

Collapse
 
cjbrooks12 profile image
Casey Brooks

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?

Collapse
 
zachklipp profile image
Zach Klippenstein

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.

Collapse
 
zachklipp profile image
Zach Klippenstein

Done!

Collapse
 
mzgreen profile image
Michał Z.

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!

Collapse
 
zachklipp profile image
Zach Klippenstein

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.

Collapse
 
gumiku profile image
Gumiku

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:

LaunchEffect(Unit){
    modify state 1
    modify state 2
    cancel()
    modify state 3
}
Enter fullscreen mode Exit fullscreen mode

shouldn't we roll back state1 and state2 if this effect cancel before it modify state3?

Collapse
 
mgroth0 profile image
Matt Groth • Edited

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:

  fun onChanged(scope: Int) {
    println("something was changed from pass $scope")
    println("performing next read pass")
    observer.observeReads(
      scope = scope + 1,
      onValueChangedForScope = ::onChanged,
      block = ::blockToObserve
    )
  }
Enter fullscreen mode Exit fullscreen mode

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:

  fun onChanged(scope: Int) {
    println("something was changed from pass $scope")
    println("performing next read pass")
}
Enter fullscreen mode Exit fullscreen mode

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?

Collapse
 
mgroth0 profile image
Matt Groth • Edited

Also, what is the advantage of SnapshotStateListener, which seems relatively complex, over snapshotFlow which seems more robust and elegant in so many ways?

Collapse
 
climber418 profile image
climber418

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?

Collapse
 
adibfara profile image
Adib Faramarzi

Good read :)

Collapse
 
ragdroid profile image
Garima Jain

Great Post! Enjoyed it. Thanks