KMM Beta推出现已有一段时间了,可是写这篇文章期间因为各种原因耽搁了,导致拖了良久才完结,或许会有部分内容与当下最新状况不同

KMM(Kotlin Multiplatform Mobile)最近推出了Beta版别,Jetpack也官宣了将对KMM进行支撑,并推出了DataStore与Collection两个库的预览版别,正好手头有个Android项目,于是计划尝尝鲜。

  • Kotlin Multiplatform Mobile Is in Beta – Start Using It Now!
  • Announcing an Experimental Preview of Jetpack Multiplatform Libraries

首先介绍一下Android App的全体技能计划。全体架构遵循了MAD引荐架构,如下图所示,将App分为UI层、网域层和数据层。UI层中,事务逻辑均交给了ViewModel完结,比较通用的逻辑则下沉到了网域层;数据层中,较为杂乱的Repository又依靠了DataSource,部分比较简单的Repository则直接运用了API拜访:

Android App 迁移 KMM 实践

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运用十分简单,检查官方文档即可,此处不再赘述。因为两套依靠注入库共存,因而需求一些桥接手法,这儿介绍一下桥接过程中遇到的问题:

  1. 现已下沉到common层而且运用Koin注入的类,假如Hilt仍然需求注入,能够声明Provides,其完结从Koin中获取:

    @Module
    @InstallIn(SingletonComponent::class)
    object KoinAdapterModule {
        @Provides
        @Singleton
        fun provideAuthTokenRepository(): AuthTokenRepository {
            return KoinJavaComponent.get(AuthTokenRepository::class.java)
        }
    }
    
  2. 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)
                // appModule() 在common层中
                modules(appModule() + provideOverrideModule())
            }
        }
        private fun provideOverrideModule(): Module = module {
            factory<InterfaceA> {
                InterfaceAAndroidImpl()
            }
            factory<InterfaceB> {
                interfaceBImpl
            }
            single<UserService> {
                userServiceImpl
            }
        }
    }
    // AndroidUserService.kt
    @Singleton
    class AndroidUserService @Inject constructor(
        // AuthTokenRepository由Koin供给注入
        private val authTokenRepository: AuthTokenRepository
    ) : UserService {
        // ...
    }
    

    在上面,咱们从头注入了三个目标。从头注入的状况比较杂乱,或许会有时序问题,咱们别离分析:

    1. 从头注入的目标InterfaceAAndroidImpl不依靠Hilt,此刻没有任何问题;

    2. 从头注入的目标interfaceBImpl依靠Hilt,可是不依靠Koin供给的实例,此刻代码上面的代码也没有问题;

    3. 从头注入的目标userServiceImpl不只依靠Hilt,还依靠Koin供给的其他实例,此刻需求将startKoin放在super.onCreate()之前,确保Koin在Hilt之前完结注入;咱们知道Hilt经过生成代码的办法完结注入,也便是在super.onCreate()内进行注入,因而待Hilt注入之后,咱们再次将Koin从头注入。此刻代码变为:

      class MyApplication : Application() {
          override fun onCreate() {
              // 1. Koin注入根底完结
              val koin = startKoin {
                  androidLogger()
                  androidContext(this@MyApplication)
                  modules(appModule())
              }
              // 2. Hilt在生成的类中完结@Inject目标的注入
              super.onCreate()
              // 3. 从头对Koin注入真正完结
              koin.modules(listOf(provideOverrideModule()))
          }
      }
      

      上述的办法依靠Koin的默许装备,即allowOverride=truecreatedAtStart=false

    4. 从头注入的目标不只依靠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中增加以下代码:

