Illustrated with Feature Examples
I recently wrote (and updated) a blog post about our product feature that was completely rewritten with Jetpack Compose.
After the grinding but also exciting development phase, we have come to a good checkpoint to reflect on what we have been doing and why. I can summarize into 3 reasons why we adopted Compose and think it is the future for Android UI development. I also would like to incorporate the theory into our feature work and compare the resulting code of Compose vs. the old View system.
Many Android engineers started the journey of learning Compose from the article Thinking in Compose from the official developer site. Indeed, the introduction captured 2 essential reasons why Google’s Android team designed the new UI toolkit from the platform perspective. In addition, as an app developer, I also want to call out the 3rd reason why I really enjoy using Compose, from the API users’ perspective.
Let us start with the excerpt from Thinking in Compose. I will offer my interpretation and illustrate with code comparison.
Historically, an Android view hierarchy has been representable as a tree of UI widgets. As the state of the app changes because of things like user interactions, the UI hierarchy needs to be updated to display the current data. The most common way of updating the UI is to walk the tree using functions like
findViewById(), and change nodes by calling methods like
img.setImageBitmap(Bitmap). These methods change the internal state of the widget.
Over the last several years, the entire industry has started shifting to a declarative UI model, which greatly simplifies the engineering associated with building and updating user interfaces. The technique works by conceptually regenerating the entire screen from scratch, then applying only the necessary changes. This approach avoids the complexity of manually updating a stateful view hierarchy. Compose is a declarative UI framework.
Declarative is a buzzword and it may mean different things in different context. But it would make more sense when we compare declarative with imperative programming styles in examples. Then we can see their difference relatively.
In old Android View system, we write UI via inflating view widgets then mutate their internal states by calling getters & setters. Let us called that imperative paradigm because app developer needs to manually control internal states of view widgets.
However, in Jetpack Compose, app developers don’t have direct access to widget objects any more. The underlying UI hierarchy is hidden behind the Compose declarative API. The reason they are called declarative is because the way we call those function APIs reads like describing what we want UI looks and behaves like.
So imperative coding is more about how and declarative is more about what. Because the Compose library exposes only DSL API and encapsulate a lot of underlying heavy-lifting work.
Let us use a feature example to compare the results: we want to build a list of rooms vertically, as shown below:
In View system, we would typically define recycler_view.xml, row_item.xml, RecyclerViewAdapter and binding & config adapter.
There are actually multiple reasons that View system would require more coding in general: The forced separation of xml and logic code(will mention in reason #3) would require manual view inflation and view binding(recycler view & row item view) in logic code. Also, app developers have to manually manage data binding and configure Layout. As result, everything adds up.
With Compose, writing code in declarative API would result in:
We describe UI like:
- Make a Column with items (as data) and lazyListState (as widget state)
- Compose each item in RoomItem
The amount of code speaks for itself for the efficiency.
In View system, application should hold app state in each screen, view widgets also hold their internal states. But in Jetpack Compose, app developers won’t have references to the view objects and won’t manually mutate internal states of them. Instead, we can only build composable functions like this:
fun FunctionName(inputState: T) …
Note that we annotate the function with @Composable and the function has no return type. By doing that, we are telling Compose-compiler that this function is to convert the input state into a node that is registered in the composition tree. Composition tree is the in-memory representation of UI views that Compose-runtime manages. Composable functions would emit scheduled changes to UI tree nodes. The mental model can be diagrammed something like this:
The underlying magic was done by Compose-compiler which would add an implicit parameter, Composer, to the composable function to perform lot of underlying work such as tracking, caching and optimization. It is in similar fashion as adding the implicit parameter, Continuation, to suspend functions in coroutines.
The nature of the architecture eliminated the need for app developers to manage the internal states of the view widgets. Instead, only the state input dictates how UI is rendered. The new architecture yields the following benefits:
- By eliminating managing internal state of view widgets, it truly delivered the unidirectional data flow: InputState => UI
- App developers only need to describe what the current state should be and no longer need to worry about the previous state that UI was in and how to transition from one state to another. The library would take care of that.
- This allows the vast majority of the performance optimization happen in Compose library level, therefore, it alleviates such daunting task from app developers.
Let us use another example to illustrate how the new architecture plays out in real world. The feature is that assuming we already had a Row of rooms in the Revealed state of scaffold and Column of rooms in the Concealed state, we would like the same scrolled position to be transferred from Row to Column and vice versa. As shown below. IOW, the scrolled position is synchronized between Row & Column.
During the transition, we’d like both Row & Column to render on screen with different gradually increased / decreased transparency:
With View system, a typical code skeleton would looks like this:
- Inflate horizontalRecyclerView
- Inflate verticalRecyclerView
- During transition, manually fetch scroll-position & pass from one orientation of list to the other
Because of the design of View system, not only we need to inflate and hold on to the recycler views, but also have to manually manage the internal states of the recycler view – the scrolled positions.
In Compose, we would code something like this:
Besides the code brevity(mentioned in reason #1), The state-drive architecture played an important role in the difference of the resulting code. The input state for composable function has 2 things:
We passed them into
LazyColumn . Notice that we even pass the exact same instance of
LazyListState into both
LazyColumn . We are effectively telling Compose-runtime just to render Row & Column based on the same scrolling state. That is how simply we can achieve the synchronization of scroll-positions. Also no need to worry about the previous scroll position and transition, because Compose renders UI based on the current input state for each frame. Thanks to Compose’s state-driven architecture and unidirectional data flow, features like this can be easily achieved.
Thinking in Compose may not explicitly point it out. But personally, this was actually the main reason that drew me into learning about Compose in the first place.
Thanks to the design of Android View system, XML-based UI development becomes a separate knowledge base from the core software development. We needed to learn how to use XML to express layout, attribute, style, theme, animations, etc.
But with Compose, we finally unified our skill set and write UI with the same core expertise that Android developer already possessed: Kotlin language features, functional programming, coroutines, software engineering principles of writing readable and reusable code. Composable functions are similar to normal Kotlin functions. We can express conditions and loops with the same coding struts we are used to. The more we know about Compose API and its internal implementation, the more we find it familiar with our core knowledge base:
- DSL captures many aspects of functional programming like extension, high-order functions, lambda with receivers, operator and infix(ex. provides) functions
- Kotlin features such as immutability, trailing lambda argument, named function parameter and default values, delegate(ex. by), destructuring(ex. mutableStateOf), inline classes(ex. Color), singleton(ex. Theme), factory(ex. LazyListState)
- Async programming with Kotlin coroutines for UI animations
- Patterns like reactive programming like observable State and Flow
I have formed the 3 reasons why we(also We 😅)adopted Jetpack Compose. Having said that, there have been inconveniences that we experienced through the journey:
- Incompatibility & bugs in Compose & the associated tech stack(Kotlin, Gradle, Android Studio & other libraries)
- Missing features to completely match View system
But those are what I called “growing pain”, inevitable snags and hurdles on the bright path.
There should be plenty of resource to understand Kotlin & DSL already. But regarding the Compose architecture and internals (compiler/runtime/UI), there seemed to be limited insightful information available online. Also, the caveat is that some of them may contain guess work from the authors. Nevertheless, I attached a few resources here as they have been useful for me to understand Compose better. Hope they are helpful.