🧪 单元测试 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.kt和ClassName.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 章:理解 KMP 理念——共享业务逻辑,保留原生 UI
- 第 2 章:搭建开发环境,用 Wizard 创建第一个 KMP 项目
- 第 3 章:掌握 commonMain / expect / actual / 三层架构
- 第 4 章:用 Ktor Client 构建多平台网络层
- 第 5 章:用 SQLDelight 实现本地数据库和离线缓存
- 第 6 章:用 Flow + StateFlow 管理 UI 状态
- 第 7 章:Android 端用 Jetpack Compose 消费共享 ViewModel
- 第 8 章:iOS 端集成 Framework,用 SwiftUI + SKIE 消费 ViewModel
- 第 9 章:用 Compose Multiplatform 共享 UI 代码
- 第 10 章:测试、发布、CI/CD 和最佳实践
下一步建议: 选择一个真实项目,从现有 Android 代码中抽取业务逻辑到 shared 模块,然后开发对应的 iOS 端。实践是掌握 KMP 的最快方式。