惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tenable Blog
Last Week in AI
Last Week in AI
P
Proofpoint News Feed
Engineering at Meta
Engineering at Meta
H
Help Net Security
F
Fortinet All Blogs
MyScale Blog
MyScale Blog
宝玉的分享
宝玉的分享
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - 司徒正美
量子位
N
Netflix TechBlog - Medium
Apple Machine Learning Research
Apple Machine Learning Research
小众软件
小众软件
Recorded Future
Recorded Future
博客园 - 三生石上(FineUI控件)
Vercel News
Vercel News
aimingoo的专栏
aimingoo的专栏
I
InfoQ
Microsoft Security Blog
Microsoft Security Blog
Scott Helme
Scott Helme
The Last Watchdog
The Last Watchdog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
IT之家
IT之家
AI
AI
WordPress大学
WordPress大学
Security Archives - TechRepublic
Security Archives - TechRepublic
Google Online Security Blog
Google Online Security Blog
U
Unit 42
V2EX - 技术
V2EX - 技术
MongoDB | Blog
MongoDB | Blog
Schneier on Security
Schneier on Security
博客园 - Franky
H
Heimdal Security Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Jina AI
Jina AI
W
WeLiveSecurity
P
Privacy & Cybersecurity Law Blog
Cloudbric
Cloudbric
B
Blog RSS Feed
N
News | PayPal Newsroom
S
Securelist
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
I
Intezer
Hacker News - Newest:
Hacker News - Newest: "LLM"
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园_首页
罗磊的独立博客
H
Hackread – Cybersecurity News, Data Breaches, AI and More
雷峰网
雷峰网

ISLAND

SugarLite 的 KMP 渐进式迁移实践 在 CC Switch 中配置 Claude Desktop 使用 cc switch 和 cc desktop switch 快速切换 Claude Code 供应商 用vscode开发 ios/macos App LazyVim 使用 分布式理论 neovim入门指南(四):LSP配置(下) neovim入门指南(三):LSP配置(上) neovim入门指南(二):常用插件 neovim入门指南(一):基础配置 Gin源码分析一:引擎 Engine HTTP 源码解析 vim 学习路径
轻糖的 KMP 渐进式迁移实践(二):ViewModel、本地存储与平台抽象
youngxhui · 2026-06-26 · via ISLAND

上一篇文章记录了轻糖将数据层从 Supabase Swift SDK 下沉到 KMP shared/ 模块的过程。但当时我们留了一个尾巴——ViewModels 原封不动地留在 iOS 侧用 @Observable,承诺"暂不迁移"。

后来发生的事情是:我们食言了。

随着 Android 版本的开发推进,23 个 ViewModel 中有 21 个需要双端共用。继续在 iOS 侧维护一套 Swift ViewModel、Android 侧再来一套 Kotlin ViewModel 显然不可持续——同一段业务逻辑(拉取今日血糖、构建时间线、计算 PGRS),凭什么写两遍?

这篇文章接着第一篇往下写:ViewModels 如何下沉到 KMP,以及在这个过程中遇到的本地存储、平台抽象、订阅管理和 Swift-Kotlin 桥接问题。

第一篇的迁移策略第 4 条写的是"ViewModel 暂不迁移,保留 @Observable 维持 SwiftUI 响应式体验"。这个决策在当时是合理的——Kotlin/Native 编译出的 StateFlow,Swift 端拿什么消费?你总不能要求 iOS 团队引入一个 ObservableObject + Combine 的中间层来桥接吧。

转机来自于 SKIE(0.10.13)。这个 TouchLab 开发的 Kotlin 编译器插件能自动将 Kotlin 类型映射为 Swift 原生类型:

  • suspend 函数 → Swift async throws
  • StateFlow<T> / Flow<T> → Swift AsyncSequence(底层生成 SkieSwiftAsyncSequence
  • 枚举、Sealed Class → Swift enum

有了 SKIE,一个 KMP ViewModel 在 SwiftUI 侧的消费体验几乎和原生 @Observable 一样自然。

经过 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) }
    }
}

几个设计决策:

  1. 每个 ViewModel 只暴露一个 uiState: StateFlow。不搞多个独立 Flow,避免 iOS 侧需要多路订阅。
  2. State 是单一 data class。Android 端 collectAsStateWithLifecycle() 直接解构;iOS 端通过 SKIE 转成 AsyncSequence,一次 for await 拿到全部状态。
  3. 状态变更统一用 _uiState.update { it.copy(...) }。不用 _uiState.value = ...,避免竞态条件。
  4. 依赖通过顶层 useCase 函数注入,不走 Koin。ViewModel 实例化就是 HomeViewModel(),零 DI 开销。

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 侧还简单。

迁移前(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 实现,双端同时可用。

数据层下沉(第一篇)解决了远程存储问题,但本地持久化还是分裂的:iOS 用 SwiftData,Android 用 Room。这两个框架的 schema、查询语法、迁移机制完全不同,维护两套本地存储的代价不亚于维护两套网络层。

SwiftData 是 Apple 生态专属,无法跨平台。Room KMP 在 2024 年末随着 Room 2.8 正式支持 KMP 后,成熟度已经足够用于生产。我们的选择很明确:将本地存储也统一到 shared/ 模块

@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 合并到本地。

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。

引入 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_43→4food_glycemic_responses 添加同步字段(is_dirty, deleted, sync_version
MIGRATION_5_65→6删除 food_name 列。由于 SQLite 不支持 DROP COLUMN,用表重建模式:建新表→复制数据→删旧表→重命名
MIGRATION_11_1211→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)")
    }
}

