目录

轻糖的 KMP 渐进式迁移实践(三):KMPObservableViewModel 直连,iOS 本地 ViewModel 全量退休

第二篇文章的结尾,我们给出的方案是"KMP ViewModel + SKIE + 一个 50 行的 StateHolder",当时我们说:这个成本很低,一个 ViewModel 的桥接通常不到 50 行。

这句话后来被我们收回了。

成本确实低,但它是一笔数量税——23 个 ViewModel 就要写 23 份 StateHolder,每一份都是 @Published 属性映射、for await 订阅循环、deinit 手动取消的重复样板。更麻烦的是,KMP 侧的 ViewModel 生命周期在 iOS 上一直是悬空的:androidx 的 ViewModel 在 iOS 没有 ViewModelStoreviewModelScope 永远不会被系统取消,只能靠我们在 deinit 里手动 cancel()——漏一次,就是一次协程泄漏。

所以当我们看到 rickclephas 的 KMP-ObservableViewModel 时,决定做第三次、也是最后一次桥接升级:让 SwiftUI 直接观察 KMP ViewModel,把 StateHolder 全部删掉

这篇文章记录这次"清零"迁移:库怎么配、代码怎么改、两批怎么执行,以及迁移过程中沉淀的平台能力边界模式。作为系列收尾,后半篇也记录了迁移完成后的项目形态——新功能怎么开发、这次迁移换来了什么。

第二篇里我们展示过 iOS 侧的桥接形态:

// OnboardingStateHolder.swift —— 第二篇的形态
@MainActor
final class OnboardingStateHolder: ObservableObject {
    @Published var state: HomeUiState = .init()
    private let viewModel = Shared.OnboardingViewModel()
    private var collectionTask: Task<Void, Never>?

    func startObserving() {
        collectionTask = Task { [weak self] in
            for await state in self?.viewModel.uiState ?? AsyncStream<HomeUiState>.empty {
                guard let self else { return }
                self.state = state
            }
        }
        viewModel.loadData()
    }

    deinit {
        collectionTask?.cancel()
    }
}

它确实"薄",但有几个结构性问题:

  1. 样板重复。每个 ViewModel 都要写一遍 @Published + for await + deinit cancel,逻辑一模一样,只有类型不同。23 个 ViewModel 就是 23 份复制粘贴,改一处订阅逻辑要同步改 23 处。
  2. 生命周期悬空。KMP 侧用的 androidx.lifecycle.ViewModel 在 iOS 上没有宿主(没有 ViewModelStore 机制),viewModelScope 的协程不会随视图销毁被取消。订阅的清理完全依赖 Swift 侧 deinit 的手动 cancel()——写对了是自觉,写漏了就是泄漏。
  3. 双端行为不对称。Android 侧 collectAsStateWithLifecycle() 有生命周期感知,App 退后台会自动停止收集;iOS 侧 for await 一旦开始就停不下来,只能靠 Task 取消。同一个 ViewModel,两端的状态订阅行为不一致,调试时非常容易踩"为什么 iOS 还在刷新"这种问题。

StateHolder 不是不能忍受,但它是一笔持续付出的税。如果有方案能让 SwiftUI 原生观察 StateFlow,这笔税就该被删掉。

KMP-ObservableViewModel 是 androidx ViewModel 的 KMP 移植版,由 rickclephas 维护。它解决了两件事:

  • viewModelScope 在 iOS 上真正可用,协程生命周期与 ViewModel 绑定;
  • 提供 MutableStateFlow(viewModelScope, initialValue) 构造,把状态与 ViewModel 生命周期绑定,并让状态可被 Swift 侧原生观察。

KMP-NativeCoroutines 是配套的 Gradle 编译器插件。给 StateFlow<T> 属性加上 @NativeCoroutinesState 注解后,插件会在编译期生成对应的 Swift 可观察桥接,SwiftUI 的 property wrapper 可以直接订阅,for await 循环和 @Published 全部消失。

第一步gradle/libs.versions.toml 加版本:

[versions]
androidx-viewmodel = "2.10.0"
kmp-observableviewmodel = "1.0.5"
kmp-nativecoroutines = "1.0.4"

