Aengus 的技术小筑

Aengus | Blog

Android App 迁移 KMM 实践

2023-04-03

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 调用及限制

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: &$message)
    }
}

class EmptyMessage: LoginMessage

message类型为EmptyMessage时则忽略。

上面提到的 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 中尝鲜或者作为新技术学习可以一试。