【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
本课目标:建立“测试金字塔”的架构意识,掌握
commonTest中 UI 测试的runComposeUiTest模式与桌面端 JUnit 的差异,学会用 Turbine 断言 StateFlow 的状态序列,掌握 Kotlin/Native 内存泄漏检测的 GC 统计方法,理解 iOS Skia 渲染的性能陷阱与 Instruments 诊断路径。
系列整体规划
| 课次 | 主题 | 核心内容 | 难度 |
|---|---|---|---|
| 第1课 | 从零开始 | 技术概览、环境搭建、第一个应用、代码解读 | ⭐ |
| 第2课 | Compose 基础语法 | @Composable、状态管理、重组机制、Modifier 体系 | ⭐⭐ |
| 第3课 | 布局与组件 | Column/Row/Box、LazyColumn、Material3 组件库 | ⭐⭐ |
| 第4课 | 导航与路由 | Navigation Compose、类型安全路由、深层链接 | ⭐⭐⭐ |
| 第5课 | 网络与数据层 | Ktor 客户端、序列化、Repository 模式 | ⭐⭐⭐ |
| 第6课 | 状态管理与架构 | ViewModel、单向数据流、依赖注入 | ⭐⭐⭐⭐ |
| 第7课 | 平台适配与互操作 | expect/actual、SwiftUI 互操作、平台特定 API | ⭐⭐⭐⭐ |
| 第8课 | 资源管理与主题 | 多平台资源、图片加载、深浅色主题 | ⭐⭐⭐ |
| 第9课 | 测试与调试 | Compose UI 测试、单元测试、性能分析 | ⭐⭐⭐⭐ |
| 第10课 | 发布与部署 | Android/iOS/桌面/Web 打包发布、CI/CD | ⭐⭐⭐⭐⭐ |
第9课 测试与调试
一、测试金字塔:CMP 视角
1.1 三层结构
软件测试通常被描述为一个金字塔:底层是大量快速、廉价的单元测试,中层是集成测试,顶层是少量昂贵的 UI 测试。CMP 项目的测试体系遵循同样的结构,但每层测试的“共享范围”不同。
第一层:单元测试(commonTest) 。ViewModel、UseCase、Repository 的逻辑、数据转换、状态校验,全部在 commonMain 中。这些测试写在 commonTest 中,一次编写,所有平台共享。这是 CMP 测试体系最大的优势——核心业务逻辑的测试不需要重复三遍。
第二层:集成测试(平台特定或 commonTest) 。验证多个组件协作。如果涉及平台 API(数据库、文件系统),测试需要放在平台特定的源集中,通过 expect/actual 暴露测试所需的平台能力。
第三层:UI 测试(commonTest + 平台源集) 。验证 Composable 的渲染和交互。CMP 提供了统一的测试 API,但运行机制在各平台有差异。
一个实用的覆盖率目标:ViewModel + UseCase 是每个功能的最低测试覆盖单元。UI 测试覆盖关键用户路径(登录、支付、导航),不需要覆盖每个 Composable。
1.2 为什么 Fake 优于 Mock
在 ViewModel 测试中,用手写的 Fake 实现替代 Mock 框架是 Kotlin 社区的推荐做法。原因有三:
CMP 跨平台兼容性。MockK 等框架在 Kotlin/Native 和 Wasm 上的支持有限,手写 Fake 没有任何平台依赖。
更简单的测试代码。Fake 就是一个实现了接口的普通类,带有可控的状态和错误注入能力:
class FakeTodoRepository : TodoRepository {
private val items = mutableListOf<TodoItem>()
var fetchError: Throwable? = null
override fun getAll() = items.toList()
override fun add(title: String): TodoItem {
val item = TodoItem(id = items.size + 1, title = title)
items.add(item)
return item
}
// ...
}
构造即注入。ViewModel 通过构造函数接收 Repository,测试时注入 Fake 即可,不需要依赖注入容器或反射。
二、Compose UI 测试
2.1 commonTest 与桌面端的机制差异
CMP 的 UI 测试 API 与 Jetpack Compose 共享同一套 finders、assertions、actions 和 matchers。但运行机制在 commonTest 和桌面端有本质区别。
commonTest 使用 runComposeUiTest 。它不依赖 JUnit 的 TestRule,而是调用 runComposeUiTest 函数,在 ComposeUiTest 接收者上执行断言:
@OptIn(ExperimentalTestApi::class)
@Test
fun myTest() = runComposeUiTest {
setContent {
var text by remember { mutableStateOf("Hello") }
Text(text, modifier = Modifier.testTag("text"))
Button(onClick = { text = "Compose" }, modifier = Modifier.testTag("button")) {
Text("Click me")
}
}
onNodeWithTag("text").assertTextEquals("Hello")
onNodeWithTag("button").performClick()
onNodeWithTag("text").assertTextEquals("Compose")
}
桌面端可以使用 JUnit 模式。在 desktopTest 源集中,用 createComposeRule() 配合 @get:Rule:
class ExampleTest {
@get:Rule
val rule = createComposeRule()
@Test
fun myTest() {
rule.setContent { /* UI */ }
rule.onNodeWithTag("text").assertTextEquals("Hello")
}
}
选择原则:如果测试逻辑可以在所有平台上运行,写在 commonTest 中使用 runComposeUiTest。如果测试依赖桌面端特有的窗口行为,写在 desktopTest 中使用 JUnit 模式。
2.2 依赖配置
在 composeApp/build.gradle.kts 中添加:
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTest) // 或 implementation("org.jetbrains.compose.ui:ui-test:1.11.1")
}
jvmTest.dependencies {
implementation(compose.desktop.currentOs)
}
}
Android 仪器化测试需要额外配置:androidLibrary.withDeviceTestBuilder、testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"、androidTestImplementation("androidx.compose.ui:ui-test-junit4-android:..."),并创建 androidDeviceTest 源集。
2.3 测试 Flows 与 StateFlow
ViewModel 的状态是 StateFlow。测试它最直接的方式是用 Turbine:
@Test
fun `loading then data`() = runTest {
val repo = FakeItemRepository()
val viewModel = ItemListViewModel(GetItemsUseCase(repo))
viewModel.state.test {
assertEquals(ItemListState(), awaitItem()) // 初始状态
viewModel.onEvent(ItemListEvent.Load)
assertEquals(listOf(testItem), awaitItem().items) // 加载完成
}
}
Turbine 的 test {} 块按顺序 awaitItem() 每一个发射的状态。配合 runTest 的虚拟时钟,delay 不需要真实等待。
三、Kotlin/Native 内存泄漏检测
3.1 GC 统计方法
Kotlin/Native 提供了 kotlin.native.internal.GC.lastGCInfo() 来获取上一次垃圾回收的统计信息。这可以用来检测全局变量导致的内存泄漏:
import kotlin.native.internal.*
fun getHeapUsage(): Long {
GC.collect()
return GC.lastGCInfo!!.memoryUsageAfter["heap"]!!.totalObjectsSizeBytes
}
@Test
fun `global list should not leak`() {
val before = getHeapUsage()
// 执行可能泄漏的代码
val after = getHeapUsage()
assertEquals(before, after)
}
GC.collect() 强制触发一次完整的垃圾回收,确保之前的临时对象被清理。如果 before 和 after 不相等,说明有对象被意外持有。
3.2 Xcode Instruments 的 Signpost 追踪
在 Apple 平台上,Kotlin/Native 的 GC 暂停可以通过 os_signpost 在 Instruments 中可视化。配置方式:
在 gradle.properties 中启用:
kotlin.native.binary.enableSafepointSignposts=true
然后在 Xcode 中:Product → Profile(Cmd+I),选择 os_signpost 模板,配置 subsystem 为 org.kotlinlang.native.runtime,category 为 safepoint。记录时,每个蓝色标记代表一次 GC 暂停。如果 GC 暂停频繁出现在 UI 交互期间,说明内存分配压力过大。
3.3 Apple 平台的内存标记追踪
从 Kotlin 2.2.0 开始,Kotlin 代码分配的内存会被标记,可以在 Xcode Instruments 的 VM Tracker 中单独查看。这让你能区分“iOS 系统分配的内存”和“Kotlin/Native 分配的内存”。
标记功能默认启用,但依赖三个条件:启用标记(mmapTag 非 0)、使用 mmap 分配、启用分页分配。Apple 建议的标记数字范围是 240-255,默认值为 246。
四、iOS Skia 渲染调试
4.1 渲染管线的特殊性
CMP 在 iOS 上通过 Skiko(Skia for Kotlin)直接渲染到 Metal,完全绕过 UIKit 的布局引擎和 Core Animation。这意味着:
- 没有 RenderThread。Android 有后台渲染线程预编译着色器,iOS 没有。每个新的着色器变体在主线程上调用 Metal 编译器,可能导致数毫秒的阻塞。
- 纹理图集由应用层管理。Android 的图集内存由系统合成器管理,iOS 上是应用级的 Metal 分配。内存压力下,图集驱逐和重上传会导致周期性掉帧。
4.2 诊断路径
着色器编译阻塞:在 Xcode Instruments 中附加 GPU 和 Metal System Trace 模板,关注 MTLCreatePipelineState 调用是否超过 2ms,以及 GPU 轨道中的帧间隙。
纹理图集抖动:查看 Instruments 的 Metal Resource Allocations,如果 GPU 内存分配/释放出现尖峰,说明图集在抖动。减少热路径中不同字体大小和图片缩放的数量,优先使用整数缩放的图片。
120fps 目标(ProMotion) :帧预算是 8.3ms。Android 上 60fps 掉一帧可以接受,iOS 上 120fps 掉两帧就会被用户感知为卡顿。用 derivedStateOf、稳定 key、@Immutable 注解 aggressively 减少重组传播到绘制调用。
4.3 何时应该放弃 Compose 渲染
MVP Factory 的文章给出了一个明确的判断:UIKitView 互操作不是失败状态,而是架构工具。当某个组件的渲染需求超出了 Skia 的合理范围(如复杂的 Metal 自定义着色器、AR 渲染),用原生 UIKit 实现是正确的工程决策。
五、习题与参考答案
本课习题分为三类:概念理解(1-5 题)、代码实践(6-10 题)、综合设计(11-15 题)。
概念理解
习题 1:测试金字塔的 CMP 特殊性
题目:CMP 项目的测试金字塔与单平台项目有什么本质不同?
参考答案:单元测试写在 commonTest 中,一次编写所有平台共享。这是 CMP 最大的测试优势。平台特定的集成测试(数据库、文件系统)需要放在平台源集中。
习题 2:runComposeUiTest 与 JUnit 模式
题目:runComposeUiTest 和 createComposeRule() 有什么区别?各适用于什么场景?
参考答案:runComposeUiTest 不依赖 JUnit TestRule,在 ComposeUiTest 接收者上执行,可用于 commonTest 中所有平台共享的 UI 测试。createComposeRule() 是 JUnit 模式的 API,只适用于桌面端 desktopTest 源集。
习题 3:Turbine 的作用
题目:为什么测试 StateFlow 需要 Turbine 而不是直接读取 .value?
参考答案:StateFlow.value 只反映当前值,无法验证状态发射序列。Turbine 的 test {} 块按顺序 awaitItem() 每一个发射的状态,可以验证“初始状态 → 加载中 → 数据到达”的完整序列。
习题 4:Kotlin/Native 内存泄漏检测
题目:如何用 GC.lastGCInfo() 检测全局变量导致的内存泄漏?
参考答案:记录 GC.collect() 后的堆使用量 before,执行可能泄漏的代码,再次 GC.collect() 并记录 after。如果 before != after,说明有对象被意外持有。GC.collect() 强制完整回收,确保临时对象被清理。
习题 5:iOS Skia 渲染的核心差异
题目:CMP 在 iOS 上通过 Skia 渲染,与 Android 的 Compose 渲染有什么关键差异?
参考答案:Android 有 RenderThread 在后台预编译着色器,iOS 没有,每个新着色器变体在主线程编译。Android 的图集内存由系统合成器管理,iOS 上是应用级的 Metal 分配。这两个差异导致 iOS 上更容易出现首帧着色器阻塞和纹理图集抖动。
代码实践
习题 6:添加测试依赖
题目:在 composeApp/build.gradle.kts 中添加 commonTest 的 UI 测试依赖和桌面端依赖。
参考答案:
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTest)
}
jvmTest.dependencies {
implementation(compose.desktop.currentOs)
}
}
习题 7:编写第一个 commonTest UI 测试
题目:用 runComposeUiTest 编写一个测试,验证一个计数器 Composable 的初始值和点击后的变化。
参考答案:
@OptIn(ExperimentalTestApi::class)
class CounterTest {
@Test
fun `counter increments on click`() = runComposeUiTest {
setContent {
var count by remember { mutableStateOf(0) }
Text("Count: $count", modifier = Modifier.testTag("count"))
Button(onClick = { count++ }, modifier = Modifier.testTag("button")) {
Text("+1")
}
}
onNodeWithTag("count").assertTextEquals("Count: 0")
onNodeWithTag("button").performClick()
onNodeWithTag("count").assertTextEquals("Count: 1")
}
}
习题 8:ViewModel 单元测试
题目:为 ItemListViewModel 编写单元测试,验证初始状态为空、加载后填充数据。
参考答案:
@Test
fun `initial state is empty`() {
val viewModel = ItemListViewModel(FakeItemRepository())
assertTrue(viewModel.state.value.items.isEmpty())
}
@Test
fun `load populates items`() = runTest {
val repo = FakeItemRepository().apply { add(testItem) }
val viewModel = ItemListViewModel(repo)
viewModel.onEvent(ItemListEvent.Load)
assertEquals(listOf(testItem), viewModel.state.value.items)
}
习题 9:Turbine 测试 StateFlow
题目:用 Turbine 测试 ItemListViewModel 的状态发射序列:初始状态 → 加载中 → 数据。
参考答案:
@Test
fun `state emissions are correct`() = runTest {
val repo = FakeItemRepository().apply { add(testItem) }
val viewModel = ItemListViewModel(repo)
viewModel.state.test {
assertEquals(ItemListState(), awaitItem()) // 初始
viewModel.onEvent(ItemListEvent.Load)
assertEquals(ItemListState(isLoading = true), awaitItem()) // 加载中
assertEquals(listOf(testItem), awaitItem().items) // 数据到达
}
}
习题 10:Kotlin/Native 内存泄漏测试
题目:编写一个测试,验证向全局列表添加并清除元素后,堆使用量回到初始值。
参考答案:
val global = mutableListOf<Any>()
@OptIn(ExperimentalStdlibApi::class)
fun getHeapUsage(): Long {
GC.collect()
return GC.lastGCInfo!!.memoryUsageAfter["heap"]!!.totalObjectsSizeBytes
}
@Test
fun `global list does not leak`() {
val before = getHeapUsage()
global.add(Any())
global.clear()
val after = getHeapUsage()
assertEquals(before, after)
}
综合设计
习题 11:登录流程的完整测试
题目:为登录流程编写测试:初始状态下按钮禁用,输入用户名和密码后按钮启用,提交后显示加载状态。
参考答案:
@OptIn(ExperimentalTestApi::class)
class LoginTest {
@Test
fun `login flow`() = runComposeUiTest {
setContent { LoginScreen(LoginViewModel(FakeAuthRepository())) }
onNodeWithTag("submit").assertIsNotEnabled()
onNodeWithTag("username").performTextInput("user")
onNodeWithTag("password").performTextInput("pass")
onNodeWithTag("submit").assertIsEnabled()
onNodeWithTag("submit").performClick()
onNodeWithTag("loading").assertExists()
}
}
习题 12:Repository 的 Fake 实现
题目:为 ItemRepository 编写 Fake 实现,支持注入加载错误。
参考答案:
class FakeItemRepository : ItemRepository {
private val items = mutableListOf<Item>()
var fetchError: Throwable? = null
override suspend fun getItems(): NetworkResult<List<Item>> {
fetchError?.let { return NetworkResult.Error(it.message ?: "error", it) }
return NetworkResult.Success(items.toList())
}
fun addItem(item: Item) { items.add(item) }
}
习题 13:iOS Skia 性能问题诊断
题目:用户报告 iOS 上图片列表滚动时周期性卡顿。如何用 Instruments 诊断?
参考答案:打开 Xcode Instruments,附加 Metal System Trace 模板。查看 MTLCreatePipelineState 调用是否超过 2ms(着色器编译阻塞),以及 Metal Resource Allocations 是否出现周期性尖峰(纹理图集抖动)。如果是图集抖动,减少热路径中不同字体大小和图片缩放的数量。
习题 14:GC 暂停的 Signpost 追踪
题目:如何在 Xcode Instruments 中追踪 Kotlin/Native 的 GC 暂停?
参考答案:在 gradle.properties 中设置 kotlin.native.binary.enableSafepointSignposts=true。在 Xcode 中 Product → Profile(Cmd+I),选择 os_signpost 模板,配置 subsystem 为 org.kotlinlang.native.runtime,category 为 safepoint。记录时每个蓝色标记代表一次 GC 暂停。
习题 15:测试策略设计
题目:为一个“商品详情页”设计完整的测试策略:哪些测试写在 commonTest,哪些写在平台源集,各测试什么。
参考答案:
- commonTest:ViewModel 的加载/错误/成功状态序列(Turbine)、加入购物车的事件处理逻辑、价格格式化函数。
- commonTest UI 测试:详情页渲染商品名称和价格、点击“加入购物车”按钮显示确认提示。
- 平台源集(如 iOS):分享功能的
UIActivityViewController调用、原生支付按钮的集成。
六、本课小结
测试金字塔的 CMP 特殊性:单元测试写在 commonTest,一次编写所有平台共享。ViewModel + UseCase 是每个功能的最低测试覆盖单元。Fake 优于 Mock,因为无平台依赖且更简单。
UI 测试的双模式:commonTest 用 runComposeUiTest(不依赖 JUnit TestRule),desktopTest 用 createComposeRule()。finders、assertions、actions 与 Jetpack Compose 共享。
StateFlow 测试:用 Turbine 验证状态发射序列,配合 runTest 的虚拟时钟。
Kotlin/Native 内存调试:GC.lastGCInfo() 检测全局变量泄漏,enableSafepointSignposts 在 Instruments 中可视化 GC 暂停,Kotlin 2.2+ 的内存标记可在 VM Tracker 中区分 Kotlin 分配。
iOS Skia 渲染调试:核心陷阱是主线程着色器编译和纹理图集抖动。用 Metal System Trace 诊断,120fps 下帧预算仅 8.3ms。UIKitView 互操作是架构工具,不是失败状态。
七、下一课预告
第10课 发布与部署
更多推荐




所有评论(0)