工程文章

云端 Mac 构建 Kotlin Multiplatform iOS Framework 实战

云端 Mac 构建 Kotlin Multiplatform iOS Framework 实战

一个 Kotlin Multiplatform 仓库在本地能够编译,不代表搬到云端 Mac 后会自然得到相同结果。最常见的差异并不在业务代码,而在 Java、Gradle Wrapper、Kotlin/Native、CocoaPods 与 Xcode 的组合,以及真机和模拟器产物被错误混用。可靠的做法是先固定工具链,再分别验收共享模块、Framework 和最终 Xcode 工作区。

先把构建输入固定下来

首次连接节点后,不要立即执行完整流水线。先记录构建实际使用的工具,而不是只看团队文档中的推荐版本。

sw_vers
uname -m
xcodebuild -version
xcode-select -p
java -version
./gradlew --version
bundle exec pod --version

仓库应提交 gradlewgradle/wrapper/gradle-wrapper.properties、依赖锁文件、Podfile.lock 和 Ruby 依赖锁文件。CI 中使用 ./gradlew,不要调用机器上碰巧存在的全局 Gradle。CocoaPods 同样通过 bundle exec pod 运行,避免不同任务加载不同 gem 版本。

如果需要切换 Xcode,应在任务开头显式设置并立即验证:

sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version

工具“已经安装”不是可复现条件。可复现条件是版本、路径、锁文件和调用入口均能从日志中确认。

分开定义真机与模拟器目标

Apple Silicon Mac 上的 iOS 模拟器通常也是 arm64,但它与 iPhone 真机不是同一个目标平台。仅检查 CPU 架构会让链接阶段出现难以理解的错误。

共享模块可以保留两个明确目标:

kotlin {
    iosArm64()
    iosSimulatorArm64()

    targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>()
        .configureEach {
            binaries.framework {
                baseName = "SharedKit"
                isStatic = true
            }
        }
}

先单独构建共享模块,缩短故障路径:

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 \
  --no-daemon --stacktrace

./gradlew :shared:linkReleaseFrameworkIosArm64 \
  --no-daemon --stacktrace

这两个任务都成功后,再进入 CocoaPods 和 Xcode 层。若任务名不存在,应检查模块路径、目标声明和 Framework 名称,不要通过复制已有产物绕过配置问题。

让 CocoaPods 与 Xcode 只消费锁定结果

CI 不应在每次构建时主动更新 Pod。先生成或检查 podspec,再严格按照 Podfile.lock 安装:

./gradlew :shared:podspec
bundle check
bundle exec pod install --project-directory=iosApp

随后必须构建 workspace,而不是项目文件:

xcodebuild \
  -workspace iosApp/App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -sdk iphonesimulator \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath "$PWD/.build/DerivedData" \
  CODE_SIGNING_ALLOWED=NO \
  build

CODE_SIGNING_ALLOWED=NO 适合模拟器编译验收,不能替代真机归档所需的签名配置。若工程使用自定义配置,还要确认对应 .xcconfig 没有把 Framework 搜索路径写成本地绝对路径。

用分层检查缩小问题范围

检查层 推荐命令 失败时优先检查
Kotlin 源码 ./gradlew :shared:compileKotlinIosSimulatorArm64 expect/actual、依赖可见性
Framework 链接 linkDebugFrameworkIosSimulatorArm64 目标平台、原生依赖
Pods 集成 bundle exec pod install 锁文件、Ruby 环境、podspec
Xcode 构建 xcodebuild -workspace Scheme、搜索路径、构建配置

缓存下载,不共享可写工作区

~/.gradle/caches~/.konan 通常值得缓存,因为重新下载成本较高;Pods 和 DerivedData 则必须更谨慎。并发任务共享可写 DerivedData,可能让一个任务读取另一个分支的中间文件。最稳妥的方式是按任务创建独立目录,并在结束后删除。

缓存键至少应包含:

  • gradle-wrapper.properties 与依赖锁文件摘要
  • libs.versions.tomlPodfile.lock 和 Ruby 依赖锁文件摘要
  • xcodebuild -version 输出
  • Java 主版本与 Kotlin 插件版本
  • 目标配置是 Debug 还是 Release

不要把整个用户目录打包成一个长期缓存。这样不仅难以失效,还会混入凭据、日志和无关状态。缓存恢复后应运行一次轻量编译;若失败,先清除当前任务缓存,而不是直接删除节点上的所有开发目录。

按错误形态定位并留下证据

出现 building for iOS Simulator, but linking in object file built for iOS 时,优先检查 Framework 来自哪个目标,而不是重新安装 CocoaPods。出现 framework not found 时,检查 Build Settings 中展开后的搜索路径、Framework 实际位置,以及脚本阶段是否在编译前执行。

可以把关键诊断输出保存为任务附件:

xcodebuild -showBuildSettings \
  -workspace iosApp/App.xcworkspace \
  -scheme App > .build/build-settings.txt

find shared -type d -name '*.framework' -print \
  > .build/framework-inventory.txt

若本地成功而远端失败,再比较 xcodebuild -versionjava -version、锁文件摘要、环境变量和工作区生成时间。不要一开始就清空全部缓存;那会抹掉现场,同时让问题暂时消失却无法解释。

合并前的最小检查项

合并请求至少应验证共享模块模拟器构建、Xcode workspace 构建和 Release 真机 Framework 链接。并发任务使用独立 DerivedData,日志保留工具版本与完整失败命令,构建产物按平台命名。做到这些后,Kotlin 层、Pods 层和 Xcode 层的责任边界会足够清晰,多数故障可以在一次流水线内定位。

常见问题

为什么真机 Framework 不能直接用于 Apple Silicon 模拟器?

两者虽然都可能使用 arm64 指令集,但目标平台标识不同。应分别构建 iosArm64 与 iosSimulatorArm64 产物,再按交付方式组合,不能通过复制或改名混用。

Kotlin Multiplatform 构建应该缓存哪些目录?

优先缓存 Gradle 下载缓存与 Kotlin/Native 工具链,并以锁文件、Gradle Wrapper、Xcode 和 Java 版本组成缓存键。Pods 与 DerivedData 更适合作为可重建缓存,不能跨不兼容环境直接复用。

CocoaPods 集成在 CI 中最常见的失败原因是什么?

常见原因是绕过锁文件更新依赖、Ruby 环境不一致、错误打开 xcodeproj,以及并发任务共享同一 Pods 或 DerivedData 目录。

独享物理节点

把构建任务放到独享云端 Mac

选择 VMOrbit M4 或 VMOrbit M4 Pro,并按团队位置选择节点。实际可用性与交付信息以控制台实时返回为准。

选择云端 Mac