This year’s Android Dev Summit (ADS) got kicked off on October 24 with keynote and Modern Android Development track. They covered a lot of topics ranging from Jetpack Compose, App Architecture, App Performance, Android Studio Tooling support (for both Jetpack Compose & Crashlytics), Relay — a tool for Design handoff (I highly recommend this talk) and much more. In case you haven’t watched it yet I highly suggest you to go through them via this link.
NOTE: ADS is not yet over. This event is going to have more tracks like Form Factors (November 9 in London) & Platform (November 14) and later in December in Asia as well. So Stay Tuned. 🙂
Even though all the things covered very important and insightful, the topic that got my attention was Compose Modifiers deep dive by Leland Richardson where he covers the history of how Modifiers came into existence (along with some of the things to consider when designing/building a new UI system), the problems associated with their current state and how they’re evolving (thankfully without any changes on the API surface unless you’re using Modifier.composed{ }). Since this was a complex topic I decided to take notes on the same in case I need to refer something in future then I don’t have to scrimmage through the YouTube video (still I highly recommend people to watch the talk atleast once). So the remaining post is my notes on the same and some of the commentary from my end to understand the things covered in the talk.
How Modifiers Came into Existence
- One of the approach to provide paddings (all directions), background color, gradient, border and other properties to each Compose UI Node can be via class variables but that can quickly get out of hand because of the sheer number of properties that an UI component can support.
Hypothetical implementation of Compose UI Node
- Then another approach can be to make each of these properties i.e. padding, background color, clickable as standalone Composable but this approach requires each of this composable to have a composable lambda parameter which can accept children, which leads to a problem that
Clickable/Padding needs to know about some sort of Layout Policy
(which will be utilizied during the Layout phase to place the children composables)
Hypothetical implementation of a Composable
- Then came the Modifiers which we are using these days which can be chained/cascaded to provide multiple properties to Composables e.g. padding, background color, border, shape, click listeners or even gestures.
Using Modifiers to provide padding, background color & click listener to a Row composable
- Using Modifiers is neat but it could cause a problem if a modifier with some state (e.g. Clickable which has ripple effect animation) is stored as a variable and then passed to two or more different Composables, as each composable needs to have their own animation state. These issue was tackled by using a
Modifier.composed { }
&materialize()
API (which is one of the first thing that gets called in Layout() composable) so that even if same modifier is passed to multiple Composables each of them will get their own state.
Modifier.composed {} & materialize() API usage
Job Offers
Modifier.composed { }
API along withmaterialize()
solved the issue of having separate states even if same Modifier is used for multiple Composables but this ended up causing a performance issue because of following reasons.
– Because Modifier.composed { } has a return type i.e. Modifier and since Composables with return type can’t be skipped during Recomposition.
– Also since Modifier.composed() is not a composable we can’t even use the all mighty Compose compiler to cache the lambda used earlier to compose a new Modifier which results in the new Modifier composed to be not equal to the one generated earlier even if there was no change.
– This also causes many remember calls internal to the composed Modifiers to be called unnecessary.
Here we can’t skip composition because clickable function has a return type i.e. Modifier
since composed is not a Composable function we can’t memorize the lambda using remember API
To enable unique state per composable the returned Modifier from composed lambda is never same
As composed API is used to store some kind of state by Modifiers, remember calls required to cache the state
Even though it seemed like a big problem, the assumption was that Modifier.composed {} API will be required only in very few special cases, but it ended up being wrong. 😅
Modifiers in Practice & the problems associated with it
- The Text composable with just a
Modifier.clickable{}
seems very simple butModifier.clickable{}
internally cascades to more modifiers likesemantics(), onKeyEvent(), indication(), pointerInput() and so on
plus the Text Composable which might look very innocent, usesTextController
internally which also creates it’s own Modifiers (checkTextController.modifiers for better understanding) which are responsible for rendering the text, semantics & selections (i.e. which facilitates the Cut, Copy functions that we get on selecting a text). So for a simple Text Composable with just clickable modifiers leads to having some 20+ cascaded Modifiers 😲. Check below screenshots for reference:
- If we aggregate the the contents of
Modifier.clickable{}
then we can see the result having (belowCompose 1.3.0-beta01
at least):
– 13 Modifier.composed calls
– 34 remember calls
– 11 Side Effects
– 16 Leaf Modifier.Elements
What above point essentially means is that when we apply
clickable {}
modifier to a Composable– On composition it’ll end up calling
materialize()
for all of the13
Modifier.composed calls then all the remember calls held inside also gets called– Which then allocates memory for Entity objects (i.e. objects that manage behaviours between different subsystems and dispatch things to these Modifiers 😵💫😵💫…🤷) for each modifier
– On Recomposition we end up allocating new Modifiers which calls
materialize()
for all composed Modifiers then the old Entity objects getsdisposed
and new ones gets allocated
- The key takeaway here is that
many of the Modifiers needs to have their own state
and that state is applied per layout node. And these wasonly possible by Modifier.composed{}
API beforeCompose 1.3.0-beta01
release where experimental support forModifier.Node
got implemented
Evolution of Modifiers staring Compose 1.3.0-beta01
- In
Compose 1.3.0-beta01
experimental support Modifier.Node got implemented as an alternative to Modifier.composed{} which is more performant (NOTE: This migration will happen over multiple releases and is still WIP) - With the introduction of Modifier.Node all the modifiers like padding, background,
even clickable
will be treated as anew type of lightweight immutable Modifier.Element
which knows how to maintain an instance of a correspondingModifier.Node
class.
- These Modifier.Element classes also gets a
create()
function which then creates/allocatesModifier.Node
instances. These Node instances aredirectly part of the UI hierarchy
and they have thesame lifecycle as the layouts
they are applied to, which makes them ideal owners of the state for the modifiers that need it like clickable.
Modifier.Element calling create() to instantiate Modifier.Node instances
- Now talking about the earlier scenario when the recomposition happens,
we don’t need to create new modifiers for the whole modifier chain
because these Modifier.Element are immutable and can be compared easily. So we end up creating and applying Modifier.Element & Modifier.Node only for the changed modifiers in the whole chain. So in current case since on only the padding Modifier value changed from 10.dp -> 16.dp, only padding Modifier.Element calls update() to update PaddingNode and the remaining Modifier.Element don’t do any unnecessary computation.
On recomposition Modifier chain gets evaluated again
On recomposition only padding Modifier.Element calls update() to update the PaddingNode
Benefits of the new Modifier.Element & Modifier.Node
Takeaways of the new Modifier.Element & Modifier.Node
Alas, we’ve reached the end! 🙌 By this point you might be thinking that this seems a bit more complex and might not be needed to know as well since it’s an internal implementation (as mentioned earlier unless you’re using Modifier.composed {} API directly just updating to Compose 1.3.0-beta01 version should be sufficient to get the performance benefits of the internal changes) but it’s always better to get an idea about how things are working internally and the challenges faced in designing them. 🙂
Hope you liked reading the article. Until next time. Thank you!
This article was originally published on proandroiddev.com on November 04, 2022