Jetpack Compose basics + under-the-hood stuff

Roman Shtykalo
16 min readOct 23, 2022

--

This is a guide for engineers who still haven't tried Jetpack Compose or want to dive deeper into it, have fun:)
(Sorry for the messy writing, these were just notes for myself)

What is Jetpack Compose?

Jetpack Compose is a modern declarative UI Toolkit for Android. Compose makes it easier to write and maintain your app UI by providing a declarative API that allows you to render your app UI without imperatively mutating frontend views

Why Compose?

Concise and Idiomatic Kotlin

  • Built with the benefits that Kotlin brings

Declarative

  • Fully declarative for defining UI components

Compatible

  • Compatible with existing views

Enable Beautiful Apps

  • Designed with Material Design

100% Kotlin

  • written in Kotlin programming language

Accelerate Development

  • writing less code and using tools

One codebase

  • No need to write XML anymore.

Less code

  • With less code, you can achieve more.

The declarative paradigm shift

With many imperative object-oriented UI toolkits, you initialize the UI by instantiating a tree of widgets. You often do this by inflating an XML layout file. Each widget maintains its own internal state and exposes getter and setter methods that allow the app logic to interact with the widget.

In Compose’s declarative approach, widgets are relatively stateless and do not expose setter or getter functions. In fact, widgets are not exposed as objects. You update the UI by calling the same composable function with different arguments. This makes it easy to provide state. Then, your composables are responsible for transforming the current application state into a UI every time the observable data updates.

The app logic provides data to the top-level composable function. That function uses the data to describe the UI by calling other composables, and passes the appropriate data to those composables, and on down the hierarchy.

When the user interacts with the UI, the UI raises events such as onClick. Those events should notify the app logic, which can then change the app’s state. When the state changes, the composable functions are called again with the new data. This causes the UI elements to be redrawn — this process is called recomposition.

Dynamic content

Because composable functions are written in Kotlin instead of XML, they can be as dynamic as any other Kotlin code. For example, suppose you want to build a UI that greets a list of users:

Basically, you could do the same thing with Android View, but just try to imagine how much harder would it be:

Compose layout basics

This article is a must-read to understand which compose functions to use instead of basic Android views: Recycler View, ImageView, TextView and so on:

https://developer.android.com/jetpack/compose/layouts/basics

Modifiers

https://developer.android.com/jetpack/compose/modifiers

https://developer.android.com/jetpack/compose/modifiers-list

Modifiers allow you to decorate or augment a composable. Modifiers let you do these sorts of things:

  • Change the composable’s size, layout, behavior, and appearance
  • Add information, like accessibility labels
  • Process user input
  • Add high-level interactions, like making an element clickable, scrollable, draggable, or zoomable

Managing State

remember

Composable functions can use the remember API to store an object in memory. A value computed by remember is stored in the Composition during initial composition, and the stored value is returned during recomposition. remember can be used to store both mutable and immutable objects. The remember stores objects in the Composition and forgets the object when the composable that called remember is removed from the Composition.

When to use remember?

For any operation which might be executed more than once, but shouldn’t be until the key passed in the remember changes.

When using objects that are not treated by the compiler as stable, including unstable lambdas.

Use with:

KeyboardActions();

LocalClipboardManager.current.setText (unstable lambdas);

LocalFocusManager.current.clearFocus/moveFocus (unstable lambdas);

FocusRequester;

MutableInteractionSource;

derivedStateOf();

Don’t use with:

TextStyle();

KeyboardOptions();

LocalSoftwareKeyboardController.current.hide/show;

FontFamily/Font;

.dp;

Arrangement;

Alignment;

RoundedCornerShape;

BorderStroke;

mutableStateOf

mutableStateOf creates an observable MutableState<T>, which is an observable type integrated with the compose runtime.

Key Point: Compose will automatically recompose from reading State<T> objects.

There are three ways to declare a MutableState object in a composable:

