第1课到第9课,我们深耕了 Android 平台的 Compose UI 设计。第10课,我们将视野扩展到跨平台。Compose Multiplatform(CMP)是 JetBrains 基于 Jetpack Compose 打造的跨平台 UI 框架,允许你用一套 Kotlin 代码构建 Android、iOS、桌面(Windows/macOS/Linux)和 Web 应用。
本课用真实 App 场景深入理解:KMP 与 CMP 的关系、项目结构、共享 UI 编写、expect/actual 平台差异处理、资源管理、状态管理、导航、性能优化和测试策略。
每个案例都按“真实场景 → 设计目标 → 界面拆解 → 完整代码(逐行注释) → UI 设计解读 → 常见陷阱 → 可改进方向(含参考答案) → 关键要点”展开。
课后共 8 道练习,每道练习后紧跟参考答案(逐行注释)和设计解读。

一、为什么需要 Compose Multiplatform?

前九课我们做的所有界面,都只能在 Android 上运行。但真实的产品需求往往是“一套代码,多端运行”——Android、iOS、桌面、Web 都要覆盖。传统做法是每个平台写一遍 UI,维护多套代码库,成本极高。

一个真实的成本对比

假设你要做一个电商 App,覆盖 Android 和 iOS 两个平台。传统做法的成本:

工作项AndroidiOS合计
UI 开发40 人天40 人天80 人天
业务逻辑20 人天20 人天40 人天
测试15 人天15 人天30 人天
维护(每年)30 人天30 人天60 人天

用 CMP 的成本:

工作项共享代码Android 特定iOS 特定合计
UI 开发40 人天5 人天5 人天50 人天
业务逻辑20 人天2 人天2 人天24 人天
测试15 人天3 人天3 人天21 人天
维护(每年)30 人天5 人天5 人天40 人天

节省约 35%-40% 的总成本。 Respawn Pro 的 iOS 应用与 Android 共享了 96% 的代码,这不是个例。

KMP 与 CMP 的关系

Kotlin Multiplatform(KMP)允许你共享业务逻辑(网络请求、数据模型、验证规则),但 UI 仍然需要各平台原生编写。Compose Multiplatform 在 KMP 的基础上增加了一层:共享 UI 也可以跨平台复用。

用一个比喻: KMP 是发动机,CMP 是车身。KMP 让你共享动力系统(业务逻辑),CMP 让你共享外观设计(UI)。两者结合,就是一辆可以在不同道路上行驶的车。但要注意:不是所有路都适合这辆车——有些路(如复杂的原生动画)可能需要换轮胎(平台特定实现)。

CMP 的平台成熟度(截至 2025 年)

平台成熟度说明
AndroidStable基于 Jetpack Compose,生产可用
Desktop (JVM)StableWindows、macOS、Linux 生产可用
iOSStable(2025年5月)1.8.0 起稳定,可用于生产环境
Web (Wasm)Beta1.9.0 起进入 Beta

什么时候该用 CMP,什么时候不该用

适合 CMP 的场景:

  • 新项目,需要同时覆盖 Android 和 iOS。
  • 团队以 Android 开发者为主,不想维护两套 UI。
  • App 的 UI 复杂度中等,没有大量平台特定的复杂动画。
  • 需要覆盖桌面或 Web 平台。

不适合 CMP 的场景:

  • App 有大量平台特定的复杂动画或游戏渲染。
  • 需要深度集成平台特定的硬件功能(如 ARKit、Metal)。
  • 团队已经有成熟的 Swift 团队和原生 iOS 代码库。
  • 对 iOS 性能有极致要求(如 120fps 高刷游戏)。

CMP 项目结构

ComposeDemo/
├── shared/                          # 共享模块
│   └── src/
│       ├── commonMain/              # 公共代码(Compose UI、业务逻辑)
│       │   └── kotlin/
│       │       ├── App.kt           # 共享 UI 入口
│       │       └── ...
│       ├── androidMain/             # Android 平台特定代码
│       ├── iosMain/                 # iOS 平台特定代码
│       ├── desktopMain/             # 桌面平台特定代码
│       └── wasmJsMain/              # Web 平台特定代码
├── androidApp/                      # Android 应用模块
├── iosApp/                          # iOS Xcode 项目
├── desktopApp/                      # 桌面启动模块
└── build.gradle.kts                 # 构建配置

核心原则: 尽可能多的代码放在 commonMain,只在必要时使用 expect/actual 在平台特定源集中实现差异。一条经验法则:如果 commonMain 中的代码超过 80%,说明共享做得好;如果低于 50%,说明共享做得不够。

Android 到 CMP 的迁移路径

如果你已经有一个 Android Compose 项目,迁移到 CMP 的步骤:

  1. 创建 shared 模块。 把数据模型、Repository、ViewModel 移到 commonMain。
  2. 把 Compose UI 移到 commonMain。 大部分 Compose 代码可以直接迁移,只需要处理平台特定的 API。
  3. 用 expect/actual 替换 Android 特定 API。 如 Context、SharedPreferences、Toast。
  4. 创建 iOS、桌面、Web 入口。 各平台入口极简,只负责启动共享 UI。
  5. 逐个模块测试。 不要一次性迁移所有页面,按模块逐步迁移。

本课案例地图

案例核心技术真实场景
一项目创建 + 共享 UI第一个跨平台页面
二expect/actual 平台差异获取设备信息
三共享 ViewModel计数器状态管理
四跨平台导航多页面切换
五资源管理图片和字体
六平台互操作调用原生 API
七性能优化复杂列表
八综合案例 + 测试完整跨平台 App

案例一:第一个跨平台页面——共享 UI 的 Hello World

1. 真实场景

你刚刚创建了一个 Compose Multiplatform 项目,希望在 Android、iOS、桌面和 Web 上看到同一个页面。这是理解 CMP 共享 UI 机制的第一步。

2. 设计目标

  • 在 commonMain 中编写共享的 Composable。
  • 在各平台的入口点中调用共享 Composable。
  • 理解 shared 模块和平台入口的关系。

3. 界面拆解

  • commonMain 中的 App() Composable:所有平台共享的 UI。
  • Android:MainActivity 中 setContent { App() }。
  • iOS:MainViewController 中 ComposeUIViewController { App() }。
  • 桌面:main() 中 application { Window { App() } }。
  • Web:main() 中 ComposeViewport { App() }。

关键设计: commonMain 中的 Composable 是“平台无关”的,它只使用 CMP 提供的通用 API。平台特定的入口点负责“启动”这个共享 UI。

4. 完整代码(逐行注释)

// ============ shared/src/commonMain/kotlin/App.kt ============
package compose.project.demo

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun App() { // 共享 UI 入口
    MaterialTheme { // 应用 Material 3 主题
        Surface( // 表面容器
            modifier = Modifier.fillMaxSize(), // 占满屏幕
            color = MaterialTheme.colorScheme.background // 背景色
        ) {
            GreetingScreen() // 问候页面
        }
    }
}

@Composable
fun GreetingScreen() { // 问候页面
    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满屏幕
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text( // 标题
            text = "Hello Compose Multiplatform!", // 文字
            style = MaterialTheme.typography.headlineMedium, // 大标题
            fontWeight = FontWeight.Bold // 加粗
        )
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        Text( // 副标题
            text = "这套 UI 在 Android、iOS、桌面和 Web 上共享", // 文字
            style = MaterialTheme.typography.bodyLarge, // 大号正文
            color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
        )
    }
}
// ============ androidApp/src/main/kotlin/MainActivity.kt ============
package compose.project.demo

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class MainActivity : ComponentActivity() { // Android 入口
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { // 设置 Compose 内容
            App() // 调用共享 UI
        }
    }
}
// ============ shared/src/iosMain/kotlin/MainViewController.kt ============
package compose.project.demo

import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController

fun MainViewController(): UIViewController = // iOS 入口
    ComposeUIViewController { App() } // 包装共享 UI
// ============ desktopApp/src/main/kotlin/main.kt ============
package compose.project.demo

import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

fun main() = application { // 桌面入口
    Window( // 窗口
        onCloseRequest = ::exitApplication, // 关闭请求
        title = "Compose Multiplatform Demo" // 标题
    ) {
        App() // 调用共享 UI
    }
}

5. UI 设计解读

第一,commonMain 是共享的核心。 App() 和 GreetingScreen() 都放在 commonMain 中,它们使用 CMP 提供的通用 Compose API。这些代码在 Android 上编译为 Jetpack Compose,在 iOS 上编译为 Kotlin/Native 的 Compose 渲染,在桌面上编译为 JVM 的 Compose 渲染。

