Android相机Overlay叠加:贴纸水印稳定对齐的工程实践指南 前阵子我在做一个相机贴纸功能需求听起来很简单在 CameraX 预览画面上叠加一个半透明 overlay让用户拍照时能看到贴纸预览。第一次跑通只花了一个下午可一上真机就露馅了——贴纸和水印在不同手机上不是偏移就是被拉伸甚至预览方向也会跟着转。当时我意识到overlay 相机这种看似简单的需求真正难的不是“叠上去”而是把它叠得稳定、一致、可维护。这个叫“不乖^_overlay”的项目本质上就是我给这个尝试起的名字。名字带着点不正经但在实验阶段反而帮我记住了最核心的经验overlay 不只是一个 View 盖在相机上面它是一整套从传感器到屏幕、从坐标到渲染链路的系统工程。1. 先搞清楚“overlay 相机”是在哪一层叠加1.1 三种常见叠加方式工作量和稳定性完全不一样很多人第一次做相机叠加时第一反应是“盖一个 View 上去”。这个思路没有错但只适合显示层需求。如果你希望贴纸能写进照片、能跟着人脸移动、能在短视频录制的每一帧里都出现就必须把叠加放进图像处理管线里。常见的做法大概分成三类叠加方式工作位置适合场景最容易踩的坑UI 控件叠加相机预览 View 的上层简单水印、操作提示、静态贴纸旋转、缩放、裁剪后位置对不上Bitmap / Canvas 合成摄像头帧转成位图后再绘制贴纸照片水印、离线批处理、截图盖章内存占用高实时性差掉帧明显GPU 纹理叠加相机纹理和贴纸纹理在渲染管线里合成实时滤镜、AR 贴纸、短视频特效开发成本高生命周期和线程模型复杂这三条路线不是互斥的。产品里经常是 UI 层叠加做“编辑时的预览”图像管线里的叠加做“最终输出”。如果一开始就把这两件事混在一起后面会非常难受。1.2 为什么选择哪一层决定了后面所有坑UI 控件叠加看起来最简单但它有几个绕不过去的问题它不是图像数据的一部分截图、录屏、视频流里不会包含它。相机预览为了适配不同屏幕会有裁剪和缩放UI 层坐标要重新换算。前置摄像头还有镜像问题贴纸边缘和实时画面总是对不上。Bitmap 合成适合单张处理。如果是一张照片用 Canvas 把水印或贴纸画上去再保存到本地这是很常见的做法。但到了视频预览或连续拍摄场景每一帧都要转位图、画贴纸、再提交渲染内存和 CPU 都不会好受。GPU 纹理叠加是实时相机类产品的正规路径。相机的帧数据直接作为纹理交给 GPU贴纸也是纹理最后在着色器里完成合成。这样能保住帧率也能支持实时效果。但代价是你得对 OpenGL ES 或 Vulkan 有基本认知还要管理好纹理生命周期不然很容易出现黑屏、花屏、切换后台崩溃。我自己的判断是先看你要不要输出成文件。如果只是临时预览UI 层足够只要涉及照片、视频、直播或后续二次处理就尽早把叠加逻辑从 View 层往下挪。2. 最小可用流程从一个可运行的 overlay demo 说起2.1 先把技术选型定在一个可维护的范围内以 Android 端为例最省力的方案是 CameraX PreviewView 自定义 Overlay View。PreviewView 负责把相机帧渲染出来它自己也是依赖 Surface / TextureView 的。我们在它上面再叠一个自定义 View负责绘制贴纸和水印。这个组合适合做第一版因为 CameraX 处理了权限、生命周期、旋转适配的很大一部分。你不需要一上来就碰 GLSurfaceView也不需要自己写相机设备轮询。下面是一段很常见的初始化结构只做思路演示class OverlayCameraActivity : AppCompatActivity() { private lateinit var previewView: PreviewView private lateinit var overlayView: OverlayView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_overlay_camera) previewView findViewById(R.id.previewView) overlayView findViewById(R.id.overlayView) // 这里省略了相机权限检查实际操作时必须先处理 val preview Preview.Builder() .build() .also { it.setSurfaceProvider(previewView.surfaceProvider) } val cameraProviderFuture ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ val cameraProvider cameraProviderFuture.get() cameraProvider.unbindAll() cameraProvider.bindToLifecycle( this, CameraSelector.DEFAULT_BACK_CAMERA, preview ) // 绑定完成后再根据实际预览尺寸计算 overlay 坐标 overlayView.post { overlayView.updateOrientation() } }, ContextCompat.getMainExecutor(this)) } }这个示例不完整也不会直接复制就能跑。它想表达的是最小的 overlay demo 相机预览 上层 View 一次坐标对齐。如果这三块没有串起来后面的效果都谈不上。2.2 数据流摄像头帧是怎么一步一步变成用户看到画面的理解数据流比记忆 API 重要得多。第一步CMOS 传感器拿到光信号生成原始图像帧。第二步图像信号处理器做基础调整比如自动曝光、白平衡、降噪。第三步帧数据被送到预览管线最终交给 PreviewView 显示。第四步如果你的 Overlay View 叠在上面用户看到的其实是两层底层是相机画面上层是贴纸。第五步如果用户点击拍照照片是不是包含贴纸取决于你的拍照通道是不是也走了一次 overlay 合成。这五步里最容易出错的是第三步和第五步。预览时贴纸只要在 View 层显示即可拍照时贴纸必须写进最终图片这时候就需要 ImageCapture 或 Bitmap 合成不能再依赖 View 层。2.3 最小实现里不该忽略的几个关键参数我见过不少人把 demo 跑通后又花了很久调 bug原因是忽略了几个基础参数预览尺寸相机输出 4:3屏幕比例是 19.5:9PreviewView 默认会裁掉一部分画面。overlay 如果不跟着裁位置必然偏。旋转角度手机横竖屏切换时传感器方向和 UI 方向不一致贴纸会横过来。镜像前置摄像头默认是镜像的如果贴纸文字也按镜像绘制会影响可读性。更新频率连续移动贴纸时不能每帧都重新创建重绘对象要复用。从工程经验看第一版甚至可以把“实时移动贴纸”这种交互先放一放。先让贴纸固定在某个角落测试手机横屏、竖屏、相同画面下前后摄像头切换确认位置准确后再增加拖拽手势。3. 真正麻烦的不是叠加而是坐标系和对齐3.1 传感器方向、预览画面、UI 坐标三者要怎么统一这是所有 overlay 项目里最容易被低估的一环。很多 demo 在“拍照不旋转”的模拟器上看着很正常一放到真机就乱掉。原因在于相机的传感器方向、预览画面的宽高比、屏幕的坐标方向是三个独立的东西。你拿到一帧画面后它可能本身是横着的PreviewView 会帮你旋转成竖屏但这个旋转过程会导致画面被裁剪裁剪比例又和屏幕宽高比相关。上层 View 如果还按照原始布局坐标计算自然就对不上。更直接地说你用的预览尺寸指的是相机输出图像的宽高你看到的效果是 PreviewView 根据自身尺寸和 scaleType 调整过的结果。overlay 要对齐的是“调整后显示出来的画面区域”而不是“相机原始帧的完整区域”。3.2 一个可靠的坐标换算思路我先说结论不要手写一堆 if else 去判断横竖屏。尽量把坐标放在一个统一的变换体系里每次只更新一次变换矩阵所有贴纸位置都通过矩阵映射。常见的做法是拿到相机输出尺寸和预览 View 最终尺寸。计算 PreviewView 的 scaleType 是 FIT_CENTER 还是 FILL_CENTER 之类的裁剪方式。以 PreviewView 实际显示的区域作为 overlay 的基准区域。把传感器坐标先旋转到 UI 方向再做缩放平移。代码不一定是下面这样但思路非常接近// 表示计算 overlay 显示区域的一个通用思路 val previewWidth previewView.width val previewHeight previewView.height val rotation previewView.display.rotation val sensorSize cameraInfo.sensorRotationDegrees // 计算显示出来的裁剪区域 val displayedRect computeDisplayedRect( sensorSize, rotation, previewWidth, previewHeight, scaleType ) overlayView.setAlignRect(displayedRect)如果你不想一开始就研究矩阵可以通过“先对齐画面中心再做等比缩放”的土办法验证。常见步骤是拿到相机预览帧的宽高。计算预览 View 的宽高比。计算原始画面等比例缩放后多出来的部分会在哪一侧被裁剪。根据裁剪矩形的起点把贴纸的坐标换算到 View 坐标。这个办法能做出来但只适合固定方向。一旦手机旋转、摄像头切换、屏幕比例变化代码会越来越乱。所以我还是建议在项目早期引入矩阵或统一的 Rect 换算工具而不是继续堆 if else。3.3 常见错位现象和排查方向下面这张表是我做 overlay 项目时实际遇到过的现象现象可能原因优先检查项贴纸整体偏左上或右下旋转后没有重新计算裁剪原点打印 PreviewView 显示区域和 overlay 区域贴纸被拉长或压扁宽高比不一致或者 scaleType 使用不当检查相机输出尺寸和 View 尺寸是否等比贴纸左右颠倒前置摄像头镜像处理反向查看 cameraInfo 是否启用镜像旋转屏幕后贴纸位置乱了生命周期重建后没有重新计算检查 configuration change 处理方式贴纸在预览里正常拍出照片没有输出通道没有叠加层确认最终图片走的哪条管线很多时候定位问题不是靠猜而是先打日志。把预览尺寸、屏幕尺寸、旋转角、裁剪矩形全部打出来再叠加一个“调试网格”上去一眼就能看出是哪个环节偏移了。4. 从“能跑”到“稳定”需要处理哪些工程细节4.1 生命周期和资源释放是 overlay 项目最容易崩的地方相机本身就是稀缺资源overlay 里的贴纸、位图、纹理也是要释放的。如果把生命周期管理交给“跑起来再说”的 demo 逻辑很容易出现进后台再回来黑屏、切换摄像头后贴纸消失、页面销毁后相机还占着。一个相对稳妥的流程是在 onStart 或权限回调后创建相机实例。在 onResume 里绑定生命周期的相机并在绑定完成后重新计算 overlay 对齐。在 onPause 里停止预览或解绑相机避免后台占用。在 onDestroy 里释放贴图、位图、Bitmap 缓存。如果是 GPU 纹理还要在 GL 线程里删除纹理不能直接在 UI 线程调用。还要特别注意配置变更。很多机型在旋转屏幕时Activity 会重建如果 overlay 的坐标只依赖之前的宽高重建后没有重新计算位置就会乱。解决思路是保存一份与旋转无关的“业务坐标”等 View 尺寸确定后再换算成显示坐标。4.2 性能与实时性怎么取舍更合理overlay 做的不是一次性计算而是要跟着相机帧频跑。如果每一帧都重新去加载一张大图、创建几个临时对象很容易卡顿。我建议先设一个性能基线预览保持 30 帧左右不要追求 60 帧而牺牲稳定性。overlay 的重绘频率可以降低到 15~20 帧只要拖拽不觉得卡就够。贴纸资源不要每次都 decode要提前加载。不要在 draw 方法里做大量计算。如果走 GPU 路线还有一点值得注意不要在应用切后台后继续绑定纹理。Android 的 EGL 上下文在后台可能会失效直接使用旧纹理大概率会黑屏或崩。稳妥做法是切后台时暂停渲染线程等真正回到前台后再重新初始化。4.3 一套通用的排查链路帮你减少无效调试遇到问题不要上来就改代码。按顺序排查通常能更快定位看现象。先弄清楚是“位置错”“画面黑”“卡顿”还是“没有叠加”。现象不同排查方向完全不同。看输入。预览尺寸、文件格式、贴纸尺寸、相机旋转角这些输入对不对。看环境。依赖版本、系统版本、设备是否支持对应 CameraX / OpenGL 版本。看参数。scaleType、裁剪区域、更新频率、纹理尺寸这些参数是不是符合硬件限制。看工具边界。是不是 CameraX 对某些预览规模的限制或者是 OpenGL 版本不支持某个特性。这套链路最核心的价值是强迫你先确认输入和环境而不是一上来就怀疑算法。很多时候问题根本不在 overlay 逻辑而是贴纸资源本身没有透明通道或者相机权限没有回调成功。5. 从 demo 到长期使用边界和判断在哪里5.1 真正适合 overlay 技术方案的场景overlay 并不是只属于相机贴纸。它可以被用在很多地方相机水印给拍摄画面加时间、地点、Logo。视频标注在录屏或回放画面上叠加操作提示。照片批处理连续给一批图片加统一角标。游戏选中框在角色或单位上叠加状态框。教学工具在实时演示画面里画箭头、圈重点。这些场景有一个共同点叠加的元素和画面之间是“平行关系”它不需要像素级理解画面内容。也就是说只要你知道位置就能把它摆上去。这比人脸贴合、目标追踪要简单很多。5.2 哪些场景需要谨慎甚至不适合用简单 overlay反过来说以下场景如果只靠普通 View 叠加或简单 Bitmap 绘制基本做不出来人脸关键点贴合。贴纸要跟着眼睛、嘴巴移动需要额外接人脸检测和稳定算法。动态光影融合。贴纸要发光、变形、受遮挡这已经是特效渲染引擎的范畴。多相机画面同步。多个摄像头画面拼接后再叠加问题会指数级放大。无人值守的长期任务。比如后台持续处理视频流但无法保证前台生命周期这时要用专门的渲染服务。从工程角度说overlay 不是灵丹妙药。它擅长解决“固定区域叠加固定内容”的问题不擅长解决“需要理解画面内容后智能对齐”的问题。认识到这一点才能避免把一个 demo 硬撑成产品。5.3 一份可以直接参考的 overlay 项目确认清单做完这个项目后我把经验沉淀成了一个五个确认点之后再做类似功能时都会先检查一遍检查项具体内容验证方式坐标系传感器、预览、UI 三套坐标是否统一横竖屏各测一次贴纸位置不变生命周期前后台切换、摄像头切换是否正常快速进后台再回前台重复测 10 次性能单帧耗时不抖动内存不持续上涨用 Profiler 看帧率和内存曲线输出一致性预览里看到的和最终文件里的画面一致拍照后对比原图和预览截图异常恢复权限拒绝、相机被占用时能不能给用户合适反馈拒绝权限后点击拍照页面不能崩这个清单不算长但能挡住最常见的线上问题。很多 overlay 项目上线后被吐槽不是功能没做出来而是某一台设备上旋转错了、某一台设备上贴纸被吃了。6. 项目名可以是临时的但问题不会变6.1 “不乖^_overlay”到底给我留下了什么“不乖^_overlay”这个名字本身没有多少含义更像是一个临时实验标签。但这类项目留下来最有价值的东西不是名字而是那一整套“为什么不能直接盖一个 View”的经验。它让我重新理解了 overlay 的三个层次显示层 overlay 解决“用户现在看到什么”。数据层 overlay 解决“最终文件里有什么”。渲染层 overlay 解决“实时性和编辑自由度能否兼顾”。这三层如果不分开思考项目就会越做越黏。一开始以为只是一个小 View 的事做到后面却发现要和相机频率、生命周期、设备适配打长期交道。6.2 如果你也想做自己的 overlay 项目下一步建议不要急着写复杂的着色器也不要一上来就追着人脸贴纸不放。先把最基础的三步做完用系统相机预览在 View 层画一个固定矩形让这个矩形在横竖屏和前后摄像头切换后仍然对齐。这三步看起来不起眼但你会发现九十多个问题都能归到“坐标系没统一”或“生命周期没处理”这两类里。解决完这两类问题再做实时滤镜、做视频录制输出、做人脸贴合才会有真正的地基。如果你正好也卡在“相机预览上叠一个东西总是对不准”这种问题上可以按上面第五节的清单逐项检查。先打日志再画调试网格再换真机测试。不要相信模拟器也不要在小屏设备上勉强测完后直接上大屏。项目名可能过几个月就改了但 overlay 背后的坐标系、渲染管线和生命周期问题在每一代相机方案里都会以类似方式重来一次。搞懂它们比记住一个 API 值钱得多。