One Off the Slack: Are State Changes Transactional?
Suppose, in back-to-back statements, you update a pair of MutableState
variables.
Marco Romano wanted to know if those changes would happen simultaneously with respect
to composables that need to be recomposed:
Is it possible to mutate two (or more) MutableState variables in a single “transaction” ?
Or is this just nonsense because all composable callbacks happen inside a single “message” on the UI thread hence that act as “transaction”?
Google’s Zach Klippenstein pointed out that you can opt into this behavior
using Snapshot.withMutableSnapshot()
, and Zach pointed out
his May 29 blog post
on that subject.
Rick Regan was uncertain if that would be needed:
The explicit snapshot API aside, isn’t what Marco said true (a callback is on the UI thread and hence an implicit transaction on MutableState variables)?
My assumption has been, and I thought prior conversations on this channel have confirmed it, that a single callback can mutate multiple mutable state variables atomically. In other words, I assumed that the UI is not updated (recomposed) until the callback finishes.
Rick later pointed to another Slack thread where Google’s Adam Powell wrote:
if you’re running in a callback on the main thread this can’t happen until your callback returns, so yes, changes made in that callback will be atomic with regard to that snapshot commit
Marco concurred:
That’s exactly what I meant, it’s just that Adam is way better than me at saying it :)
My assumption was that whenever the framework is giving us a chance to run code on the UI thread it will always be inside a “message” (queued on the UI thread’s Looper). So the framework (compose in this case) will have to wait until our message has finished running before resuming its own work.
Of course this assumption works only if mutation of the state is done from the UI thread.
Casey Brooks warned that Compose is trending towards multi-thread behavior:
Just like coroutines, there’s really nothing magical about Compose (beyond its magical compiler). Anything that is running at runtime is bound by the same rules as any other normal function:. On a single thread, functions are not interrupted, so if you do some long, expensive operation in a callback, it will ultimately block the thread until the callback function returns. In Compose, this would mean taking longer than 1 frame to recompose/apply a Snapshot, and you’ll drop frames as a result. The things that would interrupt a thread are the “magic” of Compose and/or Coroutines: calling other such functions puts an interruption point in there, but the normal code between suspend/composable function calls is just normal code.
The Compose documentation also has this bit which kinda confirms that, but also warns that compose functions run in parallel (although I don’t think that is actually fully enabled just yet), and so you can’t always be sure of what exactly is the “main thread” https://developer.android.com/jetpack/compose/mental-model#parallel
Adam later chimed in directly:
We may end up needing to take the recomposition snapshot on the Recomposer’s applier thread (i.e. the main thread) to keep some consistency expectations like the ones you’re making here even if the actual recomposition is happening in parallel. Implementation details aside though, it’s our goal to not make you have to think about this too hard if your event handling code is happening on the main thread. That is, after all, why the global transaction mechanism exists.
If you’re off the main thread and you’re not implicitly in a snapshot because something else took one on your behalf, we assume you know what you’re doing and can use withMutableSnapshot and such to maintain the guarantees you need. 🙂
TL;DR: if you are not being too fancy, multiple consecutive state mutations should appear to your composables as happening at the same time (a.k.a., be transactional).
Read the original thread in the kotlinlang Slack workspace. Not a member? Join that Slack workspace here!