[libraries]
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-viewmodel" }
kmp-observableviewmodel-core = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmp-observableviewmodel" }

[plugins]
kmp-nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmp-nativecoroutines" }

第二步shared/build.gradle.kts 挂插件、加依赖、导出到 framework:

plugins {
    alias(libs.plugins.kmp.nativecoroutines)
    // ...
}

kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
            export(libs.androidx.lifecycle.viewmodel)        // 导出给 iOS 可见
            export(libs.kmp.observableviewmodel.core)
        }
    }

    sourceSets {
        commonMain.dependencies {
            api(libs.androidx.lifecycle.viewmodel)
            api(libs.kmp.observableviewmodel.core)
            // ...
        }
    }
}

// 不暴露 NativeCoroutines 生成的中间类型,Swift 侧只看得到注解后的干净 API
nativeCoroutines {
    exposedSeverity = ExposedSeverity.NONE
}

第三步,Xcode 工程添加 SPM 包 https://github.com/rickclephas/KMP-ObservableViewModel.git,版本 exact 1.0.5,把 KMPObservableViewModelSwiftUI 产物加入 target。

第四步,一行桥接扩展,让所有 Shared.ViewModel 子类符合 KMP-ObservableViewModel 的协议:

// Extensions/KMPObservableViewModel.swift
import KMPObservableViewModelCore
import Shared

extension Shared.ViewModel: @retroactive KMPObservableViewModelCore.ViewModel { }

至此,桥接基建完成。剩下的全是删代码。

HomeViewModel 为例,改造前(第二篇的形态)和改造后:

// 改造前:androidx ViewModel + 普通 MutableStateFlow
class HomeViewModel : androidx.lifecycle.ViewModel() {
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
    // iOS 侧只能靠 SKIE 转 AsyncSequence + StateHolder 手动订阅
}
// 改造后:KMP-ObservableViewModel + @NativeCoroutinesState
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
import com.rickclephas.kmp.observableviewmodel.ViewModel
import com.rickclephas.kmp.observableviewmodel.launch

class HomeViewModel : ViewModel() {
    // 关键:必须用 observableviewmodel 的 MutableStateFlow(viewModelScope, initial)
    private val _uiState = MutableStateFlow(viewModelScope, HomeUiState())

    @NativeCoroutinesState
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    init {
        startObserving()  // viewModelScope.launch { ... }
    }

    fun loadData() {
        viewModelScope.launch { /* 业务逻辑 */ }
    }
}

三处改动,缺一不可:

  1. 继承类androidx.lifecycle.ViewModel 换成 com.rickclephas.kmp.observableviewmodel.ViewModel
  2. 状态容器换成 MutableStateFlow(viewModelScope, initialValue)——这是最容易踩的坑:继续用标准 kotlinx.coroutines.flow.MutableStateFlow 的话,Kotlin 侧一切正常,但 SwiftUI 永远收不到状态更新;
  3. StateFlow 属性@NativeCoroutinesState 注解,让编译器插件生成 Swift 侧可观察桥接。

这套改动是机械性的:26 个 ViewModel 全部统一到同一范式,没有例外。

改造后,SwiftUI 视图长这样:

import SwiftUI
@preconcurrency import Shared
@preconcurrency import KMPObservableViewModelSwiftUI

struct FoodDetailView: View {
    // @StateViewModel 替代 @StateObject,直接持有 KMP ViewModel
    @StateViewModel private var viewModel = Shared.FoodDetailViewModel()

    var body: some View {
        Text("\(viewModel.uiState.foodName)")
        // ...
    }
    .task {
        try? await viewModel.loadData()  // suspend fun → async throws
    }
}

