CMP 1.8.0: Where to Draw the Shared Boundary, How to Sync Interop, and When to Roll Back
Compose Multiplatform Shared Boundary in Practice: Architecture Contract, Interop Synchronization, and Fallback Design
Author: 传奇开心果
Abstract: CMP 1.8.0 stable removes the "can it be used in production" entry threshold, but it doesn't answer "how deep to share, where the boundary lies, how to verify performance, or whether fallback is actually viable." This article revolves around three engineering questions: where to draw the shared boundary, whether the interop layer's synchronization contract is correctly implemented, and whether performance issues can be attributed to the correct stage. For the boundary, it gives hard criteria for
commonMain(compiling to iOS and wasmJs with zero modifications), the StateHolder + StateFlow state contract, and the SharedKernel boundary layer's role as "defining contracts, not glue." For interop, it reducesAndroidView/UIKitViewto synchronization contracts between two rendering pipelines, and breaks down three problem classes: dual layout, bidirectional gesture and focus sync, and unstable lambdas triggering native view recreation, each with fix code. For performance, it uses TechnoAlpin's complete attribution chain to show how "zero recompositions and single-digit frame rate" gets attributed to the drawing stage, and gives acceptance methods using same-device native baselines, out-of-bounds actions, and a separate Web baseline. Finally it covers host integration and degradation: Android'ssetContent/ComposeView, iOS'sComposeUIViewController+UIViewControllerRepresentable, host-level feature flags and single-page fallback, and the line for stopping optimization.
Table of Contents
- Preface
- 1. Shared boundary: hard rules and state ownership in commonMain
- 2. Interop: not a wrapper, a synchronization contract
- 3. Performance: attribute to the stage first, then decide optimize or fallback
- 4. Host integration and degradation: making fallback a verifiable path
- 5. Scenarios where CMP is not suitable
- 6. Conclusion
Preface
CMP 1.8.0 data already proves "usable": startup is comparable to native, scrolling matches SwiftUI, package size increment is about 9 MB, and over 96% of iOS developers report no major performance problems. But that is an entry threshold, not an engineering decision. What really determines migration success are three other things: where the shared boundary is drawn, whether the interop layer's synchronization contract is correctly implemented, and whether performance issues can be attributed to the correct stage rather than patched at the wrong level.
The Sony and TechnoAlpin cases are used only to extract reusable technical patterns, not as evidence: the former shows how a four-language UI stack can be replaced without rewriting legacy code, the latter shows how "zero recompositions but single-digit frame rate" is precisely attributed. Code and configuration are based on verifiability; thresholds replace with the team's own native baseline.
1. Shared boundary: hard rules for commonMain and state ownership
1.1 Three typical forms of misdrawn boundaries
Most CMP migration problems are not due to framework performance gaps but to defects in the boundary definition from the start. Three typical forms exist.
The following diagram summarizes the boundary relationship between commonMain and platform source sets, and how platform API leaks break that boundary:
flowchart TD
subgraph C["commonMain (shared boundary)"]
A["SettingsStateHolder<br/>StateFlow state contract"]
B["SettingsScreen<br/>Stateless Composable"]
A --> B
end
subgraph P["Platform source sets"]
D["androidMain<br/>setContent / ComposeView"]
E["iosMain<br/>ComposeUIViewController"]
F["wasmJsMain<br/>ComposeViewport"]
end
B --> D
B --> E
B --> F
G["android.* / LocalContext / Hilt"] -.->|"leak = boundary failure"| C
D --> H["Native fallback implementation<br/>does not import shared"]
E --> H
Shared layer depends on platform API. The appearance of android.* imports, LocalContext.current, or Android's ViewModel in commonMain causes compilation failures when targeting iOS or Wasm, or forces a pile of expect/actual wrappers, degrading the shared layer into a "distribution center for platform differences." The rule is simple: compile commonMain code to iOS and wasmJs as-is, does it pass with zero modifications? If not, platform API has leaked into the shared layer.
Wrong state ownership. UI state held independently by each host leads to divergent behavior over iterations, until only a stateless shell is shared. The correct approach: put state logic in commonMain, expose it as immutable UiState and StateFlow, and let platform layers handle lifecycle-aware collection only.
Degradation point coupled to shared layer. The so-called "native fallback implementation" references types from the shared module; falling back to native just switches to another function in the shared module, so the rollback path doesn't exist architecturally.
1.2 StateHolder pattern: a safe state contract for commonMain
Using ViewModel in commonMain requires org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 2.10.0 or later, and viewModel { } must provide an initializer—the no-argument viewModel<T>() fails on non-JVM targets. If you don't want to introduce an additional lifecycle artifact, a lighter, fully cross-platform pattern is StateHolder + StateFlow:
// commonMain
class SettingsStateHolder(
private val repository: SettingsRepository,
) {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
fun toggleNotifications() {
_uiState.update { it.copy(notificationsEnabled = !it.notificationsEnabled) }
}
}
data class SettingsUiState(
val userName: String = "",
val notificationsEnabled: Boolean = true,
)
The shared Composable only receives state and events, remaining stateless:
@Composable
fun SettingsScreen(
state: SettingsUiState,
onToggleNotifications: () -> Unit,
onOpenNativePage: () -> Unit,
onLogout: () -> Unit,
) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Current user: ${state.userName}")
Text("Notifications: ${if (state.notificationsEnabled) "On" else "Off"}")
Button(onClick = onToggleNotifications) { Text("Toggle notification") }
Button(onClick = onOpenNativePage) { Text("Open native settings page") }
Button(onClick = onLogout) { Text("Logout") }
}
}
Dependency injection boundary. Hilt depends on kapt/Java annotation processing and can only be used in androidMain; shared module DI should use Koin or Metro, both of which work in commonMain. The principle: platform side injects platform-specific implementations, shared layer consumes via interfaces.
1.3 The real role of the boundary layer: contract, not glue
Sony Auto Play's 0.2% SharedKernel boundary layer is often cited as an example of "zero-rewrite migration", but more worth examining is what it defines: which UI is rendered by CMP, which still by legacy J2ObjC code, what capabilities the legacy layer exposes to the shared layer, and where the data and navigation boundaries lie.
This pattern's premise is module separability. If the existing app's UI and business logic are deeply interwoven and cannot be isolated into clean module boundaries, SharedKernel cannot be replicated—it's a condition for applicability, not a universal formula.
2. Interop: not a wrapper, a synchronization contract
2.1 Why the "wrapper" intuition is wrong
Most teams treat AndroidView and UIKitView as simple wrappers, and this understanding leads to dropped frames and broken gestures in production. Their actual identity is a synchronization contract between two rendering pipelines: Compose uses a retained-mode LayoutNode scene graph; native views use immediate-mode layouts (Auto Layout constraint solving on iOS, measure/layout on Android). Both must complete layout and drawing within the same 16ms frame budget. If the native view's size requires dynamic negotiation, both layout engines each measure once in the same frame, doubling layout time.
All three interop problem classes map to the same synchronization contract. The diagram below correlates problems with fixes:
flowchart LR
A["Compose retained mode<br/>LayoutNode scene graph"] -->|"same 16ms frame budget"| B["Native immediate layout<br/>Auto Layout / measure-layout"]
B --> C{"is sync contract explicitly implemented?"}
C -->|"no"| D["dual layout<br/>two measurements per frame"]
C -->|"no"| E["gesture and focus contention<br/>parent scroll swallowed"]
C -->|"no"| F["unstable lambda<br/>native view recreation"]
D --> G["fixed bounds<br/>Modifier.fillMaxSize()"]
E --> H["requestDisallowInterceptTouchEvent<br/>shouldRecognizeSimultaneouslyWith<br/>focus bidirectional bridge"]
F --> I["remember stable lambda<br/>idempotent update"]
2.2 Problem 1: dual layout and frame budget
The fix is to give native views fixed, stable bounds, replacing content negotiation with explicit constraints:
AndroidView(
factory = { context -> NativeMapView(context) },
modifier = Modifier.fillMaxSize(), // explicit constraint, avoid wrapContentSize
update = { mapView -> mapView.setZoom(12f) },
)
Modifier.size() or fillMaxSize() prevents the native layout engine from having to negotiate size within a frame.
2.3 Problem 2: bidirectional sync of gestures and focus
On Android, touch events on AndroidView first pass through ViewGroup hit testing, then to Compose's gesture detectors; on iOS, UIKitView's UIGestureRecognizer competes with Compose's pointer input system. If unhandled, the parent scroll container silently loses scroll gestures.
Minimal Android-side protection:
AndroidView(
factory = { context ->
NativeMapView(context).apply {
setOnTouchListener { v, event ->
v.parent.requestDisallowInterceptTouchEvent(
event.action != MotionEvent.ACTION_UP
)
false
}
}
},
modifier = Modifier.fillMaxSize(),
)
On iOS, coordinating shouldRecognizeSimultaneouslyWith is required, otherwise scroll gestures are completely swallowed by the native view.
Focus is a more subtle failure point. Compose's FocusManager and iOS's native focus system are two independent state machines. When a user taps an embedded UIKitView text field, Compose's onFocusChanged doesn't fire, and keyboard avoidance logic breaks. A bidirectional bridge must be explicitly built:
// iOS side, bridged via expect/actual
UIKitView(
factory = {
NativeTextField().apply {
onFocusGained = { focusRequester.requestFocus() }
onFocusLost = { focusManager.clearFocus() }
}
},
)
2.4 Problem 3: unstable lambdas causing native view recreation
If the factory, update, or callback lambdas passed to AndroidView / UIKitView capture closures that are recreated on every recomposition, Compose destroys and recreates the native view on every recomposition. For maps, camera previews, or ad SDKs, this means constant initialization overhead and visual flicker. The fix is to stabilize lambda references with remember and ensure operations in update are idempotent.
3. Performance: attribute to the stage first, then decide optimize or fallback
3.1 TechnoAlpin case: the complete attribution chain
TechnoAlpin's main iOS app is built on SwiftUI, with three features implemented in CMP. One Canvas-heavy machine room control chart dropped to single-digit frame rate during rotation animations. The case's value lies in showing a troubleshooting path from misdiagnosis to precise attribution.
Step 1: rule out the composition stage. The first instinct was "excessive recomposition", but the profiler showed zero recompositions—the bottleneck is not in composition.
Step 2: locate the drawing stage. The Canvas contains dozens of text labels. The engine attempts to recalculate, rasterize, and draw these texts 60 to 120 times per second. The problem sits in Canvas's drawText path.
Step 3: distinguish symptom from root cause. The team fixed the symptom with offscreen bitmap caching: draw static text once to an ImageBitmap, and frame rate recovered from single digits to 120 Hz. But the root cause was a Skia layer regression in CMP 1.8.0: whenever any layer property changes (including Modifier.graphicsLayer { rotationZ }), the layer invalidates its recorded content, forcing the entire subtree to redraw every frame. The fix landed in CMP 1.9.0 (CMP-8491), which runs smoothly without bitmap caching.
The methodology is reusable: first determine whether the bottleneck is in composition, layout, or drawing, then decide whether it's an application-layer issue, interop issue, or framework-layer regression. Patching the composition stage for a drawing-stage problem (or vice versa) wastes time. On iOS, there is another drawing bottleneck worth investigating separately: Metal shader compilation happens on the main thread, so new blend modes, clip shapes, or gradient types can block for milliseconds; average frame time masks such spikes.
This attribution chain as a decision path, to avoid patching the wrong stage:
flowchart TD
A["abnormal frame rate"] --> B{"recomposition count abnormal?"}
B -->|"yes"| C["composition stage<br/>check state reads and keys"]
B -->|"no, zero recompositions"| D{"layout time over budget?"}
D -->|"yes"| E["layout stage<br/>check dual measurement and constraint negotiation"]
D -->|"no"| F["drawing stage<br/>Canvas / drawText / layer invalidation"]
F --> G["application-layer fix<br/>offscreen bitmap cache"]
G --> H{"still worse than native baseline?"}
H -->|"yes"| I["framework regression<br/>CMP-8491, upgrade to 1.9.0"]
H -->|"no"| J["acceptance passed"]
3.2 Baseline and acceptance
Performance thresholds must come from the same device and same scenario native implementation:
| Metric | Measurement method | Baseline requirement | Out-of-bounds action |
|---|---|---|---|
| Startup time | Android Macrobenchmark / iOS Instruments App Launch, median of 10 runs | Native cold start on same device | Move CMP screen out of first frame; lazy init shared module |
| Scroll frame rate | JankStats / Instruments Core Animation, P99 frame time and drop rate | Native long list on same device | Provide stable key for LazyColumn's items(); if still failing, fall back to native list |
| Package size increase | Compare same ABI artifact before/after | Pre-introduction same ABI size | Split shared module; limit CMP page scope |
| Canvas / animation | Sample frame rate, first frame time, offscreen cache hit separately | Native frame rate for that animation | Offscreen cache; still failing, fall back to native drawing |
LazyColumn's stable key is a concrete and high-frequency fix: without a key, Compose uses positional index as identity, so inserting an item causes all subsequent rows to recompose; using a business ID rather than index or random value lets Compose correctly diff list changes.
3.3 Web cannot reuse mobile baselines
Web must have its own baseline. Runtime (Wasm bytecode JIT vs native machine code), rendering backend (ComposeViewport to Canvas bridging vs platform compositor), and loading model (downloading and instantiating Wasm on every cold start vs one-time install) all differ. At minimum measure three things: .wasm transfer size compressed and uncompressed, first-screen load time (split into download, compilation, instantiation, first-frame render), and average FPS. Mobile baselines only serve to judge whether a page is worth putting on Web; they cannot set Web thresholds.
4. Host integration and degradation: making fallback a verifiable path
4.1 Android: setContent / ComposeView as the only seam
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SettingsScreen(
state = uiState,
onToggleNotifications = { holder.toggleNotifications() },
onOpenNativePage = {
startActivity(Intent(this, NativeSettingsActivity::class.java))
},
onLogout = { finish() },
)
}
}
}
For embedding in an existing View system, use ComposeView; migration granularity can be as fine as "one region within a page."
4.2 iOS: ComposeUIViewController + UIViewControllerRepresentable
First wrap in iosMain:
fun SettingsScreenViewController(
state: SettingsUiState,
onOpenNativePage: () -> Unit,
onLogout: () -> Unit,
): UIViewController = ComposeUIViewController {
SettingsScreen(state, onOpenNativePage = onOpenNativePage, onLogout = onLogout)
}
On the Swift side, use UIViewControllerRepresentable; makeUIViewController returns the controller above. In build configuration, the embedAndSignAppleFrameworkForXcode task compiles Kotlin into a framework and signs it; Framework Search Paths points to $(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME). All three iOS targets (iosArm64, iosSimulatorArm64, iosX64) must be declared, iosMain depends on compose.ui; when using this script, don't also embed in Embed Frameworks, or signature conflicts occur.
4.3 Degradation: host-level flag and single-page fallback
Host-level flag. Put the feature switch in the shared layer, both ends read the same value:
object SettingsFeatureFlag {
var useComposeUi: Boolean = true
}
On Android, useComposeUi = false directly calls setContentView(R.layout.activity_settings_native); on iOS, switch SettingsScreenView with NativeSettingsView. The key constraint: the native fallback implementation does not import shared, and does not reference any Compose types.
Single-page fallback. For pages embedded locally, the rollback scope is limited to a single page or single feature. ComposeView reverts to the original XML, UIViewControllerRepresentable reverts to a pure SwiftUI view, and the shared module exits that page's rendering path.
Verification checklist (in order; resolve any failure before proceeding).
| Step | Action | Expected result | On failure, check |
|---|---|---|---|
| 1 | ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 |
Framework produced, header exposes host functions | baseName, target declarations, compose.ui dependency |
| 2 | Xcode import shared |
No No such module |
Framework Search Paths, whether embed script ran |
| 3 | Android opens shared screen | UI renders, state toggles work | Whether setContent is called within lifecycle |
| 4 | iOS opens shared screen | Same UI, both callbacks triggered | Whether makeUIViewController returns the shared controller |
| 5 | Both ends switch back to native fallback | Still compiles and runs | Whether native fallback mistakenly references shared types |
Step 5 is the watershed. A rollback path only exists once it has actually been walked.
The overall structure of host integration and rollback path—the flag and single-page fallback are two independent exit channels:
flowchart TD
A["Shared SettingsScreen"] --> B{"SettingsFeatureFlag.useComposeUi"}
B -->|"true"| C["Android: setContent / ComposeView"]
B -->|"true"| D["iOS: ComposeUIViewController<br/>+ UIViewControllerRepresentable"]
B -->|"false"| E["host-level flag fallback<br/>setContentView / NativeSettingsView"]
C --> F["single-page fallback<br/>ComposeView back to original XML"]
D --> G["single-page fallback<br/>back to pure SwiftUI view"]
E --> H["native fallback<br/>does not import shared"]
F --> H
G --> H
4.4 The line for stopping optimization
If any of the following conditions hold, continuing CMP-side tuning is no longer economical:
- After completing stage attribution and conventional optimizations (stable keys, offscreen cache, focus bridge, lambda stabilization), still more than 15% worse than the native baseline;
- The page's core is strong platform-coupled interaction (video editing, complex map gestures, custom-drawn charts), and CMP side can only assemble via large native slots;
- Accessibility or system behavior still fails after explicit bridging, and it's on a high-frequency critical path;
- Exception stacks land in the Skia rendering layer or Kotlin/Native bridge layer, and cannot be located in the short term.
5. Scenarios where CMP is not suitable
Strongly platform-coupled interactions. Camera preview, map gestures, video editing, complex custom charts—on the CMP side either rely on large native slots or suffer degraded experience. Honoring the rendering pipeline boundary (e.g., using SurfaceView for camera) is more sensible than forced unification.
Paths extremely sensitive to first-screen startup. If shared module initialization enters the first-frame critical path, perceptible delay may occur. Move CMP screens out of the first frame, or lazy-init heavy dependencies.
Legacy apps with deeply interwoven, inseparable UI stacks. The SharedKernel pattern presumes module separability; if you can't separate, migration becomes a global rewrite.
Teams lacking Compose and KMP engineering experience. CMP requires understanding declarative UI's recomposition semantics, platform source set build artifacts, and interop synchronization contracts. Without these, local embedding or shared-layer-driven approaches are more realistic starting points.
6. Conclusion
CMP stable removes the "can it be used in production" question, but doesn't answer for a team "how deep to share, where the boundary lies, how to verify performance, or whether fallback is actually viable." Four verifiable judgments serve as decision criteria:
First, does commonMain compile to iOS and Wasm with zero modifications? If not, platform API has leaked into the shared layer, and the boundary is wrong at the source.
Second, is the interop synchronization contract explicitly implemented? Whether native views have fixed bounds, whether gestures are forwarded bidirectionally, whether focus is bridged, whether lambdas are stable—these four are prerequisites for interop to work, not optional "optimizations."
Third, can performance issues be attributed to composition, layout, or drawing stages? Without attribution, you don't know whether you're fixing an application-layer problem or a framework-layer regression. The TechnoAlpin lesson: zero recompositions plus single-digit frame rate means the problem is in drawing, not composition.
Fourth, is the native fallback independent of the shared module? The moment the fallback implementation does import shared, the rollback path ceases to exist in practice.
Only when all four hold does deeper sharing become viable; if any one fails, stop at the current depth or revert to native. CMP lets you choose depth from "a single component" to "the entire UI"—and the basis for choosing depth is the verifiable engineering conditions above, not how deep others have gone.