iOSCoding.md

iOSCoding.md — RenUniversal iOS 原生端

這是 AI Coding Agent 讀的啟動規格檔(bootstrap spec)。開始動任何 iOS/Swift 程式碼前,先完整讀完本檔。本檔的決策是鎖定的,不得因「更好的想法」而擅自更動;若你認為某決策有誤,先停下來提出質疑,不要直接改。


0. 你是誰、你在做什麼

你是本專案 iOS 原生端的實作 agent。RenUniversal 是一個基於電腦視覺邊緣運算的 AI 生理姿態回饋與即時監控系統,iOS 原生 App(RenUniversal/RenUniversal/)是目前主要開發重心,相較於 Python 後端提供了更低延遲、更完整的離線體驗,且不需要架設任何伺服器。

本端的核心職責

  1. 透過 AVFoundation 取得相機影格,送進 MediaPipe 做臉部(Face Mesh,468 點)與身體(Pose,33 點)關鍵點估算。
  2. RuleEngine.swift 依使用者設定的幾何規則評估姿態偏差。
  3. ActionEngine.swift 兩段式評估(幾何先、邏輯後),將結果寫回 SharedState
  4. 透過 OverlayView.swift 在即時相機畫面上渲染標注線段與觸發狀態。
  5. 透過 ActionEngine 評估後,呼叫 AppConfig 啟動對應的外部 App(App Launcher)。

不要動 Python 後端backend/web/skills/)。除非任務明確指定,你只操作 RenUniversal/RenUniversal/ 底下的 Swift 程式碼。


1. 開發方法

工作循環固定為:

  1. 讀規格 — 讀本檔對應章節,確認要做的功能屬於哪個 phase、是否在當前範圍內。
  2. 確認契約 — 你動的模組必須符合第 4 節的型別契約。型別優先於實作;不得為了實作方便偷改 Codable struct。
  3. 最小切片 — 一次只實作一個可驗證的最小功能,能跑、能測。
  4. 驗證 — 每個功能都要有對應的手動驗證方式(第 6 節驗收標準)。
  5. 停在 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 2 依賴 Pass 1 的輸出,因此順序不可對調、不可合併成一個 pass。若未來要支援三層巢狀邏輯(邏輯 trigger 引用另一個邏輯 trigger),需要在 Pass 2 加入拓撲排序,但目前的限制是邏輯 trigger 只能引用幾何 trigger——這是刻意的設計,不是遺漏。

3.3 校準基準(Baseline)

所有幾何判定都對比校準瞬間拍攝的 30 幀平均值。校準結果以 [NormalizedLandmark]? 形式存在 SharedState.baselineFaceLandmarksSharedState.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. 建構順序與驗收標準

