安卓原生项目迁移KMP——核心迁移

前言

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

wireprotobuf-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"))
            }
        }
    }
}

考虑到以后其他平台也需要依赖,这里我们将把datacommon一起导出,这块我还没想清楚。

但现在,我们离成功又近一步,已经可以用gradle到命令产出这个shared模块的IOS构建产物了!

总结

通过上面对核心依赖的替换,我们最终实现了将整个core迁移到KMP,之后我将再分享如何嵌入IOS原生项目,感谢大家阅读,后续可以关注专栏和仓库哦。

项目地址: github.com/1250422131/...

分支:KMP

相关推荐
plainGeekDev8 小时前
Android Framework 面试题:Binder都说不清楚,简历别写精通了
android·java
小孔龙8 小时前
AndroidManifest.xml 配置速查手册
android
七牛云行业应用9 小时前
OpenAI Codex手机版上线实战:iOS/Android 5步配置远程控制指南(2026)
android·ios·智能手机
背包客(wyq)9 小时前
YOLO手势检测识别模型Android端部署测试
android·yolo
peakmain99 小时前
基于 Hilt 实现 Android 网络库可插拔替换 Skill
android·架构·ai编程
黄林晴9 小时前
Google I/O 2026 Android开发者速览
android·android studio
DogDaoDao10 小时前
Android 播放器开发:从零构建全功能视频播放器
android·ffmpeg·音视频·播放器·mediacodec·编解码
Kapaseker10 小时前
Kotlin 的 SAM 到底解决了什么?
kotlin
真鬼12311 小时前
【Unity安卓】Unity 嵌入 Android Studio 完整流程
android·unity·android studio