Compose Multiplatform 共享边界实战:架构契约、互操作同步与可回退设计

摘要 :CMP 1.8.0 稳定版解决了"能不能用于生产"的准入门槛,但没有回答"共享多深、边界在哪、性能怎么验收、回退是否真实可用"。本文围绕三个工程问题展开:共享边界画在哪里、互操作层的同步契约是否被正确实现、性能问题出现时能否归因到正确的阶段。 边界层面给出 commonMain 的硬性判定标准(原样编译到 iOS 与 wasmJs 零修改通过)、StateHolder + StateFlow 的状态契约,以及 SharedKernel 边界层"定义契约而非胶水"的作用;互操作层面把 AndroidView / UIKitView 还原为两套渲染管线之间的同步契约,逐一拆解双重布局、手势与焦点双向同步、不稳定 lambda 触发原生视图重建三类问题及修复代码;性能层面以 TechnoAlpin 的完整归因链说明"重组次数为零而帧率个位数"如何被定位到绘制阶段,并给出同设备原生基线、越界动作与 Web 端独立建基线的验收方法;最后落到宿主接入与降级:Android 的 setContent / ComposeView、iOS 的 ComposeUIViewController + UIViewControllerRepresentable、宿主级特性开关与单页回退,以及停止优化的判断线。

