外观
水印相机
拍照后的全流程文件处理,按时间线排列。
定位数据模型、坐标系规范参见
/standards/location.md。相机架构(CameraContainer、Tab 委派)参见/standards/camera.md。
预加载阶段(P0)
进入水印 Tab 时,清空旧缓存后重新异步预加载定位和天气,不阻塞 UI。
- 触发时机: 切换到水印 Tab → 若已有定位权限则立即预加载,否则等待权限弹窗授权后自动触发
- 权限已授予(
LaunchedEffect(Unit)):直接调用clearLocationWeatherCache()+prefetchLocationWeather(context) - 权限未授予:仅弹出权限对话框,授权后通过
LaunchedEffect(hasLocationPermission)自动触发预加载
- 权限已授予(
- 动作:
- 清空最近一次定位/天气结果(
prefetchedLocation/prefetchedWeather清空) - 启动协程异步获取定位(总是发起新请求)
- 等定位就绪后异步获取天气(若
cachedWeather中已有同坐标的天气结果则复用,否则发起请求)
- 清空最近一次定位/天气结果(
- 产物预加载到内存:
prefetchedLocation:LocationResult?(含经纬度、地址、海拔)prefetchedWeather:WeatherProvider.WeatherResult?(含天气文字,来源:高德天气 API)cachedWeather: /cachedWeatherLat/cachedWeatherLng(session 级天气缓存,同坐标复用,不重复请求)
- 有效期: 定位结果
prefetchedLocation仅当前 session 内有效,切换 Tab 再回来时重新获取。天气结果cachedWeather在当前 session 内持续有效,同坐标不重复请求。 - 幂等: 同一 session 内重复调用不发起请求(仅进入 Tab 时触发一次)
- 手动刷新: 点击顶部操作栏定位按钮 →
WatermarkViewModel.reloadLocation()→ 清缓存 → 重新请求 → 更新预览 - 定位策略:
AmapLocationProvider严格按照官方文档使用setOnceLocation(true)+setOnceLocationLatest(true)单次定位,返回最近 3s 内精度最高的结果。超时 25s 后返回 null。无自定义重试循环。 - 失败处理: 超时后预览展示"定位失败"文案,底部显示红色"重新定位"按钮供用户手动重试。
- 坐标系: 国内始终返回 GCJ-02(高德类型坐标),与高德地图一致。无需手动坐标转换。
- 地址兜底: AMap 逆地理编码失败时,用 Android 原生
Geocoder.getFromLocation()获取中文地址。
转圈启动
- 动作: 按下按钮 →
setCapturingImmediate()立即设置isCapturing=true - UI 效果: 按钮尚未抬起即显示转圈,消除感知延迟
- 最小时长: 拍照完成后 delay 补足至 500ms,确保转圈不会一闪而过
全流程时间线
T0:按快门
- 动作: 点击拍照按钮 →
takePhoto()(CameraContainer) - 产物:
context.cacheDir/capture_{ts}.jpg - 大小: 使用用户设置的拍照质量(水印设置 → 拍照质量),可选「高质量」「标准」「低质量」。CameraX 以传感器最佳分辨率捕获完整画面,不再裁剪。JPEG 文件约 0.5~3MB(取决于分辨率和画面复杂度)
- 源文件:
CameraScreen.kt—bottomBarContent中的onCapture+CameraContainer.takePhoto() - 清理: 读出 bytes 后
delete(),原始文件不保留
T1:读取原始 JPEG bytes
- 动作:
photoFile.readBytes()→ByteArray - 产物: 内存中的原始 JPEG 数据
- 大小: 原始 JPEG 文件大小,约 1~6MB
- 清理:
photoFile.delete()
T2:传入 WatermarkViewModel
- 动作:
watermarkViewModel.onPhotoCaptured(context, bytes, captureStartMs) - 产物: ViewModel 协程启动,首先 delay 补足最小 500ms 转圈时间,然后进入统一处理流程
- 源文件:
WatermarkModeScreen.kt—onCapture()+takePhoto()(CameraContainer),WatermarkViewModel.kt—onPhotoCaptured()
T3:降采样解码(定位/天气从缓存读,不阻塞等待)
- 动作:
- 从
prefetchedLocation/prefetchedWeather/cachedWeather中读取已缓存的数据(可能为 null) BitmapFactory.Options(inJustDecodeBounds)先读原始尺寸- 通过
PhotoQualityMapper将用户设置的质量等级(如"标准")映射为目标分辨率(如 1080×1920) BitmapFactory.Options(inJustDecodeBounds)先读原始尺寸- 按目标分辨率长边计算
inSampleSize,降采样解码为 Bitmap fixOrientationAndScale(bitmap, displayRotation, targetResolution)按物理方向修正(用rememberScreenRotation()获取的物理方向,不再读 JPEG EXIF — CameraX 未设 setTargetRotation,EXIF 固定为竖屏方向不可信)- 若长边 > 目标分辨率长边,等比例缩放到目标分辨率长边
- 从
- 产物:
- Bitmap(内存),尺寸随质量等级变化:高质量 ~4032×3024(~13MB),标准 ~1920×1080(~2.5~8MB),低质量 ~1280×720(~1.5~3MB)
- 定位/天气结果(取缓存已有值,可能为 null)
- 源文件:
WatermarkViewModel.kt—onPhotoCaptured()/decodeSampled()/parseResolution(),PhotoQualityMapper.kt,CameraScreen.kt—rememberScreenRotation()
T4:叠加水印
- 动作:
WatermarkOverlay.drawWatermark(context, bitmap, watermarkData) - 产物: 新 Bitmap,尺寸同原图(≤ 用户设置的目标分辨率,如 1080×1920),加了文字和防伪水印
- 大小: ~8MB(内存)
- 绘制内容:
- 居中: "现场拍照" 图片(可选)
- 左下角: 时间/经纬度/地址/天气/海拔/备注
- 右下角: 50×50 正方形区域内放置防伪水印图片(watermark_anti_fake.png), 原始 400×400 等比缩放至 50×50 平铺,透明度 20%
- 清理:
bitmap.recycle()(原始无印图在 drawWatermark 后立即回收)
T5:压缩 + 保存
- 动作:
watermarkedBitmap.compress(JPEG, ALBUM_PHOTO.quality, out) - 产物:
watermark/compressed_{ts}.jpg - 大小: 约 50~200KB
- 清理:
BitmapPool.recycle(watermarkedBitmap)(有水印的内存 Bitmap 归还到复用池,池满时 native recycle) - 兜底: 若 >
ALBUM_PHOTO.maxSizeKB→ImageCompressor.furtherCompress(),当前流程通常不进入
T6:重命名为最终文件
- 动作:
compressed_{ts}.jpg直接重命名为{timestamp}.jpg - 产物:
watermark/{timestamp}.jpg - 大小: 最终压缩后的大小,目标 ≤
maxSizeKB
T7:写入 Room 数据库
- 实体:
AlbumPhotoEntity - 写入内容:
| 字段 | 值 |
|---|---|
localPath | watermark/{timestamp}.jpg 的绝对路径 |
url | ""(空,等待同步后填充) |
qrCodeId | null(不关联二维码) |
latitude, longitude | 定位结果 |
locationDesc | 地址 |
altitude | 海拔 |
weather | 天气文字 |
remark | 用户输入的备注 |
createdAt | System.currentTimeMillis() |
syncStatus | "PendingCreate" |
T8:写入系统相册(副本)
- 动作: MediaStore API 写入
- 产物:
DCIM/CodeNote/{timestamp}.jpg - 大小: 与 T6 最终文件相同(拷贝)
- 注意: 这是副本,不是移动,watermark/ 中的原文件不变
文件/内存生命周期速览
| 阶段 | 文件/对象 | 何时创建 | 何时销毁 | 当前大小 |
|---|---|---|---|---|
| T0 | cache/capture_{ts}.jpg | 拍照完成 | T1 读完 delete | 1~6MB(原始传感器分辨率) |
| T1 | ByteArray(内存) | 读原始文件 | GC(T3 解码后) | 1~6MB |
| T3 | 原始 Bitmap(内存) | 降采样解码+EXIF修正+缩放 | T4 后 recycle | ≤ 2.5~8MB |
| T4 | 水印 Bitmap(内存) | WatermarkOverlay | T5 后 BitmapPool.recycle() | 同 T3 |
| T5 | watermark/compressed_{ts}.jpg | 质量压缩 | T6 重命名 | 50~200KB |
| T6 | watermark/{timestamp}.jpg(最终) | T5 直接 rename | 永久保留 | ≤ maxSizeKB |
| T8 | DCIM/CodeNote/{timestamp}.jpg | MediaStore 写入 | 用户手动删 | = T6 |
BitmapPool 复用机制
新增。解决连续拍照 native skia codec/encoder 对象累积问题。
背景: CameraX 连续拍照时,每次 decode → encode 都会在 Skia native 层创建 SkJpegCodec / SkJpegEncoder 对象(日志中 mId 持续递增),这些对象仅通过 finalizer 清理,GC 滞后导致 5 张后 RSS 从 348MB 涨到 437MB。
方案: 固定大小的 BitmapPool + 拍照后清理 + 拍照后 GC:
- T3 降采样时通过
BitmapPool.reusableOptions()借出复用 Bitmap - T5 压缩后通过
BitmapPool.recycle()归还到池,调用WatermarkOverlay.releaseTemporaryIfNeeded()清理水印图片缓存引用 - 拍照流程结束后
finally块中调BitmapPool.clearIdle()清理池中空闲 Bitmap,再 postSystem.gc()加速 native 层回收 - ViewModel
onCleared()和CameraScreenDisposableEffect中BitmapPool.clearIdle()
效果: 第 2 张之后的 Bitmap 像素内存复用,Skia 编解码器对象在第 2 次 GC 后被 native 释放,RSS 稳定在 2 张照片峰值内。
| 配置 | 值 |
|---|---|
| 池大小 | 3 |
| 借出条件 | width ≥ 目标、height ≥ 目标、config 匹配 |
| 归还行为 | 池未满则保留,池满则 recycle |
| 复用方式 | BitmapFactory.Options.inBitmap |
| 后置 GC | 拍照完成后 post System.gc() |
| 拍照冷却 | 0.5 秒(防抖,支持 1 秒 2 张连拍) |
文件大小总结
| 节点 | 预估大小 |
|---|---|
| 原始拍照 JPEG(文件) | 1~6MB(传感器原生分辨率) |
| 降采样 Bitmap(内存) | 随质量等级变化:高质量 ~13MB,标准 ~2.5~8MB,低质量 ~1.5~3MB |
| 水印 Bitmap(内存) | 同降采样 Bitmap |
| 压缩后 JPEG(文件) | 50~200KB |
| 最终落盘(文件) | 50~200KB(≤ maxSizeKB) |
| 系统相册副本(文件) | = 最终落盘 |
权限语义(RBAC,接入)
相册为双域资源(方案 §1.2.9/§1.2.10):个人照片(org_id 空)走 APP 域;分享到组织的副本(org_id 非空)走 ORG 域。
| 操作 | 权限点 | 说明 |
|---|---|---|
| 个人相册列表 | APP album:read | 个人照片(org_id IS NULL 过滤) |
| 上传照片 | APP album:upload | USER 预置 |
| 删除照片 | APP album:delete(个人);ORG album:delete(组织副本) | 个人严格属主;组织副本按 ORG 域角色(MEMBER 只读) |
| 组织相册副本列表 | ORG album:read(GET /api/album/org/{orgId}) | 组织副本归组织所有 |
| 分享到组织 | ORG share:create(MEMBER 开放) | 复制副本(org_id=orgId,文件共用 file_id 不复制);Android 相册选择模式批量分享 |
| 取消分享 | ORG share:delete(OWNER/ADMIN) | 物理删除副本 + 映射(不进个人回收站) |
关键行为:
- 个人列表/回收站查询统一加
org_id IS NULL,组织副本不混入个人相册 - 副本与原件完全解耦:个人改原件不影响组织副本,组织改副本不影响个人原件
- 删除副本不删除文件(文件清理由
file:cleanup孤儿检测兜底)