几个要点:

  • @StateViewModel / @ObservedViewModel 替代 @StateObject / @ObservedObject。前者是 KMP-ObservableViewModel 提供的 property wrapper,SwiftUI 直接订阅 @NativeCoroutinesState 生成的可观察属性,视图销毁时自动取消订阅——deinit cancel 那几行终于可以删了。
  • @preconcurrency import。Swift 6 严格并发下,SharedKMPObservableViewModelSwiftUI 的 region-isolation 会报错,@preconcurrency 把它们标记为"按旧规则处理",一行解决。
  • KMP 的 suspend fun 在 Swift 里是 async throws,调用处写 try? awaitdo/catch
  • 父子视图传递同一个实例:子视图用 @ObservedViewModel,构造时 ObservedViewModel(wrappedValue: viewModel) 包一层。ContentView 持有一个 HomeViewModel 实例,传给 HomeViewAddBloodSugarScreen 等所有需要它的子视图,全 App 只有一份状态。
迁移前迁移后
iOS 侧代码@StateObject + StateHolder(约 50 行/VM)@StateViewModel 一行属性
状态订阅for await 手动循环 + @Published 映射property wrapper 自动订阅
取消订阅deinit 手动 cancel()视图销毁自动取消
生命周期iOS 无宿主,协程靠手动清理viewModelScope 与 ViewModel 绑定

桥接基建就绪后,迁移分两批执行,避免一次性大爆炸。

第一批:挑 5 个边界清晰的 ViewModel 试点——AICreditViewModelFoodDetailViewModelAddMedicationRecordViewModelEditBloodSugarRecordViewModelEditExerciseRecordViewModel。这一步验证了两件事:@NativeCoroutinesState 在真实项目里稳定可用;iOS 侧删除桥接壳后视图改动可控。

第二批:全量清零,删除 13 个文件、约 2242 行 Swift:

删除的 iOS 文件行数对应的 KMP ViewModel
ViewModels/Home/HomeViewModel.swift300HomeViewModel(KMP 已有,360 行改造)
ViewModels/Auth/AuthViewModel.swift369AuthViewModel(KMP 重写 254 行)
ViewModels/Onboarding/OnboardingStateHolder.swift158OnboardingViewModel
ViewModels/Profile/ReminderSettingsViewModel.swift100ReminderSettingsViewModel
ViewModels/Profile/GlycemicResponseViewModel.swift440GlycemicResponseViewModel(KMP 补齐 friendly/risky 计算)
ViewModels/Profile/HealthKitSettingsViewModel.swift170HealthDataSyncViewModel
ViewModels/FoodExercise/FoodRecordingViewModel.swift55FoodRecordingViewModel(KMP 新增,PGRS 映射)
ViewModels/Recipe/AIRecipeScanViewModel.swift73RecipeViewModel
ViewModels/FoodExercise/FoodReferenceViewModel.swift120FoodExerciseViewModel
ViewModels/FoodExercise/CustomFoodReferenceViewModel.swift123AddCustomFoodViewModel
ViewModels/FoodExercise/ExerciseReferenceViewModel.swift85FoodExerciseViewModel
ViewModels/FoodExercise/CustomExerciseReferenceViewModel.swift152AddCustomExerciseViewModel
ViewModels/Exercise/ExerciseRecordViewModel.swift97FoodExerciseViewModel

迁移完成后,iosApp/BloodSugarApp/ViewModels/ 目录整体消失。整个 iOS 工程只剩 2 处 @StateObject——蓝牙血糖仪适配器 GlucoseDeviceManagerToastManager,都不是业务 ViewModel。

ViewModel 可以共享,但平台 SDK 永远进不了 KMP。这次迁移把"平台能力留在 Adapter 层"的原则落成了三个可复用的模式。

登录是迁移中最容易"顺手把 SDK 也搬进 shared"的地方——Apple/Google Sign-In 的 SDK 是平台专属的,搬进去就是灾难。我们的做法是把平台 SDK 的职责压缩到只剩"产出 idToken"

// AuthSignInHelper.swift —— 平台层 Sign-In SDK 的唯一职责
enum AuthSignInHelper {
    /// Google SDK 交互拿 idToken(需 present VC)
    @MainActor
    static func googleIdToken(presenting viewController: UIViewController) async throws -> String {
        let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: viewController)
        guard let idToken = result.user.idToken?.tokenString else {
            throw AuthError.accountInvalid
        }
        return idToken
    }

    /// 从 Apple 授权结果解析 idToken
    static func appleIdToken(from result: Result<ASAuthorization, Error>) throws -> String {
        // ...
        return idToken
    }
}

