Using Jetpack Compose in production code: first impressions

Roman Kamyshnikov
ProAndroidDev
Published in
7 min readNov 15, 2021

--

Since its announcement, Jetpack Compose seemed like a very promising framework and my colleagues at MTS Bank and I were waiting for its stable version to be released to try it out in production code. Last month we finally got the chance, when we decided to update the design of one of our app’s screens:

In this article I’d like to talk about our experience with Jetpack Compose, the advantages it brings, share some best-practices of composable functions and also provide a few useful links along the way to help you get started with Compose. Hopefully you’ll find this article useful if you’re considering using Jetpack Compose in your projects.

Interoperability

We use the Single-Activity architectural approach in our app, so, as described in the docs, we just needed to change our implementation of the onCreateView() Fragment method to return a ComposeView with our screens UI.

override fun onCreateView(...): View {
return ComposeView(requireContext()).apply {
setContent { /* In Compose world */ }
}
}

Something we noticed straight away is that the first launch of a screen with compose takes longer to load (over 1 second!). While investigating the reasons for this, a great article was found with some explanations: it turns out that having R8 disabled and debuggability enabled increases the first-load time of Compose screens by around 0.5 seconds each. Also, the first screen with compose also takes almost twice as long as subsequent screens.

Some testing on our part confirmed this, so, as stated in the article above, using Compose might have a higher performance cost than you expect, and it’s something you should keep in mind, but it’s definitely not something critical that should stop you from starting to use it.

Click here for more official compatibility information.

Jetpack integration

Accessing ViewModels directly in @Composable functions is possible by using the viewModel() function from the lifecycle-viewmodel-compose library (still in alpha) and modern DI frameworks (Koin, Dagger/Hilt) also provide support for this. But since we’re not changing out navigation graph and display our @Composable screen content inside a fragment, a simpler solution might be to just inject the ViewModel into the fragment itself like we did pre-compose and pass a reference to it (or the state that it produces) to the @Composable functions that needs it.

Here you can find docs about integration with other libraries.

Material design

Because of the relatively small size of our experiment, there is not much to say on this topic. Note that the Material Design catalog app will definitely come in handy when you’re starting out to see the components that are already implemented.

Another great resource is the official compose-samples repository in GitHub.

Lists

If you’ve seen at least a couple of videos of Compose, you probably already noticed how easy it is to create a RecyclerView (LazyList):

@Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(messages) { message -> MessageRow(message) }
}
}

Also, it’s very easy to customize their behaviour:

One of the feats of the new design was a ViewPager with two groups of buttons, but at the time of writing this article, there’s still no official ViewPager in Compose. A library by Chris Banes has its own implementation and can serve as a solution to this problem, but it’s API is still marked as experimental (by the way, the library is used in official Jetpack Compose examples and might even become part of the framework itself in the future). The solution above might be totally fine for a pet project or some apps, using alpha libraries and experimental APIs is not something you’d want to do in a banking app.

A valid solution would be to just use the existing ViewPager in Compose with the help of the AndroidApi composable. Or, alternatively, we could just use a LazyRow for the content with some analogue of SnapHelper for RecyclerView. Compose currently doesn’t have one, but implementing one ourselves is a lot easier than you might think: you just need to pass a custom implementation of FlingBehavior to LazyRow.

You can check the full code here.

And this is how it looks, when combined with a Row of buttons synced with LazyRow’s LazyListState:

You can also find several great examples with code on various topics here.

Animations

This has to be one of my favourite features of Jetpack Compose, because it’s animation APIs are very easy to use. Just check the example video from Android Developers
- it’s just 5 minutes, but contains examples for animations that will cover your needs most of the time. Yep, animations are that easy-to-use now.

Best practices for @Composable fun

Follow the parameters order convention: obligatory parameters, modifier, parameters with default values, content.

This is the way the standard library composables are written. Let’s see why the order matters using the function that we made for a quick implementation of a collapsing toolbar as an example:

@Composable
fun CollapsingToolbar(
title: String,
modifier: Modifier = Modifier,
navigationIcon: NavigationIcon? = null,
config: CollapsingToolbarConfig = CollapsingToolbarConfig(),
scrollState: ScrollState = rememberScrollState(),
content: @Composable ColumnScope.() -> Unit,
)

