Sitemap

Compose UI Performance Secrets (Part 3): The Expert’s Toolkit

8 min readMay 18, 2025

Alright performance warriors, welcome back! In part 2, we explored advanced techniques to make your UIs sing. If you thought we were done, buckle up! Part 3 is where we hand you the keys to the Compose engine room. The expert’s toolkit for proactive performance tuning and mastering complex UI construction.

We’re moving beyond reactive fixes. Today, we’re talking about shaping your app’s performance from the build process itself, deciphering the compiler’s secrets, and architecting custom UIs that are both powerful and blazingly fast.

Ready to go full expert mode? 🚀

(AI Generated)

Mastering Baseline Profiles

You’ve probably heard about Baseline Profiles. You might have even enabled the default one that comes with Compose. But true mastering means treating them not as a magic bullet, but as precision tool to supercharge your app’s startup and critical user journeys (CUJs).

🤔 The Problem?

Sluggish app startup times, janky scrolling on first launch or stuttering during key interactions (like opening a crucial screen). These often happen because code needs to be Just-In-Time (JIT) compiled on the device, right when you need it most.

✅ Proactive AOT with Custom Baseline Profiles

Baseline Profiles allow you to tell the Android Runtime which code paths are critical. This code is then Ahead-Of-Time (AOT) compiled during app installation or background dexopt, making it much faster when the user actually executes it.

🛠️ Here’s how to Setup

Setup your Macrobenchmark Module. This is non-negotiable. You need a dedicated module in your project to generate and test profiles. Add androidx.benchmark.macro.junit4 library.

// project/build.gradle.kts
plugins {
id("androidx.benchmark.macro") version "1.2.4" apply false // Or latest version
}

// app/build.gradle.kts
dependencies {
// ... other dependencies
implementation("androidx.profileinstaller:profileinstaller:1.4.1") // Or latest version
}

// :macrobenchmark/build.gradle.kts
plugins {
id("com.android.library") // or .application (either works)
id("androidx.benchmark.macro")
}
android {
defaultConfig {
// ...
testInstrumentationRunner "androidx.benchmark.junit4.AndroidBenchmarkRunner"
}
// ... your other android configurations
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.3.4") // Or latest version
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
// ... your other dependencies
}

Next, write a BaselineProfileGenerator, which is a UI Automator test that navigates through your app’s critical user journeys.

// In your :macrobenchmark module (src/androidTest)
@ExperimentalBaselineProfilesApi
@RunWith(AndroidJUnit4::class)
class MyBaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()

@Test
fun generateBaselineProfile() = baselineProfileRule.collect(
packageName = "com.tanishranjan.my_cool_app", // Your app's package name
// Optional: Use a ProfileVerificationMode
// profileBlock = { ... } // For more complex scenarios
) {
// This block defines the critical user journey
pressHome()
startActivityAndWait() // Waits for the main activity to render

// TODO: Add interactions for your CUJs
// Example:
// device.findObject(By.text("Open Important Screen")).click()
// device.wait(Until.hasObject(By.text("Important Content")), 5_000)
// device.findObject(By.res("my_recycler_view"))?.also {
// it.setGestureMargin(device.displayWidth / 5)
// it.fling(Direction.DOWN)
// it.fling(Direction.UP)
// }
}
}

Run the generator test on a rooted physical device or emulator (API 28+ for user builds, API 23+ for rooted). The generated profile (baseline-prof.txt) will appear in your macrobenchmark module’s build outputs. Copy this to src/main/baseline-prof.txt in your app module.

You can verify the impact by writing another Macrobenchmark test, this time using StartupTimingMetric or FrameTimingMetric , and run it with and without the profile (CompilationModel.Partial() vs CompilationMode.None()).

✨ How it works?

Baseline Profiles, generated and compiled into binary form within your app’s APK/AAB, and uploaded to Google Play. Google Play ships these profiles alongside the APK, allowing the device’s ART runtime to perform Ahead-of-Time (AOT) compilation of critical methods during installation, significantly speeding up app launch and improving rendering performance. This system works in conjunction with Cloud Profiles for ongoing optimization based on user behavior.

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

🧠 Pro Tip: Baseline Profiles aren’t a “set it and forget it” solution. As your app evolves, your CUJs might change. Regenerate and re-verify your profiles periodically, especially before major releases. Focus on interactions that affect the majority of users.

Decoding Compose Compiler Metrics

Jetpack Compose’s compiler is a marvel, but sometimes its decisions about stabilitya nd skippability can feel like a black box. When you see unexpected recompositions, the compiler metrics are your Rosetta Stone.

🤔 The Problem?

Composables recomposing more than expected, parameters being treated as “unstable” (as discussed in part 1) leading to unnecessary work, and a general feeling that you’re fighting the recomposition system.

✅ Using Compiler Reports as a Diagnostic Superpower

The compose compiler can generate detailed reports about every composable function and class it processes, telling you if it’s skippable, restartable, and if its parameters are stable.

🛠️ Here’s how to setup

Enable compiler reports by adding these flags to your app module’s build.gradle.kts file.

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.addAll(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_compiler_reports"
)
compilerOptions.freeCompilerArgs.addAll(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_compiler_reports"
)
}

Now, after a clean build (./gradlew clean assembleRelease), you’ll find the reports in your module’s build/compose_compiler_reports directory. The important files here will be *_composables.txt — which details about each composable function, and *_classes.txt — which details about the stability of classes used as parameters.