These declarations are equivalent and are provided as syntax sugar for different uses of state. You should pick the one that produces the easiest-to-read code in the composable you’re writing. I prefer using the 2nd one btw.

The by delegate syntax requires the following imports:

You can use the remembered value as a parameter for other composables or even as logic in statements to change which composables are displayed. For example, if you don’t want to display the greeting if the name is empty, use the state in an if statement:

While remember helps you retain state across recompositions, the state is not retained across configuration changes. For this, you must use rememberSaveable. rememberSaveable automatically saves any value that can be saved in a Bundle. For other values, you can pass in a custom saver object. Make sure that your rememberSaveable does not crash with custom objects.

State hoisting

State hoisting in Compose is a pattern of moving state to a composable’s caller to make a composable stateless. The general pattern for state hoisting in Jetpack Compose is to replace the state variable with two parameters:

State that is hoisted this way has some important properties:

  • Single source of truth: By moving state instead of duplicating it, we’re ensuring there’s only one source of truth. This helps avoid bugs.
  • Encapsulated: Only stateful composables will be able to modify their state. It’s completely internal.
  • Shareable: Hoisted state can be shared with multiple composables. Say we wanted to name in a different composable, hoisting would allow us to do that.
  • Interceptable: callers to the stateless composables can decide to ignore or modify events before changing the state.
  • Decoupled: the state for the stateless @Composable may be stored anywhere. For example, it’s now possible to move the state into a ViewModel.

Key Point: When hoisting state, there are three rules to help you figure out where state should go:

  1. State should be hoisted to at least the lowest common parent of all composables that use the state (read).
  2. State should be hoisted to at least the highest level it may be changed (write).
  3. If two states change in response to the same events they should be hoisted together.

You can hoist state higher than these rules require, but underhoisting state will make it difficult or impossible to follow unidirectional data flow.

Managing state in Compose

Simple state hoisting can be managed in the composable functions itself. However, if the amount of state to keep track of increases, or the logic to perform in composable functions arises, it’s a good practice to delegate the logic and state responsibilities to other classes: state holders (View Model in our case).

ViewModels as a source of truth

ViewModel is a special type of state holder that is in charge of:

  • providing access to the business logic of the application that is usually placed in other layers of the hierarchy such as the business and data layers, and
  • preparing the application data for presentation on a particular screen, which becomes the screen or UI state.

ViewModels have a longer lifetime than Composition because they survive configuration changes.

We recommend screen-level composables use ViewModel instances for providing access to business logic and being the source of truth for their UI state. You should not pass ViewModel instances down to other composables. Check the ViewModel and state holders section to see why ViewModel can be used for this.

Warning: Don’t pass ViewModel instances down to other composable functions. For more information, see the Architecture state holders documentation.

Side effects

https://medium.com/mobile-app-development-publication/jetpack-compose-side-effects-made-easy-a4867f876928

Real-world examples (read after the foregoing article)

  1. Launched effect mostly for entry animations or you can also trigger animation by using key param in Launched Effect (progress in this case)

2. Passing constant key (Unit, true, etc.) allows to run Lanched effect only once:

3. DisposableEffect and SideEffect are really uncommon:)

CompositionLocal

Usually, in Compose, data flows down through the UI tree as parameters to each composable function. This makes a composable’s dependencies explicit. This can however be cumbersome for data that is very frequently and widely used such as colors or type styles. See the following example:

To support not needing to pass the colors as an explicit parameter dependency to most composables, Compose offers CompositionLocal which allows you to create tree-scoped named objects that can be used as an implicit way to have data flow through the UI tree.

CompositionLocal elements are usually provided with a value in a certain node of the UI tree. That value can be used by its composable descendants without declaring the CompositionLocal as a parameter in the composable function.

