Skip to content

水印相机

拍照后的全流程文件处理,按时间线排列。


定位数据模型、坐标系规范参见 /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.ktbottomBarContent 中的 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.ktonCapture() + takePhoto()(CameraContainer),WatermarkViewModel.ktonPhotoCaptured()

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.ktonPhotoCaptured() / decodeSampled() / parseResolution()PhotoQualityMapper.ktCameraScreen.ktrememberScreenRotation()

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.maxSizeKBImageCompressor.furtherCompress(),当前流程通常不进入

T6:重命名为最终文件

  • 动作: compressed_{ts}.jpg 直接重命名为 {timestamp}.jpg
  • 产物: watermark/{timestamp}.jpg
  • 大小: 最终压缩后的大小,目标 ≤ maxSizeKB

T7:写入 Room 数据库

  • 实体: AlbumPhotoEntity
  • 写入内容:
字段
localPathwatermark/{timestamp}.jpg 的绝对路径
url""(空,等待同步后填充)
qrCodeIdnull(不关联二维码)
latitude, longitude定位结果
locationDesc地址
altitude海拔
weather天气文字
remark用户输入的备注
createdAtSystem.currentTimeMillis()
syncStatus"PendingCreate"

T8:写入系统相册(副本)

  • 动作: MediaStore API 写入
  • 产物: DCIM/CodeNote/{timestamp}.jpg
  • 大小: 与 T6 最终文件相同(拷贝)
  • 注意: 这是副本,不是移动,watermark/ 中的原文件不变

文件/内存生命周期速览

阶段文件/对象何时创建何时销毁当前大小
T0cache/capture_{ts}.jpg拍照完成T1 读完 delete1~6MB(原始传感器分辨率)
T1ByteArray(内存)读原始文件GC(T3 解码后)1~6MB
T3原始 Bitmap(内存)降采样解码+EXIF修正+缩放T4 后 recycle≤ 2.5~8MB
T4水印 Bitmap(内存)WatermarkOverlayT5 后 BitmapPool.recycle()同 T3
T5watermark/compressed_{ts}.jpg质量压缩T6 重命名50~200KB
T6watermark/{timestamp}.jpg(最终)T5 直接 rename永久保留≤ maxSizeKB
T8DCIM/CodeNote/{timestamp}.jpgMediaStore 写入用户手动删= 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,再 post System.gc() 加速 native 层回收
  • ViewModel onCleared()CameraScreen DisposableEffectBitmapPool.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:uploadUSER 预置
删除照片APP album:delete(个人);ORG album:delete(组织副本)个人严格属主;组织副本按 ORG 域角色(MEMBER 只读)
组织相册副本列表ORG album:readGET /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 孤儿检测兜底)