业务登录(校验 token、拉用户资料、合并游客数据)全部走 Shared.AuthViewModel。iOS 视图拿到 idToken 后调用 viewModel.loginWithIdToken(idToken) 即可。认证流程的状态机、错误处理、游客迁移逻辑,双端共用一份。

HealthKit 页的迁移是最重的一个。KMP 侧定义协议,Swift 侧实现,通过 Koin 注入回共享层:

// shared/iosMain —— KMP 侧定义桥接协议
interface HealthKitBridgeDelegate {
    fun isAvailable(): Boolean
    suspend fun checkAuthorizationStatus(): HealthDataAuthorizationStatus
    suspend fun requestAuthorization(): Boolean
    suspend fun readBloodGlucoseRecords(startTime: Instant, endTime: Instant, uid: String): List<BloodSugarRecord>
}
// HealthKitBridgeSetup.swift —— Swift 实现 KMP 协议
final class HealthKitBridgeDelegateImpl: HealthKitBridgeDelegate {
    func isAvailable() -> Bool {
        MainActor.assumeIsolated {
            HealthKitRepository.shared.isHealthKitAvailable
        }
    }

    func __readBloodGlucoseRecords(
        startTime: KotlinInstant,
        endTime: KotlinInstant,
        uid: String
    ) async throws -> [Shared.BloodSugarRecord] {
        let repository = await HealthKitRepository.shared
        let startDate = Date(timeIntervalSince1970: TimeInterval(startTime.epochSeconds))
        let endDate = Date(timeIntervalSince1970: TimeInterval(endTime.epochSeconds))
        let records = try await repository.importBloodGlucoseRecords(from: startDate, to: endDate, uid: uid)
        return records.map { $0.toKmpModel() }  // 本地 Swift 模型 → KMP model
    }
}

几个细节:

  • Kotlin/Native 导出的协议,Swift 侧以 __ 前缀 + async throws 形式实现(SKIE 为 completionHandler 变体生成了默认实现,无需重复);
  • KMP 的 Dispatchers.Main 在 iOS 上就是主线程,同步方法用 MainActor.assumeIsolated 安全访问 @MainActorHealthKitRepository
  • 协议签名里的 BloodSugarRecordShared.BloodSugarRecord(KMP model),与本地 Swift 模型同名,必须显式加 Shared. 前缀。

这样 HealthKit 的权限弹窗、数据读取、导入去重全部留在平台层,HealthDataSyncViewModel 只管编排——而且这套协议 Android 侧也有一个实现(Health Connect),双端行为完全对齐。

HomeUiState 之前维护了 Model/DTO 两套并行镜像,Swift 和 Kotlin 各取所需。迁移时我们砍掉了镜像,UiState 直接持有 DTO,Android 在边界 toModel() 转换:

data class TimelineEventDto(
    val id: String,
    val timestamp: Instant,
    /** [timestamp] 的 epoch 毫秒,供 Swift / ObjC 直接消费 */
    val timestampEpochMillis: Long,
    val type: TimelineEventType,
    // ...
)

data class HomeUiState(
    val selectedDateEpochMillis: Long = Clock.System.now().toEpochMilliseconds(),
    val bloodSugarUnitIsMgDl: Boolean = false,      // 枚举旁路布尔
    val selectedTimeRangeOrdinal: Int = 0,          // 枚举旁路序数
    val timelineEvents: List<TimelineEventDto> = emptyList(),
    // ...
)

凡是 Swift 要消费的值,都附带"桥接友好"形态:kotlin.time.Instant 换成 epochMillis 长整型,枚举旁边挂布尔/序数。这样 Swift 侧零转换、零类型体操,拿到就是能直接渲染的数据。

@StateViewModel 引入后,Xcode 报了一堆 SendingRisksDataRace / region-isolation 错误。原因是 Shared 框架和 KMPObservableViewModelSwiftUI 没有 Swift 6 并发标注。解法统一为 @preconcurrency import——告诉编译器"这个模块按旧规则处理",一次到位,全工程所有视图统一加。

