Compose Guard: Detecting Regressions In Jetpack Compose

Joe RoskopfJoe Roskopf
4 min read

As Jetpack Compose becomes more widely used across Android (and Multiplatform projects!), detecting regressions via tooling becomes more important to shift left and detect regressions earlier in the development cycle instead of relying on manually catching errors.

These types of regressions are helpful to catch for a multitude of reasons. More context can be found here, but below are a few high level reasons:

  1. If you see a function that is restartable but not skippable, it’s not always a bad sign, but it sometimes is an opportunity to do one of two things:

    1. Make the function skippable by ensuring all of its parameters are stable

    2. Make the function not restartable by marking it as a @NonRestartableComposable

  2. Default expressions should be @static in every case except for the following two cases:

    1. You are explicitly reading an observable dynamic variable. Composition Locals and state variables are an important example of this. In these cases, you need to rely on the fact that the default expression will be re-executed when the value changes

    2. You are explicitly calling a composable function, such as remember. The most common use case for this is state hoisting

  3. Not all classes need to be stable, but a class being stable unlocks a lot of flexibility for the compose compiler to make optimizations when a stable type is being used in places, which is why it is such an important concept for compose.

To help detect regressions via tooling, you can utilize a new Gradle Plugin called Compose Guard. Compose Guard allows you to generate compose compiler metrics and detect regressions to those metrics on PRs or release builds on CI.

Compose Guard currently checks for numerous regressions that would be useful to a team:

  • New restartable but not skippable @Composables are added

  • New unstable classes are added (only triggers if they are used as a @Composable parameter)

  • New @dynamic properties are added to a @Composable

  • New unstable parameters are added to a @Composable

By being able catch these regressions to the UI layer in PRs, you can ensure better performance without developers having to manually catch these types of issues in a PR review.

Adding Compose Guard To Your Project

Compose Guard is available via Maven Central. To start, add the plugin to your root build.gradle.kts file.

Maven Central Version

plugins {
    id("com.joetr.compose.guard") version "<latest version>" apply false
}

Then, you can apply the plugin to any subsequent modules that contain your Compose code. This can be simplified by utilizing convention plugins as well. I will use Now In Android as an example:

AndroidApplicationComposeConventionPlugin.kt

import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getByType

class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("com.android.application")

            // apply Compose Guard Plugin
            pluginManager.apply("com.joetr.compose.guard")

            val extension = extensions.getByType<ApplicationExtension>()
            configureAndroidCompose(extension)
        }
    }
}

AndroidLibraryComposeConventionPlugin.kt

import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin

class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("com.android.library")

            // apply Compose Guard Plugin
            pluginManager.apply("com.joetr.compose.guard")

            val extension = extensions.getByType<LibraryExtension>()
            configureAndroidCompose(extension)
        }
    }
}

Now that you've applied the plugin, you will notice new Gradle tasks appear for the project. Compose Guard adds two new tasks for each variant as well as one 'clean' task.

  • <variant>ComposeCompilerGenerate (example releaseComposeCompilerGenerate)

    • Generate golden compose metrics to compare against
  • <variant>ComposeCompilerCheck (example releaseComposeCompilerCheck)

    • Generates new metrics and compares against golden values
  • composeCompilerClean

    • Deletes all golden and check compiler metrics

You can execute a root level prodReleaseComposeCompilerGenerate task to generate the metrics for every module in Now In Android (but you can also just run :app:prodReleaseComposeCompilerGenerate to generate the the metrics for just the :app module.

After running the above task, you should now see metrics generated for every module under the compose_reports folder of that module.

The folder name can be customized via Kotlin DSL in the module's build file.

composeGuard {
    outputDirectory = layout.projectDirectory.dir("custom_dir").asFile
}

Now that the golden metrics are generated, let's add an unstable class to a @Composable! I picked a random @Composable in the NIA app to demo this feature.

data class NewUnstableClass(var unstableParameter: Int)

@Composable
private fun LoadingState(modifier: Modifier = Modifier, unstableParameter: NewUnstableClass) {
    NiaLoadingWheel(
        modifier = modifier
            .fillMaxWidth()
            .wrapContentSize()
            .testTag("forYou:loading"),
        contentDesc = stringResource(id = R.string.feature_bookmarks_loading),
    )
}

Now, if I run ./gradlew prodReleaseComposeCompilerCheck, you should see the build fail!

* What went wrong:
Execution failed for task ':feature:bookmarks:prodReleaseComposeCompilerCheck'.
> New unstable classes were added! 
  ClassDetail(className=NewUnstableClass, stability=UNSTABLE, runtimeStability=UNSTABLE, fields=[Field(status=stable, details=var unstableParameter: Int)], rawContent=RawContent(content=unstable class NewUnstableClass {
    stable var unstableParameter: Int
    <runtime stability> = Unstable
  }))
  More info: https://github.com/androidx/androidx/blob/androidx-main/compose/compiler/design/compiler-metrics.md#classes-that-are-unstable

By adding this check to PR and release build jobs on CI, you can start to detect these types of UI regressions!

Summary

By adding Compose Guard to your project, you can start to detect UI regressions via tooling. These UI regressions have a multitude of performance implications for a project, so it's important to try and catch issues as early as possible.

Notice

I, Joseph Roskopf, am one of the authors of this Gradle Plugin along with Joshua Swigut.

0
Subscribe to my newsletter

Read articles from Joe Roskopf directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Joe Roskopf
Joe Roskopf

Hey! I’m Joe! I’m an Android developer who is passionate about the human side of software development.