安卓相机开发:Camera、Camera2、CameraX对比与选型指南
安卓相机开发这块,Camera、Camera2、CameraX 三套 API 摆在同一张桌子上,几乎每个接手相机模块的人都要先纠结一轮。新项目直接上 CameraX 会不会被功能卡住?老项目里那套 Camera2 的 Session 代码还值不值得维护?还在用 Camera 老 API 的工程是不是必须重写?这些问题在文档里往往只有一句“Camera 已废弃,建议使用 CameraX”,但真落到业务里,废弃不等于不能用,推荐也不等于适合。我把这几年在扫码、证件拍摄、实时图像分析几类场景里踩过的坑整理成这篇对比与选型指南,从架构差异讲到代码落地,从参数计算讲到线上排查,面向的是已经写过一点相机代码、准备做技术选型或者正在被预览拉伸、黑屏、掉帧折磨的开发者。看完至少能明确一件事:你的场景该用哪套 API,以及为什么。
1. 三套相机 API 的来龙去脉:先搞清楚它们在解决什么问题
三套 API 不是简单的“新旧替代”关系,它们诞生于不同的硬件环境和不同的工程诉求。搞清楚每一代想解决的核心矛盾,选型时就不会只盯着“哪个更新”这种表面判断。
1.1 Camera:能用就行的年代产物
Camera 这套 API 的设计目标非常朴素:让应用能打开摄像头、显示预览、拍一张照片。它的接口是面向过程的一串方法调用——open()、setPreviewDisplay()、startPreview()、takePicture(),参数通过一个巨大的Camera.Parameters对象整体读写。想改对焦模式?先把 parameters 取出来,改完再整体 set 回去,任何一步顺序错了都可能导致预览卡死。
它的问题不在于“简单”,而在于抽象层次错位。摄像头本质是一个有状态、有 pipeline、有并发约束的设备,而 Camera 把它包装成了一个同步方法集合。你没法精确控制一帧的曝光时间,没法同时输出两路不同格式的流,没法知道某一帧到底在哪一刻被传感器采集。Camera.Parameters里的getSupportedXXX系列方法返回的支持列表还经常和实际行为对不上,不同厂商的实现差异极大,导致兼容性代码越写越厚。
即便如此,它在低版本设备上的覆盖是无可替代的。Camera2 从 API 21 才引入,CameraX 最低也要求 API 21,如果你的应用还要覆盖更老的设备,Camera 依然是唯一选择。另外在一些极简场景——比如只是做一个“拍个照上传”的功能,不涉及任何图像处理——Camera 的代码量确实比 Camera2 少一个数量级。这也是为什么很多老工程至今没动它。
1.2 Camera2:把控制权交出来,也把复杂度交出来
Camera2 是一次彻底的重构,它把相机抽象成了设备—会话—请求三层模型。CameraDevice代表硬件,CameraCaptureSession代表一组输出 Surface 的配置,CaptureRequest代表一次具体的采集请求。这个模型的好处是惊人的:你可以为预览和拍照配置不同的 Surface,可以在一个 Session 里同时输出 YUV 给算法、输出 JPEG 给相册,可以逐帧设置曝光、增益、对焦距离,可以从CaptureResult里读到这一帧真实的传感器时间戳、自动曝光状态、镜头焦距。
代价同样惊人。一个最小可用的预览流程需要处理:权限、相机枚举与选择、后台线程与 Handler、SurfaceTexture 的可用性、Session 的创建与重配、请求模板的选择、生命周期的关闭顺序。每一步都有坑,而且这些坑大多不会在编译期暴露,而是在某些机型上偶发。更麻烦的是兼容性分级:Camera2 把设备能力分成 LEGACY、LIMITED、FULL、LEVEL_3 几档,LEGACY 设备上的 Camera2 实际上是对 Camera1 的一层封装,很多高级特性直接不可用,但代码不会报错,只是行为不符合预期。
我个人的判断是:Camera2 适合那些必须精确控制采集参数的场景,比如需要固定曝光的工业检测、需要读取 RAW 做后期处理的影像应用、需要多路流同步的算法管线。如果只是“拍张照”,用 Camera2 属于给自己找活干。
1.3 CameraX:把生命周期和兼容性收进框架
CameraX 的定位很明确——它不追求暴露全部底层能力,而是把大多数应用真正需要的那部分能力做成开箱即用的组件。它建立在 Camera2 之上(在 LEGACY 设备上会退化为兼容路径),对外暴露三个核心用例:Preview、ImageCapture、ImageAnalysis。你不需要自己管理 Session,不需要自己算旋转角,不需要自己处理onDisconnected,框架把生命周期绑定、设备兼容、旋转适配这些事情都收走了。
它的价值体现在几个具体的地方。第一是生命周期自动管理,bindToLifecycle()之后,页面不可见时相机会自动释放,回到前台自动恢复,不用再写一堆onResume/onPause里的状态机。第二是旋转与镜像的统一处理,ImageCapture输出的图片方向、ImageAnalysis回调里的rotationDegrees,都有明确的语义,不用再自己维护一套SENSOR_ORIENTATION加Display.getRotation()的换算表。第三是扩展能力,通过CameraExtensionsAvailability可以查询设备是否支持 HDR、夜间模式、虚化,支持的机型上直接启用,不支持的自动降级。
它也有明确的边界。CameraX 不直接暴露逐帧的CaptureResult,想读某一帧的实际曝光值需要走Camera2Interop拿到底层对象;它对多摄的抽象是CameraSelector加缩放比例,逻辑多摄的精细控制能力弱于直接写 Camera2;它的一些高级配置(比如自定义Surface输出)需要绕一下。所以 CameraX 不是万能钥匙,它是“八成场景的最优解”,剩下两成要看你的需求到底有多硬。
1.4 选型前必须先回答的三个问题
在动手选之前,我习惯先问三个问题,答案基本能锁定方向。
第一个问题:最低支持版本是多少?如果要覆盖 API 21 以下,只能选 Camera,没有讨论空间。如果最低是 API 21 及以上,CameraX 和 Camera2 都在候选范围内。
第二个问题:你需要读到底层的哪些数据?如果只是拍照、录像、扫码、简单的图像分析,CameraX 够用。如果你需要 RAW、需要逐帧的传感器时间戳做多传感器对齐、需要精确控制每一帧的曝光时间和增益、需要同时输出三路以上不同格式的流,那 Camera2 更合适。
第三个问题:团队有多少人力维护相机模块?相机模块的特点是“一次写完,长期救火”。Camera2 的代码在机型适配上的维护成本远高于 CameraX,尤其是国内机型碎片化严重的环境下,一个 Session 重配的时序问题可能只在某几个型号上出现。如果团队没有专人负责这块,CameraX 的稳定性收益会非常明显。
2. 能力对照:参数、性能与兼容性到底差在哪
光说架构太虚,落到具体能力上才能做决策。这一节把三套 API 在常见需求维度上拉平对比,重点解释差异背后的原因。
2.1 一张表看清三代 API 的边界
| 能力维度 | Camera(老 API) | Camera2 | CameraX |
|---|---|---|---|
| 最低 API | API 1 | API 21 | API 21 |
| 预览实现 | setPreviewDisplay | CaptureSession+Surface | PreviewUseCase |
| 拍照 | takePicture回调 JPEG | TEMPLATE_STILL_CAPTURE请求 | ImageCapture |
| 多路输出 | 不支持 | 支持,任意组合 | 支持 Preview + Capture + Analysis 组合 |
| 手动曝光/对焦 | 部分支持,参数粗 | 完整支持,逐帧可控 | 需Camera2Interop补齐 |
| RAW 输出 | 不支持 | 支持(需设备能力) | 不直接支持 |
| 逐帧元数据 | 无 | CaptureResult全量 | 仅分析帧的有限信息 |
| 生命周期管理 | 手动 | 手动 | 自动绑定 |
| 旋转处理 | 手动算 | 手动算 | 框架统一 |
| 设备兼容性 | 差,靠厂商 | 分级,需判断 | 框架兜底 |
| 代码量(预览+拍照) | 少 | 多 | 中 |
| 典型维护成本 | 高(历史包袱) | 高 | 低 |
这张表里最容易被误读的是“代码量”那一行。Camera 的代码量少,是因为它把复杂度藏进了黑盒,出问题时你无从下手;Camera2 的代码量多,是因为每一行都在显式表达一个决策;CameraX 居中,是因为框架替你做了大部分决策,但你得知道它做了什么,否则调参时就是盲人摸象。
2.2 预览链路与帧率:谁在替你做决定
预览链路的差异直接决定了帧率和延迟表现。
Camera 老 API 的预览走的是SurfaceHolder,帧率基本由驱动决定,应用层能干预的手段极少。setPreviewFrameRate()在多数设备上形同虚设,实际帧率往往是 15fps 或 30fps,取决于厂商默认值。这也是老 API 在扫码场景下体验差的主要原因——预览卡顿导致对焦框跟不上。
Camera2 的预览是setRepeatingRequest驱动的,每一帧都是一个 Request。理论上你可以通过CONTROL_AE_TARGET_FPS_RANGE指定帧率区间,但要注意这是一个目标范围而非硬保证,实际帧率还受光照影响:暗光下自动曝光会拉长曝光时间,曝光时间一旦超过帧间隔,帧率就会掉下来。比如你设了 30fps,帧间隔是 33.3ms,如果 AE 算出需要 50ms 曝光,实际帧率就会掉到 20fps 左右。想让夜景预览不掉帧,要么提高 ISO 上限压缩曝光时间,要么接受掉帧。
CameraX 的预览由框架统一调度,内部会根据 UseCase 组合做 Surface 复用和分辨率协商。这里有个细节值得注意:当你同时绑定Preview和ImageAnalysis时,框架倾向于让两者共享同一个分辨率,以减少额外拷贝。如果你的分析需要更高分辨率,就得显式配置ResolutionSelector,但代价是增加一次缩放或者多一路流,帧率可能下降。这个取舍在 Camera2 里是显式的,在 CameraX 里是隐式的,不读文档很容易忽略。
2.3 高阶功能的支持度差异
高帧率慢动作:Camera2 通过CONTROL_AE_TARGET_FPS_RANGE设到 120/240 需要设备在getSupportedHighSpeedVideoFpsRanges里声明支持,并且要创建createConstrainedHighSpeedCaptureSession。CameraX 在较新版本提供了Camera2Interop配合的方式,但不如 Camera2 直接。
多摄与逻辑多摄:Camera2 可以枚举所有物理相机 ID,读取LENS_FACING和焦距,实现真正的镜头切换。CameraX 通过CameraSelector加ZoomState实现变焦,物理镜头的切换由框架决定,应用层拿不到精细控制权。如果你的业务需要“0.5x 超广角 + 1x 主摄 + 3x 长焦”三档精确切换,Camera2 更可靠。
RAW 与专业模式:只有 Camera2 能在ImageFormat.RAW_SENSOR上输出,且需要设备支持REQUEST_AVAILABLE_CAPABILITIES_RAW。CameraX 目前不提供 RAW 输出通道。
HDR 与夜景:反过来了,CameraX 的扩展接口封装了厂商的 HDR、夜景、人像虚化,调用简单且自动降级。Camera2 想做这些只能自己组合曝光帧做多帧合成,工作量大得多。
3. Camera 老 API 的实操与适用边界
虽然不推荐新项目使用,但维护老工程时绕不开它,而且它的代码结构足够简单,适合用来理解相机的基本流程。
3.1 最小可用预览:二十行代码跑起来
@Suppress("DEPRECATION") private fun startLegacyPreview(holder: SurfaceHolder, camera: Camera) { camera.setPreviewDisplay(holder) val params = camera.parameters val candidates = params.supportedPreviewSizes .filter { it.width * 3 == it.height * 4 } // 优先 4:3 .sortedBy { kotlin.math.abs(it.width - 1280) } val size = candidates.firstOrNull() ?: params.supportedPreviewSizes.first() params.setPreviewSize(size.width, size.height) params.previewFormat = ImageFormat.NV21 params.focusMode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE camera.parameters = params camera.setDisplayOrientation(calcDisplayOrientation()) camera.startPreview() } @Suppress("DEPRECATION") private fun calcDisplayOrientation(): Int { val info = cameraInfo val degrees = when (displayRotation) { Surface.ROTATION_0 -> 0 Surface.ROTATION_90 -> 90 Surface.ROTATION_180 -> 180 else -> 270 } return if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { (360 - (info.orientation + degrees) % 360) % 360 } else { (info.orientation - degrees + 360) % 360 } }这段代码里有三个需要注意的点。setPreviewSize必须在setPreviewDisplay之后、startPreview之前设置,顺序反了在某些设备上会闪退。setDisplayOrientation只影响预览显示,不影响takePicture出来的 JPEG 方向,后者需要在拍照回调里自己旋转。前置摄像头的公式和后置不同,那个(360 - ...)是为了处理镜像,写反了自拍就是反的。
3.2 拍照、对焦与参数设置的坑
takePicture的回调顺序是ShutterCallback→RawCallback→JpegCallback,但实际设备上不保证都触发,RawCallback在很多机型上直接不回调。所以不要把关键逻辑挂在 Raw 上。对焦方面,FOCUS_MODE_CONTINUOUS_PICTURE在部分设备上会导致拍照瞬间画面模糊,稳妥的做法是先切到FOCUS_MODE_AUTO触发一次对焦,在autoFocus回调里再调takePicture。
还有一个经典坑:Camera.Parameters的set操作是全局的,如果你在预览线程和拍照线程同时改参数,会出现竞态。老代码里常见“拍照时把预览分辨率改大”这种写法,结果就是拍完预览变形。正确的做法是拍照用setPictureSize,预览分辨率不要动。
3.3 老 API 还留着干什么
除了低版本兼容,Camera 老 API 还有一个实际用途:在极低端设备上作为降级方案。有些低端机型的 Camera2 实现是 LEGACY 级别,走 Camera2 反而比 Camera1 更慢更不稳定。我见过一个案例,某扫码应用在低端机上用 Camera2 预览延迟超过 200ms,切回 Camera1 降到 80ms。所以如果产品线覆盖很广,保留一条 Camera1 的降级路径是合理的,但要控制好代码隔离,别让两套逻辑互相污染。
4. Camera2 完整链路:从打开设备到拿到一帧图
Camera2 的代码值得认真写一遍,因为它把相机的真实工作方式暴露得最清楚。理解了这套模型,再用 CameraX 就会知道框架在背后做了什么。
4.1 三层模型:CameraDevice、CaptureSession、CaptureRequest
三者的关系可以这么类比:CameraDevice是相机机身,CaptureSession是装上镜头并接好输出线之后的配置,CaptureRequest是按一次快门。机身只能打开一次,Session 可以在不同输出组合之间重配,Request 可以每帧都不同。
打开设备是异步的,openCamera之后回调onOpened才能用。这里要处理三种回调:onOpened正常打开,onDisconnected设备被抢占(比如另一个应用打开相机),onError出现致命错误。onDisconnected和onError里都必须调用camera.close(),否则下次打开会失败。我见过不少崩溃是因为onError里没关设备,导致ERROR_CAMERA_IN_USE一直循环。
Session 的创建同样是异步的,createCaptureSession在onConfigured之后才能下发 Request。Session 的创建有开销,频繁重配会导致预览卡顿,所以能复用就复用,只有输出 Surface 集合变化时才重配。
4.2 预览与拍照的请求构建
private fun openCamera(manager: CameraManager, id: String) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return manager.openCamera(id, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { cameraDevice = device createPreviewSession(device) } override fun onDisconnected(device: CameraDevice) { device.close() cameraDevice = null } override fun onError(device: CameraDevice, error: Int) { device.close() cameraDevice = null } }, backgroundHandler) } private fun createPreviewSession(device: CameraDevice) { val texture = previewView.surfaceTexture ?: return texture.setDefaultBufferSize(previewWidth, previewHeight) val previewSurface = Surface(texture) val jpegReader = ImageReader.newInstance( captureWidth, captureHeight, ImageFormat.JPEG, 2 ) device.createCaptureSession( listOf(previewSurface, jpegReader.surface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { captureSession = session val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(previewSurface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(30, 30)) }.build() session.setRepeatingRequest(request, null, backgroundHandler) } override fun onConfigureFailed(session: CameraCaptureSession) { // 记录日志并降级分辨率重试 } }, backgroundHandler ) }ImageReader.newInstance的maxImages建议设为 2。设 1 容易在连续拍照时阻塞,设太大则会积压 buffer 占内存。JPEG 的ImageReader在拿到Image之后必须close(),否则下一次拍照拿不到回调——这是最常见的“第二次拍照无响应”原因。
拍照请求的构建和预览类似,但要用TEMPLATE_STILL_CAPTURE:
fun takePhoto() { val device = cameraDevice ?: return val session = captureSession ?: return val reader = jpegReader ?: return val request = device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { addTarget(reader.surface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) set(CaptureRequest.JPEG_ORIENTATION, computeJpegOrientation()) }.build() session.capture(request, object : CameraCaptureSession.CaptureCallback() { override fun onCaptureCompleted( session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult ) { val exposure = result.get(CaptureResult.SENSOR_EXPOSURE_TIME) val iso = result.get(CaptureResult.SENSOR_SENSITIVITY) Log.d(TAG, "exposure=$exposure ns, iso=$iso") } }, backgroundHandler) }JPEG_ORIENTATION的计算是个高频错误点。它的公式和预览的setDisplayOrientation不同,正确写法是:后置摄像头取(sensorOrientation - deviceRotation + 360) % 360,前置摄像头取(sensorOrientation + deviceRotation) % 360,然后前置还要考虑是否镜像(JPEG_FLIP)。这里如果把预览公式直接搬过来,拍出来的图大概率是歪的。
4.3 手动控制与 3A:参数怎么算
Camera2 把自动曝光、自动对焦、自动白平衡(合称 3A)拆成了可独立控制的模式位。想手动控制,先把CONTROL_MODE设为CONTROL_MODE_OFF,再分别设置CONTROL_AE_MODE_OFF、CONTROL_AF_MODE_OFF、CONTROL_AWB_MODE_OFF,否则你设的曝光值会被自动算法覆盖。
曝光时间的单位是纳秒。曝光时间上限由帧率决定:目标帧率 30fps 时,单帧间隔 33.3ms,曝光时间理论上限是 33,333,333ns,超过就会掉帧。实际使用中建议留 10% 余量,控制在 30ms 以内。ISO 的取值范围可以从SENSOR_INFO_SENSITIVITY_RANGE读取,典型是 100 到 3200。两者共同决定画面亮度,公式可以近似理解为:亮度正比于 曝光时间 × ISO / 光圈面积平方。手动模式下调整顺序建议是:先固定 ISO 在较低值(比如 100)保证画质,再调曝光时间;如果曝光时间到顶还偏暗,再抬 ISO。
对焦距离用LENS_FOCUS_DISTANCE,单位是屈光度(1/米),0 表示无穷远。手动对焦在微距场景下有用,但要注意部分设备的对焦行程是非线性的,扫焦时需要小步长多次下发 Request,一次跳太大会失焦。
白平衡用COLOR_CORRECTION_MODE配合COLOR_CORRECTION_GAINS,四个增益分别是 R、G_even、G_odd、B。这个参数在不同设备上的基准值差异很大,没有通用值,只能实测调整。如果你的业务不是专业的色彩处理,不要碰这块。
4.4 线程模型与 Surface 生命周期管理
Camera2 的所有回调都需要一个 Handler,通常开一个HandlerThread专门跑相机相关操作,注意不要用主线程。原因有两个:Session 创建和请求下发都是阻塞的,放主线程会卡 UI;回调频率高,主线程处理不过来。
Surface 的生命周期是另一个坑区。TextureView的SurfaceTexture在onSurfaceTextureDestroyed之后就失效了,如果此时 Session 还持有这个 Surface,会抛IllegalArgumentException。正确的关闭顺序是:session.close()→device.close()→reader.close()→texture.release(),每一步都要判空。反过来,如果TextureView还没准备好就创建 Session,surfaceTexture为 null,Session 会创建失败。
页面切换时的处理也要注意。onPause里释放相机,onResume里重新打开,但如果用户快速切后台再切回来,可能出现“上一次的 close 还没完成,新的 open 就来了”的竞态。稳妥的做法是加一个状态标记,onResume里检查cameraDevice == null才打开,同时在onOpened里再检查一次当前页面是否还在前台,不在就立即关闭。
4.5 分辨率与格式选择:几个必须算清楚的数字
分辨率选择不是越大越好。预览用previewSize应该和屏幕显示区域的比例匹配,否则会拉伸。假设你的预览控件宽高比是 3:4,却选了 16:9 的分辨率,画面就会被压扁。正确做法是先读控件测量尺寸,再在supportedPreviewSizes里找宽高比最接近的。
拍照分辨率则要考虑内存。一张 4000×3000 的 JPEG,解码成 ARGB_8888 的 Bitmap 需要4000 × 3000 × 4 = 48MB,这还没算 CameraX 或 Camera2 内部的 buffer。如果要做图像处理,建议拍照分辨率控制在 1920×1440 以内,或者用采样率解码。
YUV 格式的数据量也要心里有数。ImageFormat.YUV_420_888的大小约为width × height × 1.5。1080×1440 的一帧就是1080 × 1440 × 1.5 ≈ 2.3MB,30fps 下每秒产生 70MB 数据,如果分析线程处理不过来,内存会迅速上涨。ImageReader的maxImages和Image.close()的及时性直接决定了会不会 OOM。
5. CameraX 工程化落地:少写代码但不等于少思考
CameraX 的 API 表面上很简单,但配置项背后都有取舍,配错了不会报错,只会表现成“预览有点糊”“分析帧率上不去”这类模糊症状。
5.1 三个 UseCase 的职责与组合方式
Preview负责显示,ImageCapture负责拍照,ImageAnalysis负责把帧送进算法。三者可以任意组合绑定,但组合数量会影响底层流的数量。CameraX 在内部会尽量复用底层流,能合并的合并,合并不了的才真正开多路。
val providerFuture = ProcessCameraProvider.getInstance(context) providerFuture.addListener({ val provider = providerFuture.get() val preview = Preview.Builder() .setResolutionSelector( ResolutionSelector.Builder() .setAspectRatioStrategy( AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY ) .build() ) .build() .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) } val capture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setJpegQuality(90) .build() val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .setResolutionSelector( ResolutionSelector.Builder() .setResolutionStrategy( ResolutionStrategy( Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER ) ) .build() ) .build() .also { it.setAnalyzer(analysisExecutor) { image -> try { processImage(image) } finally { image.close() } } } provider.unbindAll() provider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, capture, analysis ) }, ContextCompat.getMainExecutor(context))bindToLifecycle的调用位置很重要,必须在前台且LifecycleOwner处于 STARTED 之后,否则会抛异常。unbindAll()不是必须的,但如果要切换相机(前后摄切换)或者重新配置,先解绑再绑定比直接重复绑定更稳。
5.2 分辨率与背压策略:两个最容易被忽略的配置
背压策略决定分析线程处理不过来时怎么办。STRATEGY_KEEP_ONLY_LATEST是丢弃旧帧,只保留最新一帧,适合实时性优先的场景(比如扫码)。STRATEGY_BLOCK_PRODUCER是阻塞生产者,不丢帧但会导致预览卡顿,适合必须处理每一帧的场景(比如录制分析)。默认是KEEP_ONLY_LATEST,多数情况下这个默认值是对的,但如果你的算法处理后要和时间戳对齐,丢帧就需要额外处理。
分辨率策略决定框架怎么选分辨率。ResolutionStrategy有三个回退规则:FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER、FALLBACK_RULE_CLOSEST_HIGHER、FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER。选“higher”意味着优先保证清晰度但会增加处理压力,选“lower”意味着优先保证帧率。我在扫码场景里一般指定 1280×720 并允许向下回退,因为这个分辨率下二维码识别足够,且单帧处理时间能控制在 20ms 以内。
还有一个隐式行为值得注意:当你同时绑定Preview和ImageAnalysis时,两者的分辨率是联动的。如果分析要求 1080p 而预览只想要 720p,框架可能会把两路都拉到 1080p,导致预览更耗电。想让两者独立,需要显式指定各自的分辨率策略,并在真机上验证实际生效值——通过Preview.getResolutionInfo()可以读到最终使用的分辨率。
5.3 通过 Camera2Interop 补齐底层能力
CameraX 不暴露的底层参数,可以通过Camera2Interop.Extender注进去。常见的有三种用法。
第一种是固定帧率。某些场景需要稳定 30fps 而不是波动帧率:
val builder = Preview.Builder() Camera2Interop.Extender(builder) .setCaptureRequestOption( CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(30, 30) )第二种是关掉自动曝光,做固定参数采集:
Camera2Interop.Extender(previewBuilder) .setCaptureRequestOption( CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF ) .setCaptureRequestOption( CaptureRequest.SENSOR_EXPOSURE_TIME, 16_666_666L ) .setCaptureRequestOption( CaptureRequest.SENSOR_SENSITIVITY, 200 )第三种是读取帧的元数据。ImageAnalysis的ImageProxy里带ImageInfo,其中getTagBundle()可以拿到部分 Camera2 信息,但更完整的还是要在setAnalyzer里通过Camera2Interop注册的CameraCaptureSession.CaptureCallback获取。这条路径不如直接用 Camera2 顺畅,如果元数据需求很重,说明选型该往 Camera2 靠。
注意:
Camera2Interop注入的参数不会在设备不支持时自动降级,设了不支持的值可能导致预览黑屏。每次注入都要包在try-catch里,并在真机上做兼容性验证。
5.4 和协程、Flow 一起用的时候注意什么
CameraX 的回调本身不是挂起函数,直接在里面调suspend方法需要套runBlocking,这会阻塞分析线程,帧率立刻掉下来。正确的做法是用一个单线程的Executor作为分析线程,在里面启动协程(CoroutineScope(analysisDispatcher).launch),并且保证image.close()在协程结束后调用。
如果想用 Flow 把分析帧串起来,可以用callbackFlow:
fun analyzeFrames(analysis: ImageAnalysis): Flow<ImageProxy> = callbackFlow { val analyzer = ImageAnalysis.Analyzer { image -> trySend(image) } analysis.setAnalyzer(analysisExecutor, analyzer) awaitClose { analysis.clearAnalyzer() } }这里有个必须记住的约束:ImageProxy是有生命周期的,必须在下一个帧到来之前close(),否则缓冲区会被耗尽,分析直接停摆。如果下游处理是异步的,就不能把ImageProxy直接往 Flow 里丢,要么在里面拷贝出需要的字节数组,要么用ImageProxy.close()之后只传数据。我倾向于后者:先转成需要的格式(比如 NV21 字节数组),再close(),然后才把数组发出去。
NV21 的转换有个简单算法:
fun ImageProxy.toNv21(): ByteArray { val yPlane = planes[0] val uPlane = planes[1] val vPlane = planes[2] val ySize = width * height val out = ByteArray(ySize + ySize / 2) // Y 分量 var pos = 0 for (row in 0 until height) { yPlane.buffer.position(row * yPlane.rowStride) yPlane.buffer.get(out, pos, width) pos += width } // VU 交错,NV21 是 V 在前 U 在后 val chromaSize = width * height / 4 for (i in 0 until chromaSize) { val row = i / (width / 2) val col = i % (width / 2) out[ySize + i * 2] = vPlane.buffer.get(row * vPlane.rowStride + col * vPlane.pixelStride) out[ySize + i * 2 + 1] = uPlane.buffer.get(row * uPlane.rowStride + col * uPlane.pixelStride) } return out }注意rowStride和pixelStride的差异,很多设备上rowStride大于width(有对齐填充),直接按width读会错位,表现是图像右边缘出现斜纹。
6. 常见问题与排查实录
相机模块的 bug 大多不是逻辑错误,而是时序、兼容性和配置问题。这一节把高频问题整理成可对照的排查表,并展开几个典型案例。
6.1 问题速查表
| 现象 | 高概率原因 | 排查动作 |
|---|---|---|
| 预览黑屏 | 权限未授予 / Surface 未就绪 / Session 创建失败 | 检查onConfigureFailed回调与权限状态 |
| 预览拉伸变形 | 预览分辨率与控件比例不一致 | 打印实际分辨率,按比例调整控件或换分辨率 |
| 拍照方向错误 | JPEG_ORIENTATION算错 | 分别验证前后摄、四种旋转状态 |
| 第二次拍照无响应 | ImageReader的 Image 未 close | 检查onImageAvailable里是否 close |
| 分析帧率上不去 | 背压策略或分辨率过高 | 换KEEP_ONLY_LATEST,降分辨率 |
| 切后台再回来崩溃 | 旧设备未关闭就重开 | 检查 close 顺序与状态标记 |
| 低端机预览卡顿 | Frame 拷贝过多 / 分辨率过大 | 减少拷贝,降分辨率 |
| 使用中黑屏 | 设备被抢占(onDisconnected) | 提示用户并重新打开 |
6.2 预览拉伸、旋转、镜像:三个高频视觉问题
拉伸的本质是 Surface 的尺寸和画面比例不匹配。TextureView默认按控件大小铺满,如果传入的画面是 4:3 而控件是全屏 9:16,就会被拉长。解决方式有两种:一是改控件尺寸,让控件宽高比等于预览分辨率宽高比;二是用GLSurfaceView或者重写onMeasure做裁剪。我更推荐第一种,改动小且不会引入额外的渲染开销。
旋转的问题在于三个角度要分清:传感器方向(SENSOR_ORIENTATION,固定值,后置常见 90,前置常见 270)、设备旋转(Display.getRotation(),动态)、显示方向(最终画面应该转多少)。三者组合出 4 种设备状态 × 2 种摄像头位置 = 8 种情况,每一种都要验证。测试时不要只在竖屏下点一遍,横屏、反向横屏、倒竖屏都要过。
镜像只影响前置摄像头。setDisplayOrientation里已经处理了预览镜像,但拍照出的 JPEG 默认是不镜像的,用户会看到“预览里是镜像的,拍出来是反的”。如果产品要求一致,需要在保存后做一次水平翻转,或者通过JPEG_FLIP参数控制(部分设备支持)。
6.3 黑屏、崩溃、状态错乱:生命周期相关的一类问题
Camera2 的onDisconnected是最容易被忽略的回调。它触发的原因很杂:另一个应用打开了相机、系统资源紧张、锁屏、甚至某些厂商的省电策略。触发之后CameraDevice就失效了,必须重新openCamera。但重开需要时间,如果这期间用户还在操作,就需要一个“相机恢复中”的中间态。
另一个高频问题是多页面共用相机。比如列表页有个扫码浮层,详情页也有一个,两个页面同时openCamera就会冲突,后打开的会因为ERROR_CAMERA_IN_USE失败。解法是做一个全局的相机占用标记,或者在页面切换时先释放再申请。CameraX 的bindToLifecycle在这一点上更友好,因为它跟着 Lifecycle 走,页面不可见就自动解绑。
还有一类是权限授予后的状态恢复。用户第一次拒绝权限,第二次在设置里打开,回到应用时相机不会自动打开——因为onResume里没检查权限状态变化。稳妥的做法是在每次onResume都检查一次权限并比对上一次的状态。
6.4 性能与内存:掉帧、发热、OOM 的排查顺序
按这个顺序排查,基本能覆盖八成性能问题。
第一步,确认是否有多余的拷贝。ImageProxy到Bitmap再到算法输入,如果中间转了两三次,数据量翻几倍。能用ImageProxy直接读就不要转 Bitmap,能用 YUV 就不要转 RGB。
第二步,确认分辨率是否过高。分析用 1080p 和 720p,单帧处理时间可能差一倍。真正的实时分析,720p 甚至 480p 就够,识别精度不会明显下降。
第三步,确认分析耗时是否超过帧间隔。30fps 下单帧预算 33.3ms,如果你的算法要 50ms,帧率必然掉到 20fps 左右,而且会积压。这时候要么降分辨率,要么降帧率,要么把算法拆成多帧增量处理。
第四步,确认是否有泄漏。相机相关的对象(CameraDevice、CameraCaptureSession、ImageReader、Surface)如果没关,会一直持有 native 内存。用 Profiler 看 native 内存曲线,如果在页面退出后还在涨,基本就是没关。
发热问题通常是多路流叠加导致的。同时开预览 + 拍照 + 分析 + 录像,底层可能要开四路流,ISP 满负荷。如果产品不是必须,尽量在拍照时暂停分析,或者降低预览帧率。
7. 选型决策:按场景给结论,按成本给路径
对比了这么多,最后的落点还是要给出可执行的判断。我把常见的几类业务场景整理成决策表,再补一个迁移路径的建议。
7.1 决策表与判断顺序
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 简单拍照上传 | CameraXImageCapture | 代码少,旋转和生命周期都自动处理 |
| 扫码 / 条码识别 | CameraXImageAnalysis+ 720p | 实时性与功耗平衡,扩展性好 |
| 实时图像算法(人脸、OCR) | CameraX 起步,必要时转 Camera2 | 先验证可行性,性能不够再下沉 |
| 专业摄影 / 手动曝光 | Camera2 | 需要逐帧控制与完整元数据 |
| RAW 拍摄 / 后期处理 | Camera2 | 唯一支持通道 |
| 多摄精确切换(超广角/长焦) | Camera2 | 需要物理相机枚举与焦距控制 |
| 高帧率慢动作 | Camera2 | 需高速会话与帧率范围控制 |
| 低端机兼容 | CameraX 优先,保留 Camera 降级 | 框架自动处理兼容分级 |
| 老工程维护(API 21 以下) | Camera | 没有替代方案 |
判断顺序建议是:先看功能硬需求,再看兼容范围,最后看维护成本。功能硬需求是红线,比如必须 RAW,那就只能 Camera2,其他维度不用比了。兼容范围决定可用集合,如果必须支持 API 19,Camera 是唯一选择。前两条都通过的情况下,默认选 CameraX,因为它省下的维护成本在长期看是实打实的。
7.2 从 Camera2 迁到 CameraX 的渐进策略
不建议一次性重写,风险太大。我试过比较稳的路径是按功能块分批迁移。
第一步,先把预览迁过去。用Preview替换原来的 Session + Surface 逻辑,拍照和分析暂时还走 Camera2。这个阶段要解决的是两套 API 共存时的相机占用冲突——CameraX 绑定了,Camera2 就 open 不了。做法是迁移期间只保留一条链路,用开关控制。
第二步,迁拍照。ImageCapture的接口和 Camera2 的TEMPLATE_STILL_CAPTURE在使用上差异不大,主要注意ImageCapture.OutputFileOptions的用法和OnImageSavedCallback的错误分支。如果原来的拍照带自定义参数(比如固定 ISO),需要评估能不能用Camera2Interop补上,补不上就先不迁。
第三步,迁图像分析。这一步最值得迁,因为ImageAnalysis的背压策略和生命周期管理确实省事。迁移时注意ImageProxy的格式和原来ImageReader的格式要对齐,旋转角度从imageInfo.rotationDegrees读,不要再用自己那套换算。
第四步,清理老代码。确认新链路稳定运行两周以上,再删掉 Camera2 的相关实现。这期间保留旧代码作为灰度降级开关,出问题能快速回滚。
每一步之间都要留观察期,重点看三类指标:崩溃率、预览首帧时间、拍照成功率。这三个指标稳定了,说明迁移没引入新问题。
我个人在实际操作中的体会是,相机模块的选型争论往往高估了“功能差异”的影响,低估了“长期维护”的成本。很多团队花两周用 Camera2 实现了一个带手动控制的功能,然后在接下来一年里为各种机型适配反复打补丁。如果这个手动控制不是核心卖点,用 CameraX 加 20% 的功能妥协换来 80% 的稳定性,其实是更划算的账。反过来,如果功能就是产品命脉,那就老老实实啃 Camera2,把兼容性分级、错误回调、状态机都写扎实,别指望框架兜底。