Skip to content
Application Development II
GitLabGitHub

Introduction to Jetpack Compose

Explaination of the fundamentals of the user interface framework we will use in this course.
Overview

These notes have been adapted from Thinking in Compose.

Jetpack Compose is the user interface framework we will use in this class to develop applications in Android.

In class, we will focus on learning by doing, going through the Android Jetpack Compose Codelabs and seeing how Compose works in action.

This set of lectures notes should provide theoretical background information on how Compose works, providing engineering motivation for why Compose is written the way it is. These lecture notes should provide understanding for how Compose works under the hood, whenever you feel you would like to understand more about what we are doing.

You will often see Jetpack Compose described as “modern” and “declarative” — after a few lectures about Kotlin, these words should start to sound familiar.

In fact, the goals with languages like Kotlin and frameworks like Jetpack Compose (Compose for short) are similar: improve upon existing frameworks (modern) such that complicated application behavior can be written in code that directly expresses the requirements of that application (declarative).

So what can we compare Compose against? Since 2008 when Android launched, the architecture of Android applications was based on the View Model.

In the View Model, Views (visible interface components) are organized as trees of widgets: layouts, buttons, images, text, etc. were all leaves/branches of a great user interface tree, where each branch could contain other branches and leaves.

Dynamic interface behavior that can respond to user interfactions requires updates in the face of changing data. As the state of the app changes, the UI hierarchy needs to be updated accordingly: that is, to “walk” the tree (visiting each branch/leaf along the way) using functions like findViewById(), and changing nodes with methods like button.setText(String), container.addChild(View), or img.setImageBitmap(Bitmap). These methods change the internal state of the widget.

Everything was based on the incredibly large and ancient View.java. You can see a brief example I made up of how that works below:

<TextView
android:id="@+id/someText"
android:layoutwidth="wrapcontent"
android:layoutheight="wrapcontent"
android:text="Hello World!"/>
<Button
android:id="@+id/supabutton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="I'm a button" />
<LinearLayout
android:layoutwidth="matchparent"
android:layoutheight="matchparent"
android:orientation="vertical">
</LinearLayout>
<TextView
android:id="@+id/someText"
android:layoutwidth="wrapcontent"
android:layoutheight="wrapcontent"
android:text="Hello World!"/>
<Button
android:id="@+id/supabutton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="I'm a button" />
<LinearLayout
android:layoutwidth="matchparent"
android:layoutheight="matchparent"
android:orientation="vertical">
</LinearLayout>

You might find the above syntax pretty similar to frameworkless Javascript/HTML and the Document Object Model (DOM).

It is pretty similar — XML is a markup language like HTML, and though Javascript is quite different from Java/Kotlin, it is also the vessel for dynamic programming logic and computation in user interfaces. Finally, you can think of the View.java as akin to the DOM in Javascript, providing an API for interacting with interface elements.

Manipulating views manually increases the likelihood of errors:

  • If a piece of data is rendered in multiple places, it’s easy to forget to update one of the views that shows it.
  • It’s also easy to create illegal states, when two updates conflict in an unexpected way. For example, an update might try to set a value of a node that was just removed from the UI. In general, the software maintenance complexity grows proportionally to the number of views that require updating.

Android UI toolkit versioning:

  • Similar to the problem in Web Dev where some features are not supported by all browsers
  • However, Android updates on a 6-12 month basis — a long time to wait for features unrelated to the operating system!

Is keeping views in XML really “decoupling” the user interface from the application logic?

  • XML creep into Java/Kotlin source code to select/modify XML views
    • E.g. the “state” of the application tied to the “state” of the user interface (error -> red, success -> green — the display state and the application state need to match. What is the source of truth?)
  • Some user interface logic IS inherently coupled to business logic — there is such thing as “good” coupling! Artificially separating two intertwined concepts introduces different code instabilities

Over the last several years, the entire industry — mobile, web, desktop, server, etc. — has started shifting to a declarative UI model, which greatly simplifies the engineering associated with building and updating user interfaces.

In Jetpack Compose, the technique works by conceptually regenerating the entire screen from scratch, then applying only the necessary changes — avoiding the computational “walk” of the View model, and avoiding the engineering complexity of manually updating a stateful view hierarchy.

