美文网首页
Android App 迁移 KMM 实践

Android App 迁移 KMM 实践

作者: Aengus_Sun | 来源:发表于2023-04-02 16:16 被阅读0次

    KMM Beta推出已经有一段时间了,但是写这篇文章期间因为各种原因耽搁了,导致拖了好久才完成,可能会有部分内容与当下最新情况不同

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

    首先介绍一下Android App的整体技术方案。整体架构遵循了MAD推荐架构,如下图所示,将App分为UI层、网域层和数据层。UI层中,业务逻辑均交给了ViewModel实现,比较通用的逻辑则下沉到了网域层;数据层中,较为复杂的Repository又依赖了DataSource,部分比较简单的Repository则直接使用了API访问:

    MAD Architecture

    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-ViewModelMOKO 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作为容器相关文章:

    和上述方案思路类似的也有现成的库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调用及限制

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

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

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

    第三方库: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:

    Swift cast error

    但如果将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中尝鲜或者作为新技术学习可以一试。

    相关文章

      网友评论

          本文标题:Android App 迁移 KMM 实践

          本文链接:https://www.haomeiwen.com/subject/vterddtx.html