目录

  • 前言
  • [一、共享边界:commonMain 的硬性规则与状态归属](#一、共享边界:commonMain 的硬性规则与状态归属)
    • [1.1 边界画错的三种典型形式](#1.1 边界画错的三种典型形式)
    • [1.2 StateHolder 模式:commonMain 的安全状态契约](#1.2 StateHolder 模式:commonMain 的安全状态契约)
    • [1.3 边界层的真实作用:契约,而非胶水](#1.3 边界层的真实作用:契约,而非胶水)
  • 二、互操作:不是"包装",是同步契约
    • [2.1 为什么"包装"的直觉是错的](#2.1 为什么“包装”的直觉是错的)
    • [2.2 问题一:双重布局与帧预算](#2.2 问题一:双重布局与帧预算)
    • [2.3 问题二:手势与焦点的双向同步](#2.3 问题二:手势与焦点的双向同步)
    • [2.4 问题三:不稳定 lambda 导致原生视图重建](#2.4 问题三:不稳定 lambda 导致原生视图重建)
  • 三、性能:先归因到阶段,再决定优化还是回退
    • [3.1 TechnoAlpin 案例的完整归因链](#3.1 TechnoAlpin 案例的完整归因链)
    • [3.2 基线与验收](#3.2 基线与验收)
    • [3.3 Web 端不能复用移动端基线](#3.3 Web 端不能复用移动端基线)
  • 四、宿主接入与降级:让回退成为可验证的路径
    • [4.1 Android:setContent / ComposeView 作为唯一接缝](#4.1 Android:setContent / ComposeView 作为唯一接缝)
    • [4.2 iOS:ComposeUIViewController + UIViewControllerRepresentable](#4.2 iOS:ComposeUIViewController + UIViewControllerRepresentable)
    • [4.3 降级:宿主级开关与单页回退](#4.3 降级:宿主级开关与单页回退)
    • [4.4 停止优化的判断线](#4.4 停止优化的判断线)
  • [五、CMP 不适用的场景](#五、CMP 不适用的场景)
  • 六、结论

前言

CMP 1.8.0 的数据已经说明"可以用":启动与原生相当,滚动与 SwiftUI 持平,包体积增量约 9 MB,96% 以上的 iOS 端开发者报告没有重大性能问题。但这是准入门槛,不是工程决策。真正决定迁移成败的是另外三件事:共享边界画在哪里、互操作层的同步契约是否被正确实现、性能问题出现时能否归因到正确的阶段,而非在错误的层面上打补丁。

Sony 和 TechnoAlpin 的案例只用来提取可复用的技术模式,不作为论据:前者展示一个四语言 UI 栈如何在不对遗留代码做重写的前提下被替换,后者展示"重组次数为零但帧率只有个位数"如何被精确归因。代码和配置以可验证为准,阈值以团队自身原生基线替代。

一、共享边界:commonMain 的硬性规则与状态归属

1.1 边界画错的三种典型形式

CMP 迁移的多数问题不是框架性能不足,而是边界定义在源头就有缺陷,典型的有三种形式。

下图概括 commonMain 与各平台源集之间的边界关系,以及平台 API 泄漏如何破坏这条边界:
#mermaid-svg-ENurnAXcLxbWr0Sp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ENurnAXcLxbWr0Sp .error-icon{fill:#552222;}#mermaid-svg-ENurnAXcLxbWr0Sp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ENurnAXcLxbWr0Sp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ENurnAXcLxbWr0Sp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ENurnAXcLxbWr0Sp .marker.cross{stroke:#333333;}#mermaid-svg-ENurnAXcLxbWr0Sp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ENurnAXcLxbWr0Sp p{margin:0;}#mermaid-svg-ENurnAXcLxbWr0Sp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp .cluster-label text{fill:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp .cluster-label span{color:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp .cluster-label span p{background-color:transparent;}#mermaid-svg-ENurnAXcLxbWr0Sp .label text,#mermaid-svg-ENurnAXcLxbWr0Sp span{fill:#333;color:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp .node rect,#mermaid-svg-ENurnAXcLxbWr0Sp .node circle,#mermaid-svg-ENurnAXcLxbWr0Sp .node ellipse,#mermaid-svg-ENurnAXcLxbWr0Sp .node polygon,#mermaid-svg-ENurnAXcLxbWr0Sp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ENurnAXcLxbWr0Sp .rough-node .label text,#mermaid-svg-ENurnAXcLxbWr0Sp .node .label text,#mermaid-svg-ENurnAXcLxbWr0Sp .image-shape .label,#mermaid-svg-ENurnAXcLxbWr0Sp .icon-shape .label{text-anchor:middle;}#mermaid-svg-ENurnAXcLxbWr0Sp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ENurnAXcLxbWr0Sp .rough-node .label,#mermaid-svg-ENurnAXcLxbWr0Sp .node .label,#mermaid-svg-ENurnAXcLxbWr0Sp .image-shape .label,#mermaid-svg-ENurnAXcLxbWr0Sp .icon-shape .label{text-align:center;}#mermaid-svg-ENurnAXcLxbWr0Sp .node.clickable{cursor:pointer;}#mermaid-svg-ENurnAXcLxbWr0Sp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ENurnAXcLxbWr0Sp .arrowheadPath{fill:#333333;}#mermaid-svg-ENurnAXcLxbWr0Sp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ENurnAXcLxbWr0Sp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ENurnAXcLxbWr0Sp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ENurnAXcLxbWr0Sp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ENurnAXcLxbWr0Sp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ENurnAXcLxbWr0Sp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ENurnAXcLxbWr0Sp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ENurnAXcLxbWr0Sp .cluster text{fill:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp .cluster span{color:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ENurnAXcLxbWr0Sp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ENurnAXcLxbWr0Sp rect.text{fill:none;stroke-width:0;}#mermaid-svg-ENurnAXcLxbWr0Sp .icon-shape,#mermaid-svg-ENurnAXcLxbWr0Sp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ENurnAXcLxbWr0Sp .icon-shape p,#mermaid-svg-ENurnAXcLxbWr0Sp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ENurnAXcLxbWr0Sp .icon-shape .label rect,#mermaid-svg-ENurnAXcLxbWr0Sp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ENurnAXcLxbWr0Sp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ENurnAXcLxbWr0Sp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ENurnAXcLxbWr0Sp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 平台源集
commonMain(共享边界)
泄漏即边界失效
SettingsStateHolder

StateFlow 状态契约
SettingsScreen

无状态 Composable
androidMain

setContent / ComposeView
iosMain

ComposeUIViewController
wasmJsMain

ComposeViewport
android.* / LocalContext / Hilt
原生兜底实现

不 import shared

共享层依赖平台 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 帧预算内完成布局与绘制。如果原生视图的尺寸需要动态协商,两套布局引擎会在同一帧内各自测量一次,布局耗时翻倍。

三类互操作问题都可以归到同一条同步契约上,下图把问题与修复手段对应起来:
#mermaid-svg-SkCqAzURacDCXQH5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SkCqAzURacDCXQH5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SkCqAzURacDCXQH5 .error-icon{fill:#552222;}#mermaid-svg-SkCqAzURacDCXQH5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SkCqAzURacDCXQH5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SkCqAzURacDCXQH5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SkCqAzURacDCXQH5 .marker.cross{stroke:#333333;}#mermaid-svg-SkCqAzURacDCXQH5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SkCqAzURacDCXQH5 p{margin:0;}#mermaid-svg-SkCqAzURacDCXQH5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SkCqAzURacDCXQH5 .cluster-label text{fill:#333;}#mermaid-svg-SkCqAzURacDCXQH5 .cluster-label span{color:#333;}#mermaid-svg-SkCqAzURacDCXQH5 .cluster-label span p{background-color:transparent;}#mermaid-svg-SkCqAzURacDCXQH5 .label text,#mermaid-svg-SkCqAzURacDCXQH5 span{fill:#333;color:#333;}#mermaid-svg-SkCqAzURacDCXQH5 .node rect,#mermaid-svg-SkCqAzURacDCXQH5 .node circle,#mermaid-svg-SkCqAzURacDCXQH5 .node ellipse,#mermaid-svg-SkCqAzURacDCXQH5 .node polygon,#mermaid-svg-SkCqAzURacDCXQH5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SkCqAzURacDCXQH5 .rough-node .label text,#mermaid-svg-SkCqAzURacDCXQH5 .node .label text,#mermaid-svg-SkCqAzURacDCXQH5 .image-shape .label,#mermaid-svg-SkCqAzURacDCXQH5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-SkCqAzURacDCXQH5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SkCqAzURacDCXQH5 .rough-node .label,#mermaid-svg-SkCqAzURacDCXQH5 .node .label,#mermaid-svg-SkCqAzURacDCXQH5 .image-shape .label,#mermaid-svg-SkCqAzURacDCXQH5 .icon-shape .label{text-align:center;}#mermaid-svg-SkCqAzURacDCXQH5 .node.clickable{cursor:pointer;}#mermaid-svg-SkCqAzURacDCXQH5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SkCqAzURacDCXQH5 .arrowheadPath{fill:#333333;}#mermaid-svg-SkCqAzURacDCXQH5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SkCqAzURacDCXQH5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SkCqAzURacDCXQH5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SkCqAzURacDCXQH5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SkCqAzURacDCXQH5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SkCqAzURacDCXQH5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SkCqAzURacDCXQH5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SkCqAzURacDCXQH5 .cluster text{fill:#333;}#mermaid-svg-SkCqAzURacDCXQH5 .cluster span{color:#333;}#mermaid-svg-SkCqAzURacDCXQH5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SkCqAzURacDCXQH5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SkCqAzURacDCXQH5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-SkCqAzURacDCXQH5 .icon-shape,#mermaid-svg-SkCqAzURacDCXQH5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SkCqAzURacDCXQH5 .icon-shape p,#mermaid-svg-SkCqAzURacDCXQH5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SkCqAzURacDCXQH5 .icon-shape .label rect,#mermaid-svg-SkCqAzURacDCXQH5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SkCqAzURacDCXQH5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SkCqAzURacDCXQH5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SkCqAzURacDCXQH5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 同一 16ms 帧预算
否
否
否
Compose 保留模式

LayoutNode 场景图
原生即时布局

Auto Layout / measure-layout
同步契约是否显式实现
双重布局

帧内两次测量
手势与焦点竞争

父级滚动被吞
不稳定 lambda

原生视图重建
固定边界

Modifier.fillMaxSize()
requestDisallowInterceptTouchEvent

shouldRecognizeSimultaneouslyWith

焦点双向桥接
remember 稳定 lambda

update 幂等

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 着色器编译在主线程上完成,新出现的混合模式、裁剪形状或渐变类型可能阻塞数毫秒,普通帧时间平均值会掩盖这类尖峰。

把这条归因链画成决策路径,避免在错误的阶段打补丁:
#mermaid-svg-QoyEBxbN4wjnbG9f{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QoyEBxbN4wjnbG9f .error-icon{fill:#552222;}#mermaid-svg-QoyEBxbN4wjnbG9f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QoyEBxbN4wjnbG9f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QoyEBxbN4wjnbG9f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QoyEBxbN4wjnbG9f .marker.cross{stroke:#333333;}#mermaid-svg-QoyEBxbN4wjnbG9f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QoyEBxbN4wjnbG9f p{margin:0;}#mermaid-svg-QoyEBxbN4wjnbG9f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f .cluster-label text{fill:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f .cluster-label span{color:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f .cluster-label span p{background-color:transparent;}#mermaid-svg-QoyEBxbN4wjnbG9f .label text,#mermaid-svg-QoyEBxbN4wjnbG9f span{fill:#333;color:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f .node rect,#mermaid-svg-QoyEBxbN4wjnbG9f .node circle,#mermaid-svg-QoyEBxbN4wjnbG9f .node ellipse,#mermaid-svg-QoyEBxbN4wjnbG9f .node polygon,#mermaid-svg-QoyEBxbN4wjnbG9f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QoyEBxbN4wjnbG9f .rough-node .label text,#mermaid-svg-QoyEBxbN4wjnbG9f .node .label text,#mermaid-svg-QoyEBxbN4wjnbG9f .image-shape .label,#mermaid-svg-QoyEBxbN4wjnbG9f .icon-shape .label{text-anchor:middle;}#mermaid-svg-QoyEBxbN4wjnbG9f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QoyEBxbN4wjnbG9f .rough-node .label,#mermaid-svg-QoyEBxbN4wjnbG9f .node .label,#mermaid-svg-QoyEBxbN4wjnbG9f .image-shape .label,#mermaid-svg-QoyEBxbN4wjnbG9f .icon-shape .label{text-align:center;}#mermaid-svg-QoyEBxbN4wjnbG9f .node.clickable{cursor:pointer;}#mermaid-svg-QoyEBxbN4wjnbG9f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QoyEBxbN4wjnbG9f .arrowheadPath{fill:#333333;}#mermaid-svg-QoyEBxbN4wjnbG9f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QoyEBxbN4wjnbG9f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QoyEBxbN4wjnbG9f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QoyEBxbN4wjnbG9f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QoyEBxbN4wjnbG9f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QoyEBxbN4wjnbG9f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QoyEBxbN4wjnbG9f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QoyEBxbN4wjnbG9f .cluster text{fill:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f .cluster span{color:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QoyEBxbN4wjnbG9f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QoyEBxbN4wjnbG9f rect.text{fill:none;stroke-width:0;}#mermaid-svg-QoyEBxbN4wjnbG9f .icon-shape,#mermaid-svg-QoyEBxbN4wjnbG9f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QoyEBxbN4wjnbG9f .icon-shape p,#mermaid-svg-QoyEBxbN4wjnbG9f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QoyEBxbN4wjnbG9f .icon-shape .label rect,#mermaid-svg-QoyEBxbN4wjnbG9f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QoyEBxbN4wjnbG9f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QoyEBxbN4wjnbG9f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QoyEBxbN4wjnbG9f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否,重组为零
是
否
是
否
帧率异常
重组次数是否异常
组合阶段

检查状态读取与 key
布局耗时是否超预算
布局阶段

检查双重测量与约束协商
绘制阶段

Canvas / drawText / layer 失效
应用层修复

离屏位图缓存
是否仍劣于原生基线
框架层回归

CMP-8491,升级 1.9.0
验收通过

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 步是分水岭。降级路径只有被真正走过,才算存在。

宿主接入与降级路径的整体结构如下------开关与单页回退是两条独立的退出通道:
#mermaid-svg-49hxnYxrzwU70RMx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-49hxnYxrzwU70RMx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-49hxnYxrzwU70RMx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-49hxnYxrzwU70RMx .error-icon{fill:#552222;}#mermaid-svg-49hxnYxrzwU70RMx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-49hxnYxrzwU70RMx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-49hxnYxrzwU70RMx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-49hxnYxrzwU70RMx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-49hxnYxrzwU70RMx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-49hxnYxrzwU70RMx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-49hxnYxrzwU70RMx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-49hxnYxrzwU70RMx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-49hxnYxrzwU70RMx .marker.cross{stroke:#333333;}#mermaid-svg-49hxnYxrzwU70RMx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-49hxnYxrzwU70RMx p{margin:0;}#mermaid-svg-49hxnYxrzwU70RMx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-49hxnYxrzwU70RMx .cluster-label text{fill:#333;}#mermaid-svg-49hxnYxrzwU70RMx .cluster-label span{color:#333;}#mermaid-svg-49hxnYxrzwU70RMx .cluster-label span p{background-color:transparent;}#mermaid-svg-49hxnYxrzwU70RMx .label text,#mermaid-svg-49hxnYxrzwU70RMx span{fill:#333;color:#333;}#mermaid-svg-49hxnYxrzwU70RMx .node rect,#mermaid-svg-49hxnYxrzwU70RMx .node circle,#mermaid-svg-49hxnYxrzwU70RMx .node ellipse,#mermaid-svg-49hxnYxrzwU70RMx .node polygon,#mermaid-svg-49hxnYxrzwU70RMx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-49hxnYxrzwU70RMx .rough-node .label text,#mermaid-svg-49hxnYxrzwU70RMx .node .label text,#mermaid-svg-49hxnYxrzwU70RMx .image-shape .label,#mermaid-svg-49hxnYxrzwU70RMx .icon-shape .label{text-anchor:middle;}#mermaid-svg-49hxnYxrzwU70RMx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-49hxnYxrzwU70RMx .rough-node .label,#mermaid-svg-49hxnYxrzwU70RMx .node .label,#mermaid-svg-49hxnYxrzwU70RMx .image-shape .label,#mermaid-svg-49hxnYxrzwU70RMx .icon-shape .label{text-align:center;}#mermaid-svg-49hxnYxrzwU70RMx .node.clickable{cursor:pointer;}#mermaid-svg-49hxnYxrzwU70RMx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-49hxnYxrzwU70RMx .arrowheadPath{fill:#333333;}#mermaid-svg-49hxnYxrzwU70RMx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-49hxnYxrzwU70RMx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-49hxnYxrzwU70RMx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-49hxnYxrzwU70RMx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-49hxnYxrzwU70RMx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-49hxnYxrzwU70RMx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-49hxnYxrzwU70RMx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-49hxnYxrzwU70RMx .cluster text{fill:#333;}#mermaid-svg-49hxnYxrzwU70RMx .cluster span{color:#333;}#mermaid-svg-49hxnYxrzwU70RMx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-49hxnYxrzwU70RMx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-49hxnYxrzwU70RMx rect.text{fill:none;stroke-width:0;}#mermaid-svg-49hxnYxrzwU70RMx .icon-shape,#mermaid-svg-49hxnYxrzwU70RMx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-49hxnYxrzwU70RMx .icon-shape p,#mermaid-svg-49hxnYxrzwU70RMx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-49hxnYxrzwU70RMx .icon-shape .label rect,#mermaid-svg-49hxnYxrzwU70RMx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-49hxnYxrzwU70RMx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-49hxnYxrzwU70RMx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-49hxnYxrzwU70RMx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} true
true
false
共享层 SettingsScreen
SettingsFeatureFlag.useComposeUi
Android: setContent / ComposeView
iOS: ComposeUIViewController

  • UIViewControllerRepresentable
    宿主级开关回退

setContentView / NativeSettingsView
单页回退

ComposeView 换回原 XML
单页回退

换回纯 SwiftUI 视图
原生兜底

不 import shared

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"之间选择深度------而选择深度的依据,是上述可验证的工程条件,不是别人用了多深。

相关推荐
2501_944525765 小时前
开源鸿蒙平台 KMP_CMP 三方库「Kaml」适配全流程
开源·kotlin·harmonyos
晓蛋5 小时前
什么是C语言
c语言·跨平台·编程语言·高级语言·低级语言
传奇开心果编程19 小时前
【Jetpack Compose娓娓道来】第26课:Repository模式与数据层设计——数据的唯一入口
android·移动开发·1024程序员节·jetpack compose·编程语言理论·编程框架·声明式ui
传奇开心果编程21 小时前
从替代到粒度:Compose Multiplatform 的决策模式重构已开启跨平台新时代
kotlin·移动开发·跨平台·cmp·编程语言理论·编程框架·声明式ui
三少爷的鞋1 天前
Kotlin 协程,重点就在「协」这个字上
android·kotlin
贾伟康1 天前
【健康菜谱助手|06】HarmonyOS ArkTS 烹饪计时器实战:处理倒计时、暂停、恢复和到期反馈
软件测试·移动开发·harmonyos·arkts·arkui
贾伟康1 天前
【健康菜谱助手|05】HarmonyOS ArkTS 购物清单实战:去重食材并同步勾选、数量与清空动作
软件测试·移动开发·harmonyos·arkts·arkui
贾伟康2 天前
【健康菜谱助手|01】HarmonyOS ArkTS 菜谱详情页实战:组织食材、营养、步骤和收藏状态
软件测试·移动开发·harmonyos·arkts·arkui
恶猫2 天前
7-Zip - 免费开源的压缩解压王者
开源·免费·跨平台·winrar·7-zip·压缩·解压缩