← 返回学习路线
🚀

第 10 章:测试、发布与最佳实践

测试共享业务逻辑,构建 XCFramework,配置 CI/CD,以及 KMP 项目的最佳实践与选型建议

🧪 单元测试 commonMain

10.1 kotlin.test 基础

kotlin.test 是 KMP 的标准测试库,在所有平台上均可使用。测试代码写在 commonTest 源集中,可以在 JVM、iOS 模拟器、JS 等目标上运行。

shared/build.gradle.kts sourceSets { commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) } }

10.2 测试纯 Kotlin 逻辑

commonTest — ArticleDtoTest.kt import kotlin.test.* class ArticleMappingTest { @Test fun testArticleDtoToDomain() { val dto = ArticleDto( id = 1, title = "KMP 入门", content = "这是一篇关于 KMP 的文章", coverUrl = null, createdAt = "2024-01-01T00:00:00Z", author = AuthorDto(id = 1, name = "张三") ) val domain = dto.toDomain() assertEquals("1", domain.id) assertEquals("KMP 入门", domain.title) assertNull(domain.imageUrl) assertFalse(domain.isBookmarked) } @Test fun testSummaryTruncation() { val longContent = "a".repeat(300) val dto = ArticleDto( id = 1, title = "test", content = longContent, createdAt = "2024-01-01T00:00:00Z", author = AuthorDto(1, "test") ) val domain = dto.toDomain() assertTrue(domain.summary.length <= 200) } }

10.3 测试协程(runTest)

