40 KiB
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§4 P-2 / P-3 与 §6.2 C-P2-6。
本章承接
00_overview.md§4 架构图、01_data_schema.md的 9 张表与layer_manifest.jsonschema、以及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的扫描引导思想与plans/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§4),iOS 端只提供「在浏览器中打开并 Remix」的深链接跳转,不放独立 Tab。
1.2 页面跳转图
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§4 P-2 「owner 可在『我的房间 → 隐私详情』查看每个 bbox 缩略图并对漏检/误检发起『申请重做』工单」。AccountDelete节点为09_privacy.md§4 P-4 三阶段注销流程的 iOS 入口(路径Tab5 我的 → 设置 → 注销账号并删除全部数据),调用02_api_contract.md§2 E-18account-delete。
2. 核心用户流程(Critical Flows)
2.1 Flow A:扫描 → 端侧脱敏 → 三段式上传 → 等待转码 → 发布
✅ 契约 iOS-X1(端侧人脸检测 + 高斯模糊)、iOS-X2(三段式上传)、iOS-X3(JSON 原样)、iOS-X4(Realtime 订阅)、iOS-X5(预查配额)全部出现在此流程
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。
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:通知到达 → 跳详情
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 ≥ 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 管线流程
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 伪代码骨架
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 顺序图
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 §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 订阅代码骨架
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§3.1 的「准备 / 慢速移动 / 覆盖检查」三阶段思想,但裁剪为消费级三步引导。精度边界引用plans/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.mdE-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§4 P-3 决策追加。NSLocationWhenInUseUsageDescription文案在 v0.2 起必须显式包含「城市标签会显示在公开房间页」与「精确 GPS 不会上传」两句——前者满足 PR-3 用户可控的「授权时即告知公开后果」,后者满足 PR-2 最小数据采集的「告知不持有原始坐标」。该文案变更同时对齐01_data_schema.md§3.2rooms.location_city(=location_label别名)字段语义与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 首次启动权限弹窗顺序
- 进入扫描页 → 申请
Camera(必要) - 扫描完成后想保存预览图到相册 → 申请
PhotoLibraryAdd(按需) - 上传表单页填写「城市」标签时 → 申请
LocationWhenInUse(按需,可跳过)
✅ 契约 iOS-X5 衍生:进入扫描页时同时调
GET /functions/v1/quota,与相机权限请求并行发起,弹窗与配额预检不互相阻塞。
8.4 PrivacyManifest(PrivacyInfo.xcprivacy)
🔄 v0.2 — 回写自 G-4:本节根据
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 时的签名验证清单
按以下清单逐项勾选,否则放弃发版:
- ☐ Xcode 项目根目录下存在
PrivacyInfo.xcprivacy(不是Resources/子目录) - ☐ Archive 后
.ipa解包,PrivacyInfo.xcprivacy出现在主 bundle 根 - ☐ 所有
Frameworks/下的第三方.framework/.xcframework内部存在PrivacyInfo.xcprivacy - ☐ App Store Connect 上传后 24h 内查看 Privacy Manifest Aggregate Report,确认无
Missing reason code与Missing data type警告 - ☐ 如有警告 → 找到对应 SDK 升级版本 → 重新 archive 上传
- ☐ 与 App Store Connect 「应用隐私详情」(Privacy Nutrition Label)字段逐项核对(同源;不一致会被 Apple 人工标红)
拒审风险等级:高。Apple 2024 春起对漏报的 PrivacyManifest 直接拒审(不再警告);本节的 6 项 checklist 必须在每次 minor 版发版前由 release manager 复核一次。
9. MVP 范围与不做项
9.1 MVP(与 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 §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 端「轻、快、合规」三件套。