To get to know Jetpack Compose, let’s start with the basic example provided for us by creating an “Empty Activity” in Android Studio:

Compose is built around composable functions. These are the “lego bricks” of a user interface: complicated layouts are built up with indepedent and “composable” parts. Using Compose, you can build your user interface by defining a set of composable functions that take in data and emit UI elements.

A simple example is a Greeting widget, which takes in a String and emits a Text widget which displays a greeting message. To make a function composable, just add the @Composable annotation to it:

Kotlin
// Question: what are the parameters and return types of this function?
@Composable
fun Greeting(name: String) {
Text("Hello $name.")
}
// Answer: (String) -> Unit
Kotlin
// Question: what are the parameters and return types of this function?
@Composable
fun Greeting(name: String) {
Text("Hello $name.")
}
// Answer: (String) -> Unit

This function takes in a string as data, and returns nothing but a kotlin Unit — its effect is to create a Greeting component, which itself contains a Text component, to be rendered on the user interface. That is the core purpose of all @Composable functions.

There are a few noteworthy things about the Greeting function, which demonstrate principles of @Composable functions in general:

  • The function is annotated with the @Composable annotation. All Composable functions must have this annotation; this annotation informs the Compose compiler that this function is intended to convert data into UI.

  • The function takes in data. Composable functions can accept parameters, which allow the app logic to describe the UI. In this case, our widget accepts a String so it can greet the user by name.

  • The function displays text in the UI. It does so by calling the Text() composable function, which actually creates the text UI element. Composable functions emit UI hierarchy by calling other composable functions.

  • The function doesn’t return anything (i.e. returns a kotlin Unit). Compose functions that emit UI do not need to return anything, because they describe the desired screen state instead of constructing UI widgets.

  • This function is fast, idempotent, and free of side-effects.

    • Idempotent (fancy latin: idem + potent, “same” “power”): this term is used to describe operations in math/programming that give the same result even when applied to the same input multiple times. E.g. the operation * 0 is idempotent: you can multiply a number by 0 repeatedly and the result will not change.
    • In User Interfaces, this means “adding the same Composable will only create an effect once” — similar to pressing the “Off” button on a calculator multiple times (it will remain “off”)
    • The function behaves the same way when called multiple times with the same argument; it is not affected by other values such as global variables or the state of the application/runtime environment.
    • The function describes the UI without any side-effects — it does not modify any global variables or the state of the runtime environment.
  • This function is written in Kotlin, and completely describes both the interface and the behavior of this component.

    • Because composable functions are written in Kotlin instead of XML, they can be as dynamic as any other Kotlin code. For example, suppose you to alter the Greeting UI to greet a list of users. This is a pretty easy modification:
Kotlin
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello $name")
}
}
Kotlin
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello $name")
}
}

Composable functions can be quite sophisticated. You can use if statements to decide if you want to show a particular UI element. You can use loops. You can call helper functions. You have the full flexibility of the underlying language. This power and flexibility is one of the key advantages of Jetpack Compose.

  • This function is immutable:
    • Like the String class in Java: once a Composable is created, its state cannot be altered (you can provide different parameters to transform it into a new composable that replaces the old one)
    • You need to pass any and all information as parameters (or state)
    • When parameters/state of the Composable function changes, the UI for that composable is regenerated. This process is called recomposition

As in React, every time the state of the UI changes, Compose recreates the parts of the UI tree that have changed. Taking advantage of the above properties of @Composables allows developers to succinctly describe complicated user interfaces that can react to any change in state — though there is a bit more to learn about how that works under the surface to understand why this is possible, which we will see in the recomposition section.

This section is adapted from API Guidelines for Jetpack Compose.

One way to get used to the idea of using functions to generate UI elements is to learn the conventions for writing @Composable functions.

This might seem trite — of course, following code conventions doesn’t change the behavior of the code. It does, however, change the way that you think about the code you are writing and reading — following conventions allows code to have obvious meaning that developers can share and understanding of.

We’ll see some examples in the following sections. Make sure you click on the Do and Don’t tabs for each, and read all the comments.

