Skip to content

扫码相机

扫码从相机取帧到业务跳转的全流程,按时间线排列。


相机架构(CameraContainer、Tab 委派、分析器注册)参见 /standards/camera.md。 相册扫码走 ActivityResultContracts.GetContent,调 ScanManager.scanFromUri()

入口与模式

入口

  • 底部导航 → 点击"扫码" Tab → CameraScreen(selectedTab="scan")
  • 外部指定CameraScreen(defaultTab="scan"),强制扫码 Tab
  • Tab 恢复 → 读取 CameraMemoryRepository.lastUsedTab,上次用扫码则回扫码

两种扫描模式

模式开关位置行为
单次扫描(默认)右上角"批量扫码"关闭3帧防抖 → 跳转结果页 + 声音提示
批量扫描右上角"批量扫码"开启左侧累积浮层,列表显示编号+类型,逐条可跳转,支持全部保存,扫码后不跳转
  • 打开 App 默认为单次扫描
  • 批量扫描状态随页面停留保持:从其他页面切回扫码界面恢复到离开时的模式

全流程模拟

场景一:单次扫描(默认)

  1. 打开 App → 进入扫码相机界面(默认单次扫描)
  2. 扫码 → CameraX 帧到达 → ZXing 识别二维码
  3. 3 帧内容一致(防抖)→ 触发导航 → 跳转结果页
  4. 同时声音提示

场景二:批量扫描

开启与扫码

  1. 在扫码相机界面 → 点击右上角"批量扫码"按钮 → 开启批量扫描
  2. 扫描二维码 → CameraX 帧到达 → ZXing 识别
  3. 跳过 3 帧防抖 + 2 秒冷却门控,直接追加到累积列表(mergeBatchResults() 整体 distinct() 去重)
  4. 左侧弹出浮层,显示"已扫描 N 个码/50" + 每条记录显示编号+类型标签+内容摘要
  5. 同时声音提示

浮层操作

操作行为
浮层中点击某条记录的 > 按钮保存该条记录 + 导航到结果页;返回后浮层仍在,其余记录保留
点击浮层关闭按钮 / 点击界面空白关闭浮层浮层关闭,不清空累积列表
点击浮层"全部保存"按钮保存所有记录的码(逐条存 Room)+ 导航到结果页显示最新一条
点击右上角切换回单次扫描浮层关闭,累积列表清空

状态连续性

  • 关闭浮层后继续扫描:仍处于批量扫描模式 → 继续扫 → 继续弹出浮层(从 0 开始累积)
  • 切换到其他页面再回来:保持离开时的模式
    • 批量扫描模式 → 继续扫 → 继续弹出浮层(累积列表清空,从 0 开始累积)
    • 单次扫描模式 → 扫到码 → 直接导航跳转
  • 点击右上角切换到单次扫描:立刻生效 → 扫到码走单次行为
  • 点击右上角切换到批量扫描:立刻生效 → 扫到码走批量行为,累积列表重新开始
  • App 杀进程重启:从 CameraMemoryRepository 恢复上次模式,浮层重新累积

预加载阶段(P0)

进入扫码 Tab 时,由 CameraScreen 负责恢复缩放值、初始化分析器。

  • 触发时机: selectedTab == "scan"SideEffect 同步注册 analyzerCameraContainer
  • 缩放恢复: 读取 CameraMemoryRepository.scanZoomRatio,非 1.0f 则设置回 CameraControl
  • 分析器创建: 在 ScanModeOverlayremember 中构建匿名 ImageAnalysis.Analyzer,内部使用专用 ExecutorService 线程池执行解码
  • 分析器注册: 使用 SideEffect(非 LaunchedEffect)同步注册,避免 CameraContainer 绑定时竞争
  • 线程模型: 分析器回调在 CameraX 线程 → 解码在专用 executor 线程 → 结果回调通过 Handler.post 返回主线程

全流程时间线