在 proto 过程中,我们发现迁移脚本中的 CREATE INDEX IF NOT EXISTS index_xyztree ... 在某些 SQLite 版本上会失败——因为 RTrees(R*Tree)索引只支持局部创建,跨 schema 复制时需要先创建虚拟表,再逐行 INSERT,最后重建关联索引。最终我们在 migration v10→v11 中直接删除了不再需要的索引,避开了 RTree 兼容性问题。

迁移 ViewModel 和 Room 之后,shared/ 模块的代码占比超过 85%。剩下的平台差异怎么处理?

Kotlin 的 expect/actual 机制允许在 commonMain 中声明接口,在 androidMain/iosMain 中提供平台实现。轻糖有 5 组 expect/actual 声明,每一组都很轻量:

// 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。

// 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 头和首次启动时的默认语言选择。

// 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 前分配)。

// 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() 即可完成所有初始化。

// commonMain — Room KSP 自动生成 actual
@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase>

这个是 Room KSP 编译器生成的,不需要手写 actual

5 组声明,总计不到 200 行 actual 代码。我们遵循两条原则:

  1. 只抽象"数据来源"差异,不抽象行为差异。比如 iOS HealthKit 和 Android Health Connect 虽然名字相似,但 API 形态完全不同。强行用 expect interface 抹平会让接口签名变得奇怪。HealthDataSyncRepositoryIos 目前是 stub(所有方法返回空),等 HealthKit 接入时直接替换真实实现。
  2. 粒度要够小。一个 expect fun getDeviceLanguageCode(): Stringexpect class LocaleManager 好维护得多。每个 expect 只做一件事,平台实现零依赖(不引入额外框架),测试也不必 mock。

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 → Swift async throws
  • Flow<T> / StateFlow<T> → Swift AsyncSequence
  • 所有类型变成 Swift 可读的名称

既然 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 会变成 NSErrorgeneric 错误;加了之后 Swift 可以按具体异常类型 catch
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 没有 checked exception,所有异常都是运行时抛出;而 Swift 使用 throws + do/catch 的显式错误模型。当 Kotlin 的异常穿过 Kotlin/Native 边界来到 Swift,会变成什么?

Kotlin 和 Swift 的异常模型有根本性分歧。Kotlin 所有异常都是 unchecked——你在函数签名上看不到它会抛什么;而 Swift 通过 throws 要求编译期声明。

先看一个反面案例。假设 CloudSourceProvider 中有一个不标注 @Throwssuspend 函数:

// ❌ 不加 @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(这是唯一的例外)
  • 其他任何异常HttpRequestExceptionIOException、自己定义的业务异常……)→ 被视为 未处理异常,直达 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 注解告诉 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 在 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。查询类函数(返回 Flowsuspend)通常不加,但前提是异常在 Kotlin 侧已经被 catch 掉,不会穿过边界:

  1. Flow 内部的异常通过 Flow 本身传播——SKIE 会把异常包装到 AsyncSequence 的迭代中,Swift 侧在 for try await 里可以 catch 到。这里的行为不同:Flow 中的异常会被终止流,但不会 crash。
  2. 一次性 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 才是安全的。

  1. 跨边界暴露的函数必须加 @Throws,且类型要列全。这不是代码风格问题,是 crash vs 不 crash 的问题。一个遗漏的异常类型就是一颗定时炸弹。
  2. @Throws 的异常类型要精确、要覆盖全面。别写 @Throws(Exception::class)——到了 Swift 那边还是区分不了。花几分钟把 HttpRequestExceptionRestExceptionIOException 分开声明。同时确保可能抛出的所有类型都在列表里。
  3. CancellationException 不要吞掉。在 Kotlin 侧的 try/catch 中判断 if (e is CancellationException) throw e,这是一个机械操作但非常关键的习惯。即使在 @Throws 列表中声明了它,吞掉它依然会导致协程无法正常取消。
  4. 查询函数如果不想加 @Throws,必须在 Kotlin 侧消化所有异常。把异常转成 null 或默认值再返回,确保没有任何异常穿过语言边界。

会员订阅是收入的核心。在迁移前,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
)

续接第一篇文章的五条原则,补充后续迁移中沉淀的经验:

  1. ViewModel 可以迁移,前提是有 SKIEStateFlow 通过 SKIE 转 AsyncSequence 的体验足够好,不用再为每个功能维护两份 ViewModel。但 iOS 侧仍需要一个薄薄的 StateHolder 来做 @Published 桥接——这个成本很低,一个 ViewModel 的桥接通常不到 50 行。
  2. 本地存储选 KMP 优先。Room KMP 已经足够成熟,14 个 Entity、13 个 DAO、12 个版本迁移都能稳定运行。新的本地存储需求不要再用平台专属方案(SwiftData、SharedPreferences 等),直接走 Room KMP。
  3. expect/actual 不求全,只做必要抽象。5 组声明覆盖了 UUID、语言、设备信息、DB 路径、DI 注入。不要为了"设计一致性"强行抽象——HealthDataSyncRepository 的 iOS 实现目前就是一个 stub,因为强行抹平 HealthKit 和 Health Connect 的接口差异会让代码更难读。
  4. RevenueCat KMP 值得用。官方 KMP SDK 的 API 覆盖了核心场景(登录、购买、恢复、Offerings),PurchasesDelegate 的实时推送机制也很好用。少量未覆盖的高级功能(如 Paywalls)可以直接在平台层补充。
  5. SKIE 让桥接层几乎消失CloudSourceProvider 只负责从 Koin 取实例并暴露函数签名,不写任何胶水代码。suspend 自动变 asyncFlow 自动变 AsyncSequence。新成员上手时看到 import Shared + try await 的组合会觉得很自然。

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


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