KMM迁移实践:Android项目向多平台演进指南

Viewed 0

KMM(Kotlin Multiplatform Mobile)最近推出了Beta版本,Jetpack也官宣了将对KMM进行支持,并推出了DataStore与Collection两个库的预览版本。正好手头有个Android项目,于是打算尝试迁移。

首先介绍Android App的整体技术方案。整体架构遵循了MAD推荐架构,将App分为UI层、网域层和数据层。UI层中,业务逻辑均交给了ViewModel实现,比较通用的逻辑则下沉到了网域层;数据层中,较为复杂的Repository又依赖了DataSource,部分比较简单的Repository则直接使用了API访问。App目前主要用到的技术选型为:UI界面Compose,界面导航Navigation,数据库Room,网络请求Retrofit,依赖注入Hilt,JSON库Moshi;此外在所有地方均使用协程与Flow。

得益于协程已经提供了KMM支持,并且数据库、网络请求、依赖注入、JSON序列化均已有可用的工具,因此理论上来讲除了UI界面相关的元素,网域层和数据层均可下沉到common层以达到双端复用的目的。对于数据库,有SQLDelight,网络请求有Ktor,而依赖注入和序列化则分别有Koin和KotlinX Serialization。下面介绍具体迁移过程。

工程迁移

为了防止原本的Gradle版本、库版本不对齐导致难以排查的问题,创建了一个全新的KMM项目,然后再将原先的代码库搬到Android Module下,然后再进行下沉,这样做可以保证KMM项目均使用官方推荐的Gradle脚本等,但需要手工搬代码、改包名等,工作量比较大,推荐的方式还是将KMM以Module的形式集成进来。

依赖注入

原来是Hilt,改为Koin。考虑兼容成本,Android现有代码仍使用Hilt,Koin使用十分简单,查看官方文档即可。由于两套依赖注入库共存,因此需要一些桥接手段,这里介绍桥接过程中遇到的问题。

已经下沉到common层并且使用Koin注入的类,如果Hilt仍然需要注入,可以声明 Provides,其实现从Koin中获取。

@Module
@InstallIn(SingletonComponent::class)
object KoinAdapterModule {
       @Provides
       @Singleton
       fun provideAuthTokenRepository(): AuthTokenRepository {
           return KoinJavaComponent.get(AuthTokenRepository::class.java)
       }
}

Android工程Module内的类依赖Android实现,但是又想把这部分移到common层复用。解决方法是抽离接口,在common层的Koin Module中注入空实现或者基础实现,然后在Android application中重新注入实现。

@HiltAndroidApp
class MyApplication : Application() {
       @Inject lateinit var interfaceBImpl: InterfaceBAndroidImpl
       @Inject lateinit var userServiceImpl: AndroidUserService

       override fun onCreate() {
           super.onCreate()
           startKoin {
               androidLogger()
               androidContext(this@MyApplication)
               modules(appModule() + provideOverrideModule())
           }
       }

       private fun provideOverrideModule(): Module = module {
           factory<InterfaceA> { InterfaceAAndroidImpl() }
           factory<InterfaceB> { interfaceBImpl }
           single<UserService> { userServiceImpl }
       }
}

@Singleton
class AndroidUserService @Inject constructor(
       private val authTokenRepository: AuthTokenRepository
) : UserService {
       // ...
}

重新注入的情况比较复杂,可能会有时序问题。如果重新注入的对象依赖Hilt,还依赖Koin提供的其他实例,此时需要将 startKoin 放在 super.onCreate() 之前,保证Koin在Hilt之前完成注入。我们知道Hilt通过生成代码的方式完成注入,也就是在 super.onCreate() 内进行注入,因此待Hilt注入之后,我们再次将Koin重新注入。

class MyApplication : Application() {
    override fun onCreate() {
        val koin = startKoin {
            androidLogger()
            androidContext(this@MyApplication)
            modules(appModule())
        }
        super.onCreate()
        koin.modules(listOf(provideOverrideModule()))
    }
}

