SugarLite 的 KMP 渐进式迁移实践
SugarLite 最初是一个纯 iOS 应用,技术栈是 SwiftUI + MVVM + Supabase Swift SDK。随着 Android 版本的需求提上日程,如果简单地把业务逻辑复制一份到 Kotlin,后续的维护成本会成倍增长——网络层、数据模型、业务规则都要双端维护。所以我们选择了 Kotlin Multiplatform,让 shared/ 模块承载所有跨平台业务逻辑,iOS 侧只保留 SwiftUI 和系统框架调用。
这篇文章记录整个迁移过程,从架构重构到 CI/CD 适配,踩过的坑和学到的经验。
🏗️ 迁移前的 MVVM Clean Architecture 重构
在引入 KMP 之前,我们先做了一次架构重构。核心目标很简单:让 ViewModel 不依赖任何外部框架,这样底层将来换成 Kotlin 也不会波及上层。
重构后的依赖方向:
View → ViewModel → Protocol ← Repository → External Frameworks各层职责:
| 层级 | 目录 | 允许导入 | 约束 |
|---|---|---|---|
| View | Views/ | SwiftUI | 禁止直接访问 Repository |
| ViewModel | ViewModels/ | Foundation | 禁止导入 Supabase/HealthKit/SwiftData |
| Domain | Domain/ | Foundation | 定义 Protocol、UseCase、Service |
| Data | Repositories/ + Data/ | 任意框架 | 唯一允许导入 Supabase、SwiftData 的层级 |
这次重构的价值在后来的迁移中体现得非常明显——当 Supabase 客户端从 Swift SDK 换成 KMP shared 模块时,ViewModel 层一行都不用改,因为它只依赖 FoodReferenceRepositoryProtocol 这样的接口。
📦 引入 KMP 项目基线
架构清理完成后,正式引入 KMP。项目根目录变成了这样:
├── iosApp/ # 原 iOS 项目整体迁入
│ ├── BloodSugarApp/ # SwiftUI 源码
│ ├── BloodSugarApp.xcodeproj
│ └── ci_scripts/ # Xcode Cloud 构建脚本
├── composeApp/ # Compose Multiplatform(Android 入口)
├── shared/ # KMP 共享模块
│ ├── src/commonMain/kotlin/ # 跨平台业务逻辑
│ ├── src/androidMain/kotlin/
│ └── src/iosMain/kotlin/
├── gradle/
├── build.gradle.kts
└── settings.gradle.ktsshared/build.gradle.kts 的关键配置:
kotlin {
android { ... }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
api(libs.supabase.auth)
api(libs.supabase.postgrest)
api(libs.supabase.storage)
api(libs.ktor.client.core)
implementation(libs.koin.core)
}
androidMain.dependencies {
implementation(libs.ktor.client.cio)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}两个点值得说:
isStatic = true:静态 framework,避免 Xcode Cloud 构建时的动态库签名问题。api(libs.supabase.*):作为 API 依赖暴露,iOS 侧理论上能直接用,但我们会用封装层隐藏细节。
🔄 下沉数据层:Supabase Swift SDK → KMP shared
这是最核心的一步——把散落在各 Repository 中的 Supabase 调用整体下沉到 shared/src/commonMain/kotlin/。
DTO 层
为所有 13 张表创建 @Serializable 的 Kotlin DTO,统一用 snake_case 字段映射:
@Serializable
data class FoodReferenceDto(
val id: String,
val name: String,
val name_en: String? = null,
val category: String,
val gi_value: Int? = null,
val calories: Double? = null,
// ...
)CloudSource 封装
每个领域实体对应一个 CloudSource,封装 CRUD 操作:
class FoodReferenceCloudSource(private val client: SupabaseClient) {
suspend fun fetchAll(): List<FoodReferenceDto> {
return client.from("food_reference").select().decodeList()
}
suspend fun fetchPage(
page: Int, pageSize: Int,
searchText: String?, category: String?
): List<FoodReferenceDto> {
// 构建 PostgREST 查询...
}
}Koin DI
在 sharedModule 中注册所有 CloudSource:
val sharedModule = module {
single { SupabaseClientProvider.client }
single { FoodReferenceCloudSource(get()) }
single { BloodSugarCloudSource(get()) }
// ...
}🔌 iOS 侧渐进式接入
KMP 的迁移不需要一次性完成。我们的策略是"新功能直接写 KMP,旧功能逐步替换"。以 FoodReferenceRepository 为例:
桥接层
先让 Swift 能拿到 Koin 容器里的实例。在 iosMain 中:
fun doInitKoin() {
startKoin { modules(sharedModule) }
}在 commonMain 中暴露获取方法:
private object KoinProvider : KoinComponent {
fun provideFoodReferenceCloudSource(): FoodReferenceCloudSource = get()
}
fun getFoodReferenceCloudSource(): FoodReferenceCloudSource =
KoinProvider.provideFoodReferenceCloudSource()Kotlin 的顶层函数会被编译为 Swift 的全局函数,iOS 侧直接调用 CloudSourceProviderKt.getFoodReferenceCloudSource() 即可。
App 启动初始化
在 SwiftUI 入口的 init() 中:
init() {
KoinHelperKt.doInitKoin()
AppLogger.database.info("KMP Koin 已初始化")
}Repository 改造
改造前(Swift Supabase SDK):
import Supabase
final class FoodReferenceRepository {
private var supabaseClient: SupabaseClient
func fetchAll() async throws -> [FoodReferenceDTO] {
let dtos: [FoodReferenceDTO] = try await supabaseClient
.from("food_reference")
.select()
.execute()
.value
return dtos
}
}改造后(KMP CloudSource):
import Shared
final class FoodReferenceRepository {
private let cloudSource: FoodReferenceCloudSource
init() {
self.cloudSource = CloudSourceProviderKt.getFoodReferenceCloudSource()
}
func fetchAll() async throws -> [FoodReferenceDTO] {
let kmpDtos = try await cloudSource.fetchAll()
return kmpDtos.map { FoodReferenceDTO(fromKmp: $0) }
}
}变化就三处:import Supabase → import Shared,直接写 PostgREST 查询 → 调用封装好的 cloudSource.fetchAll(),新增一个 fromKmp 的 DTO 转换扩展。
这个模式可以复制到每一个 Repository,团队按优先级逐个迁移即可。
⚙️ Xcode Cloud CI/CD 适配
引入 KMP 后最大的挑战是 CI/CD。Xcode Cloud 的构建环境默认没有 Java 和 Gradle,而 iOS 构建又依赖 Kotlin/Native 编译出的 Shared.framework。
ci_pre_xcodebuild.sh:环境准备
在 Xcode Cloud 的 ci_pre_xcodebuild.sh 阶段安装 Java 和 Gradle:
#!/bin/sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT_DIR"
export GRADLE_USER_HOME="$ROOT_DIR/.gradle"
export HOMEBREW_NO_AUTO_UPDATE=1
resolve_java_home() {
if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ]; then
echo "$JAVA_HOME"
return 0
fi
for version in 21 17 11; do
if JAVA_CANDIDATE=$(/usr/libexec/java_home -v "$version" 2>/dev/null); then
if [ -x "$JAVA_CANDIDATE/bin/java" ]; then
echo "$JAVA_CANDIDATE"
return 0
fi
fi
done
if command -v brew >/dev/null 2>&1; then
for formula in openjdk@17 openjdk; do
if brew list --versions "$formula" >/dev/null 2>&1; then
JAVA_CANDIDATE="$(brew --prefix "$formula")/libexec/openjdk.jdk/Contents/Home"
if [ -x "$JAVA_CANDIDATE/bin/java" ]; then
echo "$JAVA_CANDIDATE"
return 0
fi
fi
done
fi
return 1
}
ensure_java() {
if JAVA_CANDIDATE="$(resolve_java_home)"; then
export JAVA_HOME="$JAVA_CANDIDATE"
else
echo "[Xcode Cloud] Java not found. Installing openjdk@17 via Homebrew..."
brew install openjdk@17
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home"
fi
export PATH="$JAVA_HOME/bin:$PATH"
}
ensure_java
echo "[Xcode Cloud] JAVA_HOME=$JAVA_HOME"
java -version
./gradlew --versionBuild Phase 脚本:编译 Kotlin Framework
在 Xcode Build Phases 中添加 Run Script:
#!/bin/sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT_DIR"
# Xcode Preview 优化:跳过 Gradle 构建
if [ "${OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED:-}" = "YES" ]; then
echo "Skipping Gradle build due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED=YES"
exit 0
fi
# 解析 JAVA_HOME(与 ci_pre_xcodebuild.sh 相同逻辑)
# ...
export PATH="$JAVA_HOME/bin:$PATH"
./gradlew :shared:embedAndSignAppleFrameworkForXcode这个脚本在每次 Xcode 构建时调用 Gradle 任务来编译 Kotlin/Native 并嵌入 framework。本地开发时通过 OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED 环境变量做了优化——Xcode Preview 触发构建时跳过 Gradle,避免拖慢速度。
踩坑
Sentry 脚本与 Widget Extension 的构建循环依赖:错误信息很诡异,排查后发现是 Sentry 的 “Upload Debug Symbols” 脚本和 Widget Extension 的 Embed 阶段之间存在隐式依赖。解决方案很简单——把 Sentry 脚本拖到 Embed 阶段之后。
Java Runtime 解析不稳定:Xcode Cloud 各版本镜像上 Java 安装路径不统一。最终的 resolve_java_home() 用了多级 fallback:系统 JAVA_HOME → /usr/libexec/java_home → Homebrew → brew list。这套冗余查找策略确保了在任何镜像版本上都能找到 Java。
📋 迁移策略总结
回头看整个过程,我们遵循了几个原则:
- DTO/Model 优先:数据模型最没有平台特性,最先下沉。
- Repository Protocol 其次:接口定义契约,实现可以暂时留 iOS。
- UseCase/Service 随后:纯计算逻辑天然适合跨平台。
- ViewModel 暂不迁移:保留
@Observable维持 SwiftUI 响应式体验。 - 新功能直接写 KMP,旧代码在涉及修改时评估是否可下沉,能移则移。不为迁移而迁移,避免"大爆炸"重构。
📚 参考资料
本文基于 SugarLite 项目的真实迁移过程撰写。
相关内容
如果你觉得这篇文章对你有所帮助,欢迎赞赏~
赞赏