第二,各平台入口的职责。 每个平台只需要一个极简的入口点。Android 用 setContent,iOS 用 ComposeUIViewController,桌面用 Window,Web 用 ComposeViewport。入口点的工作是“启动共享 UI”,不包含业务逻辑。入口点应该尽可能薄——如果你发现入口点中有大量代码,说明该把代码移到 commonMain 了。

第三,Material 3 在 CMP 中可用。 MaterialTheme、Surface、Text、Button 等 Material 3 组件在 CMP 中完全可用。你不需要为每个平台重新设计 UI 组件。

第四,UI 设计意义。 共享 UI 的核心价值是“一次编写,多处运行”。你只需要在 commonMain 中设计一次界面,所有平台自动获得相同的视觉和交互。这并不意味着所有平台看起来完全一样——CMP 会自动适配各平台的滚动物理效果、返回手势等。

6. 常见陷阱

  • 在 commonMain 中使用 Android 特定的 API:如 android.content.Context,会导致编译失败。
  • 平台入口点中包含业务逻辑:应该保持入口点极简,逻辑放在 commonMain。
  • iOS 编译需要 macOS:iOS 目标只能在 macOS 上编译,这是 Kotlin/Native 的限制。
  • 忘记配置 iOS 的 Share UI 选项:创建项目时需要勾选,否则 iOS 不会使用共享 UI。
  • Web 平台忘记添加 wasmJs 目标:需要在 build.gradle.kts 中配置。

7. 可改进方向(含参考答案)

可改进点
  • 增加跨平台的主题切换。
  • 增加简单的交互(按钮点击计数)。
  • 使用 expect/actual 显示平台名称。
增强版参考答案
// commonMain
@Composable
fun App() { // 共享 UI
    MaterialTheme { // 主题
        PlatformGreeting() // 平台问候
    }
}

expect fun getPlatformName(): String // 期望函数:获取平台名称

@Composable
fun PlatformGreeting() { // 平台问候
    var count by remember { mutableStateOf(0) } // 计数

    Column( // 纵向布局
        modifier = Modifier.fillMaxSize().padding(16.dp), // 占满 + 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text( // 平台名称
            text = "Running on ${getPlatformName()}", // 调用期望函数
            style = MaterialTheme.typography.headlineMedium // 大标题
        )
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        Button(onClick = { count++ }) { // 按钮
            Text("Clicked $count times") // 文字
        }
    }
}

// androidMain
actual fun getPlatformName(): String = "Android" // Android 实现

// iosMain
actual fun getPlatformName(): String = "iOS" // iOS 实现

// desktopMain
actual fun getPlatformName(): String = "Desktop" // 桌面实现
改进解读

expect/actual 机制让 commonMain 声明“需要什么”,平台源集提供“具体实现”。getPlatformName() 在 commonMain 中用 expect 声明,在 androidMain、iosMain、desktopMain 中用 actual 实现。编译时,Kotlin 编译器会根据目标平台自动选择正确的 actual 实现。注意:actual 不能有默认实现,必须有具体的返回语句。

8. 关键要点

  • commonMain 存放共享 UI 和业务逻辑,平台源集存放平台特定代码。
  • 各平台入口点保持极简,只负责启动共享 UI。
  • Material 3 组件在 CMP 中完全可用。
  • expect/actual 是处理平台差异的标准机制。
  • iOS 编译需要 macOS 和 Xcode。

案例二:expect/actual——用平台差异实现设备信息展示

1. 真实场景

你在做一个设置页面,需要显示当前设备的型号和操作系统版本。Android、iOS、桌面获取这些信息的方式完全不同。你用 expect/actual 声明一个“获取设备信息”的接口,各平台提供具体实现。

2. 设计目标

  • 在 commonMain 中声明 expect 函数。
  • 在 androidMain、iosMain、desktopMain 中提供 actual 实现。
  • 在共享 UI 中调用 expect 函数,显示平台特定信息。

3. 界面拆解

  • commonMain:expect fun getDeviceInfo(): DeviceInfo。
  • androidMain:通过 Build.MODEL 和 Build.VERSION.SDK_INT 获取。
  • iosMain:通过 UIDevice.currentDevice 获取。
  • desktopMain:通过 System.getProperty("os.name") 获取。
  • 共享 UI:调用 getDeviceInfo() 并显示。

expect/actual 的三种形态:

形态用途示例
expect 函数平台特定逻辑getDeviceInfo()
expect 属性平台特定常量expect val platformName: String
expect 类平台特定实现expect class FileHandler

4. 完整代码(逐行注释)

// ============ commonMain ============
// 设备信息数据类
data class DeviceInfo( // 设备信息
    val model: String, // 型号
    val osVersion: String // 系统版本
)

expect fun getDeviceInfo(): DeviceInfo // 期望函数:获取设备信息

@Composable
fun DeviceInfoScreen() { // 设备信息页面
    val deviceInfo = remember { getDeviceInfo() } // 获取设备信息

    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满屏幕
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text( // 标题
            text = "设备信息", // 文字
            style = MaterialTheme.typography.headlineMedium, // 大标题
            fontWeight = FontWeight.Bold // 加粗
        )
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        Text( // 型号
            text = "型号:${deviceInfo.model}", // 文字
            style = MaterialTheme.typography.bodyLarge // 大号正文
        )
        Text( // 系统版本
            text = "系统:${deviceInfo.osVersion}", // 文字
            style = MaterialTheme.typography.bodyLarge // 大号正文
        )
    }
}
// ============ androidMain ============
actual fun getDeviceInfo(): DeviceInfo { // Android 实现
    return DeviceInfo( // 返回设备信息
        model = android.os.Build.MODEL, // 设备型号
        osVersion = "Android ${android.os.Build.VERSION.RELEASE}" // 系统版本
    )
}
// ============ iosMain ============
import platform.UIKit.UIDevice

actual fun getDeviceInfo(): DeviceInfo { // iOS 实现
    val device = UIDevice.currentDevice // 当前设备
    return DeviceInfo( // 返回设备信息
        model = device.model, // 设备型号
        osVersion = "${device.systemName} ${device.systemVersion}" // 系统版本
    )
}
// ============ desktopMain ============
actual fun getDeviceInfo(): DeviceInfo { // 桌面实现
    return DeviceInfo( // 返回设备信息
        model = System.getProperty("os.arch") ?: "Unknown", // 架构
        osVersion = "${System.getProperty("os.name")} ${System.getProperty("os.version")}" // 系统
    )
}

5. UI 设计解读

第一,expect/actual 的核心机制。 commonMain 中用 expect 声明一个函数签名,各平台源集中用 actual 提供实现。编译时,Kotlin 编译器根据目标平台自动选择正确的 actual 实现。这比接口 + 依赖注入更轻量,适合简单的平台差异。

第二,expect/actual 的适用场景。 它适合“同一功能,不同平台实现方式不同”的场景。比如:获取设备信息、读写文件、调用原生 API、平台特定的权限请求。

第三,expect/actual 的局限性。 expect/actual 是编译期绑定,不支持运行时替换。如果需要更灵活的依赖注入,应该用 Koin 或 Kodein 等跨平台 DI 框架。当 expect/actual 超过 10 个时,考虑用 DI 框架替代。

第四,UI 设计意义。 设备信息展示是一个典型的“平台差异”场景。共享 UI 层不关心设备信息如何获取,只关心“有设备信息可以展示”。这种分离让 commonMain 保持纯净,平台特定逻辑隔离在各自的源集中。

6. 常见陷阱

  • expect 函数不能有默认实现:expect 只声明签名,实现必须在 actual 中。
  • actual 的签名必须与 expect 完全一致:包括参数类型、返回类型。
  • 忘记为某个平台提供 actual 实现:编译时会报错。
  • 在 commonMain 中直接使用平台 API:应该通过 expect/actual 封装。
  • expect/actual 类型不匹配:如 expect 返回 String,actual 返回 String?,会编译失败。

7. 可改进方向(含参考答案)

可改进点
  • 增加更多平台信息(屏幕尺寸、语言、时区)。
  • 用 expect/actual 实现平台特定的权限请求。
  • 用 Koin 替代 expect/actual 实现更灵活的依赖注入。
增强版参考答案
// commonMain
data class PlatformInfo( // 平台信息
    val name: String, // 平台名称
    val version: String, // 版本
    val isDarkMode: Boolean // 是否深色模式
)

expect fun getPlatformInfo(): PlatformInfo // 期望函数

expect fun setStatusBarColor(color: Int) // 期望函数:设置状态栏颜色

// androidMain
actual fun getPlatformInfo(): PlatformInfo = PlatformInfo( // Android 实现
    name = "Android", // 名称
    version = android.os.Build.VERSION.RELEASE, // 版本
    isDarkMode = false // 深色模式(需要 Context,简化处理)
)