上述的方式依赖Koin的默认配置,即 allowOverride=truecreatedAtStart=false。重新注入的对象不仅依赖Hilt,还依赖Koin提供的其他重新注入的实例,那只能将此对象以及此对象依赖的其他实例全部交由Koin进行注入,需要进行较大的改动。

同时也吐槽一下在iOS中使用Koin注入,需要将所有用到的类在Kotlin中包一层,而不是像在Android中可以直接 get(),不清楚iOS是否有更方便的注入方式,但是目前的注入方式实在有些繁琐。

网络库

网络库由Retrofit迁移至Ktor,相应的JSON库也由Moshi迁移为Kotlin Serialization,JSON库迁移比较简单,主要就是注解换一下。网络库迁移则稍微麻烦一些。

首先是依赖部分,Android和iOS均需要添加平台依赖。

val commonMain by getting {
    dependencies {
        implementation("io.ktor:ktor-client-core:2.1.2")
        implementation("io.ktor:ktor-client-content-negotiation:2.1.2")
        implementation("io.ktor:ktor-serialization-kotlinx-json:2.1.2")
    }
}

val androidMain by getting {
    dependencies {
        implementation("io.ktor:ktor-client-android:2.1.2")
    }
}

val iosMain by creating {
    dependencies {
        implementation("io.ktor:ktor-client-darwin:2.1.2")
    }
}

Ktor使用 HttpClient 进行网络请求,在 commonMain 中添加以下代码。

val commonModule = module {
    factory {
        HttpClient(provideEngineFactory()) {
            defaultRequest {
                url("https://example.com")
                header(HttpHeaders.ContentType, ContentType.Application.Json)
            }
            install(ContentNegotiation) {
                json(Json {
                    encodeDefaults = true
                    prettyPrint = true
                    isLenient = true
                    ignoreUnknownKeys = true
                })
            }
        }
    }
}

expect fun provideEngineFactory(): HttpClientEngineFactory<HttpClientEngineConfig>

然后分别在 androidMainiosMain 目录下实现 provideEngineFactory 方法。

// androidMain
actual fun provideEngineFactory(): HttpClientEngineFactory<HttpClientEngineConfig> = Android

// iosMain
actual fun provideEngineFactory(): HttpClientEngineFactory<HttpClientEngineConfig> = Darwin

在数据层,拿到 HttpClient 实例后,直接调用 get/post/... 方法即可,使用 body<T> 方法获取结果。

httpClient
    .put("/api/v1/article") {
        url { appendPathSegments("20230101") }
        parameter("from", "web")
        header("token", token)
        setBody(param)
    }
    .body<Response<Data>>()

数据库

数据库使用 SQLDelight 框架。其依赖分别为:

val commonMain by getting {
    dependencies {
        implementation("com.squareup.sqldelight:runtime:1.5.4")
    }
}

val androidMain by getting {
    dependencies {
        implementation("com.squareup.sqldelight:android-driver:1.5.4")
    }
}

val iosMain by creating {
    dependencies {
        implementation("com.squareup.sqldelight:native-driver:1.5.4")
    }
}

接着在分别在根目录下的 build.gradle.kts 和common层Module下的 build.gradle.kts 中添加以下内容。

// 根目录 build.gradle.kts
buildscript {
    dependencies {
        classpath("com.squareup.sqldelight:gradle-plugin:1.5.4")
    }
}

// shared/build.gradle.kts
plugins {
    id("com.squareup.sqldelight")
}

sqldelight {
    database("AppDatabase") {
        packageName = "com.example.app.database"
    }
}

SQLDelight将根据上面的配置,生成 com.example.app.database.AppDatabase 类及其 Schema,之后可以调用此类进行数据库相关操作。SQLDelight默认读取sqldelight目录下的 sq 文件生成代码。在 src/commonMain/sqldelight 目录下创建 com.example.app.database 包,然后在其中创建 Article.sq 文件。

