摘要 :CMP 1.8.0 稳定版解决了"能不能用于生产"的准入门槛,但没有回答"共享多深、边界在哪、性能怎么验收、回退是否真实可用"。本文围绕三个工程问题展开:共享边界画在哪里、互操作层的同步契约是否被正确实现、性能问题出现时能否归因到正确的阶段。 边界层面给出
commonMain的硬性判定标准(原样编译到 iOS 与 wasmJs 零修改通过)、StateHolder + StateFlow 的状态契约,以及 SharedKernel 边界层"定义契约而非胶水"的作用;互操作层面把AndroidView/UIKitView还原为两套渲染管线之间的同步契约,逐一拆解双重布局、手势与焦点双向同步、不稳定 lambda 触发原生视图重建三类问题及修复代码;性能层面以 TechnoAlpin 的完整归因链说明"重组次数为零而帧率个位数"如何被定位到绘制阶段,并给出同设备原生基线、越界动作与 Web 端独立建基线的验收方法;最后落到宿主接入与降级:Android 的setContent/ComposeView、iOS 的ComposeUIViewController+UIViewControllerRepresentable、宿主级特性开关与单页回退,以及停止优化的判断线。
目录
- 前言
- [一、共享边界:commonMain 的硬性规则与状态归属](#一、共享边界:commonMain 的硬性规则与状态归属 "#%E4%B8%80%E5%85%B1%E4%BA%AB%E8%BE%B9%E7%95%8Ccommonmain-%E7%9A%84%E7%A1%AC%E6%80%A7%E8%A7%84%E5%88%99%E4%B8%8E%E7%8A%B6%E6%80%81%E5%BD%92%E5%B1%9E")
- [1.1 边界画错的三种典型形式](#1.1 边界画错的三种典型形式 "#11-%E8%BE%B9%E7%95%8C%E7%94%BB%E9%94%99%E7%9A%84%E4%B8%89%E7%A7%8D%E5%85%B8%E5%9E%8B%E5%BD%A2%E5%BC%8F")
- [1.2 StateHolder 模式:commonMain 的安全状态契约](#1.2 StateHolder 模式:commonMain 的安全状态契约 "#12-stateholder-%E6%A8%A1%E5%BC%8Fcommonmain-%E7%9A%84%E5%AE%89%E5%85%A8%E7%8A%B6%E6%80%81%E5%A5%91%E7%BA%A6")
- [1.3 边界层的真实作用:契约,而非胶水](#1.3 边界层的真实作用:契约,而非胶水 "#13-%E8%BE%B9%E7%95%8C%E5%B1%82%E7%9A%84%E7%9C%9F%E5%AE%9E%E4%BD%9C%E7%94%A8%E5%A5%91%E7%BA%A6%E8%80%8C%E9%9D%9E%E8%83%B6%E6%B0%B4")
- 二、互操作:不是"包装",是同步契约
- [2.1 为什么"包装"的直觉是错的](#2.1 为什么“包装”的直觉是错的 "#21-%E4%B8%BA%E4%BB%80%E4%B9%88%E5%8C%85%E8%A3%85%E7%9A%84%E7%9B%B4%E8%A7%89%E6%98%AF%E9%94%99%E7%9A%84")
- [2.2 问题一:双重布局与帧预算](#2.2 问题一:双重布局与帧预算 "#22-%E9%97%AE%E9%A2%98%E4%B8%80%E5%8F%8C%E9%87%8D%E5%B8%83%E5%B1%80%E4%B8%8E%E5%B8%A7%E9%A2%84%E7%AE%97")
- [2.3 问题二:手势与焦点的双向同步](#2.3 问题二:手势与焦点的双向同步 "#23-%E9%97%AE%E9%A2%98%E4%BA%8C%E6%89%8B%E5%8A%BF%E4%B8%8E%E7%84%A6%E7%82%B9%E7%9A%84%E5%8F%8C%E5%90%91%E5%90%8C%E6%AD%A5")
- [2.4 问题三:不稳定 lambda 导致原生视图重建](#2.4 问题三:不稳定 lambda 导致原生视图重建 "#24-%E9%97%AE%E9%A2%98%E4%B8%89%E4%B8%8D%E7%A8%B3%E5%AE%9A-lambda-%E5%AF%BC%E8%87%B4%E5%8E%9F%E7%94%9F%E8%A7%86%E5%9B%BE%E9%87%8D%E5%BB%BA")
- 三、性能:先归因到阶段,再决定优化还是回退
- [3.1 TechnoAlpin 案例的完整归因链](#3.1 TechnoAlpin 案例的完整归因链 "#31-technoalpin-%E6%A1%88%E4%BE%8B%E7%9A%84%E5%AE%8C%E6%95%B4%E5%BD%92%E5%9B%A0%E9%93%BE")
- [3.2 基线与验收](#3.2 基线与验收 "#32-%E5%9F%BA%E7%BA%BF%E4%B8%8E%E9%AA%8C%E6%94%B6")
- [3.3 Web 端不能复用移动端基线](#3.3 Web 端不能复用移动端基线 "#33-web-%E7%AB%AF%E4%B8%8D%E8%83%BD%E5%A4%8D%E7%94%A8%E7%A7%BB%E5%8A%A8%E7%AB%AF%E5%9F%BA%E7%BA%BF")
- 四、宿主接入与降级:让回退成为可验证的路径
- [4.1 Android:setContent / ComposeView 作为唯一接缝](#4.1 Android:setContent / ComposeView 作为唯一接缝 "#41-androidsetcontent--composeview-%E4%BD%9C%E4%B8%BA%E5%94%AF%E4%B8%80%E6%8E%A5%E7%BC%9D")
- [4.2 iOS:ComposeUIViewController + UIViewControllerRepresentable](#4.2 iOS:ComposeUIViewController + UIViewControllerRepresentable "#42-ioscomposeuiviewcontroller--uiviewcontrollerrepresentable")
- [4.3 降级:宿主级开关与单页回退](#4.3 降级:宿主级开关与单页回退 "#43-%E9%99%8D%E7%BA%A7%E5%AE%BF%E4%B8%BB%E7%BA%A7%E5%BC%80%E5%85%B3%E4%B8%8E%E5%8D%95%E9%A1%B5%E5%9B%9E%E9%80%80")
- [4.4 停止优化的判断线](#4.4 停止优化的判断线 "#44-%E5%81%9C%E6%AD%A2%E4%BC%98%E5%8C%96%E7%9A%84%E5%88%A4%E6%96%AD%E7%BA%BF")
- [五、CMP 不适用的场景](#五、CMP 不适用的场景 "#%E4%BA%94cmp-%E4%B8%8D%E9%80%82%E7%94%A8%E7%9A%84%E5%9C%BA%E6%99%AF")
- 六、结论
前言
CMP 1.8.0 的数据已经说明"可以用":启动与原生相当,滚动与 SwiftUI 持平,包体积增量约 9 MB,96% 以上的 iOS 端开发者报告没有重大性能问题。但这是准入门槛,不是工程决策。真正决定迁移成败的是另外三件事:共享边界画在哪里、互操作层的同步契约是否被正确实现、性能问题出现时能否归因到正确的阶段,而非在错误的层面上打补丁。
Sony 和 TechnoAlpin 的案例只用来提取可复用的技术模式,不作为论据:前者展示一个四语言 UI 栈如何在不对遗留代码做重写的前提下被替换,后者展示"重组次数为零但帧率只有个位数"如何被精确归因。代码和配置以可验证为准,阈值以团队自身原生基线替代。
一、共享边界:commonMain 的硬性规则与状态归属
1.1 边界画错的三种典型形式
CMP 迁移的多数问题不是框架性能不足,而是边界定义在源头就有缺陷,典型的有三种形式。
下图概括 commonMain 与各平台源集之间的边界关系,以及平台 API 泄漏如何破坏这条边界:
共享层依赖平台 API。 commonMain 中出现 android.* 导入、LocalContext.current 或 Android 的 ViewModel,编译到 iOS 或 Wasm 时失败,或者被迫用大量 expect/actual 包裹,共享层退化为"平台差异的分发中心"。判定标准很简单:把 commonMain 的代码原样编译到 iOS 和 wasmJs,是否零修改通过。 答案是否定的,就说明平台 API 泄漏进了共享层。
状态归属错误。 UI 状态由各端宿主各自持有,双端行为在迭代中逐渐分叉,最终共享的只是一层无状态的壳。正确做法是:状态逻辑放在 commonMain 中,以不可变 UiState 和 StateFlow 暴露,平台层只负责生命周期感知的收集。
降级点与共享层耦合。 所谓的"原生兜底实现"引用了共享模块的类型,退回原生时只是切换到共享模块的另一个函数,回退路径在架构上不存在。
1.2 StateHolder 模式:commonMain 的安全状态契约
在 commonMain 中使用 ViewModel 需要 org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 2.10.0 及以上,并且 viewModel { } 必须提供初始化器------无参的 viewModel<T>() 在非 JVM 目标上会失败。如果不愿引入额外的 lifecycle 构件,更轻量且完全跨平台的模式是 StateHolder + StateFlow:
kotlin
// 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,
)
共享 Composable 只接收状态和事件,保持无状态:
kotlin
@Composable
fun SettingsScreen(
state: SettingsUiState,
onToggleNotifications: () -> Unit,
onOpenNativePage: () -> Unit,
onLogout: () -> Unit,
) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("当前用户:${state.userName}")
Text("通知:${if (state.notificationsEnabled) "已开启" else "已关闭"}")
Button(onClick = onToggleNotifications) { Text("切换通知开关") }
Button(onClick = onOpenNativePage) { Text("打开原生设置页") }
Button(onClick = onLogout) { Text("退出登录") }
}
}
依赖注入的边界。 Hilt 依赖 kapt/Java 注解处理,只能在 androidMain 中使用;共享模块的 DI 应选用 Koin 或 Metro,二者都能在 commonMain 工作。原则是:平台侧注入平台特有的实现,共享层通过接口消费。
1.3 边界层的真实作用:契约,而非胶水
Sony Auto Play 的 0.2% SharedKernel 边界层常被引用为"零重写迁移"的范例,但更值得看的是它定义了什么:哪些 UI 由 CMP 渲染、哪些仍由遗留 J2ObjC 代码渲染;遗留层向共享层暴露哪些能力;数据与导航的边界在哪里。
这个模式的前提是模块可切割。如果存量应用的 UI 与业务逻辑深度交织、划不出可隔离的模块边界,SharedKernel 无法复制------它是适用边界,不是通用公式。
二、互操作:不是"包装",是同步契约
2.1 为什么"包装"的直觉是错的
多数团队把 AndroidView 和 UIKitView 理解为简单的包装器,这个理解会导致生产环境中的掉帧和手势失效。它们的真实身份是两套渲染管线之间的同步契约 :Compose 使用保留模式的 LayoutNode 场景图,原生视图使用即时模式的布局系统(iOS 的 Auto Layout 约束求解、Android 的 measure/layout 过程),双方必须在同一个 16ms 帧预算内完成布局与绘制。如果原生视图的尺寸需要动态协商,两套布局引擎会在同一帧内各自测量一次,布局耗时翻倍。
三类互操作问题都可以归到同一条同步契约上,下图把问题与修复手段对应起来:
2.2 问题一:双重布局与帧预算
修复方式是给原生视图固定、稳定的边界,用显式约束替代内容协商:
kotlin
AndroidView(
factory = { context -> NativeMapView(context) },
modifier = Modifier.fillMaxSize(), // 显式约束,避免 wrapContentSize
update = { mapView -> mapView.setZoom(12f) },
)
Modifier.size() 或 fillMaxSize() 让原生布局引擎不再需要在帧内协商尺寸。
2.3 问题二:手势与焦点的双向同步
Android 上 AndroidView 的触摸事件先经过 ViewGroup 的命中测试,再到达 Compose 的手势检测器;iOS 上 UIKitView 的 UIGestureRecognizer 与 Compose 的指针输入系统存在竞争。不处理时,父级滚动容器会静默失去滚动手势。
Android 侧的最小防护:
kotlin
AndroidView(
factory = { context ->
NativeMapView(context).apply {
setOnTouchListener { v, event ->
v.parent.requestDisallowInterceptTouchEvent(
event.action != MotionEvent.ACTION_UP
)
false
}
}
},
modifier = Modifier.fillMaxSize(),
)
iOS 侧需要协调 shouldRecognizeSimultaneouslyWith,否则滚动手势会被原生视图完全吞掉。
焦点是更隐蔽的失效点。Compose 的 FocusManager 和 iOS 原生焦点系统是两台独立的状态机。用户点击嵌入的 UIKitView 文本框时,Compose 的 onFocusChanged 不会触发,键盘避让逻辑失效。必须显式建立双向桥:
kotlin
// iOS 侧,通过 expect/actual 桥接
UIKitView(
factory = {
NativeTextField().apply {
onFocusGained = { focusRequester.requestFocus() }
onFocusLost = { focusManager.clearFocus() }
}
},
)
2.4 问题三:不稳定 lambda 导致原生视图重建
传给 AndroidView / UIKitView 的 factory、update 或回调 lambda,如果捕获了每次重组都会新建的闭包,Compose 会在每次重组时销毁并重建原生视图。对地图、相机预览或广告 SDK,这意味着持续的初始化开销和视觉闪烁。修复方式是用 remember 稳定 lambda 引用,并确保 update 中的操作幂等。
三、性能:先归因到阶段,再决定优化还是回退
3.1 TechnoAlpin 案例的完整归因链
TechnoAlpin 的 iOS 主应用基于 SwiftUI,三个功能用 CMP 实现,其中一个 Canvas 密集的机房控制图在旋转动画时帧率掉到个位数。这个案例的价值在于展示了一条从误判到精确归因的排查路径。
第一步:排除组合阶段。 第一直觉是"过度重组",但 profiler 显示重组次数为零------瓶颈不在组合阶段。
第二步:定位到绘制阶段。 Canvas 内有数十个文本标签,引擎每秒尝试重新计算、光栅化并绘制这些文字 60 到 120 次。问题出在 Canvas 的 drawText 路径。
第三步:区分症状与根因。 团队用离屏位图缓存修复了症状:将静态文本一次性绘制到 ImageBitmap,帧率从个位数恢复到 120 Hz。但根因是 CMP 1.8.0 的 Skia 层回归:每当任何 layer 属性(包括 Modifier.graphicsLayer { rotationZ })变化,layer 会将记录的内容标记为无效,强制整个子树每帧重绘。修复落在 CMP 1.9.0(CMP-8491),在 1.9.0 上无需位图缓存即可流畅运行。
这个案例的方法论可以复用:先确定瓶颈在组合、布局还是绘制阶段,再判断是应用层问题、互操作问题还是框架层回归。 在绘制阶段打组合阶段的补丁(或反过来)只会浪费工时。iOS 上还有一类值得单独排查的绘制瓶颈:Metal 着色器编译在主线程上完成,新出现的混合模式、裁剪形状或渐变类型可能阻塞数毫秒,普通帧时间平均值会掩盖这类尖峰。
把这条归因链画成决策路径,避免在错误的阶段打补丁:
3.2 基线与验收
性能阈值必须来自同设备、同场景的原生实现:
| 指标 | 测量方式 | 基线要求 | 越界动作 |
|---|---|---|---|
| 启动耗时 | Android Macrobenchmark / iOS Instruments App Launch,各测 10 次取中位数 | 同设备原生冷启动 | CMP 屏幕移出首屏;延迟初始化共享模块 |
| 滚动帧率 | JankStats / Instruments Core Animation,采集 P99 帧耗时与掉帧率 | 同设备原生长列表 | 为 LazyColumn 的 items() 提供稳定 key;仍不达标则退回原生列表 |
| 包体积增量 | 对比引入前后同 ABI 产物 | 引入前同 ABI 体积 | 拆分共享模块;限定 CMP 页面范围 |
| Canvas / 动画 | 单独采样帧率、首帧耗时、离屏缓存命中 | 原生实现下该动画帧率 | 离屏缓存;仍不达标退回原生绘制 |
LazyColumn 的稳定 key 是一个具体且高频的修复点:没有 key 时,Compose 使用位置索引作为身份,插入一项会导致后续所有行重组;使用业务 ID 而非 index 或随机值,才能让 Compose 正确 diff 列表变化。
3.3 Web 端不能复用移动端基线
Web 端必须单独建基线。运行时(Wasm 字节码即时编译 vs 原生机器码)、渲染后端(ComposeViewport 到 Canvas 的桥接 vs 平台合成器)、加载模型(每次冷启动下载并实例化 Wasm vs 安装包一次性落地)都不同。至少测三项:.wasm 压缩前后传输体积、首屏加载时间(拆成下载、编译、实例化、首帧渲染四段)、平均 FPS。移动端基线只用于判断"这个页面是否值得放到 Web",不能给 Web 端定阈值。
四、宿主接入与降级:让回退成为可验证的路径
4.1 Android:setContent / ComposeView 作为唯一接缝
kotlin
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() },
)
}
}
}
存量 View 体系里嵌入时用 ComposeView,迁移粒度可以细到"一个页面里的一块区域"。
4.2 iOS:ComposeUIViewController + UIViewControllerRepresentable
先在 iosMain 包装:
kotlin
fun SettingsScreenViewController(
state: SettingsUiState,
onOpenNativePage: () -> Unit,
onLogout: () -> Unit,
): UIViewController = ComposeUIViewController {
SettingsScreen(state, onOpenNativePage = onOpenNativePage, onLogout = onLogout)
}
Swift 侧用 UIViewControllerRepresentable 接入,makeUIViewController 返回上面的控制器。构建配置中,embedAndSignAppleFrameworkForXcode 任务将 Kotlin 编译为 framework 并签名,Framework Search Paths 指向 $(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)。三个 iOS target(iosArm64、iosSimulatorArm64、iosX64)都要声明,iosMain 依赖 compose.ui;使用该脚本时不要在 Embed Frameworks 里重复嵌入,否则签名冲突。
4.3 降级:宿主级开关与单页回退
宿主级开关。 特性开关放在共享层,双端读同一个值:
kotlin
object SettingsFeatureFlag {
var useComposeUi: Boolean = true
}
Android 侧 useComposeUi = false 时直接 setContentView(R.layout.activity_settings_native);iOS 侧切换 SettingsScreenView 与 NativeSettingsView。关键约束:原生兜底实现不 import shared,不引用任何 Compose 类型。
单页回退。 局部嵌入的页面,回退范围限制在单页或单功能。ComposeView 换回原 XML,UIViewControllerRepresentable 换回纯 SwiftUI 视图,共享模块从该页面的渲染路径中退出。
验证清单(按顺序,任一不通过先解决再推进)。
| 步骤 | 操作 | 预期结果 | 不通过时查什么 |
|---|---|---|---|
| 1 | ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 |
产出 framework,头文件可见宿主函数 | baseName、target 声明、compose.ui 依赖 |
| 2 | Xcode import shared |
无 No such module |
Framework Search Paths、embed 脚本是否执行 |
| 3 | Android 打开共享屏幕 | 界面渲染,状态切换生效 | setContent 是否在生命周期内调用 |
| 4 | iOS 打开共享屏幕 | 同一份界面,两个回调被触发 | makeUIViewController 是否返回共享控制器 |
| 5 | 双端换回原生兜底 | 仍可编译运行 | 原生兜底是否误引用共享类型 |
第 5 步是分水岭。降级路径只有被真正走过,才算存在。
宿主接入与降级路径的整体结构如下------开关与单页回退是两条独立的退出通道:
4.4 停止优化的判断线
以下任一条件成立时,继续在 CMP 侧调优的经济性已经不成立:
- 完成阶段归因和常规优化(稳定 key、离屏缓存、焦点桥接、lambda 稳定化)后,仍劣于原生基线 15% 以上;
- 页面主体是强平台耦合交互(视频编辑、复杂地图手势、自绘图表),CMP 侧只能靠大面积原生插槽拼装;
- 无障碍或系统行为在显式桥接后仍不达标,且属于高频关键路径;
- 异常堆栈落在 Skia 渲染层或 Kotlin/Native 桥接层,短期无法定位。
五、CMP 不适用的场景
强平台耦合的交互。 相机预览、地图手势、视频编辑、复杂自绘图表,CMP 侧要么依赖大面积原生插槽,要么体验有折损。顺应渲染管线边界(如用 SurfaceView 处理相机)比强行统一更合理。
对首屏启动极度敏感的路径。 如果共享模块初始化进入首屏关键路径,可能带来可感知延迟。应把 CMP 屏幕移出首屏,或延迟初始化重型依赖。
UI 栈深度交织、无法隔离的存量应用。 SharedKernel 模式的前提是模块可切割;切不开,迁移就会变成全局重写。
团队缺少 Compose 与 KMP 工程经验。 CMP 要求理解声明式 UI 的重组语义、平台源集的编译产物、互操作的同步契约。没有这些基础,局部嵌入或共享层驱动是更现实的起点。
六、结论
CMP 稳定版解决了"能不能用于生产",但没有替团队回答"共享多深、边界在哪、性能怎么验收、回退是否真实可用"。四个可验证的判断,可作为决策依据:
第一,commonMain 是否能在 iOS 和 Wasm 上零修改编译通过。 不能通过,说明平台 API 泄漏进了共享层,边界在源头就是错的。
第二,互操作的同步契约是否被显式实现。 原生视图是否有固定边界、手势是否双向转发、焦点是否桥接、lambda 是否稳定------这四项是互操作正常工作的前提,不是可选的"优化项"。
第三,性能问题是否能被归因到组合、布局或绘制阶段。 不能归因,就不知道是在修应用层问题还是在修框架层回归。TechnoAlpin 的教训是:重组次数为零而帧率个位数,问题在绘制,不在组合。
第四,原生兜底是否独立于共享模块。 兜底实现 import shared 的那一刻,回退路径就名存实亡。
四项都成立,才谈得上向更深的共享扩展;任何一项不成立,就应该停在当前深度,或者退回原生。CMP 允许你在"一个组件"到"整个 UI"之间选择深度------而选择深度的依据,是上述可验证的工程条件,不是别人用了多深。