首页 / 资讯中心 / 文章详情

【Compose Multiplatform 跨端开发学与练】第9课 测试与调试

【Compose Multiplatform 跨端开发学与练】第9课 测试与调试 ★ FEATURED ARTICLE
本课目标建立“测试金字塔”的架构意识掌握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 就是一个实现了接口的普通类带有可控的状态和错误注入能力classFakeTodoRepository:TodoRepository{privatevalitemsmutableListOfTodoItem()varfetchError:Throwable?nulloverridefungetAll()items.toList()overridefunadd(title:String):TodoItem{valitemTodoItem(iditems.size1,titletitle)items.add(item)returnitem}// ...}构造即注入。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)TestfunmyTest()runComposeUiTest{setContent{vartextbyremember{mutableStateOf(Hello)}Text(text,modifierModifier.testTag(text))Button(onClick{textCompose},modifierModifier.testTag(button)){Text(Click me)}}onNodeWithTag(text).assertTextEquals(Hello)onNodeWithTag(button).performClick()onNodeWithTag(text).assertTextEquals(Compose)}桌面端可以使用 JUnit 模式。在desktopTest源集中用createComposeRule()配合get:RuleclassExampleTest{get:RulevalrulecreateComposeRule()TestfunmyTest(){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 与 StateFlowViewModel 的状态是StateFlow。测试它最直接的方式是用TurbineTestfunloading then data()runTest{valrepoFakeItemRepository()valviewModelItemListViewModel(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()来获取上一次垃圾回收的统计信息。这可以用来检测全局变量导致的内存泄漏importkotlin.native.internal.*fungetHeapUsage():Long{GC.collect()returnGC.lastGCInfo!!.memoryUsageAfter[heap]!!.totalObjectsSizeBytes}Testfunglobal list should not leak(){valbeforegetHeapUsage()// 执行可能泄漏的代码valaftergetHeapUsage()assertEquals(before,after)}GC.collect()强制触发一次完整的垃圾回收确保之前的临时对象被清理。如果before和after不相等说明有对象被意外持有。3.2 Xcode Instruments 的 Signpost 追踪在 Apple 平台上Kotlin/Native 的 GC 暂停可以通过os_signpost在 Instruments 中可视化。配置方式在gradle.properties中启用kotlin.native.binary.enableSafepointSignpoststrue然后在 Xcode 中Product → ProfileCmdI选择os_signpost模板配置 subsystem 为org.kotlinlang.native.runtimecategory 为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 上通过SkikoSkia 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 最大的测试优势。平台特定的集成测试数据库、文件系统需要放在平台源集中。习题 2runComposeUiTest 与 JUnit 模式题目runComposeUiTest和createComposeRule()有什么区别各适用于什么场景参考答案runComposeUiTest不依赖 JUnit TestRule在ComposeUiTest接收者上执行可用于commonTest中所有平台共享的 UI 测试。createComposeRule()是 JUnit 模式的 API只适用于桌面端desktopTest源集。习题 3Turbine 的作用题目为什么测试 StateFlow 需要 Turbine 而不是直接读取.value参考答案StateFlow.value只反映当前值无法验证状态发射序列。Turbine 的test {}块按顺序awaitItem()每一个发射的状态可以验证“初始状态 → 加载中 → 数据到达”的完整序列。习题 4Kotlin/Native 内存泄漏检测题目如何用GC.lastGCInfo()检测全局变量导致的内存泄漏参考答案记录GC.collect()后的堆使用量before执行可能泄漏的代码再次GC.collect()并记录after。如果before ! after说明有对象被意外持有。GC.collect()强制完整回收确保临时对象被清理。习题 5iOS 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)classCounterTest{Testfuncounter increments on click()runComposeUiTest{setContent{varcountbyremember{mutableStateOf(0)}Text(Count:$count,modifierModifier.testTag(count))Button(onClick{count},modifierModifier.testTag(button)){Text(1)}}onNodeWithTag(count).assertTextEquals(Count: 0)onNodeWithTag(button).performClick()onNodeWithTag(count).assertTextEquals(Count: 1)}}习题 8ViewModel 单元测试题目为ItemListViewModel编写单元测试验证初始状态为空、加载后填充数据。参考答案Testfuninitial state is empty(){valviewModelItemListViewModel(FakeItemRepository())assertTrue(viewModel.state.value.items.isEmpty())}Testfunload populates items()runTest{valrepoFakeItemRepository().apply{add(testItem)}valviewModelItemListViewModel(repo)viewModel.onEvent(ItemListEvent.Load)assertEquals(listOf(testItem),viewModel.state.value.items)}习题 9Turbine 测试 StateFlow题目用 Turbine 测试ItemListViewModel的状态发射序列初始状态 → 加载中 → 数据。参考答案Testfunstate emissions are correct()runTest{valrepoFakeItemRepository().apply{add(testItem)}valviewModelItemListViewModel(repo)viewModel.state.test{assertEquals(ItemListState(),awaitItem())// 初始viewModel.onEvent(ItemListEvent.Load)assertEquals(ItemListState(isLoadingtrue),awaitItem())// 加载中assertEquals(listOf(testItem),awaitItem().items)// 数据到达}}习题 10Kotlin/Native 内存泄漏测试题目编写一个测试验证向全局列表添加并清除元素后堆使用量回到初始值。参考答案valglobalmutableListOfAny()OptIn(ExperimentalStdlibApi::class)fungetHeapUsage():Long{GC.collect()returnGC.lastGCInfo!!.memoryUsageAfter[heap]!!.totalObjectsSizeBytes}Testfunglobal list does not leak(){valbeforegetHeapUsage()global.add(Any())global.clear()valaftergetHeapUsage()assertEquals(before,after)}综合设计习题 11登录流程的完整测试题目为登录流程编写测试初始状态下按钮禁用输入用户名和密码后按钮启用提交后显示加载状态。参考答案OptIn(ExperimentalTestApi::class)classLoginTest{Testfunlogin 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()}}习题 12Repository 的 Fake 实现题目为ItemRepository编写 Fake 实现支持注入加载错误。参考答案classFakeItemRepository:ItemRepository{privatevalitemsmutableListOfItem()varfetchError:Throwable?nulloverridesuspendfungetItems():NetworkResultListItem{fetchError?.let{returnNetworkResult.Error(it.message?:error,it)}returnNetworkResult.Success(items.toList())}funaddItem(item:Item){items.add(item)}}习题 13iOS Skia 性能问题诊断题目用户报告 iOS 上图片列表滚动时周期性卡顿。如何用 Instruments 诊断参考答案打开 Xcode Instruments附加Metal System Trace模板。查看MTLCreatePipelineState调用是否超过 2ms着色器编译阻塞以及 Metal Resource Allocations 是否出现周期性尖峰纹理图集抖动。如果是图集抖动减少热路径中不同字体大小和图片缩放的数量。习题 14GC 暂停的 Signpost 追踪题目如何在 Xcode Instruments 中追踪 Kotlin/Native 的 GC 暂停参考答案在gradle.properties中设置kotlin.native.binary.enableSafepointSignpoststrue。在 Xcode 中 Product → ProfileCmdI选择os_signpost模板配置 subsystem 为org.kotlinlang.native.runtimecategory 为safepoint。记录时每个蓝色标记代表一次 GC 暂停。习题 15测试策略设计题目为一个“商品详情页”设计完整的测试策略哪些测试写在 commonTest哪些写在平台源集各测试什么。参考答案commonTestViewModel 的加载/错误/成功状态序列Turbine、加入购物车的事件处理逻辑、价格格式化函数。commonTest UI 测试详情页渲染商品名称和价格、点击“加入购物车”按钮显示确认提示。平台源集如 iOS分享功能的UIActivityViewController调用、原生支付按钮的集成。六、本课小结测试金字塔的 CMP 特殊性单元测试写在commonTest一次编写所有平台共享。ViewModel UseCase 是每个功能的最低测试覆盖单元。Fake 优于 Mock因为无平台依赖且更简单。UI 测试的双模式commonTest用runComposeUiTest不依赖 JUnit TestRuledesktopTest用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课 发布与部署
阅读完成 · 觉得有帮助?
咨询建站