Jetpack Compose: Tips & Tricks from Our Android App Migration

Reading Time: 8 minutes

🎯 A Big Milestone

Over the recent weeks, we have celebrated an important transition in our Android Customer App. We completed the migration of all our Widgets to Jetpack Compose 🎉

In our context, the term widget represents the foundational building blocks of our application. They could be simple components such as a Button or Title, or they could be complex components like a Call to Action or Cart Item.

These widgets form a hefty 70-80% of our User Interface (UI), which now implies that a significant portion of our live application thrives on Jetpack Compose!

Examples of widgets below:

call to action
Call to Action Widget
cart item
Cart Item Widget

It’s important to clarify that our migration to Jetpack Compose is gradual. Some facets of our application, such as navigation and a majority of our fragments, continue to leverage the traditional View System and XML (for the container elements, in particular).

📚 Our Learnings from Jetpack Compose

Having explored Jetpack Compose for an extended period and implemented it within a live environment, we’ve gained valuable insights and developed hands-on strategies that could be beneficial.

We want to share some lessons, tips, and tricks that emerged from our hands-on experience.

We acknowledge ideas like:

  • Learning from mistakes
  • Strategically planning the migration
  • Adhering to Google’s Guidelines
  • And more…

Sure, these tips are really important, but they’re kind of like “Tech 101”, right? They apply no matter what you’re working on. So, let’s focus instead on more concentrate on more practical Android ideas.

1. Having a design system helps 🎨

We talked about our Design system in this post but in a nutshell 🥜 having company-wide style guidelines (a.k.a Design System) will help a lot.

The key idea is to leverage CompositionLocalProvider to pass styling options down through the Composition tree implicitly.

A good starting point is to look at the default Material Theme that comes with Compose and customize it. This was doable even before in the View system, but that has been extended in Compose and it’s much easier and more powerful than before.

Here’s a glimpse at our Theme:

// Demand (customer) specific theme -> extend generic Theme
@Composable
fun DemandTheme(content: @Composable () -> Unit) {
  CompositionLocalProvider(
    LocalRippleTheme provides EverliRippleTheme.Default,
    LocalDimensions provides dimensions(),
    LocalCheckboxTheme provides DefaultCheckboxTheme,
  ) {
    DefaultTheme(content = content)
  }
}

// Everli (generic) theme
@Composable
fun DefaultTheme(content: @Composable () -> Unit) {
  EverliTheme(
    typography = DefaultTypography,
    buttonTheme = DefaultButtonTheme,
    radiusTheme = DefaultRadiusTheme,
    iconTheme = DefaultIconTheme,
    textTheme = DefaultTextTheme,
    content = content,
  )
}

It’s really up to you how you structure your theme and what you put in it. This is what worked for us and we’re still tinkering it. We would recommend at least having the basic stuff such as:

  1. 🎨 Colors
  2. 📐 Dimensions
  3. 🔤 Typography

2. Leverage the Interoperability API

Truth is, it’s not possible to migrate everything to Compose in one go. Most mature apps are fully functional using XML, and it’s not feasible to rewrite everything in Compose overnight.

As a result, you’ll often have a mix of View and Compose in your app. Fortunately, there are some excellent APIs to assist you in this transition. For further details, refer to the official documentation.

Notably, we made extensive use of AndroidView and ComposeView, which allow you to embed Views in Compose and vice-versa. Also worth mentioning is AbstractComposeView, which facilitates the reuse of ComposeView inside XML layouts. I recall that during our migration process, we once had a regular .xml layout with a ComposeView, and inside that, ComposeView was an AndroidView 👀

As an example, one of the first widgets we migrated was the Button. As you might imagine, it was also the most used widget in our app:

/**
 * View System wrapper for [EverliButton()]
 */
