Files
worldmodel/plans/CrowdRoom/03_ios_app_plan.md
T
gaojie 1ea74b46da
Sync to site1 / sync (push) Has been cancelled
chore: update CrowdRoom categories from worldmodel to CrowdRoom
2026-05-21 02:20:22 +08:00

698 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "CrowdRoom · iOS App 设计(v0.2"
date: 2026-05-20
draft: false
tags: ["CrowdRoom", "众包", "3D 重建", "导航", "隐私", "iOS"]
categories: ["CrowdRoom"]
---
# CrowdRoom · iOS App 设计(v0.2
> **版本**v0.22026-05-19
> **v0.2 修订**:回写 G-4(§8.4 PrivacyManifest 新节,Apple 强制项)、G-5(§8.1 `NSLocationWhenInUseUsageDescription` 文案明确「精确 GPS 不会上传」)、G-8(§1.2 IA 跳转图追加 `PrivacyDetail` 节点)。源决策见 [`09_privacy.md`](09_privacy.md) §4 P-2 / P-3 与 §6.2 C-P2-6。
> 本章承接 [`00_overview.md`](00_overview.md) §4 架构图、[`01_data_schema.md`](01_data_schema.md) 的 9 张表与 `layer_manifest.json` schema、以及 [`02_api_contract.md`](02_api_contract.md) §2 的 19 个端点(v0.2,原 16 个 + 3 个新增)与 §8.1 的 5 条 iOS 硬契约(iOS-X1 ~ iOS-X5),落地为一份**可直接交付给 iOS 工程团队**的 App 设计。
>
> 复用 [`plans/iphone/iphone_simplified_plan.md`](../iphone/iphone_simplified_plan.md) 的扫描引导思想与 [`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) 的 RoomPlan 精度边界,但裁剪到「**只上传、不出图**」的轻量端。
>
> **iOS 端只做三件事:采集(RoomPlan+ 端侧脱敏(Vision + CIGaussianBlur+ 上传到 Supabase**。Remix 编辑、Web 渲染、转码、审核——一概不做。
---
## 1. iOS App 信息架构(IA
### 1.1 整体导航:5 Tab + 模态扫描
CrowdRoom iOS 采用 5 个底部 Tab + 一个独立模态扫描页的结构。**扫描入口故意放在 Tab Bar 正中间作为大按钮**(参考 Instagram Reels 的 + 设计),强化「采集」是 App 的第一动作。
| Tab | 图标 | 功能 |
|-----|------|------|
| 1. 发现 | 🔭 | 公共 feed、搜索、标签筛选 |
| 2. 通知 | 🔔 | 我的房间被 Remix / 评论 / 点赞、转码完成通知 |
| 3. 扫描 ★ | +(中间凸起大按钮) | **唤起 RoomCaptureView 模态**,全屏覆盖;扫描结束回 Tab 1 或我的 |
| 4. 我的房间 | 📁 | 我上传的 / 草稿 / 失败需要重试 |
| 5. 我的 | 👤 | Profile、配额、设置、登录态 |
> Remix 编辑入口**只在 Web 端**(见 [`02_api_contract.md`](02_api_contract.md) §4),iOS 端只提供「在浏览器中打开并 Remix」的深链接跳转,不放独立 Tab。
### 1.2 页面跳转图
```mermaid
graph TD
Launch[启动页]
Auth[登录 注册页]
AppleLogin[Sign in with Apple]
EmailLogin[邮箱登录]
TabDiscover[Tab1 发现 Feed]
TabNotif[Tab2 通知列表]
TabScan[Tab3 扫描入口大按钮]
TabMyRooms[Tab4 我的房间]
TabProfile[Tab5 我的]
SearchPage[搜索 Tag 筛选页]
RoomDetail[房间详情 360 预览]
CommentList[评论列表]
ReportSheet[举报弹层]
RemixLaunchSheet[Remix 跳转确认弹层]
WebRemix[Safari 跳转 Web Remix 编辑器]
ScanIntro[扫描引导页 三步]
ScanCapture[RoomPlan 扫描中]
ScanReview[扫描完成预览页]
Redacting[端侧脱敏进度页]
UploadForm[上传表单 标题 标签 可见性]
UploadProgress[上传进度页]
TranscodeWait[等待转码页 Realtime]
UploadDone[发布完成页]
DraftList[草稿列表]
QuotaPage[配额详情页]
SettingsPage[设置页]
PrivacyPage[隐私脱敏策略页]
PrivacyDetail[单房间 隐私详情 脱敏检测框列表]
AccountDelete[注销账号并删除全部数据]
Launch --> Auth
Auth --> AppleLogin
Auth --> EmailLogin
AppleLogin --> TabDiscover
EmailLogin --> TabDiscover
TabDiscover --> SearchPage
TabDiscover --> RoomDetail
SearchPage --> RoomDetail
RoomDetail --> CommentList
RoomDetail --> ReportSheet
RoomDetail --> RemixLaunchSheet
RemixLaunchSheet --> WebRemix
TabScan --> ScanIntro
ScanIntro --> ScanCapture
ScanCapture --> ScanReview
ScanReview --> Redacting
Redacting --> UploadForm
UploadForm --> UploadProgress
UploadProgress --> TranscodeWait
TranscodeWait --> UploadDone
UploadDone --> RoomDetail
TabNotif --> RoomDetail
TabMyRooms --> RoomDetail
TabMyRooms --> DraftList
DraftList --> UploadForm
TabProfile --> QuotaPage
TabProfile --> SettingsPage
SettingsPage --> PrivacyPage
SettingsPage --> AccountDelete
TabMyRooms --> RoomDetail
RoomDetail --> PrivacyDetail
```
> 共 **24 个页面节点**v0.2,原 22 个 + v0.2 / G-8 追加 `PrivacyDetail` + v0.2 / P-4 配套追加 `AccountDelete`)。
>
> 🔄 **v0.2 — 回写自 G-8**:上图追加 `PrivacyDetail` 节点(路径 `我的房间 → 单房间详情 → 隐私详情`),对应 [`09_privacy.md`](09_privacy.md) §4 P-2 「owner 可在『我的房间 → 隐私详情』查看每个 bbox 缩略图并对漏检/误检发起『申请重做』工单」。`AccountDelete` 节点为 [`09_privacy.md`](09_privacy.md) §4 P-4 三阶段注销流程的 iOS 入口(路径 `Tab5 我的 → 设置 → 注销账号并删除全部数据`),调用 [`02_api_contract.md`](02_api_contract.md) §2 E-18 `account-delete`。
---
## 2. 核心用户流程(Critical Flows
### 2.1 Flow A:扫描 → 端侧脱敏 → 三段式上传 → 等待转码 → 发布
> ✅ 契约 iOS-X1(端侧人脸检测 + 高斯模糊)、iOS-X2(三段式上传)、iOS-X3JSON 原样)、iOS-X4Realtime 订阅)、iOS-X5(预查配额)全部出现在此流程
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant App as iOS App
participant V as Vision CoreImage
participant E as Edge Function
participant S as Supabase Storage
participant DB as Postgres
participant W as Transcode Worker
participant RT as Realtime Channel
U->>App: 点击 Tab 中间 + 按钮
Note over App: iOS-X5 进扫描页前预查配额
App->>E: GET functions v1 quota
E-->>App: tier free storage used limit
alt 配额超限
App-->>U: 本地拦截 弹窗提示升级
end
U->>App: 走完三步引导 开始 RoomCaptureView
App->>App: RoomPlan 采集 5 10 分钟
U->>App: 点击完成
App->>App: 导出 source usdz 与 roomplan json
Note over App: iOS-X3 RoomPlan JSON 原样保留 不做坐标变换
Note over App,V: iOS-X1 端侧脱敏开始
App->>V: 解压 usdz 遍历每张贴图 PNG JPEG
V->>V: VNDetectFaceRectanglesRequest
V-->>App: 命中 face bbox list
App->>V: CIGaussianBlur radius 18 仅作用于命中区域
V-->>App: 模糊后贴图
App->>App: 重新打包 usdz 同时把 face bbox 累积到 redactions
U->>App: 填写标题 标签 可见性 公开 私有
App->>E: POST upload-init title tags bytes_source
E->>DB: insert rooms 与 room_versions status uploading
E->>S: sign presigned URL usdz 与 json
E-->>App: version_id presigned_usdz presigned_json
Note over App,S: iOS-X2 三段式 第二段 直传 Storage
App->>S: PUT source usdz 大于 30 MB 走分片 multipart
App->>S: PUT source roomplan json
App->>E: POST upload-complete version_id redactions
E->>DB: update status queued insert redactions rows
E->>W: POST enqueue version_id
Note over App,RT: iOS-X4 订阅 Realtime 不轮询
App->>RT: subscribe channel room room_id
W->>W: usdz to glb 分层标注 manifest 缩略图
W->>E: POST transcode-done status ready paths
E->>DB: update status ready current_version_id
E->>RT: broadcast event ready version_id
RT-->>App: WebSocket push 收到 ready
App-->>U: 弹卡片 你的扫描已上线 跳房间详情
```
### 2.2 Flow B:浏览 → 房间详情 → 触发 Remix → 跳 Web
> iOS 端不做 Remix 编辑(编辑在 Web 端 R3F 渲染器里),仅做发起入口。Universal Link 透传 `room_id + version_id`,落地 Web 域名 `crowdroom.app/r/{room_id}?remix=1`。
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant App as iOS App
participant PG as PostgREST
participant CDN as CDN
participant SF as SFSafariViewController
U->>App: Tab 发现 下拉刷新
App->>PG: GET rest v1 rooms visibility eq public order created_at desc limit 20
PG-->>App: Room 列表 含 thumbnail_path
App->>CDN: GET thumbnail webp 列表卡片
CDN-->>App: 缩略图
U->>App: 点击某张卡片
App->>PG: GET rest v1 rooms id eq room_id with current_version
PG-->>App: Room 详情
Note over App: iOS 端仅展示静态 360 缩略图轮播
Note over App: 不在端内渲染 glb 太重 留给 Web
U->>App: 点击 Remix 按钮
App-->>U: 弹层 提示 Remix 编辑器在浏览器中体验更好
U->>App: 点击 确认
App->>SF: 打开 https crowdroom app r room_id remix 1
SF-->>U: 进入 Web Remix 编辑器
```
### 2.3 Flow C:通知到达 → 跳详情
```mermaid
sequenceDiagram
autonumber
participant RT as Realtime Channel
participant App as iOS App 后台 或 前台
participant UN as UNUserNotificationCenter
participant U as 用户
participant PG as PostgREST
Note over App,RT: App 启动时 已订阅 user user_id 个人频道
RT-->>App: push event remix_created 或 comment_new
alt App 在前台
App-->>U: Toast 提示 你的房间被 Remix
else App 在后台
App->>UN: scheduleLocalNotification 标题 房间被 Remix
UN-->>U: 系统通知中心展示
U->>UN: 点击通知
UN->>App: 唤起 app 携带 deeplink room_id
end
App->>PG: GET rest v1 rooms id eq room_id
PG-->>App: Room 详情
App-->>U: 跳房间详情页 评论列表锚到该条
```
---
## 3. 关键技术栈与库选型表
| 模块 | 推荐方案 | 备选 | 一行理由 |
|------|---------|------|---------|
| 语言 / 最低 iOS | **Swift 5.9 + iOS 17.0** | iOS 16.0 | RoomPlan API 在 17+ 增加了 second-pass mesh 优化与更稳的 `MultiRoom` 支持;Realtime SDK 也对 17 友好 |
| UI 框架 | **SwiftUI 主,UIKit 局部桥接** | 纯 UIKit | SwiftUI 写 Tab/列表/表单效率高;扫描页的 `RoomCaptureView``UIViewControllerRepresentable` 桥接 |
| RoomPlan 集成 | **`RoomCaptureView` 标准引导** | `RoomCaptureSession` 自定义 | MVP 不重写引导动画;标准 View 自带语音/手势提示,省 2 周 UX 工时;后续如要换皮再切 Session |
| 端侧人脸检测 | **Vision `VNDetectFaceRectanglesRequest`** | CoreML + 自训模型 | 系统级、无依赖、iPhone 13+ 单张 1024×1024 < 80 msRevision 3 召回率够 |
| 贴图脱敏 | **Core Image `CIGaussianBlur` + 蒙版合成** | Metal Shader 自写 | CIFilter 链路成熟、GPU 加速、`CIContext` 直接渲到 `CGImage` 写回 PNG |
| .usdz 解包/重打包 | **`ModelIO` + `USDKit`iOS 17+ `Compression` framework** | 外挂 `usdzconvert` CLI | iOS 沙箱不能跑 CLI`ModelIO` 支持读 USD,贴图替换走解包→改文件→重压 zip(usdz 本质是 zip |
| 网络层 | **URLSession + async/await + `URLSessionUploadTask`background config** | Alamofire | 无三方依赖;`backgroundSessionConfiguration` 是 App 进后台续传的唯一官方路径 |
| Supabase SDK | **[supabase-swift](https://github.com/supabase-community/supabase-swift) ≥ 2.0** | 手撸 REST + WebSocket | 官方维护、Realtime channel + Auth + Storage 三件套统一 SDK |
| Realtime 监听 | **`supabase-swift` Realtime channel `room:{id}` + `user:{id}`** | 自建 SSE | iOS-X4 强制要求订阅模型;SDK 处理重连与心跳 |
| 本地缓存 | **SwiftDataiOS 17** | Core Data / Realm | 草稿、未完成上传任务、Feed 分页缓存;SwiftData 与 SwiftUI 双向绑定省胶水代码 |
| 性能/崩溃监控 | **MetricKit(系统)+ Sentry iOS SDK** | Firebase Crashlytics | MetricKit 拿 RoomPlan 期 GPU/热量数据;Sentry 与 Supabase 后端 Sentry 共享 issue 视图 |
| 深链接 | **Universal Linksapple-app-site-association** | URL Scheme | Web Remix 编辑器跳回 App、通知点击跳详情都走 UL;URL Scheme 不安全且会被 Safari 拦截 |
| 图像渲染 | **`Image(uiImage:)` + `AsyncImage`** + 自建磁盘缓存 | Kingfisher / SDWebImage | 列表缩略图为主,自建 LRU 200 MB 够用;不引三方避免主线程阻塞 |
---
## 4. 端侧脱敏管线详细设计(iOS-X1 落地)
> ✅ 契约 iOS-X1:上传前必须在端侧跑人脸检测 + 贴图高斯模糊,并把命中区域写入 `redactions[]`,不依赖服务端二次脱敏。本节是 X1 的**完整工程落地**。
### 4.1 管线流程
```mermaid
graph TD
A[RoomPlan 导出 source usdz] --> B[Compression 解包 usdz 到 tmp redact 目录]
B --> C[ModelIO 枚举所有 MDLTexture 引用]
C --> D[拿到贴图文件路径列表 PNG JPEG]
D --> E[对每张贴图执行 Vision 人脸检测]
E --> F{检测到人脸}
F -- 无 --> G[原图不动]
F -- 有 --> H[CIGaussianBlur radius 18 渲染整图]
H --> I[用人脸 bbox 做蒙版 仅模糊区域合成回原图]
I --> J[写回贴图文件 同名覆盖]
G --> K[累计到 redactions list]
J --> K
K --> L[Compression 重新打包 usdz]
L --> M[校验 sha256 与体积 失败回滚]
M --> N[redactions 数组准备 POST 到 upload-complete]
```
### 4.2 Swift 伪代码骨架
```swift
import RoomPlan
import Vision
import CoreImage
import ModelIO
import Compression
// 线
// RoomPlan sourceUSDZ URL
// usdz URL redactions
func redactUSDZ(at sourceURL: URL) async throws -> (URL, [Redaction]) {
// 1 usdz zip
let workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("redact_\(UUID().uuidString)")
try unzipUSDZ(source: sourceURL, into: workDir)
// 2 ModelIO
let asset = MDLAsset(url: workDir.appendingPathComponent("scene.usdc"))
let textureURLs = collectTextureURLs(in: asset, workDir: workDir)
// 3 Vision +
var redactions: [Redaction] = []
let ctx = CIContext(options: [.useSoftwareRenderer: false])
for texURL in textureURLs {
guard let cgImage = loadCGImage(texURL) else { continue }
// 3 1 Vision
let request = VNDetectFaceRectanglesRequest()
request.revision = VNDetectFaceRectanglesRequestRevision3
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try handler.perform([request])
guard let faces = request.results, !faces.isEmpty else { continue }
// 3 2 CIGaussianBlur
let ciOriginal = CIImage(cgImage: cgImage)
let blurred = ciOriginal
.applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 18.0])
.cropped(to: ciOriginal.extent)
// 3 3 bbox
let maskCI = buildFaceMask(faces: faces, extent: ciOriginal.extent)
let composited = blurred.applyingFilter(
"CIBlendWithMask",
parameters: [
kCIInputBackgroundImageKey: ciOriginal,
kCIInputMaskImageKey: maskCI
]
)
// 3 4 ModelIO
guard let outCG = ctx.createCGImage(composited, from: composited.extent) else { continue }
try writePNG(outCG, to: texURL)
// 3 5 redactions POST Edge Function
for face in faces {
redactions.append(Redaction(
textureRef: texURL.lastPathComponent,
kind: "face",
region: face.boundingBox, // 0 1
method: "gaussian_blur_r18"
))
}
}
// 4 usdz
let redactedURL = workDir.appendingPathComponent("redacted.usdz")
try zipUSDZ(folder: workDir, output: redactedURL)
try validatePackaged(redactedURL)
return (redactedURL, redactions)
}
```
> 关键点:`usdz` 是无压缩 zip,重新打包必须用 `compression_encode_buffer` 的 `COMPRESSION_LZFSE_NONE` 或直接走 store-only zip——否则 Apple 工具链识别不出来。
### 4.3 性能预算
| 阶段 | iPhone 15 Pro 目标 | iPhone 13 Pro 目标 | 备注 |
|------|------------------|------------------|------|
| 解包 60 MB usdz | < 0.5 s | < 1.0 s | I/O bound |
| Vision 检测 20 张 1024² 贴图 | < 2.5 s | < 4.5 s | Neural Engine |
| CIGaussianBlur + 合成 | < 1.5 s | < 2.5 s | GPU bound |
| 重打包 | < 0.5 s | < 1.0 s | I/O |
| **总计** | **≤ 8 s** | **≤ 12 s** | iPhone 12 Pro 预期 16 s 见 §10 风险 |
UI:脱敏期间显示带百分比的进度页,文案「正在检查照片中的人脸…」。
### 4.4 失败兜底
- **重试机制**:单张贴图脱敏失败(如 ModelIO 解不开纹理)→ 重试 1 次。整体管线失败 → 重试最多 3 次。
- **3 次失败后**:弹窗提示「我们没能自动模糊照片里的人脸。建议你**关闭含人的扫描区域重扫**,或**手动框选要模糊的区域**」,给出两个按钮:「重新扫描」与「手动标注后上传」。
- **手动标注降级**:进入一个 `UIScrollView` 缩略图墙,让用户长按贴图后框选矩形,前端写到 `redactions[kind=manual]`,仍走相同上传流。
- **「不脱敏直接上传」按钮一律不提供**——这是 iOS-X1 的硬约束。
---
## 5. 三段式上传实现(iOS-X2 落地)
> ✅ 契约 iOS-X2:上传走 `upload-init → 直传 Storage → upload-complete` 三步,禁止把 `.usdz` 字节流塞进 Edge Function body。
### 5.1 顺序图
```mermaid
sequenceDiagram
autonumber
participant App as iOS App
participant E as Edge Function
participant S as Supabase Storage
App->>E: POST upload-init title tags bytes_source
E-->>App: version_id presigned_usdz presigned_json expires_at
App->>App: 创建 URLSession backgroundConfiguration identifier crowdroom upload version_id
par 并行 双 PUT
App->>S: PUT source usdz 大于 30 MB 走 multipart 5 MB chunk
App->>S: PUT source roomplan json
end
S-->>App: 200 OK 两次
App->>E: POST upload-complete version_id redactions
E-->>App: status queued
```
### 5.2 关键实现要点
| 场景 | 策略 |
|------|------|
| **大文件分片** | `.usdz > 30 MB` 启用 multipart PUT5 MB 一片,并行 3 路;Supabase Storage 支持 S3 兼容的 multipart |
| **断点续传** | 用 `URLSessionUploadTask` + 自管理 `Range` 头;SwiftData 表 `upload_chunks` 记录每片状态(pending/sent/acked),App 重启后扫表续传 |
| **App 进后台** | `URLSession(configuration: .background(withIdentifier:))` 让系统在 App 被挂起后继续传;完成时通过 `application(_:handleEventsForBackgroundURLSession:completionHandler:)` 唤醒 |
| **网络切换** | 注册 `NWPathMonitor`,从 Wi-Fi 切到蜂窝时**暂停**上传并弹窗:「当前已切换到蜂窝网络,继续上传可能产生流量费用」(默认开关:仅 Wi-Fi) |
| **电量低于 20%** | 启动上传前查 `UIDevice.current.batteryLevel`,< 0.20 时弹窗「电量较低,是否仍继续上传?」并禁用 multipart 并行(降为 1 路) |
| **超时与退避** | 单片超时 60 s,整体 20 分钟;指数退避 5 s → 15 s → 45 s,3 次失败后转 `failed` 入草稿 |
| **presigned 过期** | `expires_at` 提前 30 s 触发 `upload-init` 重签;server-side 已设 15 min TTL,足以覆盖大文件 |
### 5.3 错误码 → 用户文案映射
| 业务错误码(来自 [`02_api_contract.md`](02_api_contract.md) §7 | iOS 文案 | 后续动作 |
|---|---|---|
| `QUOTA_EXCEEDED` | "本月上传额度已用完,下月 1 号重置" | 跳「我的 → 配额详情」 |
| `FILE_TOO_LARGE` | "扫描文件超过 50 MB,请尝试缩小扫描范围" | 跳「重新扫描」 |
| `INVALID_TAGS` | "标签数量超过 8 个或含非法字符" | 高亮表单标签输入框 |
| `STORAGE_FORBIDDEN` | "上传授权已过期,正在重新申请…" | 自动调 `upload-init` 重签 1 次 |
| `NETWORK_TIMEOUT`(本地判定) | "网络连接超时,已为你保存草稿" | 写入草稿表,Tab 4 可见 |
| `REDACTION_INVALID` | "脱敏信息格式错误,请重新扫描" | 跳「重新扫描」;同时 Sentry 上报(端 bug |
| `ROOM_TRANSCODE_FAILED` | "云端处理失败" | 展示「重试」按钮 → `transcode-retry`3 次失败后建议人工反馈 |
| `WORKER_UNAVAILABLE` | "服务繁忙,已自动加入队列,1 分钟后重试" | 60 s 后自动重发 `upload-complete` |
| `UNAUTHENTICATED` | "登录已过期,请重新登录" | 弹登录页(保留草稿) |
| `RATE_LIMITED` | "操作过于频繁,请稍后再试" | 30 s 冷却倒计时 |
---
## 6. Realtime 转码进度 UIiOS-X4 落地)
> ✅ 契约 iOS-X4:监听 Realtime channel `room:{room_id}` 等待 `ready` 事件,**禁止轮询** `room_versions.status`。
### 6.1 等待页 UX
进入「等待转码」页后展示一个环形进度(CircularProgressView),中间显示阶段文案,下方有「在后台等待」与「取消并保存草稿」两个按钮。
| 阶段(来自 `room_versions.status` enum) | 文案 | 环形进度估算(无真实百分比,按阶段递增) |
|------|------|--------------------------------|
| `uploading` | "上传中…" | 0 → 25% |
| `queued` | "已加入处理队列" | 25% → 35% |
| `transcoding` | "解析中 / 分层中 / 生成预览…" | 35% → 90%(每收一次 progress payload +5% |
| `ready` | "发布完成 🎉" | 100% |
| `failed` | "处理失败" | 红色 × ,展示重试按钮 |
> 阶段进度**没有真实百分比**(Worker 不上报中间百分比),iOS 端用「阶段映射 + 时间外推」假装平滑;Realtime payload 一旦到 `ready`,直接跳 100%。
### 6.2 Realtime 订阅代码骨架
```swift
let channel = supabase.realtime.channel("room:\(roomId)")
channel.on("broadcast", filter: .init(event: "transcode_progress")) { msg in
// UI
Task { await viewModel.updatePhase(msg.payload["status"] as? String) }
}
channel.on("broadcast", filter: .init(event: "ready")) { msg in
Task { await viewModel.markReady(versionId: msg.payload["version_id"] as? String) }
}
channel.on("broadcast", filter: .init(event: "failed")) { msg in
Task { await viewModel.markFailed(error: msg.payload["error"] as? String) }
}
await channel.subscribe()
```
**重连**Supabase SDK 内置 30 s 心跳 + 指数退避重连。断线期间错过的事件由 App 重连后**主动**调一次 `GET /rest/v1/room_versions?id=eq.{id}` 补查最终状态(这是唯一允许的「兜底查询」,不是轮询)。
### 6.3 后台模式与本地推送
- App 进后台时 Realtime channel 会被 iOS 暂停(WebSocket 不能在后台保活)
- 解决方案:App 进后台前若仍在等待转码 → 注册 `BGAppRefreshTask`,约 15 分钟后唤醒一次后台拉取
- 后台任务执行时调 `GET /rest/v1/room_versions?id=eq.{id}` 单次查询,若已 `ready` → 通过 `UNUserNotificationCenter` 发本地通知「你的扫描已发布」
- 不依赖服务端 APNs push(MVP 不接苹果推送证书),全部走本地通知
### 6.4 失败重试入口
- `failed` 事件到达时,等待页转为「失败页」,展示错误码对应文案 + 「重试」按钮
- 「重试」按钮调 `POST /functions/v1/transcode-retry`02_api_contract 未列,作为 iOS 期望接口提给后端)
- 失败 3 次后,「重试」按钮隐藏,改显「联系客服」入口(跳邮件 `mailto:`
---
## 7. 扫描引导 UX 与质量门控
> 复用 [`plans/iphone/iphone_simplified_plan.md`](../iphone/iphone_simplified_plan.md) §3.1 的「准备 / 慢速移动 / 覆盖检查」三阶段思想,但裁剪为消费级三步引导。精度边界引用 [`plans/iphone/roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §1.2 的 ±3 cm 墙面 / ±5 cm 家具。
### 7.1 三步引导
| 步骤 | 标题 | 文案与示意 | 用户操作 |
|------|------|----------|---------|
| **Step 1 · 准备** | "把房间整理一下" | 三条建议:① 打开所有灯 ② 移走移动物体(宠物/人) ③ 清洁 LiDAR 镜头 | 点「我准备好了」 |
| **Step 2 · 慢走** | "用 0.3 m/s 的速度环绕房间" | 短视频示意 + RoomCaptureView 自带的语音引导一起播 | 进入 `RoomCaptureView`,扫描 510 分钟 |
| **Step 3 · 完成** | "看一下你的房间" | 展示 RoomPlan 生成的 wireframe 预览 + 质量分 | 「重扫」或「下一步:脱敏并上传」 |
### 7.2 实时质量提示(扫描中浮层)
`RoomCaptureView` 上叠加一个 SwiftUI 浮层,每 1 s 从 `RoomCaptureSession.Delegate` 读取一次状态:
| 信号 | 检测方式 | 浮层提示 |
|------|---------|---------|
| **覆盖率不足** | RoomPlan `instructions == .lowTexture` 或墙面置信度 `.low` 的占比 > 30% | "🔍 这面墙再扫一遍" |
| **漏扫墙面** | 房间未闭合(拓扑检测:墙数 < 3 或存在自由端) | "↩️ 似乎少了一面墙" |
| **强反光面** | 检测到 `Window``Mirror`(用 RoomPlan category)正对镜头 | "⚠️ 镜面会影响精度,请侧 30° 扫描" |
| **速度过快** | ARKit transform 一阶差分 > 0.6 m/s 持续 2 s | "🐢 慢一点,0.3 m/s 最佳" |
### 7.3 完成后质量分(A/B/C
扫描结束后用一个简单规则给房间打分(**不是机器学习模型**,是规则引擎,避免 MVP 复杂度):
| 维度 | A 档 | B 档 | C 档 |
|------|------|------|------|
| 墙面 confidence high 占比 | ≥ 90% | 7090% | < 70% |
| 家具识别数 | ≥ 5 件 | 24 件 | < 2 件 |
| 房间闭合 | ✅ | ✅ | ❌ 拓扑不闭合 |
| 扫描时长 | 510 min | 35 min | < 3 min |
**任一维度命中 C → 整体 C 档**
- **A/B 档**:直接进上传流,UI 给绿勾或黄勾标记
- **C 档**:弹窗「这次扫描质量较低,建议重扫;如仍上传,我们会给它打『C 档』标签,发现页排序权重会降低」——**仍允许上传**(社区数据不挑食),但 `rooms.quality_grade` 字段标 C[`02_api_contract.md`](02_api_contract.md) E-06 列表查询时排序权重 × 0.5
---
## 8. 隐私与权限请求文案
### 8.1 `Info.plist` 条目
| Key | 中文 usage description |
|-----|----------------------|
| `NSCameraUsageDescription` | "CrowdRoom 需要相机权限来扫描你的房间。我们只会使用相机进行 3D 建模,不会单独保存照片到相册。" |
| `NSPhotoLibraryAddUsageDescription` | "CrowdRoom 需要相册写入权限,以便把你扫描完成的 3D 房间预览图保存到相册(可选)。" |
| `NSLocationWhenInUseUsageDescription` | **v0.2 文案**"CrowdRoom 可选使用你的位置,仅为给你扫描的房间打上「城市」级标签(约 5 km 精度),**该城市标签会显示在你的公开房间页**;**你的精确 GPS 坐标不会上传、不会存储到服务器**。你可以随时在「设置 → 隐私 → 位置打标」里关闭。" |
| `NSMicrophoneUsageDescription` | **不申请**RoomPlan 不需要录音) |
| `NSUserTrackingUsageDescription` | **不申请**(MVP 不做跨 App 跟踪,见 8.2 |
> 🔄 **v0.2 — 回写自 G-5**:本节根据 [`09_privacy.md`](09_privacy.md) §4 P-3 决策追加。`NSLocationWhenInUseUsageDescription` 文案在 v0.2 起必须显式包含「**城市标签会显示在公开房间页**」与「**精确 GPS 不会上传**」两句——前者满足 PR-3 用户可控的「授权时即告知公开后果」,后者满足 PR-2 最小数据采集的「告知不持有原始坐标」。该文案变更同时对齐 [`01_data_schema.md`](01_data_schema.md) §3.2 `rooms.location_city`= `location_label` 别名)字段语义与 [`04_web_app_plan.md`](04_web_app_plan.md) §8.2 详情页元信息行。
### 8.2 ATTApp Tracking Transparency
**结论:MVP 不申请 ATT**
- CrowdRoom MVP 不接广告 SDK、不与第三方数据公司共享 IDFA
- Sentry / MetricKit 不使用 IDFA,走 Apple 私有指标
- 后续如接入 Apple Search Ads 归因,再单独申请 ATT 并补充权限弹窗文案
- 这一决策与本任务规划的「隐私治理」预留接口由子任务 5 收口
### 8.3 首次启动权限弹窗顺序
1. 进入扫描页 → 申请 `Camera`(必要)
2. 扫描完成后想保存预览图到相册 → 申请 `PhotoLibraryAdd`(按需)
3. 上传表单页填写「城市」标签时 → 申请 `LocationWhenInUse`(按需,可跳过)
> ✅ 契约 iOS-X5 衍生:进入扫描页时同时调 `GET /functions/v1/quota`,与相机权限请求**并行**发起,弹窗与配额预检不互相阻塞。
### 8.4 PrivacyManifest`PrivacyInfo.xcprivacy`
> 🔄 **v0.2 — 回写自 G-4**:本节根据 [`09_privacy.md`](09_privacy.md) §6.2 C-P2-6Apple 2024 春起强制,本已 cross-ref 为「MVP 待补」)新增。Apple App Store Connect 在 Xcode 15+ 上传时会自动跑 **Privacy Manifest Aggregate Report**;缺失或第三方 SDK 未声明会**直接拒审**。
#### 8.4.1 我们自己声明(CrowdRoom App bundle 内)
`PrivacyInfo.xcprivacy` 必须列出 4 类 Required Reason API 使用类别(CrowdRoom 业务场景下能触发的常见类别):
| `NSPrivacyAccessedAPICategory` 键 | 触发场景 | 选择的 reason code |
|---|---|---|
| `NSPrivacyAccessedAPICategoryFileTimestamp` | 读 `.usdz` / `.roomplan.json` 文件 `creationDate` 用于上传顺序排序 | `C617.1`(在 App 内用于显示给用户) |
| `NSPrivacyAccessedAPICategorySystemBootTime` | MetricKit / Sentry 性能事件相对时间戳 | `35F9.1`(计算相对设备启动时间) |
| `NSPrivacyAccessedAPICategoryDiskSpace` | 扫描前预估「设备剩余空间是否够暂存原始 .usdz」 | `85F4.1`(在 App 内显示空间不足提示) |
| `NSPrivacyAccessedAPICategoryUserDefaults` | 持久化「位置打标开关 / 端侧脱敏统计」等用户偏好 | `CA92.1`(同一 App 内读写) |
同时声明 **数据收集分类**`NSPrivacyCollectedDataTypes`),与 App Store Connect Privacy Nutrition Label 同源:
| 数据类型 | 是否收集 | 用途 | 关联用户 |
|---|---|---|---|
| `NSPrivacyCollectedDataTypeUserID` | ✅ | `App Functionality`(鉴权) | 关联 |
| `NSPrivacyCollectedDataTypeEmailAddress` | ✅ | `App Functionality`(账号恢复) | 关联 |
| `NSPrivacyCollectedDataTypeOtherUserContent`(房间几何 + 标题 + 评论) | ✅ | `App Functionality` | 关联 |
| `NSPrivacyCollectedDataTypeCoarseLocation`(城市级 5 km) | ✅(可选) | `App Functionality`(公开标签) | 关联 |
| `NSPrivacyCollectedDataTypePreciseLocation` | ❌ | — | — |
| `NSPrivacyCollectedDataTypeCrashData` | ✅ | `App Functionality`Sentry | 不关联 |
| `NSPrivacyCollectedDataTypePerformanceData` | ✅ | `Analytics`PostHog,需 opt-in | 不关联 |
| `NSPrivacyCollectedDataTypeAdvertisingData` | ❌ | — | — |
| `NSPrivacyCollectedDataTypeTrackingID`IDFA | ❌ | — | — |
#### 8.4.2 第三方 SDK PrivacyManifest 自查表
每个引入的 SDK 都必须**自带** `PrivacyInfo.xcprivacy`(Apple 维护一份强制 SDK 名单:`OpenSSL / FMDB / SQLite` 等通用基础库以及主流分析/广告 SDK 都在其中)。MVP 引入的 SDK 自查状态:
| SDK | 是否在 Apple 强制列表 | SDK 已自带 PrivacyManifest | 版本下限 | 备注 |
|---|---|---|---|---|
| **Supabase Swift**GoTrue + PostgREST + Storage + Realtime | ⚠ 部分依赖(如 SQLite)在列 | ✅ 自 v2.0+ 已自带;底层 `URLSession` 无需 | v2.5.0+ | Apple 通用网络栈无需自带 |
| **Sentry-Cocoa** | ✅ 在列 | ✅ 自 v8.20+ 自带 | v8.25+ | 关键:必须升级到 ≥ v8.20 才能过审 |
| **PostHog iOS** | ✅ 在列(涉及 `UserDefaults` + `SystemBootTime` | ✅ 自 v3.0+ 自带 | v3.5+ | MVP 仅在用户 opt-in 后初始化 |
| **Lucide Icons**(仅 SVG 资源,无 runtime | ❌ 不在列 | n/a | n/a | 资源包,不申报 |
| **Apple Vision / RoomPlan / ARKit** | n/a | n/a(系统框架) | iOS 17+ | 系统框架不计入第三方 |
> **CI 检查项**:在 GitHub Actions 上跑 `xcodebuild` 时附加 `-checkPrivacyManifest YES`Xcode 15.3+ 支持的隐式 lint),任何缺失会直接 build fail。
#### 8.4.3 上传到 App Store Connect 时的签名验证清单
按以下清单逐项勾选,否则放弃发版:
1. ☐ Xcode 项目根目录下存在 `PrivacyInfo.xcprivacy`(不是 `Resources/` 子目录)
2. ☐ Archive 后 `.ipa` 解包,`PrivacyInfo.xcprivacy` 出现在主 bundle 根
3. ☐ 所有 `Frameworks/` 下的第三方 `.framework` / `.xcframework` 内部存在 `PrivacyInfo.xcprivacy`
4. ☐ App Store Connect 上传后 24h 内查看 **Privacy Manifest Aggregate Report**,确认无 `Missing reason code``Missing data type` 警告
5. ☐ 如有警告 → 找到对应 SDK 升级版本 → 重新 archive 上传
6. ☐ 与 App Store Connect 「应用隐私详情」(Privacy Nutrition Label)字段逐项核对(同源;不一致会被 Apple 人工标红)
> **拒审风险等级**:高。Apple 2024 春起对漏报的 PrivacyManifest 直接拒审(不再警告);本节的 6 项 checklist 必须在每次 minor 版发版前由 release manager 复核一次。
---
## 9. MVP 范围与不做项
### 9.1 MVP(与 [`00_overview.md`](00_overview.md) §7.1 同步,8 周窗口)能交付
| 模块 | 交付物 |
|------|--------|
| Auth | Sign in with Apple + 邮箱登录两条路径 |
| Tab 1 发现 | 公共 feed 分页 + 标签筛选 + 搜索 |
| Tab 2 通知 | 评论 / 点赞 / Remix 三类通知 + 转码完成通知 |
| Tab 3 扫描 | RoomPlan 标准 `RoomCaptureView` + 三步引导 + 质量分 + 端侧脱敏 + 三段式上传 |
| Tab 4 我的房间 | 已发布 / 草稿 / 失败 三个分段控制器 |
| Tab 5 我的 | Profile、配额详情、设置、隐私脱敏策略说明 |
| 详情页 | 静态 360° 缩略图轮播 + 评论 + 点赞 + 跳 Web Remix |
| Realtime | `room:{id}``user:{id}` 两个 channel 订阅 |
| 配额 | 进扫描前预检 + 错误码本地拦截 |
### 9.2 明确不做
-**iOS 端 Remix 编辑器**(统一在 Web 端做,原因:R3F + drei 在 iOS WKWebView 性能足够;做原生 SceneKit Remix 工作量约等于重写一遍 Web 渲染器)
-**AR 内即时换家具预览**(QuickLook 也不接,避免「我以为我换了,实际服务端没收到」的双状态问题)
-**协同编辑**Remix 走 fork 模型)
-**Android / iPad 专属布局**iPad 用 iPhone scaledMVP 不做 split view 适配)
-**Apple Watch / visionOS 端**
-**本地 .glb 渲染**(详情页只展示缩略图,3D 渲染交给 Web)
-**离线模式**(草稿可保留,但浏览必须联网)
-**自训 ML 模型做家具识别**(一律走 RoomPlan 内置 16 类语义)
---
## 10. 风险与开放问题
| # | 风险 / 开放问题 | 当前判断 | 待解答 |
|---|---------------|---------|--------|
| **R-iOS-1** | **RoomPlan 在低光场景失败率高**[`roomplan_accuracy_and_cad_export.md`](../iphone/roomplan_accuracy_and_cad_export.md) §1.3 提到光照影响 ±1–3 cm,且极弱光会直接拒绝开始扫描) | UX 兜底:扫描前自动检测 `AVCaptureDevice.iso`,过高时弹窗建议开灯;仍允许强行扫描 | 是否需要在 App 内置「补光手电」开关?(受发热限制,可能不实用) |
| **R-iOS-2** | **端侧脱敏在 iPhone 12 Pro 上耗时超目标**(§4.3 估算 16 s,超 8 s 预算 2 倍) | 12 Pro 是 RoomPlan 最低支持设备(无 12 Pro 就无 LiDAR),不能放弃;预计提示「正在处理,预计 15 秒」即可 | 是否对 12/12 Pro 默认降级到「仅检测 + 不模糊,把任务转交服务端」?——但这违反 iOS-X1,需要子任务 5 隐私治理章节回答能否破例 |
| **R-iOS-3** | **`.usdz` 重新打包工具链稳定性**:iOS 沙箱无法跑 `usdzconvert` CLI,只能用 `ModelIO` + 手动 zip`ModelIO` 对 USD 写回支持有限 | 备选方案:不重打包,把脱敏后的贴图作为「平行文件」一起 PUT,让 Worker 端做替换合并。代价是 Worker 改造一次 | 是否可接受 Worker 帮忙合并?此决策由子任务 5 与后端方共同确认 |
| **R-iOS-4** | **Universal Link 在国内 Safari 跳转受限**:部分国产浏览器(QQ/微信)不会触发 UL | iOS 端从微信打开链接时,引导用户「点右上角 → 在 Safari 中打开」;这是行业通病不再投入解决 | 是否做微信小程序版的 Remix 编辑入口?P2 再说 |
| **R-iOS-5** | **Realtime channel 在长时间后台时丢事件**WebSocket 在 iOS 后台 30 s 内被杀;BGAppRefreshTask 调度由系统决定,最长可能 1 小时才唤醒一次 | 兜底:每次 App 前台化时主动调一次 `GET /rest/v1/room_versions?id=in.(...)` 补查所有「进行中」版本 | 此「补查」是否会被风控误判为「轮询」?需要与 iOS-X4 契约文字微调(明确:「补查」≠「轮询」) |
---
## 11. 给子任务 5(隐私治理)的契约要点
iOS 端在端侧脱敏管线(§4)、权限文案(§8)、风险 R-iOS-2/R-iOS-3 处与隐私治理章节有强耦合。子任务 5 撰写隐私治理总章时**必须保证**以下要点:
| # | 治理章节必须保证 X | 与本章对应 |
|---|------------------|-----------|
| **P-1** | 明确「端侧脱敏失败 3 次后是否可降级到服务端二次脱敏」的策略——若不允许,则 iOS-X1 维持硬约束;若允许,则需定义服务端二次脱敏 SLA | §4.4 + R-iOS-2 |
| **P-2** | 给出 `redactions[]` 表的保留期与可见性策略(用户能否查看自己上传的人脸 bbox 数据) | §4.1 末尾的 redactions POST |
| **P-3** | 明确「位置标签是否在公开页面展示」——若展示,则需补强 `NSLocationWhenInUseUsageDescription` 文案中的「会被其他用户看到」声明 | §8.1 location 文案 |
| **P-4** | 给出用户「申请删除我所有数据」的端到端流程,iOS 端需要提供入口(设置 → 账号 → 注销账号并删除全部数据) | §1.2 SettingsPage 待加子页 |
| **P-5** | 明确「ATT 何时启用」的触发条件(接入哪种 SDK 必须申请 ATT),iOS 端据此决定是否在某个版本灰度推 ATT 弹窗 | §8.2 |
| **P-6** | 明确「未成年用户保护」策略:是否需要在 iOS 端首次启动时弹年龄确认;CrowdRoom 内容是否在 App Store 标 17+ | 本章未覆盖,留给子任务 5 |
---
## 12. 本章小结
| 关键产出 | 一句话 |
|----------|--------|
| **5 Tab + 22 页面 IA** | 扫描放中间凸起按钮强化采集动作;Remix 编辑全在 Web,iOS 只做发起入口 |
| **3 个核心 Flow** | A 扫描脱敏上传等待发布 / B 浏览跳 Web Remix / C 通知跳详情 |
| **13 项技术栈选型** | SwiftUI + iOS 17 + RoomCaptureView + Vision + CoreImage + supabase-swift + SwiftData |
| **端侧脱敏管线** | RoomPlan 导出 → ModelIO 解包 → Vision 检测 → CIGaussianBlur 蒙版合成 → 重打包,iPhone 15 Pro ≤ 8 s |
| **三段式上传 + 10 条错误码文案** | upload-init → 直传 Storage → upload-complete,背景 URLSession + 断点续传 + 分片 + 电量检查 |
| **Realtime 5 阶段进度 UI** | uploading → queued → transcoding → ready/failed,禁止轮询,后台用 BGAppRefreshTask 兜底 |
| **三步扫描引导 + A/B/C 质量分** | 复用 iPhone 简易方案的扫描节奏,规则引擎打分,C 档允许上传但排序降权 |
| **6 条治理契约移交** | 端侧脱敏破例、redactions 保留期、位置可见性、删除流程、ATT、未成年保护 |
读完本章你应能:
- ✅ 给 iOS 工程师一份 8 周内可交付的功能清单
- ✅ 评审端侧脱敏方案是否真的能落到 iPhone 12 Pro 上
- ✅ 接手子任务 5 时知道隐私治理章节要回答哪 6 个问题
---
**章节版本**v0.1 · 草案
**关键收获**iOS 端是 CrowdRoom 的**唯一采集入口**与**最重的隐私防线**——5 条硬契约(iOS-X1~X5)全部落到具体章节;3D 渲染、Remix 编辑、转码、审核一概甩给 Web 与服务端,保持 iOS 端「轻、快、合规」三件套。