actual fun setStatusBarColor(color: Int) { // Android 实现
    // 通过 Activity 设置状态栏颜色
}

// iosMain
actual fun getPlatformInfo(): PlatformInfo = PlatformInfo( // iOS 实现
    name = "iOS", // 名称
    version = platform.UIKit.UIDevice.currentDevice.systemVersion, // 版本
    isDarkMode = false // 深色模式(需要 traitCollection)
)

actual fun setStatusBarColor(color: Int) { // iOS 实现
    // 通过 UIApplication 设置状态栏
}
改进解读

expect/actual 可以处理更复杂的平台差异,包括平台特定的副作用操作(如设置状态栏颜色)。当 expect/actual 变得过多时,考虑用 Koin 等 DI 框架替代,让 commonMain 通过接口调用平台实现。

8. 关键要点

  • expect/actual 是 CMP 处理平台差异的标准机制。
  • expect 声明在 commonMain,actual 实现在平台源集。
  • 适合简单的平台差异,复杂场景考虑 DI 框架。
  • actual 的签名必须与 expect 完全一致。

案例三:共享 ViewModel——跨平台状态管理

1. 真实场景

你在做一个计数器页面,需要在所有平台上共享状态和逻辑。在 Android 上,你会用 ViewModel + StateFlow。在 CMP 中,ViewModel 可以在 commonMain 中定义,所有平台共享同一套状态管理逻辑。

2. 设计目标

  • 在 commonMain 中定义 CounterViewModel。
  • 用 StateFlow 暴露状态。
  • 在共享 Composable 中收集状态。
  • 理解 CMP 中的 ViewModel 生命周期。

3. 界面拆解

  • CounterViewModel:持有 StateFlow<CounterState>。
  • CounterState:数据类,包含计数和加载状态。
  • CounterScreen:收集状态,显示 UI,发送事件。

CMP 中 ViewModel 的关键差异: Android 的 ViewModel 依赖 ViewModelStoreOwner(通常是 Activity)。在 CMP 中,Compose Multiplatform 实现了 commonViewModelStoreOwner 接口,所以在 commonMain 中使用 ViewModel 与 Android 最佳实践几乎相同。

4. 完整代码(逐行注释)

// ============ commonMain ============
// 状态数据类
data class CounterState( // 计数器状态
    val count: Int = 0, // 计数
    val isLoading: Boolean = false // 加载中
)

// ViewModel
class CounterViewModel : ViewModel() { // 计数器 ViewModel
    private val _state = MutableStateFlow(CounterState()) // 私有可变状态
    val state: StateFlow<CounterState> = _state.asStateFlow() // 公开只读状态

    fun increment() { // 增加
        _state.update { it.copy(count = it.count + 1) } // 更新计数
    }

    fun decrement() { // 减少
        _state.update { it.copy(count = it.count - 1) } // 更新计数
    }

    fun reset() { // 重置
        _state.update { it.copy(count = 0) } // 重置
    }
}

// 页面
@Composable
fun CounterScreen( // 计数器页面
    viewModel: CounterViewModel = viewModel { CounterViewModel() } // 注入 ViewModel
) {
    val state by viewModel.state.collectAsStateWithLifecycle() // 收集状态

    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满屏幕
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text( // 标题
            text = "共享计数器", // 文字
            style = MaterialTheme.typography.headlineMedium, // 大标题
            fontWeight = FontWeight.Bold // 加粗
        )

        Spacer(modifier = Modifier.height(24.dp)) // 间距

        Text( // 计数
            text = state.count.toString(), // 数字
            style = MaterialTheme.typography.displayLarge, // 超大标题
            fontWeight = FontWeight.Bold, // 加粗
            color = MaterialTheme.colorScheme.primary // 主题色
        )

        Spacer(modifier = Modifier.height(24.dp)) // 间距

        Row( // 按钮行
            horizontalArrangement = Arrangement.spacedBy(12.dp) // 间距
        ) {
            OutlinedButton(onClick = { viewModel.decrement() }) { // 减少
                Text("−") // 减号
            }
            Button(onClick = { viewModel.increment() }) { // 增加
                Text("+") // 加号
            }
        }

        Spacer(modifier = Modifier.height(12.dp)) // 间距

        TextButton(onClick = { viewModel.reset() }) { // 重置
            Text("重置") // 文字
        }
    }
}

5. UI 设计解读

第一,viewModel { CounterViewModel() } 的用法。 在 CMP 中,viewModel 函数需要一个初始化器,因为 CMP 不支持反射(wasmJs 和 iOS 目标)。viewModel { CounterViewModel() } 显式创建 ViewModel 实例,然后由 ViewModelStoreOwner 管理其生命周期。

第二,collectAsStateWithLifecycle 在 CMP 中可用。 CMP 提供了跨平台的 collectAsStateWithLifecycle,让状态收集遵循生命周期。在 Android 上,它等同于 androidx.lifecycle.compose.collectAsStateWithLifecycle。

第三,ViewModel 在 CMP 中的生命周期。 CMP 的 ViewModelStoreOwner 在 Android 上映射为 Activity,在 iOS 上映射为 UIViewController,在桌面上映射为 Window。ViewModel 在配置变更后保留,与 Android 行为一致。

第四,UI 设计意义。 共享 ViewModel 让状态管理逻辑在所有平台上完全一致。你不需要为 iOS 写一套状态管理,为 Android 写另一套。一套逻辑,所有平台运行。

6. 常见陷阱

  • 忘记传初始化器:viewModel<CounterViewModel>() 在 CMP 中可能失败,应该用 viewModel { CounterViewModel() }。
  • 在 commonMain 中使用 Android 的 viewModelScope:应该用 CMP 提供的 viewModelScope。
  • ViewModel 中持有平台特定对象:如 Context、UIViewController,会导致泄漏。
  • 状态更新不是不可变的:应该用 _state.update { it.copy(...) } 创建新状态。
  • 在 iOS 上忘记配置 ViewModelStoreOwner:CMP 会自动处理,但需要确保 ComposeUIViewController 正确初始化。

7. 可改进方向(含参考答案)

可改进点
  • 增加加载状态和错误处理。
  • 用 Event Sink 模式替代多个回调。
  • 用 Koin 注入 ViewModel。
增强版参考答案
// Event Sink 模式
sealed interface CounterEvent { // 计数器事件
    data object Increment : CounterEvent // 增加
    data object Decrement : CounterEvent // 减少
    data object Reset : CounterEvent // 重置
}

class CounterViewModelEnhanced : ViewModel() { // 增强版 ViewModel
    private val _state = MutableStateFlow(CounterState()) // 状态
    val state: StateFlow<CounterState> = _state.asStateFlow() // 公开状态

    fun onEvent(event: CounterEvent) { // 处理事件
        when (event) { // 根据事件
            CounterEvent.Increment -> _state.update { it.copy(count = it.count + 1) } // 增加
            CounterEvent.Decrement -> _state.update { it.copy(count = it.count - 1) } // 减少
            CounterEvent.Reset -> _state.update { it.copy(count = 0) } // 重置
        }
    }
}

@Composable
fun CounterScreenEnhanced(viewModel: CounterViewModelEnhanced = viewModel { CounterViewModelEnhanced() }) {
    val state by viewModel.state.collectAsStateWithLifecycle() // 收集状态

    Column { // 纵向布局
        Text(state.count.toString()) // 计数
        Row { // 按钮行
            OutlinedButton(onClick = { viewModel.onEvent(CounterEvent.Decrement) }) { Text("−") } // 减少
            Button(onClick = { viewModel.onEvent(CounterEvent.Increment) }) { Text("+") } // 增加
            TextButton(onClick = { viewModel.onEvent(CounterEvent.Reset) }) { Text("重置") } // 重置
        }
    }
}
改进解读

Event Sink 模式用密封接口定义所有事件,ViewModel 暴露一个 onEvent 方法,而不是多个回调。这让 Composable 只需要传递一个 lambda,而不是多个。这是 KMP/CMP 项目的推荐模式。

8. 关键要点

  • ViewModel 可以在 commonMain 中定义,所有平台共享。
  • 用 viewModel { CounterViewModel() } 显式初始化。
  • collectAsStateWithLifecycle 在 CMP 中可用。
  • Event Sink 模式适合复杂页面的状态管理。

案例四:跨平台导航——用 Navigation 库实现多页面切换

1. 真实场景

你在做一个多页面 App,从首页进入详情页,再返回首页。你需要一套在所有平台上都能工作的导航方案。

