--- title: "CrowdRoom · iOS App 设计(v0.2)" date: 2026-05-20 draft: false tags: ["CrowdRoom", "众包", "3D 重建", "导航", "隐私", "iOS"] categories: ["worldmodel"] --- # CrowdRoom · iOS App 设计(v0.2) > **版本**:v0.2(2026-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-X3(JSON 原样)、iOS-X4(Realtime 订阅)、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 ms;Revision 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 处理重连与心跳 | | 本地缓存 | **SwiftData(iOS 17)** | Core Data / Realm | 草稿、未完成上传任务、Feed 分页缓存;SwiftData 与 SwiftUI 双向绑定省胶水代码 | | 性能/崩溃监控 | **MetricKit(系统)+ Sentry iOS SDK** | Firebase Crashlytics | MetricKit 拿 RoomPlan 期 GPU/热量数据;Sentry 与 Supabase 后端 Sentry 共享 issue 视图 | | 深链接 | **Universal Links(apple-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 PUT,5 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 转码进度 UI(iOS-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`,扫描 5–10 分钟 | | **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% | 70–90% | < 70% | | 家具识别数 | ≥ 5 件 | 2–4 件 | < 2 件 | | 房间闭合 | ✅ | ✅ | ❌ 拓扑不闭合 | | 扫描时长 | 5–10 min | 3–5 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 ATT(App 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-6(Apple 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 scaled,MVP 不做 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 端「轻、快、合规」三件套。