🕵️ How do you read these reports?

Check for the following markings:

  • restartable skippable : This is the dream! Your composable can be skipped if its inputs haven’t changed, and it can act as a scope for restarting recomposition.
  • unstable : Uh oh. If a parameter is marked unstable , the composable often won’t be skippable. The report will tell you why (e.g. C::MyAnnoyingClass is not stable).

Common fixes:

  • Annotate classes with @Immutable or @Stable if they truly are.
  • Use immutable collections for parameters.
  • Ensure lambda parameters are stable or wrapped with remember .
  • Check the official docs for detailed guidance on stability.

🧠 Pro Tip: Make checking compiler reports a part of your development workflow, especially when dealing with complex state or custom data types. Some teams even add scripts to parse these reports in CI to catch stability regressions early. Fixing these can significantly reduce your recomposition count.

Building Custom Layout efficiently from Scratch

When Compose’s built-in layouts (Row, Column, Box, LazyColumn, etc.) don’t quire cut it for your unique UI vision, you’ll venture into creating custom Layout(...) composables. This is powerful, but with great power comes great responsibility — especially for performance.

🤔 The Problem

Custom layouts can easily become janky if not implemented carefullly. Common issues include performing too many measurement passes, inefficient calculations during measurement or placement, or misusing SubcomposeLayout .

✅Deeply Understanding the Layout Model & Optimization Patterns

Intrinsic Measurements: The Smart Sizing Hint

Intrinsics (minIntrinsicWidth, maxIntrinsicWidth, minIntrinsicHeight, maxIntrinsicHeight) allow a parent to query a child’s size before definitive measuring it with specific constraints.

They can help avoid multiple measurement passes for children, especially in layouts where a child’s size can influence the size of other children or the parent itself (e.g., a Row trying to give equal width to children based on the widest one)

When implementing MeasurePolicy for your custom layout, override these intrinsic functions if your layout has specific logic for them. For example, the minIntrinsicWidth of a custom row might be the sum of its children’s minIntrinsicWidths.

SubcomposeLayout: Powerful, But Use Wisely

SubcomposeLayout allows you to defer the composition and measurement of some children until a later phase, often based on the results of measuring other children or the available space (e.g., BoxWithConstraints uses it)

Note: Subcomposition has overhead. Each subcomposition is a new composition scope. Overuse or deep nesting can impact performance.

Use it when you truly need to compose content based on intermediate layout data (e.g., measuring a main content block, then subcomposing a dependent footer based on remaining space).

Try to minimize the amount of content you subcompose and consider if you can achieve a similar effect with custom MeasurePolicy logic or by passing size information down through state or composition locals if the content is already composed.

Efficient Measurement & Placement Logic

The measure block in MeasurePolicy (or LayoutModifierNode) should be as lean as possible. Avoid complex calculations or allocations here if they can be done elsewhere or cached.

Children are measured by calling measurable.measure(constraints) . Be precise with the Constraints you pass.

The placeables list gives you the measured children. In the layout block (where you call placeRelative or placeAbsolute), keep placement logic straightforward.

Remember that the measure block can be called multiple times during a single frame if the parent’s constraints change or intrinsics are involved.

// Conceptual example of a custom layout using intrinsics
@Composable
fun MyCustomRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
// Example: Max intrinsic height might be the max of children's max intrinsic heights
val maxIntrinsicHeight = measurables.maxOfOrNull { it.maxIntrinsicHeight(constraints.maxWidth) } ?: 0

// ... (actual measurement logic)
val placeables = measurables.map { it.measure(constraints) }
// ... (calculate width and height)
val totalWidth = placeables.sumOf { it.width }
val maxHeight = placeables.maxOfOrNull { it.height } ?: 0

layout(totalWidth, maxHeight) {
var xPosition = 0
placeables.forEach { placeable ->
placeable.placeRelative(xPosition, 0)
xPosition += placeable.width
}
}
}
}

🧠 Pro Tip: Always profile your custom layouts using the Layout Inspector and system tracing, especially if they are used in scrollable lists or complex screens. Sometimes, a slightly simpler layout logic that avoids an extra measurement pass or a subcomposition can yield significant performance wins. Consider if Modifier.Node (discussed in part 2) could be a more performant way to achieve some custom layout modifications.

Phew! That was a whirlwind tour through some seriously advanced territory. By mastering Baseline Profiles, decoding compiler metrics, and building performant custom layouts, you’re truly operating at an expert level of Jetpack Compose development.

🔜 Coming Up Next… Part 4: Runtime Mastery & Fine-Tuning!

Believe it or not, there’s still more ground to cover in our quest for ultimate Compose performance!

Stay tuned as we continue to unlock the final secrets to making your Jetpack Compose UIs flawlessly smooth and efficient!

🚀 Final Thought

With Parts 1, 2 and 3 under your belt, you’re not just using Jetpack Compose; you’re starting to conduct it. These expert tools and techniques empower you to look “under the hood”, proactively optimize, and build sophisticated UIs that don’t just work beautifully but perform exceptionally.

The journey to performance mastery is ongoing. Keep experimenting, keep measuring, and keep pushing the boundaries of what’s possible.

If this deep dive was helpful, smash that 👏 and share your own expert tips or questions in the comments!

--

--

Tanish Ranjan
Tanish Ranjan

Written by Tanish Ranjan

Self-taught software developer: Android | Wear OS | .NET | Cross platform. Youngest programming language (TPL) creator. Sharing tech insights.

No responses yet