2. 设计目标

  • 用 androidx.navigation 的 CMP 版本实现导航。
  • 用类型安全路由定义目的地。
  • 在 commonMain 中定义 NavHost。
  • 各平台自动适配返回手势。

3. 界面拆解

  • @Serializable 路由:HomeRoute、DetailRoute。
  • NavHost:在 commonMain 中定义。
  • NavController:管理返回栈。
  • 浏览器导航:Web 平台自动绑定浏览器历史。

4. 完整代码(逐行注释)

// ============ commonMain ============
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute

@Serializable data object HomeRoute // 首页路由
@Serializable data class DetailRoute(val id: String) // 详情路由

@Composable
fun App() { // 应用入口
    MaterialTheme { // 主题
        AppNavHost() // 导航容器
    }
}

@Composable
fun AppNavHost() { // 导航容器
    val navController = rememberNavController() // 导航控制器

    NavHost( // 导航图
        navController = navController, // 绑定
        startDestination = HomeRoute // 起始页
    ) {
        composable<HomeRoute> { // 首页
            HomeScreen( // 首页组件
                onNavigateToDetail = { id -> // 跳转详情
                    navController.navigate(DetailRoute(id)) // 导航
                }
            )
        }
        composable<DetailRoute> { backStackEntry -> // 详情页
            val detail: DetailRoute = backStackEntry.toRoute() // 提取参数
            DetailScreen( // 详情组件
                id = detail.id, // 参数
                onBack = { navController.popBackStack() } // 返回
            )
        }
    }
}

@Composable
fun HomeScreen(onNavigateToDetail: (String) -> Unit) { // 首页
    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text("首页", style = MaterialTheme.typography.headlineMedium) // 标题
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        Button(onClick = { onNavigateToDetail("123") }) { // 跳转按钮
            Text("进入详情") // 文字
        }
    }
}

@Composable
fun DetailScreen(id: String, onBack: () -> Unit) { // 详情页
    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text("详情页 ID: $id", style = MaterialTheme.typography.headlineMedium) // 标题
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        Button(onClick = onBack) { // 返回
            Text("返回") // 文字
        }
    }
}

5. UI 设计解读

第一,CMP 的导航库来自 AndroidX。 Compose Multiplatform 团队为 AndroidX Navigation 库贡献了多平台支持。导航 API 与 Android 上的 NavHost、composable、rememberNavController 完全一致。这意味着 Android 开发者几乎不需要学习新 API。

第二,类型安全路由在 CMP 中可用。 @Serializable 的 object 和 data class 定义路由,backStackEntry.toRoute<DetailRoute>() 提取参数。这与 Android 上的用法完全一致。

第三,返回手势的平台适配。 CMP 的导航库会自动将各平台的返回手势转换为“导航到上一页”。Android 的返回手势、iOS 的滑动返回、Web 的浏览器后退按钮都会触发相同的导航行为。你不需要为每个平台单独处理返回逻辑。

第四,Web 平台的浏览器导航。 在 Web 上,导航库支持 bindToBrowserNavigation(),让 App 的导航与浏览器历史同步。用户按浏览器后退按钮时,App 会返回上一页。

第五,UI 设计意义。 导航是 App 的骨架。共享导航意味着用户在所有平台上获得一致的页面切换体验,开发者只需要维护一套导航逻辑。

6. 常见陷阱

  • 忘记添加导航依赖:需要在 commonMain 中添加 androidx.navigation:navigation-compose 的 CMP 版本。
  • 路由参数类型不匹配:DetailRoute("123") 的参数是 String,不能用 Int。
  • 在 commonMain 中使用 Android 特定的导航 API:如 NavController.currentBackStackEntryAsState 在 CMP 中可用,但某些 Android 特有的扩展可能不可用。
  • Web 平台忘记绑定浏览器导航:需要调用 navController.bindToBrowserNavigation()。
  • iOS 上返回手势与导航冲突:CMP 会自动处理,但如果自定义了手势,需要注意冲突。

7. 可改进方向(含参考答案)

可改进点
  • 增加底部导航栏。
  • 用 Navigation 3 替代传统导航。
  • 增加导航参数传递。
增强版参考答案
// Navigation 3 的 CMP 支持
// Compose Multiplatform 1.10 起支持 Navigation 3

@Serializable data object HomeKey : NavKey // 首页 Key
@Serializable data class DetailKey(val id: String) : NavKey // 详情 Key

@Composable
fun AppNav3() { // Navigation 3 应用
    val backStack = rememberNavBackStack(HomeKey) // 返回栈

    NavDisplay( // 导航显示
        backStack = backStack, // 返回栈
        onBack = { repeat(it) { backStack.removeLastOrNull() } }, // 返回处理
        entryProvider = entryProvider { // 条目提供者
            entry<HomeKey> { // 首页
                HomeScreen(onNavigateToDetail = { id -> backStack.add(DetailKey(id)) }) // 跳转
            }
            entry<DetailKey> { key -> // 详情页
                DetailScreen(id = key.id, onBack = { backStack.removeLastOrNull() }) // 返回
            }
        }
    )
}
改进解读

Navigation 3 在 Compose Multiplatform 1.10 中开始支持。它用 rememberNavBackStack 管理返回栈,用 NavDisplay 替代 NavHost,用 entryProvider 替代 NavGraph。导航状态变成一个普通的 SnapshotStateList,可以直接观察和操作。

8. 关键要点

  • CMP 的导航库来自 AndroidX Navigation,API 与 Android 一致。
  • 类型安全路由在 CMP 中完全可用。
  • 各平台的返回手势自动适配。
  • Web 平台支持浏览器导航绑定。
  • Navigation 3 从 CMP 1.10 起支持。

案例五:资源管理——跨平台图片和字体

1. 真实场景

你在做一个商品列表,需要显示商品图片和品牌字体。在 Android 上,你把图片放在 res/drawable,字体放在 res/font。在 CMP 中,资源的管理方式有所不同,需要使用 compose-multiplatform-resources 库。

2. 设计目标

  • 在 commonMain 的 composeResources 目录中管理图片和字体。
  • 用 Res.drawable.xxx 和 Res.font.xxx 访问资源。
  • 理解 CMP 资源的目录结构。

3. 界面拆解

  • composeResources/drawable/:图片资源(PNG、JPEG、WebP、XML 矢量图)。
  • composeResources/font/:字体资源(TTF、OTF)。
  • composeResources/values/:字符串资源(strings.xml 格式)。
  • Res 对象:自动生成的资源访问器。

4. 完整代码(逐行注释)

// ============ 目录结构 ============
// shared/src/commonMain/composeResources/
// ├── drawable/
// │   └── product_placeholder.png
// ├── font/
// │   └── inter_regular.ttf
// └── values/
//     └── strings.xml

// ============ 资源使用 ============
import compose.project.demo.generated.resources.Res
import compose.project.demo.generated.resources.product_placeholder
import compose.project.demo.generated.resources.inter_regular
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource

@Composable
fun ProductCard() { // 商品卡片
    val fontFamily = FontFamily( // 品牌字体
        Font(Res.font.inter_regular, FontWeight.Normal) // 从资源加载
    )

    Card( // 卡片
        modifier = Modifier.fillMaxWidth().padding(16.dp), // 占满宽度 + 外边距
        shape = MaterialTheme.shapes.medium // 圆角
    ) {
        Column { // 纵向布局
            Image( // 商品图片
                painter = painterResource(Res.drawable.product_placeholder), // 从资源加载
                contentDescription = "商品图片", // 描述
                modifier = Modifier
                    .fillMaxWidth() // 占满宽度
                    .height(200.dp), // 高度
                contentScale = ContentScale.Crop // 裁剪
            )
            Column(modifier = Modifier.padding(16.dp)) { // 文字区
                Text( // 商品名
                    text = "Compose 实战", // 文字
                    fontFamily = fontFamily, // 品牌字体
                    style = MaterialTheme.typography.titleMedium, // 中等标题
                    fontWeight = FontWeight.Bold // 加粗
                )
                Spacer(modifier = Modifier.height(4.dp)) // 间距
                Text( // 价格
                    text = "¥59.00", // 文字
                    fontFamily = fontFamily, // 品牌字体
                    style = MaterialTheme.typography.bodyLarge, // 大号正文
                    color = MaterialTheme.colorScheme.primary // 主题色
                )
            }
        }
    }
}

5. UI 设计解读

第一,CMP 资源管理的核心。 compose-multiplatform-resources 库提供了跨平台的资源访问。图片放在 composeResources/drawable/,字体放在 composeResources/font/,字符串放在 composeResources/values/。编译时,Gradle 插件生成 Res 对象,提供类型安全的资源访问。

