Kotlin MultiPlatform实战:如何用KMP在Android和iOS上共享90%的业务逻辑
最近和几个移动端团队聊,发现大家普遍有个痛点:同一个业务需求,Android和iOS两边要各写一遍。一个电商的购物车逻辑,一个社交应用的即时消息处理,都得在Java/Kotlin和Swift里分别实现。不仅开发周期翻倍,后续维护、修复Bug更是双倍的烦恼,两边逻辑稍有偏差,用户体验就不一致了。有没有一种方案,能让我们只写一次核心业务代码,就能在双端运行?
这正是Kotlin MultiPlatform(KMP)要解决的核心问题。它不是另一个Flutter或React Native那样的UI框架,而是一套专注于共享业务逻辑和数据的跨平台解决方案。你可以把它想象成移动开发的“共享内核”——把那些与平台无关的、决定应用核心行为的代码(比如网络请求、数据模型、业务规则、状态管理)用Kotlin写在一个地方,然后让Android和iOS各自去“消费”这个内核。UI层?完全交给原生。这样一来,你既享受了代码复用的高效率,又保留了原生应用顶级的性能和用户体验。
我去年主导了一个中型内容阅读App的重构,核心业务层(包括文章获取、解析、缓存、用户阅读进度同步)全部用KMP共享,最终实现了超过90%的Kotlin代码复用率。iOS端只需要用Swift写界面和调用共享模块提供的接口,开发效率提升了近40%,而且双端行为完全一致,再也没有“Android正常,iOS有Bug”的尴尬。这篇文章,我就结合这个实战项目,拆解如何一步步用KMP架构你的应用,真正把“写一次,跑两端”落到实处。
1. 理解KMP的核心:共享什么,不共享什么?
在动手之前,必须厘清KMP的边界。它不是万能的,清晰的分工是成功的前提。
KMP共享层(Common Code)的理想候选:
- 数据模型(Data Models):所有网络响应体(DTO)、数据库实体(Entity)、领域模型(Domain Model)。用
data class定义一次,双端通用。 - 业务逻辑与用例(Business Logic & Use Cases):例如,验证用户输入、计算订单价格、处理推送消息的业务规则、格式化内容的逻辑。
- 数据层抽象(Repository Interfaces):定义数据获取的契约,如
UserRepository的getUserById方法签名。 - 平台无关的工具类(Utilities):日期处理、字符串操作、加密解密(使用跨平台库)、JSON序列化/反序列化(如
kotlinx.serialization)。 - 状态管理核心(如采用MVI/MVVM):定义
State、Intent/Action、Reducer等纯逻辑部分。
留给平台原生层(Platform-Specific Code)的部分:
- 用户界面(UI):所有
Activity/Fragment、ViewController、SwiftUI视图、XML/Storyboard。 - 平台特定的SDK调用:如调用系统相册、蓝牙、传感器、生物识别(Face ID/Touch ID)。
- UI框架相关的状态持有者:Android的
ViewModel、iOS的ObservableObject(但ViewModel内的业务逻辑可以抽到共享层)。 - 平台特定的依赖注入:使用Hilt、Koin(Android)或Swinject(iOS)来组装对象图。
一个常见的误解是试图用KMP共享UI组件。虽然通过Compose Multiplatform可以实现部分UI共享,但这引入了新的复杂度,且与“原生体验”的初衷有所背离。在追求高代码复用率的初期,我强烈建议坚持“共享逻辑,原生UI”的架构,这是风险最低、收益最明确的路径。
2. 项目搭建与模块化设计实战
纸上谈兵终觉浅,我们直接从一个模拟的“新闻阅读App”场景开始,搭建一个真实的KMP项目结构。假设我们的核心共享业务是:获取新闻列表、缓存新闻、标记已读。
2.1 初始化项目与模块划分
我们使用Gradle作为构建工具。一个典型的KMP多模块项目结构如下:
my-kmp-news-app/ ├── build.gradle.kts (项目根目录) ├── settings.gradle.kts ├── shared/ (我们的核心共享模块) │ ├── build.gradle.kts │ ├── src/ │ │ ├── androidMain/kotlin/ (Android平台实现) │ │ ├── iosMain/kotlin/ (iOS平台实现) │ │ └── commonMain/kotlin/ (共享代码主目录) │ │ ├── model/ │ │ ├── repository/ │ │ ├── datasource/ │ │ └── usecase/ │ └── ... ├── androidApp/ (Android原生应用模块) │ └── src/main/kotlin/com/example/newsapp └── iosApp/ (iOS原生应用模块,Xcode项目) └── ...shared/build.gradle.kts的关键配置示例:
plugins { kotlin("multiplatform") id("com.android.library") // 对Android来说,它也是一个库 } kotlin { androidTarget() { compilations.all { kotlinOptions { jvmTarget = "11" } } } iosX64() iosArm64() iosSimulatorArm64() sourceSets { val commonMain by getting { dependencies { // 跨平台依赖 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") // Koin Core for 跨平台依赖注入(可选) implementation("io.insert-koin:koin-core:3.5.0") } } val androidMain by getting { dependencies { // Android平台特定依赖,如Room(如果需要在此模块访问) // implementation("androidx.room:room-ktx:2.6.0") } } val iosMain by creating { dependsOn(commonMain) // iOS平台特定依赖通常通过cocoapods或直接使用系统框架 } } } android { // 标准Android库配置 compileSdk = 34 namespace = "com.example.shared" defaultConfig { minSdk = 24 } }提示:
expect/actual机制是KMP实现平台特定代码的基石。在commonMain中声明expect函数或接口,然后在androidMain和iosMain中分别提供actual实现。
2.2 定义共享数据模型与业务接口
在shared/src/commonMain/kotlin/model/下,我们定义核心数据模型。
// NewsItem.kt @Serializable data class NewsItem( val id: String, val title: String, val summary: String, val content: String, val publishTime: Long, val source: String, var isRead: Boolean = false // 阅读状态 ) // ApiResponse.kt @Serializable sealed class ApiResponse<out T> { data class Success<T>(val data: T) : ApiResponse<T>() data class Error(val message: String, val code: Int? = null) : ApiResponse<Nothing>() object Loading : ApiResponse<Nothing>() }在shared/src/commonMain/kotlin/repository/下,定义数据仓库接口。这是业务层与数据层之间的契约。
// NewsRepository.kt interface NewsRepository { suspend fun fetchTopNews(forceRefresh: Boolean = false): ApiResponse<List<NewsItem>> suspend fun markAsRead(newsId: String) fun getCachedNews(): Flow<List<NewsItem>> // 使用Flow实现响应式数据流 }3. 实现跨平台数据层与业务逻辑
有了接口和模型,接下来在共享模块中实现它们。这里会涉及网络请求、本地缓存等通常需要平台特定实现的部分,我们将利用KMP的expect/actual机制优雅地解决。
3.1 使用Ktor实现跨平台网络请求
网络层是共享业务逻辑的关键。我们选择ktor-client,它是一个支持KMP的异步HTTP客户端。
首先,在shared/build.gradle.kts的commonMain依赖中添加:
implementation("io.ktor:ktor-client-core:2.3.7") implementation("io.ktor:ktor-client-content-negotiation:2.3.7") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")然后,在commonMain中创建网络数据源。注意,我们需要一个expect来获取平台特定的HTTP客户端引擎。
// commonMain/kotlin/datasource/network/NewsApi.kt import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* expect fun createHttpClient(): HttpClient class NewsApi { private val client = createHttpClient() suspend fun getTopNews(): List<NewsItem> { // 这里使用模拟URL,实际项目中替换为真实API return client.get("https://api.example.com/news/top").body() } }现在,分别在androidMain和iosMain中提供actual实现。
Android端实现 (androidMain/kotlin/):
import io.ktor.client.engine.android.* actual fun createHttpClient(): HttpClient { return HttpClient(Android) { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } }iOS端实现 (iosMain/kotlin/):
import io.ktor.client.engine.darwin.* actual fun createHttpClient(): HttpClient { return HttpClient(Darwin) { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } }3.2 使用SQLDelight实现跨平台本地缓存
对于本地持久化,SQLDelight是官方推荐的跨平台SQLite库。它在commonMain中生成类型安全的Kotlin API,在各自平台生成实际的SQLite驱动。
配置SQLDelight:
- 在
shared/build.gradle.kts中添加插件和依赖。 - 在
shared/src/commonMain/sqldelight/目录下创建.sq文件定义表结构。
-- NewsItem.sq CREATE TABLE news_item ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, summary TEXT NOT NULL, content TEXT NOT NULL, publish_time INTEGER NOT NULL, source TEXT NOT NULL, is_read INTEGER AS Boolean DEFAULT 0 ); selectAll: SELECT * FROM news_item ORDER BY publish_time DESC; insertOrReplace: INSERT OR REPLACE INTO news_item (id, title, summary, content, publish_time, source, is_read) VALUES (?, ?, ?, ?, ?, ?, ?); markAsRead: UPDATE news_item SET is_read = 1 WHERE id = ?;构建后,SQLDelight会在commonMain中生成一个NewsItemQueries接口。我们可以在commonMain中创建一个DatabaseHelper类来封装这些操作,而具体的SqlDriver实例需要通过expect/actual在平台层提供。
3.3 组装Repository实现
现在,我们可以将网络数据源和本地缓存组合起来,实现NewsRepository。
// commonMain/kotlin/repository/NewsRepositoryImpl.kt class NewsRepositoryImpl( private val newsApi: NewsApi, private val database: NewsDatabase ) : NewsRepository { private val newsQueries = database.newsItemQueries override fun getCachedNews(): Flow<List<NewsItem>> { return newsQueries.selectAll().asFlow().mapToList() } override suspend fun fetchTopNews(forceRefresh: Boolean): ApiResponse<List<NewsItem>> { return try { if (forceRefresh) { val remoteNews = newsApi.getTopNews() // 清空旧缓存并插入新数据 withContext(Dispatchers.Default) { newsQueries.transaction { remoteNews.forEach { news -> newsQueries.insertOrReplace( news.id, news.title, news.summary, news.content, news.publishTime, news.source, news.isRead ) } } } ApiResponse.Success(remoteNews) } else { // 先返回缓存,再尝试静默更新 val cached = newsQueries.selectAll().executeAsList() // 启动一个协程在后台静默更新,不阻塞当前流 launch { try { refreshFromRemote() } catch (e: Exception) { /* 忽略静默更新错误 */ } } ApiResponse.Success(cached) } } catch (e: Exception) { ApiResponse.Error(e.message ?: "Unknown error") } } override suspend fun markAsRead(newsId: String) { withContext(Dispatchers.Default) { newsQueries.markAsRead(newsId) } } private suspend fun refreshFromRemote() { val remoteNews = newsApi.getTopNews() // ... 更新数据库逻辑 } }至此,我们已经在共享模块中完成了一个包含网络请求、本地数据库缓存、响应式数据流的完整数据层和业务逻辑层。这些代码100%由Kotlin编写,并且将在Android和iOS上完全一致地运行。
4. 在Android与iOS原生端集成共享模块
共享模块编译后,对Android会生成一个AAR库,对iOS会生成一个Framework(或XCFramework)。集成过程非常直观。
4.1 Android端集成
在androidApp模块的build.gradle.kts中,添加对共享模块的依赖:
dependencies { implementation(project(":shared")) // 其他Android依赖... }然后,在Android的ViewModel或UseCase中,你可以像使用普通Kotlin类一样使用共享模块中的代码:
// MainViewModel.kt (Android) import com.example.shared.repository.NewsRepository import kotlinx.coroutines.flow.StateFlow class MainViewModel( private val newsRepository: NewsRepository // 通过依赖注入传入 ) : ViewModel() { val newsState: StateFlow<ApiResponse<List<NewsItem>>> = newsRepository.getCachedNews() .map { ApiResponse.Success(it) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = ApiResponse.Loading ) fun refreshNews() { viewModelScope.launch { // 调用共享模块中的挂起函数 val result = newsRepository.fetchTopNews(forceRefresh = true) // 处理结果,更新UI状态 } } }4.2 iOS端集成与Swift调用
这是KMP最精妙的部分。在iOS端,共享模块被编译为一个Objective-C Framework。你需要在Xcode项目中添加这个Framework。
集成步骤简述:
- 在
shared模块的构建中,配置生成XCFramework。 - 运行Gradle任务(如
./gradlew :shared:embedAndSignAppleFrameworkForXcode)将Framework输出到指定目录。 - 在Xcode项目中,将该Framework添加到
Frameworks, Libraries, and Embedded Content中。
集成后,你就可以在Swift代码中调用共享的Kotlin代码了。Kotlin/Native编译器会生成友好的Objective-C API。
// iOS端 SwiftUI ViewModel import Foundation import shared // 导入我们的KMP共享模块 @MainActor class NewsViewModel: ObservableObject { @Published var newsItems: [NewsItem] = [] @Published var isLoading = false @Published var errorMessage: String? private let newsRepository: NewsRepository init() { // 如何获取NewsRepository实例?通常通过一个共享的DI容器 // 这里假设我们有一个Helper类来提供单例 self.newsRepository = KoinHelper.shared.getNewsRepository() loadNews() } func loadNews() { Task { isLoading = true defer { isLoading = false } do { // 注意:Kotlin协程的挂起函数在Swift端会暴露为async函数 let response = try await newsRepository.fetchTopNews(forceRefresh: false) // 处理ApiResponse枚举 switch response { case let response as ApiResponseSuccess<NSArray>: // 注意类型转换,Kotlin List在Swift中是NSArray if let items = response.data as? [NewsItem] { self.newsItems = items } case let response as ApiResponseError: self.errorMessage = response.message default: break } } catch { self.errorMessage = error.localizedDescription } } } func markAsRead(id: String) { Task { try? await newsRepository.markAsRead(newsId: id) } } }注意:Kotlin与Swift/Objective-C之间的类型映射需要一些适应。例如,Kotlin的
List<NewsItem>在Swift中会是NSArray,需要安全地转换为[NewsItem]。sealed class(如ApiResponse)会被编译为Objective-C的类簇,在Swift中使用switch进行类型判断。虽然有一些样板代码,但一旦熟悉了模式,调用就非常顺畅。
5. 高级技巧与避坑指南
在实际项目中达到90%的共享率,需要一些策略和技巧来应对复杂场景。
5.1 依赖注入的跨平台方案
如何在Android和iOS两端都方便地获取共享模块中类的实例?Koin或Kodein-DI这类支持KMP的依赖注入框架是绝佳选择。你可以在共享模块的commonMain中定义所有的模块和单例。
// commonMain/kotlin/di/SharedModule.kt import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val sharedModule = module { singleOf(::createHttpClient) // 提供HttpClient single { NewsDatabase(get()) } // 提供数据库,get()会解析SqlDriver singleOf(::NewsApi) singleOf(::NewsRepositoryImpl) { bind<NewsRepository>() } }在Android的Application类中启动Koin,并包含sharedModule。在iOS端,你需要一个小的启动器(用Kotlin写,放在iosMain里),在App启动时初始化Koin,并提供一个访问器供Swift调用。
5.2 处理平台特定需求:Expect/Actual的深度使用
当共享逻辑中必须调用平台API时(如获取设备ID、读写特定格式文件),expect/actual是你的利器。
案例:获取设备唯一标识符用于日志。 在commonMain中:
expect class DeviceInfo() { fun getDeviceId(): String }在androidMain中:
actual class DeviceInfo actual constructor() { actual fun getDeviceId(): String { return android.provider.Settings.Secure.getString( android.content.Context.getSystemService(Context.ANDROID_ID_SERVICE), android.provider.Settings.Secure.ANDROID_ID ) ?: "unknown_android" } }在iosMain中:
import platform.UIKit.UIDevice actual class DeviceInfo actual constructor() { actual fun getDeviceId(): String { return UIDevice.currentDevice.identifierForVendor?.UUIDString ?: "unknown_ios" } }5.3 性能与包体积考量
- 代码剥离(Tree Shaking):Kotlin/Native编译器会对iOS端的产物进行积极的死代码剔除,未使用的共享代码不会被打进最终的Framework。确保你的发布构建是开启优化的。
- 资源管理:共享模块中的资源(如图片、字体)处理需要小心。通常建议将真正的资源文件放在各自的原生App模块中,共享模块只定义资源标识符或路径常量。
- 调试:Android端调试和普通Kotlin/Java项目无异。iOS端调试稍复杂,可以在Xcode中附加到进程,并在LLDB中使用Kotlin/Native的调试符号。更常用的方式是在共享模块中实现完善的日志系统,通过
expect/actual将日志输出到Android的Logcat和iOS的NSLog/os_log。
5.4 团队协作与构建流程
- 版本管理:将
shared模块当作一个独立的库来管理版本。可以使用JitPack或私有Maven仓库来发布共享模块的二进制包,让Android和iOS项目通过版本号依赖,而不是源码依赖,这更符合大型团队的协作习惯。 - CI/CD:在CI流水线中,需要为共享模块分别运行Android单元测试和iOS测试(可以在macOS runner上运行)。确保任何对共享代码的修改都能在双端通过编译和基础测试。
从我的经验来看,最大的“坑”往往不是技术上的,而是思维模式上的转变。团队需要从“Android组”和“iOS组”的隔离思维,转向“移动业务组”的融合思维。共同评审共享模块的代码,共同定义数据模型和接口契约,是项目成功的关键。当Android和iOS工程师开始用同一种语言(Kotlin)讨论同一个业务逻辑时,那种沟通效率的提升和认知负荷的降低,是比代码复用率更宝贵的收获。