前言
Kotlin多平台已经推出数年了,在发布之初我就一直在关注,但很可惜,没有找到合适的机会实践,之前我在其他Flutter的文章里也讲过原因。
最近我换了MacBook,就一直想找机会尝试一下Kotlin多平台,我自己有一个维护长达5年的项目,在开发项目之初,我就尽可能拆解了模块,采用了未来可能支持多平台的Jetpack库,现在,我们就用这个项目开始。
项目介绍
这个项目是我学习安卓开发的起源,经过多次架构替换重构,尝试各种新鲜技术,才达到了今天的高度。
也许有读者使用过?

项目地址: github.com/1250422131/...
本文讲解对照代码: github.com/1250422131/...
项目结构

这是这个项目第三次大的重构,一开始其实没有跨平台的想法,项目参考了 nowinandroid ,但没有完全照搬。
我将网络请求、持久化、数据结构、通用组件放入了core下面,设计初衷是希望如果后期支持插件,可对这些配置进行可选引入。
整个业务放入了app模块下,这也为我们后续的KMP整合埋下了伏笔,当然真正的问题远不止于此。
本文将分享我是怎么把这一块内容迁移至Kotlin多平台以及如何整合到IOS项目中。
核心依赖
当初,为了方便后续真的想迁移KMP,我尽可能的在底层选用了可以跨平台的第三方库。
依赖注入:Koin
网络请求:Ktor
持久化存储:Datastore
数据库:Room3
这些依赖构成了这个APP底层的大部分功能支撑,他们也确实都支持多平台,这才让我可以难度较低的迁移至KMP。
迁移
我的想法是这样的,先将core下面的模块全部转化成KMP模块,然后创建一个shared模块整合core,最后其他的平台都导入shared即可,这也和官方例子类似。

需要注意的是,我们此次不迁移UI,我想尝试一下 SwiftUI,当然之后我也会尝试Compose多平台,但不是现在,因为最重要的问题没有解决。
从里到外
我们优先迁移core:common这个模块,因为他被其他多个模块依赖,不迁移它,其他模块应该也不好做。

我们可以看到它还依赖了core:ui,这不太行,我们必须抽离出这部分,当时应该是为了省事,所以一次性导入了。

看起来是之前写的Toast事件分发,为了方便,我直接导入了Compose的事件,我们当然需要调整,我们暂时放到core:ui,之后再考虑。
照猫画虎
有个坏消息,我完全不知道kmp的项目结构,特别是gradle脚本这一块,现在也不是agp了,而是kotlin的某个gradle插件才对。
好吧,我们去idea创建个项目看看。

创建后我们发现核心的地方有3块,询问AI后,红色的区域是支持的平台,黄色区域不必多说,就是安卓的配置了,而蓝色部分则是依赖,commonMain 意味着是给共用代码的依赖。

哦对对对,我们还忘记了最重要的,之所以可以这么写是因为插件。

