工程文章

雲端 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 CI 應該快取哪些內容?

優先快取 Gradle 下載內容與 Kotlin/Native 工具鏈,並將鎖定檔、Gradle Wrapper、Xcode 和 Java 版本納入快取鍵。

獨享實體節點

將建置工作交給獨享雲端 Mac

選擇 VMOrbit M4 或 VMOrbit M4 Pro,並依團隊所在位置選擇節點。實際可用性與交付資訊以控制台即時回傳結果為準。

選擇雲端 Mac