commonTest — 协程测试 import kotlinx.coroutines.test.* import kotlin.test.* class NewsRepositoryTest { private lateinit var repository: NewsRepository private lateinit var fakeApi: FakeNewsApi private lateinit var fakeLocalSource: FakeNewsLocalDataSource @BeforeTest fun setUp() { fakeApi = FakeNewsApi() fakeLocalSource = FakeNewsLocalDataSource() repository = NewsRepositoryImpl(fakeApi, fakeLocalSource) } @Test fun testRefreshPopulatesLocalCache() = runTest { // 设置假数据 fakeApi.articles = listOf( createFakeArticleDto(id = "1", title = "文章 1"), createFakeArticleDto(id = "2", title = "文章 2") ) // 执行刷新 repository.refreshArticles() // 验证本地缓存 assertEquals(2, fakeLocalSource.articles.size) assertEquals("1", fakeLocalSource.articles[0].id) } @Test fun testGetArticlesReturnsFlow() = runTest { fakeLocalSource.articles = mutableListOf( createFakeArticleEntity(id = "1") ) val result = repository.getArticles().first() assertEquals(1, result.size) } @Test fun testRefreshThrowsOnNetworkError() = runTest { fakeApi.shouldThrow = true assertFailsWith<ApiException.NetworkError> { repository.refreshArticles() } } }

10.4 Fake 实现(测试替身)

commonTest — FakeNewsApi.kt class FakeNewsApi : NewsApi { var articles: List<ArticleDto> = emptyList() var shouldThrow: Boolean = false override suspend fun getArticles(page: Int, pageSize: Int): PagedResponse<ArticleDto> { if (shouldThrow) throw ApiException.NetworkError(Exception("No network")) return PagedResponse(data = articles, total = articles.size, page = page, hasNext = false) } override suspend fun getArticle(id: String): ArticleDto = articles.first { it.id.toString() == id } override suspend fun searchArticles(query: String): List<ArticleDto> = articles.filter { it.title.contains(query) } }

🎭 MockK 使用

10.5 MockK 配置

MockK 是 Kotlin 专用的 Mock 库,在 KMP 中也可以使用(主要用于 androidTest 或 JVM 测试)。

libs.versions.toml mockk = { module = "io.mockk:mockk", version = "1.13.12" }
androidTest 或 jvmTest import io.mockk.* import kotlinx.coroutines.test.* import kotlin.test.* class HomeViewModelTest { private val mockRepository = mockk<NewsRepository>() @Test fun testInitialLoadSetsLoadingState() = runTest { coEvery { mockRepository.getArticles() } returns flowOf(emptyList()) coEvery { mockRepository.refreshArticles() } just Runs val viewModel = HomeViewModel(mockRepository) coVerify { mockRepository.refreshArticles() } } @Test fun testToggleBookmark() = runTest { coEvery { mockRepository.getArticles() } returns flowOf(emptyList()) coEvery { mockRepository.refreshArticles() } just Runs coEvery { mockRepository.toggleBookmark(any()) } just Runs val viewModel = HomeViewModel(mockRepository) viewModel.toggleBookmark("article-1") coVerify { mockRepository.toggleBookmark("article-1") } } }

10.6 运行测试命令

Terminal # 运行 commonTest(在 JVM 上执行) ./gradlew :shared:jvmTest # 运行 Android 测试 ./gradlew :shared:testDebugUnitTest # 运行 iOS 测试(需要模拟器) ./gradlew :shared:iosSimulatorArm64Test # 运行所有测试 ./gradlew :shared:allTests # 生成测试报告 ./gradlew :shared:allTests --continue # 报告位置: shared/build/reports/tests/

📦 iOS Framework 发布

10.7 构建 XCFramework

XCFramework 是 Apple 的通用 Framework 格式,包含所有目标架构(arm64 真机 + x86_64/arm64 模拟器),一个文件兼顾所有场景。

Terminal # 构建 Debug XCFramework(开发阶段) ./gradlew :shared:assembleDebugXCFramework # 构建 Release XCFramework(提交 App Store) ./gradlew :shared:assembleReleaseXCFramework # 输出位置: # shared/build/XCFrameworks/debug/shared.xcframework # shared/build/XCFrameworks/release/shared.xcframework

10.8 通过 SPM Package 分发

对于多团队协作,可以将 XCFramework 发布到 SPM Package,iOS 团队通过 Swift Package Manager 依赖:

Package.swift // swift-tools-version:5.9 import PackageDescription let package = Package( name: "SharedKMP", platforms: [.iOS(.v16)], products: [ .library(name: "shared", targets: ["shared"]) ], targets: [ .binaryTarget( name: "shared", // 可以是本地路径或远程 URL url: "https://github.com/example/newsapp/releases/download/v1.0.0/shared.xcframework.zip", checksum: "abc123..." ) ] )

🤖 Android 发布

10.9 与普通 Android 项目无差异

KMP 的 Android 端发布流程与普通 Android 应用完全一样。

Terminal # 构建 Release APK(需要配置签名) ./gradlew :composeApp:assembleRelease # 构建 App Bundle(推荐,用于 Google Play) ./gradlew :composeApp:bundleRelease # 输出位置: # composeApp/build/outputs/apk/release/ # composeApp/build/outputs/bundle/release/

10.10 签名配置

composeApp/build.gradle.kts android { signingConfigs { create("release") { keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") storeFile = file(System.getenv("KEYSTORE_PATH") ?: "debug.keystore") storePassword = System.getenv("STORE_PASSWORD") } } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("release") } } }

⚙️ GitHub Actions CI/CD

10.11 矩阵构建 Android + iOS

.github/workflows/ci.yml name: KMP CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: name: Run Tests runs-on: macos-14 # 必须用 macOS(iOS 构建要求) steps: - uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Cache Gradle uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ hashFiles('**/*.gradle.kts', '**/libs.versions.toml') }} # 运行共享模块单元测试(JVM 上,无需模拟器) - name: Run Shared Tests (JVM) run: ./gradlew :shared:jvmTest # Android 测试 - name: Run Android Unit Tests run: ./gradlew :composeApp:testDebugUnitTest # iOS 测试(需要 Xcode) - name: Run iOS Tests run: ./gradlew :shared:iosSimulatorArm64Test build-android: name: Build Android runs-on: ubuntu-latest needs: test steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - uses: gradle/actions/setup-gradle@v4 - name: Build Android Debug APK run: ./gradlew :composeApp:assembleDebug - name: Upload APK Artifact uses: actions/upload-artifact@v4 with: name: debug-apk path: composeApp/build/outputs/apk/debug/*.apk build-ios-framework: name: Build iOS XCFramework runs-on: macos-14 needs: test steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - uses: gradle/actions/setup-gradle@v4 - name: Build XCFramework run: ./gradlew :shared:assembleReleaseXCFramework - name: Upload XCFramework uses: actions/upload-artifact@v4 with: name: xcframework path: shared/build/XCFrameworks/release/

✅ 代码组织最佳实践

10.12 应该共享什么

✅ 适合共享

  • 数据模型(Data class)
  • API 接口定义
  • 网络请求逻辑(Ktor)
  • 本地数据库操作(SQLDelight)
  • 业务规则(价格计算、表单验证)
  • ViewModel 和 UI State
  • 工具函数(日期格式化等)
  • 依赖注入模块(Koin)

❌ 不适合强行共享

  • 平台特定 UI 动画效果
  • 权限请求逻辑
  • 推送通知实现
  • 生物识别认证
  • 蓝牙 / NFC 操作
  • 视频/音频播放控制
  • App 评价弹窗

10.13 命名规范

  • actual 文件 建议命名为 ClassName.android.ktClassName.ios.kt,Kotlin 编译器可以自动找到(无需手动配置)。
  • DTO vs Domain 网络层数据类加 Dto 后缀(ArticleDto),数据库实体加 Entity 后缀,Domain 模型不加后缀(Article)。
  • Repository 接口不加 I 前缀(NewsRepository),实现类加 Impl 后缀(NewsRepositoryImpl),使用 by 委托注入接口。
  • 模块划分 小项目:单一 shared 模块即可。大项目:可将 shared 拆分为 feature 模块(:feature:home:shared, :feature:auth:shared),与 Android 的多模块架构对齐。

10.14 避免常见陷阱

Kotlin/Native 冻结机制(Kotlin 1.7.20 之前): 旧版 Kotlin/Native 在不同线程传递对象时会自动"冻结"(immutable),导致修改时崩溃。Kotlin 1.7.20 起新内存模型默认启用,此问题已基本解决,但仍需注意不要在 Kotlin/Native 中使用 @ThreadLocal 和线程不安全的对象。
iOS 编译速度优化: Kotlin/Native 编译比 JVM 慢很多。开发阶段用 iosSimulatorArm64 目标(避免构建不必要的架构),CI 中才构建完整 XCFramework。在 gradle.properties 中添加 kotlin.native.cacheKind.iosSimulatorArm64=static 启用缓存。
Coroutines 主线程调度: 在 iOS 端,Dispatchers.Main 映射到 iOS 主线程(DispatchQueue.main)。注意不要在 ViewModel 的 init 中直接 launch 协程并立即访问 UI,需等待 iOS 端订阅后再执行。

🎯 框架选型建议

10.15 KMP vs Flutter vs React Native

场景推荐方案理由
已有 Android 应用,需要扩展 iOS KMP 共享 Android 业务代码,iOS 用 SwiftUI 原生开发,风险最低
全新跨平台应用,团队只会一门语言 Flutter 学习成本低,UI 一致,生态成熟
Web 团队(JS/TS)转型移动端 React Native 复用 JS/TS 技能,生态庞大
企业级应用,注重平台体验和性能 KMP + 各平台原生 UI 完全原生体验,满足 Apple HIG / Material 规范
快速原型验证,时间紧张 Flutter 或 React Native 开发速度更快,一次开发两端运行
追求极致性能(游戏、AR) 原生 Android + iOS 任何跨平台方案都有额外开销

10.16 KMP 成熟度现状(2025 年)

✅ 已生产就绪

  • Android + iOS 共享业务逻辑
  • Ktor Client 网络层
  • SQLDelight 本地数据库
  • Kotlinx 系列库
  • Koin 依赖注入
  • kotlin.test 测试

🔶 趋于成熟

  • Compose Multiplatform iOS(Beta)
  • lifecycle-viewmodel KMP
  • SKIE Swift 互操作
  • Desktop(JVM)应用

🔴 尚在开发

  • Compose Web (Wasm)(Alpha)
  • KMP 调试工具
  • iOS Compose 性能优化

🎯 学习总结

恭喜完成 KMP 全 10 章学习!回顾一下整个技术路径:

  1. 第 1 章:理解 KMP 理念——共享业务逻辑,保留原生 UI
  2. 第 2 章:搭建开发环境,用 Wizard 创建第一个 KMP 项目
  3. 第 3 章:掌握 commonMain / expect / actual / 三层架构
  4. 第 4 章:用 Ktor Client 构建多平台网络层
  5. 第 5 章:用 SQLDelight 实现本地数据库和离线缓存
  6. 第 6 章:用 Flow + StateFlow 管理 UI 状态
  7. 第 7 章:Android 端用 Jetpack Compose 消费共享 ViewModel
  8. 第 8 章:iOS 端集成 Framework,用 SwiftUI + SKIE 消费 ViewModel
  9. 第 9 章:用 Compose Multiplatform 共享 UI 代码
  10. 第 10 章:测试、发布、CI/CD 和最佳实践

下一步建议: 选择一个真实项目,从现有 Android 代码中抽取业务逻辑到 shared 模块,然后开发对应的 iOS 端。实践是掌握 KMP 的最快方式。