iOSCoding.md
iOSCoding.md — RenUniversal iOS 原生端
這是 AI Coding Agent 讀的啟動規格檔(bootstrap spec)。開始動任何 iOS/Swift 程式碼前,先完整讀完本檔。本檔的決策是鎖定的,不得因「更好的想法」而擅自更動;若你認為某決策有誤,先停下來提出質疑,不要直接改。
0. 你是誰、你在做什麼
你是本專案 iOS 原生端的實作 agent。RenUniversal 是一個基於電腦視覺邊緣運算的 AI 生理姿態回饋與即時監控系統,iOS 原生 App(RenUniversal/RenUniversal/)是目前主要開發重心,相較於 Python 後端提供了更低延遲、更完整的離線體驗,且不需要架設任何伺服器。
本端的核心職責:
- 透過 AVFoundation 取得相機影格,送進 MediaPipe 做臉部(Face Mesh,468 點)與身體(Pose,33 點)關鍵點估算。
- 以
RuleEngine.swift依使用者設定的幾何規則評估姿態偏差。 - 以
ActionEngine.swift兩段式評估(幾何先、邏輯後),將結果寫回SharedState。 - 透過
OverlayView.swift在即時相機畫面上渲染標注線段與觸發狀態。 - 透過
ActionEngine評估後,呼叫AppConfig啟動對應的外部 App(App Launcher)。
不要動 Python 後端(backend/、web/、skills/)。除非任務明確指定,你只操作 RenUniversal/RenUniversal/ 底下的 Swift 程式碼。
1. 開發方法
工作循環固定為:
- 讀規格 — 讀本檔對應章節,確認要做的功能屬於哪個 phase、是否在當前範圍內。
- 確認契約 — 你動的模組必須符合第 4 節的型別契約。型別優先於實作;不得為了實作方便偷改 Codable struct。
- 最小切片 — 一次只實作一個可驗證的最小功能,能跑、能測。
- 驗證 — 每個功能都要有對應的手動驗證方式(第 6 節驗收標準)。
- 停在 gate — 到達 phase gate 時停下來回報,不要自動往下一個 phase 衝。
若某功能不在當前 phase 範圍,就算你覺得「順手加一下」很合理,也不准建。
2. 技術選型(已鎖定)
| 層 | 選型 | 為何 |
|---|---|---|
| 視覺推理 | MediaPipe Tasks Vision(mediapipe_vision_ios,官方 XCFramework) |
本地推理、不需要網路、Face Mesh 468 點 + Pose 33 點同一個 delegate 回調 |
| 狀態管理 | SwiftUI @StateObject + Combine @Published |
響應式 UI 更新,不需要 Redux 或其他框架 |
| 持久化 | UserDefaults(Triggers / Apps / Settings) |
資料量小、不需要 Core Data;複雜結構先 JSONEncoder 再存 Data |
| 相機 | AVCaptureSession + AVCaptureVideoDataOutput |
最低延遲,直接取得 CMSampleBuffer 送入 MediaPipe |
| 多語言 | I18nManager.swift(自研 JSON key-value,無 NSLocalizable.strings) |
動態切換不需要 App 重啟,runtime t("key", lang: ...) |
| 打包 | xcodebuild archive + xcodebuild -exportArchive,ExportOptions.plist 為 method=debugging |
Ad-hoc 發行給自己裝置用;一律加 SWIFT_OPTIMIZATION_LEVEL="-Onone"(見 §10) |
不要引入任何額外 Swift Package(除了 MediaPipe 本身)。目前專案刻意維持零第三方依賴。
3. iOS 原生端架構設計原則
3.1 單向資料流
CameraManager (AVCapture)
↓ CMSampleBuffer (CameraManagerDelegate)
PipelineManager
↓ 送入推理
MediaPipeService
↓ NormalizedLandmark[] (MediaPipeServiceDelegate)
PipelineManager
↓ evaluateAll(...)
ActionEngine → RuleEngine
↓ [String: Bool] results
SharedState (DispatchQueue.main.async)
↓ @Published 觸發 UI 更新
OverlayView + ContentView + TriggersView + ...
SharedState 是唯一的狀態中心。任何模組不得在 SharedState 以外持有另一份「活著的」觸發狀態。唯讀快取、暫存計算中間值可以放在各模組 private 欄位,但最終評估結果必須寫回 SharedState。
3.2 兩段式評估(ActionEngine)
評估分兩 pass:
- Pass 1:掃描所有
type == .geometric的 trigger,呼叫RuleEngine.evaluateGeometric(),結果寫入results: [String: Bool]。 - Pass 2:掃描所有
type == .logic的 trigger,讀取 Pass 1 產生的results做 AND/OR 組合,也寫入results。
Pass 2 依賴 Pass 1 的輸出,因此順序不可對調、不可合併成一個 pass。若未來要支援三層巢狀邏輯(邏輯 trigger 引用另一個邏輯 trigger),需要在 Pass 2 加入拓撲排序,但目前的限制是邏輯 trigger 只能引用幾何 trigger——這是刻意的設計,不是遺漏。
3.3 校準基準(Baseline)
所有幾何判定都對比校準瞬間拍攝的 30 幀平均值。校準結果以 [NormalizedLandmark]? 形式存在 SharedState.baselineFaceLandmarks 和 SharedState.baselinePoseLandmarks。
校準時 PipelineManager 收集 30 幀、逐欄取中位數、一次性寫入 SharedState,之後不再更新(除非使用者重新點「重新校準」按鈕)。
3.4 螢幕物理對齊(RuleEngine)
不要用 normalized 座標直接做距離計算。 RuleEngine.distanceInScreen() 將 NormalizedLandmark 乘以 viewSize(實際 SwiftUI 渲染尺寸,非螢幕尺寸)才算像素距離。這是為了修正設備長寬比差異——同一個 NormalizedLandmark 在 4:3 的設備上跟 9:19.5 的設備上算出來的「歐式距離」是不同的物理距離。
viewSize 必須從 UI 層用 GeometryReader 取得並寫入 SharedState.viewSize,不得寫死。
4. 介面契約(鎖定,實作必須符合)
4.1 TriggerConfig 與 GeometricRule
// RenUniversal/TriggerConfig.swift
struct TriggerConfig: Codable, Identifiable, Equatable {
var id: String
var description: String
var enabled: Bool
var type: TriggerType
var geometricRule: GeometricRule? // type == .geometric 時非 nil
var logicRule: LogicRule? // type == .logic 時非 nil
}
enum TriggerType: String, Codable, Equatable {
case geometric
case logic
}
struct GeometricRule: Codable, Equatable {
var pt1: String // e.g. "f1", "p11"
var pt2: String
var op: String // "><", "<>", ">><<", "~~", "x><", "x<>", "y><", "y<>"
var value: Double
var isPercentage: Bool
}
struct LogicCondition: Codable, Equatable, Identifiable {
var id = UUID()
var isNot: Bool = false
var triggerId: String = ""
}
struct LogicRule: Codable, Equatable {
var conditions: [LogicCondition]
var joinOperator: String // "AND" | "OR"
}
任何功能的新增或修改都不得改變以上 struct 的 Codable 結構,否則舊使用者升級後 UserDefaults 反序列化會靜默失敗、觸發器全部遺失。新增欄位必須給預設值(= false、= "")才能維持向後相容。
4.2 AppConfig
// RenUniversal/AppConfig.swift
struct AppConfig: Codable, Identifiable, Equatable {
var id: String = UUID().uuidString
var name: String
var urlScheme: String // e.g. "youtube://",空字串表示不啟動 App
var triggerId: String // 哪個 trigger 觸發啟動
var iconSystemName: String // SF Symbol 名稱,預設 "gamecontroller.fill"
var enabled: Bool = true
}
4.3 RuleEngine 公開介面(不得擅自改型別)
class RuleEngine {
static let shared = RuleEngine()
func evaluateGeometric(
rule: GeometricRule,
face: [NormalizedLandmark]?,
pose: [NormalizedLandmark]?,
baselineFace: [NormalizedLandmark]?,
baselinePose: [NormalizedLandmark]?,
viewSize: CGSize
) -> (Bool, Double?) // (觸發與否, 當前比率用於 HUD 顯示)
func evaluateLogic(
rule: LogicRule,
activeTriggers: [String: Bool]
) -> Bool
}
evaluateGeometric 回傳的 Double? 是「當前偏差量/比率」,用於 HUD 顯示 activeRatios。非 nil 時前端應顯示百分比;nil 表示點位資料不足(臉或身體不在鏡頭內),前端顯示「--」。
5. 目錄結構
RenUniversal/
RenUniversal/
RenUniversalApp.swift # App 進入點
ContentView.swift # 根 View,TabView 配置(相機、監控、Triggers、Apps)
SharedState.swift # 全域狀態 ObservableObject + 持久化 + 遷移邏輯
PipelineManager.swift # 相機→MediaPipe→ActionEngine 的驅動層,持有 delegates
CameraManager.swift # AVCaptureSession 管理(SessionPreset, FPS, Orientation)
MediaPipeService.swift # MediaPipe Tasks Vision 推理(FaceLandmarker + PoseLandmarker)
ActionEngine.swift # 兩段式評估:Pass 1 幾何,Pass 2 邏輯
RuleEngine.swift # 所有幾何算法(distanceInScreen、軸向計算、~~ 越界)
TriggerConfig.swift # 所有資料模型(TriggerConfig / GeometricRule / LogicRule)
AppConfig.swift # App Launcher 設定模型
OverlayView.swift # 相機即時疊加層(地標點、觸發線段、顏色切換)
TriggersView.swift # 觸發器管理 UI(新增、編輯、排序、刪除)
AppsView.swift # App Launcher 管理 UI
PointReferenceView.swift # 點位參考 UI(500+ 點的可視化選取)
EventsView.swift # 歷史事件記錄 UI
ProfileImportExportView.swift # 整包設定匯入/匯出(JSON 分享)
SingleEntityImportExportView.swift # 單一 Trigger/App 匯入/匯出
GameWebView.swift # 外部 URL 嵌入 WebView(遊戲/網頁 App 啟動)
I18nManager.swift # 動態多語言管理(zh-TW / en / ja / ko / es / fr)
BundledData.swift # 內建預設 Triggers JSON(新裝置首次載入)
BundledGames.swift # 內建預設 App 清單
FaceTopologyData.swift # Face Mesh 468 點的拓撲連線定義(用於 OverlayView)
WebTriggerFormat.swift # 與 Python 後端共用的 Trigger JSON schema(未來橋接用)
EventsManager.swift # 歷史事件儲存與讀取
原則:不要新增 ViewModel 層。SharedState 就是 ViewModel,View 直接 @EnvironmentObject 取用。SwiftUI 的 @Published 已經足夠細粒度,不需要再包一層。
6. 建構順序與驗收標準
依序做,每步做完自我驗證再往下:
- 相機流通(Camera Pipeline) —
CameraManager→MediaPipeService→PipelineManager能把NormalizedLandmark穩定回調出來。驗收:開啟相機,SharedState.currentFaceLandmarks非 nil,OverlayView 能在臉上看到點。 - 基準校準 — 按下校準按鈕後,30 幀採樣平均,
SharedState.baselineFaceLandmarks非 nil 且穩定。驗收:校準後isCalibrated = true,HUD 出現。 - 幾何觸發(RuleEngine Pass 1) — 內建的
slouch/turn/lean/tilt四個幾何 trigger 都要能正確觸發與解除。驗收:刻意低頭時slouch亮紅,回正時熄滅;刻意轉頭時turn亮紅;tilt(歪頭)亮紅——特別驗證d_base == 0 && isPct的 guard(見 §10 Bug 1)。 - 邏輯觸發(ActionEngine Pass 2) —
bad_posture(OR)和ctar_tuck(AND + NOT)的組合邏輯正確。驗收:lean單獨亮時bad_posture也亮;slouch亮但turn和lean都沒亮時ctar_tuck才亮。 - OverlayView 渲染 — 觸發時線段由綠轉紅,軸向 operator(
x<>、y<>)顯示水平/垂直對齊線而非斜線。驗收:tilt觸發時顯示水平線(因為y<>前綴),turn觸發時顯示斜線。 - Triggers UI(TriggersView) — 新增、編輯、刪除、啟用/停用 trigger,持久化到 UserDefaults,重啟後資料仍在。驗收:刪除
lean後bad_posture不再有 lean 子條件;重啟 App 後自訂 trigger 仍存在。 - App Launcher(AppsView / AppConfig) — 新增 App、指定 URL Scheme 與觸發 trigger,啟動時能正確跳轉。驗收:新增一個 YouTube(
youtube://)綁slouch,低頭時 YouTube 跳出來。 - 多語言(I18nManager) — 切換語言後 UI 所有 key 都正確翻譯,不重啟。驗收:切到 English,HUD 的 "Live Status" 換成英文;切到 繁體中文 回來正常。
- Profile 匯入/匯出 — 整包 Triggers + Apps 能匯出 JSON,在另一台裝置匯入後完整還原。驗收:匯出 JSON,模擬「新裝置」(刪掉所有 trigger 後)再匯入,所有 trigger 全部回來且邏輯正確。
- 打包與發行 — 見 §11。驗收:
xcodebuild archive0 errors,IPA 可正常安裝到裝置。
7. 程式碼規範
- 所有 UI 更新在
DispatchQueue.main.async。MediaPipe 的 delegate callback 在背景 thread,直接更新@Published會 crash。 - Codable struct 新增欄位一律給預設值(見 §4.1 契約原則)。不給預設值等同在 UserDefaults 裡放了一顆定時炸彈,舊版資料升級後反序列化失敗、整包 trigger 消失。
- 語系 key 一律透過
sharedState.t("key")(ContentView內)或I18nManager.shared.t("key", lang:),不得在 View 裡寫死中文字串(除非是固定不翻譯的品牌名稱)。 - 不要在
SharedStateinit() 以外的地方寫 UserDefaults。存取 UserDefaults 的邏輯集中在SharedState的didSet觀察者與init(),避免分散到各個 View。 RuleEngine是純函數。不得在RuleEngine.swift裡持有任何狀態,所有計算從外部傳入、結果以 tuple 回傳。
8. 反腐化紅線(違反即回報,不得自作主張)
- Codable struct 在沒有明確 migration 計畫的情況下移除或重命名欄位。會導致舊裝置升級後資料損毀。
- 在 MediaPipe delegate callback 裡直接操作
@Published或 UI。必須DispatchQueue.main.async。 - 在
RuleEngine.evaluateGeometric()裡加全域 side effect 或狀態持有。RuleEngine 是無狀態的純計算層。 - 引入任何 Swift Package 依賴(除了 MediaPipe 官方 XCFramework)。
- 在
TriggerConfig/GeometricRule/AppConfig以外的地方建立「平行的觸發狀態」。SharedState.activeTriggers是唯一的真相來源。 - 在
UserDefaults裡存[NormalizedLandmark]。Landmark 是 runtime 資料,不應持久化;只有GeometricRule(使用者設定)持久化,Baseline Landmarks 在 App 關閉後不保留(重啟 App 後需要重新校準)。
9. 歷史決策紀錄
9.1 為何選擇 MediaPipe 而非 Vision Framework(2026-01-xx 定案)
iOS 的 Vision framework 提供 VNDetectFaceLandmarksRequest 與 VNDetectHumanBodyPoseRequest,但兩者無法在同一個 request 裡同時得到臉部細節與身體姿勢,需要分別建立不同的 VNImageRequestHandler,且 Face Landmarks 只有 76 點(Face Mesh)不足以支援高精度幾何判定。MediaPipe 的 FaceLandmarker 提供 468 點,PoseLandmarker 提供 33 點,且兩者可以串接在同一個 MediaPipeServiceDelegate 回調週期內取得。選定 MediaPipe 後,確認了在 iPhone X(A11)以上的設備 30FPS 穩定跑得住。
9.2 校準設計:固定 30 幀平均,不做即時 baseline 更新(2026-02-xx 定案)
最初考慮過「滑動視窗 baseline」(持續用最近 N 幀更新基準),但這在使用者「維持正確姿勢超過 N 幀」時會把壞姿勢慢慢吃進基準,讓觸發永遠不再發生。定案為「一次性校準 + 使用者手動重新校準」,基準在 App 重啟後不保留(使用者開 App 就校準,已成習慣)。
9.3 ~~ 越界(Capsule)運算子設計(2026-04-xx 定案)
最初只有 ><、<>、>><< 三個線段距離變化運算子,但在測試代償動作時發現:若使用者「頭沒有傾斜但整個身體往旁邊平移」,線段長度不變,所有現有運算子都偵測不到。引入 ~~:分別記錄校準時兩端點的絕對螢幕座標,以各自座標為圓心畫正圓,任一端點飄出自己的圓就觸發。這個設計的關鍵在於「兩點獨立判斷」而非「線段整體」,才能真正覆蓋代償動作的防守死角。
9.4 輕量 I18n 設計:不用 NSLocalizable.strings(2026-04-xx 定案)
iOS 原生的本地化要求重啟 App 才能生效(語系改變 → 系統重新載入 Bundle),且無法做 runtime 動態切換。本專案需要「在設定頁直接切換語言、不重啟、立即生效」,因此自研 I18nManager.swift:所有翻譯 key 存在 Swift dict 裡,t("key", lang:) 直接查 dict 回傳字串,@Published var language 的 didSet 觸發全部 View 重繪。代價:翻譯字串分散在各個 View 的 t() 呼叫裡,沒有集中的 strings table,之後若要做大規模翻譯審查需要全文搜尋。這個取捨是刻意的。
9.5 軸向運算子 x/y 前綴設計(2026-05-xx,v1.3.0 上線)
最初所有運算子都計算歐式距離,但偵測「歪頭(tilt)」需要的是兩眼在 y 軸的絕對像素距離——歐式距離在頭部輕微前傾時也可能觸發(因為視野壓縮導致兩眼全局距離縮短),不精確。引入 y<> / y>< / x<> / x><:對運算子前綴 x 或 y 時,distanceInScreen() 只計算對應軸向的分量。對應 OverlayView 也更新:x 前綴畫水平線,y 前綴畫垂直線,直觀反映「在看哪個軸向」。
9.6 遷移邏輯(Migration Logic)設計原則(2026-05-xx 定案)
SharedState.init() 在反序列化舊版 UserDefaults 資料後,做一次補齊遷移:遍歷 defaultTriggers 清單,若使用者現有清單裡沒有某個 id,就 append 進去。這個「只補、不蓋」的原則保證:
- 舊裝置升級後自動獲得新版新增的內建 trigger
- 使用者自訂的同 id trigger 不會被覆蓋
- 使用者手動刪除的內建 trigger 不會被強制加回來(只在從無到有的首次安裝時補)
10. 已知坑與變通方案
Bug 1:tilt 偵測在 v1.3.2 裝置上永遠不觸發(v1.3.3 修正)
根因:RuleEngine.evaluateGeometric() 原本有一行 guard:
if d_base == 0 { return (false, nil) }
這是為了防止除以零。但 tilt 用的是 y<> 運算子(isPercentage: false),即絕對像素距離,d_base(校準時兩眼 y 軸距離)在頭部正常狀態下本來就接近 0——因為正視前方時兩眼的 y 座標幾乎相同。這行 guard 讓 tilt 永遠提早 return,永遠不觸發。
修正(已在 a887100 commit 修入):
// 修正前
if d_base == 0 { return (false, nil) }
// 修正後——只有相對百分比運算才需要防止除以零
if d_base == 0 && isPct { return (false, nil) }
教訓:任何新增軸向 operator 或絕對距離 operator 之前,都要確認 guard 條件是否對該 operator 成立。「d_base == 0」在相對百分比模式是問題(除以零),在絕對距離模式是正常的初始狀態(不應視為錯誤)。
Bug 2:語言選單(Picker + MenuPickerStyle)點擊區域過小(v1.3.4 修正)
根因:Picker 的 MenuPickerStyle 只有選項旁邊的下拉箭頭是可點擊的,整張 Card 不是 hit target。使用者難以精準點擊,尤其在小螢幕裝置(iPhone SE)上。
修正(v1.3.4 已改):改用 Menu { ... } label: { HStack { ... }.contentShape(Rectangle()) },整張 card 都是 hit target。關鍵是 label 裡要加 .contentShape(Rectangle()),否則 SwiftUI 只把有背景填色的部分算為可點擊區域,透明的 Spacer 仍然無效。
坑 3:SIL 優化器 crash(Release build,所有 Xcode 版本均可能觸發)
症狀:xcodebuild archive 使用 Release 設定時,swift-frontend 在 SILPassManager::runFunctionPasses 階段 crash,無 Swift 程式碼錯誤,純粹是編譯器 bug。
變通方案:所有 xcodebuild archive 指令一律加 SWIFT_OPTIMIZATION_LEVEL="-Onone":
xcodebuild archive \
-project RenUniversal/RenUniversal.xcodeproj \
-scheme RenUniversal \
-configuration Release \
-archivePath build/RenUniversal.xcarchive \
SWIFT_OPTIMIZATION_LEVEL="-Onone" \
CODE_SIGNING_ALLOWED=YES
-Onone 關閉了 SIL 優化 pass,等同 Debug build 的優化等級,IPA 稍微大一點但功能完全相同。正式 App Store 若未來要上架,需要評估是否能改用 -O(有可能在未來版本 Xcode 修好 crash)。
坑 4:ExportOptions.plist 被 .gitignore 排除,每次換環境需重建
build/Exported/ExportOptions.plist 列在 .gitignore 裡,換機器或換 session 後需要手動重建:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key><string>export</string>
<key>method</key><string>debugging</string>
<key>signingStyle</key><string>automatic</string>
<key>stripSwiftSymbols</key><true/>
<key>teamID</key><string>SYYA56DCPZ</string>
<key>thinning</key><string><none></string>
</dict>
</plist>
build/ 目錄也需要先建立(mkdir -p build/Exported)。
11. 打包與發行流程(已驗證,v1.3.x 起固定用此流程)
# 1. 升版號(修改 project.pbxproj 的 MARKETING_VERSION)
cd RenUniversal
NEW_VERSION="1.x.x"
sed -i '' "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${NEW_VERSION}/" \
RenUniversal/RenUniversal.xcodeproj/project.pbxproj
# 2. 重建 ExportOptions.plist(若不存在)
mkdir -p build/Exported
# (參考 §10 坑 4 的內容)
# 3. Archive(加 -Onone,見 §10 坑 3)
xcodebuild archive \
-project RenUniversal/RenUniversal.xcodeproj \
-scheme RenUniversal \
-configuration Release \
-archivePath build/RenUniversal.xcarchive \
SWIFT_OPTIMIZATION_LEVEL="-Onone" \
CODE_SIGNING_ALLOWED=YES
# 4. Export IPA
xcodebuild -exportArchive \
-archivePath build/RenUniversal.xcarchive \
-exportPath build/Exported \
-exportOptionsPlist build/Exported/ExportOptions.plist
# 5. 確認 IPA 存在
ls build/Exported/*.ipa
# 6. 建立 GitHub Release(tag + attach IPA)
git tag "v${NEW_VERSION}"
git push origin "v${NEW_VERSION}"
gh release create "v${NEW_VERSION}" \
"build/Exported/RenUniversal.ipa" \
--title "v${NEW_VERSION}" \
--notes "變更說明..."
驗收:xcodebuild archive 0 errors(不是 0 warnings),IPA 可以透過 AltStore 或其他側載工具安裝到 iPhone X 以上裝置,功能正常。
12. 待規劃(打磨階段,尚未排入 MVP)
以下功能使用者曾提及但尚未進入開發,不要提前做:
- ❌ 動態 Overlay 濃淡動畫:觸發時線段從綠到紅的 animated 漸變,目前是瞬間切換。
- ❌ 歷史數據圖表(EventsView 強化):目前只有純文字清單,未來考慮 Charts framework 做觸發頻率趨勢圖。
- ❌ Apple Watch 連動:WatchConnectivity 傳遞
activeTriggers到 Watch 端振動提示。 - ❌ 多人 / 遠端監控橋接:本端 App 作為 WebSocket client 連線到 Python 後端的
/stream端點,讓遠端治療師也能看到即時觸發狀態。 - ❌ Siri Shortcuts 整合:讓使用者可以用「嘿 Siri,開始姿態監控」觸發校準流程。
遇到以上項目的實作請求,先確認使用者已明確排入當前 phase 再動手。
本檔撰於 2026 年 7 月,版本 1.3.4。