From alpha to stable: One year with Jetpack Compose UI
This post was written to celebrate the launch of Jetpack Compose 1.0. Congrats to the Android team for this amazing milestone! 🎉
Intro
In May of 2020 our team made what would turn out to be a happy accident, hiring a React Native engineer to work on our fully native Android codebase. They would be responsible for helping build a feature that had become highly visible as an Amazon "S-Team goal", at a time where our team had gone from four engineers, to two. Whether by accident (the hiring manager was new and unfamiliar with our native Android codebase) or on purpose (to push us towards React Native), it was clear that we needed to figure something out quickly, or risk not delivering on time. This was a major issue for our team- we hire native Android engineers because we want to deliver UX at a bar that few Amazon apps have. Adding React Native was not an option.
Seeing that Compose 1.0 Alpha was a few weeks away, I hatched a plan:
Given that Compose is similarly modeled on the same declarative UI principles as React, we would investigate building the feature with Compose so our new engineer could ramp up more quickly. We decided to spend a few weeks prototyping the hardest parts of the feature in Compose to fully understand the risks involved with using it during its earliest alphas. We'd try to evaluate the risks as deeply as possible, especially around tooling, app size, crash rate, API stability, accessibility, and animations. These experiments went well and our team decided to move forward with Compose. In many ways our new engineer was the early expert, having done a great deal of declarative UI in React. They took to Compose like a duck to water.
We ended up shipping the feature to production in December 2020 on Compose 1.0.0-alpha07, without major issue.
I don't intend this post to walk through the risks or challenges we came across, but I will say this:
I believe with today's release of Compose 1.0, it is now an excellent option for building UI on Android, and mature enough that most teams should consider building on it. All of the risks we enumerated during alpha are no longer significant issues.
Instead, I'd like to look back on the past year and share some of the insights, lessons learned, and things I'm excited about with Compose.
A Note on Declarative UI
First, a quick note on declarative UI: What is the big idea?
I've found it helpful to think of declarative UI as a "pure" function (in the functional programming sense): a function with UI state as input, that returns a tree of UI components. The same input always outputs the same tree of UI components (that is, it's idempotent, with no side-effects).
Each time the state changes, the function executes again with the new state as input, and outputs an updated tree of UI components. This is a simplistic explanation, but captures the essence: a pure function with input state that creates a tree of UI components. Compose does a lot to make this process performant, such that it can create these trees and render updates with a minimal amount of work.
The above mental model brings a lot of advantages. Foremost is the ability to more easily reason about UI code. If you know the input, you know that the pure function will always return the same UI. At a small scale this may not be important, but as UI grows more complex it pays off quickly: UI becomes easier to reason about (for example, much of the state is in one place) and an entire class of bugs (around direct mutation of state in Android views) goes away.
For a much more in-depth introduction, please see the excellent Compose docs, especially Thinking in Compose.
Lessons learned, and things I'm excited about
It's ok to start small
Compose is a big change. A lot of what you know about building UI on Android will change, and it will be an adjustment. For those who are worried about throwing away years of knowledge in building Android UI, remember this: nothing can ever replace your desire to build beautiful UX, and Compose will help take that to the next level. The API surface area of Compose is small in comparison, and is much easier to master. That said, it's ok to start small.
For my team, we decided to continue following our existing MVVM architecture based on Activities with one Fragment and ViewModel per screen. This was useful to help limit the number of changes while we were learning. Long term, we'll likely migrate to Compose Navigation, which should allow us to remove Fragments altogether! Chris Banes' Tivi is a good example of an app that does this.
Similar to Kotlin's excellent Java interop, Compose itself has excellent interop with the existing Android view system, so it's possible to start small, with minimal risk.
Compose's API surface is much smaller and easier to learn
When I was starting Android in 2008, it took me at least 2 years to feel like I could build UI at a high level using XML in Android. There's just so much to learn that getting things right is really hard: different layouts and when to use them, custom views, selectors, ripples, data binding, ConstraintLayout, etc. It's a lot, and not straightforward.
For many of us, we've paid the price of learning so it feels easy. But when starting from first-principles, the Compose API has a vastly smaller API surface area. If you walk through the code path here you will be well on your way to being productive in Compose. Our React Native engineer was decently productive within a few weeks, despite learning a new language (Javascript -> Kotlin), tooling (VS Code -> Android Studio), and UI system (React -> Compose).
I followed a similar trajectory, picking up the basics in a couple weeks.
MVI is more interesting with Compose
I've found it straight-forward and easy to reason about having a single state object/data class per ViewModel, then subscribing to a Flow of updates from the top/screen-level @Composable. It very much matches the mental model of passing in state to a pure function that then outputs a tree of UI components. It's also less boilerplate than having many Flows in a ViewModel. It's amazing to see all the state in one place and know that it's the entirety of all state used to build the current screen's UI. It's super easy to reason about (sample). It's also super easy to test (sample).
I'm still wrapping my head around whether Compose can do fine-grained state tracking such that updating a single state object doesn't recompose the entire UI (as it would in data binding). If it doesn't, these extension functions from Mavericks 2.0 are a great example of how to manage that, while still having a single state object in the ViewModel. I'm not aware of a way to do this with data binding, apart from having multiple observable (Flow/LiveData/etc) state objects in each ViewModel.
Mavericks 2.0--considering the above--seems to be amazing for apps built on Compose. It's definitely on my short list to experiment with. In the meantime, I've been using a simple MVI-"ish" pattern which you can see in this small sample app:
https://github.com/petedoyle/Demo-ComposeMovieSearch
For more on MVI with Compose, check out Garima Jain's blog post: Jetpack Compose: Missing piece to the MVI puzzle?.
Building custom views is now really easy
Effectively, every @Composable function is a custom view and can be completely reusable with minimal effort. This makes it much easier to build custom component libraries, and makes it much easier to implement design systems in code. If you have a design team building from a design system they built in a tool like Figma, it's now much easier to implement these in code.
For teams that build this way, it's likely to improve both the fit and finish of their apps and reduce the amount of time it takes to implement designs (as well as making prototyping easier both during design and dev). Adam Bennett has an excellent blog series about implementing design systems in Compose here. I'm also hoping Alex Lockwood will write more about his experiences at Lyft.
The tooling is great, when it works
As a real app with 50+ Gradle modules, we've had so many issues with @Preview. It's likely something specific to our code base, but still frustrating. That said, when it works well it's incredible. One of the screens we built had ~15 states, all of which we can see in preview without deploying the app.
Being able to view these states in a single glance is incredible. Previously I would have had to start the app and reproduce each state to see how it looks. This can be really difficult for things like network errors. When @Preview becomes fast (and reliable), it will be a joy to use. For me, I'm excited to invest in improving our incremental build times to help improve the performance of @Preview (via increased modularization, Gradle build caching, configuration caching, moving to plugins that use KSP instead of annotation processing, etc.)
I was recently surprised to see that the Live Literals feature updates on device! From the time a value is changed to the time it updates on the device is less than half a second on my Pixel 4a with Android 11.
I have to imagine the Android team is looking for ways to make this work with more than just literals. What an incredible day that will be to write UI code and have it appear immediately.
Better reactivity than data binding
Data binding is a controversial feature. Some people hate it, but I've long been a fan- mostly because it facilitates reactive updates when data changes, without excessive boilerplate to manually update view state. That said, there are real and valid arguments against it: the ability to put too much logic in XML, a Kotlin-like-but-not-actually-Kotlin DSL, massive lists of compiler errors when one thing goes wrong, etc.
Compose gives us the reactivity of data binding, removes the downsides, and gives us the expressivity and IDE support of using real Kotlin code.
Unbundled from the Android OS
Compose is shipped as a library, as part of your app. This means the Android team can ship updates at a rate faster than the underlying OS (as they can with other AndroidX libraries). Since a specific version is baked into your .apk, it also means the Android team can fix bugs over time without breaking existing app installs. This was previously hard to do with things like LinearLayout. It also means there's less chance for a phone manufacturer to modify the classes your UI depends on, in a way that'd cause it to have device-specific UI bugs.
Potentially multi-platform (in the future)
Jetpack Compose is already in preview on Desktop and Web. As I read "between the lines" of certain tweets, it's easy to infer there's a hope inside Google for Compose to be multi-platform at some point. This will be an exciting area to watch.
Conclusion
I couldn't be more excited about Jetpack Compose hitting 1.0! The above is just a quick list of why. In the past year, it's helped us build an important, complex, high-visibility feature in a way that has been reliable in production, with code that is easy to reason about, and with a high degree of test coverage. It was also fun! For those who have been hesitant to try Compose, now is a great time! I'm fully convinced this is the future of UI on Android, and a massive step forward.
Congrats to the Android team for this amazing milestone! 🎉
Pete Doyle is an Android engineer at Amazon Care. This blog contains Pete's thoughts and does not represent his employer.
Follow Pete on Twitter (@petedoyle_) and LinkedIn (@petedoyle).