本课目标:建立“测试金字塔”的架构意识,掌握 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课 发布与部署

Logo

一站式 AI 云服务平台

更多推荐