Track Compose Recomposition Causes with an IR-Based Monitoring Plugin
As Android development shifts from the traditional View system to Jetpack Compose, the more modern Compose approach brings a distinctly different development experience. There are also significant differences in tooling and performance tracing workflows. Besides frame-rate monitoring, the View system typically relies on ANR reports to identify problem stack traces. However, Compose updates UI based on data changes, so ANR stack traces may not directly point to the code causing the issue.
Factors Affecting Compose Page Performance
Compose stores page state in memory. When page state changes, it triggers recomposition of the corresponding parts of the UI. The following factors affect rendering performance:
- Frequency of recomposition triggers
- Whether method parameters are stable
- The UI hierarchy structure that triggers state changes
Stability is one of the factors Compose uses to decide whether recomposition can be skipped. In daily development, declaring values that won't change as val can avoid unnecessary state updates and recomposition. Open-source IDE plugins can also display directly in the editor whether a composable function's parameters are stable.
The most common cause of Compose performance issues is frequent state changes triggering excessive recomposition. Identifying frequently changing state is key to finding the root cause.
Monitoring UI Updates Triggered by Compose
One approach is to collect all states and parameters, compare their values when a composable function recomposes, and print the differences to logcat. Tools like Rebugger already provide this functionality, with an IDE plugin that can add monitoring code.
Minimizing Intrusiveness by Automatically Injecting Monitoring Code
In most cases, monitoring state changes is unnecessary during daily development. Monitoring is only needed when performance issues arise. In other cases, keeping instrumentation code out of the source code helps keep the codebase clean.
Adding and removing monitoring code isn't the only maintenance burden. As business logic changes, composable functions may gain or lose state variables and parameters, all of which need manual maintenance. This adds extra work. Could monitoring code instead be automatically injected at compile time, excluded from release builds, and only inserted when a feature flag is enabled?
In the Android build pipeline, ASM-related tools (https://asm.ow2.io/) can be used to inject monitoring code into compiled bytecode.
compileDebugKotlin / compileDebugJavaWithJavac
│
│ finalizedBy
▼
┌───────────────────────────────────────────────────────────────────────┐
│ Pass 1 — generateVariableInfoDebug (GenerateVariableInfoTask) │
│ reads build/tmp/kotlin-classes/debug/**/*.class │
│ for each method annotated @ComposableMonitor: │
│ record every local var (name, descriptor, signature, slot, │
│ isMutableState, line of its last ASTORE) │
│ writes build/asm-analysis/<Class>_<method>.json │
└───────────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ Pass 2 — transformDebugClassesWithAsm (ComposableStateMonitorLogger…) │
│ for each method annotated @ComposableMonitor: │
│ load its JSON, then inject bytecode that │
│ • builds an ArrayList<Pair<String, Any>> on entry │
│ • adds every parameter on entry │
│ • adds every State/MutableState local right after it's stored │
│ • calls CollectAndPrintValues(...) before every exit │
└───────────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ Runtime — composemonitorhelper │
│ CollectAndPrintValues → unwrap MutableState, strip "$delegate" │
│ Inspector → remember previous values, diff, Log.d("Inspector", …) │
└───────────────────────────────────────────────────────────────────────┘
The implementation starts with an annotation: simply add @ComposableMonitor to the composable function whose state changes need monitoring. The bytecode injection plugin can be enabled or disabled at any time via configuration.
The implementation performs two passes of bytecode processing at compile time. The first pass collects the locations of state variables; the second injects monitoring code. Two passes are required because ASM must first complete a traversal of the method to obtain the full set of local variable symbols and the frame layout. The injected code has two main responsibilities: collecting all states and method parameters, and comparing old and new values when the composable recomposes, to determine which state or parameter changed.
The main challenge is that the JVM bytecode generated by composable functions, after being compiled by the Compose compiler, can differ significantly from its source code. The compiler adds checks, such as dirty checks for UI updates. Therefore, instrumentation must find the correct insertion points without breaking variable indices in the stack frame.
A Better Approach: Compiling for Compose Multiplatform
Although ASM makes it possible to automatically inject monitoring code on Android, this approach is still not perfect. The instrumentation mechanism relies on fixed patterns in the bytecode generated for composable functions. Since the Compose compiler continues to evolve and optimize with version updates, matching these fixed patterns may not be compatible with future changes. Furthermore, this monitoring tool only supports Android, while Compose Multiplatform is now widely adopted and needs broader platform support. Therefore, a better code injection method is needed.
The core compilation principle of Kotlin Multiplatform (KMP) is "write once, run anywhere" — code runs natively on each platform. Unlike Flutter, which uses the Skia/Impeller engine, or React Native, which uses a JavaScript bridge to wrap a virtual machine on non-native platforms, KMP uses a unified compiler frontend to analyze code, then uses different compiler backends to compile the same Kotlin source into native binaries or bytecode for each platform.
This makes it possible to inject code by modifying the IR generated by the Kotlin frontend.
Kotlin source ──► FIR (frontend) ──► IR
│
▼
┌──────────────────────────────────────┐
│ composemonitor IR extension │ inserts plain @Composable calls
│ (MonitorFunctionTransformer) │ before every exit
└──────────────────────────────────────┘
│ -Xcompiler-plugin-order
▼
┌──────────────────────────────────────┐
│ Compose compiler IR extension │ adds $composer/$changed, groups,
│ │ skipping, to user code AND ours
└──────────────────────────────────────┘
│
▼
JVM / Android bytecode, Kotlin/Native, wasm, js backends
The main challenge in implementing the IR compiler extension is that the IR editing plugin API differs between Kotlin versions. In earlier versions, these APIs also changed due to instability. Given that the Compose Monitor compiler plugin needs to support projects using different Kotlin versions, it has been adapted for most Kotlin 2.0 and later versions.
user applies io.github.hellsong.composemonitor (Java, KGP 2.0 API)
│ reads the project's Kotlin version, e.g. 2.2.10
▼
KotlinVersionSupport.lineFor("2.2.10") → k22
│ getPluginArtifact()
▼
KGP resolves composemonitor-compiler-k22
= shared/ + compat/{params, lookup-reference, registrar-legacy}
compiled against Kotlin 2.2.0
▼
runs inside kotlinc 2.2.10 and transforms the IR before Compose
I created a new Gradle plugin for this, which has been published on GitHub. You can find the source code and usage instructions below:
GitHub - hellsong/ComposeStateMonitor: initial commit
Welcome to search and follow the official account 「稀有猿诉」 for more quality articles!
Protect originality, please do not repost!