Kotlin Multiplatform 实战:共享业务逻辑到 iOS
Kotlin Multiplatform(KMP)已经从「实验特性」进化到「生产可用」。2024 年 Google 宣布 KMP 成为 Android 官方推荐的跨平台方案,2026 年 Compose Multiplatform for iOS 进入 Stable,KMP 的生态已经成熟到可以认真考虑在商业项目中使用了。
但对很多 Android 开发者来说,KMP 仍然是个模糊的概念——知道能跨平台,但不知道从哪里下手。这篇文章就是为你准备的:从一个纯 Android 项目出发,逐步把业务逻辑抽到 shared module,最终让 iOS 端也能调用。
架构设计
KMP 项目的核心架构是把代码分成三层:
关键原则:
- commonMain:纯 Kotlin 代码,不依赖任何平台 API
- androidMain:Android 特定实现(DataStore、Context 相关)
- iosMain:iOS 特定实现(NSUserDefaults、NSLog 等)
- UI 层不共享:Android 用 Compose,iOS 用 SwiftUI,各自原生
项目搭建
目录结构
MyApp/
├── shared/ # KMP shared module
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/kotlin/
│ │ └── com/example/shared/
│ │ ├── domain/
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ └── usecase/
│ │ ├── data/
│ │ │ ├── repository/
│ │ │ ├── network/
│ │ │ └── storage/
│ │ └── AppModule.kt
│ ├── androidMain/kotlin/
│ │ └── com/example/shared/
│ │ └── platform/
│ └── iosMain/kotlin/
│ └── com/example/shared/
│ └── platform/
├── androidApp/ # Android 应用
│ ├── build.gradle.kts
│ └── src/main/
├── iosApp/ # iOS 应用
│ └── iosApp/
└── build.gradle.kts
shared/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("native.cocoapods") // iOS 集成需要
id("com.android.library")
id("org.jetbrains.kotlinx.serialization")
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
cocoapods {
summary = "Shared module for MyApp"
homepage = "https://github.com/zzdbilly/MyApp"
ios.deploymentTarget = "15.0"
podfile = project.file("../iosApp/Podfile")
framework {
baseName = "shared"
isStatic = true // 推荐使用静态框架
}
}
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("io.ktor:ktor-client-core:2.3.11")
implementation("io.ktor:ktor-client-content-negotiation:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
implementation("androidx.datastore:datastore-core-okio:1.1.1")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.11")
implementation("androidx.datastore:datastore-preferences:1.1.1")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.11")
}
}
}
android {
namespace = "com.example.shared"
compileSdk = 34
defaultConfig { minSdk = 24 }
}
ktor-client-darwin(基于 NSURLSession),而不是 OkHttp。这是 KMP 的精髓——同一套 API,不同平台用不同的底层实现。
expect/actual:平台差异的桥梁
expect/actual 是 KMP 处理平台差异的核心机制。简单说:
expect声明一个接口(在 commonMain 中)actual提供各平台的具体实现(在 androidMain/iosMain 中)
示例 1:日志
// commonMain - 声明期望
expect object Logger {
fun d(tag: String, message: String)
fun e(tag: String, message: String, throwable: Throwable? = null)
}
// androidMain - Android 实现
actual object Logger {
actual fun d(tag: String, message: String) {
android.util.Log.d(tag, message)
}
actual fun e(tag: String, message: String, throwable: Throwable?) {
android.util.Log.e(tag, message, throwable)
}
}
// iosMain - iOS 实现
actual object Logger {
actual fun d(tag: String, message: String) {
NSLog("[$tag] $message")
}
actual fun e(tag: String, message: String, throwable: Throwable?) {
NSLog("[$tag] ERROR: $message ${throwable?.stackTraceToString() ?: ""}")
}
}
示例 2:平台线程调度器
// commonMain
expect object AppDispatchers {
val io: CoroutineDispatcher
val main: CoroutineDispatcher
}
// androidMain
actual object AppDispatchers {
actual val io: CoroutineDispatcher = Dispatchers.IO
actual val main: CoroutineDispatcher = Dispatchers.Main
}
// iosMain
actual object AppDispatchers {
actual val io: CoroutineDispatcher = Dispatchers.Default
actual val main: CoroutineDispatcher = Dispatchers.Main
}
Dispatchers.Main 在 iOS 上需要额外依赖 kotlinx-coroutines-core 的 Main 支持。确保 shared module 的 iosMain 依赖中包含 kotlinx-coroutines-core,否则运行时崩溃。
网络层:Ktor 实战
Ktor 是 KMP 生态中最成熟的 HTTP 客户端,API 设计类似 Retrofit,但完全基于 Kotlin 协程。
API 接口定义
// commonMain/domain/model/User.kt
@Serializable
data class User(
val id: String,
val name: String,
val email: String,
val avatar: String
)
@Serializable
data class LoginRequest(
val email: String,
val password: String
)
@Serializable
data class LoginResponse(
val token: String,
val user: User
)
@Serializable
data class ApiError(
val code: Int,
val message: String
)
Ktor 客户端配置
// commonMain/data/network/HttpClientFactory.kt
object HttpClientFactory {
fun create(baseUrl: String): HttpClient {
return HttpClient {
defaultRequest {
url(baseUrl)
contentType(ContentType.Application.Json)
}
install(ContentNegotiation) {
json(Json {
prettyPrint = false
isLenient = true
ignoreUnknownKeys = true
encodeDefaults = true
})
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Logger.d("Ktor", message)
}
}
level = LogLevel.INFO
}
// Token 拦截器
install(Auth) {
bearer {
loadTokens {
// 从 DataStore 读取 token
val token = tokenProvider.getToken()
if (token != null) {
BearerTokens(token, "")
} else {
null
}
}
refreshTokens {
// Token 刷新逻辑
val response = client.post("/auth/refresh") {
setBody(mapOf("refreshToken" to oldTokens?.refreshToken))
}
val newToken = response.body<LoginResponse>()
BearerTokens(newToken.token, "")
}
}
}
}
}
}
Repository 实现
// commonMain/domain/repository/UserRepository.kt
interface UserRepository {
suspend fun login(email: String, password: String): Result<User>
suspend fun getProfile(): Result<User>
suspend fun logout()
}
// commonMain/data/repository/UserRepositoryImpl.kt
class UserRepositoryImpl(
private val httpClient: HttpClient,
private val storage: TokenStorage
) : UserRepository {
override suspend fun login(email: String, password: String): Result<User> = runCatching {
val response = httpClient.post("/auth/login") {
setBody(LoginRequest(email, password))
}
val loginResponse = response.body<LoginResponse>()
storage.saveToken(loginResponse.token)
loginResponse.user
}
override suspend fun getProfile(): Result<User> = runCatching {
httpClient.get("/user/profile").body<User>()
}
override suspend fun logout() {
storage.clearToken()
}
}
数据存储:DataStore 跨平台
Preferences DataStore 可以在 KMP 中直接使用,但初始化方式需要通过 expect/actual 处理。
// commonMain/data/storage/SettingsStorage.kt
interface SettingsStorage {
suspend fun getString(key: String): String?
suspend fun putString(key: String, value: String)
suspend fun getBoolean(key: String): Boolean?
suspend fun putBoolean(key: String, value: Boolean)
suspend fun remove(key: String)
suspend fun clear()
}
// commonMain - 使用 DataStore 实现
class DataStoreSettingsStorage(
private val dataStore: DataStore<Preferences>
) : SettingsStorage {
override suspend fun getString(key: String): String? =
dataStore.data.map { it[stringPreferencesKey(key)] }.first()
override suspend fun putString(key: String, value: String) {
dataStore.edit { it[stringPreferencesKey(key)] = value }
}
override suspend fun getBoolean(key: String): Boolean? =
dataStore.data.map { it[booleanPreferencesKey(key)] }.first()
override suspend fun putBoolean(key: String, value: Boolean) {
dataStore.edit { it[booleanPreferencesKey(key)] = value }
}
override suspend fun remove(key: String) {
dataStore.edit { it.remove(stringPreferencesKey(key)) }
}
override suspend fun clear() {
dataStore.edit { it.clear() }
}
}
// commonMain - DataStore 创建需要 expect
expect fun createDataStore(): DataStore<Preferences>
// androidMain
actual fun createDataStore(): DataStore<Preferences> {
val context = ApplicationHolder.context // 通过 expect/actual 拿到 Context
return PreferenceDataStoreFactory.createWithPath(
produceFile = { context.dataStoreFile("shared_prefs.preferences_pb") }
)
}
// iosMain - 使用 okio 的文件路径
actual fun createDataStore(): DataStore<Preferences> {
return PreferenceDataStoreFactory.createWithPath(
produceFile = {
val documentDirectory = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory, NSUserDomainMask, true
).first() as String
path = "$documentDirectory/shared_prefs.preferences_pb"
okio.Path.toPath(path)
}
)
}
UseCase 层
// commonMain/domain/usecase/LoginUseCase.kt
class LoginUseCase(
private val userRepository: UserRepository,
private val settingsStorage: SettingsStorage
) {
suspend operator fun invoke(email: String, password: String): Result<User> {
return userRepository.login(email, password).onSuccess { user ->
// 登录成功后缓存用户信息
settingsStorage.putString("user_id", user.id)
settingsStorage.putString("user_name", user.name)
}
}
}
// commonMain/domain/usecase/GetUserProfileUseCase.kt
class GetUserProfileUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<User> {
return userRepository.getProfile()
}
}
依赖注入
KMP 中没有 Hilt/Dagger(它们依赖 Android),但有轻量替代方案。推荐两种:
方案 1: 手动 DI(最简单)
// commonMain/AppModule.kt
class AppModule {
// 懒加载,线程安全
val httpClient: HttpClient by lazy {
HttpClientFactory.create("https://api.example.com")
}
val settingsStorage: SettingsStorage by lazy {
DataStoreSettingsStorage(createDataStore())
}
val userRepository: UserRepository by lazy {
UserRepositoryImpl(httpClient, settingsStorage)
}
val loginUseCase: LoginUseCase by lazy {
LoginUseCase(userRepository, settingsStorage)
}
val getUserProfileUseCase: GetUserProfileUseCase by lazy {
GetUserProfileUseCase(userRepository)
}
}
// 全局单例
object AppModuleProvider {
lateinit var appModule: AppModule
fun init() {
appModule = AppModule()
}
}
方案 2: Koin(推荐)
// commonMain/di/AppModule.kt
val sharedModule = module {
single { HttpClientFactory.create("https://api.example.com") }
single { DataStoreSettingsStorage(createDataStore()) }
single<UserRepository> { UserRepositoryImpl(get(), get()) }
single { LoginUseCase(get(), get()) }
single { GetUserProfileUseCase(get()) }
}
// Android 端启动
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(sharedModule)
}
}
}
// iOS 端启动(在 AppDelegate 中)
fun initializeKoin() {
startKoin {
modules(sharedModule)
}
}
iOS 端集成
通过 CocoaPods 集成
在 iosApp/Podfile 中添加:
target 'iosApp' do
use_frameworks!
platform :ios, '15.0'
pod 'shared', :path => '../shared'
end
Swift 中调用
// iosApp/ContentView.swift
import shared
class LoginViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let loginUseCase = AppModule.shared.loginUseCase
func login(email: String, password: String) {
isLoading = true
Task {
do {
let result = try await loginUseCase(email: email, password: password)
await MainActor.run {
switch result {
case .success(let user):
self.user = user
self.isLoading = false
case .failure(let error):
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
}
}
}
suspend 函数导出到 iOS 后,Swift 需要用 async/await 调用。Kotlin 2.0+ 已经原生支持 Swift async/await,不再需要 callbackFlow 包装。
测试
commonTest:纯 Kotlin 单元测试
// commonTest/domain/usecase/LoginUseCaseTest.kt
class LoginUseCaseTest {
private lateinit var userRepository: UserRepository
private lateinit var settingsStorage: SettingsStorage
private lateinit var loginUseCase: LoginUseCase
@BeforeTest
fun setup() {
userRepository = mockk()
settingsStorage = mockk(relaxed = true)
loginUseCase = LoginUseCase(userRepository, settingsStorage)
}
@Test
fun `login success saves user info`() = runTest {
// Given
val user = User("1", "张小猛", "test@example.com", "avatar.png")
coEvery { userRepository.login("test@example.com", "123456") } returns Result.success(user)
// When
val result = loginUseCase("test@example.com", "123456")
// Then
assertTrue(result.isSuccess)
assertEquals(user, result.getOrNull())
coVerify { settingsStorage.putString("user_id", "1") }
coVerify { settingsStorage.putString("user_name", "张小猛") }
}
@Test
fun `login failure returns error`() = runTest {
// Given
coEvery { userRepository.login(any(), any()) } returns Result.failure(
RuntimeException("Invalid credentials")
)
// When
val result = loginUseCase("wrong@example.com", "wrong")
// Then
assertTrue(result.isFailure)
}
}
shared/build.gradle.kts 中添加测试依赖:
sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
implementation("io.mockk:mockk:1.13.11")
}
}
踩坑记录
坑 1: iOS Framework 类型导出
KMP 导出到 iOS 的类型有限制:
sealed class导出为 Swift 的 protocol + 具体类型,但模式匹配不如 Swift 原生 enumUnit返回的 suspend 函数在 Swift 中返回KotlinUnit,不是VoidList<T>导出为KotlinArray,需要手动转换
解决方案:在 iosMain 中写一层薄薄的适配器:
// iosMain/platform/IosAdapter.kt
fun List<User>.toNSArray(): NSArray {
return NSArray(array = this.map { it as AnyObject }.toTypedArray())
}
坑 2: 构建速度
iOS Framework 的编译比较慢,尤其是首次构建。建议:
- 开发时用
./gradlew shared:assembleDebug只构建 Android - iOS 相关改动才跑
./gradlew shared:linkDebugFrameworkIosArm64 - CI 中加缓存:
~/.gradle/caches和~/Library/Caches/CocoaPods
坑 3: 内存管理
iOS 端使用 KMP 对象时要注意循环引用:
// ❌ 可能循环引用
class ProfileViewModel: ObservableObject {
private let useCase = KoinHelper.shared.getUserProfileUseCase
// useCase 持有 HttpClient → HttpClient 持有 closure → ...
}
// ✅ 显式管理生命周期
class ProfileViewModel: ObservableObject {
private let useCase: GetUserProfileUseCase
private var handle: DisposableHandle?
init(useCase: GetUserProfileUseCase) {
self.useCase = useCase
}
deinit {
handle?.dispose()
}
}
坑 4: 日期时间
不要用 java.util.Date 或 java.time(JVM 专属)。使用 kotlinx-datetime:
// commonMain
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
// 使用
val now = Clock.System.now()
val localDate = now.toLocalDateTime(TimeZone.currentSystemDefault())
什么时候该用 KMP?
| 场景 | 推荐度 | 说明 |
|---|---|---|
| 新项目,需要 Android + iOS | ⭐⭐⭐⭐⭐ | 从一开始就规划 KMP,成本最低 |
| 现有 Android 项目,新增 iOS 端 | ⭐⭐⭐⭐ | 逐步抽 shared module,Android 端改动小 |
| 纯 Android 项目 | ⭐⭐ | KMP 增加了构建复杂度,短期内没收益 |
| 只共享网络层 | ⭐⭐⭐⭐ | 投入产出比最高的切入点 |
| 共享 UI(Compose Multiplatform) | ⭐⭐⭐ | 2026 年 iOS 端已 Stable,但性能和原生体验仍有差距 |
工具链推荐
| 功能 | 推荐库 | 说明 |
|---|---|---|
| HTTP 客户端 | Ktor | KMP 生态事实标准 |
| JSON 序列化 | kotlinx.serialization | 编译期生成,零反射 |
| 协程 | kotlinx-coroutines | 跨平台协程支持 |
| 日期时间 | kotlinx-datetime | 替代 java.time |
| KV 存储 | DataStore (Multiplatform) | 1.1+ 支持 KMP |
| 依赖注入 | Koin | 轻量、KMP 原生支持 |
| 日志 | kermit / Napier | KMP 原生日志库 |
| 测试 | kotlin-test + mockk | commonTest 统一测试 |
总结
KMP 的核心价值不是「写一次代码到处跑」,而是让 Android 开发者的 Kotlin 能力延伸到 iOS。你不需要学 Swift 就能写 iOS 端的业务逻辑,你不需要维护两套网络层代码,你不需要担心两端的数据模型不一致。
迁移路径很务实:
- 从网络层开始——Ktor + kotlinx.serialization,3-5 天搞定
- 加上数据存储——DataStore 跨平台,1-2 天
- 抽领域层——UseCase + Repository 接口,1 周
- 不要动 UI——Compose 和 SwiftUI 各自原生
2026 年的 KMP 已经不是早期实验品了。Kotlin 2.0 的 K2 编译器让构建速度大幅提升,Compose Multiplatform for iOS 进入 Stable,Google 官方背书——如果你在做 Android 开发同时需要 iOS 端,现在是认真考虑 KMP 的时候了。