@Composable functions that return Unit emit UI elements; that is, they represent things that will be rendered in your UI. Unlike normal functions, @Composables that return Unit are more like objects than actions! They follow the following conventions:

  • named using PascalCase (not camelCase)
  • named by nouns, not verb or verb phrases, prepositions, adjectives or adverbs. (e.g., fun Button, not fun CreateButton)
  • MAY be named by nouns prefixed by descriptive adjectives. (e.g. fun RedButton)

Composable functions that return Unit are considered declarative entities that can be either present or absent in a composition and therefore follow the naming rules that we are used to using for classes. Naming @Composables as though they are things/objects/entities promotes a declarative mental model for thinking about the persistent identity of our UI elements.

Kotlin
// This function is a descriptive PascalCased noun as a visual UI element
@Composable
fun FancyButton(text: String, onclick: () -> Unit) { /*...*/ }
// This function is a descriptive PascalCased noun as a non-visual element
// with presence in the composition
@Composable
fun BackButtonHandler(onBackPressed: () -> Unit) { /*...*/ }
Kotlin
// This function is a descriptive PascalCased noun as a visual UI element
@Composable
fun FancyButton(text: String, onclick: () -> Unit) { /*...*/ }
// This function is a descriptive PascalCased noun as a non-visual element
// with presence in the composition
@Composable
fun BackButtonHandler(onBackPressed: () -> Unit) { /*...*/ }

This naming rule will cover all of our use cases for now — remember that it only applies to @Composable functions, NOT to normal Kotlin helper/class/global functions, which you can write using normal camelCase and verb-based conventions as is usual in Java/C#/Kotlin.

