Swift 6 語言模式讓編譯器成為「並發審計員」:不再靠人工 code review 發現 @MainActor 誤用或跨 Actor 數據共享,而是在 xcodebuild 階段直接報錯。這意味著你需要一台算力穩定、環境可釘版本的構建節點——既能跑完整編譯,也不會因為鄰居爭 CPU 導致尾延遲抖動。本文結合 NUKCLOUD 獨佔遠程 Mac 節點,展示如何分階段接入 Swift 6 嚴格並發檢查,並將其鎖進 PR 合併門禁。
00為什麼現在遷移——以及為什麼需要獨佔節點
Swift 6 嚴格並發檢查(-strict-concurrency=complete)觸發的錯誤數量與代碼庫規模正相關:中等規模 iOS 項目首次啟用時,出現 200–800 個編譯錯誤並不罕見。這些錯誤需要逐模塊修復,修復期往往橫跨數週甚至數月。在此期間,CI 環境必須保證:
- Xcode 小版本固定:Swift 6 的 Actor isolation 診斷在不同 Xcode 版本間措辭會變化,釘住版本才能讓 CI 輸出可比較。
- 無鄰居 CPU 爭搶:嚴格並發全量編譯比普通 debug build 耗時多 20–40%,獨佔節點避免 P95 尾延遲爆表。
- 磁碟 DerivedData 命名空間隔離:多個 PR 同時跑並發檢查,需要各自獨立的增量編譯緩存,否則緩存污染導致結果不可信。
- 錯誤計數可追蹤:把每次構建的 Swift 6 錯誤數、修復 commit、剩餘模組列表寫入結構化日誌,便於向管理層證明遷移進度。
獨佔節點的價值在於:同一 Xcode 小版本下,錯誤措辭與行號在數週內保持穩定,方便對比「上週 412 個、本週 87 個」這類可審計曲線;分鐘池頻繁換鏡像則會把問題混成環境噪聲。撥備與驗收欄位可與節點 Runbook 六步一併對齊,並在合併前用定價頁確認磁碟與區域是否覆蓋全量掃描。
01三種並發檢查級別與遷移路徑
Swift 提供三檔編譯器標誌,可在 OTHER_SWIFT_FLAGS(Xcode Build Settings)或 Package.swift 的 swiftSettings 裡設置:
| 標誌 | 含義 | 推薦階段 |
|---|---|---|
-strict-concurrency=minimal | 僅檢查 async/await 基礎語法合規,不檢查 Actor 邊界 | 存量代碼熱身期(第 1–2 週) |
-strict-concurrency=targeted | 檢查顯式標註了 Sendable、@MainActor 等註解的類型的 Actor 邊界 | 逐模塊修復期(第 3–6 週) |
-strict-concurrency=complete | 全量檢查,等價於 Swift 6 語言模式;未顯式處理的並發共享均報錯 | 達到 0 錯誤後納入門禁 |
實踐中推薦按模塊漸進:先在葉子模塊(無依賴或依賴少)開啟 complete,再向核心模塊推進。SwiftPM 的 .enableUpcomingFeature("StrictConcurrency") 可精確控制單個 target 的檢查級別。
02Actor 隔離邊界:三類高頻錯誤
遷移期 90% 的編譯錯誤集中在以下三類,先處理完這三類可清掉絕大部分紅字:
① 跨 Actor 訪問非 Sendable 類型
// ❌ 錯誤:從非 @MainActor 上下文存取 @MainActor 隔離狀態
actor DataFetcher {
func load() async {
let result = await networkClient.fetch()
// ❌ passing argument of non-sendable type 'UserModel' across actor boundary
await MainActor.run { viewModel.update(result) }
}
}
修復方式:讓 UserModel 實現 Sendable(值類型通常自動滿足),或將跨邊界的轉換提前到 Actor 邊界內完成。
② @MainActor 漏標
UIKit/SwiftUI 的 ViewController、ObservableObject 子類在遷移前隱式跑在主線程;Swift 6 要求顯式標註 @MainActor。批量修復可用 Xcode 的「Fix」按鈕,但建議人工 review 每一處,避免把不應在主線程的邏輯也拉了進來。
③ 全局變量並發訪問
// ❌ Swift 6 中全域常數須為 Sendable 或 nonisolated
var sharedCache: NSCache<NSString, AnyObject> = .init()
// ✅ 遷移期以 nonisolated(unsafe) 明示
nonisolated(unsafe) var sharedCache: NSCache<NSString, AnyObject> = .init()
// ✅ 更佳:包進 Actor 並以 async API 存取
actor CacheStore {
private let cache = NSCache<NSString, AnyObject>()
func object(forKey key: String) -> AnyObject? { cache.object(forKey: key as NSString) }
}
03在遠程 Mac 上配置 CI 門禁
把嚴格並發檢查納入 PR 門禁的核心思路:單獨開一個 Job 跑並發檢查,不與常規測試 Job 合併。這樣並發檢查失敗不會污染測試報告,也便於在修復期設置不同的失敗策略(warn-only vs. block-merge)。在 NUKCLOUD 上撥備節點時,建議先在 定價頁 選定磁碟與區域,再經 下單頁 交付 SSH 與 Runner 基線,避免遷移期臨時借用分鐘池導致 P95 不可比。
修復期通常持續數週:請把「並發錯誤計數」「全量編譯 wall time」「DerivedData 體積」三項寫入結構化日誌,並與節點規格變更(加盤、換區)關聯,否則很難判斷是程式回退還是環境漂移。
name: Swift 6 Concurrency Gate
on:
pull_request:
branches: [main, release/*]
jobs:
concurrency-check:
runs-on: self-hosted # NUKCLOUD 獨占遠端 Mac Runner
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: 選擇 Xcode(釘住小版本)
run: sudo xcode-select -s /Applications/Xcode_16.3.app
- name: Restore DerivedData cache
uses: actions/cache@v4
with:
path: ~/DerivedData/swift6-check
key: swift6-${{ runner.name }}-${{ hashFiles('**/*.xcodeproj/project.pbxproj', '**/Package.resolved') }}
restore-keys: swift6-${{ runner.name }}-
- name: Build with strict concurrency (complete)
run: |
xcodebuild build \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath ~/DerivedData/swift6-check \
OTHER_SWIFT_FLAGS="-strict-concurrency=complete" \
| xcbeautify --renderer github-actions
- name: Upload build log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: swift6-build-log-${{ github.sha }}
path: ~/DerivedData/swift6-check/Logs/Build/
-derivedDataPath ~/DerivedData/swift6-check 與普通構建的 DerivedData 目錄分開,避免並發檢查的編譯產物污染調試構建緩存。多個 PR 同時跑時,加上 runner.name 或 PR 編號進一步隔離。04六步漸進遷移 Runbook
-
01
基線快照:在獨佔遠程 Mac 上用
-strict-concurrency=minimal跑一次全量編譯,記錄錯誤數與編譯耗時,作為遷移起點的可量化基線。 -
02
葉子模塊先行:從 SwiftPM 依賴圖中找出無下游依賴的 target,逐一開啟
complete。每個 target 修復完畢後提單獨 PR,保持 diff 可審查。 -
03
CI 分支策略:在 Git 的
feature/swift6-migration長期分支上以warn-only(continue-on-error: true)運行並發檢查 Job,不阻斷日常 PR 合併。只有當某個模塊達到 0 錯誤時,才將該模塊的檢查級別升級為門禁(block-merge)。 -
04
釘住 Xcode 版本:把
xcode-select版本號寫入 Runbook 與 README,確保本地 Mac 開發者與 CI 節點使用相同小版本,避免「本地過、CI 掛」的錯誤擾動。 -
05
錯誤趨勢看板:把每次 CI 運行的 Swift 6 錯誤數寫入結構化日誌或 Build Metrics,用折線圖跟蹤遷移進度;給團隊設置「本週減少 X 個錯誤」的週目標。
-
06
全量門禁驗收:全部模塊達到 0 錯誤後,在
main分支的 Branch Protection Rule 裡啟用「Require swift6-concurrency-check to pass」,同時把SWIFT_VERSION = 6寫入所有 target 的 Build Settings,正式切換 Swift 6 語言模式。
05本地 Mac 與獨佔遠程節點對照
Swift 6 遷移期同時需要開發者本地快速試錯與CI 全量可信驗證,兩者對節點的需求不同:
| 維度 | 開發者本地 Mac | NUKCLOUD 獨佔遠程 Mac(CI) |
|---|---|---|
| Xcode 版本 | 開發者自行管理,可能存在差異 | 腳本釘住小版本,全團隊一致 |
| DerivedData | 與日常開發共享,增量緩存可能被污染 | 獨立路徑,按 PR 或 Runner 分桶 |
| CPU 可用性 | 受其他應用爭搶,編譯耗時有方差 | 裸金屬獨佔,P95 延遲可預期 |
| 結果可信度 | 「本地過」不代表 CI 過 | 門禁階段的最終裁決環境 |
| 適合場景 | 單模塊快速試錯與 Xcode 診斷閱讀 | 全量並發檢查門禁與每日回歸 |
06常見問題
-strict-concurrency=complete 是編譯器標誌,可以在 Swift 5 語言模式下啟用,讓你先看到所有錯誤再決定何時切換。正式的 Swift 6 語言模式(SWIFT_VERSION = 6)在此基礎上還會鎖定若干已移除的語法糖。推薦先用 complete 標誌修完錯誤,再切換語言模式。Package.swift 裡用 .unsafeFlags(["-strict-concurrency=minimal"]) 為特定依賴降級檢查,或在 Podfile 裡用 post_install hook 設置 SWIFT_STRICT_CONCURRENCY = minimal。大多數主流庫在 2026 年已陸續完成遷移,可先檢查對應庫的 Release Notes。--concurrent-jobs N,配合 DerivedData 按 PR 編號分桶(-derivedDataPath ~/DerivedData/pr-${{ github.event.number }}),多個 Job 獨立編譯互不影響。獨佔裸金屬節點的核心優勢正在於此:無鄰居爭 CPU,並發 Job 的完成時間方差小。-strict-concurrency=complete 鎖進合併門禁並維持數週修復節奏時,NUKCLOUD 多區域裸金屬 Mac / 雲端 Mac 節點在釘版本、分桶 DerivedData 與可觀測隊列上更穩,也更易與 控制台撥備 Runbook 裡的六步驗收對齊。