Overlay 在相机应用里并不神秘,它就是这个时代的贴纸、水印、人脸框和增强现实效果的统称。常规做法是把相机预览区当成一个底层画布,在其上叠加另一层透明或半透明画面,形成“相机画面 + 实时绘制层”的组合视觉效果。很多入门开发者第一次接触“overlay相机”这个概念时,会误以为一定要使用 OpenGL 或者复杂的图像处理库,实际上从系统提供的 View 层到硬件加速的 GLSurfaceView,都存在成熟的叠加路径。本文以 Android 平台为例,使用 CameraX 作为相机预览方案,通过自定义 OverlayView 在相机画面上叠加参考网格和水印,完整走通环境配置、代码实现、运行验证和问题排查全流程。
如果项目命名里出现“绵绵很好_overlay”这类格式,后缀_overlay通常代表这是一个以叠加层能力为核心的模块、分支或版本。下文不展开产品背景,只把它当作工程代号理解,用“overlay-camera-demo”作为示例工程名。阅读本文需要具备基础的 Android 开发知识,知道 Activity 生命周期、View 的回调顺序以及 Gradle 依赖的基本配置方法。完成后,你可以把同一套叠加思路迁移到扫码框、人脸关键点绘制、AR 贴纸和图片合成等场景。
1. 什么是 Overlay 相机,为什么预览叠加层要在相机流之上再画一层
这一节先解决概念问题。只有理解了 Overlay 是“两层结构”而不是“修改像素”,后续写代码时才不会把坐标系、性能和处理顺序全部搞混。
1.1 Overlay 的本质:预览画面与叠加画面的两层模型
相机 Overlay 的本质是一个两层模型:底层是相机输出的实时画面,顶层是透明或半透明的辅助画面。底层的相机画面持续从摄像头传感器取帧并渲染到屏幕上,顶层的 Overlay 内容则由程序实时绘制,例如文字、线段、矩形框、位图或动态动画。两层内容在用户眼里是一个整体,但在技术实现上是两路完全独立的渲染任务。
这里最关键的一点是:Overlay 不修改底层的相机图像数据。预览阶段看到的叠加效果,只是显示层的“视觉合成”。如果你通过ImageCapture保存照片,会发现保存下来的照片里并没有水印,因为你从未把水印合成到图像像素上。这是新手最容易踩的坑,后面的“拍照保存结果没有 Overlay”部分会详细展开。
从渲染链路来看,相机预览的底层画面通常由 PreviewView 内部的 SurfaceView 或 TextureView 承载,而 Overlay 则是另一个普通 View 或 GLSurfaceView。两者通过同一个父容器的层级关系完成视觉上的叠加。用代码表达就是:
<FrameLayout> <!-- 底层:相机预览 --> <androidx.camera.view.PreviewView /> <!-- 上层:Overlay 叠加层 --> <com.example.overlaycamera.OverlayView /> </FrameLayout>View 的绘制顺序是从底部开始、顶部结束,所以PreviewView在前、OverlayView在后,Overlay 内容会覆盖在预览画面上。一旦顺序写反,叠加层就会被相机画面盖住,表现出来就是“水印看不见”或“只有相机画面”。
1.2 典型应用场景一览
Overlay 相机在移动端很常见。理解场景有助于判断应该采用哪种实现方式,下面是几类典型的叠加需求:
| 场景 | 叠加内容 | 绘制周期 | 建议实现方式 |
|---|---|---|---|
| 扫码应用 | 取景框四角线段、扫描线动画 | 每次布局变化或动画帧 | 自定义 View 的 onDraw |
| 美颜相机 | 美颜程度文字、滤镜名称 | 只在设置变化时刷新 | 普通 OverlayView |
| 人脸贴纸 | 人脸关键点连线、贴图位图 | 跟随人脸检测结果高频刷新 | GLSurfaceView 或自定义 View |
| 直播相机 | 观众数量、礼物动效 | 高频 | GLSurfaceView |
| 工程测试 | 网格线、FPS、CPU 占用 | 定时刷新 | 自定义 View |
如果是简单静态或低频率叠加,使用 View 层足够;如果是人脸关键点、动画贴纸这类高频且需要大量位图旋转缩放的场景,就要考虑 OpenGL 或 GPU 渲染。这对应了“技术选型要跟业务需求走”这条原则。
1.3 最容易混淆的三个概念
理解 Overlay、水印和渲染合成这三个词的区别,能避免写代码时走偏。
第一个概念是 Overlay 层。它描述的是“层级关系”,强调的是显示顺序:一个内容叠在另一个内容之上。相机 Overlay 不要求你去修改底层图像像素。
第二个概念是水印。水印是 Overlay 层的一种具体内容,本质是文字或图片。水印属于 Overlay,但 Overlay 不等于水印。
第三个概念是图像合成。指的是把叠加内容真正写入底层图像的像素矩阵里,生成一张新的图片。如果你想输出带水印的照片,必须做图像合成。这个差异决定了你是在“预览时做假的合成效果”,还是在“保存时做真的像素合成”。
注意:只要需求是“拍照后把水印也保存到相册”,单纯在 Preview 上叠加 OverlayView 是不够的,必须额外做一帧离屏合成。
2. 技术选型:直接画、View 叠加、还是 OpenGL 渲染
概念清楚后,接下来要决定实现路径。选型不是越高级越好,而是要看业务形态、开发成本和维护成本。
2.1 从系统层面看 Overlay 的三种挂载位置
在 Android 系统里,相机叠加工有三种常见挂载位置。
第一种是 Activity 的布局树内,也就是把 OverlayView 作为 PreviewView 的兄弟节点放到同一个帧布局中。这种方式的优点是实现简单、生命周期跟随 Activity,通常使用 Canvas 的 onDraw 完成绘制,适合网格、矩形框、低频率文字等轻量内容。
第二种是独立的系统窗口层面,例如使用 WindowManager 添加悬浮窗。这种方式不适用于普通相机 App 的功能开发,更多出现在系统权限管理和录屏工具场景中,不在普通业务代码里使用。
第三种是 OpenGL 渲染管线内部。在这种方式下,相机帧不是直接显示在 PreviewView,而是作为外部纹理传给 GLSurfaceView,再由 OpenGL 完成底图和叠加图形的统一渲染。这种方式性能上限高,适合人脸贴纸、实时滤镜和复杂动画,但代码量和调试成本明显更大。
2.2 主流方案对比
把常见方案整理成表格后,选型逻辑会清楚很多。
| 方案 | 实现成本 | 性能 | 适用场景 | 局限 |
|---|---|---|---|---|
| 自定义 View 叠加绘制 | 低 | 中 | 网格、水印、扫码框 | 复杂动画性能一般 |
| TextureView + Canvas | 中 | 中 | 需要获取预览帧叠加 | 不支持所有设备灵活切换 SurfaceView |
| GLSurfaceView + 外部纹理 | 高 | 高 | 贴纸、滤镜、人脸 AR | 学习曲线陡峭 |
| CameraX + OverlayView | 低 | 中 | 大多数普通 Overlay 需求 | 保存到照片时需另做合成 |
从表里可以得出一个判断:如果你的叠加内容只是线框、文字、网格、静态贴图,完全没必要上 OpenGL。直接用自定义 View 叠加,不仅代码简洁,而且更容易排查问题。
2.3 本文为什么选择 CameraX + 自定义 OverlayView
我选择 CameraX 加自定义 OverlayView 组合,原因有三个。
第一是官方生态成熟。CameraX 内部封装了 Camera2 的大量细节,自动处理生命周期、旋转和画面适配,适合把重点放在 Overlay 绘制本身。
第二是重绘机制清晰。自定义 View 的所有绘制都在 onDraw 中完成,调用 invalidate 即可触发重绘。相机预览流和 Overlay 绘制互不阻塞,问题边界清楚。
第三是可迁移性强。OverlayView 里绘制的网格、文字、位图、旋转逻辑,在迁移到 OpenGL 时仍可以作为设计参考。先跑通最简单的两层结构,再考虑性能上限,是更稳的学习路径。
3. 环境准备、依赖配置与项目结构
技术方案确定后,第一步是搭环境。不要一上来就写覆盖全功能的代码,先确保最小工程能在模拟器或真机上跑起来。
3.1 开发环境基线
本文示例以 Android 原生开发为例,使用 Kotlin 语言。以下版本属于常见基线,实际项目请以你本机安装的稳定版本为准。
| 项目 | 建议配置 | 说明 |
|---|---|---|
| Android Studio | 近期稳定版 | 使用 AGP 和 Gradle 自动同步 |
| Gradle | 8.x | 由 Android Studio 生成,不必手动指定 |
| compileSdk | 34 左右 | 需支持 CameraX |
| minSdk | 21 | CameraX 最低要求 |
| targetSdk | 34 或最新可用版本 | 按应用市场要求调整 |
| 真机 | 建议使用 Android 10+ 设备 | 模拟器相机能力有限 |
注意:CameraX 功能受设备 Camera2 能力影响,部分模拟器上预览画面可能是黑屏或彩色条纹。遇到这种情况不要立刻怀疑代码,先换真机验证。
3.2 依赖和权限配置
在模块的build.gradle.kts中添加 CameraX 依赖。版本号请以 Android 官方文档和 Google Maven 仓库中的最新稳定版为准,以下代码只给出结构示例。
dependencies { implementation("androidx.camera:camera-core:1.3.4") implementation("androidx.camera:camera-camera2:1.3.4") implementation("androidx.camera:camera-lifecycle:1.3.4") implementation("androidx.camera:camera-view:1.3.4") }接着在AndroidManifest.xml中声明相机权限和硬件特性。
<uses-feature android:name="android.hardware.camera" android:required="true" /> <uses-permission android:name="android.permission.CAMERA" />required="true"表示应用必须依赖相机硬件,在无相机设备上会被过滤。如果你的应用不是以相机为唯一功能,建议设为false并在运行时检测。运行时权限申请在后面的 Activity 代码中处理。
3.3 工程文件结构
为了保持最小可运行,工程文件并不复杂。下面是一个参考结构:
overlay-camera-demo/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/overlaycamera/ │ │ │ ├── MainActivity.kt │ │ │ └── OverlayView.kt │ │ ├── res/layout/ │ │ │ └── activity_main.xml │ │ └── AndroidManifest.xml │ └── build.gradle.kts └── build.gradle.kts真实项目还会有res/values下的颜色和主题文件。这里暂时省略,不影响功能验证。
4. 最小可运行实现:在相机预览上叠加网格和水印
下面是核心实现部分。目标是把 CameraX 预览跑通,同时用一个自定义 OverlayView 在画面中央画出三分构图网格,并在页面下方显示一个固定水印文字。代码可以继续扩展为贴纸、人脸框等业务能力。
4.1 布局是关键:使用 FrameLayout 控制层级
Overlay 能否生效,布局顺序是第一道门槛。在res/layout/activity_main.xml中写入以下内容:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.camera.view.PreviewView android:id="@+id/previewView" android:layout_width="match_parent" android:layout_height="match_parent" /> <com.example.overlaycamera.OverlayView android:id="@+id/overlayView" android:layout_width="match_parent" android:layout_height="match_parent" android:clipChildren="false" android:clipToPadding="false" /> </FrameLayout>PreviewView在第一个位置,OverlayView在第二个位置。这样后绘制的 OverlayView 会覆盖在预览画面上。clipChildren="false"让 Overlay 内容可以绘制到父容器边界之外,clipToPadding="false"避免 padding 区域被裁剪,这两项在绘制四角线框时很有用。
4.2 绑定 CameraX 预览流程
在MainActivity.kt中,先请求摄像头权限,再绑定 CameraX 预览。完整代码如下:
package com.example.overlaycamera import android.Manifest import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.camera.core.CameraSelector import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat class MainActivity : AppCompatActivity() { private lateinit var previewView: PreviewView private lateinit var overlayView: OverlayView private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { bindCamera() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) previewView = findViewById(R.id.previewView) overlayView = findViewById(R.id.overlayView) if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ) { bindCamera() } else { requestPermissionLauncher.launch(Manifest.permission.CAMERA) } } private fun bindCamera() { val providerFuture = ProcessCameraProvider.getInstance(this) providerFuture.addListener({ val provider = providerFuture.get() val preview = Preview.Builder().build() preview.setSurfaceProvider(previewView.surfaceProvider) provider.unbindAll() provider.bindToLifecycle( this, CameraSelector.DEFAULT_BACK_CAMERA, preview ) }, ContextCompat.getMainExecutor(this)) } override fun onPause() { super.onPause() val provider = ProcessCameraProvider.getInstance(this) provider.addListener({ provider.get().unbindAll() }, ContextCompat.getMainExecutor(this)) } }这段代码做了三件事:申请权限、绑定预览、在 onPause 时解绑。unbindAll是为了避免切换页面后相机仍被占用,这一点在真机上很容易被忽视,解绑不及时会出现“另一个应用无法打开相机”的错误。
4.3 自定义 OverlayView 的绘制逻辑
在OverlayView.kt中实现网格和水印绘制:
package com.example.overlaycamera import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.util.AttributeSet import android.view.View class OverlayView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val gridPaint = Paint().apply { color = Color.argb(120, 255, 255, 255) strokeWidth = 2f style = Paint.Style.STROKE } private val watermarkPaint = Paint().apply { color = Color.WHITE textSize = 48f isAntiAlias = true } private var rotationDegrees = 0 fun setCameraRotation(degrees: Int) { rotationDegrees = degrees invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawGrid(canvas) drawWatermark(canvas) } private fun drawGrid(canvas: Canvas) { val oneThirdWidth = width / 3f val oneThirdHeight = height / 3f canvas.drawLine(oneThirdWidth, 0f, oneThirdWidth, height.toFloat(), gridPaint) canvas.drawLine(oneThirdWidth * 2, 0f, oneThirdWidth * 2, height.toFloat(), gridPaint) canvas.drawLine(0f, oneThirdHeight, width.toFloat(), oneThirdHeight, gridPaint) canvas.drawLine(0f, oneThirdHeight * 2, width.toFloat(), oneThirdHeight * 2, gridPaint) } private fun drawWatermark(canvas: Canvas) { canvas.save() canvas.rotate(rotationDegrees.toFloat(), width / 2f, height / 2f) canvas.drawText("OVERLAY DEMO", 32f, height - 80f, watermarkPaint) canvas.restore() } }这里值得强调的是rotationDegrees。手机横竖屏切换时,相机图像的方向和 View 的方向不同步,如果 Overlay 上的文字不随相机旋转,会出现“预览竖过来了,水印还是横着”的问题。通过setCameraRotation接收相机角度,再在绘制水印前旋转画布,可以让水印方向与底层画面保持一致。
4.4 把相机旋转角度传给 OverlayView
CameraX 的Preview提供了旋转角度信息。在真实项目中,可以通过previewView.display.rotation和CameraInfo配合计算,也可以先手动传一个角度验证 OverlayView 的旋转逻辑是否正确。下面这段代码演示了如何获取后置摄像头的旋转角度:
private fun applyCameraRotation() { val cameraInfo = cameraProvider.get().getCameraInfo(CameraSelector.DEFAULT_BACK_CAMERA) val rotation = cameraInfo.sensorRotationDegrees overlayView.setCameraRotation(rotation) }如果暂时不接入 CameraInfo,也可以写死 90 度观察效果。不过生产环境必须正式接入传感器旋转角度,否则不同机型的预览方向差异会直接暴露。
5. 关键参数、坐标系与绘制细节
代码跑通之后,还需要理解几个影响 Overlay 质量的关键参数和坐标概念。这些细节决定叠加层能否在不同设备上保持一致。
5.1 OverlayView 常用参数与含义
在布局和代码中,有几个参数值得单独说明,它们的设置直接决定 Overlay 的显示效果和绘制性能。
| 参数 | 常见值 | 作用 | 错误配置的表现 |
|---|---|---|---|
| android:clipChildren | false | 允许子 View 绘制到父容器外 | 四角框在屏幕边缘被截断 |
| android:clipToPadding | false | 允许绘制进 padding 区域 | 边缘内容被裁剪 |
| android:layerType | hardware | 开启硬件加速图层 | 复杂绘制卡顿 |
| isAntiAlias | true | 绘制文字和线条抗锯齿 | 斜线锯齿明显 |
| setCameraRotation | 0/90/180/270 | 旋转 Overlay 内容 | 水印方向错乱 |
layerType需要特别说明。View.LAYER_TYPE_HARDWARE会在 GPU 上为一个 View 单独建立硬件层,适合内容不变的静态叠加;如果叠加层内容每帧都在变化,硬件层反而可能增加开销。推荐先保持默认,遇到卡顿再针对具体场景调整。
5.2 相机预览坐标系与 View 坐标系换算
相机的输出坐标和 View 坐标是两个不同的坐标系。相机传感器输出的图像坐标系以左上角为原点,而 View 坐标系以 View 的左上角为原点,单位是像素。加上PreviewView默认的缩放模式是FILL_CENTER,实际显示的预览画面可能是等比裁剪后的结果。
如果你要画一个“跟随人脸”的框,不能直接把图像分析返回的坐标画在 OverlayView 上。必须完成两步换算:
- 把图像分析坐标转换为 0 到 1 之间的归一化坐标。
- 把归一化坐标乘以 OverlayView 的实际宽高。
简单示例如下:
// imageX: 图像分析返回的 x,imageWidth: 图像宽度,overlayWidth: View 宽度 val normalizedX = imageX / imageWidth val viewX = normalizedX * overlayView.width如果忽略这一步,最常见的问题是“检测到的框和实际人脸位置差半屏”。这是因为你直接把输入帧坐标当成了屏幕坐标。
5.3 何时使用 hardwareAccel 与 layerType
Android 默认启用硬件加速,但每个 View 可以单独设置layerType。如果 Overlay 内容包含大量路径和位图旋转,可以在 OverlayView 的构造函数中尝试设置:
setLayerType(LAYER_TYPE_HARDWARE, null)建议的开发策略是:先用默认配置跑通功能,在真机上用 Profile GPU Rendering 看帧耗时。只有在确认 Canvas 绘制是瓶颈时,才调整layerType或迁移到 OpenGL。过早优化不仅增加复杂度,还可能导致设备兼容问题。
注意:不要把所有 Overlay 内容都写在一帧 onDraw 里做实时时间戳刷新。除非确有动画需要,否则应尽量降低 invalidate 频率,防止整机功耗升高。
6. 运行验证、结果检查与日志分析
写完代码后,验证不能只看“能启动”。Overlay 功能有自己的一套验证标准,下面给出一个检查清单。
6.1 验证清单
在真机上运行时,逐项确认以下内容:
| 检查项 | 预期结果 | 不通过时可能的入口 |
|---|---|---|
| 权限弹窗 | 首次启动弹出相机权限请求 | MainActivity 权限逻辑 |
| 预览画面 | 画面清晰、方向正确 | PreviewView 布局、CameraX 绑定 |
| 网格线 | 四条线把画面分成九宫格 | OverlayView onDraw |
| 水印文字 | 位于画面底部且不旋转错乱 | rotationDegrees 传入 |
| 旋转屏幕 | Overlay 跟随相机画面方向 | display.rotation、CameraInfo |
| 退出页面 | 相机释放,其他应用可打开摄像头 | onPause unbindAll |
每一项都不难验证,但缺一个都可能导致上线后体验问题。
6.2 预期现象和不正常现象
正常情况下,启动后可以看到相机画面中央出现白色九宫格,底部有“OVERLAY DEMO”文字。摄像头转动时,画面方向变化,水印也随之旋转。
不正常现象通常分几类:
- 只看到黑屏,没有相机画面。先检查权限是否授权,再检查 CameraX 是否成功绑定。
- 有相机画面但看不到网格。检查 XML 中 OverlayView 是否在 PreviewView 之后。
- 网格和水印方向不对。检查
setCameraRotation传的值是否正确。 - 水印闪烁。检查是否有外部代码频繁调用 invalidate,以及 onDraw 里是否创建了大量临时对象。
排错时从输入到输出逐层检查:权限是否授予,Provider 是否成功获取,SurfaceProvider 是否设置,OverlayView 是否被正确 attach 到窗口。
7. 常见问题排查:从现象到根因
下面整理的是 Overlay 相机开发中最高频的几类问题。排查顺序建议先看现象是否稳定复现,再检查布局与代码顺序,再考虑硬件和版本限制。
7.1 预览画面拉伸或方向不对
现象是相机画面变形,或者竖屏时显示横屏内容。
常见原因有三个:一是PreviewView宽高比和相机输出宽高比不一致,二是没有根据display.rotation设置预览目标旋转,三是自定义 OverlayView 绘制时没有同步旋转。
处理方式:
| 检查点 | 操作 | 结果 |
|---|---|---|
| PreviewView scaleType | 根据需求设置为 FILL_CENTER 或 FIT_CENTER | 画面按比例展示 |
| CameraX 旋转 | 使用 CameraInfo.sensorRotationDegrees | 方向与设备一致 |
| Overlay 旋转 | 在 onDraw 中画布旋转 | 叠加内容与预览对齐 |
如果叠加坐标始终不准,优先打印 OverlayView 的宽高和预览分辨率,确认二者比例关系。
7.2 Overlay 层不显示或被预览盖住
现象是 OverlayView 的 onDraw 已经执行,但屏幕上看不到内容。
最典型原因是 View 层级顺序错误。FrameLayout中第一个子 View 在最底层,后添加的 View 覆盖在前面。如果先写 OverlayView,后写 PreviewView,预览画面会把 Overlay 盖住。
解决方法是反过来写:
<!-- 正确顺序 --> <androidx.camera.view.PreviewView /> <com.example.overlaycamera.OverlayView />另一种可能是 OverlayView 宽高为 0。检查布局是否给了match_parent,或者是否被放在没有尺寸约束的父容器中。调试时可以在 onDraw 第一行打印 width 和 height。
7.3 叠加层频繁重绘导致卡顿
现象是预览流畅度下降,Overlay 内容有撕裂感。
大部分性能问题来自 onDraw 中的无效工作,包括创建 Paint 对象、解码 Bitmap、计算复杂路径、每次 invalidate 都重新绘制静态内容。
优化策略:
- 静态内容不放在 onDraw 中重复计算,初始化时创建对象。
- Bitmap 使用预缩放或二次采样,避免加载原始大图。
- 降低 invalidate 频率,例如每秒刷新一次或只在数据变化时刷新。
- 在不需要动画时移除硬件图层或硬件层不要高频更新。
7.4 拍照保存的结果没有 Overlay 内容
这个问题的根因在第一节已经提到:预览 Overlay 只是视觉叠加,并没有写回像素数据。当ImageCapture触发时,它保存的是相机传感器输出的原始帧,不包含 OverlayView 绘制的内容。
要生成带 Overlay 的照片,需要走离屏合成:
- 使用
ImageCapture获取原始图像 Bitmap。 - 根据原始图像尺寸创建对应的 Bitmap 画布。
- 在画布上重绘 Overlay 内容。
- 保存合成后的 Bitmap。
val outputBitmap = Bitmap.createBitmap(originalBitmap.width, originalBitmap.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(outputBitmap) canvas.drawBitmap(originalBitmap, 0f, 0f, null) overlayView.draw(canvas) saveBitmap(outputBitmap)注意:直接把 View 的draw(canvas)用于离屏合成时,View 的宽高和原始 Bitmap 尺寸可能不一致,需要先缩放 View 层内容,否则合成后 Overlay 位置会偏移。生产环境建议用独立绘制方法,而不是复用 OverlayView 的 onDraw。
7.5 位图叠加后内存明显上涨
现象是添加贴纸后,应用内存从几十 MB 涨到几百 MB,甚至触发 OOM。
原因通常是 Bitmap 过大或没有复用。CameraX 输出的一帧图片可能是 4000x3000 像素,ARGB_8888 格式下单帧就接近 48 MB,如果每帧都创建新 Bitmap,内存必然失控。
排查方式:
- 检查 Bitmap 是否做了采样压缩。
- 检查 Overlay 贴图是否加载原图。
- 检查离屏合成后是否及时回收中间 Bitmap。
- 在 Android Studio Profiler 中观察内存曲线。
8. 最佳实践、发布前检查和扩展方向
功能跑通只是开始。真实项目里,Overlay 镜头通常和权限、生命周期、设备适配、性能监控放在一起考虑。下面给出可执行的实践建议。
8.1 学习环境与生产环境的差异
学习环境满足“能跑”,生产环境必须考虑“稳定、可观测、可回滚”。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 权限 | 手动授权即可 | 处理拒绝授权和永久拒绝提示 |
| 生命周期 | Activity 简单绑定 | Fragment 场景、页面栈回收 |
| 相机释放 | onPause 解绑 | onStop 解绑,页面销毁释放资源 |
| 机型适配 | 主力真机 | 多品牌多系统版本回归 |
| 日志 | 只 print | 结构化日志、错误上报 |
| 性能 | 肉眼判断 | 帧耗时、内存、温度监控 |
在实际项目里,不要只依赖onPause解绑相机。Fragment 场景中,生命周期回调可能更多,建议把ProcessCameraProvider的绑定状态放到 ViewModel 或明确的初始化类中统一管理,避免 Activity 重建后重复绑定。
8.2 发布前 Overlay 功能检查清单
下面这份清单可以直接复制到项目的上线检查文档中:
- [ ] 相机权限拒绝时给出引导,不直接闪退
- [ ] 摄像头切前后置时,Overlay 内容仍处于正确位置
- [ ] 屏幕旋转时,Overlay 与生物识别区域不冲突
- [ ] 拍照保存的图片尺寸与叠加内容比例一致
- [ ] 连续拍照 20 次内存无明显泄漏
- [ ] 弱光环境下 Overlay 文字可读
- [ ] 叠加大图时无 OOM
- [ ] 相机被其他应用占用时给出错误提示
- [ ] 页面退出后摄像头可被其他应用正常使用
- [ ] 不同分辨率设备上 Overlay 坐标不偏移
每一条都需要在真机上验证。模拟器只能验证基本逻辑,不能替代真机矩阵。
8.3 从 View 叠加到 OpenGL 渲染管线的演进路线
当叠加内容增多、动画复杂后,View 层叠加会逐渐到达瓶颈。常见的演进路径是:
- 先保持 CameraX 的 PreviewView 作为预览显示层。
- 在业务层封装一个 OverlayManager,统一管理网格、贴纸、水印等元素。
- 当需要高频位图变换时,引入 OpenGL 外部纹理,把相机帧直接渲染到 GLSurfaceView。
- 将 Overlay 绘制逻辑迁移到 GLSL 中,由 GPU 完成坐标变换和 alpha 混合。
这套演进路线的核心价值是:业务逻辑与渲染细节解耦。即使未迁移到 OpenGL,先封装好的 Overlay 元素模型也能复用。
8.4 新手最容易踩的三个坑
第一个坑是在 onDraw 中加载位图。正确做法是在 View 初始化时加载并缩放好位图,onDraw 只负责绘制。否则每一次 invalidate 都会触发一次文件 IO,卡顿会非常明显。
第二个坑是忽略图片输出比例。预览是 16:9,拍出来的照片可能是 4:3,如果直接把 OverlayView 画到照片 Bitmap 上,水印会变形或偏移。建议单独保存 Overlay 元素的归一化坐标,在合成照片时按目标尺寸重新计算位置。
第三个坑是只在主界面验证,没有测试切后台、锁屏、接电话等中断场景。相机是独占资源,一旦没有及时释放,用户在切回 App 时会看到黑屏或异常。所有绑定和解绑路径都要在生命周期中断场景中测试一遍。
Overlay 相机的实现路径并不难,难的是在设备差异、渲染性能和生命周期之间找到平衡。先从两层叠加模型入手,用最小工程跑通预览网格和水印,再逐步引入坐标系换算、离屏合成和性能优化。每个新增需求都对应一个明确的技术栈选择,而不是把所有功能都堆到同一个 View 的 onDraw 里。以工程命名中常见的_overlay后缀为起点,可以把这类模块抽象成独立的渲染能力层,后续接入扫码、贴纸、AR 或照片合成时,都可以复用同一套坐标与绘制设计。