没想到合二为一了,我们的项目一直使用build-logic来统一管理安卓的一些项目信息,现在也需要照猫画虎做一个自定义插件。
kotlin
class MultiplatformLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.multiplatform")
apply("com.android.kotlin.multiplatform.library")
}
extensions.configure<KotlinMultiplatformExtension> {
configureKotlinMultiplatformAndroid(this)
}
}
}
}
kotlin
internal fun Project.configureKotlinMultiplatformAndroid(kotlinMultiplatformExtension: KotlinMultiplatformExtension) {
kotlinMultiplatformExtension.apply {
val iosXcframework = XCFramework(project.iosFrameworkBaseName())
targets.withType(KotlinMultiplatformAndroidLibraryTarget::class.java).configureEach {
compileSdk = libs.findVersion("android-compileSdk").get().requiredVersion.toInt()
minSdk = libs.findVersion("android-minSdk").get().requiredVersion.toInt()
}
targets.withType(KotlinNativeTarget::class.java).configureEach {
if (konanTarget.family == Family.IOS) {
binaries.framework(project.iosFrameworkBaseName()) {
isStatic = true
iosXcframework.add(this)
}
}
}
val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map {
it.toBoolean()
}.orElse(false)
targets.configureEach {
when (platformType) {
KotlinPlatformType.jvm if this is KotlinJvmTarget -> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
KotlinPlatformType.androidJvm if this is KotlinMultiplatformAndroidLibraryTarget -> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
else -> {}
}
}
}
}
private fun Project.iosFrameworkBaseName(): String = "AS" + path
.removePrefix(":")
.split(":")
.joinToString(separator = "") { segment ->
segment
.replace("-", " ")
.split(" ")
.filter { it.isNotBlank() }
.joinToString("") { word ->
word.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
}
}
}
这里我仿照 nowinandroid 设计了自定义的插件,其实就是用gradle的插件配置刚刚看到的那三块中固定的东西。 Koin也类似,这里我就不展示代码了,大家可以直接看现在的git仓库的kmp分支。
我的gradle版本比较高,这个官方的案例中使用的dsl已经废除,需要替换,就像是下面这样:
kotlin
plugins {
alias(libs.plugins.bilibilias.multiplatform.library)
alias(libs.plugins.bilibilias.multiplatform.koin)
alias(libs.plugins.kotlin.plugin.serialization)
}
kotlin {
androidLibrary {
namespace = "com.imcys.bilibilias.common"
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.coroutines.core)
}
androidMain.dependencies {
api(libs.androidx.core.ktx)
api(libs.androidx.lifecycle.runtime.ktx)
}
}
}
可以看到,省去了很多配置。
现在我们需要创建commonMain文件夹,把整个common的代码给塞进去。

就像是这样:

现在,我有一个之前放在 common 但是只有安卓可以用的扩展函数文件,那此时我们只需要放在 androidMain 即可。 
Datastore 迁移
当初为了可以很容易的同步设置给后端,无论是什么语言,所以我选择了protobuf作为Datastore内部存储格式,core:datastroe-proto这里面可以清晰的看到,我们使用了 protobuf-gradle-plugin 以及 protobuf-kotlin-lite ,这个gradle插件会帮助我们映射proto文件生成Java和Kotlin的实体类,但问题是,生成的Kotlin仍然采用了Java的类,这导致我们无法直接迁移到KMP,目前,我们只能替换一个生成插件。
这一次,我将目光投向Wrie:github.com/square/wire
wire和protobuf-gradle-plugin类似,都支持生成Kotlin的代码,但是wire序列化支持使用okio和Kotlin标准库,这使得我们可以快速迁移过去。
使用相当简单,加个配置就可以。
kotlin
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.wire)
}
wire {
kotlin {}
}
接下来我们和之前一样,把所有文件先都放到commonMain,然后排查有问题的点。

这里我们参考谷歌的整合案例:developer.android.google.cn/kotlin/mult...