第二,painterResource 和 Font 的用法。 painterResource(Res.drawable.xxx) 加载图片资源。Font(Res.font.xxx, FontWeight.Normal) 加载字体资源。这些 API 在所有平台上一致。

第三,CMP 支持的图片格式。 光栅化图像(JPEG、PNG、位图、WebP)以及矢量 Android XML 图像(不含对 Android 资源的引用)。在除 Android 外的平台上,SVG 文件也可以转换为 Painter 对象。

第四,字体回退。 CMP 支持自动字体回退,当指定字体缺少某个字符时,会自动使用系统默认字体。

第五,UI 设计意义。 资源管理是跨平台 UI 的基础。一套资源,所有平台共享。品牌字体、产品图片、颜色值都不需要重复定义。

6. 常见陷阱

  • 忘记添加 compose-multiplatform-resources 依赖:需要在 commonMain 中添加库依赖和 Gradle 插件。
  • 图片放在错误目录:必须在 composeResources/drawable/ 下,不是 res/drawable/。
  • 字体文件名包含大写字母:资源文件名必须全小写,用下划线分隔。
  • Web 平台加载大图片:Web 平台的资源加载机制与移动端不同,大图片可能影响加载速度。
  • XML 矢量图引用了 Android 资源:CMP 只支持不引用 Android 资源的 XML 矢量图。

7. 可改进方向(含参考答案)

可改进点
  • 用 stringResource 管理多语言字符串。
  • 用 pluralStringResource 管理复数。
  • 用 painterResource 加载不同密度的图片。
增强版参考答案
// strings.xml
// <resources>
//     <string name="product_name">Compose 实战</string>
//     <string name="product_price">¥%1$.2f</string>
// </resources>

@Composable
fun LocalizedProductCard() { // 本地化商品卡片
    Text( // 商品名
        text = stringResource(Res.string.product_name), // 从资源读取
        style = MaterialTheme.typography.titleMedium // 中等标题
    )
    Text( // 价格
        text = stringResource(Res.string.product_price, 59.0), // 格式化价格
        style = MaterialTheme.typography.bodyLarge // 大号正文
    )
}
改进解读

CMP 的 stringResource 与 Android 的 stringResource 用法一致,根据当前 Locale 返回对应翻译。pluralStringResource 处理复数。所有字符串资源统一管理在 composeResources/values/ 中。

8. 关键要点

  • CMP 资源放在 composeResources/ 目录下。
  • 用 Res.drawable.xxx、Res.font.xxx、Res.string.xxx 访问资源。
  • 支持光栅化图像和 XML 矢量图。
  • 字体文件名必须全小写。

案例六:平台互操作——在共享 UI 中调用原生组件

1. 真实场景

你在做一个视频播放页面,需要在共享 UI 中嵌入 Android 的 ExoPlayer 和 iOS 的 AVPlayer。CMP 提供了 AndroidView(Android)和 UIKitView(iOS)来实现平台互操作。

2. 设计目标

  • 用 expect/actual 声明一个平台特定的视频播放器 Composable。
  • Android 用 AndroidView 包装 ExoPlayer。
  • iOS 用 UIKitView 包装 AVPlayer。
  • 在共享 UI 中调用这个 Composable。

3. 界面拆解

  • commonMain:expect @Composable fun VideoPlayer(url: String)。
  • androidMain:actual @Composable fun VideoPlayer 用 AndroidView。
  • iosMain:actual @Composable fun VideoPlayer 用 UIKitView。
  • 共享 UI:调用 VideoPlayer(url)。

4. 完整代码(逐行注释)

// ============ commonMain ============
@Composable
expect fun VideoPlayer(url: String, modifier: Modifier = Modifier) // 期望函数

@Composable
fun VideoScreen() { // 视频页面
    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text( // 标题
            text = "视频播放", // 文字
            style = MaterialTheme.typography.headlineMedium // 大标题
        )
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        VideoPlayer( // 视频播放器
            url = "https://example.com/video.mp4", // 视频地址
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .height(300.dp) // 高度
                .clip(MaterialTheme.shapes.medium) // 圆角
        )
    }
}
// ============ androidMain ============
import android.view.ViewGroup
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerView
import com.google.android.exoplayer2.ExoPlayer

@Composable
actual fun VideoPlayer(url: String, modifier: Modifier) { // Android 实现
    val context = LocalContext.current // 上下文

    AndroidView( // AndroidView 包装原生 View
        factory = { ctx -> // 创建原生 View
            PlayerView(ctx).apply { // 创建 ExoPlayer View
                player = ExoPlayer.Builder(ctx).build().apply { // 创建播放器
                    setMediaItem(MediaItem.fromUri(url)) // 设置媒体
                    prepare() // 准备
                    playWhenReady = true // 自动播放
                }
            }
        },
        modifier = modifier // 修饰符
    )
}
// ============ iosMain ============
import androidx.compose.ui.interop.UIKitView
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerLayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVURLAsset
import platform.CoreGraphics.CGRectMake
import platform.Foundation.NSURL
import platform.UIKit.UIView

@Composable
actual fun VideoPlayer(url: String, modifier: Modifier) { // iOS 实现
    UIKitView( // UIKitView 包装 UIKit View
        factory = { // 创建 UIView
            val player = AVPlayer( // 创建播放器
                AVPlayerItem(AVURLAsset(NSURL(string = url))) // 设置媒体
            )
            UIView().apply { // 创建视图
                layer.addSublayer(AVPlayerLayer.playerLayerWithPlayer(player)) // 添加播放层
            }
        },
        modifier = modifier // 修饰符
    )
}

5. UI 设计解读

第一,AndroidView 和 UIKitView 的核心作用。 它们允许在 Compose UI 中嵌入原生 View。AndroidView 用于嵌入 Android 的 View(如 PlayerView),UIKitView 用于嵌入 iOS 的 UIView(如包含 AVPlayerLayer 的视图)。

第二,平台互操作的必要性。 有些功能 CMP 没有提供跨平台实现,比如视频播放、相机、地图。这些功能需要通过互操作调用平台原生 API。

第三,expect/actual 与互操作的结合。 commonMain 用 expect 声明 VideoPlayer,各平台用 actual 提供实现。共享 UI 调用 VideoPlayer(url),不关心底层是 ExoPlayer 还是 AVPlayer。

第四,UI 设计意义。 平台互操作让 CMP 可以覆盖所有功能。你不需要因为 CMP 没有某个组件而放弃使用它。共享 UI + 平台互操作 = 完整的跨平台方案。

6. 常见陷阱

  • 在 commonMain 中直接使用 AndroidView 或 UIKitView:这些是平台特定的,必须通过 expect/actual 隔离。
  • 忘记释放原生资源:ExoPlayer 和 AVPlayer 需要手动释放,否则会泄漏。
  • AndroidView 的 factory 中创建对象:factory 可能被多次调用,应该用 remember 缓存。
  • iOS 的 UIKitView 需要 background 参数:某些版本需要指定背景色,否则可能显示异常。
  • 平台互操作组件的尺寸测量问题:原生 View 的尺寸可能与 Compose 布局不同步,需要仔细处理 modifier。

7. 可改进方向(含参考答案)

可改进点
  • 用 DisposableEffect 释放播放器资源。
  • 增加播放控制按钮。
  • 用 remember 缓存原生 View 实例。
增强版参考答案
@Composable
actual fun VideoPlayerEnhanced(url: String, modifier: Modifier) { // 增强版
    val context = LocalContext.current // 上下文
    val player = remember { ExoPlayer.Builder(context).build() } // 记住播放器

    DisposableEffect(Unit) { // 副作用
        onDispose { // 离开时
            player.release() // 释放播放器
        }
    }

    AndroidView( // AndroidView
        factory = { ctx ->
            PlayerView(ctx).apply { // 创建视图
                this.player = player // 绑定播放器
            }
        },
        update = { view -> // 更新
            player.setMediaItem(MediaItem.fromUri(url)) // 设置媒体
            player.prepare() // 准备
            player.playWhenReady = true // 自动播放
        },
        modifier = modifier // 修饰符
    )
}
改进解读

DisposableEffect 在 Composable 离开组合时释放播放器资源,避免内存泄漏。remember 缓存播放器实例,避免在重组时重复创建。update 块在 url 变化时更新媒体源。

8. 关键要点

  • AndroidView 和 UIKitView 用于平台互操作。
  • 通过 expect/actual 在 commonMain 中声明,平台源集中实现。
  • 用 DisposableEffect 释放原生资源。
  • 用 remember 缓存原生 View 实例。

案例七:性能优化——CMP 中的列表和重组优化

1. 真实场景

