跪拜 Guibai
← All articles
Android · Kotlin

CMP 1.8.0: Where to Draw the Shared Boundary, How to Sync Interop, and When to Roll Back

By 传奇开心果 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

CMP's stable release lowers the entry cost, but without explicit boundary rules, interop contracts, stage-level performance diagnosis, and a fallback path that doesn't reference shared types, teams will sink time into shared code that can't ship or can't go back. These four checks turn 'CMP is usable' into a concrete go/no-go decision for a given feature or page.

Summary

CMP 1.8.0's benchmark numbers look good—startup comparable to native, scrolling on par with SwiftUI, ~9 MB size increase, and 96% of iOS developers reporting no major performance issues. But those are entry qualifications, not engineering decisions. The article lays out the three questions that actually determine migration success: where the shared boundary goes, whether the interop layer implements its synchronization contract, and whether performance problems can be attributed to the correct stage.

On the boundary, `commonMain` must compile to iOS and wasmJs with zero modifications; any `android.*` import, `LocalContext`, or Hilt reference breaks it. State belongs in `commonMain` via StateHolder + StateFlow, and the boundary layer defines contracts, not glue. Interop is treated as a synchronization contract between two rendering pipelines, not a wrapper: fixed bounds to avoid dual layout, `requestDisallowInterceptTouchEvent` on Android and `shouldRecognizeSimultaneouslyWith` on iOS for gestures, bidirectional focus bridging, and `remember`-stabilized lambdas to prevent native view recreation.

Performance attribution follows the TechnoAlpin case: zero recompositions and single-digit frame rate pointed to the drawing stage, where a Skia layer regression in 1.8.0 forced full subtree redraws; CMP-8491 fixed it in 1.9.0. Baselines must come from the same device and native implementation, with separate thresholds for Web. Finally, rollback is only real if the native fallback doesn't `import shared` and a verification checklist includes actually reverting both platforms.

Takeaways
— CMP 1.8.0 shows startup comparable to native, scrolling on par with SwiftUI, ~9 MB package increase, and over 96% of iOS developers reporting no major performance problems.
— The hard rule for `commonMain`: it must compile to iOS and wasmJs with zero modifications; platform API leaks (android.* imports, LocalContext, Android ViewModel) fail this test.
— State should live in `commonMain` as immutable UiState exposed via StateFlow, using StateHolder instead of ViewModel unless using lifecycle-viewmodel-compose 2.10.0+ with an initializer.
— Hilt only works in androidMain; use Koin or Metro for shared DI, and inject platform-specific implementations at the platform layer.
— AndroidView and UIKitView are synchronization contracts between Compose's retained-mode LayoutNode scene graph and native immediate-mode layout, both fitting in one 16ms frame budget.
— Fix dual layout by giving native views fixed bounds via Modifier.fillMaxSize() or size(), avoiding wrapContentSize.
— Android gestures require `requestDisallowInterceptTouchEvent` (disallow except on ACTION_UP); iOS requires `shouldRecognizeSimultaneouslyWith` coordination.
— Focus is two independent state machines; bridge iOS text field focus with explicit callbacks that call focusRequester.requestFocus() and focusManager.clearFocus().
— Capture unstable lambdas in factory/update callbacks causes native view recreation on every recomposition; stabilize with `remember` and make update idempotent.
— TechnoAlpin: zero recompositions but single-digit frame rate traced to the drawing stage—CMP 1.8.0's Skia layer invalidated recorded content on any layer property change; fixed in CMP 1.9.0 (CMP-8491) without offscreen caching.
— Use offscreen ImageBitmap caching to patch drawing-stage cost, but attribute to the stage first: composition, layout, or drawing.
— iOS Metal shader compilation on the main thread can cause spikes; average frame time hides them.
— Performance baselines must come from the same device and same scenario native implementation, measured 10 times for startup median.
— LazyColumn without a stable key uses positional index; inserting an item recomposes all subsequent rows—use business IDs.
— Web needs a separate baseline; measure wasm transfer size, first-screen load split into download/compile/instantiate/first-frame, and average FPS.
— Android host seam is setContent/ComposeView; iOS uses ComposeUIViewController + UIViewControllerRepresentable, requiring all three iOS targets and not double-embedding the framework.
— The native fallback must not `import shared` or reference Compose types; a rollback path only counts if you actually run it (checklist step 5).
— Stop optimizing when still >15% worse than native after standard fixes, when core interaction is strongly platform-coupled, when accessibility fails on critical paths, or when stacks land in Skia/Kotlin-Native.
Conclusions

The article treats the 'zero modifications' compilation check as the primary boundary test, which is stricter than most teams use—it forces platform-specific behavior into explicit `expect/actual` seams rather than letting leaks slip through.

The interaction between the 16ms frame budget and dual layout explains why even well-written interop code can feel sluggish: two independent layout engines each measuring in the same frame is a structural cost, not a tuning issue.

TechnoAlpin's case shows that the first instinct (recomposition) was wrong and the real cost was in drawing; the lesson generalizes: before optimizing, prove which stage the bottleneck belongs to, or you risk patching the wrong layer.

CMP's performance acceptance thresholds are explicitly tied to native baselines on the same device, which means the framework isn't judged in a vacuum—it's judged against the platform's own best case, a realistic but demanding standard.

The verification checklist's final step—actually reverting to native—is the most underused practice in cross-platform adoption; most teams only plan for fallback, but the article insists fallback must be exercised to count.

CMP is framed as a depth-selectable technology: the decision to share more isn't based on what Sony or TechnoAlpin did, but on four verifiable engineering conditions that any team can check.

The 15% threshold for stopping optimization is presented as a heuristic, not a universal rule, which is a refreshingly honest stance—it puts responsibility back on the team to define what acceptable degradation means.

Concepts & terms
StateHolder + StateFlow
A pattern for shared state in `commonMain`: a plain class holds a `MutableStateFlow` and exposes it as read-only `StateFlow`, with UI state as an immutable data class. It avoids ViewModel's platform-specific dependencies while giving Compose a reactive state source.
SharedKernel boundary layer
A thin layer (e.g., 0.2% of codebase) that explicitly defines which parts of the UI are rendered by CMP, which by legacy native code, and what capabilities the legacy layer exposes to the shared layer. It works only when the module boundaries are cleanly separable.
Synchronization contract (interop)
The implicit agreement between Compose's retained-mode scene graph and a native immediate-mode view system: both must complete layout and drawing within the same 16ms frame budget, and events like gestures and focus must be forwarded bidirectionally without contention.
Layer invalidation (Skia regression)
In CMP 1.8.0, changing any layer property (like `rotationZ` in graphicsLayer) marked the layer's recorded content invalid, forcing the entire subtree to redraw each frame. Fixed in 1.9.0 via CMP-8491.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