Understanding Jetpack Compose — part 1 of 2
The expectations around UI development have grown. Today, we can’t build an app and meet the user’s needs without having a polished user interface including animation and motion. These requirements are things that didn’t exist when the current UI Toolkit was created. To address the technical challenges of creating a polished UI quickly and efficiently we have introduced Jetpack Compose, a modern UI toolkit that sets app developers up for success in this new landscape.
Over two posts we’re going to explain the benefits of Compose and look at how it works under the hood. To start, in this post, I discuss the challenges Compose addresses, the reasons behind some of our design decisions, and how those help app developers. Also, I will discuss the mental model of Compose, how you should think about the code you write in Compose, and how you should shape your APIs.
What challenges does Compose address?
Separation of concerns is a well-known software design principle. It’s one of the fundamental things that we learn as app developers. Despite being well known, it is often difficult to grasp whether or not this principle is being followed in practice. It can be helpful to think of this principle in terms of “Coupling” and “Cohesion”.
When we write code, we create modules that consist of multiple units. Coupling is the dependency among units in different modules and reflects the ways in which parts of one module influence parts of other modules. Cohesion is instead the relationship between the units within a module, and indicates how well grouped the units in the module are.
When writing maintainable software, our goal is to minimize coupling and maximize cohesion.
When we have highly coupled modules, making a change to code in one place means having to make many other changes to other modules. Worse still, coupling can often be implicit so that unexpected things break because of a change that appears to be entirely unrelated.
Separation of concerns is about grouping as much related code together as possible so that our code can be easily maintained and scale as the app grows.
Let’s look at this more practically in the context of Android development today and take the example of a view model and an XML layout.
The view model provides data to the layout. It turns out there can be a lot of dependencies hidden here: a lot of coupling between the view model and the layout. One of the more familiar ways that you can see this manifest is through APIs that require some amount of knowledge of the shape and content of the XML layout itself, such as
Using these APIs requires knowledge of how the XML layout is defined and creates a coupling between the two. As our app grows over time we have to ensure that none of these dependencies become outdated.
Most modern apps display UI dynamically and evolve during their execution. As a result, one needs to not only verify that these dependencies are satisfied by the layout XML statically, but that they will be satisfied for the life of the program as well. If an element leaves the view hierarchy at runtime, some of these dependencies may be broken and can lead to issues like
Typically the view model is defined in a programming language such as Kotlin and the layout in XML. Because of this difference in language, there’s a forced line of separation, even though the view model and the layout XML can sometimes be intimately related. In other words, they’re very tightly coupled.
This begs the question: What if we started to define the layout, the structure of our UI, in the same language? What if we chose Kotlin?
Because we would then be working in the same language, some of the dependencies that were previously implicit might start to become more explicit. We can also refactor the code and move things over to where they will reduce coupling and increase the cohesion.
Now, you might think that this is suggesting that you mix logic with the UI. The reality is that you will have UI-related logic in your application no matter how it is structured. The framework itself cannot change this.
But what the framework can do is provide you with tools to make the separation easier: that tool is the Composable function. Functions are something that you’ve likely been using for a long time to separate concerns elsewhere in your code. The skills you’ve acquired to do that type of refactoring and writing reliable, maintainable, clean code — those same skills apply to Composable functions.
Anatomy of a Composable function
This is an example of a Composable function.
In this case it receives data as parameters from the appData class. Ideally this data is immutable data that the Composable function doesn’t change: the Composable function should be a transform function for that data. Therefore, we can use any Kotlin code to take that data and use it to describe our hierarchy, such as the
This means that we call other Composable functions and those invocations represent the UI in our hierarchy. We are able to use all of the language level primitives that Kotlin has to do things dynamically. We can include if statements and for loops for control flow to deal with the more complicated UI logic.
Composable functions often utilize Kotlin’s trailing lambda syntax, so
Body() is a composable function that has a composable lambda as a parameter. That implies a hierarchy or structure, so
Body() wraps the set of items here.
The declarative UI
Declarative is a buzzword, but an important one. When we talk about declarative programming, we’re talking about it in contrast to imperative programming. Let’s look at an example.
Consider an email app with an unread messages icon. If there are no messages, the app renders a blank envelope. If there are some messages, we render some paper in the envelope, and if there are 100 messages we render the icon as if it was on fire..
With an imperative interface, we might have to write an update count function like this:
In this code, we receive the new count and must figure out how we update the current UI to reflect that state. There are a lot of corner cases here and this logic isn’t easy, even though it’s a relatively simple example.
Alternatively, writing this logic in a declarative interface might result in something that looks like this.
Here we say:
- If the count is over 99, show fire.
- If the count is over 0, show paper,
- If the count is over 0, render a count badge.
This is what is meant by a declarative API. The code we write describes the UI we want, but not how to transition into that state. The critical thing here is that when writing declarative code like this, you no longer need to worry about what the previous state of your UI was in, you only need to specify what your current state should be. The framework controls how to get from one state to the other, so we no longer need to think about it.
Composition vs Inheritance
In software development, Composition is how multiple units of simpler code can come together to form a more complex unit of code. In an object-oriented programming model, one of the most common forms of composition is class-based inheritance. In the world of Jetpack Compose, since we are working with just functions instead of classes, the method of composition is quite different, but has many advantages over inheritance. Let’s look at an example.
Say we have a view and we want to add an input. In the inheritance model our code might look like this:
View is the base class.
ValidatedInput uses a subclass of Input. To validate a date, DateInput uses a subclass of
ValidatedInput. But then there is a challenge: we want to create a date range input, which means validating against two dates — the start and end dates. You could subclass
DateInput, but need to do it twice and you can’t do that. This is a limitation of inheritance: we have to have a single parent that we inherit from.
In Compose, this is less of a challenge. Let’s say we start out with a base Input composable:
When we create our
ValidatedInput, we just call
Input in the body of our function. We can then decorate it with something for validation.
Then for a DataInput we can call
Now, when we run into the date range input, we no longer have a challenge: it’s just two calls instead of one.
There is no single parent that we compose onto in Compose’s composition model, and that resolves this challenge that we had with the inheritance model.
Another type of composition problem is abstracting over a type of decoration. To illustrate, consider the following inheritance example:
FancyBox is a view that decorates other views, in this case
EditForm. We want to code a
FancyStory and a
FancyEditForm, but how? Do we inherit from
FancyBox or do we inherit from Story? It’s unclear because, again, we need one parent for the inheritance chain.
By contrast, Compose handles this really well.
We have a Composable lambda as children, enabling us to define something that wraps another thing. So now, when we want to create
FancyStory, we call
Story inside the children of
FancyBox, and can do the same with
FancyEditForm. This is Compose’s composition model.
Another thing that Compose accomplishes well is encapsulation. This is what you should be thinking about when you make public APIs of composable functions: the public API of a composable is the set of parameters that it receives, so it doesn’t have control over them. On the other hand, a composable can manage and create state, then pass that state along with any data that it received to other composables as parameters.
Now, because it’s managing that state, if you want to change the state, you can enable your child composables to signal that change back up using a callback.
This is our way of saying that any Composable function can be re-invoked at any time. If you have this very large Composable hierarchy, when part of your hierarchy changes, you don’t want to have to recompute the entire hierarchy. So Composable functions are restartable and you can use this to do some powerful things.
For example, here’s a Bind function, something you would see today in Android development.
We have a
LiveData that we want to subscribe the view to. To do that, we call the observe method with a lifecycle owner, then pass in a lambda. The lambda gets called every time
LiveData updates and when that happens, we want to update the views.
With Compose, we can invert this relationship.
There is a similar Messages Composable that receives
LiveData and a call to Compose’s
observeAsState method. The
observeAsState method will map the
LiveData<T> into a
State<T>. That means you can use the value in the surrounding body of the function. The State instance is subscribed to the
LiveData instance, meaning it will update whenever the
LiveData updates. This also means that wherever the
State instance is read, the surrounding composable function which it is read in will be automatically subscribed to these changes. The end result is that there is no longer any need to specify a
LifecycleOwner or an update callback, as the Composable can implicitly serve as both.
Compose provides a modern approach to defining your UI that enables you to separate concerns effectively. Because Composable functions are so similar to normal kotlin functions, the tools with which you write and refactor them will fit neatly into your kit of Android development skills.
In the next post, I’m going to shift the focus to some of the implementation details of Compose and its compiler. For additional resources on Compose, discover more here.