你在做一个跨平台的长列表页面。在 Android 上运行流畅,但在 iOS 上出现掉帧。你需要用 Compose 性能优化技巧来提升 CMP 中的列表性能。

2. 设计目标

  • 用 LazyColumn 的 key 和 contentType 优化列表复用。
  • 用 @Immutable 标记数据类,让可组合函数可跳过重组。
  • 用 derivedStateOf 限制重组频率。
  • 理解 CMP 在 iOS 上的性能特点。

3. 界面拆解

  • @Immutable 数据类:让列表项可跳过重组。
  • key:稳定标识,保证列表更新时正确复用。
  • contentType:区分不同类型,提升复用效率。
  • derivedStateOf:派生状态,限制重组频率。

CMP 在 iOS 上的性能特点: 在复杂动画场景下,CMP 可能比原生 Swift 使用更多 CPU 并出现掉帧。对于简单动画和中等性能要求的应用,CMP 表现良好。

4. 完整代码(逐行注释)

// ============ 优化的数据类 ============
@Immutable // 标记不可变,让可组合函数可跳过重组
data class OptimizedProduct( // 优化的商品数据
    val id: Long, // 唯一 ID
    val name: String, // 名称
    val price: Double, // 价格
    val category: String // 分类
)

// ============ 优化的列表 ============
@Composable
fun OptimizedProductList(products: List<OptimizedProduct>) { // 优化商品列表
    var selectedCategory by remember { mutableStateOf("全部") } // 选中分类

    // 派生状态:只在过滤结果变化时触发
    val filteredProducts by remember { // 派生状态
        derivedStateOf { // 派生
            if (selectedCategory == "全部") products // 全部
            else products.filter { it.category == selectedCategory } // 过滤
        }
    }

    Column { // 纵向布局
        // 分类筛选
        LazyRow( // 横向列表
            horizontalArrangement = Arrangement.spacedBy(8.dp) // 间距
        ) {
            items(listOf("全部", "数码", "服饰", "食品")) { category -> // 遍历
                FilterChip( // 筛选标签
                    selected = selectedCategory == category, // 选中
                    onClick = { selectedCategory = category }, // 点击
                    label = { Text(category) } // 文字
                )
            }
        }

        // 商品列表
        LazyColumn( // 纵向列表
            contentPadding = PaddingValues(16.dp), // 内边距
            verticalArrangement = Arrangement.spacedBy(12.dp) // 间距
        ) {
            items(
                items = filteredProducts, // 数据
                key = { it.id }, // 稳定 key
                contentType = { it.category } // 内容类型
            ) { product ->
                OptimizedProductItem(product) // 商品项
            }
        }
    }
}

@Composable
fun OptimizedProductItem(product: OptimizedProduct) { // 优化的商品项
    // OptimizedProduct 是 @Immutable,当 product 未变化时可被跳过
    Card( // 卡片
        modifier = Modifier.fillMaxWidth(), // 占满宽度
        shape = MaterialTheme.shapes.medium // 圆角
    ) {
        Row( // 横向布局
            modifier = Modifier.padding(16.dp), // 内边距
            verticalAlignment = Alignment.CenterVertically // 垂直居中
        ) {
            Column(modifier = Modifier.weight(1f)) { // 信息列
                Text(product.name, style = MaterialTheme.typography.titleMedium) // 名称
                Text("¥${product.price}", color = MaterialTheme.colorScheme.primary) // 价格
            }
            Text(product.category, style = MaterialTheme.typography.labelSmall) // 分类
        }
    }
}

5. UI 设计解读

第一,@Immutable 的核心作用。 @Immutable 告诉 Compose 编译器:这个类的所有属性在创建后不会改变。编译器可以安全地跳过重组。在 CMP 中,@Immutable 同样有效,因为 Compose 编译器的稳定性分析是跨平台共享的。

第二,key 和 contentType 的跨平台有效性。 LazyColumn 的 key 和 contentType 在 CMP 的所有平台上都有效。它们帮助 Compose 精确追踪每个列表项,减少不必要的重组。

第三,derivedStateOf 在 CMP 中的使用。 derivedStateOf 在 CMP 中完全可用。它从频繁变化的状态派生低频变化的状态,减少重组次数。在 iOS 上,减少重组意味着减少 CPU 使用和帧延迟。

第四,CMP 在 iOS 上的性能特点。 根据研究,CMP 在 iOS 上的简单动画和中等性能场景表现良好,但在复杂动画场景下可能不如原生 Swift。如果你的 App 有大量复杂动画,应该考虑在 iOS 上使用原生 UI 或优化动画实现。

第五,UI 设计意义。 性能优化在 CMP 中比在纯 Android 中更重要,因为 iOS 上的渲染路径不同。同样的代码在 Android 上流畅,在 iOS 上可能卡顿。在所有目标平台上测试性能是 CMP 开发的必要步骤。

6. 常见陷阱

  • 在 iOS 上不测试性能:Android 流畅不代表 iOS 流畅,必须在所有平台上测试。
  • 使用复杂动画:CMP 在 iOS 上的复杂动画可能掉帧,考虑简化或使用原生动画。
  • 列表项数据类不稳定:List、Map 等类型导致无法跳过重组。
  • 忘记 key:列表更新时全量重组,滚动位置丢失。
  • 在 iOS 上未启用独立渲染线程:默认情况下渲染在主线程,可能导致卡顿。

7. 可改进方向(含参考答案)

可改进点
  • 在 iOS 上启用独立渲染线程。
  • 用 graphicsLayer 延迟状态读取。
  • 用 remember 缓存昂贵计算。
增强版参考答案
// iOS 入口启用独立渲染线程
fun MainViewController(): UIViewController = ComposeUIViewController( // iOS 入口
    configure = { // 配置
        parallelRendering = true // 启用并行渲染
        useSeparateRenderThreadWhenPossible = true // 独立渲染线程
    }
) {
    App() // 共享 UI
}

// 延迟状态读取
@Composable
fun DeferredAnimation() { // 延迟动画
    var offsetX by remember { mutableFloatStateOf(0f) } // 偏移

    LaunchedEffect(Unit) { // 启动协程
        while (true) { // 循环
            delay(16) // 60fps
            offsetX = (offsetX + 5f) % 300f // 更新
        }
    }

    Box( // 容器
        modifier = Modifier
            .size(50.dp) // 尺寸
            .graphicsLayer { translationX = offsetX } // 绘制阶段读取
            .background(MaterialTheme.colorScheme.primary) // 背景色
    )
}
改进解读

在 iOS 上启用 parallelRendering 和 useSeparateRenderThreadWhenPossible,将渲染任务卸载到专用渲染线程,提升性能。graphicsLayer 在绘制阶段读取状态,跳过组合和布局阶段。

8. 关键要点

  • @Immutable 让数据类稳定,可组合函数可跳过重组。
  • key 和 contentType 在所有平台上有效。
  • derivedStateOf 限制重组频率。
  • CMP 在 iOS 上的复杂动画可能掉帧,需要优化。
  • 在 iOS 上启用独立渲染线程提升性能。

案例八:综合案例——完整跨平台 App + 测试策略

1. 真实场景

结合本课所有知识,实现一个完整的跨平台 App:包含共享 UI、共享 ViewModel、跨平台导航、资源管理、平台互操作,并制定测试策略。

2. 设计目标

  • 共享 UI:commonMain 中定义所有页面。
  • 共享 ViewModel:状态管理逻辑跨平台。
  • 跨平台导航:类型安全路由。
  • 资源管理:图片和字体跨平台。
  • 平台互操作:显示平台信息。
  • 测试策略:逻辑层单元测试 + UI 测试。

3. 完整代码(逐行注释)

// ============ commonMain ============
// 数据
@Immutable
data class Product( // 商品
    val id: Long, // ID
    val name: String, // 名称
    val price: Double // 价格
)

// 路由
@Serializable data object HomeRoute // 首页
@Serializable data class DetailRoute(val productId: Long) // 详情

// ViewModel
class ProductViewModel : ViewModel() { // 商品 ViewModel
    private val _products = MutableStateFlow<List<Product>>(emptyList()) // 商品列表
    val products: StateFlow<List<Product>> = _products.asStateFlow() // 公开状态

    private val _isLoading = MutableStateFlow(false) // 加载中
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() // 公开状态

    init { // 初始化
        loadProducts() // 加载商品
    }

    private fun loadProducts() { // 加载商品
        viewModelScope.launch { // 启动协程
            _isLoading.value = true // 进入加载
            delay(500) // 模拟网络
            _products.value = listOf( // 模拟数据
                Product(1, "Compose 实战", 59.0),
                Product(2, "Kotlin 进阶", 49.0),
                Product(3, "Android 架构", 69.0)
            )
            _isLoading.value = false // 结束加载
        }
    }
}

