SugarLite 的 KMP 渐进式迁移实践

SugarLite 最初是一个纯 iOS 应用,技术栈是 SwiftUI + MVVM + Supabase Swift SDK。随着 Android 版本的需求提上日程,如果简单地把业务逻辑复制一份到 Kotlin,后续的维护成本会成倍增长——网络层、数据模型、业务规则都要双端维护。所以我们选择了 Kotlin Multiplatform,让 shared/ 模块承载所有跨平台业务逻辑,iOS 侧只保留 SwiftUI 和系统框架调用。

这篇文章记录整个迁移过程,从架构重构到 CI/CD 适配,踩过的坑和学到的经验。

在引入 KMP 之前,我们先做了一次架构重构。核心目标很简单:让 ViewModel 不依赖任何外部框架,这样底层将来换成 Kotlin 也不会波及上层。

重构后的依赖方向:

View → ViewModel → Protocol ← Repository → External Frameworks

各层职责:

层级目录允许导入约束
ViewViews/SwiftUI禁止直接访问 Repository
ViewModelViewModels/Foundation禁止导入 Supabase/HealthKit/SwiftData
DomainDomain/Foundation定义 Protocol、UseCase、Service
DataRepositories/ + Data/任意框架唯一允许导入 Supabase、SwiftData 的层级

这次重构的价值在后来的迁移中体现得非常明显——当 Supabase 客户端从 Swift SDK 换成 KMP shared 模块时,ViewModel 层一行都不用改,因为它只依赖 FoodReferenceRepositoryProtocol 这样的接口。

架构清理完成后,正式引入 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.kts

shared/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)
        }
    }
}

两个点值得说:

  1. isStatic = true:静态 framework,避免 Xcode Cloud 构建时的动态库签名问题。
  2. api(libs.supabase.*):作为 API 依赖暴露,iOS 侧理论上能直接用,但我们会用封装层隐藏细节。

这是最核心的一步——把散落在各 Repository 中的 Supabase 调用整体下沉到 shared/src/commonMain/kotlin/

为所有 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,封装 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 查询...
    }
}

sharedModule 中注册所有 CloudSource:

val sharedModule = module {
    single { SupabaseClientProvider.client }
    single { FoodReferenceCloudSource(get()) }
    single { BloodSugarCloudSource(get()) }
    // ...
}

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() 即可。

在 SwiftUI 入口的 init() 中:

init() {
    KoinHelperKt.doInitKoin()
    AppLogger.database.info("KMP Koin 已初始化")
}

改造前(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 Supabaseimport Shared,直接写 PostgREST 查询 → 调用封装好的 cloudSource.fetchAll(),新增一个 fromKmp 的 DTO 转换扩展。

这个模式可以复制到每一个 Repository,团队按优先级逐个迁移即可。

引入 KMP 后最大的挑战是 CI/CD。Xcode Cloud 的构建环境默认没有 Java 和 Gradle,而 iOS 构建又依赖 Kotlin/Native 编译出的 Shared.framework

在 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 --version

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

回头看整个过程,我们遵循了几个原则:

  1. DTO/Model 优先:数据模型最没有平台特性,最先下沉。
  2. Repository Protocol 其次:接口定义契约,实现可以暂时留 iOS。
  3. UseCase/Service 随后:纯计算逻辑天然适合跨平台。
  4. ViewModel 暂不迁移:保留 @Observable 维持 SwiftUI 响应式体验。
  5. 新功能直接写 KMP,旧代码在涉及修改时评估是否可下沉,能移则移。不为迁移而迁移,避免"大爆炸"重构。

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

相关内容