People’s expectations about UI development are not what they used to be. Today, in order to meet the needs of our users, we must build applications that have a good user interface, including animation and motion, which did not exist when UI toolkits were created. To address the technical challenge of creating sophisticated UIs quickly and efficiently, we introduced Jetpack Compose, a modern UI toolkit that helps developers succeed in new trends.
In two articles in this series, we’ll explain Compose’s strengths and explore how it works. To start this article, I’ll share the problems that Compose solves, the reasons behind some of the design decisions, and how those decisions can help developers. In addition, I’ll share a mental model for Compose, how you should think about writing code in Compose, and how to create your own API.
Compose solves the problem
Separation of Concerns (SOC) is a well-known software design principle that is one of the fundamental things we learn as developers. However, although it is widely known, in practice it is often difficult to determine whether it should be followed. Faced with such problems, it may be helpful to think about this principle in terms of “coupling” and “cohesion.”
When we write code, we create modules that contain multiple units.” “Coupling” is the dependency between units in different modules, which reflects how parts of one module affect parts of another. Cohesion, on the other hand, refers to the relationship between the elements of a module, indicating the reasonable degree to which the elements of the module are combined with each other.
When writing maintainable software, our goal is to minimize coupling and increase cohesion.
When we work with tightly coupled modules, code changes in one place mean many other changes in other modules. Worse, the coupling is often implicit, so that seemingly unrelated changes cause unexpected errors to occur.
Separation of concerns is about organizing related code together as much as possible so that we can easily maintain it and expand our code as the size of the application grows.
Let’s take a more realistic approach in the context of current Android development and use view Models and XML layouts as examples:
The viewmodel feeds the data to the layout. As it turns out, there are a lot of hidden dependencies: there is a lot of coupling between the viewmodel and the layout. A more familiar way for you to view this list is through some API, such as findViewByID. Using these apis requires some knowledge of the form and content of XML layouts.
Using these apis requires understanding how the XML layout is defined and coupled to the viewmodel. Since the scale of the application will grow over time, we must also ensure that these dependencies do not become obsolete.
Most modern applications display the UI dynamically and evolve as they are implemented. The result is that the application not only needs to verify that the layout XML statically satisfies these dependencies, but also needs to ensure that these dependencies are satisfied over the life of the application. If an element leaves the view hierarchy at run time, some dependencies can be broken, leading to issues such as NullReferenceExceptions.
Typically, the viewmodel is defined in a programming language like Kotlin, and the layout is XML. Because of the differences between the two languages, there is a mandatory separation line between them. Even in this case, however, the viewmodel and layout XML can be very closely related. In other words, they are tightly coupled.
This begs the question: What if we started defining layouts and UI structures in the same language? What if we use Kotlin to do this?
Because we can use the same language, some previously implicit dependencies may become more obvious. We can also refactor the code and move it to places where it is less coupled and more cohesive.
Now, you might think this is suggesting that you mix the logic with the UI. But the reality is that no matter how you structure it, your application will have logic associated with the UI. The framework itself does not change that.
However, the framework provides you with a few tools to make this separation of concerns easier: This tool is the Composable function, and the techniques you gain in doing such refactoring and in writing concise, reliable, and maintainable code can be applied to the Composable functions that have long been used to achieve separation of concerns elsewhere in your code.
Composable function profiling
Here is an example of a Composable function:
@Composable
fun App(appData: AppData) {
val derivedData = compute(appData)
Header()
if (appData.isOwner) {
EditButton()
}
Body {
for (item in derivedData.items) {
Item(item)
}
}
}
Copy the code
In the example, the function receives data as an argument from the AppData class. Ideally, this data is immutable and the Composable function does not change: the Composable function should be the transformation function for this data. This way, we can use any Kotlin code to get this data and use it to describe our hierarchy, such as the Header() and Body() calls.
This means that we make calls to the other Composable functions, and those calls represent the UI in our hierarchy. We can use language-level primitives in Kotlin to perform various operations dynamically. We can also implement control flow using if statements and for loops to handle more complex UI logic.
Composable functions typically take advantage of Kotlin’s trailing lambda syntax, so Body() is a Composable function that takes a Composable lambda argument. This relationship implies hierarchy or structure, so here Body() can contain a collection of elements composed of multiple elements.
Declarative UI
Declarative is a buzzword, but it’s an important one. When we talk about declarative programming, we’re talking about the opposite of imperative. Let’s look at an example:
Suppose you have an E-mail application with an icon for unread messages. If there is no message, the application draws an empty envelope; If we have some information, we draw some paper in the envelope; If there are 100 messages, we draw the icon as if it is on fire……
Using an imperative interface, we might write a function with the following number of updates:
fun updateCount(count: Int) { if (count > 0 && ! hasBadge()) { addBadge() } else if (count == 0 && hasBadge()) { removeBadge() } if (count > 99 && ! hasFire()) { addFire() setBadgeText("99+") } else if (count <= 99 && hasFire()) { removeFire() } if (count > 0 && ! hasPaper()) { addPaper() } else if (count == 0 && hasPaper()) { removePaper() } if (count <= 99) { setBadgeText("$count") } }Copy the code
In this code, we receive new quantities and must figure out how to update the current UI to reflect the corresponding state. Although this is a relatively simple example, there are many extreme cases, and the logic here is not simple.
Instead, writing the logic using a declarative interface would look like this:
@Composable
fun BadgedEnvelope(count: Int) {
Envelope(fire=count > 99, paper=count > 0) {
if (count > 0) {
Badge(text="$count")
}
}
}
Copy the code
Here we define:
- When the number is greater than 99, the flame is displayed.
- When the quantity is greater than 0, the paper is displayed;
- When the number is greater than 0, draw the number bubble.
This is what a declarative API means. We write code to describe the UI as we want it to be, not how to transition to the corresponding state. The key here is that when writing declarative code like this, you don’t need to care about what state your UI was in previously, but just specify what state it should be in currently. The framework controls how to move from one state to another, so we no longer need to think about it.
Composition vs Inheritance
In software development, Composition refers to how simple units of code fit together to form more complex units of code. One of the most common forms of composition in the object-oriented programming model is class-based inheritance. In the world of Jetpack Compose, the way we implement composition is quite different because we use functions instead of types, but it also has many advantages over inheritance. Let’s look at an example:
Suppose we have a view and we want to add an input. In the inheritance model, our code might look like this:
class Input : View() { /* ... */ } class ValidatedInput : Input() { /* ... */ } class DateInput : ValidatedInput() { /* ... */ } class DateRangeInput : ??? {/ *... * /}Copy the code
View is the base class, and ValidatedInput uses a subclass of Input. To verify the date, DateInput uses a subclass of ValidatedInput. But here’s the challenge: We’re going to create an input for a date range, which means verifying two dates — the start date and the end date. You can inherit from DateInput, but you can’t do it twice, and that’s the limit of inheritance: we can only inherit from one parent.
In Compose, the problem becomes simple. Suppose we start with a basic Input Composable function:
@Composable
fun <T> Input(value: T, onChange: (T) -> Unit) {
/* ... */
}
Copy the code
When we create ValidatedInput, we simply call Input in the body of the method. We can then decorate it to implement validation logic:
@Composable
fun ValidatedInput(value: T, onChange: (T) -> Unit, isValid: Boolean) {
InputDecoration(color=if(isValid) blue else red) {
Input(value, onChange)
}
}
Copy the code
Next, for DataInput, we can call ValidatedInput directly:
@Composable
fun DateInput(value: DateTime, onChange: (DateTime) -> Unit) {
ValidatedInput(
value,
onChange = { ... onChange(...) },
isValid = isValidDate(value)
)
}
Copy the code
Now, when we implement date range input, there are no more challenges here: only two calls are needed. The following is an example:
@Composable
fun DateRangeInput(value: DateRange, onChange: (DateRange) -> Unit) {
DateInput(value=value.start, ...)
DateInput(value=value.end, ...)
}
Copy the code
In the Compose composition model, we no longer have the limitation of a single parent class, which solves the problem we encountered in the inheritance model.
Another type of combination problem is the abstraction of decorative types. To illustrate this, consider the following inheritance example:
class FancyBox : View() { /* ... */ } class Story : View() { /* ... */ } class EditForm : FormView() { /* ... */ } class FancyStory : ??? {/ *... */ } class FancyEditForm : ??? {/ *... * /}Copy the code
FancyBox is a view that decorates other views, in this case stories and EditForms. We want to write FancyStory and FancyEditForm, but how? Do we inherit from FancyBox or Story? Because of the limitation of a single parent class in the inheritance chain, this becomes quite ambiguous.
Compose, by contrast, handles this very well:
@Composable Fun FancyBox(Children: @Composable () -> Unit) {Box(fancy) {children()}} @Composable Fun Story(...) {/ *... */ } @Composable fun EditForm(...) {/ *... */ } @Composable fun FancyStory(...) {FancyBox {Story (...). } } @Composable fun FancyEditForm(...) { FancyBox { EditForm(...) }}Copy the code
We made the Composable Lambda a child, allowing us to define functions that wrap around other functions. This way, when we want to create a FancyStory, we can call the Story in a child of FancyBox, and we can do the same with FancyEditForm. This is the composition model for Compose.
encapsulation
The other thing that Compose does well is “encapsulation.” This is something you need to consider when creating a public Composable function API: The public Composable API is just a set of parameters it receives, so Compose has no control over them. The Composable function, on the other hand, can manage and create state, and then pass that state and any data it receives as parameters to the other Composable functions.
Now, since it is managing the state, if you want to change the state, you can enable your child Composable function to tell you that the current change has been backed up through a callback.
restructuring
“Reassemble” means that any Composable function can be recalled at any time. If you have a large Composable hierarchy, you don’t want to recalculate the entire hierarchy when one part of your hierarchy changes. So the Composable function is restartable, and you can leverage this feature to achieve some powerful capabilities.
For example, here’s a Bind function that contains some common Android development code:
fun bind(liveMsgs: LiveData<MessageData>) {
liveMsgs.observe(this) { msgs ->
updateBody(msgs)
}
}
Copy the code
We have a LiveData and want the view to subscribe to it. To do this, we call the observe method and pass a LifecycleOwner, followed by a lambda. Lambda will be called every time a LiveData update happens, and when that happens, we’ll want to update the view.
With Compose, we can reverse this relationship.
@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
val msgs by liveMsgs.observeAsState()
for (msg in msgs) {
Message(msg)
}
}
Copy the code
There is a similar Composable function, Messages. It takes LiveData as a parameter and calls the observeAsState method for Compose. The observeAsState method maps LiveData to State, which means that you can use its value within the scope of the function body. The State instance is subscribed to the LiveData instance, which means that the State is updated wherever changes are made to LiveData. It also means that wherever the State instance is read, the Composable function that wraps it and has been read will automatically subscribe to those changes. As a result, there is no longer a need to specify LifecycleOwner or update callbacks; the Composable can implement both implicitly.
conclusion
Compose provides a modern way to define your UI, which allows you to effectively achieve separation of concerns. Because the Composable functions are similar to regular Kotlin functions, the tools you use to write and refactor your UI with Compose will be seamless with your Android development knowledge and the tools you use.