class EverliButton @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0,
) : AbstractComposeView(context, attrs, defStyleAttr) {

  /*
    Fields with attributes (usable in xml and code)
   */
  var variant by mutableStateOf(ButtonVariant.PRIMARY)
  var size by mutableStateOf(ButtonSize.NORMAL)
  var shape by mutableStateOf(ButtonShape.NORMAL)
  var icon by mutableStateOf<Drawable?>(null)
  var iconTint by mutableStateOf<Int?>(null)

  /*
    Fields without attributes (usable just in code)
  */
  var text by mutableStateOf<AnnotatedString?>(null)
  var textStyle by mutableStateOf<TextStyle?>(null)
  var textDecoration by mutableStateOf<TextDecoration?>(null)
  var contentColors by mutableStateOf<StateColor?>(null)
  var iconSize by mutableStateOf<Dp?>(null)
  var backgroundColor by mutableStateOf<StateColor?>(null)
  var contentPadding by mutableStateOf<PaddingValues?>(null)

  private var onClick by mutableStateOf({})
  private var isButtonEnabled by mutableStateOf(isEnabled)

  init {
    context.theme.obtainStyledAttributes(
      attrs,
      R.styleable.EverliButton,
      0,
      0,
    ).apply {
      try {
        variant = enumValueOfOrFallback(getInt(R.styleable.EverliButton_button_variant, variant.ordinal), ButtonVariant.PRIMARY)
        shape = enumValueOfOrFallback(getInt(R.styleable.EverliButton_button_shape, shape.ordinal), ButtonShape.NORMAL)
        size = enumValueOfOrFallback(getInt(R.styleable.EverliButton_button_size, size.ordinal), ButtonSize.NORMAL)
        isButtonEnabled = getBooleanOrTrue(R.styleable.EverliButton_enabled)
        icon = getDrawable(R.styleable.EverliButton_icon)
        iconTint = getResourceIdOrNull(R.styleable.EverliButton_icon_tint)
      } finally {
        recycle()
      }
    }
  }

  @Composable
  override fun Content() {
    DemandTheme {
      EverliButton(
        onClick = onClick,
        variant = variant,
        shape = shape,
        size = size,
        text = text,
        enabled = isButtonEnabled,
        icon = icon?.let { rememberDrawablePainter(drawable = it) },
        iconTint = iconTint?.let { colorResource(id = it) },
        iconSize = iconSize,
        contentDescription = contentDescription?.let { it.toString() },
        textStyle = textStyle,
        textDecoration = textDecoration,
        contentColor = contentColors,
        backgroundColor = backgroundColor,
        contentPadding = contentPadding,
      )
    }
  }

  fun onClick(block: (() -> Unit)) {
    onClick = block
  }

  override fun setEnabled(enabled: Boolean) {
    super.setEnabled(enabled)
    isButtonEnabled = enabled
  }

}

☝️ This is the View wrapper for the Composable Button. It exposes as many attributes as possible, allowing you to use it in XML.

ℹ️ One final note: When using ComposeView, be mindful of the ViewCompositionStrategy you select. We encountered several production crashes 💥 due to an incorrect strategy being implemented. For further guidance, refer to the official documentation.

3. Be mindful of Recomposition

If you started looking into Compose, chances are you already stumbled across the idea of recomposition.

Basically, it’s the process of calling your composable functions again when inputs change. This can have a huge impact on your app performance, so while Compose is quite optimized on its own to reduce recomposition, it’s still a good idea to keep an eye on it.

We can recommend two things:

1. Using Android Studio Layout Inspector

That’s a built-in tool to debug the recomposition count.

layout inspector
Layout Inspector

2. Using immutable data classes as parameters for composables

Using immutable data classes in Jetpack Compose greatly optimizes the recomposition process. Since the data in these classes is unchangeable post-creation, any modification results in a new instance.

Compose, therefore, only needs to check if the current instance differs from the previous one to determine whether to recompose the UI. This approach reduces unnecessary recompositions, making your app more efficient ⚡️ and your code more predictable.

// DON'T use multiple variables for your state
var title by mutableStateOf<AnnotatedString?>(null)
  private set

var subtitle by mutableStateOf<AnnotatedString?>(null)
  private set

var backgroundColor by mutableStateOf(EverliColor.WHITE.toColor())
  private set


// DO use a single data class for your UI state
data class TitleUIState(
  val title: AnnotatedString? = null,
  val subtitle: AnnotatedString = EmptyAnnotatedString,
  val backgroundColor: Color = EverliColors.White,
)

You then make use of .copy() method of data classes to create new instances of your UI state.

Another nice variant is using sealed classes to somehow structure your different state types:

sealed class RouletteModalState {

  data class Start(val roulette: RouletteResponse) : RouletteModalState()
  data class Reward(val rouletteSpin: RouletteSpinResponse) : RouletteModalState()

  object Loading : RouletteModalState()
  object Spin : RouletteModalState()
  object Error : RouletteModalState()
}
UI State with when statement
Using `when` to switch between the states

4. A Big Push for MVVM & Hilt

To provide some background, discussions about starting with Compose have been ongoing in our team for some time. In fact, almost 3 years ago, I wasn’t even part of the team!

At that time, the app was a blend of MVP and MVVM.

However, as we began to take Compose more seriously, we also delved deeper into our app’s architecture. We came to the realization that it was essential to fully embrace MVVM and Hilt. This is because they complement the advantages of Compose.

A significant challenge we encountered was transitioning our Widget’s Presenter to ViewModels. Previously, we used a Factory to instantiate the Presenter and then pass it to the View.