Having the Modifier parameter first or after any obligatory parameters allows you to specify it without explicitly writing it’s name at the callsite:

@Composable
fun MyScreen() {
CollapsingToolbar(
title = "My title",
Modifier.fillMaxSize(),
navigationIcon = NavigationIcon(R.drawable.ic_arrow_right){
/* handle click logic */
},
) {
// trailing lambda with content!
}
}

Putting the content parameter last allows you to use trailing lambdas.

Standard library examples: any composable function.

Use helper classes to hold related parameters together.

There are two helper classes in the above example:

class CollapsingToolbarConfig(
val collapsedToolbarHeight: Dp = 56.dp,
val expandedToolbarHeight: Dp = 112.dp,
val collapsedTitleStartPadding: Dp = 72.dp,
val expandedTitleStartPadding: Dp = 20.dp,
val collapsedTitleFontSize: TextUnit = 20.sp,
val expandedTitleFontSize: TextUnit = 32.sp,
)
class NavigationIcon(
@DrawableRes val icon: Int,
val onClick: (() -> Unit)
)

Using helper classes like the ones above allow us to group together the parameters of our toolbar to improve code readability.

Note that NavigationClass was used to combine the icon for the toolbar and the callback that should be invoked when it’s clicked. We won’t need a callback if there is no icon to draw, so combining them is a good idea.

Standard library examples: TextStyle, PaddingValues, SwitchDefaults to name a few.

Make stateful and stateless versions of your composables.

If you’re going to have a composable with state, you might as well let the caller use state hoisting (in compose: the pattern of moving state to a composable’s caller to make a composable stateless). LazyRow and LazyColumn have their state defined as a default parameter, so that the caller of the function can control the state and change it if needed. We did the same in our example with the parameter scrollState, that is passed to a Column that holds the content with Modifier.verticalScroll(scrollState). For now, the default value is always used, but since we need to initiate the state somewhere anyway, there is nothing wrong in doing so in the function declaration as a default parameter and it’ll give our function more flexibility for the future.

Another important convention is that functions that hold state, should be named with the “remember” prefix.

Examples: rememberLazyListState(), rememberCoroutineScope().

Use the scope of the content as the receiver for content functions.

In Compose, modifiers are extension functions and some of them are only available in the scope of their composables (BoxScope, ColumnScope, etc). For example, the ColumnScope defines an align() modifier:

interface ColumnScope {
fun Modifier.align(alignment: Alignment.Horizontal): Modifier
}

In our CollapsingToolbar example, we defined the parameter content as:

content: @Composable ColumnScope.() -> Unit

So, at the call site the scope is available and we’re able to apply modifiers to the content that we would not be able to do otherwise.

CollapsingToolbar(
title = "My title",
) { this: ColumnScope
Text(
text = "some text",
Modifier.align(Alignment.CenterHorizontally)
)
}

Standard library examples: any composable function with content.

Remember to keep your code DRY (don’t repeat yourself).

This is more actual than ever. Of course we had the <include> tag in xml, and we could even pass parameters to included layouts with DataBinding, but copy pasting xml views and modifying a parameter or two was usually faster and easier. Nowadays, for composables, extracting the code of an item and reusing it is easier than ever. Just please remember to use Android Studio’s hotkeys while doing so: command+option+m or ctrl+alt+m for Mac/Windows.

Afterword

In our app, we already use a MVI approach with a single observable in ViewModels that holds the current state of the view and unidirectional data flow, so the business logic in the VM remained untouched and integration with Compose was really easy in that regard. One of the only concerns that we have left is that some of the framework’s APIs are still experimental (sticky headers for lazy lists, no out-of-the-box ViewPager and no animations for list changes as some examples). You might find some things you need missing and you’ll need to either write your own implementation of some view or behaviour or use the interoperability API to include old views in compose (WebView and MapView being the most common examples). Either way, considering all of the advantages it brings, it’s clear that any Android developer should start becoming familiar with it as soon as possible.

A version of this article in Russian is available here.

--

--