CompositionLocal is what the Material theme uses under the hood. MaterialTheme is an object that provides three CompositionLocal instances — colors, typography, and shapes — allowing you to retrieve them later in any descendant part of the Composition. Specifically, these are the LocalColors, LocalShapes, and LocalTypography properties that you can access through the MaterialTheme colors, shapes, and typography attributes.

A CompositionLocal instance is scoped to a part of the Composition so you can provide different values at different levels of the tree. The current value of a CompositionLocal corresponds to the closest value provided by an ancestor in that part of the Composition.

To provide a new value to a CompositionLocal, use the CompositionLocalProvider and its provides an infix function that associates a CompositionLocal key to a value. The content lambda of the CompositionLocalProvider will get the provided value when accessing the current property of the CompositionLocal. When a new value is provided, Compose recomposes parts of the Composition that read the CompositionLocal.

As an example of this, the LocalContentAlpha CompositionLocal contains the preferred content alpha used for text and iconography to emphasize or de-emphasize different parts of the UI. In the following example, CompositionLocalProvider is used to provide different values for different parts of the Composition.

Canvas

https://developer.android.com/jetpack/compose/graphics

Basically, the same as in Android views.

Animations

https://developer.android.com/jetpack/compose/animation

Complex topic, let’s try to implement some examples.

PinDots wrong passcode animation:

Let’s start with basics, how to trigger animation? For this we need to change state, for instance errorCount:

Now we need to change some offset value, let’s create it:

Then to trigger anim we need to “observe” errorCount changes like that:

This LaunchedEffect will be triggered by new state.errorCount value, so now we need to implement the shaking animation:

Here the most important part is fun animate, which is basically a ValueAnimator for Android Views.

Now let’s use shakeOffset to animate horizontal position of our view:

Result code:

Codelabs

https://github.com/googlecodelabs/android-compose-codelabs

Basics codelabs

Go hands-on and learn the fundamentals of declarative UI, working with state, layouts and theming.

Basic layouts codelab

Learn how to implement real-world designs with the composables and modifiers that Compose provides out of the box.

State codelab

Understand patterns for working with state in a declarative world by building a Wellness application.

Theming codelab

Go hands on with Compose’s implementation of Material Design to understand how to theme an application’s colors, typography and shapes and support light and dark themes.

Migration codelab

Understand how Jetpack Compose and View-based UIs can co-exist and interact, making it easy to adopt Compose at your own pace.

Animation codelab

Learn how to use Jetpack Compose Animation APIs.

Navigation codelab

Learn how to use the Jetpack Navigation library in Compose, navigate within your application, navigate with arguments, support deep-links, and test your navigation.

Testing codelab

Learn about testing Jetpack Compose UIs. Write your first tests, and learn about testing in isolation, debugging tests, the semantics tree, and test synchronization.

Accessibility codelab

Learn about the various ways to improve an app’s accessibility. Increase touch target sizes, add content descriptions, create custom actions, and more.

Compose Under The Hood

The three phases of a frame

Compose has three main phases:

  1. Composition: What UI to show? Compose runs composable functions and creates a description of your UI.
  2. Layout: Where to place UI. This phase consists of two steps: measurement and placement. Layout elements measure and place themselves and any child elements in 2D coordinates, for each node in the layout tree.
  3. Drawing: How it renders. UI elements draw into a Canvas, usually a device screen.

Recomposition

In an imperative UI model, 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!

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

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 (Button) are not recomposed.

As we discussed, recomposing the entire UI tree can be computationally expensive, which uses computing power and battery life. Compose solves this problem with this intelligent recomposition.

Recomposition is the process of calling your composable functions again when inputs change. This happens when the function’s inputs change. When Compose recomposes based on new inputs, it only calls the functions or lambdas that might have changed and skips the rest. By skipping all functions or lambdas that don’t have changed parameters, Compose can recompose efficiently.

Composable functions can execute in any order

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:

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.

Composable functions can run in parallel

Compose can optimize recomposition by running composable functions in parallel. This lets Compose take advantage of multiple cores and run composable functions not on the screen 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.