可以看到IOS需要配置一个路径,Okio对安卓支持也不错,我们这里就全面改用Okio的接口存储。
kotlin
private fun <T> createDataStore(
fileName: String,
serializer: OkioSerializer<T>,
): DataStore<T> {
return DataStoreFactory.create(
storage = OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = serializer,
producePath = { createDataStorePath(fileName) },
),
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
}
internal expect fun createDataStorePath(fileName: String): Path
这里我们让AI处理一下,我们要在 android 和 IOS 都实现一下这个 createDataStorePath函数。
安卓:
kotlin
internal actual fun createDataStorePath(fileName: String): Path {
val context: Context = KoinPlatform.getKoin().get()
return context.filesDir.resolve("datastore").resolve(fileName).absolutePath.toPath()
}
IOS:
kotlin
@OptIn(ExperimentalForeignApi::class)
internal actual fun createDataStorePath(fileName: String): Path {
val directory = checkNotNull(
NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
)
val basePath = requireNotNull(directory.path)
return "$basePath/datastore/$fileName".toPath()
}
其实就是官方文档里面的那段内容,但是安卓我手动给了下路径,因为okio必须指定,这里我们就按datastore的存储路径写,实际上我们已经有不少用户了,不能放弃旧的配置。
最终我们还需要改一下序列化,也换成Okio的,这里User是已经代码生成出来了哦。
kotlin
object UserSerializer : OkioSerializer<User> {
override val defaultValue: User = User.getDefaultInstance()
override suspend fun readFrom(source: BufferedSource): User {
try {
return User.ADAPTER.decode(source)
} catch (exception: Exception) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: User, sink: BufferedSink) {
User.ADAPTER.encode(sink, t)
}
}
这里,datastore的迁移基本上完成了。
Room3迁移
Room的迁移官方也有指导: developer.android.google.cn/kotlin/mult...
kotlin
@Suppress("KotlinNoActualForExpect")
expect object BILIBILIASDatabaseConstructor :
RoomDatabaseConstructor<BILIBILIASDatabase> {
override fun initialize(): BILIBILIASDatabase
}
internal fun buildDatabase(
builder: RoomDatabase.Builder<BILIBILIASDatabase>
): BILIBILIASDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
}
看上去我们需要在各自的平台实现一下RoomDatabaseConstructor<BILIBILIASDatabase>,但其实room3本身就会自动生成这部分代码我们无需理会。

但为了真正的创建和使用,我们需要按文章那样配置安卓和IOS的创建代码。 androidMain:
kotlin
fun createDatabaseBuilder(context: Context): RoomDatabase.Builder<BILIBILIASDatabase> {
val appContext = context.applicationContext
val databaseFile = appContext.getDatabasePath(DATABASE_NAME)
return Room.databaseBuilder<BILIBILIASDatabase>(
context = appContext,
name = databaseFile.absolutePath,
)
}
iosMain:
kotlin
@OptIn(ExperimentalForeignApi::class)
fun createDatabaseBuilder(): RoomDatabase.Builder<BILIBILIASDatabase> {
val directory = checkNotNull(
NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
)
val databasePath = requireNotNull(directory.path) + "/$DATABASE_NAME"
return Room.databaseBuilder<BILIBILIASDatabase>(name = databasePath)
}
commonMain:
最终,我们用koin完成注入。
Ktor迁移
Ktor支持很多网络请求库来接入, commonMain:
kotlin
expect fun platformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient
androidMain:
kotlin
actual fun platformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient = HttpClient(CIO, block)
iosMain:
kotlin
actual fun platformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient = HttpClient(Darwin, block)
我们在安卓使用OkHttp或者CIO,在IOS上使用Darwin,其实到现在我都不知道Darwin。
Shared整合
实际上我们的core的统一导出就是core:data,但为了真正分离,我们创建新的模块shared。
kotlin
plugins {
alias(libs.plugins.bilibilias.multiplatform.library)
alias(libs.plugins.bilibilias.multiplatform.koin)
alias(libs.plugins.ksp)
}
kotlin {
android {
namespace = "com.imcys.bilibilias.shared"
}
listOf(
iosArm64(),
iosSimulatorArm64(),
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ASShared"
isStatic = true
export(project(":core:data"))
transitiveExport = true
}
}
sourceSets {
commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
dependencies {
implementation(libs.kmp.androidx.lifecycle.runtimeCompose)
implementation(libs.kmp.androidx.lifecycle.viewmodel)
api(project(":core:data"))
api(project(":core:common"))
}
}
}
}
考虑到以后其他平台也需要依赖,这里我们将把data和common一起导出,这块我还没想清楚。
但现在,我们离成功又近一步,已经可以用gradle到命令产出这个shared模块的IOS构建产物了!
总结
通过上面对核心依赖的替换,我们最终实现了将整个core迁移到KMP,之后我将再分享如何嵌入IOS原生项目,感谢大家阅读,后续可以关注专栏和仓库哦。
项目地址: github.com/1250422131/...
分支:KMP