调相机的问题调多了,你会发现一个挺反直觉的现象:真正让人在原地卡上半天的,往往不是图像参数调优,也不是算法耗时,而是CameraManager这一层看着“没什么可看”的薄壳。它对外暴露的 API 就那么十几个,文档也写得四平八稳,可一旦遇到“相机列表为空”“打开超时”“手电筒点不亮”“应用切后台再回来相机就废了”这类问题,绕来绕去最后都会落到这一层。我前后断断续续花了两三周时间,把 CameraManager 相关源码顺着调用链往下捋了一遍,从getSystemService一路跟到相机服务的会话创建,中间踩了不少坑,也纠正了好几个自己以前想当然的认知。这篇 CameraManager 源码梳理,就是把这些过程完整记下来——它讲的是这个类到底怎么跟系统相机服务建立连接、怎么枚举设备、怎么发起一次打开、怎么感知设备状态变化,适合已经能写 Camera2 基本流程、但一遇到时序和并发问题就抓瞎的中高级开发者,也适合刚接手相机模块、想快速建立全局地图的同学。看完至少能做到:拿到一份相机相关日志,知道该去哪个文件里找线索,而不是盲目加打印。
1. 先搞清楚这层壳到底包了什么
很多人第一次读 CameraManager 的源码会失望,因为它实在是太短了——主文件几百行,大量还是注释和 javadoc。但短不代表简单,它的设计精髓在于把跨进程通信的所有脏活都收进了一个静态内部单例,外层的 CameraManager 只是一个面向应用的门面。理解这个分层,后面所有奇怪的时序问题都能解释得通。
1.1 CameraManager 与 CameraManagerGlobal 的两层结构
从结构上看,CameraManager是一个final类,构造方法是隐藏的,应用只能通过Context.getSystemService(Context.CAMERA_SERVICE)拿到实例。也就是说,它由系统服务容器托管,生命周期跟着 Context 走,跟相机设备本身没有关系。这一点很关键:拿到 CameraManager 不代表相机可用,也不代表你持有了任何硬件资源。
真正干活的是内部的CameraManagerGlobal,一个进程级单例,通过CameraManagerGlobal.get()获取。它持有三样核心东西:与相机服务通信的远端接口对象、相机 ID 列表的本地缓存、以及一组状态表(相机可用性、手电筒状态)。所有注册的回调也都挂在它身上,用一个列表统一管理。
这里的设计取舍很值得琢磨。为什么不让每个 CameraManager 实例各自持有一份连接?因为相机服务是系统级单例,一个进程注册十几个监听毫无意义,反而会让服务端的监听器列表膨胀。做成进程级单例之后,同一个进程里无论多少个组件申请监听,最终只会在服务端注册一次,回调在客户端统一分发。代价是:单例的生命周期跟进程绑定,你没法主动“断开”它,CameraManager也没有提供注销整个连接的方法,只能逐个 unregister 回调。
注意:正因为连接是进程级共享的,某些组件注销回调时如果写错(比如传了新的回调对象而不是注册时的那个),会导致监听器泄漏。表现为“应用退出后系统日志里还能看到这个进程的相机状态回调”。这个坑我在一个多进程应用里踩过,排查了半天才发现是注销时
new了一个新对象。
1.2 三个常见误解,源码视角的纠正
误解一:getCameraIdList()返回的是硬件列表。实际上它返回的是“系统相机 API 视角下当前可见的相机 ID”,是经过过滤的。源码里存在两套列表:服务端的全量列表和客户端缓存的状态表,最终返回前会根据可用状态和隐藏标记做筛选。这意味着同一个设备在不同时刻调用,结果可能不一样,尤其是设备刚启动、相机服务还在初始化的时候。
误解二:相机 ID 是固定的字符串。物理上确实是固定编号,但逻辑相机(多摄封装)和物理相机的 ID 是两套体系。你在应用层拿到的"0"可能是一颗封装了广角+长焦的逻辑相机,它的物理子相机 ID 是"2"、"3"。源码里这个映射关系不是 CameraManager 维护的,而是通过特性字段(LOGICAL_MULTI_CAMERA_PHYSICAL_IDS)暴露出来的,CameraManager 只负责把这份特性描述原样递给你。
误解三:openCamera返回成功就代表相机真的能出图。打开成功只代表会话在服务端建立起来了,CameraDevice拿到的只是一个控制句柄。真正的出图能力取决于后续的会话配置和请求下发,硬件层面的错误会在更后面的回调里才暴露出来。
1.3 读这层源码前该准备的工具
我不建议直接打开编辑器从第一行读到最后一行,效率极低。更实用的方式是带着一条调用链去读。我的准备清单大致是这样:
| 工具/材料 | 用途 | 说明 |
|---|---|---|
| 接口定义文件 | 看跨进程调用的方法签名 | 客户端和服务端各有一份,方法名基本一一对应 |
| 服务端实现类 | 看参数校验和权限判断 | 客户端抛的异常大多是服务端返回的错误码转译的 |
| 日志过滤脚本 | 快速抓取相机相关日志 | 按标签过滤比全文搜索快得多 |
| 一份完整的打开流程 trace | 对照时序 | 自己跑一遍录下来最靠谱 |
还有一个容易被忽略的点:AIDL 生成的接口在不同系统版本上方法签名会有增删。你在旧版本上读到的调用顺序,换到新版本可能就多了一层校验。所以我读的时候习惯先确认版本,再看方法,避免把不同版本的逻辑混在一起记。
2. 连接是怎么建立的:从服务发现到监听注册
整个 CameraManager 的所有能力,都建立在“客户端连上了相机服务”这个前提上。连接不是构造时建立的,而是懒加载——第一次真正需要服务的时候才去连。这个设计直接影响了很多问题的表现:为什么第一次调用耗时明显更长,为什么监听注册晚于设备变化会丢事件,答案都在这里。
2.1 懒连接与首次触发的时机
CameraManagerGlobal内部维护一个连接状态和一个锁。所有需要服务的公开方法,进入后第一件事都是检查是否已连接,如果没连就发起连接。这个动作在锁内完成,所以并发调用时只有第一个线程真正执行连接逻辑,其他线程等锁。
连接过程本身不复杂:通过服务管理器按名字找到相机服务,拿到远端接口对象,然后在服务端注册一个状态监听器。这里有个细节值得注意——源码里特意保留了远端接口对象的强引用并加了注释说明。原因是这类系统服务的句柄如果只被弱引用持有,在某些低内存场景下可能被回收,导致后续调用拿到空句柄直接崩。这种问题在压力测试里才暴露,平时很难复现。
实操心得:如果你在排查“偶发性的相机调用空指针”,可以先怀疑连接是否被意外中断过。相机服务进程重启(比如崩溃拉起)会导致原有句柄失效,客户端需要有重连机制。CameraManager 内部做了重连,但重连后之前注册的回调需要重新生效,这部分逻辑就在状态监听的实现里。
2.2 注册监听的完整链路
注册可用性监听的调用链大致如下(按常见版本的结构简化):
// 应用侧调用 cameraManager.registerAvailabilityCallback(executor, callback); // CameraManager 内部:参数校验 + 转发到单例 public void registerAvailabilityCallback( @NonNull @CallbackExecutor Executor executor, @NonNull AvailabilityCallback callback) { // 空校验 CameraManagerGlobal.get().registerAvailabilityCallback(executor, callback); } // 单例内部:连接服务 + 存入本地列表 public void registerAvailabilityCallback(Executor executor, AvailabilityCallback callback) { synchronized (mLock) { connectCameraServiceLocked(); // 首次调用才真正连接 // 包装成带 Executor 的条目,加入本地列表 // 若列表中已有相同回调,会被替换而不是重复添加 } }关键点在于:回调被包装后存进本地列表,服务端只注册一个统一的监听器。服务端状态变化时,统一监听器收到通知,再遍历本地列表逐个分发到各自指定的线程上。
这个设计解决了一个现实问题:不同组件可能希望回调在自己的线程上执行,比如 UI 组件要在主线程,后台分析模块要在工作线程。如果每个回调都往服务端注册,服务端要维护一堆跨进程回调对象,成本极高。统一收口后,服务端只认识一个监听器,客户端内部做线程调度,两边都清爽。
2.3 注册时机的坑:为什么回调没被触发
最典型的场景是这样的:应用启动,注册了可用性监听,然后立刻去查相机列表。结果监听的onCameraUnavailable一直没来,直到某次相机被别的应用占用才触发。这不是 bug,是因为注册时并不会补发当前所有设备的状态——某些版本会补发,某些不会,行为差异跟具体实现有关。
更可靠的做法是:注册监听之后,主动调用一次getCameraIdList(),把当前状态当作基线,之后只处理增量变化。这样无论补发与否,你的状态表都是对的。这个模式我在三个不同项目里都用过,比单纯依赖回调可靠得多。
另一个容易忽略的点是注销的对称性。注册时传的是(executor, callback)二元组,注销时也要传同样的二元组。如果 executor 每次都是新创建的(比如Executors.newSingleThreadExecutor()直接写在参数里),那注销时根本匹配不上,因为内部比较的是对象引用。这种代码在 code review 里我见过不止一次。
// 错误写法:executor 每次都是新对象,注销永远匹配不上 cameraManager.registerAvailabilityCallback( Executors.newSingleThreadExecutor(), callback); cameraManager.unregisterAvailabilityCallback(callback); // 无效 // 正确写法:executor 与 callback 都持有引用 private final Executor mCameraExecutor = ...; private final AvailabilityCallback mCallback = ...; // 注册与注销使用同一组引用3. 相机枚举与特性查询:看似最简单的两个接口
getCameraIdList()和getCameraCharacteristics()大概是使用频率最高的两个方法,也是我见过被误用最多的两个。前者被当成“获取硬件清单”,后者被当成“随便调,不耗时”。实际源码里这两个方法都有缓存,也都有各自的失效逻辑。
3.1 getCameraIdList 的调用链与过滤逻辑
调用进入后会走到单例的同名方法,大致流程是:确保已连接 → 从服务端拉取全量 ID 列表 → 按本地状态表过滤 → 更新缓存 → 返回副本。
这里有两个设计细节值得说。
第一,返回的是副本。源码里明确做了拷贝,避免调用方修改内部缓存。这个防御性拷贝在小列表上开销可以忽略,但说明作者考虑过被滥用的可能。
第二,列表更新有前后对比逻辑。服务端返回新列表后,客户端会跟缓存的旧列表对比,找出新增和移除的设备,再据此触发相应的可用性回调。这个逻辑存在的意义是处理“客户端还没注册监听时设备已经变化”的情况。换句话说,列表拉取本身也是一个状态同步的时机,不只是查询。
注意:如果你在设备启动早期(比如开机广播里)频繁调用这个方法,可能会拿到空列表或只有部分设备。原因不是接口坏了,而是相机服务还没完成设备枚举。合理的做法是配合可用性监听做重试,而不是死循环轮询。
3.2 CameraCharacteristics 的懒加载与缓存键
getCameraCharacteristics(cameraId)的内部实现有缓存,键就是相机 ID,值是一份特性描述的封装对象。第一次调用会跨进程取数据,之后直接命中缓存。
缓存带来一个副作用:特性是不会刷新的。这在常规场景下没问题,因为相机硬件特性不会动态变化。但有些设备和系统实现里,某些特性值会随着外部状态变化(比如某个相机被其他进程占用后可用输出尺寸变化),这时候缓存就可能返回过期数据。我在一个多摄设备上遇到过类似情况:主摄被占用后,副摄的可用尺寸列表本应扩大,但拿到的还是旧值。
规避方式不是去想办法清缓存(没有公开接口),而是在关键路径上重新获取一次特性,或者在检测到可用性变化后主动重建相关对象。代价是轻微的性能损失,换来的是状态一致性。
3.3 逻辑相机与物理相机的映射处理
多摄时代,这个映射关系是绕不开的。应用层拿到的相机 ID 是逻辑相机,它封装的物理子相机通过特性字段暴露。源码里 CameraManager 本身不解析这个映射,只是把特性原样返回,解析责任在调用方。
处理逻辑大致是:先用getCameraIdList()拿到逻辑相机列表,对每个 ID 取特性,如果没有物理 ID 字段,说明是单摄,直接用;如果有,就遍历物理 ID 做进一步查询。要注意物理相机 ID 不一定能被openCamera直接打开,在很多实现里它们是“仅可查询”的,打开会返回错误。
| 场景 | 是否可直接打开 | 建议做法 |
|---|---|---|
| 逻辑相机 ID | 可以 | 正常走打开流程 |
| 物理子相机 ID | 通常不可以 | 只用于查询特性,不用于打开 |
| 隐藏相机 ID | 不可以 | 需要系统权限,普通应用拿不到 |
这个表格里的第二行是我踩过的坑:早期做双摄切换时,我直接拿物理 ID 去打开,结果在某些设备上成功、某些设备上直接报错,行为完全不统一。后来统一改成“只对逻辑相机打开,物理相机只查特性”,问题一次性消失。
4. openCamera 全链路:一次打开到底发生了什么
这是整个 CameraManager 里最复杂的一段,也是问题最集中的地方。我把它拆成客户端准备、跨进程调用、句柄回填三个阶段来看,每一阶段都有自己的失败模式。
4.1 客户端阶段的参数校验与对象构造
openCamera的签名有好几个重载,最终都会收敛到一个核心实现。进入后先做一系列校验:相机 ID 不能为空、回调不能为空、执行器不能为空(新版本要求显式传入执行器)。这些校验失败会直接抛参数异常,不会走到跨进程调用。
校验通过后进入异步打开流程。这里有个容易被忽略的设计:打开操作是在后台线程发起的,但异常可能通过两种途径抛出——一种是同步抛出(参数问题、服务未连接),一种是异步回调(服务端拒绝、硬件忙)。如果你的代码只 try-catch 了同步异常,异步失败就会静默丢失,表现为“回调一直不触发,也没有任何日志”。
// 完整覆盖两种失败路径的写法 try { cameraManager.openCamera(cameraId, stateCallback, backgroundExecutor); } catch (CameraAccessException e) { // 同步失败:权限、参数、服务不可用 handleSyncFailure(e); } catch (IllegalArgumentException e) { // 同步失败:参数非法 handleBadArgument(e); } // 异步失败在 StateCallback.onError 里处理,两个都要写源码里构造的核心对象是一个设备实现类的实例,它内部持有一组回调桩(传给服务端用)和状态机。构造完成后才发起跨进程调用,先把对象建好再连接,这个顺序保证了服务端回调进来时客户端对象已经就绪,不会出现“回调先到、对象后建”的空窗。
4.2 跨进程调用与服务端返回的错误码
跨进程调用传入的参数包括:相机 ID、客户端回调桩、包名、特性描述、以及一些会话参数。服务端收到后做权限检查、设备存在性检查、占用状态检查,任何一项不过就返回错误码。
客户端把这些错误码转译成CameraAccessException,其中错误原因字段很关键。常见对应关系如下:
| 错误原因 | 触发条件 | 典型排查方向 |
|---|---|---|
| 相机被占用 | 其他进程已打开同一相机 | 检查是否有残留进程未释放 |
| 设备已断开 | 服务端会话异常终止 | 检查服务进程是否重启过 |
| 相机数量超限 | 单进程打开数量达到上限 | 确认是否泄漏了未关闭的设备 |
| 权限不足 | 缺少相机权限或被策略拦截 | 检查权限声明与运行时授予 |
| 参数非法 | ID 格式错误或不存在 | 用正确方式重新枚举列表 |
| 未知错误 | 服务端内部异常 | 抓服务端日志看具体堆栈 |
这张表我建议直接抄进项目的问题排查文档里,因为客户端拿到的原因字段粒度有限,很多情况下只能定位到“大概哪一类”,再细就得看服务端日志。
4.3 句柄回填与状态机的初始状态
服务端返回成功后,客户端会拿到一个远端设备接口对象,把它回填到之前构造的设备实例上。这个回填动作有个前置判断:如果设备已经被关闭(比如用户在等待期间退出了页面),回填就不能执行,否则会出现“关掉的对象又被复活”的诡异状态。
源码里用一个状态字段来标记,只有在“打开中”状态下才允许回填,回填后转为“已打开”。如果此时状态已经是“已关闭”,客户端会主动把刚拿到的远端句柄释放掉,避免服务端资源泄漏。
这个逻辑看起来很细,但它解决的是真实存在的竞态:用户点了打开相机,然后在回调到来前迅速退出页面触发关闭,两个操作在两条线程上跑。没有这个判断,就会出现“页面已经销毁但相机还开着”的经典问题,表现出来就是耗电异常、下次打开报占用。
实操心得:如果你的应用存在“快速进出相机页面导致相机被占用”的问题,不要只在外层加标志位,要确认关闭逻辑真的等到了打开流程结束。我在一个项目里用了一个简单的原子计数器:打开时自增,关闭时置位标记并在打开回调里检查,检查到已关闭就立刻释放。这个模式比加锁简单,也不会阻塞主线程。
4.4 关闭时序与资源释放
close()的实现相对简单,但时序上有讲究。它会先置状态、再调用远端接口的关闭方法、最后清理本地引用。关闭过程中如果还有未完成的请求,服务端会做清理。
一个常见误区是认为close()返回就代表资源已经彻底释放,可以立刻重新打开。实际上服务端的清理是异步的,紧接着重新打开同一相机有概率失败。可靠的做法是在关闭回调或者短暂延迟后再重开,或者干脆复用同一个设备对象,只在必要时才销毁重建。
5. 手电筒与状态监听:不起眼但最容易翻车的部分
手电筒功能代码量不大,但它是 CameraManager 里除打开之外最容易出问题的地方。原因很简单:它跟相机设备的占用状态强耦合,而很多开发者把它当成独立开关来用。
5.1 setTorchMode 的实现路径与前置条件
调用进来后同样走单例,最终跨进程通知服务端。服务端会检查:该相机是否存在、当前是否被其他客户端占用、是否支持手电筒模式。任何一项不满足就返回失败。
关键点在于手电筒通常必须独占相机。也就是说,如果某个应用正在使用这颗相机做预览,另一个应用调setTorchMode大概率会失败。这个行为在不同设备上表现不一致,有的设备允许共用,有的直接拒绝。所以正确的做法是:先注册手电筒状态回调,根据回调给出的可用性决定是否显示开关,而不是无条件显示一个可以点的按钮。
// 推荐:先注册监听,再根据状态决定 UI cameraManager.registerTorchCallback(executor, new TorchCallback() { @Override public void onTorchModeChanged(String cameraId, boolean enabled) { // 可用,更新 UI 状态 } @Override public void onTorchModeUnavailable(String cameraId) { // 不可用,禁用开关 } });5.2 状态回调的分发与线程模型
手电筒回调和可用性回调的分发机制一致:统一收口、本地遍历、按各自执行器投递。这里有个细节值得注意——回调里的状态可能是合并后的。比如短时间内多次开关手电筒,客户端可能只收到最终状态,中间的抖动被合并掉了。这对 UI 展示是好事,但如果你在回调里做业务逻辑(比如统计使用次数),就会漏计。
我的处理方式是:UI 状态用回调驱动,业务计数在调用侧自己记录,两条路径互不干扰。这样既保证了界面正确,也保证了数据准确。
5.3 可用性变化与热插拔的感知
当相机被其他进程占用、或者硬件被拔出(外接相机场景)、或者系统策略禁用某颗相机,客户端都会收到可用性变化通知。这个通知的触发源在服务端,客户端的统一监听器收到后更新本地状态表,再遍历分发给所有注册者。
这里有个容易忽略的点:通知是异步的,状态表更新和回调分发之间可能有时间差。如果在回调里立刻去查getCameraIdList(),拿到的可能还是旧列表。所以我在回调里从不直接查列表,只根据通知携带的信息更新自己的状态,需要完整列表时再单独拉一次并做容错。
6. 常见问题与排查技巧实录
理论讲完了,这一节全是实战。下面这些场景我基本都亲手处理过,有的是自己的代码,有的是帮别人看的。
6.1 问题速查表
| 现象 | 优先怀疑 | 排查手段 |
|---|---|---|
| 相机列表为空 | 服务未就绪/权限缺失 | 抓服务端日志看枚举结果 |
| 打开一直无回调 | 异步失败被吞/线程阻塞 | 检查 onError 是否实现、执行器是否死锁 |
| 提示相机被占用 | 上次会话未释放 | 查进程是否残留、关闭是否走到 |
| 手电筒点不亮 | 相机被占用/不支持 | 看手电筒可用性回调 |
| 切后台回来打不开 | 生命周期处理不当 | 确认关闭时机与重开时机 |
| 多摄切换失败 | 用了物理相机 ID | 改用逻辑相机 ID |
| 偶发空指针 | 连接被中断 | 查服务进程是否重启过 |
6.2 日志抓取的正确姿势
相机相关日志分散在应用进程和系统服务进程里,只抓应用侧是看不到全貌的。我的习惯是同时抓两侧,按标签过滤后按时间排序合并。应用侧看调用时序和异常,服务侧看参数校验和状态流转,两边一对照,问题基本就定位了。
具体操作上,先用一条命令持续抓取,再复现问题,最后按时间窗口截取。不要一上来就抓全量日志,文件会大到打不开,而且关键信息淹没在噪音里。过滤技巧是先用宽条件抓一轮,找出关键标签,再用窄条件抓第二轮。
注意:日志里可能包含设备标识类信息,抓取后要注意脱敏。我在团队里推的做法是日志只保留必要字段,其余用占位符替换,避免排查过程本身变成新的风险点。
6.3 三条踩出来的经验
第一条,永远不要假设回调一定会来。无论是打开、关闭、状态变化还是手电筒,都要有超时兜底。我给所有相机操作都加了一个几十秒的超时定时器,超时后主动释放资源并记录日志。这个机制在真机上救了我好几次,因为在极端场景下回调确实会丢。
第二条,打开和关闭要成对出现在同一个生命周期作用域内。我习惯把相机资源的获取和释放绑定到页面的可见性生命周期上,页面不可见就释放,可见就重新获取。这样即使中途出现异常,作用域结束时的清理也能兜住。
第三条,多摄设备上不要硬编码相机 ID。我见过太多代码写死"0"当后摄、"1"当前摄,换到某些设备上直接反了。正确做法是通过特性里的朝向和位置字段做筛选,筛不出来再回退到列表顺序。这段筛选逻辑不长,但能省掉大量设备兼容问题。
// 按特性筛选后置主摄的思路 String targetId = null; for (String id : cameraManager.getCameraIdList()) { CameraCharacteristics c = cameraManager.getCameraCharacteristics(id); Integer facing = c.get(CameraCharacteristics.LENS_FACING); if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) { // 再根据其他字段优选,比如是否支持某类能力 targetId = id; } }这套逻辑写下来大概二三十行,比硬编码靠谱得多,也比依赖设备型号判断优雅得多。
我个人在整个梳理过程中最大的体会是:CameraManager 这层的代码好读,但它依赖的时序关系难懂。所有看起来“多余”的判断——状态回填前的检查、列表对比、缓存、防御性拷贝——背后都对应着一类真实发生过的线上问题。读的时候不要只记方法名,多问一句“这个判断是为了防什么”,收获会大得多。