Jetpack Compose: Missing piece to the MVI puzzle?
When I first started exploring Jetpack compose, I saw many examples of the screen state modeled as a combination of mutable properties :
Having worked with the Model View Intent (MVI) pattern for a few years and being a strong believer in the MVI pattern, the burning question in my head after looking at mutable state object was :
“Is MVI even possible with Jetpack Compose?”
Then, I stumbled upon a few code samples, articles, and even talks on MVI with Compose. Instead of exposing multiple mutable states, we can expose a single state object and consume it using
mutableStateOf within our composable functions :
That looks great! But, then I started wondering :
“So, MVI does work with Jetpack Compose, but does it work well?”
Following the principles of MVI, it means that our screen state should be immutable, which means, each time we mutate any property of the state object a new object is created and assigned to our state property. Then, a lot more questions popped up in my head :
- What about recomposition in case only a partial view state is mutated?
- Does immutable state work as good as separate mutable properties with Jetpack Compose?
- Is Compose even built for MVI?
If like me, you are also looking for the answers to the above questions or just trying to understand if MVI fits well with Jetpack compose or not. I hope this article can help you get your answers.
The short answer is Yes, MVI works well, maybe even better than I expected it to work. But, keep reading for the longer answer.
Recompositions with MVI
Building on top of our
MovieDetailState example, this is our simple
I am not going to go into details about how MVI is implemented on this screen, but if you’d like to overcomplicate things like me, feel free to check out my GitHub sample project here: Clayground
Use Case: Upvoting a Movie
Whenever a user intends to perform an action, for example, upvote a movie, we fire an intent :
MovieDetailEvent, this event causes the state of view to change:
voteCount ~> voteCount + 1. For this example, we just increase the vote count to 1, but depending on the use case, we might need to keep this value in sync with either database or server (not required for this article).
Note: We are following naming conventions from mobius:
Update…). Events can fire off side-effects, only events can update our state model. Side-effects can fire events.
As mentioned earlier, following the principles of MVI, the state object should be immutable. So, as shown above, in order to increase
voteCount, we create a copy of the state object and increment
voteCount to 1.
This new state is exposed as a
Flow from our ViewModel and can be collected within our
collectAsState() method returns a
State<MovieDetailState> object or rather a
State object takes care of subscribing to state updates (reads and writes) for this
@Composable for us. By default, mutation policy is specified as
structuralEqualityPolicy(), i.e. setting state’s value to structurally equal value (==) is not considered a change. Any other change in state object can cause the current Composable to recompose. This is because
SnapshotMutableState adds read and write observers to the state and whenever there’s a change, Compose can then call the corresponding
RecomposeScope to recompose the current Composable.
Not just this,
SnapshotMutableState object also utilizes the
Snapshot system for handling write transactions and conflicts. You can read about it in more detail in this blog post about Snapshot System by Zach Klippenstein.
So, coming back to our
Upvote action, which caused our
stateFlow to emit a new state object, which as a result causes our
@Composable MovieDetail() to re-compose. Let us look at our
@Composable MovieDetail() :
MovieDetail composable calls some other composable functions like
MovieVotes(), etc. to decorate various movie properties. The question is, does recomposition of
MovieDetail composable due to a change in its state cause all its child composables to recompose?
The answer to this is NO!. Compose handles this intelligently internally using a slot table built using Gap Buffers. Compose stores the parameters to a Composable function in the slot table, these parameters can then be compared for equality with previous composition values based on their position in the widget tree, and thus Compose can skip recomposition for a Composable if all of its input parameters are stable and haven’t changed.
Compose exposes two annotations, i.e.
@Immutable to mark input parameters and function types as stable. This aids the compose runtime in smart recomposition, i.e., skipping the recomposition of composable functions whose input types are stable and their values didn’t change. More information here.
Compose compiler can infer the stability for certain types like primitive immutable types, function types and treats them as stable by default. For other types like interfaces, etc. for which stability can not be inferred by the compiler, we can explicitly mark them as stable if we can promise their stability, via
a.equals().bshould always return the same value
- change in public properties should notify compose for changes
- public properties should also be stable
@Immutable i.e. once instantiated, their properties will never change
Caution: “With great power comes great responsibility”, so use
@Immutable with care. Compose API guidelines explain this well.
The stability of input parameters is important for us because if the compose runtime can not infer stability of the inputs, it will always consider the inputs to be unstable and recompose such composable functions. While exploring, I came across a pitfall with sealed classes.
Sealed classes as input parameter types to composable functions
Usually, compose compiler plugin can infer the stability of sealed classes if they are defined in a compose enabled gradle module. But, if your state contains a sealed class that is defined in a non-compose module, then its stability can not be inferred. This is something to keep an eye out for. I filed a bug for this here: https://issuetracker.google.com/issues/191068806
So, following smart recomposition, in our example, the only composable to recompose should be
This is because the only input that changed was
movieDetail.voteCount. Other Composables can be reused and you can verify this by logging the number of recompositions.
If we follow some simple guidelines while using Compose, then, compose will only help us by recomposing only the required parts of our widget tree. These guidelines are:
- state hoisting: pattern of passing state from outside of composable to make the composable function stateless,
- try to keep composable functions pure and side-effect free by passing lambdas instead of mutable state properties,
keycomposables or use the built-in support for
keys for composable functions like
LazyColumn, etc. to help compose runtime in smart recompositions.
- help compose compiler plugin by marking your models as
@Immutablewherever stability can be promised.
- only pass in stable and the least amount of data required, to a composable
- follow the uni-directional flow (UDF) pattern
Jetpack Compose: Missing piece to the MVI puzzle?
Now that we have answered our question, “Does MVI work well with Compose?” Yes, it works quite well, let us move on to the next part. This answer sort of leads us in the direction of the possibility that Compose might actually be the missing piece to our MVI puzzle.
To understand this, let’s talk about where MVI comes from. MVI or Model View Intent pattern in Android is highly inspired by web frameworks like Cycle.js and redux. In the web world, with virtual DOMs, frameworks handle diffing and change only the required parts of the DOM instead of re-rendering the whole DOM.
For MVI, it would be great if Android had something similar, i.e. automatically calculating diffs of what changed and re-render only the part that changed. I have often heard this question, i.e. “How do you efficiently perform partial state updates with MVI from our
render() function”. Until now, we have done proper diffing only with
DiffUtil. Otherwise, it was always left up to the developer to properly handle partial state updates by using our own diffing implementations to efficiently render the state object. MVI did solve the state management issue for us, but the view side was always a misfit piece. As pointed out by Ragunath Jawahar, people have tried to solve this problem by creating libraries around it like diffuser from Spotify to some extent. Ideally, wouldn’t it be great if the framework itself could handle invalidations of only the part that changed within our state model and thus cause only the view sub-tree to invalidate for us.
Here comes Jetpack Compose!
It can handle partial state updates for us by recomposing only a part of the widget tree and thus it seems like the missing piece to our MVI puzzle. I am not sure how exactly it performs at this moment in comparison to the Android View System, i.e. benchmarks, etc. We all know that it is currently under development and is expected to become stable sometime this year. But, so far the future of MVI in Android looks promising with Jetpack Compose.
Do let me know in the comments if you feel something can be conveyed in a better way or if it doesn’t make sense. Thanks for reading.
You can find the GitHub sample project here: Clayground