For instance, on a page with 4 buttons, each button had its own Presenter. Fast-forwarding to our adoption of Compose, we needed to integrate both Compose and MVVM.

The looming question was, how do we inject the ViewModel into the @Composable? Fortunately, there’s a viewModel() function available that facilitates retrieving the ViewModel within a @Composable.

⚠️ HOWEVER, there’s a nuance to be aware of 👀

The aforementioned function consistently returns the same ViewModel instance (based on the prevalent LocalViewModelStoreOwner). Hence, if a page contained 4 distinct buttons, all would be reliant on the same ViewModel. While feasible, this approach often leads to convoluted code.

We contemplated this dilemma for some time. Fortunately, during this period, Hilt provided an update and introduced a key-based Factory for ViewModels. This meant we could utilize the viewModel() function accompanied by a key parameter, ensuring the correct ViewModel instance was retrieved.

Below, we provide a sample illustrating our current usage.

// stateful button
@Composable
fun ButtonWidget(
  button: Button,
  modifier: Modifier = Modifier,
) {
  val viewModel = viewModel<ButtonViewModel>(
    key = button.widgetId, // make sure each button creates his own view model
  )
  viewModel.bindButton(button)

  // stateless button
  DemandTheme {
    EverliButton(
      variant = viewModel.variant,
      size = viewModel.size,
      shape = viewModel.shape,
      backgroundColor = viewModel.backgroundColors.toStateColorOrNull(),
      contentColor = viewModel.contentColors.toStateColorOrNull(),
      onClick = viewModel::onClicked,
      modifier = modifier.then(
        Modifier
          .applyIf(
            condition = viewModel.hasPadding,
            modifier = Modifier
              .paddingVertical(EverliTheme.dimensions.elementSpacing.small)
              .paddingHorizontal(EverliTheme.dimensions.elementSpacing.medium),
          )
          .applyIf(viewModel.fillMaxWidth) { fillMaxWidth() },
      ),
    )
  }

}

The key takeaway here is that if you’re considering a migration to Compose, it’s also prudent to reevaluate your architecture to determine its compatibility. While MVVM isn’t the only viable approach, it’s undoubtedly a strong starting point.

5. UI Testing Just Got a Whole Lot Easier!

I’m pretty sure as an Android Dev you are already faced with the tedious task of writing some UI tests for your code. The main pain point is the whole configuration you have to do to have the tests running.

Moreover, let’s be honest, the testing API is not the best out there. I mean it’s not bad, but nobody has said: “I really had a good time with this UI test”

On the other hand, it’s much easier to test singular @Composables functions and the testing API is quite nice and intuitive.

A full working UI test in a single file (using Hilt to handle DI):

@RunWith(AndroidJUnit4::class)
@LargeTest
@HiltAndroidTest
class ImageWidgetTest {

  @get:Rule
  var hiltRule = HiltAndroidRule(this)

  @get:Rule
  val rule = createAndroidComposeRule<HiltTestActivity>()

  private val dataProvider: ImageDataProvider = ImageDataProvider()

  @Inject
  lateinit var navigationHandler: NavigationHandler

  @Before
  fun setup() {
    hiltRule.inject()
  }

  @Test
  fun image_with_click_action() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext

    rule.setContent {
      CompositionLocalProvider(LocalContext provides context) {
        DemandTheme {
          ImageWidget(image = dataProvider.image0)
        }
      }
    }

    rule.onRoot().onChild().assertHasClickAction()
    rule.onRoot().performClick()

    verify(navigationHandler, times(1)).navigateTo(dataProvider.image0.externalUrl!!)
  }

}

So if you ever avoided writing UI tests because it was unpleasant before, give it a try now.

ui tests meme

6. Go beyond Google samples

As you ride the Compose wave, don’t limit yourself to just the official Google repositories. Think of them as the starting point, not the whole journey. The dev community is buzzing with insightful articles, tutorials, and valuable code snippets that could give you a fresh perspective or even help solve a tricky problem.

We can recommend Droidcon page as a good place to look for ideas. I key article we liked is “Sending ViewModel events to the UI in Android”

🔜 Conclusions and what’s next

I’m pretty sure we forgot to mention more tips and tricks, but hey there’s more time for it. Currently, we are looking into navigation, re-writing all fragments, improving our view models event handling and state holding, and more complex topics to advance our migration.

See you in our next blog post!


As a side note, we are looking for more people to join us 👀, check our current openings.

Author: @GhimpuLucianEduard, and special thanks to @andrea157 for finding and pushing inside our project a lot of the above guidelines.

Leave a Reply

Your email address will not be published. Required fields are marked *