There are more conventions for different types of @Composable functions` — we will review those conventions once we have learned more advanced Jetpack Compose concepts and techniques.

You may have noticed from the codelabs we have done so far, as well as from the sample projects we saw in project Milestone 1, that most @Composable functions follow some common parameter conventions: there is usually a Modifier parameter, and sometimes there is a lambda content parameter.

There are, in fact, two main types of @Composables: Elements and Layouts. We will learn more about these in the next lecture on layouts. For now, we can see the main difference is defined by the parameters they take.

A @Composable function that emits exactly one Compose UI tree node (one Kotlin Unit) is called an element. Element functions have two main properties:

  • Elements return Unit: @Composable elements emit UI node by calling one or more Compose UI element functions inside their function body. They do not return a value — that means, all behavior of the element is provided by parameters passed to the element function, allowing the @Composable to remain idempotent.
  • Elements accept a Modifier parameter: @Composable elements accept a parameter of type Modifier, used to make common modifications to the appearance/behavior of the element — we will learn more about this parameter in the next lecture. This parameter is named modifier and appears as the first optional parameter in the element function’s parameter list.
Kotlin
/* normally I'll leave this import out, but you can see Modifier
is the default value provided in the highlighted line below. */
import androidx.compose.ui.Modifier
@Composable
fun FancyButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier // Modifier parameter named modifier
) { /* ... */ } // No return type
Kotlin
/* normally I'll leave this import out, but you can see Modifier
is the default value provided in the highlighted line below. */
import androidx.compose.ui.Modifier
@Composable
fun FancyButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier // Modifier parameter named modifier
) { /* ... */ } // No return type

Remember, elements are entities in a Compose UI composition, the Unit they return represents the fact that they have been generated in the Composable User Interface. Returning a value is not necessary; any means of controlling the emitted element should be provided as a parameter to the element function, not returned by calling the element function.

You may have wondered: why are @Composables that may call multiple @Composables called elements? What is the difference then between elements and layouts?

Here is the answer: A Compose UI element that accepts one or more @Composable function parameters (i.e., lambdas) is called a layout.

You probably saw tons of these in your milestone 1 work — every @Composable with a content parameter was an example:

Kotlin
@Composable
fun SimpleRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) { /* ... */ }
Kotlin
@Composable
fun SimpleRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) { /* ... */ }

Layout functions use the name content for a @Composable function parameter if they accept only one @Composable function parameter.

If they accept more than one @Composable function parameter, Layout functions use the name content for their primary — or most common — @Composable function parameter.

Finally, Layout functions place their primary or most common @Composable function parameter in the last parameter position to permit the use of Kotlin’s trailing lambda / “last parameter call” syntax for that parameter.

I’ll make sure to include more conventions and explanations for those conventions as we learn more techniques in Jetpack Compose — for now, these naming and parameter conventions should be a good start for reading the code of others and writing your own in a consistent way.

One last major concept to cover to understand the “basics”1 of Jetpack Compose is called Recomposition. Since all UI elements are “functions”, how do we go about changing anything about them?

In imperative UI models, to change a widget, you call a setter on the widget to change its internal state.

In Compose, you call the composable function again with new data. Doing so causes the function to be recomposed—the widgets emitted by the function are redrawn, if necessary, with new data. The Compose framework can intelligently recompose only the components that changed rather than changing or even “walking” the entire interface every time.

For example, consider this composable function which displays a button:

Kotlin
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("I've been clicked $clicks times")
}
}
Kotlin
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("I've been clicked $clicks times")
}
}

Every time the button is clicked, the caller updates the value of clicks. Compose calls the lambda with the Text function again to show the new value; this process is called recomposition. Other functions that don’t depend on the value are not recomposed.

By skipping all @Composables that don’t have changed parameters, Compose can recompose efficiently. Recomposing the entire UI tree can be computationally expensive, draining computing resources and battery life — “lazy” recomposition is necessary to keep applications performant, and to allow developers to be ambitious in the interfaces they generate.

In order to facilitate lazy recomposition, Jetpack Compose depends upon the key properties described in the previous section. Since Kotlin is a programming language like any other, it is possible and sometimes necessary to write mutable, side-effect-driven, non-idempotent code — this is a frequent source of bugs in other programming languages, and Jetpack Compose is no exception.

The following sections will explain in further detail what effects these properties can have on application behavior, namely:

  • Composable functions cannot depend on side-effects
  • Composable functions can execute in any order.
  • Composable functions can execute in parallel.
  • Recomposition skips as many composable functions and lambdas as possible.
  • Recomposition is optimistic and may be canceled.
  • A composable function might be run quite frequently, as often as every frame of an animation.

A side-effect is any change of state that is global to the rest of your app. For example, these actions are all dangerous side-effects:

  • Writing to a property of a shared object (e.g. a List of messages that is used by more than one object)
  • Updating an observable in ViewModel
  • Updating shared preferences
Kotlin
@Composable
fun Checkbox(
isChecked: Boolean,
onToggle: () -> Unit
) {
// ...
// Usage: (caller mutates optIn and owns the source of truth)
Checkbox(
myState.optIn,
onToggle = { myState.optIn = !myState.optIn }
)
Kotlin
@Composable
fun Checkbox(
isChecked: Boolean,
onToggle: () -> Unit
) {
// ...
// Usage: (caller mutates optIn and owns the source of truth)
Checkbox(
myState.optIn,
onToggle = { myState.optIn = !myState.optIn }
)

Because Compose skips recomposition for functions that have not had their parameters changed, functions that depend on side-effects will not be recomposed, which will cause bugs/errors in the form of strange and unpredictable behavior in your app interface.

If you look at the code for a composable function, you might assume that the code is run in the order it appears. But this isn’t necessarily true. If a composable function contains calls to other composable functions, those functions might run in any order. Compose has the option of recognizing that some UI elements are higher priority than others, and drawing them first.

For example, suppose you have code like this to draw three screens in a tab layout:

Kotlin
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
Kotlin
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}

The calls to StartScreen, MiddleScreen, and EndScreen might happen in any order. This means you can’t, for example, have StartScreen() set some global variable (a side-effect) and have MiddleScreen() take advantage of that change. Instead, each of those functions needs to be self-contained.

Compose can optimize recomposition by running composable functions in parallel. This lets Compose take advantage of multiple CPU cores, and run composable functions not on the screen lazily or at a lower priority.

This optimization means a composable function might execute within a pool of background threads. If a composable function calls a function on a ViewModel, Compose might call that function from several threads at the same time.

When a composable function is invoked, the invocation might occur on a different thread from the caller. That means code that modifies variables in a composable lambda should be avoided: both because such code is not thread-safe, and because it is an impermissible side-effect of the composable lambda.

Here’s an example showing a composable that displays a list and its count:

Kotlin
@Composable
fun ListComposable(myList: List<String>) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
}
}
Text("Count: ${myList.size}")
}
}
Kotlin
@Composable
fun ListComposable(myList: List<String>) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
}
}
Text("Count: ${myList.size}")
}
}

This code is side-effect free, and transforms the input list to UI. This is great code for displaying a small list. However, if the function writes to a local variable, this code will not be thread-safe or correct:

Kotlin
@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
items++ // Avoid! Side-effect of the column recomposing.
}
}
Text("Count: $items")
}
}
Kotlin
@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
items++ // Avoid! Side-effect of the column recomposing.
}
}
Text("Count: $items")
}
}

In this example, items is modified with every recomposition. That could be every frame of an animation, or when the list updates. Either way, the UI will display the wrong count. Because of this, writes like this are not supported in Compose; by prohibiting those writes, we allow the framework to change threads to execute composable lambdas.

When portions of your UI are invalid, Compose does its best to recompose just the portions that need to be updated. This means it may skip to re-run a single Button’s composable without executing any of the composables above or below it in the UI tree.

Every composable function and lambda might recompose by itself. Here’s an example that demonstrates how recomposition can skip some elements when rendering a list:

Kotlin
/**
* Display a list of names the user can click with a header
*/
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// this will recompose when [header] changes, but not when [names] changes
Text(header, style = MaterialTheme.typography.bodyLarge)
Divider()
// LazyColumn is the Compose version of a RecyclerView.
// The lambda passed to items() is similar to a RecyclerView.ViewHolder.
LazyColumn {
items(names) { name ->
// When an item's [name] updates, the adapter for that item
// will recompose. This will not recompose when [header] changes
NamePickerItem(name, onNameClicked)
}
}
}
}
/**
* Display a single name the user can click.
*/
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}
Kotlin
/**
* Display a list of names the user can click with a header
*/
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// this will recompose when [header] changes, but not when [names] changes
Text(header, style = MaterialTheme.typography.bodyLarge)
Divider()
// LazyColumn is the Compose version of a RecyclerView.
// The lambda passed to items() is similar to a RecyclerView.ViewHolder.
LazyColumn {
items(names) { name ->
// When an item's [name] updates, the adapter for that item
// will recompose. This will not recompose when [header] changes
NamePickerItem(name, onNameClicked)
}
}
}
}
/**
* Display a single name the user can click.
*/
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Each of these scopes might be the only thing to execute during a recomposition. Compose might skip to the Column lambda without executing any of its parents when the header changes. And when executing Column, Compose might choose to skip the LazyColumn’s items if names didn’t change.

Again, executing all composable functions or lambdas should be side-effect free. When you need to perform a side-effect, trigger it from a callback.

Recomposition starts whenever Compose thinks that the parameters of a composable might have changed. Recomposition is optimistic, which means Compose expects to finish recomposition before the parameters change again. If a parameter does change before recomposition finishes, Compose might cancel the recomposition and restart it with the new parameter.

When recomposition is canceled, Compose discards the UI tree from the recomposition. If you have any side-effects that depend on the UI being displayed, the side-effect will be applied even if composition is canceled. This can lead to inconsistent app state.

Ensure that all composable functions and lambdas are idempotent and side-effect free to handle optimistic recomposition.

In some cases, a composable function might run for every frame of a UI animation. If the function performs expensive operations, like reading from device storage, the function can cause UI jank.

For example, if your widget tried to read device settings, it could potentially read those settings hundreds of times a second, with disastrous effects on your app’s performance.

If your composable function needs data, it should define parameters for the data. You can then move expensive work to another thread, outside of composition, and pass the data to Compose using mutableStateOf or LiveData.

  • Understanding Jetpack Compose: Part 1 and Part 2 by Leland Richardson are execellent early articles explaining the technical motivation behind Jetpack Compose.
  • Thinking in Compose: Short video lecture explaining the basic principles of Compose.
  • Composable functions: Short video lecture demonstrating how Composable functions work.

  1. Don’t worry if you feel this is all a bit complicated! There is a lot of background information to digest, but once we get it, things will come easier, and you’ll feel the sense for why we learn all of these mechanics.