返回博客

Kotlin Multiplatform 实战:共享业务逻辑到 iOS

Kotlin Multiplatform(KMP)已经从「实验特性」进化到「生产可用」。2024 年 Google 宣布 KMP 成为 Android 官方推荐的跨平台方案,2026 年 Compose Multiplatform for iOS 进入 Stable,KMP 的生态已经成熟到可以认真考虑在商业项目中使用了。

但对很多 Android 开发者来说,KMP 仍然是个模糊的概念——知道能跨平台,但不知道从哪里下手。这篇文章就是为你准备的:从一个纯 Android 项目出发,逐步把业务逻辑抽到 shared module,最终让 iOS 端也能调用

📌 核心思路:KMP 不是「一套代码跑所有平台」,而是共享业务逻辑,保留原生 UI。你的 Android 端继续用 Jetpack Compose,iOS 端继续用 SwiftUI,中间的网络层、数据层、领域逻辑由 KMP shared module 统一提供。

架构设计

KMP 项目的核心架构是把代码分成三层:

┌─────────────────────────────────────────────────┐ │ shared module │ │ ┌───────────────────────────────────────────┐ │ │ │ commonMain (纯 Kotlin) │ │ │ │ · Domain 层 (UseCase, Repository 接口) │ │ │ │ · Data 层 (Repository 实现, DTO) │ │ │ │ · Network 层 (Ktor 客户端) │ │ │ │ · Storage 层 (DataStore 接口) │ │ │ └─────────┬───────────────┬─────────────────┘ │ │ │ │ │ │ ┌─────────▼──────┐ ┌──────▼──────────┐ │ │ │ androidMain │ │ iosMain │ │ │ │ · DataStore │ │ · DataStore │ │ │ │ · 日志/Context │ │ · 日志/NSLog │ │ │ └────────────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────┘ ↑ ↑ ┌───────┴────────┐ ┌──────┴──────────┐ │ Android App │ │ iOS App │ │ Compose UI │ │ SwiftUI UI │ │ ViewModel │ │ ObservableObj │ └────────────────┘ └─────────────────┘

关键原则:

项目搭建

目录结构

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 }
}
💡 iOS 引擎选择:Ktor 在 iOS 上用 ktor-client-darwin(基于 NSURLSession),而不是 OkHttp。这是 KMP 的精髓——同一套 API,不同平台用不同的底层实现。

expect/actual:平台差异的桥梁

expect/actual 是 KMP 处理平台差异的核心机制。简单说:

示例 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
}
⚠️ iOS Main Dispatcher 的坑:KMP 的 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
                    }
                }
            }
        }
    }
}
⚠️ 协程与 Swift 的交互:Kotlin 的 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 的类型有限制:

解决方案:在 iosMain 中写一层薄薄的适配器:

// iosMain/platform/IosAdapter.kt
fun List<User>.toNSArray(): NSArray {
    return NSArray(array = this.map { it as AnyObject }.toTypedArray())
}

坑 2: 构建速度

iOS Framework 的编译比较慢,尤其是首次构建。建议:

坑 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.Datejava.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,但性能和原生体验仍有差距
💡 最佳实践从网络层开始。这是投入产出比最高的切入点——网络请求、JSON 解析、错误处理在两端几乎完全相同,用 KMP 共享后可以立刻减少 30-40% 重复代码。数据层和领域层可以逐步迁移,UI 层保持原生。

工具链推荐

功能推荐库说明
HTTP 客户端KtorKMP 生态事实标准
JSON 序列化kotlinx.serialization编译期生成,零反射
协程kotlinx-coroutines跨平台协程支持
日期时间kotlinx-datetime替代 java.time
KV 存储DataStore (Multiplatform)1.1+ 支持 KMP
依赖注入Koin轻量、KMP 原生支持
日志kermit / NapierKMP 原生日志库
测试kotlin-test + mockkcommonTest 统一测试

总结

KMP 的核心价值不是「写一次代码到处跑」,而是让 Android 开发者的 Kotlin 能力延伸到 iOS。你不需要学 Swift 就能写 iOS 端的业务逻辑,你不需要维护两套网络层代码,你不需要担心两端的数据模型不一致。

迁移路径很务实:

  1. 从网络层开始——Ktor + kotlinx.serialization,3-5 天搞定
  2. 加上数据存储——DataStore 跨平台,1-2 天
  3. 抽领域层——UseCase + Repository 接口,1 周
  4. 不要动 UI——Compose 和 SwiftUI 各自原生

2026 年的 KMP 已经不是早期实验品了。Kotlin 2.0 的 K2 编译器让构建速度大幅提升,Compose Multiplatform for iOS 进入 Stable,Google 官方背书——如果你在做 Android 开发同时需要 iOS 端,现在是认真考虑 KMP 的时候了。