外观
扫码相机
扫码从相机取帧到业务跳转的全流程,按时间线排列。
相机架构(CameraContainer、Tab 委派、分析器注册)参见
/standards/camera.md。 相册扫码走ActivityResultContracts.GetContent,调ScanManager.scanFromUri()。
入口与模式
入口
- 底部导航 → 点击"扫码" Tab →
CameraScreen(selectedTab="scan") - 外部指定 →
CameraScreen(defaultTab="scan"),强制扫码 Tab - Tab 恢复 → 读取
CameraMemoryRepository.lastUsedTab,上次用扫码则回扫码
两种扫描模式
| 模式 | 开关位置 | 行为 |
|---|---|---|
| 单次扫描(默认) | 右上角"批量扫码"关闭 | 3帧防抖 → 跳转结果页 + 声音提示 |
| 批量扫描 | 右上角"批量扫码"开启 | 左侧累积浮层,列表显示编号+类型,逐条可跳转,支持全部保存,扫码后不跳转 |
- 打开 App 默认为单次扫描
- 批量扫描状态随页面停留保持:从其他页面切回扫码界面恢复到离开时的模式
全流程模拟
场景一:单次扫描(默认)
- 打开 App → 进入扫码相机界面(默认单次扫描)
- 扫码 → CameraX 帧到达 → ZXing 识别二维码
- 3 帧内容一致(防抖)→ 触发导航 → 跳转结果页
- 同时声音提示
场景二:批量扫描
开启与扫码
- 在扫码相机界面 → 点击右上角"批量扫码"按钮 → 开启批量扫描
- 扫描二维码 → CameraX 帧到达 → ZXing 识别
- 跳过 3 帧防抖 + 2 秒冷却门控,直接追加到累积列表(
mergeBatchResults()整体distinct()去重) - 左侧弹出浮层,显示"已扫描 N 个码/50" + 每条记录显示编号+类型标签+内容摘要
- 同时声音提示
浮层操作
| 操作 | 行为 |
|---|---|
浮层中点击某条记录的 > 按钮 | 保存该条记录 + 导航到结果页;返回后浮层仍在,其余记录保留 |
| 点击浮层关闭按钮 / 点击界面空白关闭浮层 | 浮层关闭,不清空累积列表 |
| 点击浮层"全部保存"按钮 | 保存所有记录的码(逐条存 Room)+ 导航到结果页显示最新一条 |
| 点击右上角切换回单次扫描 | 浮层关闭,累积列表清空 |
状态连续性
- 关闭浮层后继续扫描:仍处于批量扫描模式 → 继续扫 → 继续弹出浮层(从 0 开始累积)
- 切换到其他页面再回来:保持离开时的模式
- 批量扫描模式 → 继续扫 → 继续弹出浮层(累积列表清空,从 0 开始累积)
- 单次扫描模式 → 扫到码 → 直接导航跳转
- 点击右上角切换到单次扫描:立刻生效 → 扫到码走单次行为
- 点击右上角切换到批量扫描:立刻生效 → 扫到码走批量行为,累积列表重新开始
- App 杀进程重启:从
CameraMemoryRepository恢复上次模式,浮层重新累积
预加载阶段(P0)
进入扫码 Tab 时,由 CameraScreen 负责恢复缩放值、初始化分析器。
- 触发时机:
selectedTab == "scan"时SideEffect同步注册analyzer到CameraContainer - 缩放恢复: 读取
CameraMemoryRepository.scanZoomRatio,非 1.0f 则设置回CameraControl - 分析器创建: 在
ScanModeOverlay的remember中构建匿名ImageAnalysis.Analyzer,内部使用专用ExecutorService线程池执行解码 - 分析器注册: 使用
SideEffect(非LaunchedEffect)同步注册,避免CameraContainer绑定时竞争 - 线程模型: 分析器回调在 CameraX 线程 → 解码在专用 executor 线程 → 结果回调通过
Handler.post返回主线程
全流程时间线
T0:相机帧到达
- 动作: CameraX
ImageAnalysis每帧回调 →analyzer的imageProxy参数 - 输入:
android.media.Image+rotationDegrees - 处理: 使用专用线程池
ExecutorService执行图像分析,imageProxy.close()在finally块中确保释放 - 帧级门控:
ScanManager.isProcessing @Volatile→ 正在处理上帧则跳过当前帧(防 ZXing 过载) - 线程模型: 分析器回调在 CameraX 线程 → 解码在专用 executor 线程 → 结果回调在主线程(通过
Handler.post)
T1:ZXing 条码识别
- 动作:
scanManager.scanFromMediaImageMulti(image, rotationDegrees)kotlinmediaImageToRgb(image, rotationDegrees) → decodePixels(pixels, width, height) - 产物:
List<String>— 当前帧识别到的所有二维码内容列表(Result.text,非空) - 耗时: ~50~200ms(取决于帧大小和设备性能)
- 支持格式:
MultiFormatReader(QR 码为主,兼容其他一维/二维条码) - 错误处理: 异常捕获 → 返回
emptyList() - 门控释放: 无论成功失败都释放
isProcessing = false
T2:模式判断 + 内容处理
batchScanEnabled == true→ 进入批量扫描逻辑(跳过 3 帧防抖)→ 直接到 T3batchScanEnabled == false→ 进入单次扫描逻辑 → 走 3 帧防抖
3 帧防抖(单次扫描)
识别到结果 → 取 firstResult 追加到 lastFrames,保留最后 3 帧
lastFrames.size == 3 && 3 帧 distinct == 1 && firstResult != null → 触发稳定识别
无结果 → 清空 lastFrames(避免站在原地时旧缓存触发导航)T3:模式分派
单次扫描
barcodeResult = firstResult→ 触发LaunchedEffect(barcodeResult)→ 保存记录 + 导航到结果页triggerFeedback = true→ 声音提示
批量扫描
- 所有识别到的码(经 2 秒冷却门控过滤)追加到
scannedCodes累积列表,mergeBatchResults()整体distinct()去重 - 来源:相机实时帧 + 相册多选图片
- 上限 50 条:累积达到 50 条后新码追加到末尾,移除最旧一条(FIFO 队列)
- 重置
lastFrames = listOf()(防抖缓存清空) triggerFeedback = true→ 声音提示- 不跳转导航
T4:扫码反馈(声音)
- 触发:
triggerFeedback = true→LaunchedEffect - 声音: 用
MediaPlayer播Settings.System.DEFAULT_NOTIFICATION_URI,静音模式跳过。有异常保护 - 状态重置:
triggerFeedback = false
T5:保存记录 + 导航
- 触发:
LaunchedEffect(barcodeResult)非空 → 回调onScanResult(content, scanResult) - 在 CameraScreen 中:
viewModel.saveScanRecord(content)— 写入本地 Room(ScanRecordRepository.createRecord())onNavigateToScanResult(content, scanResult)— 导航到结果页
- barcodeResult 重置:
barcodeResult = null(防止重复导航)
T6:结果页展示(ScanResultScreen)
- 路由:
NavRoutes.SCAN_RESULT(工厂方法NavRoutes.scanResult(...)) - 数据传递: 通过
FormStateManager(crossPageSeedScan()+crossPageSeedId()) - 展示内容:
- 根据
ScanType生成主按钮 + 可选副按钮 - 显示二维码生成图(ZXing
QRCodeWriter,RGB_565,200dp) - 最近 50 条扫描记录列表(可点击切换显示)
- 根据
- 底部操作栏: 重新扫描、复制、分享
- 历史模式: 若
initialContent为空,自动显示最新一条扫描记录
批量扫描左侧浮层
触发: batchScanEnabled && scannedCodes.isNotEmpty()
UI 结构
左侧浮层 Surface(12dp 圆角,4dp 阴影,alpha=0.7)
└── Column
├── 操作按钮行:批量扫描/单次扫描切换 + 全部保存 + 清除 + 关闭
├── "已扫描 N 个 / 50"
└── 可滚动列表(倒序,最新在前)
├── 1 [类型标签] 内容摘要 ▸
├── 2 [类型标签] 内容摘要 ▸
└── ...- 列表项:编号 + 类型标签(cyan 底色圆角小标签,支持所有扫码类型) + 内容摘要 +
>跳转按钮 - 点击
>按钮 → 保存该条记录 + 导航到结果页 - 整行可点击(clickable)
上限规则
- 累积列表上限 50 条
- 扫描到第 50 条后:新码追加到末尾,移除最旧一条(队列 FIFO)
- 显示格式:"已扫描 N 个 / 50"
浮层生命周期
| 事件 | 浮层行为 | 累积列表 |
|---|---|---|
| 开启批量扫描后扫到第 1 个码 | 弹出 | 追加 |
| 继续扫到新码 | 保持弹出,列表更新 | 追加(去重) |
点击单条 > 按钮跳转 | 保持弹出,其余记录保留 | 不清空 |
| 点击"全部保存"跳转 | 保存所有码到 Room,导航到结果页(最新一条) | 不清空 |
| 点击关闭按钮 | 关闭 | 清空,继续扫码后重新从 0 累积 |
| 关闭后扫到新码 | 重新弹出 | 从 0 开始累积 |
| 切换到单次扫描 | 关闭 | 清空 |
| 切到水印 Tab 再回来 | 关闭(组件重建,累积列表清空) | 清空,模式保持 |
| App 杀进程重启 | 无(需要重新累积) | 清空 |
相册扫码
入口: 扫码 Tab 底部操作栏 → 相册按钮 → albumLauncher(ActivityResultContracts.GetContent("image/*"))
单次扫描模式下
- 用户选择一张图片 →
scanManager.scanFromUri(uri) - 降采样 Bitmap(长边 ≤ 1280×720)→
scanFromBitmap()识别 - 识别成功 → 保存记录 → 导航到结果页
- 未识别到 → 无反馈(静默失败)
批量扫描模式下
- 相册入口支持多选图片
- 用户选择多张图片 → 对每张运行
scanManager.scanFromUri()识别 - 识别到的码(去重)追加到
scannedCodes累积列表,浮层显示,整体声音提示 1 次(非逐张反馈) - 未识别到的图片跳过(静默)
- 不出导航页
- 未识别到的图片跳过(静默)
- 不出导航页
底部操作栏(ScanBottomBar)
| 按钮 | 图标 | 点击行为 |
|---|---|---|
| 相册 | PhotoAlbum | 打开系统相册选图 → albumLauncher.launch("image/*") |
| 添加 | Add | 导航到创建页 onNavigateToQrCreate(手动输入内容生成二维码) |
| 列表 | List | 导航到二维码列表 onNavigateToQrList |
| 扫描记录 | History | 导航到 SCAN_HISTORY 路由(独立的历史记录页面,按天分组 + 搜索 + 删除) |
界面状态
TopBar 按钮
| 按钮 | 图标 | 行为 |
|---|---|---|
| 闪光灯 | FlashOn/FlashOff | 切换 cameraControl.enableTorch(),前置镜头禁用 |
| 翻转 | FlipCameraAndroid | 切换前后摄 → 自动关闪光灯 + 重置缩放 |
| 重新定位 | MyLocation | watermarkViewModel.reloadLocation()(扫码 Tab 可保留但无实际作用) |
| 设置 | Settings | 导航到相机设置页 |
扫描框叠加 UI
- 200dp 扫描框(Canvas 绘制四角 L 形线条)
- 水平扫描线动画(2 秒无限循环,
LinearEasing,RepeatMode.Restart) - 批量扫描开关按钮(右上角圆角背景,带图标 + 文字):
- 关闭状态:
Cancel图标 + "批量扫描" 文字 → 单次扫描 - 开启状态:
CheckCircle图标 + "批量扫描" 文字 → 批量扫描
- 关闭状态:
缩放控制
- 手势缩放(
detectTransformGestures,1x~8x) ZoomControlBar(预览画面底部半透明倍数条)- 缩放值持久化到
CameraMemoryRepository.scanZoomRatio(扫码模式下保存,Tap 切换时清空) - Tab 切换/翻转镜头 → 缩放重置为 1x
设置页(ScanSettingsScreen)
| 设置项 | 类型 | 值域 | 存储 |
|---|---|---|---|
| 扫码分辨率 | 下拉 | 640x480 / 1280x720 / 1920x1080 | CameraMemoryRepository |
| 识别成功声音 | 开关 | true/false | CameraMemoryRepository |
| 识别成功震动 | 开关 | true/false | CameraMemoryRepository |
操作反馈:修改任意设置项后,屏幕中央弹出 1 秒 Toast 提示(如"扫码分辨率已设置为 1280x720"),提升用户感知。 恢复默认:点击右上角"默认"按钮,一键恢复所有设置为默认值,显示"已恢复默认设置"提示。
(批量扫描开关已在右上角,设置页无重复项)
组件状态
| 变量 | 类型 | 位置 | 说明 |
|---|---|---|---|
ScanDebouncer.lastFrames | List<String?> | scan/ScanDebouncer.kt | 3 帧防抖缓存(仅单次扫描使用) |
barcodeResult | String? | ScanModeOverlay | 稳定识别结果 → 触发导航(仅单次扫描使用) |
scannedCodes | List<String> | ScanModeOverlay | 批量扫描累积列表 |
triggerFeedback | Boolean | ScanModeOverlay | 声音触发标志 |
scanBatchAllowed | Boolean | CameraMemoryRepository | 允许批量扫码开关(持久化保存,控制预览界面是否可切换到批量模式) |
scanBatchEnabled | Boolean | CameraMemoryRepository | 批量扫描模式开关(当前会话有效,冷启动重置为 false) |
权限语义(RBAC,接入)
扫码记录属 APP 域个人能力(方案 §5.4 APP 域
scan_record:create/read)。
| 操作 | 权限点 | 说明 |
|---|---|---|
| 记录扫码 | scan_record:create | 扫码动作本身(USER 预置) |
| 记录列表/统计 | scan_record:read | 个人扫码记录(Service 按 userId 隔离) |
| 编辑/删除记录 | scan_record:create | 本人记录管理(Service 属主校验) |
关键行为:扫码记录按用户隔离(scan_records.user_id),RBAC 只控制「能否操作」,数据范围由 Service 层 owner 过滤(方案 §2.3 非目标:不做数据级行权限)。
用户名片码(新增,联系人聊天模块)
完整设计见专用文档
/designs/messaging/contacts-chat.md(§2.8 场景 H / §4.8 名片码)。
- 名片码内容:
user:{16位随机码}(users.chat_qr_code,可重置,旧码作废;不用自增 id 防枚举) - 解析在客户端:
feature/camera/ScanContentParser.kt新增ScanType.USER+user:前缀分支(与 activity:/check-in:/pass:/org-join: 并列) - 路由:
ScanResultScreen对 USER 类型显示「添加好友」主按钮 →onNavigateToChatUser回调 → 进入聊天模块(AppNavHost 已接线)→ 聊天内chat/user/{code}页面调GET /api/contacts/by-qr-code/{code}反查用户 → 展示资料 + 添加好友 - 群二维码
group:{id}前缀:二期群聊时接入(同 ScanType 机制)