T0:相机帧到达

  • 动作: CameraX ImageAnalysis 每帧回调 → analyzerimageProxy 参数
  • 输入: android.media.Image + rotationDegrees
  • 处理: 使用专用线程池 ExecutorService 执行图像分析,imageProxy.close()finally 块中确保释放
  • 帧级门控: ScanManager.isProcessing @Volatile → 正在处理上帧则跳过当前帧(防 ZXing 过载)
  • 线程模型: 分析器回调在 CameraX 线程 → 解码在专用 executor 线程 → 结果回调在主线程(通过 Handler.post

T1:ZXing 条码识别

  • 动作: scanManager.scanFromMediaImageMulti(image, rotationDegrees)
    kotlin
    mediaImageToRgb(image, rotationDegrees) → decodePixels(pixels, width, height)
  • 产物: List<String> — 当前帧识别到的所有二维码内容列表(Result.text,非空)
  • 耗时: ~50~200ms(取决于帧大小和设备性能)
  • 支持格式: MultiFormatReader(QR 码为主,兼容其他一维/二维条码)
  • 错误处理: 异常捕获 → 返回 emptyList()
  • 门控释放: 无论成功失败都释放 isProcessing = false

T2:模式判断 + 内容处理

  • batchScanEnabled == true → 进入批量扫描逻辑(跳过 3 帧防抖)→ 直接到 T3
  • batchScanEnabled == 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 = trueLaunchedEffect
  • 声音: 用 MediaPlayerSettings.System.DEFAULT_NOTIFICATION_URI,静音模式跳过。有异常保护
  • 状态重置: triggerFeedback = false

T5:保存记录 + 导航

  • 触发: LaunchedEffect(barcodeResult) 非空 → 回调 onScanResult(content, scanResult)
  • 在 CameraScreen 中:
    1. viewModel.saveScanRecord(content) — 写入本地 Room(ScanRecordRepository.createRecord()
    2. onNavigateToScanResult(content, scanResult) — 导航到结果页
  • barcodeResult 重置: barcodeResult = null(防止重复导航)

T6:结果页展示(ScanResultScreen)

  • 路由: NavRoutes.SCAN_RESULT(工厂方法 NavRoutes.scanResult(...)
  • 数据传递: 通过 FormStateManagercrossPageSeedScan() + 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 底部操作栏 → 相册按钮 → albumLauncherActivityResultContracts.GetContent("image/*")

单次扫描模式下

  1. 用户选择一张图片 → scanManager.scanFromUri(uri)
  2. 降采样 Bitmap(长边 ≤ 1280×720)→ scanFromBitmap() 识别
  3. 识别成功 → 保存记录 → 导航到结果页
  4. 未识别到 → 无反馈(静默失败)

批量扫描模式下

  1. 相册入口支持多选图片
  2. 用户选择多张图片 → 对每张运行 scanManager.scanFromUri() 识别
  3. 识别到的码(去重)追加到 scannedCodes 累积列表,浮层显示,整体声音提示 1 次(非逐张反馈)
  4. 未识别到的图片跳过(静默)
  5. 不出导航页
  6. 未识别到的图片跳过(静默)
  7. 不出导航页

底部操作栏(ScanBottomBar)

按钮图标点击行为
相册PhotoAlbum打开系统相册选图 → albumLauncher.launch("image/*")
添加Add导航到创建页 onNavigateToQrCreate(手动输入内容生成二维码)
列表List导航到二维码列表 onNavigateToQrList
扫描记录History导航到 SCAN_HISTORY 路由(独立的历史记录页面,按天分组 + 搜索 + 删除)

界面状态

TopBar 按钮

按钮图标行为
闪光灯FlashOn/FlashOff切换 cameraControl.enableTorch(),前置镜头禁用
翻转FlipCameraAndroid切换前后摄 → 自动关闪光灯 + 重置缩放
重新定位MyLocationwatermarkViewModel.reloadLocation()(扫码 Tab 可保留但无实际作用)
设置Settings导航到相机设置页

扫描框叠加 UI

  • 200dp 扫描框(Canvas 绘制四角 L 形线条)
  • 水平扫描线动画(2 秒无限循环,LinearEasingRepeatMode.Restart
  • 批量扫描开关按钮(右上角圆角背景,带图标 + 文字):
    • 关闭状态:Cancel 图标 + "批量扫描" 文字 → 单次扫描
    • 开启状态:CheckCircle 图标 + "批量扫描" 文字 → 批量扫描

缩放控制

  • 手势缩放(detectTransformGestures,1x~8x)
  • ZoomControlBar(预览画面底部半透明倍数条)
  • 缩放值持久化到 CameraMemoryRepository.scanZoomRatio(扫码模式下保存,Tap 切换时清空)
  • Tab 切换/翻转镜头 → 缩放重置为 1x

设置页(ScanSettingsScreen)

设置项类型值域存储
扫码分辨率下拉640x480 / 1280x720 / 1920x1080CameraMemoryRepository
识别成功声音开关true/falseCameraMemoryRepository
识别成功震动开关true/falseCameraMemoryRepository

操作反馈:修改任意设置项后,屏幕中央弹出 1 秒 Toast 提示(如"扫码分辨率已设置为 1280x720"),提升用户感知。 恢复默认:点击右上角"默认"按钮,一键恢复所有设置为默认值,显示"已恢复默认设置"提示。

(批量扫描开关已在右上角,设置页无重复项)


组件状态

变量类型位置说明
ScanDebouncer.lastFramesList<String?>scan/ScanDebouncer.kt3 帧防抖缓存(仅单次扫描使用)
barcodeResultString?ScanModeOverlay稳定识别结果 → 触发导航(仅单次扫描使用)
scannedCodesList<String>ScanModeOverlay批量扫描累积列表
triggerFeedbackBooleanScanModeOverlay声音触发标志
scanBatchAllowedBooleanCameraMemoryRepository允许批量扫码开关(持久化保存,控制预览界面是否可切换到批量模式)
scanBatchEnabledBooleanCameraMemoryRepository批量扫描模式开关(当前会话有效,冷启动重置为 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 机制)