依序做,每步做完自我驗證再往下:

  1. 相機流通(Camera Pipeline)CameraManagerMediaPipeServicePipelineManager 能把 NormalizedLandmark 穩定回調出來。驗收:開啟相機,SharedState.currentFaceLandmarks 非 nil,OverlayView 能在臉上看到點。
  2. 基準校準 — 按下校準按鈕後,30 幀採樣平均,SharedState.baselineFaceLandmarks 非 nil 且穩定。驗收:校準後 isCalibrated = true,HUD 出現。
  3. 幾何觸發(RuleEngine Pass 1) — 內建的 slouch/turn/lean/tilt 四個幾何 trigger 都要能正確觸發與解除。驗收:刻意低頭時 slouch 亮紅,回正時熄滅;刻意轉頭時 turn 亮紅;tilt(歪頭)亮紅——特別驗證 d_base == 0 && isPct 的 guard(見 §10 Bug 1)。
  4. 邏輯觸發(ActionEngine Pass 2)bad_posture(OR)和 ctar_tuck(AND + NOT)的組合邏輯正確。驗收lean 單獨亮時 bad_posture 也亮;slouch 亮但 turnlean 都沒亮時 ctar_tuck 才亮。
  5. OverlayView 渲染 — 觸發時線段由綠轉紅,軸向 operator(x<>y<>)顯示水平/垂直對齊線而非斜線。驗收tilt 觸發時顯示水平線(因為 y<> 前綴),turn 觸發時顯示斜線。
  6. Triggers UI(TriggersView) — 新增、編輯、刪除、啟用/停用 trigger,持久化到 UserDefaults,重啟後資料仍在。驗收:刪除 leanbad_posture 不再有 lean 子條件;重啟 App 後自訂 trigger 仍存在。
  7. App Launcher(AppsView / AppConfig) — 新增 App、指定 URL Scheme 與觸發 trigger,啟動時能正確跳轉。驗收:新增一個 YouTube(youtube://)綁 slouch,低頭時 YouTube 跳出來。
  8. 多語言(I18nManager) — 切換語言後 UI 所有 key 都正確翻譯,不重啟。驗收:切到 English,HUD 的 "Live Status" 換成英文;切到 繁體中文 回來正常。
  9. Profile 匯入/匯出 — 整包 Triggers + Apps 能匯出 JSON,在另一台裝置匯入後完整還原。驗收:匯出 JSON,模擬「新裝置」(刪掉所有 trigger 後)再匯入,所有 trigger 全部回來且邏輯正確。
  10. 打包與發行 — 見 §11。驗收xcodebuild archive 0 errors,IPA 可正常安裝到裝置。

7. 程式碼規範


8. 反腐化紅線(違反即回報,不得自作主張)

  1. Codable struct 在沒有明確 migration 計畫的情況下移除或重命名欄位。會導致舊裝置升級後資料損毀。
  2. 在 MediaPipe delegate callback 裡直接操作 @Published 或 UI。必須 DispatchQueue.main.async
  3. RuleEngine.evaluateGeometric() 裡加全域 side effect 或狀態持有。RuleEngine 是無狀態的純計算層。
  4. 引入任何 Swift Package 依賴(除了 MediaPipe 官方 XCFramework)。
  5. TriggerConfig / GeometricRule / AppConfig 以外的地方建立「平行的觸發狀態」SharedState.activeTriggers 是唯一的真相來源。
  6. UserDefaults 裡存 [NormalizedLandmark]。Landmark 是 runtime 資料,不應持久化;只有 GeometricRule(使用者設定)持久化,Baseline Landmarks 在 App 關閉後不保留(重啟 App 後需要重新校準)。

9. 歷史決策紀錄

9.1 為何選擇 MediaPipe 而非 Vision Framework(2026-01-xx 定案)

iOS 的 Vision framework 提供 VNDetectFaceLandmarksRequestVNDetectHumanBodyPoseRequest,但兩者無法在同一個 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 languagedidSet 觸發全部 View 重繪。代價:翻譯字串分散在各個 View 的 t() 呼叫裡,沒有集中的 strings table,之後若要做大規模翻譯審查需要全文搜尋。這個取捨是刻意的。

9.5 軸向運算子 x/y 前綴設計(2026-05-xx,v1.3.0 上線)

最初所有運算子都計算歐式距離,但偵測「歪頭(tilt)」需要的是兩眼在 y 軸的絕對像素距離——歐式距離在頭部輕微前傾時也可能觸發(因為視野壓縮導致兩眼全局距離縮短),不精確。引入 y<> / y>< / x<> / x><:對運算子前綴 xy 時,distanceInScreen() 只計算對應軸向的分量。對應 OverlayView 也更新:x 前綴畫水平線,y 前綴畫垂直線,直觀反映「在看哪個軸向」。

9.6 遷移邏輯(Migration Logic)設計原則(2026-05-xx 定案)

SharedState.init() 在反序列化舊版 UserDefaults 資料後,做一次補齊遷移:遍歷 defaultTriggers 清單,若使用者現有清單裡沒有某個 id,就 append 進去。這個「只補、不蓋」的原則保證:


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 修正)

根因PickerMenuPickerStyle 只有選項旁邊的下拉箭頭是可點擊的,整張 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-frontendSILPassManager::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>&lt;none&gt;</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)

以下功能使用者曾提及但尚未進入開發,不要提前做:

遇到以上項目的實作請求,先確認使用者已明確排入當前 phase 再動手。

本檔撰於 2026 年 7 月,版本 1.3.4。