第一批迁移时,AICreditViewModelinit 里自动刷新积分,结果每次视图重建(导航返回、Tab 切换)都会触发一次网络请求。修复分两步:

  • 移除 init 自动刷新,改由视图层 .task { try? await viewModel.refresh() } 驱动;
  • 加 30 秒冷却,自动刷新受冷却保护,用户主动操作才 force
class AICreditViewModel : ViewModel() {
    /** 自动刷新最小间隔:避免视图重建/导航切换时产生冗余请求 */
    private val minRefreshIntervalMs = 30_000L
    private var lastCreditRefreshAtMs = 0L

    private fun shouldSkipAutoRefresh(lastRefreshAtMs: Long): Boolean {
        val now = Clock.System.now().toEpochMilliseconds()
        return now - lastRefreshAtMs < minRefreshIntervalMs
    }

    suspend fun refreshCredit(force: Boolean = false) {
        val uid = currentUserId() ?: return
        if (!force && shouldSkipAutoRefresh(lastCreditRefreshAtMs)) return
        // ...
    }
}

FoodExerciseUiState 的 companion object 里放了静态的 PGRS/统计缓存 map。多个视图各自 @StateViewModel 创建实例后,所有实例共享同一份静态 map——A 页面写入的数据污染 B 页面的展示,并发写还会出竞态。修复:静态 map 改为实例字段,每个 ViewModel 实例一份。

三篇文章,三个阶段。回头看,这是一条非常清晰的"从外到内"的下沉路线:

阶段时间文章下沉内容双端形态
基线 + 数据层2026-05第一篇Supabase Swift SDK → KMP CloudSource,DTO/Repository/UseCase 下沉iOS 调 KMP 数据层,其余不动
业务层2026-06第二篇ViewModel 下沉(SKIE 桥接)、Room KMP、expect/actual、RevenueCat KMPiOS 保留 50 行 StateHolder 壳
观察层2026-07~08本篇KMPObservableViewModel 直连,StateHolder 全删SwiftUI 直接观察 KMP ViewModel

三个阶段对应三个层次的共享:数据共享 → 业务共享 → 状态共享。每一层下沉时,上层的接入成本都在降低:第一阶段 iOS 还要自己写 DTO 转换,第二阶段只需要 50 行桥接壳,第三阶段连壳都不需要了。

迁移完成后的项目,几个可以量化的特征:

  1. 26 个 ViewModel 全部在 shared/src/commonMain/。iOS 的 ViewModels/ 目录删除后没有再出现过,也没有"反向迁移"的迹象——共享层在这之后依然稳定增长。
  2. 整个 iOS 工程只剩 2 处 @StateObject:蓝牙血糖仪适配器 GlucoseDeviceManagerToastManager。它们不是业务 ViewModel,是平台能力与 UI 工具,属于 Adapter 层的合理存在。
  3. SwiftUI 视图统一 @StateViewModel / @ObservedViewModel。新写的视图,第一行就是 @StateViewModel private var viewModel = Shared.XXXViewModel(),已经成了肌肉记忆。
  4. Android Compose 复用同一批 ViewModelviewModel() + collectAsStateWithLifecycle(),零额外桥接。

代码形态上,双端现在只剩两个本质差异:UI 层(SwiftUI vs Compose)和平台 Adapter(HealthKit vs Health Connect、Apple/Google Sign-In、蓝牙、通知)。业务逻辑、状态管理、数据持久化、会员逻辑、同步策略——全部只有一份。

迁移完成后最有说服力的证据,不是删了多少行代码,而是新功能的开发方式变了

以正在开发的"统计报告"功能为例。这个功能在迁移完成后启动,它的开发路径是:

  1. 写 PRD;
  2. shared/ 里写领域模型、计算 UseCase;
  3. 写 KMP ViewModel——直接按本篇的范式,不需要任何讨论:
// shared/src/commonMain/.../viewmodel/StatisticsReportViewModel.kt
data class StatisticsReportUiState(
    val isLoading: Boolean = true,
    val period: ReportPeriod = ReportPeriod.preset(ReportPeriodPreset.default),
    val report: StatisticsReport? = null,
    val isComparisonMode: Boolean = false,
    val isLoggedIn: Boolean = false,
    val isPremium: Boolean = false,
    val errorMessage: String? = null,
)

class StatisticsReportViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(viewModelScope, StatisticsReportUiState())
    @NativeCoroutinesState
    val uiState: StateFlow<StatisticsReportUiState> = _uiState.asStateFlow()

    init { refresh() }

    fun selectPeriod(preset: ReportPeriodPreset) {
        if (preset == ReportPeriodPreset.CUSTOM) return
        _uiState.update { it.copy(period = ReportPeriod.preset(preset), errorMessage = null) }
        refresh()
    }
    // ...
}
  1. iOS 侧只写视图:
// iosApp/.../Views/Profile/StatisticsReportView.swift
struct StatisticsReportView: View {
    @StateViewModel private var viewModel = StatisticsReportViewModel()
    // 纯 UI:渲染 viewModel.uiState,回调 viewModel.selectPeriod(...)
}
  1. Android 侧也只写视图:
// androidApp/.../ui/screens/profile/StatisticsReportScreen.kt
@Composable
fun StatisticsReportScreen(viewModel: StatisticsReportViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 纯 UI:渲染 uiState,回调 viewModel.selectPeriod(...)
}

注意第 4、5 步之间没有任何共享代码的讨论——ViewModel 和 UseCase 在 shared 里写完,双端视图各自消费即可。这就是迁移完成后的日常:新功能默认 KMP-first,只有 UI 和平台能力是平台专属的

  1. 单一事实来源。血糖记录的去重、时间线聚合、单位换算、PGRS 计算、会员判定——这些逻辑只有一份实现。不存在"iOS 修了 bug,Android 忘了同步"这类双端漂移问题。迁移完成后新增的 UserAccess 统一入口(会员/登录态判定)就是典型:一个 resolveUserAccess() 函数,双端共享同一套判定逻辑。

  2. 行为一致性。同一个 ViewModel,iOS 和 Android 的页面行为完全一致:同样的加载态、同样的错误处理、同样的排序规则。UI 测试只需要覆盖两端各自的视图层。

  3. 开发效率。功能逻辑先写在 KMP,双端同时可用;平台能力通过 delegate/helper 注入,KMP 侧不需要关心平台细节。新功能从 PRD 到双端可用的链路明显变短。

  4. 代码量的直接变化。这次迁移一次删除了约 2242 行 Swift ViewModel 代码;ViewModels/ 目录消失。此后 iOS 新增代码几乎全是视图和平台桥接。

三篇文章走完整个迁移,沉淀下来的核心经验:

  1. 渐进式,不搞大爆炸。数据层 → 业务层 → 状态层,每层独立验证、独立上线。任何一步失败都可以回退,不会出现"迁移到一半项目不可用"的状态。
  2. 桥接层越薄越好,能删就删。StateHolder 是过渡方案,不是终点。桥接层每多一层,就多一份数量税。找到 KMP-ObservableViewModel 这样的原生方案后,毫不犹豫地清零。
  3. 平台能力留在 Adapter,通过协议反向注入。AuthSignInHelper 只产 idToken、HealthKitBridgeDelegate 由 Swift 实现 KMP 协议。平台 SDK 不进入 shared,业务编排全部共享——这是双端行为一致性的前提。
  4. 库选型看社区成熟度。SKIE、Room KMP、RevenueCat KMP、KMP-ObservableViewModel,都是在社区中经过验证的库。KMP 生态里"自己造桥接轮子"是最大的浪费。
  5. 迁移是最好的双端对齐机会。每次下沉一个模块,都顺手把双端的行为差异修掉(比如这次的静态 map 共享问题)。迁移不只是搬代码,更是双端行为的统一。

回到第一篇文章的标题:从纯 iOS 到 Kotlin Multiplatform。这条路的终点不是"两端代码一样",而是两端共享所有值得共享的东西,只保留必须平台化的部分。对轻糖这个体量的团队来说,这是目前性价比最高的跨平台形态。

本文基于 轻糖 SugarLite 的真实迁移过程撰写。如果你对 KMP 跨平台开发感兴趣,欢迎下载 App 体验。


本文基于轻糖项目的真实迁移过程撰写。

相关内容