Android App 迁移 KMM 实践 Aengus
2023-04-03 4,078
KMM Beta推出已经有一段时间了,但是写这篇文章期间因为各种原因耽搁了,导致拖了好久才完成,可能会有部分内容与当下最新情况不同
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中获取:
1 2 3 4 5 6 7 8 9 @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中重新注入实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @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 { }
在上面,我们重新注入了三个对象。重新注入的情况比较复杂,可能会有时序问题,我们分别分析:
重新注入的对象 InterfaceAAndroidImpl 不依赖Hilt,此时没有任何问题;
重新注入的对象 interfaceBImpl 依赖Hilt,但是不依赖Koin提供的实例,此时代码上面的代码也没有问题;
重新注入的对象 userServiceImpl 不仅依赖Hilt,还依赖Koin提供的其他实例,此时需要将 startKoin 放在 super.onCreate() 之前,保证Koin在Hilt之前完成注入;我们知道Hilt通过生成代码的方式完成注入,也就是在 super.onCreate() 内进行注入,因此待Hilt注入之后,我们再次将Koin重新注入。此时代码变为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class MyApplication : Application () { override fun onCreate () { val koin = startKoin { androidLogger() androidContext(this @MyApplication ) modules(appModule()) } super .onCreate() koin.modules(listOf(provideOverrideModule())) } }
上述的方式依赖Koin的默认配置,即 allowOverride=true 与 createdAtStart=false
重新注入的对象不仅依赖Hilt,还依赖Koin提供的其他重新注入的实例,那只能将此对象以及此对象依赖的其他实例全部交由Koin进行注入,需要进行较大的改动;
同时也吐槽一下在iOS中使用Koin注入,需要将所有用到的类在Kotlin中包一层,而不是像在Android中可以直接 get(),不清楚iOS是否有更方便的注入方式,但是目前的注入方式实在有些繁琐。
网络库 网络库由Retrofit迁移至Ktor,相应的JSON库也由Moshi迁移为Kotlin Serialization,JSON库迁移比较简单,主要就是注解换一下。网络库迁移则稍微麻烦一些:
首先是依赖部分,Android和iOS均需要添加平台依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 中添加以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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>
然后分别在 androidMain 与 iosMain 目录下实现 provideEngineFactory 方法:
1 2 3 4 5 6 7 actual fun provideEngineFactory () : HttpClientEngineFactory<HttpClientEngineConfig> = Android actual fun provideEngineFactory () : HttpClientEngineFactory<HttpClientEngineConfig> = Darwin
在数据层,拿到 HttpClient 实例后,直接调用 get/post/... 方法即可,使用 body<T> 方法获取结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 httpClient .put("/api/v1/article" ) { url { appendPathSegments("20230101" ) } parameter("from" , "web" ) header("token" , token) setBody(param) } .body<Response<Data>()
数据库 数据库使用 SQLDelight 框架。其依赖分别为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 中添加以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 buildscript { dependencies { classpath("com.squareup.sqldelight:gradle-plugin:1.5.4" ) } } 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语句:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 CREATE TABLE article( article_id INTEGER NOT NULL , title TEXT NOT NULL , content TEXT NOT NULL ); findAll: SELECT * FROM article;findById: SELECT * FROM articleWHERE article_id = :articleId;insertArticle: INSERT INTO article(article_id, title, content)VALUES (?, ?, ?);insertArticleObject: INSERT INTO article(article_id, title, content)VALUES ?;
上面的文件将生成 ArticleQueries.kt 文件,为了访问此API,添加以下代码创建数据库:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 ) : SqlDriveractual fun createDriver (scope: Scope , schema: SqlDriver .Schema , dbName: String ) : SqlDriver { val context = scope.androidContext() return AndroidSqliteDriver(schema, context, dbName) } actual fun createDriver (scope: Scope , schema: SqlDriver .Schema , dbName: String ) : SqlDriver { return NativeSqliteDriver(schema, dbName) }
之后我们便可以通过 AppDatabase 访问到 ArticleQueries:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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 来转换结果:
1 2 3 4 5 articleQueries.selectAll( mapper = { articleId, title, content -> ArticleTitle(articleId, title) } )
SQLDelight提供了协程扩展,通过添加依赖 com.squareup.sqldelight:coroutines-extensions:1.5.4 可以将结果转为 Flow:
1 2 3 4 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 库,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class ArticleBloc ( private val articleRepository: ArticleRepository ) { val uiStateFlow: StateFlow<ArticleUiState> = ... fun destroy () { } } val blocModule = module { factory { ArticleBloc( articleRepository = get () ) } } class ArticleFragment : Fragment () { private val articleBloc: ArticleBloc by retain { entry -> val bloc = get <ArticleBloc>() entry.onClearedListeners += OnClearedListener { bloc.destroy() } bloc } } object BlocFactory : KoinComponent { fun createArticleBloc () : ArticleBloc = get () }
ViewModel作为容器相关文章:
和上述方案思路类似的也有现成的库 Kotlin Bloc ,其提供了更严格的MVI、SAM风格架构,对于新项目来说可以尝试一下。
由于 Bloc 类与平台相关类解耦,因此原本ViewModel中直接使用的 SavedStateHandle 也无法直接依赖,此时可以将从 SavedStateHandle 获取的值作为参数传入 Bloc 类中,或者抽取接口, Bloc 类依赖接口,构造时将 SavedStateHandle 作为参数传到接口的实现类中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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 () ) } } 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] } } 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,其结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import Foundatinimport Combineimport sharedclass 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 的实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import Foundationimport Combineimport sharedpublic 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代码,只是对操作符进行包装:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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中调用即可:
1 2 3 4 5 6 7 8 struct ArticleView : View { @ObservedObject var viewModel: ArticleViewModel var body: some View { return Text (viewModel.state.title) } }
有些同学可能习惯使用 SharedFlow 来用作事件通信(Android官方推荐使用 StateFlow,但是此处不在我们的讨论范围内),如果使用上面我们提到的 ArticleViewModel 的方式可能会遇到问题,比如下面这种情况:
1 2 3 4 5 6 7 8 9 10 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中实现接口作为初始值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import Foundatinimport Combineimport sharedclass 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的文章:
第三方库: Koru
密封接口/类 Kotlin的sealed interface或sealed class,在Swift中访问需要将点 . 去掉,如
1 2 3 sealed interface State <out T > { object Loading : State<Nothing > }
在Swift中就变成了 StateLoading,并且单例需要调用 StateLoading.shared。
Swift中调用类似上述的 sealed interface/class 还有一个问题,由于泛型限制,在Swift中无法将 StateLoading.shared 识别为任意 State 泛型的子类,而在Kotlin则可以:
1 2 3 4 class PageState ( val loadingState: State<Unit > = State.Loading )
1 2 3 4 5 struct PageState { var loadingState: State <KotlinUnit > = StateLoading .shared }
对于这个问题,有以下几种可选方案:
假如某个类型的 State 使用比较多,可以创建一个单独的类在Swift中使用,如 object StateUnitLoading : State<Unit>();
使用 StateLoading.shared as Any as! State<KotlinUnit> 进行强转(暂时没有试过),具体可以查看 KT-55156 [KMM] How to use Covariance in Swift;
使用插件 MOKO KSwift 将类转为Swift中的枚举类型,详细查看 How to implement Swift-friendly API with Kotlin Multiplatform Mobile ;
枚举 Kotlin中声明的枚举,到了Swift中会变成小写开头,如果小写命中了Swift的关键字,则需要在后面加 _ 后缀,如:
1 2 3 4 enum class Visibility { Private, Group }
对应到Swift中的调用则为 Visibility.private_ 和 Visibility.group;
模块化 大部分Android App都可能会有多个Module,而在KMM中,假如一个类引用了另外一个Module中的类,并在Swift中由于某些原因需要类型转换时,可能会引起cast error。比如分别在 model Module中有一个类为 UiState,而在 shared Module中有一个类为 Greeting,两个类结构如下:
1 2 3 4 5 6 7 8 data class UiState ( val title: String ) class Greeting { val uiStateFlow: StateFlow<UiState> = MutableStateFlow(UiState("" )) }
假如在Swift中获取 Greeting.uiStateFlow.value,由于 StateFlow 被编译为OC后丢失了泛型信息,因此需要对 value 进行强转,此时就会报cast error:
但如果将 UiState 也移到 shared Module中,问题就会消失。出现问题的原因是每个Kotlin Module都会被独立编译,因此 shared.UiState != model.UiState,目前官方还在跟进修复中,详细可以查看这两个issue KT-56420 , KT-42247 。这个问题也可以通过一些方式绕过,比如我们可以将强转类型修改为 ModelUiState:
1 let state = Greeting ().uiStateFlow.value as! ModelUiState
这样就可以正常运行,这是由于 ModelUiState 是 shared Module中的类,而 UiState 则是 model 中的类。
Swift Binding Compose中, TextFiled 通过传入 value 参数以及回调 onValueChange 来进行数据UI之间的绑定,而在Swift中则是通过 Binding 结构体,通过添加 @State 即可将值变为 Binding 类型,如下:
1 2 3 4 5 6 7 8 9 10 11 struct InputView : View { @State var text: String = "" var body: some View { return VStack { TextField (text: $text , lable: { Text ("请输入" ) }) } } }
如果UiState类字段为 var 可变(但不推荐这么做),虽然可以直接绑定到ViewModel中的字段让代码看似正常的跑起来,但是这直接打破了数据流的方向以及破坏了 Bloc 的封装,从而可能导致bug,因此不要这么做,此时推荐进行适当的冗余,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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中尝鲜或者作为新技术学习可以一试。