// 此处运用Koin注入
val commonModule = module {
    factory {
        HttpClient(provideEngineFactory()) {
            defaultRequest {
                url("https://example.com")
                // 增加默许Header参数
                header(HttpHeaders.ContentType, ContentType.Application.Json)
            }
            install(ContentNegotiation) {
                json(Json {
                    // 是否运用Kotlin字段的默许值
                    encodeDefaults = true
                    prettyPrint = true
                    isLenient = true
                    // 是否疏忽未知的JSON key
                    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 {
            // 在URL后方增加Path参数
            appendPathSegments("20230101")
        }
        // 增加Query参数,即 url?from=web
        parameter("from", "web")
        // 设置Header
        header("token", token)
        // 设置Request Body
        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文件生成代码,也能够经过sourceFolders = listof("customFolder")进行装备,这儿咱们不进行设置。在src/commonMain/sqldelight目录下创立com.example.app.database包,然后在其间创立Article.sq文件,文件第一行一般为创立表语句,后边跟从CRUD语句:

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? {
        // :articleId 为命名参数,因而此处形参名变为articleId而不是article_id
        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来转化成果:

articleQueries.selectAll(
    mapper = { articleId, title, content ->
        ArticleTitle(articleId, title)
    }
)

SQLDelight供给了协程扩展,经过增加依靠com.squareup.sqldelight:coroutines-extensions:1.5.4能够将成果转为Flow

val articles: Flow<List<Article>> =
  articleQueries.findAll()
    .asFlow()
    .mapToList()

注意: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有相关issue,但暂无发展),而且运用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()
}

ViewModel作为容器相关文章:

  • View Model Doesn’t Have To Depend on ViewModel
  • Why Android ViewModels are obsolete in KMM

和上述计划思路相似的也有现成的库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绑定,这儿结合之前看到的别的一个KMM项目KMMNewsApp介绍一种处理计划。

关于每个Bloc,Swift中增加一个对应的包装类,此类的职责是监听Bloc中的Flow,并将其绑定到Swift中的State,其结构如下:

import Foundatin
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的完结如下:

// 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) {
        }
    }
}

FlowExtensionsKt为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中调用即可:

struct ArticleView : View {
    @ObservedObject var viewModel: ArticleViewModel
    var body: some View {
        return Text(viewModel.state.title)
    }
}

有些同学或许习惯运用SharedFlow来用作事情通讯(Android官方引荐运用StateFlow,可是此处不在咱们的评论范围内),假如运用上面咱们说到的ArticleViewModel的办法或许会遇到问题,比方下面这种状况:

sealed class LoginMessage {
    class UsernameEmpty : LoginMessage
    class PasswordEmpty : LoginMessage
    class WrongPassword : LoginMessage
}
class LoginBloc {
    private val _messageFlow: MutableSharedFlow<LoginMessage> = MutableSharedFlow()
    val messageFlow: SharedFlow<LoginMessage> = _messageFlow
}

因为SharedFlow并没有value变量,所以Swift中的变量的初始化就变成了问题,此刻也不能将AnyPublisher的第一个泛型变为可空类型,不然会编译失利。关于这种状况,咱们能够在Swift中完结接口作为初始值:

import Foundatin
import Combine
import shared
class LoginViewModel: ObservableObject {
    private(set) var bloc: LoginBloc
    @Published private(set) var message: LoginMessage
    init(_ wrapped: LoginBloc) {
        bloc = wrapped
        message = EmptyMessage()
        (wrapped.messageFlow.asPublisher() as AnyPublisher<LoginMessage, Never>)
            .receive(on: RunLoop.main)
            .assign(to: &$state)
    }
}
class EmptyMessage: LoginMessage

message类型为EmptyMessage时则疏忽。

上面说到的Kotlin Bloc库同样供给了BlocObserver类,其功能相似将Bloc包装为ViewModel类。

一些其他介绍在Swift中监听Kotlin Flow的文章:

  • Using Kotlin Flow in Swift
  • Wrapping Kotlin Flow with Swift Combine Publisher in a Kotlin Multiplatform project
  • Working with Kotlin Coroutines and RxSwift
  • Kotlin Coroutines and Swift, revisited

第三方库:Koru

密封接口/类

Kotlin的sealed interface或sealed class,在Swift中拜访需求将点.去掉,如

sealed interface State<out T> {
    object Loading : State<Nothing>
}

在Swift中就变成了StateLoading,而且单例需求调用StateLoading.shared

Swift中调用相似上述的sealed interface/class还有一个问题,因为泛型限制,在Swift中无法将StateLoading.shared识别为任意State泛型的子类,而在Kotlin则能够:

// work in Kotlin
class PageState(
    val loadingState: State<Unit> = State.Loading
)
// not work in Swift
struct PageState {
    // Cannot assign value of type 'State<KotlinNothing>' to type 'State<KotlinUnit>'
    var loadingState: State<KotlinUnit> = StateLoading.shared
}

关于这个问题,有以下几种可选计划:

  1. 假如某个类型的State运用比较多,能够创立一个独自的类在Swift中运用,如object StateUnitLoading : State<Unit>()
  2. 运用StateLoading.shared as Any as! State<KotlinUnit>进行强转(暂时没有试过),具体能够检查KT-55156 [KMM] How to use Covariance in Swift;
  3. 运用插件MOKO KSwift将类转为Swift中的枚举类型,具体检查How to implement Swift-friendly API with Kotlin Multiplatform Mobile;

枚举

Kotlin中声明的枚举,到了Swift中会变成小写开头,假如小写命中了Swift的关键字,则需求在后边加_后缀,如:

enum class Visibility {
    Private,
    Group
}

对应到Swift中的调用则为Visibility.private_Visibility.group

模块化

大部分Android App都或许会有多个Module,而在KMM中,假如一个类引用了别的一个Module中的类,并在Swift中因为某些原因需求类型转化时,或许会引起cast error。比方别离在model Module中有一个类为UiState,而在shared Module中有一个类为Greeting,两个类结构如下:

// UiState in model Module
data class UiState(
    val title: String
)
// Greeting in shared Module
class Greeting {
    val uiStateFlow: StateFlow<UiState> = MutableStateFlow(UiState(""))
}

假如在Swift中获取Greeting.uiStateFlow.value,因为StateFlow被编译为OC后丢掉了泛型信息,因而需求对value进行强转,此刻就会报cast error:

Android App 迁移 KMM 实践

但假如将UiState也移到sharedModule中,问题就会消失。出现问题的原因是每个Kotlin Module都会被独立编译,因而shared.UiState != model.UiState,现在官方还在跟进修复中,具体能够检查这两个issueKT-56420, KT-42247。这个问题也能够经过一些办法绕过,比方咱们能够将强转类型修改为ModelUiState

let state = Greeting().uiStateFlow.value as! ModelUiState

这样就能够正常运转,这是因为ModelUiStatesharedModule中的类,而UiState则是model中的类。

Swift Binding

Compose中,TextFiled经过传入value参数以及回调onValueChange来进行数据UI之间的绑定,而在Swift中则是经过Binding结构体,经过增加@State即可将值变为Binding类型,如下:

struct InputView : View {
    @State var text: String = ""
    var body: some View {
        return VStack {
            TextField(text: $text, lable: {
                Text("请输入")
            })
        }
    }
}

假如UiState类字段为var可变(但不引荐这么做),虽然能够直接绑定到ViewModel中的字段让代码看似正常的跑起来,可是这直接打破了数据流的方向以及破坏了Bloc的封装,从而或许导致bug,因而不要这么做,此刻引荐进行适当的冗余,如下:

struct InputView : View {
    @ObservedObject var viewModel: InputViewModel
    @State var text: String = ""
    var body: some View {
        return VStack {
            TextField(text: $text, lable: {
                Text("请输入")
            }).onChange(of: text, perform: { newValue in
                viewModel.bloc.updateText(text: newValue)
            })
        }
    }
}

总结

作为一个比较简单的Android App,在搬迁过程中仍遇到了不少问题,需求用一些tricky的手法或进行一些妥协,而且遇到的一些问题也很难第一时间确认是代码逻辑有问题仍是KMM本身的问题,比较影响开发效率。现在KMM不主张在出产环境或大规模App中运用,或许作为“玩具”在新小App中尝鲜或许作为新技能学习能够一试。