轻糖的 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 没有 ViewModelStore,viewModelScope 永远不会被系统取消,只能靠我们在 deinit 里手动 cancel()——漏一次,就是一次协程泄漏。
所以当我们看到 rickclephas 的 KMP-ObservableViewModel 时,决定做第三次、也是最后一次桥接升级:让 SwiftUI 直接观察 KMP ViewModel,把 StateHolder 全部删掉。
这篇文章记录这次"清零"迁移:库怎么配、代码怎么改、两批怎么执行,以及迁移过程中沉淀的平台能力边界模式。作为系列收尾,后半篇也记录了迁移完成后的项目形态——新功能怎么开发、这次迁移换来了什么。
📱 先算一笔账: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()
}
}它确实"薄",但有几个结构性问题:
- 样板重复。每个 ViewModel 都要写一遍
@Published+for await+deinit cancel,逻辑一模一样,只有类型不同。23 个 ViewModel 就是 23 份复制粘贴,改一处订阅逻辑要同步改 23 处。 - 生命周期悬空。KMP 侧用的
androidx.lifecycle.ViewModel在 iOS 上没有宿主(没有ViewModelStore机制),viewModelScope的协程不会随视图销毁被取消。订阅的清理完全依赖 Swift 侧deinit的手动cancel()——写对了是自觉,写漏了就是泄漏。 - 双端行为不对称。Android 侧
collectAsStateWithLifecycle()有生命周期感知,App 退后台会自动停止收集;iOS 侧for await一旦开始就停不下来,只能靠 Task 取消。同一个 ViewModel,两端的状态订阅行为不一致,调试时非常容易踩"为什么 iOS 还在刷新"这种问题。
StateHolder 不是不能忍受,但它是一笔持续付出的税。如果有方案能让 SwiftUI 原生观察 StateFlow,这笔税就该被删掉。
🔧 新桥接:KMP-ObservableViewModel + NativeCoroutines
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 { }至此,桥接基建完成。剩下的全是删代码。
🏗️ KMP 侧范式升级:三处改动
以 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 { /* 业务逻辑 */ }
}
}三处改动,缺一不可:
- 继承类从
androidx.lifecycle.ViewModel换成com.rickclephas.kmp.observableviewmodel.ViewModel; - 状态容器换成
MutableStateFlow(viewModelScope, initialValue)——这是最容易踩的坑:继续用标准kotlinx.coroutines.flow.MutableStateFlow的话,Kotlin 侧一切正常,但 SwiftUI 永远收不到状态更新; - StateFlow 属性加
@NativeCoroutinesState注解,让编译器插件生成 Swift 侧可观察桥接。
这套改动是机械性的:26 个 ViewModel 全部统一到同一范式,没有例外。
📱 Swift 侧:从 50 行 StateHolder 到一行属性
改造后,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 严格并发下,Shared和KMPObservableViewModelSwiftUI的 region-isolation 会报错,@preconcurrency把它们标记为"按旧规则处理",一行解决。- KMP 的
suspend fun在 Swift 里是async throws,调用处写try? await或do/catch。 - 父子视图传递同一个实例:子视图用
@ObservedViewModel,构造时ObservedViewModel(wrappedValue: viewModel)包一层。ContentView持有一个HomeViewModel实例,传给HomeView、AddBloodSugarScreen等所有需要它的子视图,全 App 只有一份状态。
| 迁移前 | 迁移后 | |
|---|---|---|
| iOS 侧代码 | @StateObject + StateHolder(约 50 行/VM) | @StateViewModel 一行属性 |
| 状态订阅 | for await 手动循环 + @Published 映射 | property wrapper 自动订阅 |
| 取消订阅 | deinit 手动 cancel() | 视图销毁自动取消 |
| 生命周期 | iOS 无宿主,协程靠手动清理 | viewModelScope 与 ViewModel 绑定 |
🔄 两批迁移,iOS ViewModels 目录清零
桥接基建就绪后,迁移分两批执行,避免一次性大爆炸。
第一批:挑 5 个边界清晰的 ViewModel 试点——AICreditViewModel、FoodDetailViewModel、AddMedicationRecordViewModel、EditBloodSugarRecordViewModel、EditExerciseRecordViewModel。这一步验证了两件事:@NativeCoroutinesState 在真实项目里稳定可用;iOS 侧删除桥接壳后视图改动可控。
第二批:全量清零,删除 13 个文件、约 2242 行 Swift:
| 删除的 iOS 文件 | 行数 | 对应的 KMP ViewModel |
|---|---|---|
ViewModels/Home/HomeViewModel.swift | 300 | HomeViewModel(KMP 已有,360 行改造) |
ViewModels/Auth/AuthViewModel.swift | 369 | AuthViewModel(KMP 重写 254 行) |
ViewModels/Onboarding/OnboardingStateHolder.swift | 158 | OnboardingViewModel |
ViewModels/Profile/ReminderSettingsViewModel.swift | 100 | ReminderSettingsViewModel |
ViewModels/Profile/GlycemicResponseViewModel.swift | 440 | GlycemicResponseViewModel(KMP 补齐 friendly/risky 计算) |
ViewModels/Profile/HealthKitSettingsViewModel.swift | 170 | HealthDataSyncViewModel |
ViewModels/FoodExercise/FoodRecordingViewModel.swift | 55 | FoodRecordingViewModel(KMP 新增,PGRS 映射) |
ViewModels/Recipe/AIRecipeScanViewModel.swift | 73 | RecipeViewModel |
ViewModels/FoodExercise/FoodReferenceViewModel.swift | 120 | FoodExerciseViewModel |
ViewModels/FoodExercise/CustomFoodReferenceViewModel.swift | 123 | AddCustomFoodViewModel |
ViewModels/FoodExercise/ExerciseReferenceViewModel.swift | 85 | FoodExerciseViewModel |
ViewModels/FoodExercise/CustomExerciseReferenceViewModel.swift | 152 | AddCustomExerciseViewModel |
ViewModels/Exercise/ExerciseRecordViewModel.swift | 97 | FoodExerciseViewModel |
迁移完成后,iosApp/BloodSugarApp/ViewModels/ 目录整体消失。整个 iOS 工程只剩 2 处 @StateObject——蓝牙血糖仪适配器 GlucoseDeviceManager 和 ToastManager,都不是业务 ViewModel。
🧩 平台能力的边界:三个关键模式
ViewModel 可以共享,但平台 SDK 永远进不了 KMP。这次迁移把"平台能力留在 Adapter 层"的原则落成了三个可复用的模式。
1. AuthSignInHelper:平台 SDK 只产 idToken
登录是迁移中最容易"顺手把 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) 即可。认证流程的状态机、错误处理、游客迁移逻辑,双端共用一份。
2. HealthKitBridgeDelegate:Swift 实现 KMP 协议
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安全访问@MainActor的HealthKitRepository; - 协议签名里的
BloodSugarRecord指Shared.BloodSugarRecord(KMP model),与本地 Swift 模型同名,必须显式加Shared.前缀。
这样 HealthKit 的权限弹窗、数据读取、导入去重全部留在平台层,HealthDataSyncViewModel 只管编排——而且这套协议 Android 侧也有一个实现(Health Connect),双端行为完全对齐。
3. DTO 单轨 + 桥接友好字段
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 侧零转换、零类型体操,拿到就是能直接渲染的数据。
⚠️ 踩坑合集
1. Swift 6 严格并发:RegionIsolation 报错
@StateViewModel 引入后,Xcode 报了一堆 SendingRisksDataRace / region-isolation 错误。原因是 Shared 框架和 KMPObservableViewModelSwiftUI 没有 Swift 6 并发标注。解法统一为 @preconcurrency import——告诉编译器"这个模块按旧规则处理",一次到位,全工程所有视图统一加。
2. init 自动刷新:ViewModel 每次重建都发网络请求
第一批迁移时,AICreditViewModel 在 init 里自动刷新积分,结果每次视图重建(导航返回、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
// ...
}
}3. companion 静态 map:多视图实例共享过期数据
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 KMP | iOS 保留 50 行 StateHolder 壳 |
| 观察层 | 2026-07~08 | 本篇 | KMPObservableViewModel 直连,StateHolder 全删 | SwiftUI 直接观察 KMP ViewModel |
三个阶段对应三个层次的共享:数据共享 → 业务共享 → 状态共享。每一层下沉时,上层的接入成本都在降低:第一阶段 iOS 还要自己写 DTO 转换,第二阶段只需要 50 行桥接壳,第三阶段连壳都不需要了。
现状盘点:迁移完成后的代码形态
迁移完成后的项目,几个可以量化的特征:
- 26 个 ViewModel 全部在
shared/src/commonMain/。iOS 的ViewModels/目录删除后没有再出现过,也没有"反向迁移"的迹象——共享层在这之后依然稳定增长。 - 整个 iOS 工程只剩 2 处
@StateObject:蓝牙血糖仪适配器GlucoseDeviceManager和ToastManager。它们不是业务 ViewModel,是平台能力与 UI 工具,属于 Adapter 层的合理存在。 - SwiftUI 视图统一
@StateViewModel/@ObservedViewModel。新写的视图,第一行就是@StateViewModel private var viewModel = Shared.XXXViewModel(),已经成了肌肉记忆。 - Android Compose 复用同一批 ViewModel,
viewModel()+collectAsStateWithLifecycle(),零额外桥接。
代码形态上,双端现在只剩两个本质差异:UI 层(SwiftUI vs Compose)和平台 Adapter(HealthKit vs Health Connect、Apple/Google Sign-In、蓝牙、通知)。业务逻辑、状态管理、数据持久化、会员逻辑、同步策略——全部只有一份。
新功能开发流程:KMP-first 已经是默认动作
迁移完成后最有说服力的证据,不是删了多少行代码,而是新功能的开发方式变了。
以正在开发的"统计报告"功能为例。这个功能在迁移完成后启动,它的开发路径是:
- 写 PRD;
- 在
shared/里写领域模型、计算 UseCase; - 写 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()
}
// ...
}- iOS 侧只写视图:
// iosApp/.../Views/Profile/StatisticsReportView.swift
struct StatisticsReportView: View {
@StateViewModel private var viewModel = StatisticsReportViewModel()
// 纯 UI:渲染 viewModel.uiState,回调 viewModel.selectPeriod(...)
}- 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 和平台能力是平台专属的。
收益:这次迁移到底换来了什么
单一事实来源。血糖记录的去重、时间线聚合、单位换算、PGRS 计算、会员判定——这些逻辑只有一份实现。不存在"iOS 修了 bug,Android 忘了同步"这类双端漂移问题。迁移完成后新增的
UserAccess统一入口(会员/登录态判定)就是典型:一个resolveUserAccess()函数,双端共享同一套判定逻辑。行为一致性。同一个 ViewModel,iOS 和 Android 的页面行为完全一致:同样的加载态、同样的错误处理、同样的排序规则。UI 测试只需要覆盖两端各自的视图层。
开发效率。功能逻辑先写在 KMP,双端同时可用;平台能力通过 delegate/helper 注入,KMP 侧不需要关心平台细节。新功能从 PRD 到双端可用的链路明显变短。
代码量的直接变化。这次迁移一次删除了约 2242 行 Swift ViewModel 代码;
ViewModels/目录消失。此后 iOS 新增代码几乎全是视图和平台桥接。
📌 全系列经验总结
三篇文章走完整个迁移,沉淀下来的核心经验:
- 渐进式,不搞大爆炸。数据层 → 业务层 → 状态层,每层独立验证、独立上线。任何一步失败都可以回退,不会出现"迁移到一半项目不可用"的状态。
- 桥接层越薄越好,能删就删。StateHolder 是过渡方案,不是终点。桥接层每多一层,就多一份数量税。找到 KMP-ObservableViewModel 这样的原生方案后,毫不犹豫地清零。
- 平台能力留在 Adapter,通过协议反向注入。AuthSignInHelper 只产 idToken、HealthKitBridgeDelegate 由 Swift 实现 KMP 协议。平台 SDK 不进入 shared,业务编排全部共享——这是双端行为一致性的前提。
- 库选型看社区成熟度。SKIE、Room KMP、RevenueCat KMP、KMP-ObservableViewModel,都是在社区中经过验证的库。KMP 生态里"自己造桥接轮子"是最大的浪费。
- 迁移是最好的双端对齐机会。每次下沉一个模块,都顺手把双端的行为差异修掉(比如这次的静态 map 共享问题)。迁移不只是搬代码,更是双端行为的统一。
回到第一篇文章的标题:从纯 iOS 到 Kotlin Multiplatform。这条路的终点不是"两端代码一样",而是两端共享所有值得共享的东西,只保留必须平台化的部分。对轻糖这个体量的团队来说,这是目前性价比最高的跨平台形态。
本文基于 轻糖 SugarLite 的真实迁移过程撰写。如果你对 KMP 跨平台开发感兴趣,欢迎下载 App 体验。
📚 系列文章与参考资料
- 第一篇:轻糖的 KMP 渐进式迁移实践
- 第二篇:轻糖的 KMP 渐进式迁移实践(二):ViewModel、本地存储与平台抽象
- KMP-ObservableViewModel
- KMP-NativeCoroutines
本文基于轻糖项目的真实迁移过程撰写。
相关内容
如果你觉得这篇文章对你有所帮助,欢迎赞赏~
赞赏