// 导航
@Composable
fun App() { // 应用入口
    MaterialTheme { // 主题
        AppNavHost() // 导航容器
    }
}

@Composable
fun AppNavHost() { // 导航容器
    val navController = rememberNavController() // 导航控制器

    NavHost( // 导航图
        navController = navController, // 绑定
        startDestination = HomeRoute // 起始页
    ) {
        composable<HomeRoute> { // 首页
            HomeScreen( // 首页组件
                onProductClick = { id -> navController.navigate(DetailRoute(id)) } // 点击
            )
        }
        composable<DetailRoute> { backStackEntry -> // 详情页
            val detail: DetailRoute = backStackEntry.toRoute() // 提取参数
            DetailScreen( // 详情组件
                productId = detail.productId, // 商品 ID
                onBack = { navController.popBackStack() } // 返回
            )
        }
    }
}

// 首页
@Composable
fun HomeScreen( // 首页
    viewModel: ProductViewModel = viewModel { ProductViewModel() }, // ViewModel
    onProductClick: (Long) -> Unit // 点击回调
) {
    val products by viewModel.products.collectAsStateWithLifecycle() // 收集商品
    val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() // 收集加载状态

    if (isLoading) { // 加载中
        Box( // 容器
            modifier = Modifier.fillMaxSize(), // 占满
            contentAlignment = Alignment.Center // 居中
        ) {
            CircularProgressIndicator() // 进度条
        }
    } else { // 加载完成
        LazyColumn( // 列表
            contentPadding = PaddingValues(16.dp), // 内边距
            verticalArrangement = Arrangement.spacedBy(12.dp) // 间距
        ) {
            item { // 平台信息
                PlatformInfoCard() // 平台信息卡片
            }
            items(products, key = { it.id }) { product -> // 商品列表
                ProductCard(product, onClick = { onProductClick(product.id) }) // 商品卡片
            }
        }
    }
}

// 详情页
@Composable
fun DetailScreen(productId: Long, onBack: () -> Unit) { // 详情页
    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center, // 垂直居中
        horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
        Text("商品 $productId 详情", style = MaterialTheme.typography.headlineMedium) // 标题
        Spacer(modifier = Modifier.height(16.dp)) // 间距
        Button(onClick = onBack) { Text("返回") } // 返回按钮
    }
}

// 平台信息卡片
@Composable
fun PlatformInfoCard() { // 平台信息卡片
    Card( // 卡片
        modifier = Modifier.fillMaxWidth(), // 占满宽度
        shape = MaterialTheme.shapes.medium, // 圆角
        colors = CardDefaults.cardColors( // 颜色
            containerColor = MaterialTheme.colorScheme.primaryContainer // 主色容器
        )
    ) {
        Column(modifier = Modifier.padding(16.dp)) { // 内容
            Text( // 标题
                text = "运行平台", // 文字
                style = MaterialTheme.typography.labelMedium, // 小标签
                color = MaterialTheme.colorScheme.onPrimaryContainer // 对比色
            )
            Text( // 平台名称
                text = getPlatformName(), // 平台名称
                style = MaterialTheme.typography.titleLarge, // 大标题
                fontWeight = FontWeight.Bold, // 加粗
                color = MaterialTheme.colorScheme.onPrimaryContainer // 对比色
            )
        }
    }
}

expect fun getPlatformName(): String // 期望函数

// 商品卡片
@Composable
fun ProductCard(product: Product, onClick: () -> Unit) { // 商品卡片
    Card( // 卡片
        modifier = Modifier
            .fillMaxWidth() // 占满宽度
            .clickable { onClick() }, // 点击
        shape = MaterialTheme.shapes.medium // 圆角
    ) {
        Row( // 横向布局
            modifier = Modifier.padding(16.dp), // 内边距
            verticalAlignment = Alignment.CenterVertically // 垂直居中
        ) {
            Column(modifier = Modifier.weight(1f)) { // 信息列
                Text(product.name, style = MaterialTheme.typography.titleMedium) // 名称
                Text("¥${product.price}", color = MaterialTheme.colorScheme.primary) // 价格
            }
            Icon( // 箭头
                Icons.AutoMirrored.Filled.ArrowForward, // 自动镜像箭头
                contentDescription = "进入详情", // 描述
                tint = MaterialTheme.colorScheme.outline // 浅色
            )
        }
    }
}
// ============ 测试 ============
// commonTest
class ProductViewModelTest { // ViewModel 测试
    @Test // 测试:加载商品
    fun loadProducts_updatesState() = runTest { // 加载
        val viewModel = ProductViewModel() // ViewModel
        advanceUntilIdle() // 等待
        assertEquals(3, viewModel.products.value.size) // 断言数量
    }
}

4. UI 设计解读

第一,完整的 CMP 架构。 这个 App 包含了 CMP 的所有核心要素:commonMain 中的共享 UI(App、HomeScreen、DetailScreen)、共享 ViewModel(ProductViewModel)、跨平台导航(NavHost)、平台信息展示(getPlatformName)。

第二,测试策略。 commonTest 中的 ProductViewModelTest 在所有平台上运行。业务逻辑(ViewModel)的测试不需要平台特定代码,可以在 commonTest 中完成。

第三,平台信息展示的意图。 PlatformInfoCard 显示当前运行平台,让用户和开发者都能确认 App 正在哪个平台上运行。这是验证 CMP 配置是否正确的快捷方式。

第四,UI 设计意义。 完整的 CMP App 展示了跨平台开发的核心价值:一套代码,多端运行。共享 UI、共享状态、共享导航,平台特定代码隔离在极小的范围内。

5. 常见陷阱

  • 在 commonMain 中使用平台特定 API:会导致编译失败。
  • 忘记为某个平台提供 actual 实现:编译时报错。
  • 测试只覆盖 Android:应该在 commonTest 中编写跨平台测试。
  • iOS 上不测试 UI:CMP 在 iOS 上的渲染可能不同,需要实际测试。
  • 未处理平台特定的返回手势:CMP 自动处理,但自定义手势时需要注意冲突。

6. 关键要点

  • CMP 的核心是 commonMain 中的共享 UI 和逻辑。
  • 平台特定代码通过 expect/actual 隔离。
  • commonTest 中的测试在所有平台上运行。
  • 完整的 CMP App 需要覆盖所有目标平台的测试。

从案例中提炼的 Compose Multiplatform 设计原则

  1. commonMain 是共享的核心。 尽可能多的 UI 和逻辑放在 commonMain,平台特定代码隔离在平台源集。

  2. 用 expect/actual 处理平台差异。 声明在 commonMain,实现各平台提供。超过 10 个时考虑 DI 框架。

  3. 共享 ViewModel 用 viewModel { } 初始化。 CMP 不支持反射,需要显式初始化器。

  4. 导航用 AndroidX Navigation 的 CMP 版本。 API 与 Android 一致,各平台返回手势自动适配。

  5. 资源放在 composeResources/ 目录下。 用 Res.drawable.xxx、Res.font.xxx 访问。

  6. 平台互操作用 AndroidView 和 UIKitView。 通过 expect/actual 在 commonMain 中声明。

  7. 用 @Immutable 标记数据类。 让可组合函数可跳过重组,在所有平台上有效。

  8. 在 iOS 上启用独立渲染线程。 parallelRendering = true 提升性能。

  9. 在所有目标平台上测试性能。 Android 流畅不代表 iOS 流畅。

  10. commonTest 中的测试在所有平台上运行。 业务逻辑测试不需要平台特定代码。

  11. 入口点保持极简。 平台入口只负责启动共享 UI,不包含业务逻辑。

  12. CMP 不适合所有场景。 复杂动画、深度硬件集成的 App 可能需要原生实现。

CMP 常见问题速查表

问题原因解决方案
iOS 编译失败未在 macOS 上编译使用 macOS + Xcode
commonMain 编译失败使用了平台特定 API用 expect/actual 封装
ViewModel 创建失败缺少初始化器用 viewModel { }
资源找不到目录结构错误检查 composeResources/ 目录
iOS 返回手势冲突自定义手势与导航冲突让 CMP 自动处理返回手势
iOS 性能卡顿复杂动画或未启用独立渲染线程启用 parallelRendering
Web 浏览器后退失效未绑定浏览器导航调用 bindToBrowserNavigation()
字体显示异常字体文件名大写或格式不支持文件名全小写,用 TTF/OTF
平台互操作组件尺寸异常原生 View 尺寸未同步仔细处理 modifier 和 update

课后练习与参考答案

练习 1:创建第一个 CMP 页面

