轻糖的 KMP 渐进式迁移实践(二):ViewModel、本地存储与平台抽象
上一篇文章记录了轻糖将数据层从 Supabase Swift SDK 下沉到 KMP shared/ 模块的过程。但当时我们留了一个尾巴——ViewModels 原封不动地留在 iOS 侧用 @Observable,承诺"暂不迁移"。
后来发生的事情是:我们食言了。
随着 Android 版本的开发推进,23 个 ViewModel 中有 21 个需要双端共用。继续在 iOS 侧维护一套 Swift ViewModel、Android 侧再来一套 Kotlin ViewModel 显然不可持续——同一段业务逻辑(拉取今日血糖、构建时间线、计算 PGRS),凭什么写两遍?
这篇文章接着第一篇往下写:ViewModels 如何下沉到 KMP,以及在这个过程中遇到的本地存储、平台抽象、订阅管理和 Swift-Kotlin 桥接问题。
📱 ViewModel 下沉:打破"暂不迁移"的承诺
第一篇的迁移策略第 4 条写的是"ViewModel 暂不迁移,保留 @Observable 维持 SwiftUI 响应式体验"。这个决策在当时是合理的——Kotlin/Native 编译出的 StateFlow,Swift 端拿什么消费?你总不能要求 iOS 团队引入一个 ObservableObject + Combine 的中间层来桥接吧。
转机来自于 SKIE(0.10.13)。这个 TouchLab 开发的 Kotlin 编译器插件能自动将 Kotlin 类型映射为 Swift 原生类型:
suspend函数 → Swiftasync throwsStateFlow<T>/Flow<T>→ SwiftAsyncSequence(底层生成SkieSwiftAsyncSequence)- 枚举、Sealed Class → Swift
enum
有了 SKIE,一个 KMP ViewModel 在 SwiftUI 侧的消费体验几乎和原生 @Observable 一样自然。
KMP ViewModel 模式
经过 23 个 ViewModel 的实践,我们沉淀出一套统一范式:
// 1. 单一 UiState data class,承载全部 UI 状态
data class HomeUiState(
val selectedDate: Instant = Clock.System.now(),
val bloodSugarRecords: List<BloodSugarRecord> = emptyList(),
val timelineEvents: List<TimelineEvent> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
)
// 2. ViewModel 继承自 KMP ViewModel
class HomeViewModel : ViewModel() {
// 3. 私有 mutable flow + 公共 read-only flow
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
// 4. 使用 viewModelScope 管理协程
fun loadData() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val uid = getCurrentUserId()
val records = getDailyBloodSugarRecords(dateMillis, uid)
_uiState.update {
it.copy(isLoading = false, bloodSugarRecords = records.map { r -> r.toModel() })
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
}
}
}
// 5. 状态变更始终用 update { it.copy(...) }
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
}几个设计决策:
- 每个 ViewModel 只暴露一个
uiState: StateFlow。不搞多个独立 Flow,避免 iOS 侧需要多路订阅。 - State 是单一
data class。Android 端collectAsStateWithLifecycle()直接解构;iOS 端通过 SKIE 转成AsyncSequence,一次for await拿到全部状态。 - 状态变更统一用
_uiState.update { it.copy(...) }。不用_uiState.value = ...,避免竞态条件。 - 依赖通过顶层 useCase 函数注入,不走 Koin。ViewModel 实例化就是
HomeViewModel(),零 DI 开销。
iOS 侧消费
以 OnboardingViewModel 为例,iOS 侧用 SKIE 的 AsyncSequence 桥接:
// OnboardingStateHolder.swift
import Shared
@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
// SKIE 将 StateFlow 转为 AsyncSequence
for await state in self?.viewModel.uiState ?? AsyncStream<HomeUiState>.empty {
guard let self else { return }
self.state = state // 驱动 SwiftUI 刷新
}
}
viewModel.loadData()
}
deinit {
collectionTask?.cancel() // 取消订阅,释放资源
}
}核心就三步:for await state in viewModel.uiState → 赋值到 @Published → SwiftUI 自动刷新。不引入任何第三方桥接库。
为了更方便,我们还写了一个 Swift 扩展来获取首个值:
extension SkieSwiftFlowProtocol {
func first() async throws -> Element {
for try await element in self {
return element
}
throw NSError(domain: "SkieSwiftFlow", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Flow completed without emitting a value"])
}
}这样一次性读取(如检查某个标志位)可以写 let state = try await viewModel.uiState.first(),不用 for await。
Android 侧消费
Android 端直接用 Compose:
@Composable
fun HomeScreen() {
val viewModel = remember { HomeViewModel() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(
records = uiState.bloodSugarRecords,
isLoading = uiState.isLoading,
onRefresh = { viewModel.loadData() }
)
}一行 collectAsStateWithLifecycle(),比 iOS 侧还简单。
对比:迁移前 vs 迁移后
迁移前(iOS @Observable ViewModel,约 300+ 行 Swift):
@Observable
final class HomeViewModel {
var bloodSugarRecords: [BloodSugarRecord] = []
var isLoading = false
var errorMessage: String?
func loadData() async {
isLoading = true
do {
let dtos = try await repository.fetchDailyRecords(date: selectedDate)
bloodSugarRecords = dtos.map { BloodSugarRecord(from: $0) }
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}迁移后(KMP ViewModel,约 370 行 Kotlin,但 Android + iOS 共用):
KMP ViewModel 写法如上所述。iOS 侧从 300+ 行缩减为约 50 行的 StateHolder 桥接层 + @Published 属性映射。Android 侧从零开始,直接消费 uiState。
关键收益:
- 业务逻辑只写一遍。血糖记录的去重、时间线聚合、单位转换等逻辑不再需要 Swift 和 Kotlin 双份维护。
- iOS 侧的
StateHolder极薄(纯数据转发,无业务逻辑),像"遥控器"一样驱动原生 SwiftUI 视图。 - 新增功能先在 KMP 实现,双端同时可用。
💾 Room KMP:从 SwiftData 到跨平台本地数据库
数据层下沉(第一篇)解决了远程存储问题,但本地持久化还是分裂的:iOS 用 SwiftData,Android 用 Room。这两个框架的 schema、查询语法、迁移机制完全不同,维护两套本地存储的代价不亚于维护两套网络层。
为什么选 Room KMP
SwiftData 是 Apple 生态专属,无法跨平台。Room KMP 在 2024 年末随着 Room 2.8 正式支持 KMP 后,成熟度已经足够用于生产。我们的选择很明确:将本地存储也统一到 shared/ 模块。
AppDatabase 核心配置
@Database(
entities = [
BloodSugarRecordEntity::class,
FoodRecordEntity::class,
ExerciseRecordEntity::class,
UserProfileEntity::class,
// ... 共 14 个 Entity
],
version = 12
)
@TypeConverters(InstantConverter::class)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun bloodSugarDao(): BloodSugarDao
abstract fun foodRecordDao(): FoodRecordDao
abstract fun exerciseRecordDao(): ExerciseRecordDao
// ... 共 13 个 DAO
}每个 Entity 都内建了离线同步字段:
@Entity(tableName = "blood_sugar_records")
data class BloodSugarRecordEntity(
@PrimaryKey val id: String,
val uid: String,
val value: Double,
val isDirty: Boolean = false, // 是否有未同步的变更
val deleted: Boolean = false, // 软删除标记
val syncVersion: Long = 0, // 乐观锁版本号
val sourceDeviceId: String = "", // 数据来源设备
@ColumnInfo(name = "created_at") val createdAt: Long,
// ...
)同步策略很朴素:向上同步时查询 isDirty = 1 的记录推送到 Supabase;向下同步时按 uid 拉取远端增量,用 Upsert 合并到本地。
DatabaseFactory 的 expect/actual 模式
Room 需要知道数据库文件的物理路径。iOS 在沙盒的 NSDocumentDirectory 下,Android 在应用的 database 目录下。我们用 expect interface 抽象了这个差异:
// commonMain — 接口定义
interface DatabaseFactory {
fun createBuilder(): RoomDatabase.Builder<AppDatabase>
}
fun getRoomDatabase(factory: DatabaseFactory): AppDatabase {
return factory.createBuilder()
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.addMigrations(MIGRATION_3_4, MIGRATION_4_5, /* ... 共 9 个迁移 */)
.fallbackToDestructiveMigration(true)
.build()
}// iosMain — NSFileManager 获取 documents 目录
class IOSDatabaseFactory : DatabaseFactory {
override fun createBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbPath = documentDirectory() + "/sugarlite.db"
return Room.databaseBuilder<AppDatabase>(name = dbPath)
}
@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
val url = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null, create = false, error = null
)
return requireNotNull(url?.path)
}
}// androidMain — Context.getDatabasePath
class AndroidDatabaseFactory(private val context: Context) : DatabaseFactory {
override fun createBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFile = context.getDatabasePath("sugarlite.db")
return Room.databaseBuilder<AppDatabase>(
context = context.applicationContext,
name = dbFile.absolutePath
)
}
}两端的数据库文件名都是 sugarlite.db,通过 Koin DI 在各平台注入对应的 Factory。
从 SwiftData 到 Room 的一次性迁移
引入 Room KMP 后,已经安装旧版本的用户本机还有 SwiftData 存储的旧数据。我们写了一个 SwiftDataMigrationManager 来处理这个一次性迁移:
// SwiftDataMigrationManager.swift
@MainActor
final class SwiftDataMigrationManager {
func migrateIfNeeded(context: ModelContext) async {
let dao = Shared.CloudSourceProviderKt.getMigrationMetadataDao()
// 检查是否已迁移
if let meta = try? await dao.getByKey(key: "swift_data_imported"),
meta.swiftDataImported { return }
// 从 SwiftData 读取旧数据 → 写入 Room KMP
await performMigration(context: context)
}
private func performMigration(context: ModelContext) async {
let descriptor = FetchDescriptor<UserProfile>()
let oldProfiles = try? context.fetch(descriptor)
for profile in oldProfiles ?? [] {
let dto = profile.toDto()
try? await Shared.CloudSourceProviderKt.getUserProfileLocalSource().upsert(dto: dto)
}
// ... 同样迁移 BloodSugarRecord、FoodRecord、ExerciseRecord
// 标记迁移完成
try? await Shared.CloudSourceProviderKt.getMigrationMetadataDao()
.upsert(MigrationMetadataEntity(swiftDataImported: true))
}
}迁移在 App 启动时执行,完成后永不再触发。旧 SwiftData 模型代码保留在项目中(用于读取),但新数据全部走 Room KMP。
数据库版本迁移
Room 数据库经历了从 version 3 到 12 的 9 次增量迁移。下面是几个有代表性的:
| 迁移 | 版本 | 变更 |
|---|---|---|
MIGRATION_3_4 | 3→4 | 为 food_glycemic_responses 添加同步字段(is_dirty, deleted, sync_version) |
MIGRATION_5_6 | 5→6 | 删除 food_name 列。由于 SQLite 不支持 DROP COLUMN,用表重建模式:建新表→复制数据→删旧表→重命名 |
MIGRATION_11_12 | 11→12 | 清理 exercise_records 中的缓存列(cached_exercise_name 等 5 个冗余字段),改为从 exercise_reference 实时查询 |
表重建模式是 Room 迁移中常见的技巧:
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE food_glycemic_responses_new (... 新 schema,不含 food_name)
""")
db.execSQL("INSERT INTO food_glycemic_responses_new SELECT ... FROM food_glycemic_responses")
db.execSQL("DROP TABLE food_glycemic_responses")
db.execSQL("ALTER TABLE food_glycemic_responses_new RENAME TO food_glycemic_responses")
// 重建索引
db.execSQL("CREATE INDEX index_food_glycemic_responses_uid ON food_glycemic_responses(uid)")
}
}踩坑:RTrees 与 ZIP 格式
在 proto 过程中,我们发现迁移脚本中的 CREATE INDEX IF NOT EXISTS index_xyztree ... 在某些 SQLite 版本上会失败——因为 RTrees(R*Tree)索引只支持局部创建,跨 schema 复制时需要先创建虚拟表,再逐行 INSERT,最后重建关联索引。最终我们在 migration v10→v11 中直接删除了不再需要的索引,避开了 RTree 兼容性问题。
🔧 expect/actual:平台差异的优雅解法
迁移 ViewModel 和 Room 之后,shared/ 模块的代码占比超过 85%。剩下的平台差异怎么处理?
Kotlin 的 expect/actual 机制允许在 commonMain 中声明接口,在 androidMain/iosMain 中提供平台实现。轻糖有 5 组 expect/actual 声明,每一组都很轻量:
1. 平台信息获取
// commonMain
expect object PlatformInfo {
val platform: String // "ios" / "android"
val osVersion: String // "17.5" / "14"
val deviceModel: String // "iPhone15,2" / "Pixel 8"
val appVersion: String // "1.2.3"
}// iosMain
actual object PlatformInfo {
actual val platform: String = "ios"
actual val osVersion: String get() = UIDevice.currentDevice.systemVersion
actual val deviceModel: String get() = UIDevice.currentDevice.model
actual val appVersion: String get() =
NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "unknown"
}// androidMain
actual object PlatformInfo {
actual val platform: String = "android"
actual val osVersion: String get() = Build.VERSION.RELEASE
actual val deviceModel: String get() = Build.MODEL
actual var appVersion: String = "unknown"
fun init(context: Context) {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pkgInfo.versionName ?: "unknown"
}
}这些信息主要用于:App 启动日志、提交反馈时附带设备信息、Supabase 请求头中的 User-Agent。
2. 设备语言
// commonMain
expect fun getDeviceLanguageCode(): String
// iosMain
actual fun getDeviceLanguageCode(): String = NSLocale.currentLocale.languageCode ?: "en"
// androidMain
actual fun getDeviceLanguageCode(): String = java.util.Locale.getDefault().language用于 Supabase Edge Function 的 Accept-Language 头和首次启动时的默认语言选择。
3. UUID 生成
// commonMain
expect fun generateUUID(): String
// iosMain
actual fun generateUUID(): String = platform.Foundation.NSUUID().UUIDString()
// androidMain
actual fun generateUUID(): String = java.util.UUID.randomUUID().toString()用于离线记录的唯一 ID 生成(在同步到 Supabase 前分配)。
4. DI 平台模块
// commonMain
expect val platformModule: Module这是最"重"的一组 expect/actual,在 DI 初始化时合并进 Koin:
// iosMain
actual val platformModule: Module = module {
single<DatabaseFactory> { IOSDatabaseFactory() }
single<HealthDataSyncRepository> { HealthDataSyncRepositoryIos() }
single<SettingsRepository> { SettingsRepositoryIos() }
}// androidMain
actual val platformModule: Module = module {
single<DatabaseFactory> { AndroidDatabaseFactory(androidContext()) }
single<HealthDataSyncRepository> { HealthDataSyncRepositoryAndroid(androidContext()) }
single<SettingsRepository> { SettingsRepositoryAndroid(androidContext()) }
}// KoinHelper.kt — iOS 侧启动 Koin
fun doInitKoin() {
initKermitForIos()
startKoin {
modules(sharedModule, platformModule) // platformModule 来自 expect/actual
}
}iOS SwiftUI 入口调用 KoinHelperKt.doInitKoin() 即可完成所有初始化。
5. Room 构造器(代码生成)
// commonMain — Room KSP 自动生成 actual
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase>这个是 Room KSP 编译器生成的,不需要手写 actual。
expect/actual 的使用原则
5 组声明,总计不到 200 行 actual 代码。我们遵循两条原则:
- 只抽象"数据来源"差异,不抽象行为差异。比如 iOS HealthKit 和 Android Health Connect 虽然名字相似,但 API 形态完全不同。强行用
expectinterface 抹平会让接口签名变得奇怪。HealthDataSyncRepositoryIos目前是 stub(所有方法返回空),等 HealthKit 接入时直接替换真实实现。 - 粒度要够小。一个
expect fun getDeviceLanguageCode(): String比expect class LocaleManager好维护得多。每个expect只做一件事,平台实现零依赖(不引入额外框架),测试也不必 mock。
🔌 SKIE 与 CloudSourceProvider:Kotlin-Swift 桥接最佳实践
KMP 共享模块里的代码要能被 Swift 调用,需要经过 Kotlin/Native 编译成 Objective-C 兼容的 Framework。但默认的 Kotlin→ObjC 映射很生硬:suspend 函数变成带 completion handler 的回调,Flow 不可见,StateFlow 变成 Kotlinx_coroutines_coreStateFlow。
SKIE 解决了这些问题。 配置很简单:
// shared/build.gradle.kts
plugins {
alias(libs.plugins.skie) // 0.10.13,零配置
}
kotlin {
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
export(libs.androidx.lifecycle.viewmodel) // 导出给 iOS 可见
}
}不需要额外的 skie {} 配置块。SKIE 默认启用了 Feature_CoroutinesInterop,自动将:
suspend fun→ Swiftasync throwsFlow<T>/StateFlow<T>→ SwiftAsyncSequence- 所有类型变成 Swift 可读的名称
CloudSourceProvider 模式
既然 SKIE 自动桥接了函数签名,我们设计了一个集中暴露 Kotlin 能力的入口文件——CloudSourceProvider.kt。它使用 KoinComponent 从 DI 容器中获取实例,以顶层函数暴露给 Swift:
// commonMain,通过 SKIE 暴露给 Swift 调用
internal object KoinProvider : KoinComponent {
fun provideBloodSugarLocalSource(): BloodSugarLocalSource = get()
fun provideMembershipRepository(): MembershipRepository = get()
// ...
}
// 查询类操作:返回 Flow,SKIE 转为 AsyncSequence,iOS 侧做 for await
fun getBloodSugarRecordsByUid(uid: String): Flow<List<BloodSugarRecordDto>> =
KoinProvider.provideBloodSugarLocalSource().getByUidFlow(uid.lowercase())
fun observeMembershipState(): Flow<MembershipState> =
KoinProvider.provideMembershipRepository().membershipState
// 写入类操作:suspend 函数,SKIE 转为 async throws
@Throws(HttpRequestException::class, RestException::class, IOException::class)
suspend fun saveBloodSugarRecord(dto: BloodSugarRecordDto): BloodSugarRecordDto =
KoinProvider.provideBloodSugarRepository().save(dto)
// 单条查询:suspend 即可
suspend fun getBloodSugarRecordById(id: String): BloodSugarRecordDto? =
KoinProvider.provideBloodSugarLocalSource().getById(id)几条经验:
- 持续订阅用
Flow:如getBloodSugarRecordsByUid()返回Flow,iOS 端for await即可响应数据库变更。 - 一次性操作用
suspend:如saveBloodSugarRecord(),Swift 侧直接try await。 @Throws注解很重要:不加的话,Kotlin 异常到 Swift 会变成NSError的generic错误;加了之后 Swift 可以按具体异常类型catch。
在 Swift 中的调用体验
import Shared
// 持续订阅 — SKIE 将 Flow 转为 AsyncSequence
let flow = Shared.CloudSourceProviderKt.getBloodSugarRecordsByUid(uid: userId)
for await records in flow {
self.records = records
}
// 一次性写入
try await Shared.CloudSourceProviderKt.saveBloodSugarRecord(dto: newRecord)
// 订阅会员状态
let stateFlow = Shared.CloudSourceProviderKt.observeMembershipState()
for await state in stateFlow {
self.membershipState = state
}不需要任何回调、delegate 或 completion handler。这就是 SKIE 的价值——让 Kotlin 在 Swift 中几乎跟原生代码一样自然。
⚠️ Kotlin-Swift 异常处理:让错误跨语言流通
通畅的调用路径解决了,但异常处理是另一个容易被忽略的坑。Kotlin 和 Swift 的异常模型差异很大——Kotlin 没有 checked exception,所有异常都是运行时抛出;而 Swift 使用 throws + do/catch 的显式错误模型。当 Kotlin 的异常穿过 Kotlin/Native 边界来到 Swift,会变成什么?
不加 @Throws 的后果——不只是"分不清类型"
Kotlin 和 Swift 的异常模型有根本性分歧。Kotlin 所有异常都是 unchecked——你在函数签名上看不到它会抛什么;而 Swift 通过 throws 要求编译期声明。
先看一个反面案例。假设 CloudSourceProvider 中有一个不标注 @Throws 的 suspend 函数:
// ❌ 不加 @Throws
suspend fun saveBloodSugarRecord(dto: BloodSugarRecordDto): BloodSugarRecordDto =
KoinProvider.provideBloodSugarRepository().save(dto)很多人看到编译后的 Swift 签名有 throws,就以为 catch 能兜住:
// 编译后的 Swift 签名确实有 throws
func saveBloodSugarRecord(dto: BloodSugarRecordDto) async throws -> BloodSugarRecordDto实际情况比这更危险。 根据 KMP 官方文档,suspend 函数不加 @Throws 时:
CancellationException→ 正常传播为NSError(这是唯一的例外)- 其他任何异常(
HttpRequestException、IOException、自己定义的业务异常……)→ 被视为 未处理异常,直达 Swift 侧后 导致程序终止——也就是 crash
下面的代码看起来无害,实际上如果抛出的不是 CancellationException,App 直接闪退:
do {
let result = try await Shared.CloudSourceProviderKt.saveBloodSugarRecord(dto: record)
} catch {
// ⚠️ 这个 catch 永远捕获不到 HttpRequestException!
// 异常到达 Swift 之前就已被 Kotlin/Native 当作"未处理异常"终结了程序
}普通(非 suspend)函数更严格:完全不传播任何 Kotlin 异常,一旦有异常穿过边界,直接 crash。
所以 @Throws 不是一个"让异常类型更具体"的优化,而是一道 安全闸门——只在 @Throws 声明列表中的类型(及子类)才会被安全地转换为 NSError。不在列表中的异常,仍然 crash。
@Throws:安全的白名单
@Throws 注解告诉 Kotlin/Native 编译器:“只把列表中这些类型(及它们的子类)安全地转发为 NSError,其他异常仍然 crash。“这就是为什么每个 CloudSourceProvider 中的写入函数都加上了精确的异常声明:
// ✅ 加了 @Throws——只有这些异常类型会安全传播
@Throws(
HttpRequestException::class, // HTTP 超时、连接失败
RestException::class, // Supabase REST 错误(如违反约束)
UnknownRestException::class, // 未分类的 HTTP 错误
HttpRequestTimeoutException::class, // Ktor 客户端超时
IOException::class, // 网络 IO 错误
CancellationException::class, // 协程取消
)
suspend fun saveBloodSugarRecord(dto: BloodSugarRecordDto): BloodSugarRecordDto =
KoinProvider.provideBloodSugarRepository().save(dto)加了 @Throws 后,Swift 侧可以按异常类型 catch——而且只有这些类型会被安全捕获:
do {
let result = try await Shared.CloudSourceProviderKt.saveBloodSugarRecord(dto: record)
} catch let error as Shared.HttpRequestException {
// 网络超时 — 可以提示用户"网络不稳定,记录已保存到本地"
toast("网络暂时不可用,已离线保存")
// 本地 Room 的数据仍然在,下次同步会自动上传
} catch let error as Shared.RestException {
// Supabase 后端错误 — 记录日志,静默降级
AppLogger.error("保存失败,后端返回错误: \(error)")
} catch {
// ⚠️ 如果这里捕获到不在 @Throws 列表中的异常,
// 它不会被正常传播——程序可能已经 crash 了
// 所以 @Throws 的类型声明一定要全
}关键收益:网络超时不报警,后端错误记日志,两种场景的用户体验完全不同。
CancellationException 必须重新抛出
一个容易被忽略的细节:CancellationException 在 Kotlin 中不是普通异常,它是协程结构化并发的取消信号。在 CloudSourceProvider 里做错误处理时,必须遵循这一条规则:
suspend fun initializeSupabaseAuth() {
try {
val loaded = sharedSupabaseClient.auth.loadFromStorage()
// ...
} catch (e: IllegalStateException) {
AppLogger.i("AuthInit") { "本地无保存的 session" }
} catch (e: Exception) {
if (e is CancellationException) throw e // ← 必须重新抛出!
AppLogger.e("AuthInit", e) { "初始化 Supabase Auth 失败" }
}
}如果吞掉了 CancellationException,协程作用域无法正常取消,可能导致资源泄漏——比如一个 ViewModel 已经销毁了,但它的协程还在跑 Room 查询。
没有 @Throws 的异常怎么处理
不是所有函数都需要 @Throws。查询类函数(返回 Flow 或 suspend)通常不加,但前提是异常在 Kotlin 侧已经被 catch 掉,不会穿过边界:
- Flow 内部的异常通过 Flow 本身传播——SKIE 会把异常包装到
AsyncSequence的迭代中,Swift 侧在for try await里可以 catch 到。这里的行为不同:Flow 中的异常会被终止流,但不会 crash。 - 一次性
suspend查询(如getBloodSugarRecordById)有风险——虽然"没查到"返回null不需要异常,但如果 Room 查询本身抛了异常(比如数据库损坏),不加@Throws就会 crash。安全做法是在 Kotlin 侧用try/catch包裹,把异常转成null返回值:
// 查询失败返回 null,异常在 Kotlin 侧消化掉
suspend fun getBloodSugarRecordById(id: String): BloodSugarRecordDto? =
try {
KoinProvider.provideBloodSugarLocalSource().getById(id)
} catch (e: Exception) {
AppLogger.e("查询单条记录失败", e)
null
}只有在 Kotlin 侧把所有异常都 catch 干净的前提下,省略 @Throws 才是安全的。
几条实用经验
- 跨边界暴露的函数必须加
@Throws,且类型要列全。这不是代码风格问题,是 crash vs 不 crash 的问题。一个遗漏的异常类型就是一颗定时炸弹。 @Throws的异常类型要精确、要覆盖全面。别写@Throws(Exception::class)——到了 Swift 那边还是区分不了。花几分钟把HttpRequestException、RestException、IOException分开声明。同时确保可能抛出的所有类型都在列表里。CancellationException不要吞掉。在 Kotlin 侧的try/catch中判断if (e is CancellationException) throw e,这是一个机械操作但非常关键的习惯。即使在@Throws列表中声明了它,吞掉它依然会导致协程无法正常取消。- 查询函数如果不想加
@Throws,必须在 Kotlin 侧消化所有异常。把异常转成null或默认值再返回,确保没有任何异常穿过语言边界。
💰 RevenueCat KMP:统一会员管理
会员订阅是收入的核心。在迁移前,iOS 用 RevenueCat iOS SDK,Android 还没做。如果各自维护,两端的行为一致性很难保证——“免费用户能不能看到这个功能"这种判断必须是一模一样的逻辑。
RevenueCat 官方提供了 revenuecat/purchases-kmp SDK(我们用 3.1.0),API 与原生 SDK 非常接近。我们在 commonMain 中实现了完整的 MembershipRepository:
class MembershipRepositoryImpl(
private val userProfileRepository: UserProfileRepository
) : MembershipRepository {
private val _membershipState = MutableStateFlow(MembershipState.FREE)
override val membershipState: StateFlow<MembershipState> = _membershipState.asStateFlow()
// PurchasesDelegate:接收 RevenueCat 实时推送
private val delegate = object : PurchasesDelegate {
override fun onCustomerInfoUpdated(customerInfo: CustomerInfo) {
val state = customerInfo.toMembershipState()
emitAndPersist(state) // 更新本地状态 + 同步到 Supabase
}
}
override fun configure(apiKey: String, appUserId: String?, debugMode: Boolean) {
Purchases.configure(apiKey = apiKey) { this.appUserId = appUserId }
Purchases.sharedInstance.delegate = delegate
}
override suspend fun purchase(packageIdentifier: String): Result<MembershipState> {
return runCatching {
val offerings = Purchases.sharedInstance.awaitOfferings()
val pkg = offerings.current!!.availablePackages
.find { it.identifier == packageIdentifier }!!
val result = Purchases.sharedInstance.awaitPurchase(pkg)
result.customerInfo.toMembershipState()
}
}
// CustomerInfo → MembersipState 映射
private fun CustomerInfo.toMembershipState(): MembershipState {
val entitlement = entitlements["SugarLite Pro"]
return if (entitlement?.isActive == true) {
val expirationDate = entitlement.expirationDateMillis?.let {
Instant.fromEpochMilliseconds(it)
}
val tier = if (expirationDate == null) MembershipTier.LIFETIME
else MembershipTier.PREMIUM
MembershipState(tier = tier, expirationDate = expirationDate, isActive = true)
} else {
MembershipState.FREE
}
}
}核心设计:
- 单一事实来源:
_membershipState是会员状态的唯一真实来源。RevenueCat 实时推送通过PurchasesDelegate.onCustomerInfoUpdated自动更新这个状态。 - 持久化到 Supabase:每次状态变更同时写入
user_profiles.membership_type,确保后端也同步了最新的会员等级。 - 双端统一:
configure()方法接受apiKey参数,iOS 和 Android 各自使用自己的 API Key 调用,其余逻辑 100% 共享。
iOS 侧使用时只需一行:
Shared.CloudSourceProviderKt.configureRevenueCat(
apiKey: Constants.revenueCatApiKey,
appUserId: uid,
debugMode: isDebug
)📋 迁移策略总结(续)
续接第一篇文章的五条原则,补充后续迁移中沉淀的经验:
- ViewModel 可以迁移,前提是有 SKIE。
StateFlow通过 SKIE 转AsyncSequence的体验足够好,不用再为每个功能维护两份 ViewModel。但 iOS 侧仍需要一个薄薄的StateHolder来做@Published桥接——这个成本很低,一个 ViewModel 的桥接通常不到 50 行。 - 本地存储选 KMP 优先。Room KMP 已经足够成熟,14 个 Entity、13 个 DAO、12 个版本迁移都能稳定运行。新的本地存储需求不要再用平台专属方案(SwiftData、SharedPreferences 等),直接走 Room KMP。
expect/actual不求全,只做必要抽象。5 组声明覆盖了 UUID、语言、设备信息、DB 路径、DI 注入。不要为了"设计一致性"强行抽象——HealthDataSyncRepository的 iOS 实现目前就是一个 stub,因为强行抹平 HealthKit 和 Health Connect 的接口差异会让代码更难读。- RevenueCat KMP 值得用。官方 KMP SDK 的 API 覆盖了核心场景(登录、购买、恢复、Offerings),
PurchasesDelegate的实时推送机制也很好用。少量未覆盖的高级功能(如 Paywalls)可以直接在平台层补充。 - SKIE 让桥接层几乎消失。
CloudSourceProvider只负责从 Koin 取实例并暴露函数签名,不写任何胶水代码。suspend自动变async,Flow自动变AsyncSequence。新成员上手时看到import Shared+try await的组合会觉得很自然。
本文基于 轻糖 SugarLite 的真实迁移过程撰写。如果你对 KMP 跨平台开发感兴趣,欢迎下载 App 体验。
📚 参考资料
- 第一篇:轻糖的 KMP 渐进式迁移实践
- Room KMP 官方文档
- SKIE — Swift Kotlin Interface Enhancer
- RevenueCat KMP SDK
- Kotlin expect/actual 官方文档
本文基于轻糖项目的真实迁移过程撰写。
相关内容
如果你觉得这篇文章对你有所帮助,欢迎赞赏~
赞赏