To ensure your application behaves correctly, all composable functions should have no side-effects. Instead, trigger side-effects from callbacks such as onClick that always execute on the UI thread.

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

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:

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.

Recomposition skips as much as possible

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 re-running 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:

This means for us, that almost every compose function works like Recycler View with payloads automatically, which is very convenient.

Key Point: Compose skips the recomposition of a composable if all the inputs are stable and haven’t changed. The comparison uses the equals method.

Recomposition is optimistic

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 the 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 the composition is canceled. This can lead to an inconsistent app state.

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

Jetpack Compose Stability

Functions could be skippable and/or restartable:

Skippable — when called during recomposition, compose is able to skip the function if all of the parameters are equal to their previous values.

Restartable — this function serves as a “scope” where recomposition can start (In other words, this function can be used as a point of entry for where Compose can start re-executing code for recomposition after state changes).

Types could be immutable or stable:

@Immutable — Indicates a type where the value of any properties will never change after the object is constructed, and all methods are referentially transparent. All primitive types (String, Int, Float, etc) are considered immutable.

@Stable — Indicates a type that is mutable, but the Compose runtime will be notified if and when any public properties or method behavior would yield different results from a previous invocation.

  • Compose determines the stability of each parameter of your composables to work out if it can be skipped or not during recomposition.
  • If you notice your composable isn’t being skipped and it is causing a performance issue, you should check the obvious causes of instability like var parameters first.
  • You can use the compiler reports to determine what stability is being inferred about your classes.
  • Collection classes like List, Set, and Map are always determined unstable as it is not guaranteed they are immutable. You can use Kotlinx immutable collections instead or annotate your classes as @Immutable or @Stable.
  • Classes from modules where the Compose compiler is not run are always determined to be unstable. Add a dependency on compose runtime and mark them as stable in your module or wrap the classes in UI model classes if required.
  • Should every Composable be skippable? No.

Compose Tips And Tricks

Order in Modifiers matters

In short, padding() is a LayoutModifer, it takes in some constraints, measures its child size based on a projection of those constraints, and places the child at some coordinates.

Let’s see an example:

And the result:

But let’s swap the .size() and the .padding()

Now we have a different result:

Tips for writing state classes

  1. Do not use vars as properties inside state-holding classes
  2. Private properties still affect the stability
  3. There is no need to explicitly mark any of your UI state classes with @Immutable or @Stable annotations unless you have a multi-module project or are creating a library for someone to use.
  4. Try not to use classes that belong to an external module to form a state

You can create the same classState in your module and class map into classState

  1. Do not expect immutability from collections

Use Immutable wrappers for collections if necessary

  1. Flows are unstable. Use collectAsState for flows
  2. Inlined Composables are neither restartable nor skippable
  3. Use kotlinx.collections.immutable but beware of this issue: https://issuetracker.google.com/issues/254435410

BaselineProfiles really work

https://developer.android.com/topic/performance/baselineprofiles

Compose is really slow in debug mode

Do not measure any performance in debug mode

Always try to use lazy layout keys

They enable fast updates of a list, like RV DiffUtils do with Payloads

Resources

Jetpack Compose basics

https://developer.android.com/jetpack/compose/mental-model

https://developer.android.com/jetpack/compose/state

https://developer.android.com/jetpack/compose/modifiers
https://medium.com/mobile-app-development-publication/jetpack-compose-side-effects-made-easy-a4867f876928
https://developer.android.com/jetpack/compose/phases#3-phases

https://developer.android.com/jetpack/compose/compositionlocal

https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8

https://developer.android.com/jetpack/compose/layouts/basics

https://developer.android.com/jetpack/compose/graphics

https://developer.android.com/jetpack/compose/animation

https://developer.android.com/jetpack/compose/modifiers-list

https://github.com/googlecodelabs/android-compose-codelabs

https://developer.android.com/topic/performance/baselineprofiles

--

--