CREATE TABLE article(
    article_id INTEGER NOT NULL,
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

findAll:
SELECT *
FROM article;

findById:
SELECT *
FROM article
WHERE article_id = :articleId;

insertArticle:
INSERT INTO article(article_id, title, content)
VALUES (?, ?, ?);

insertArticleObject:
INSERT INTO article(article_id, title, content)
VALUES ?;

上面的文件将生成 ArticleQueries.kt 文件,为了访问此API,添加以下代码创建数据库。

// commonMain中
val databaseModule = module {
    single {
        AppDatabase(createDriver(
            scope = this,
            schema = AppDatabase.Schema,
            dbName = "app_database.db"
        ))
    }
}

expect fun createDriver(scope: Scope, schema: SqlDriver.Schema, dbName: String): SqlDriver

// androidMain中
actual fun createDriver(scope: Scope, schema: SqlDriver.Schema, dbName: String): SqlDriver {
    val context = scope.androidContext()
    return AndroidSqliteDriver(schema, context, dbName)
}

// iosMain中
actual fun createDriver(scope: Scope, schema: SqlDriver.Schema, dbName: String): SqlDriver {
    return NativeSqliteDriver(schema, dbName)
}

之后便可以通过 AppDatabase 访问到 ArticleQueries

class ArticleLocalDataSource(database: AppDatabase) {
    private val articleQueries: ArticleQueries = database.articleQueries

    fun findAll(): List<Article> {
        return articleQueries.findAll().executeAsList()
    }

    fun findById(id: Int): Article? {
        return articleQueries.findById(articleId = id).executeAsOneOrNull()
    }

    fun insertArticle(id: Int, title: String, content: String) {
        articleQueries.insertArticle(article_id = id, title = title, content = content)
    }

    fun insertArticles(articles: List<Article>) {
        articleQueries.transaction {
            articles.forEach { articleQueries.insertArticleObject(it) }
        }
    }
}

SELECT 语句默认返回 data class,可以通过传入 mapper 来转换结果。SQLDelight提供了协程扩展,通过添加依赖 com.squareup.sqldelight:coroutines-extensions:1.5.4 可以将结果转为 Flow

注意:SQLDelight 2.0.0版本后包名及plugin id有所变化,具体查看官方文档。如果由于成本或其他原因,不打算迁移数据库相关内容,但仍想复用数据层,可以将 LocalDataSource 变为接口,common层Repository依赖接口,默认使用空实现,而在上层则使用平台相关数据库实现具体逻辑。需要注意业务中不能含有依赖本地数据库操作的block逻辑,否则可能导致难以排查的bug。

业务逻辑

这里说的业务逻辑主要指ViewModel相关的类,由于ViewModel为Android Jetpack库,无法直接下沉到common层中,目前有第三方提供了KMM库,如 KMM-ViewModel 和 MOKO mvvm,其Android下的实现均是继承自Jetpack的ViewModel类,但两个库均无法使用Koin注入ViewModel,并且使用MOKO mvvm需要将Activity继承自 MvvmActivity,对项目侵入度比较高。

此处提供一个复用思路,将业务逻辑与ViewModel解耦。Android端ViewModel最大的意义是维持状态在配置发生变化时不丢失,而将业务逻辑不一定非要写在ViewModel的子类里,我们可以将业务逻辑单独提取在 Bloc 类中,在Koin中均使用 factory 提供实现。在Android中,ViewModel作为“ Bloc 容器”,iOS中则可以直接使用 Koin#get 进行创建即可。将ViewModel作为容器则可以借助 retained 库。

// commonMain
class ArticleBloc(private val articleRepository: ArticleRepository) {
    val uiStateFlow: StateFlow<ArticleUiState> = ...
    fun destroy() { // cancel coroutine... }
}

// Koin提供实现
val blocModule = module {
    factory { ArticleBloc(articleRepository = get()) }
}

// Android中使用
class ArticleFragment : Fragment() {
    private val articleBloc: ArticleBloc by retain { entry ->
        val bloc = get<ArticleBloc>()
        entry.onClearedListeners += OnClearedListener { bloc.destroy() }
        bloc
    }
}

// iOS中使用
object BlocFactory : KoinComponent {
    fun createArticleBloc(): ArticleBloc = get()
}

和上述方案思路类似的也有现成的库 Kotlin Bloc,其提供了更严格的MVI、SAM风格架构,对于新项目来说可以尝试一下。

由于 Bloc 类与平台相关类解耦,因此原本ViewModel中直接使用的 SavedStateHandle 也无法直接依赖,此时可以将从 SavedStateHandle 获取的值作为参数传入 Bloc 类中,或者抽取接口, Bloc 类依赖接口,构造时将 SavedStateHandle 作为参数传到接口的实现类中。

interface ISavedStateHandle {
    fun <T> getStateFlow(key: String, initialValue: T): StateFlow<T>
    operator fun <T> set(key: String, value: T?)
    operator fun <T> get(key: String): T?
}

val blocModule = module {
    factory { ArticleBloc(savedStateHandle = it.get()) }
}

// androidMain
class AndroidSavedStateHandle(private val delegate: SavedStateHandle) : ISavedStateHandle {
    override fun <T> getStateFlow(key: String, initialValue: T): StateFlow<T> {
        return delegate.getStateFlow(key, initialValue)
    }
    override fun <T> set(key: String, value: T?) { delegate[key] = value }
    override fun <T> get(key: String): T? { return delegate[key] }
}

// Android中使用
private val articleBloc: ArticleBloc by retain { entry ->
    val bloc = get<ArticleBloc>(parametersOf(AndroidSavedStateHandle(entry.savedStateHandle)))
    entry.onClearedListeners += OnClearedListener { bloc.destroy() }
    bloc
}

对于一些平台特殊实现的函数,若没有相关的KMM库,可以手动实现,提供其接口,然后通过依赖注入库注入实现。

Swift调用及限制

Flow / Bloc

下沉后的 Bloc,在Swift中不能像在Android中直接 launch 协程然后 collect,Swift中通常通过 ObservableObject 实现数据UI绑定。对于每个 Bloc,Swift中增加一个对应的包装类,此类的职责是监听 Bloc 中的Flow,并将其绑定到Swift中的State。

import Foundation
import Combine
import shared

class ArticleViewModel : ObservableObject {
    private(set) var bloc: ArticleBloc
    @Published private(set) var state: ArticleUiState

    init(_ wrapped: ArticleBloc) {
        bloc = wrapped
        state = wrapped.uiStateFlow.value as! ArticleUiState
        (wrapped.uiStateFlow.asPublisher() as AnyPublisher<ArticleUiState, Never>)
            .receive(on: RunLoop.main)
            .assign(to: &$state)
    }
}

asPublisher 的实现如下,需要Kotlin代码配合。

// FlowPublisher.swift
import Foundation
import Combine
import shared

public extension Kotlinx_coroutines_coreFlow {
    func asPublisher<T: AnyObject>() -> AnyPublisher<T, Never> {
        (FlowPublisher(flow: self) as FlowPublisher<T>).eraseToAnyPublisher()
    }
}

struct FlowPublisher<T: Any> : Publisher {
    public typealias Output = T
    public typealias Failure = Never
    private let flow: Kotlinx_coroutines_coreFlow

    public init(flow: Kotlinx_coroutines_coreFlow) { self.flow = flow }

    public func receive<S: Subscriber>(subscriber: S) where S.Input == T, S.Failure == Failure {
        subscriber.receive(subscription: FlowSubscription(flow: flow, subscriber: subscriber))
    }

    final class FlowSubscription<S: Subscriber>: Subscription where S.Input == T, S.Failure == Failure {
        private var subscriber: S?
        private var job: Kotlinx_coroutines_coreJob?
        private let flow: Kotlinx_coroutines_coreFlow
        init(flow: Kotlinx_coroutines_coreFlow, subscriber: S) {
            self.flow = flow
            self.subscriber = subscriber
            job = FlowExtensionsKt.subscribe(
                flow,
                onEach: { item in if let item = item as? T { _ = subscriber.receive(item) }},
                onComplete: { subscriber.receive(completion: .finished) },
                onThrow: { error in debugPrint(error) }
            )
        }

        func cancel() {
            subscriber = nil
            job?.cancel(cause: nil)
        }

        func request(_ demand: Subscribers.Demand) {}
    }
}

// Kotlin代码
fun Flow<*>.subscribe(
    onEach: (item: Any) -> Unit,
    onComplete: () -> Unit,
    onThrow: (error: Throwable) -> Unit
): Job = this.subscribe(Dispatchers.Main, onEach, onComplete, onThrow)

fun Flow<*>.subscribe(
    dispatcher: CoroutineDispatcher,
    onEach: (item: Any) -> Unit,
    onComplete: () -> Unit,
    onThrow: (error: Throwable) -> Unit
): Job =
    this.onEach { onEach(it as Any) }
        .catch { onThrow(it) }
        .onCompletion { onComplete() }
        .launchIn(CoroutineScope(Job() + dispatcher))

然后在View中调用即可。有些同学可能习惯使用 SharedFlow 来用作事件通信,如果使用上面提到的 ArticleViewModel 的方式可能会遇到问题,因为 SharedFlow 并没有 value 变量。对于这种情况,可以在Swift中实现接口作为初始值。

密封接口/类

Kotlin的sealed interface或sealed class,在Swift中访问需要将点 . 去掉,如 StateLoading,并且单例需要调用 StateLoading.shared。Swift中调用类似上述的 sealed interface/class 还有一个问题,由于泛型限制,在Swift中无法将 StateLoading.shared 识别为任意 State 泛型的子类。对于这个问题,有以下几种可选方案:假如某个类型的 State 使用比较多,可以创建一个单独的类在Swift中使用;使用强制类型转换;或使用插件 MOKO KSwift 将类转为Swift中的枚举类型。

枚举

Kotlin中声明的枚举,到了Swift中会变成小写开头,如果小写命中了Swift的关键字,则需要在后面加 _ 后缀。

模块化

大部分Android App都可能会有多个Module,而在KMM中,假如一个类引用了另外一个Module中的类,并在Swift中由于某些原因需要类型转换时,可能会引起cast error。出现问题的原因是每个Kotlin Module都会被独立编译,因此 shared.UiState != model.UiState,目前官方还在跟进修复中。这个问题也可以通过一些方式绕过,比如将强转类型修改为模块特定的类名。

Swift Binding

Compose中, TextFiled 通过传入 value 参数以及回调 onValueChange 来进行数据UI之间的绑定,而在Swift中则是通过 Binding 结构体。如果UiState类字段为 var 可变,虽然可以直接绑定到ViewModel中的字段,但是这直接打破了数据流的方向以及破坏了 Bloc 的封装,因此不要这么做。此时推荐进行适当的冗余,在Swift View中监听文本变化并调用Bloc的方法更新状态。

总结

作为一个比较简单的Android App,在迁移过程中仍遇到了不少问题,需要用一些tricky的手段或进行一些妥协,而且遇到的一些问题也很难第一时间确认是代码逻辑有问题还是KMM本身的问题,比较影响开发效率。目前KMM不建议在生产环境或大规模App中使用,或许作为“玩具”在新小App中尝鲜或者作为新技术学习可以一试。

0 Answers