要求:在 commonMain 中创建一个显示“Hello CMP”的 Composable,在各平台入口中调用。

参考答案:

// commonMain
@Composable
fun HelloCmp() { // Hello CMP
    Box( // 容器
        modifier = Modifier.fillMaxSize(), // 占满
        contentAlignment = Alignment.Center // 居中
    ) {
        Text("Hello CMP", style = MaterialTheme.typography.headlineMedium) // 文字
    }
}

// Android
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MaterialTheme { HelloCmp() } }
    }
}

// iOS
fun MainViewController(): UIViewController = ComposeUIViewController { MaterialTheme { HelloCmp() } }

// Desktop
fun main() = application { Window(onCloseRequest = ::exitApplication) { MaterialTheme { HelloCmp() } } }

解读: HelloCmp 在 commonMain 中定义,各平台入口极简。这是 CMP 的基本模式。

练习 2:用 expect/actual 实现平台名称

要求:在 commonMain 中声明 expect fun getPlatformName(): String,各平台提供实现。

参考答案:

// commonMain
expect fun getPlatformName(): String // 期望函数

// androidMain
actual fun getPlatformName(): String = "Android" // Android 实现

// iosMain
actual fun getPlatformName(): String = "iOS" // iOS 实现

// desktopMain
actual fun getPlatformName(): String = "Desktop" // 桌面实现

// wasmJsMain
actual fun getPlatformName(): String = "Web" // Web 实现

解读: expect/actual 是 CMP 处理平台差异的标准机制。expect 声明签名,actual 提供实现。

练习 3:共享 ViewModel

要求:在 commonMain 中定义一个 ViewModel,管理计数状态。

参考答案:

class CounterViewModel : ViewModel() { // ViewModel
    private val _count = MutableStateFlow(0) // 计数
    val count: StateFlow<Int> = _count.asStateFlow() // 公开

    fun increment() { _count.update { it + 1 } } // 增加
    fun reset() { _count.update { 0 } } // 重置
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel { CounterViewModel() }) { // 页面
    val count by viewModel.count.collectAsStateWithLifecycle() // 收集
    Column { // 纵向布局
        Text(count.toString()) // 计数
        Button(onClick = { viewModel.increment() }) { Text("+") } // 增加
    }
}

解读: CMP 中的 ViewModel 与 Android 用法几乎相同,用 viewModel { } 初始化。

练习 4:跨平台导航

要求:用 NavHost 实现首页到详情页的导航。

参考答案:

@Serializable data object HomeRoute // 首页
@Serializable data class DetailRoute(val id: String) // 详情

@Composable
fun AppNav() { // 导航
    val navController = rememberNavController() // 导航控制器
    NavHost(navController, startDestination = HomeRoute) { // 导航图
        composable<HomeRoute> { // 首页
            Button(onClick = { navController.navigate(DetailRoute("1")) }) { Text("进入详情") }
        }
        composable<DetailRoute> { backStackEntry -> // 详情
            val detail = backStackEntry.toRoute<DetailRoute>() // 提取参数
            Text("详情 ID: ${detail.id}") // 文字
        }
    }
}

解读: CMP 的导航 API 与 Android 完全一致,类型安全路由可用。

练习 5:资源管理

要求:在 composeResources 中放置一张图片和一个字体,在 Composable 中使用。

参考答案:

// 目录结构
// composeResources/drawable/logo.png
// composeResources/font/inter.ttf

@Composable
fun ResourceDemo() { // 资源演示
    val fontFamily = FontFamily( // 字体
        Font(Res.font.inter, FontWeight.Normal) // 加载
    )
    Column { // 纵向布局
        Image( // 图片
            painter = painterResource(Res.drawable.logo), // 加载
            contentDescription = "Logo", // 描述
            modifier = Modifier.size(100.dp) // 尺寸
        )
        Text("品牌字体", fontFamily = fontFamily) // 文字
    }
}

解读: CMP 资源放在 composeResources/ 下,用 Res.drawable.xxx 和 Res.font.xxx 访问。

练习 6:平台互操作

要求:用 expect/actual 声明一个 Composable,在 Android 用 AndroidView,在 iOS 用 UIKitView。

参考答案:

// commonMain
@Composable
expect fun PlatformView(modifier: Modifier = Modifier) // 期望函数

// androidMain
@Composable
actual fun PlatformView(modifier: Modifier) { // Android 实现
    AndroidView(factory = { TextView(it).apply { text = "Android View" } }, modifier = modifier)
}

// iosMain
@Composable
actual fun PlatformView(modifier: Modifier) { // iOS 实现
    UIKitView(factory = { UILabel().apply { text = "iOS View" } }, modifier = modifier)
}

解读: AndroidView 和 UIKitView 用于平台互操作,通过 expect/actual 隔离在平台源集。

练习 7:性能优化

要求:用 @Immutable 和 key 优化一个列表。

参考答案:

@Immutable
data class OptimizedItem(val id: Long, val name: String) // 数据

@Composable
fun OptimizedList(items: List<OptimizedItem>) { // 列表
    LazyColumn { // 纵向列表
        items(items, key = { it.id }) { item -> // 遍历
            Text(item.name, modifier = Modifier.padding(16.dp)) // 文字
        }
    }
}

解读: @Immutable 让数据类稳定,key 保证列表正确复用。

练习 8:综合运用——跨平台 App

要求:创建一个包含共享 UI、共享 ViewModel、跨平台导航和平台信息的 CMP App。

参考答案:

// commonMain
@Serializable data object HomeRoute // 首页

@Composable
fun App() { // 应用
    MaterialTheme { // 主题
        val navController = rememberNavController() // 导航控制器
        NavHost(navController, startDestination = HomeRoute) { // 导航图
            composable<HomeRoute> { // 首页
                Column( // 纵向布局
                    modifier = Modifier.fillMaxSize().padding(16.dp), // 占满 + 内边距
                    verticalArrangement = Arrangement.Center, // 垂直居中
                    horizontalAlignment = Alignment.CenterHorizontally // 水平居中
                ) {
                    Text("Running on ${getPlatformName()}", style = MaterialTheme.typography.headlineMedium) // 平台名称
                }
            }
        }
    }
}

expect fun getPlatformName(): String // 平台名称

解读: 完整的 CMP App 包含共享 UI、导航和平台信息。一套代码,多端运行。

本课总结

第10课聚焦 Compose Multiplatform 与跨平台 UI 设计,用八个真实案例深入理解了:

  1. 项目创建 + 共享 UI:commonMain 中的 Composable 在所有平台共享。
  2. expect/actual :声明在 commonMain,实现在平台源集,处理平台差异。
  3. 共享 ViewModel:在 commonMain 中定义 ViewModel,用 viewModel { } 初始化。
  4. 跨平台导航:AndroidX Navigation 的 CMP 版本,API 与 Android 一致。
  5. 资源管理:composeResources/ 目录,Res.drawable.xxx、Res.font.xxx 访问。
  6. 平台互操作:AndroidView 和 UIKitView 嵌入原生 View。
  7. 性能优化:@Immutable、key、contentType 在所有平台上有效。
  8. 测试策略:commonTest 中的测试在所有平台上运行。

你需要记住:

  • CMP 的核心是 commonMain 中的共享 UI 和逻辑。
  • 平台特定代码通过 expect/actual 隔离。
  • 共享 ViewModel 用 viewModel { } 初始化。
  • 导航 API 与 Android 一致,各平台返回手势自动适配。
  • 资源放在 composeResources/ 目录下。
  • 平台互操作用 AndroidView 和 UIKitView。
  • 在 iOS 上启用独立渲染线程提升性能。
  • commonTest 中的测试在所有平台上运行。
  • CMP 不适合所有场景,复杂动画和深度硬件集成可能需要原生实现。
  • 每个案例后面都列出了常见陷阱,写代码时对照检查。

至此,《用案例学Jetpack Compose UI设计》系列已经完成了十课。从第1课的声明式 UI 认知,到第2课的布局系统,第3课的状态管理,第4课的导航架构,第5课的主题设计系统,第6课的动画过渡,第7课的可访问性与国际化,第8课的性能优化,第9课的测试与质量保证,再到第10课的跨平台实战——你已经走完了一个 Compose 开发者从 Android 到多端的全路径。

下一步,建议你选择一个真实项目,把本系列学到的知识应用到实践中。每完成一个页面,都问自己五个问题:这个界面的信息层级是什么?状态在哪里?如果屏幕变宽或变窄,它会怎样变化?这个界面流畅吗?我该怎么测试它?这五个问题,就是 Compose UI 设计的完整闭环。

Logo

一站式 AI 云服务平台

更多推荐