1. 适配问题到底出在哪里:从一次灰度事故说起
相机适配,凡是亲手做过移动端或嵌入式摄像头方案的人,都得承认这是整个项目里最磨人的模块之一。很多人以为相机适配就是"打开摄像头、把预览画面显示出来",真正经历过一次灰度事故才会明白,问题远不止如此。
前年我们给一款App接入新的预览方案,灰度到5%的时候,后台开始陆续收到两类反馈:一部分中低端机型黑屏,另外一部分预览严重卡顿,甚至偶尔弹"无法连接到相机"。最诡异的是崩溃率并没有明显上涨——也就是说,代码层面没有崩,但用户体验已经跌到谷底。
当时排查了一整天,最后定位到问题出在两处:第一,适配层在配置预览流时写死了一个 1920×1080@30fps 的组合;第二,代码里对所有机型统一开启了 HDR。这两件事单看都不致命,但叠加在低端SoC平台上就出问题了——ISP负载扛不住,帧率直接掉到15fps以下,而黑屏更直接:代码请求的"尺寸+帧率+像素格式+HDR使能"这个组合,在设备的 StreamConfigurationMap 里根本不存在,会话创建直接失败。
这才是相机适配最真实的日常。
1.1 事故还原:不是崩溃,是"组合不存在"
那次事故的本质,不是某个API用错了,而是我们的适配策略默认了"所有设备的相机能力都一样"。同一批代码,高端机上跑得丝滑,低端机上却连会话都建不起来。
干这一行时间长了你会发现,相机模块的差异是三维叠加的:
- 硬件维度:传感器像素、靶面尺寸、ISP算力、镜头模组、防抖马达,每台机器都不一样。
- 系统维度:不同安卓版本的API行为不同,各厂商的HAL实现不同,不同SoC平台的ISP驱动也不同。
- 场景维度:预览、扫码、人脸、录像、直播,对能力的需求完全是另一套逻辑。
这三个维度一叠加,组合数几乎是无穷的。所以"写完一套配置就能跑遍所有机器"的代码,现实中不存在。
正因为这样,我后来把相机适配的思路彻底改了,不再去"适配机型",而是建立一套从探测、决策、执行到反馈的闭环。核心就是三个策略:能力探测前置、分层抽象、场景驱动调优。
2. 策略一:能力探测优先于写死参数
这是我最想强调的一条。很多团队做相机适配,第一反应是维护一个"机型白名单",某个型号出问题了就往里面加特判。说实话,这个思路在机型少的年代还能凑合,现在完全行不通。
2.1 为什么机型白名单靠不住
同一款型号的手机,不同批次可能换了传感器。你说你针对"某品牌某型号"做了校准,结果用户手里的机器是第二批货,传感器换了,ISP参数变了,你的白名单和特判全部作废。
更要命的是,厂商系统升级也会改变HAL行为。同一个机型,安卓12和安卓13上的相机表现很可能是两回事。白名单写得再细,也追不上新机发布和系统迭代的速度。
所以我的原则很简单:运行时让设备自己告诉我们它能做什么,而不是靠我们在代码里猜它应该能做什么。
2.2 能力探测到底探什么:一份清单
以安卓平台为例,核心信息来源是 CameraCharacteristics,尤其是其中的 StreamConfigurationMap。以下维度是必探的:
- 输出能力:所有输出尺寸与帧率组合、支持像素格式列表,注意帧率组合要查高帧率视频的专用接口,预览和录像的配置不是一套。
- 对焦系统:AF模式列表、对焦距离范围(能不能微距、能不能无穷远)。
- 曝光系统:AE/AWB模式、ISO范围、曝光时间范围、AE目标帧率范围。
- 稳定系统:OIS光学防抖和EIS电子防抖的支持情况。
- 计算摄影能力:HDR模式、RAW输出、多帧降噪、超高分辨率输出。
- 多摄能力:物理摄像头ID集合、逻辑多摄组合、广角/长焦/景深摄像头映射。
- 基础特性:镜头朝向(前置/后置)、闪光灯模式、畸变校正、时间戳来源。
这里面最容易漏的是"组合校验"。很多团队查了尺寸表,发现 1080p 存在,就认为1080p预览没问题。但实际上 1080p + 30fps + YUV格式 + HDR 使能,可能就不是有效组合。组合校验必须用 StreamConfigurationMap 的接口逐项核对,不能只查单一维度。
2.3 探测结果缓存与更新策略
相机能力矩阵不是每次打开摄像头都要重新探测。我的做法是:进程启动后探测一次,缓存到内存里;App升级时清缓存重新探测;后台如果发现某机型上报了"缓存结果与实际能力不一致",可以主动打点上报,为后续维护提供线索。
这样做的原因是探测本身有开销,首次打开相机要快,等用户开始扫码或拍照时再异步补齐完整能力矩阵。缓存分层:第一层是必备能力(尺寸、帧率、格式),用于会话创建;第二层是增强能力(HDR、RAW、防抖等),用于策略决策。前者同步读取,后者异步补齐。
2.4 探测之后怎么选配置:一个可落地的选择逻辑
探测只是第一步,怎么用探测结果生成配置才是关键。我写过一个选择逻辑,思路是"目标优先、逐级降档":
找到一个分辨率,其长宽比与业务期望一致,面积最接近目标分辨率;然后校验该分辨率下是否支持目标帧率;再校验格式和HDR组合是否成立;如果有任何一项不满足,就按降级顺序往下走。
核心降级顺序可以是:
- 先降帧率:30 -> 24 -> 15
- 再降分辨率:1080p -> 720p -> 480p
- 关掉计算摄影:HDR、多帧降噪关闭
- 最后更换像素格式:从 YUV_420_888 切到更通用的 NV12 等
代码大概长这样:
fun resolvePreviewConfig( caps: DeviceCapabilities, targetSize: Size, targetFps: Int, needHdr: Boolean ): PreviewConfig { val candidates = caps.previewSizesByAspect(targetSize.width, targetSize.height) for (size in candidates.sortedBy { it.area() }) { val fpsSupported = caps.isFpsSupported(size, targetFps) if (!fpsSupported) continue val hdrSupported = !needHdr || caps.isHdrSupported(size, targetFps) if (hdrsupported) { return PreviewConfig(size, targetFps, needHdr) } } // 降级:关 HDR // 降级:降帧率 24/15 // 降级:降分辨率 return fallbackConfig(caps) }我说一个实测体会:90%的线上问题不是"分辨率不存在",而是"分辨率+帧率+格式+HDR的组合不存在"。所以配置选择逻辑的核心不是找最大分辨率,而是校验组合。
3. 策略二:分层抽象,让业务代码永远不碰硬件细节
如果你只在单个App里做相机功能,分层可能显得多余。但只要你的相机能力被多个业务模块共用——扫码、人脸识别、拍照、录像、直播——分层就是刚需。
3.1 三层结构设计
我通常会把相机模块拆成三层:
| 层级 | 职责 | 典型接口 |
|---|---|---|
| 底层驱动封装层 | 对接系统Camera API,维护能力表、会话创建、参数归一化 | openCamera / createSession / applyRequest |
| 策略决策层 | 接收业务场景诉求,结合能力矩阵产出最终配置 | resolve(scene, capability) |
| 业务服务层 | 只暴露"扫码/人像/视频"等高语义接口,不感知硬件 | startScene(ScanScene) |
底层封装层最重要的工作是参数归一化。不同平台的AF模式、AE模式、防抖模式的命名和枚举都不同,统一映射到内部枚举后,策略决策层和业务层就完全不用关心设备差异。
3.2 适配层的关键职责:归一化、降级、错误翻译
归一化之外,最容易被忽视的是错误翻译。厂商返回的错误码五花八门,有的设备在相机被占用时返回 ERROR_CAMERA_IN_USE,有的返回 ERROR_MAX_CAMERAS_IN_USE,有的干脆统一返回 ERROR_CAMERA_SERVICE 之类的大类错误。适配层要把这些错误统一翻译成内部枚举,业务层才可能给出正确的用户提示和重试逻辑。
另一个关键职责是降级路由。会话创建失败时,不能让业务直接感知"创建失败",而是要自动走降级链路。这个链路本质上就是策略一里的降级顺序,只不过放在分层结构里实现:
fun createSessionWithFallback(config: SessionConfig): Session { var current = config repeat(3) { try { return rawCreateSession(current) } catch (e: CameraException) { current = current.reduce() // 按降级顺序生成低一档配置 } } throw CameraException(LOW_LEVEL_CREATE_FAILED) }注意这里有个细节:降级次数一定要有限制,而且每次降级后都要打点上报。如果三次降级仍然失败,说明这台设备的问题不是配置能解决的,这时候再返回错误,并带上最终的降级报告,方便后台分析。
3.3 分层之后,接入新机型到底有多轻松
分完层之后,新机型接入就变成了一件事:在底层驱动封装层补全能力表,确认归一化逻辑没有踩到这台设备的特殊坑,其他两层完全不用动。
而业务侧新增场景也变得简单。比如要加一个"文档扫描"场景,业务服务层加一个场景定义,策略决策层填上该场景的目标配置(比如需要畸变校正、需要连续对焦、不需要高帧率),适配层的能力表已经能支撑,整个过程不会超过半天。
还有一个容易被忽略的好处:分层之后,Debug变得极其舒服。可以在底层注入一个模拟数据源,把真实相机替换成预设图像流,业务层和策略层不用接真机就能跑通大部分逻辑。这在自动化测试里价值非常大。
4. 策略三:场景驱动链路调优,不搞一刀切
相机适配做到后期,你会发现一个残酷的事实:**没有一套"万能配置"能同时满足所有业务场景。**拍照想要高分辨率,扫码想要低延迟,录像想要稳定帧率,直播想要低发热。这四件事哪怕在同一台设备上,诉求都是互相打架的。
4.1 不同业务场景对相机的诉求完全不同
扫码场景,我们要的是低分辨率YUV帧回调、快速对焦、曝光尽量锁定。分辨率高了反而增加CPU/ISP负担,帧率上不去,二维码在暗光下反而不容易解出来。
人像/人脸场景,要的是连续对焦灵敏度、人脸检测回调、画面畸变修正。这时分辨率不宜太高,不然人脸检测的耗时上不去,但美颜算法又需要足够的图像细节,所以要在720p到1080p之间找平衡。
普通拍照,要的是主摄最大可用分辨率,单帧可能不够,最好能用上HDR或者多帧合成。对焦反而是按一下对一下,不需要全程追焦。
视频录制和直播,要的是帧率和码率稳定性优先,编码器硬编支持优先。这里防抖和发热控制往往比解析力更重要。
4.2 场景参数矩阵:一张表讲清楚
我习惯把不同场景的参数整理成一张矩阵表,后续做优化、做灰度、做问题排查都直接查表:
| 场景 | 目标分辨率 | 目标帧率 | 对焦策略 | 3A策略 | 关键降级边界 |
|---|---|---|---|---|---|
| 扫码 | 720p | 30 | 连续对焦/固定焦距 | AE锁定 | 帧率下限15fps |
| 人像 | 1080p | 30 | 连续AF+人脸优先 | AWB自动 | 分辨率下限720p |
| 普通拍照 | 主摄最大 | 单帧 | 单次对焦 | HDR/多帧可用 | 曝光/ISO上限兜底 |
| 视频录制 | 1080p/4K | 30/60 | 连续AF | 防抖优先 | 帧率稳定度优先 |
| 直播 | 720p/1080p | 30 | 连续AF | 码率优先 | 优先降码率而非帧率 |
这张表不是拍脑袋定的,每条都来自线上数据。比如"扫码帧率下限15fps"——实测很多低端机在暗光下扫二维码头,帧率一旦掉到15fps以下,连续帧之间的模糊会让解码成功率暴跌。这条降级边界后来直接做成了策略层的红线,宁可降低分辨率也绝不跌破帧率。
4.3 链路分离与动态切换
场景调优不只是参数不同,链路本身也要分离。预览链路、拍照链路、分析链路(用于扫码/人脸/OCR)、录像链路,是四条独立的数据通路,不能一股脑全开。全开的结果就是ISP和DDR带宽被耗尽,中端机直接卡死。
动态切换场景时,我的经验是尽量不重建CameraSession。重建意味着几十毫秒到上百毫秒的黑屏和重新曝光,有些场景根本等不起。正确的做法是:
- 先停掉不再需要的分析流或录像流
- 更新RepeatingRequest模板,替换场景参数
- 再绑定需要的目标Surface(比如从预览流切换到拍照流)
- 最后用设置新的会话交替配置替换旧配置
这套流程在安卓Camera2和CameraX上都有对应的机制,难点不在API,而在于工程上要有一个明确的状态机,避免切换过程中收到用户连点操作导致状态错乱。
4.4 场景冲突的处理原则
多场景并发是躲不开的,最常见的是"录像中点击拍照"。两个场景抢同一个主摄的曝光和ISP资源,处理不好就是录像掉帧+拍照糊片。
我的方案是:优先保证主场景不崩。录像时点击拍照,如果设备支持逻辑多摄,优先用超广角或者副摄做拍照;如果不支持,则降低录像帧率到可接受的范围,给拍照留出资源。如果平台实在扛不住同时操作,宁可拦截拍照请求给出提示,也不要让录像质量崩掉。这个决策要跟产品提前对齐,不能纯技术自己拍板——但在工程上,主场景优先这条原则不变。
5. 三个策略之外的保命工程:监控、灰度与回归
策略定好了,代码写完了,不代表就稳了。相机模块的特殊性在于,很多问题只在部分机型、部分系统、部分光照条件下出现,没有监控和灰度体系,出了问题你甚至不知道该不该回滚。
5.1 监控指标怎么定才有效
相机模块的监控不能只看崩溃率。以下几个指标是我每次都要求团队埋上的:
- 打开耗时:从用户点击到首帧出现,分机型聚合,超过阈值即告警。
- 出帧稳定性:连续N帧帧间间隔的p95/p99。这比平均帧率更能反映卡顿。
- 黑屏/无帧上报:预览会话超时无帧,是最直接的适配失败信号。
- 会话异常率:创建失败次数、降级命中路径、最终使用的档位。
- 硬件错误回调:系统Camera HAL主动上报的错误类型。
埋点维度必须是"机型+系统版本+场景"三键组合。只看聚合值没有意义,一台低端机把整体p95拉高了,你会误判成所有机型都有问题。
5.2 相机专项灰度的正确打开方式
相机模块灰度有两个容易踩的坑:一是一次灰度太多参数,出问题定位不到元凶;二是只看崩溃率,漏了卡顿这类体验问题。
我的做法是参数维度独立开关:HDR开关、帧率开关、分辨率开关、防抖开关各自独立。灰度某个新方案时,先把旧方案保留,再用反向名单把"没测过的机型"全部留在旧链路,已覆盖机型逐步放量。
放量节奏上,低中高三个梯度各放一定比例。每个阶段盯两个东西:
- 指标差是否超过阈值,尤其是出帧稳定性和会话异常率
- 降级报告里出现了哪些新机型
提示:相机灰度最怕的是"看起来没问题"。崩溃率没涨,不代表体验好了。必须监控黑屏率和帧稳定性,否则低端用户就是在默默忍受卡顿,而你完全不知道。
5.3 真机回归矩阵:买哪些机器、测哪些用例
真机采购不能只看"旗舰机全家桶",要让中低端机占大头。我的矩阵思路是:主流SoC平台至少各一台——高通、联发科、以及其他自研平台;必须有一台千元机;必须有一台老系统版本设备;必须有一台搭载逻辑多摄的新机。
测试用例至少要覆盖这些路径:冷启动打开相机、前后摄快速切换、连续多张连拍、长时间录像发热、低光场景切换、亮度突变(开关灯)、扫码场景连续触发、息屏唤醒后快速恢复、两个应用同时抢相机。
特别强调亮度突变这一个用例,AE收敛算法在不同平台上的表现差异巨大,而且很容易被常规测试漏掉。
5.4 灰度日志保留与降级报告
灰度期一定要保留完整的降级报告日志,保存周期至少一个版本迭代。相机模块的问题往往不是当轮灰度就全部暴露的,很多坑是一个季度后系统升级才蹦出来。
降级报告里如果出现某个机型的汇聚,这就是优化方向的最可靠依据。我之前就有过这样的经历:一款中端机的降级报告连续三个版本都在同一个分辨率档位出现,后面专门针对它做了ISP参数校准,成功率一下提了十个百分点。没有日志,这种事只能靠猜。
我还有一个习惯:碰到新机型,第一件事不是跑Demo,而是把它的能力矩阵打印出来读一遍。看它支持哪些尺寸、哪些帧率、HDR能不能和30fps共存、防抖走的是EIS还是OIS。这些信息基本能预测这台机器上线后会不会出问题,比对着白名单猜准得多。相机适配做到后面,其实就是把"猜"换成"探测"、"试"换成"验证"的过程。