iPhone 應用程式開發
從將 SwiftUI 應用程式從空白儲存庫一路交付到在實體手機上執行的已簽署建置過程中,所提煉出的慣例。
假設情境:小型應用程式、免費的個人 Apple 帳戶、無 App Store 發行。付費的開發者團隊會改變以下幾項規則 — 簽署章節會標示出哪些規則會受影響。
架構預設值
- SwiftUI + SwiftData,本地優先。 一個小型應用程式不應依賴可連線的伺服器。資料儲存在裝置上的 SwiftData 中;網路呼叫僅用於公開 API(https,無 ATS 例外)。如果應用程式一開始是現有後端的輕量級用戶端,可以預期「我們能拋棄伺服器嗎?」的轉折 — 將後端邏輯移植為針對 SwiftData 陣列的純 Swift 函式,而不是為了單一手機而維持伺服器運作。
- SwiftData 建模:使用真實的物件關係,而非整數外鍵。使用
@Attribute(.unique)作為自然鍵。SwiftData 的多對多陣列不保留順序 — 在需要順序時,新增一個sortOrder欄位和一個sortedX計算屬性。日期使用Date,而非字串。列舉使用String原始值並遵循Codable協定。 - 即時安裝上的結構描述變更:新增模型或可選屬性會在下次啟動時靜默地進行輕量級遷移;重新命名、刪除或型別變更(若沒有
SchemaMigrationPlan)會導致儲存空間無法開啟 — 已部署的應用程式會在啟動時崩潰,但使用者的資料仍完好無損卻無法存取。重新部署(相同 bundle ID)永遠不會清除資料容器;只有刪除應用程式才會。在使用者累積真實資料之前,請規劃一個匯出/備份路徑。 - 檢視直接與
@Query+modelContext溝通。 除非某些功能確實需要快取,否則不需要 Store / repository 層。純邏輯(比對、解析、匯入)放在列舉命名空間中(enum Foo { static func … })—— 可在沒有 UI 的情況下測試。 - 部署目標:current-major-minus-one,除非所需 API 另有規定。單一目標,
TARGETED_DEVICE_FAMILY: "1",除非應用程式需要橫向模式,否則使用直向。
專案生成 — XcodeGen,絕不提交 .xcodeproj
ios/project.yml 是真理來源;.xcodeproj 套件是生成的並被加入 gitignore(也要 gitignore ios/build/ 和生成的 Info.plist)。在新增或移除檔案後重新生成 — xcodegen 會 glob 原始碼目錄,因此編輯現有檔案不需要任何動作。
- 從 GitHub 發行版的 zip 檔安裝 xcodegen(
PREFIX=~/.local ./install.sh)— 無需 Homebrew,可在鎖定的 Mac 上運作。 - 單元測試目標需要
GENERATE_INFOPLIST_FILE: YES,否則測試套件會簽署失敗。 - 在
project.yml中定義 scheme,並設定gatherCoverageData: true並附加測試目標,否則xcodebuild test將沒有東西可以執行。
開發迴圈 — 僅 CLI,預設使用模擬器
每個 iOS 專案都會獲得這些 Makefile 目標。將模擬器名稱、裝置 ID 和團隊 ID 參數化為 ?= 變數,這樣就不會硬編碼任何機器特定的資訊:
| 目標 | 說明 |
|---|---|
ios-project | xcodegen 生成 + 在 Xcode 中開啟 |
ios-sim | 為模擬器建置未簽署版本,啟動模擬器,安裝,啟動 — 預設迴圈;零簽署,零密碼提示 |
ios-sim-seed | ios-sim,然後使用 --seed-demo 重新啟動 |
ios-test | 在模擬器中執行 xcodebuild test + 使用 xccov 的每個目標涵蓋率報告 |
ios-deploy | 為已連接的手機建立已簽署的建置 + 使用 devicectl 安裝 + 啟動 |
ios-test 會在每次執行時列印涵蓋率 — 內部規則是 make test 總是顯示數字,但沒有附加硬性門檻。
模擬器建置:對於 generic/platform=iOS 的編譯檢查,CODE_SIGNING_ALLOWED=NO 是可行的;模擬器目標不需要任何簽署。請記住,模擬器和手機有獨立的資料庫。
總是值得的偵錯便利功能
- 範例資料播種器(
DemoData.swift,#if DEBUG):精選的資料集,其中到期/日期欄位相對於.now計算,具有冪等性(重新執行不會新增任何內容),透過三種方式公開 — 設定中的「開發者」區段按鈕、透過ProcessInfo.processInfo.arguments檢查的--seed-demo啟動參數,以及ios-sim-seed目標。新增一個測試來斷言播種器能涵蓋應用程式的主要狀態,這樣它就不會默默地腐化。 - 可搜尋的選取器:一旦列表可能超過幾十個項目(匯入的資料集保證會如此),就使用可搜尋的選取器,而非滾輪選取器。
- 相機/掃描器功能:檢查
DataScannerViewController.isSupported && .isAvailable並提供手動輸入的備援方案 — 這在模擬器中也能優雅地降級。
測試
- 在模擬器中使用 XCTest;在從其他堆疊移植邏輯時,同時移植參考測試套件,以便跨語言鎖定語義。
- 共享輔助函式:
makeInMemoryContext(),建立一個isStoredInMemoryOnly: true的 ModelContainer;使用@MainActor的測試類別搭配setUp() async throws。 - 從結果套件中讀取通過/失敗狀態:
xcrun xcresulttool get test-results summary --path build/test.xcresult。
簽署與部署 — 陷阱(每一個都花費了實際時間)
- 團隊 ID 不是
security find-identity中括號內的內容 — 那是憑證本身的 ID。從佈建設定檔中獲取真實的團隊 ID(security cms -D -i <profile>→application-identifier前綴)或 Xcode 的帳號面板。錯誤的 ID 會產生誤導的「No Account for Team」錯誤。 -allowProvisioningUpdates無法從一般終端機看到 Apple ID 工作階段。 在 Xcode GUI 中建立一次佈建設定檔(Signing & Capabilities → 設定 Team → Try Again);之後,純 CLI 的建置就能透過DEVELOPMENT_TEAM=<id> CODE_SIGN_STYLE=Automatic運作。- 首次裝置設定步驟:在手機上啟用開發者模式(設定 → 隱私權與安全性;只有在 Xcode 與裝置溝通後才會出現該切換開關;重新啟動)→ 信任電腦 → 首次安裝後,信任開發者憑證(設定 → 一般 → VPN 與裝置管理)。Xcode 的「your team has no devices」錯誤通常意味著註冊嘗試發生在手機重新啟動或鎖定時 — 選擇手機作為執行目標並重試。
- 每次建置都出現鑰匙圈提示:在 codesign 鑰匙圈對話框上按一下永遠允許;每次建置的密碼提示就會停止。
- 免費個人團隊限制:安裝在 7 天後過期(重新部署以重新簽署),最多 3 個側載應用程式,無推播通知,無 App Groups。付費團隊可以解除所有四項限制 — 如果應用程式需要推播或 App Groups,那就是取得付費團隊的觸發點。
- 部署/啟動 CLI:
xcrun devicectl list devices、xcrun devicectl device install app --device <id> <path>.app、… device process launch --device <id> <bundle-id>。
全新 Mac 引導程序(適合無頭模式)
- 從 App Store 安裝 Xcode(開啟
macappstore://apps.apple.com/app/id497799835)。 sudo xcode-select -s /Applications/Xcode.app && sudo xcodebuild -license accept && sudo xcodebuild -runFirstLaunch— 需要管理者權限;在標準帳戶上,先修正管理者成員資格(su admin -c 'sudo dseditgroup -o edit -a <user> -t user admin')。xcodebuild -downloadPlatform iOS— 自 Xcode 15 起,iOS SDK + 模擬器執行環境是獨立的多 GB 下載;沒有它,建置會失敗並顯示「iOS X.Y 未安裝」的錯誤。- 在修正 xcode-select 之前,
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer可以作為每個指令的覆蓋變數使用。 - 從發行版 zip 檔將 xcodegen 安裝到
~/.local(見上文)。
現有伺服器應用程式的伴隨用戶端 — 同步分層
當 iPhone 應用程式加入一個已有伺服器 + Web 應用程式的應用程式時(伺服器仍然是唯一的真理來源),請選擇手機使用案例允許的最便宜分層。先問「手機是否需要在不連線伺服器網路的情況下運作?」以及「是僅擷取還是完整編輯?」:
- 輕量級用戶端 — 無本地儲存;手機呼叫現有 API。在連線能力有保證的情況下直接使用。
- 快照鏡像 — 從完整的 JSON 拉取中整體重建 SwiftData 快取(單一使用者資料庫很小)。無需合併 → 無需墓碑、無需每行時間戳、無需衝突引擎;伺服器端的刪除/編輯會免費傳達。照片透過 URL 快取進行延遲載入。
- 限定範圍的寄件匣 — 離線建立功能僅限於已存在記錄的子記錄(針對已知父記錄的日誌、筆記、照片)。父記錄帶有真實的伺服器 ID,因此重播只是純 API 呼叫 — 整數主鍵的結構描述不需要 UUID 遷移。同步事件 = 推送寄件匣,然後拉取快照(自己的擷取會以已確認狀態返回)。顯示 404 孤兒記錄(「N 無法同步」),絕不默默丟棄。顯示待處理標記 + 上次同步時間戳。僅在使用者固定的伺服器位址有回應時才自動同步 — 絕不探查外部網路。
- 完整雙向同步(UUID、墓碑、增量端點、LWW/CRDT)— 僅在有證據顯示分層 1–3 不足時才使用;重構從伺服器端開始(用戶端可生成的 UUID、冪等 upsert、
changes?since=+ 墓碑),然後才撰寫任何 Swift 程式碼。
分層 2 和 3 可以很好地組合:用於讀取的快照鏡像加上用於擷取的限定範圍寄件匣,可以在不需要任何分層 4 的機制下,涵蓋「在家工作,隨處擷取」的需求。
專案衛生
- 機器與裝置特定的事實(團隊 ID、裝置 UDID、模擬器名稱)放在 Makefile 的
?=變數或專案自己的筆記中 — 絕不硬編碼在腳本中,也絕不寫入此技能。 - 為每個進行中的工作片段保留一份
plans/<topic>.md文件,以及一份頂層的CHANGELOG.md來說明已發佈的內容;在某個計畫完成後,將其移至plans/done/<topic>.md,並在頂部加上「Delivered in <版本> — <日期>」的標頭。 - 將每個計畫片段的大小切割成最終能在手機或模擬器上執行的內容。一個無法啟動的 iOS 片段是無法被審查的。