接手一个相机相关的疑难问题,最怕的就是"现象只有一个,原因可能有十几种"。前阵子就遇到过一次:同一份业务代码,跑在两台设备上,getCameraIdList()返回的相机数量一个是 3 个,一个是 5 个,前端展示的切换按钮全乱了。翻业务代码翻了两天没结果,最后逼着自己把 CameraManager 这一层的源码从头到尾捋了一遍,才发现问题出在"逻辑相机可见性"和"物理相机枚举"的差异上。这次 CameraManager 源码梳理之后我才真正意识到,绝大多数相机层的疑难杂症——打开超时、回调不来、闪光灯抢不到、可用性回调抖动——根源都在这几百行代码里。
CameraManager 是应用侧能摸到的相机栈最外层门面,它把跨进程的 Binder 调用、服务端状态机、回调派发线程统统包在一个看起来人畜无害的类里。这篇文章不讲 API 怎么调,Handler 怎么传这种手册级别的知识,我打算把源码切开,一层一层看它内部到底怎么运转:谁持有真正的服务代理,相机列表从哪里查出来的,openCamera 之后回调在哪个线程、按什么顺序触发,手电筒和可用性监听背后又挂着什么状态机。适合已经能用 Camera2 接口跑通预览和拍照、但一遇到设备兼容问题就抓瞎的开发者,也适合想往系统层或相机中间件方向走、需要一个切入点的人。
1. 把 CameraManager 放回调用链:它到底是哪一层
1.1 同名不同命:应用侧与系统侧的两个 CameraManager
打开源码目录之前,得先分清一件事:签名叫 CameraManager 的类不止一个。应用侧那个在android.hardware.camera2.CameraManager,是应用进程里直接持有对象、通过getSystemService(Context.CAMERA_SERVICE)拿到的;系统侧还有一层负责相机服务启动与策略管理的服务类,跟应用侧这个不是同一个东西。源码梳理时如果把两者混在一起看,会越看越乱。
应用侧的 CameraManager 本质上是个纯门面(Facade)。它自身几乎不承载状态,真正持有ICameraService代理、维护回调表、管理服务连接的是它内部的静态单例CameraManagerGlobal。我最初读的时候跳过了这一点,看到 CameraManager 里大量方法只有两三行、直接转调CameraManagerGlobal.get(),还以为是代码没写完,实际上这是有意为之:CameraManager 是每个 Context 一份的实例,而跨进程连接和相机状态必须是进程级唯一的,否则 N 个实例就会有 N 份回调注册和服务连接。
这个设计带来一个很实际的后果:同一个进程里 A 模块和 B 模块各自调用getSystemService,拿到的是两个不同的 CameraManager 对象,但它们的registerAvailabilityCallback注册进去的回调,全都汇总到同一个 CameraManagerGlobal 的回调表里。这意味着你在 A 模块注销服务连接,或者进程里某个模块触发了一次服务重连,B 模块正在等待的回调也可能跟着受影响。理解了这一点,后面很多"回调忽然不来了"的现象就有解释方向了。
再往下看,CameraManagerGlobal 通过一个固定的 Binder 名称拿到相机服务代理,这个名称在整个框架里是硬编码的常量。服务端那一侧对应的是原生的相机服务,负责设备枚举、会话管理、权限校验,再往下才是给厂商留的硬件抽象层接口。所以完整的层次是:应用代码 → CameraManager → CameraManagerGlobal → Binder → 原生相机服务 → 硬件抽象接口 → 驱动。
1.2 一次 openCamera 请求穿过五层
要理解源码的组织方式,最直观的方式是跟着一次真实的请求走一遍。应用调用openCamera(cameraId, callback, handler),代码在应用进程内先做参数校验,然后同步调用进 CameraManagerGlobal,CameraManagerGlobal 立即创建出一个CameraDeviceImpl对象——注意这里设备还没真正打开,对象就先创建好了,处于"连接中"状态。
接着 CameraManagerGlobal 把自己的ICameraDeviceCallbacks实现作为参数,发起一次跨进程调用connectDevice。这个回调对象很关键,它是服务端反向通知应用的唯一通道,后续的打开成功、设备断开、发生错误、会话配置结果,全部经由它回传。服务端收到连接请求后校验调用者 uid 是否有相机权限、目标相机当前是否可用、当前进程已打开的相机数量是否超限,校验通过才真正去底层打开设备,然后把操作句柄ICameraDeviceUser返回给应用侧。
应用侧的CameraDeviceImpl拿到操作句柄后,把内部状态从"连接中"切成"已打开",再通过构造时指定的 Handler 把onOpened回调派发出去。所以从调用openCamera到onOpened真正执行,中间隔了一次完整的 Binder 往返加一次 Handler 排队,它不是同步返回的,也不是立即回调的。
这条链路上有五层值得分别看:门面层(参数校验与对象包装)、进程级单例(连接与分发)、Binder 代理层(序列化与限流)、服务端(策略与状态)、以及硬件抽象层(真正的驱动交互)。CameraManager 源码梳理的价值就在于把这五层各自的职责划清楚,出问题时能第一时间判断该在哪一层找原因。比如IllegalArgumentException一定是门面层抛的,CameraAccessException带CAMERA_IN_USE是服务端拒绝的,onError带ERROR_CAMERA_DEVICE通常是硬件层报上来的。
1.3 读源码前先建立的三条主线
我读这份源码时给自己立了三条主线,后面验证下来非常有用,这里也分享出来。第一条是对象归属:这个对象属于进程、属于 Context、还是属于某次会话。搞不清楚归属就必然踩到"以为注销了其实没注销"的坑。
第二条是线程归属:哪段代码跑在调用线程(大概率是主线程),哪段跑在 Binder 线程池,哪段跑在 CameraManagerGlobal 内部那个专门的 Handler 线程,哪段又跑在你自己传进去的 Handler 所在线程。源码里这三类线程交织得非常密,尤其是回调派发部分,稍不留神就会写出在 Binder 线程里更新 UI 的代码。
第三条是生命周期配对:注册与注销、打开与关闭、连接与断开必须成对出现。CameraManager 里几乎所有资源都是通过回调注册获取的,回调对象被全局表强引用持有,只要不注销,这个引用就不会释放,连带它内部捕获的 Activity、View、Listener 全部泄漏。
| 关注维度 | 典型容易搞错的地方 | 后果 |
|---|---|---|
| 对象归属 | 以为每个 CameraManager 实例有独立连接 | 重复注册回调、重复断开连接 |
| 线程归属 | 在回调里直接操作视图 | 线程校验异常、偶发崩溃 |
| 生命周期 | 只注销了设备回调没注销可用性回调 | 内存泄漏、回调错乱 |
这三条主线不是读完源码才总结出来的,而是边读边用来定位代码的索引。有了它们,看到任何一个方法,都能快速判断出它在整张图里的位置。
2. getSystemService 之后发生了什么:CameraManagerGlobal 才是干活的那个
2.1 服务注册表里的那行代码
getSystemService(Context.CAMERA_SERVICE)之所以能返回一个 CameraManager,是因为框架启动时在服务注册表里登记过一条记录。这条记录包含服务名、服务类以及一个懒加载的创建器,创建器里就是一句new CameraManager(context)。整个机制是"延迟创建 + 按 Context 缓存",也就是说第一次取的时候才 new,之后同一个 Context 拿到的都是同一个实例。
这个注册过程值得注意的点是:创建器里除了构造函数什么都没做。CameraManager 的构造函数里也没有发起任何跨进程调用。这意味着getSystemService这个动作本身成本极低,不会阻塞、不会抛异常、不会因为相机服务还没起来而失败。真正可能失败的操作全部推迟到了具体方法调用时。
这个设计对启动性能很友好,但也带来一个容易误判的现象:应用在启动阶段就取了 CameraManager 并注册了可用性回调,代码跑过去毫无异常,于是开发者以为一切正常,其实此时相机服务可能还没准备好,回调要等连接建立之后才会陆续到达。如果业务逻辑强依赖"注册完成即拿到当前状态",就会出问题。稳妥的做法是注册之后不假设立刻有回调,而是耐心等第一个状态通知,或者主动查询一次当前相机列表做兜底。
构造函数里还保存了 Context,我特意确认过它保存的是什么类型的引用。源码里存的是应用级 Context 而不是 Activity,这点很重要——如果存的是 Activity,那么进程级的服务对象就间接持有了界面,泄漏是必然的。自己在写类似的常驻服务包装类时,这条经验可以直接照搬。
2.2 CameraManagerGlobal 的单例与 ICameraService 的拿到方式
CameraManagerGlobal 是个标准的静态内部类单例,写法是持有者模式(Holder),保证类加载时线程安全且延迟初始化。它的成员里最关键的是ICameraService的代理引用,以及一个用来注册状态监听的回调对象。
代理的获取不是直接在构造函数里同步完成的。源码的做法是第一次真正需要服务时才去取,取不到就注册一个服务连接监听,等服务可用后通过回调拿到代理,再把代理赋给字段。这套"先尝试、失败则监听、成功则回填"的流程在系统服务里非常常见,原因是系统服务之间存在启动顺序依赖,谁也不能假设自己启动时别人一定就绪。
拿到代理之后,CameraManagerGlobal 还会做一件事:注册服务状态监听。这一步的意义在于,如果相机服务因为某种原因重启(系统资源紧张、服务崩溃恢复),应用侧能收到通知并重新获取代理,而不是拿着一个已经失效的代理继续调用。这是个很多人不知道但非常实用的稳定性设计,也是排查"应用跑久了相机就用不了、重启应用又好了"这类问题的关键线索。
服务代理拿到后,CameraManagerGlobal 会把它缓存起来供所有方法复用。这里有个细节:缓存的是代理对象而不是连接状态。代理对象即使在服务端重启后依然存在,只是调用会抛异常。所以判断服务是否可用不能看代理是否为空,而要看最近一次调用的结果或者服务状态监听给出的信号。
单例初始化完成后,CameraManagerGlobal 内部还有一个专门的 Handler 线程,用来串行化回调分发和重连逻辑。为什么不用调用线程?因为回调的触发源是 Binder 线程池,如果直接在 Binder 线程里分发,应用侧的回调就会跑在一个没有 Looper 准备、也没有任何顺序保证的线程上,多个回调并发执行会让业务代码彻底失控。用一个专用线程串行分发,就把并发问题关在了框架内部。
2.3 服务端死亡与重连:一个被低估的稳定性设计
服务连接监听里有个方法专门处理"服务断开"的情况,逻辑比我预想的细致。断开时它做的第一件事不是清空代理,而是遍历所有已注册的可用性回调,通知它们相机状态变为不可用。这一步很关键,因为应用侧持有的是 CameraDeviceImpl 对象,如果不主动通知,这些对象会永远停留在"已打开"状态,业务逻辑会一直等一个永远不来的回调。
接着它清理内部的设备映射表,把"相机 id → 设备对象"的对应关系全部置空。然后是重连:注册一个定时轮询,每隔固定间隔检查一次服务是否恢复,恢复后重新获取代理并重新注册监听。这个轮询间隔源码里是个常量,大约以秒为单位,不算激进,兼顾了及时性和开销。
这套机制解释了我在实际项目里见过的一个现象:某台设备进入低内存后系统回收了一部分服务进程,相机应用界面卡在"加载中"不动,但点击重新打开相机又能用。从源码角度看,问题就是应用侧没有正确处理服务断开时的状态通知——它只监听了设备级的onDisconnected,没监听可用性变化,而服务重启场景下最先触发的是后者。
还有一个常被忽略的点:服务断开时的状态通知是在内部那个专用 Handler 线程上分发的。如果你的回调里做了耗时操作,会阻塞后续所有回调的分发。我在回调里加过日志埋点,一开始写了个同步的文件写入,结果把后面几个回调全拖慢了,排查了半天才想起来线程模型这回事。这个教训很朴素但很值钱:回调里只做状态记录和轻量转发,重活一律丢到别的线程。
3. getCameraIdList 与 getCameraCharacteristics:一次跨进程查询的全部开销
3.1 从应用侧到相机服务侧的完整调用链
getCameraIdList()看上去就是返回一个字符串数组,源码里也确实只有几行:调用 CameraManagerGlobal 的对应方法,方法内部直接调服务代理的同名接口,返回结果直接透传。但透传不代表便宜,这一次调用是完整的跨进程往返。
服务端收到请求后做的事情不少:遍历当前所有已接入的相机设备,逐个检查其状态是否属于"存在且已就绪",把处于"未就绪"或"枚举中"状态的设备过滤掉,最后把剩余的相机 id 打包返回。过滤这一步是我在排查"相机数量不一致"问题时最大的收获——同一台设备在不同时刻调用,返回的列表长度可能不同,因为某些相机的初始化本身就有时序,尤其是外接相机或需要动态加载校准数据的模组。
调用链上还有一层权限与策略检查。服务端会根据调用者的身份决定是否展示某些相机,这部分逻辑对普通应用不可见,但对现象的解读非常关键。当你在某台设备上看到相机列表突然少了一个,先别怀疑代码,去看一眼设备是不是处于某种特殊模式,或者那个相机是不是被别的进程占用了。
从开销角度看,一次getCameraIdList()的成本大致等价于一次跨进程调用加上服务端的设备遍历。单次不算贵,但绝对不能高频调用。我在测试机上做过简单的计数,主线程连续调用几十次,帧率就开始抖了,因为每次调用都要经历一次完整的 IPC 序列化与反序列化。
// 错误示范:在绘制或滚动逻辑里反复查询 @Override public void onBindViewHolder(ViewHolder holder, int position) { try { String[] ids = cameraManager.getCameraIdList(); // 每次绑定都跨进程 holder.text.setText(ids.length + " 个相机"); } catch (CameraAccessException e) { holder.text.setText("不可用"); } }正确做法是查询一次、缓存结果、通过可用性回调感知变化后再刷新缓存。缓存的生命周期跟随界面,不要做成全局静态,否则服务重启后缓存就陈旧了。
3.2 相机 id 背后的逻辑相机与物理相机
相机 id 在源码里就是个字符串,但这串字符的含义在不同实现下差别巨大。早期设备上它基本等同于"后置""前置"这样的物理位置,从多摄方案普及之后,一个 id 背后往往是一组镜头的聚合体,也就是所谓的逻辑相机;而每个物理镜头又各自有独立 id,通过特性字典里的物理相机 id 列表暴露出来。
服务端的过滤逻辑只保证"这个 id 当前可以使用",不保证它背后是单颗镜头还是多颗。我在那台返回 5 个相机的设备上验证过,其中两个是逻辑相机的物理子相机,它们可以被单独打开,但打开后拿到的能力集和逻辑相机完全不同,连支持的输出尺寸都少一截。如果业务代码统一按"每个 id 都是一个完整相机"去处理,就会在切换时崩掉。
判断方式是从特性字典里读物理相机 id 列表:如果某个 id 的对应字段非空,说明它是个逻辑相机,包含了多颗物理镜头。这个字段在较新的框架版本上才有,老设备读不到,需要做判空兜底。我在代码里是这样处理的:先读逻辑相机列表,为空则回退到物理相机列表,两个都为空才认为这台设备不支持多摄。
还有个实际经验:不要用相机 id 的字符串形式去猜位置。有些设备后置是 "0"、前置是 "1",有些设备反过来,还有些设备后置主摄之外还有超广角、长焦各自独立的 id。唯一可靠的方式是读特性里的镜头朝向字段,按朝向分组后再按焦距或视角排序。我见过一段代码直接硬编码了 "0" 当后置,结果在一批设备上前置预览调用后置参数,画面全是噪点。
3.3 特性字典的解析与缓存代价
getCameraCharacteristics(cameraId)返回的是一个不可变的键值字典,内部实现是一层对服务端返回的原始元数据的解析。这个解析过程比想象中重:服务端返回的是一段扁平化的键值对数据,应用侧要按类型逐个还原成整型、浮点、数组、区间等具体对象,其中区间类型还要构造成对象数组。
我在源码里数过,一个典型相机的特性字典包含上百个键,解析全部走完需要分配大量小对象。这个开销单次看不出来,但如果每个相机都解析一遍、每次界面刷新又解析一遍,对启动速度和内存都有影响。更合理的策略是按需读取:只解析业务真正用到的少数几个键,其余交给框架内部懒解析。
不过这里有个权衡要注意:特性字典本身在应用侧是有缓存的,同一个 id 第二次取一般不会再走跨进程,所以"缓存字典对象"和"每次重新取"之间的差距没有想象中大。真正的差距在于你是否在短时间内对多个相机 id 各取一次。我做过对比,冷启动时一次性取四个相机的完整特性,耗时能到几十毫秒量级,放在启动主路径上会被用户感知到。
| 用法 | 调用次数 | 实测影响 |
|---|---|---|
| 启动时对所有相机取完整特性 | 4 次跨进程 | 主线程明显卡顿 |
| 只取当前选中相机的特性 | 1 次跨进程 | 几乎无感 |
| 取特性后缓存字典对象复用 | 1 次 + 内存驻留 | 推荐,需注意失效 |
缓存字典对象这件事有个前提:服务重启或相机热插拔后要主动失效。我的做法是把缓存和可用性回调绑定,收到任何状态变化就把缓存清空,简单粗暴但不会出错。
3.4 实测数据与"为什么不要在 onDraw 里调用"
我把几个典型调用的耗时整理成了下面这组观察值,测试环境是几台不同定位的设备,取的是多次调用的中位数,单位毫秒。这些数字不是标准答案,但量级关系是稳定的,可以作为写代码时的直觉参考。
| 操作 | 低端设备 | 中端设备 | 高端设备 |
|---|---|---|---|
| 获取相机 id 列表 | 3–8 | 1–3 | 0.5–2 |
| 单个相机特性全量解析 | 15–40 | 8–20 | 4–10 |
| 打开相机到 onOpened 回调 | 300–1200 | 150–500 | 80–300 |
| 设置手电筒模式往返 | 5–15 | 2–8 | 1–4 |
看到这组数据就明白为什么不能在绘制流程里调这些接口了。绘制每帧只有十几毫秒预算,一次特性解析就能吃掉大半,掉帧是必然的。更麻烦的是打开相机那几百毫秒的延迟,它直接决定了用户点击快门之后多久能看到画面,这也是为什么几乎所有相机应用都会在打开阶段放一个过渡动画。
我还遇到过一种更隐蔽的写法:在onDraw里调用getCameraIdList()判断是否要显示某个提示。因为返回值每次都一样,代码跑起来"看起来没问题",只有在低端设备上快速滑动时才会掉帧,而且掉得不明显,很容易被当成是图片加载的问题。这类问题的排查方法就是抓一次主线程的调用栈,看哪里有跨进程调用。
4. openCamera 的三段式链路与状态回调的真实派发顺序
4.1 参数校验阶段:Handler 与 Looper 的强绑定
openCamera的源码第一段是密集的参数校验,这部分虽然枯燥但决定了大量异常的实际来源。相机 id 为空、回调为空、回调所属线程没有 Looper,这三种情况都会直接抛IllegalArgumentException,而且是在发起任何跨进程调用之前就抛出来,所以看到这个异常时不用怀疑服务端,问题一定在调用方。
Handler 的处理逻辑值得单独讲。源码允许传 null,传 null 时会取当前线程的 Looper 构造一个 Handler;如果当前线程也没有 Looper,才抛异常。也就是说,在主线程调用并传 null 是合法的,回调会回到主线程;在子线程调用并传 null 会直接崩。很多"在后台线程打开相机必崩"的问题就出在这里。
我自己踩过一次这个坑的变体:在子线程里创建了 Handler 并传了进去,但那个 Handler 绑定的 Looper 是主线程的,于是回调回到主线程执行,而我在回调里做了一堆磁盘读写,直接把主线程卡住。源码给的自由度很大,代价是责任全在调用方。之后的项目里我统一规定:相机相关回调一律使用专门的工作线程 Handler,主线程只做界面刷新。
校验通过之后,源码会检查调用者是否有相机权限,这个检查是基于 uid 的,如果调用方是通过某种代理方式发起的,实际校验的是被代理方还是代理方要特别留意。跨进程调用场景下这类权限归属问题会造成"明明是同一个应用却能用或不能用"的诡异现象。
4.2 connectDevice:把回调对象交给服务端
真正发起连接时,应用侧会构造一个 Binder 回调实现对象,把它连同相机 id、调用者包名、调用者 uid 等参数一起传给服务端。这个回调实现对象是整条链路上最关键的枢纽,它内部维护着对 CameraDeviceImpl 的弱引用或直接引用,服务端所有状态变更都通过它回传。
服务端收到连接请求后按顺序做几件事:确认相机 id 对应设备确实存在、检查调用者权限、检查该相机是否被占用、检查当前进程已打开的相机数量是否超过上限、最后才是调用底层打开。每一步失败都会映射成一个具体的错误码回传给应用侧,应用侧据此构造CameraAccessException或触发onError回调。
这里有个源码细节值得记下来:占用检查和实际打开之间存在时间窗口。也就是说,即使服务端告诉你相机当前可用,你真正去打开的那一瞬间,别的进程可能刚好抢先了。所以打开失败要允许重试,不能假设"查过可用就一定打得开"。我在多进程协作的场景里遇到过这个问题,两个进程几乎同时打开同一台相机,一个成功一个拿到占用错误,重试机制是必需品。
另一个细节是打开数量上限。源码在服务端有一个全局计数,超过上限直接拒绝,错误码专门有一个表示"相机数量超限"。这个上限对不同设备取值不同,应用侧无法预知,只能通过错误码识别并做降级处理——最稳妥的策略是打开新相机前先确保旧相机已经关闭,而不是并行打开多个。
// 服务端连接逻辑的简化示意,帮助理解校验顺序 Status CameraService::connectDevice(const sp<ICameraDeviceCallbacks>& callbacks, const String16& cameraId, /* ... 调用者信息 ... */) { // 1. 设备是否存在 // 2. 权限是否满足 // 3. 是否已被占用 // 4. 全局数量是否超限 // 5. 真正打开并返回操作句柄 }4.3 StateCallback 的四种典型回调与状态机
拿到的 StateCallback 不是只有一个onOpened,常用的一共有四类。onOpened表示设备打开成功,可以开始配置会话;onDisconnected表示设备被断开,通常是别的进程抢占了优先权或者服务端重启;onError表示发生了不可恢复的错误,需要关闭设备重新打开;onClosed表示设备已关闭,这是你自己调用关闭之后才会收到的。
这四者的触发顺序和组合方式是有规律的,源码里对应一个显式的状态机。正常流程是onOpened→ 业务操作 → 主动关闭 →onClosed。异常流程里,如果设备被抢占,先收到onDisconnected,此时设备已经不可用,必须调用关闭以释放本地资源,然后才会收到onClosed。如果发生严重错误,收到的是onError,源码内部的实现会顺带把设备状态置为错误,调用关闭只是走个形式收尾。
我在实际项目里见过把onDisconnected当成普通关闭处理的代码,结果设备被抢占后不再重试,用户看到的是黑屏。正确处理是在onDisconnected里记录原因并通知上层,由上层决定是否需要重新打开。还有一个细节:onError之后框架不保证onClosed一定到达,所以清理逻辑不能只挂在onClosed上,否则会漏。
| 回调 | 触发条件 | 必须做的处理 |
|---|---|---|
| onOpened | 打开成功 | 配置会话、启动预览 |
| onDisconnected | 被抢占或服务重启 | 标记失效、通知上层、调用关闭 |
| onError | 底层错误 | 记录错误码、关闭并考虑重开 |
| onClosed | 本地关闭完成 | 释放引用、清理资源 |
这张表建议直接抄进团队的编码规范,比事后排查高效得多。
4.4 打开无响应时,怎么顺着源码定位
"调了 openCamera,回调一直不来",这是相机开发里最折磨人的问题之一。读源码之后我总结出一条排查路径,按这个顺序走基本能定位。
第一步确认线程。如果调用时传了 null 的 Handler 且在子线程,根本不会进入真正的打开流程,而是直接抛异常——如果没有抛异常说明调用是合法的,可以排除这一项。第二步确认权限,权限缺失时通常会有明确异常,但如果调用链路经过中间层,权限可能被吞掉变成静默失败。第三步确认相机可用性,在打开之前先查一次当前相机状态,如果能查到但打不开,大概率是占用或数量超限。
第四步才是真正需要看源码的地方:检查服务端是否收到了连接请求。这一步没有日志很难判断,实际做法是观察是否收到了任何回调——哪怕是一个onError,也说明链路是通的,问题出在校验;如果一个回调都没有,说明请求根本没到服务端或者回调对象没被正确绑定。我在一次排查中发现,回调对象被错误地复用了:前一个相机的回调对象没注销,新相机又用了同一个,服务端把状态发到了旧对象上,表现就是"打开了但收不到通知"。
排查这类问题的通用方法我整理成了一条链路:
回调不来 ├─ 调用即抛异常 → 参数/权限/线程问题 ├─ 有 onError 但无 onOpened → 服务端校验拒绝(占用、超限、不可用) ├─ 完全无回调 → 回调对象绑定错、服务端未收到、内部线程被阻塞 └─ 延迟很久才来 → 底层打开慢、内部 Handler 线程被占用最后这一类"延迟很久"的情况我特意提一下,因为很容易被误判成失败。如果 CameraManagerGlobal 内部线程上有耗时任务(比如某个回调里做了同步 IO),新的打开请求会被排队,表现出来就是打开特别慢。源码层面没有超时保护,只能靠调用方自己加超时监控。
5. Torch 与可用性监听:CameraManager 里最容易被忽略的两块状态机
5.1 setTorchMode 的请求如何汇聚到闪光灯管理
设置手电筒的接口看起来比打开相机简单太多——只有相机 id 和一个布尔值,连回调都可以通过单独注册。但源码里这条链路的复杂度并不低,因为它涉及多个进程竞争同一个硬件资源。
应用侧调用进来之后,CameraManagerGlobal 会带上一个客户端标识发起跨进程调用。服务端收到后不是直接操作硬件,而是交给一个专门的闪光灯管理组件,由它合并所有客户端的请求,判断当前应该处于什么状态,再决定是否真的点亮。这个"合并再决策"的过程就是防冲突的核心:如果 A 应用点亮了手电筒,B 应用请求点亮同一个,结果必须是保持一致而不是互相打架。
状态查询是通过单独的回调接口拿到的,回调里除了相机 id 和开关状态,还有一个状态码。这个状态码在源码里区分了"可用且当前关"、"可用且当前开"、"当前不可用"、"被其他客户端控制中"这几种情况。最后一种是我认为最有价值也最容易被忽略的:当一个相机的手电筒被别的客户端控制时,你这里收到的状态是"被控制"而不是"开"或"关"。如果业务只判断布尔值,就会误以为自己没点亮成功,反复重试,反而加剧冲突。
我在一个需要做手电筒快捷开关的项目里被这个状态码绕过一次。当时的逻辑是:调用设置之后等待回调,回调里布尔值为 false 就认为失败并重试。结果当另一个应用正在使用手电筒时,这边的状态一直是"被控制",布尔值为 false,于是陷入无限重试。正确做法是把"被控制"当成一个独立状态展示给用户,或者提示"设备正在被占用"。
还有一点:手电筒的开关状态和相机是否打开是两套独立的状态管理。也就是说,即使相机没打开,手电筒也可以用;反过来说,打开相机之后通过会话控制闪光灯,和调用这个接口是两条不同的路径,两者不要混用。源码里这两条路径最终会汇聚到同一个硬件管理组件,但在应用侧的表现完全不同。
5.2 AvailabilityCallback 触发条件与误用
可用性回调包含两个方法,一个表示相机变为可用,一个表示相机变为不可用。从源码看,它的触发源在服务端的状态变更通知,服务端会在设备上下线、被占用、释放占用、枚举状态变化时更新状态并广播。
理解这一点非常关键,因为它决定了这个回调的语义:它描述的是"能不能打开",不是"存不存在"。相机存在但被占用时会触发不可用;有新的相机接入时会先触发"存在",等枚举完成才触发"可用"。很多代码把onCameraUnavailable当成"相机被拔掉了"处理,直接把它从列表里删掉,结果占用解除后列表就不更新了,因为删除之后没有地方再把 id 加回来。
我在源码基础上总结了一份状态映射,写代码时对着看能避免大部分误用:
| 服务端状态 | 应用侧感知 | 建议处理 |
|---|---|---|
| 不存在 | 不会出现在 id 列表 | 从列表移除 |
| 存在但未就绪 | id 列表可能包含 | 标记为加载中,不展示 |
| 可用 | 触发可用回调 | 允许打开 |
| 被占用 | 触发不可用回调 | 置灰但保留位置 |
| 枚举中 | 状态不稳定 | 忽略中间态,等最终态 |
还有一个高频误用:在可用性回调里直接调用打开相机。这是危险的,因为回调本身运行在框架内部线程上,在里面发起跨进程调用可能造成线程阻塞,进而影响后续所有回调的分发。规范做法是回调里只更新状态,把打开动作交给业务线程决策。
5.3 多进程场景下的状态不同步
只要应用里有多个进程,相机状态就一定会有不同步的问题,因为每个进程的 CameraManagerGlobal 是独立的,各自维护自己的连接和回调表。A 进程打开了相机,B 进程的可用性回调会收到"不可用",但两者的状态更新存在时间差。
这个时间差在实际项目里造成过事故:主进程负责相机预览,子进程负责扫码识别,两个进程都要判断相机是否可用。子进程在某一瞬间认为相机可用,发起打开,结果被拒绝,错误没被处理,界面卡死。根因就是子进程的状态是滞后的,它看到的"可用"是上一个时刻的快照。
解决思路不是去消除时间差,而是接受它并做好兜底:任何打开操作都必须处理"占用"和"超限"这两类错误,并且实现退避重试。我在实现里加了一个简单的重试策略,首次失败后等待一小段时间再试一次,第二次失败就通知用户,实测下来能把这类偶发失败压到极低。
另外,跨进程共享相机还有一个更根本的约束:同一台相机在同一时刻通常只能被一个客户端打开。指望两个进程同时开同一台相机是行不通的,真的要共享,得通过进程间通信把出图数据转发过去,而不是让双方都去打开。这一点在源码层面是硬性限制,不是配置能绕开的。
6. 梳理完源码后我改掉的三个写法
6.1 回调 Handler 一律显式指定
以前写相机代码图省事,回调 Handler 经常传 null,依赖"当前线程有 Looper"这个隐含条件。梳理完源码后我把项目里所有相机相关调用都改成了显式传入专属 Handler,绑在一个专门的工作线程上。
改完之后收益立刻显现:一是回调线程固定了,业务代码里不用再写线程切换;二是主线程不再被相机回调里的耗时逻辑阻塞,预览流畅度有提升;三是排查问题更方便,抓栈时一眼就能看出这段代码跑在相机线程上。代价是要管理这个线程的生命周期,我把它挂在相机模块的容器对象里,随模块销毁一起退出。
这里有个细节要提醒:Handler 所在的线程不要用默认优先级,相机回调有时候会比较密集,优先级太低会被饿着,表现出来就是预览启动慢半拍。我给这个线程设了略高于默认的优先级,实测启动时间更稳定。
6.2 相机 id 与特性一律运行时查询
硬编码相机 id 这件事我犯过,之后再没犯过。源码里相机 id 的生成规则完全由服务端和硬件抽象层决定,应用侧没有任何理由假设它是固定的。
现在的做法是:启动时查询一次 id 列表并缓存,从特性字典里读镜头朝向、焦距、物理相机列表,按业务需要排序后再展示。每次可用性回调触发时把缓存标记为脏,下次读取时重新查询。这套逻辑不复杂,但能覆盖绝大多数设备差异。
对于物理相机的处理我也补了一层:如果业务需要变焦或者切换镜头,先判断逻辑相机的物理子列表是否非空,非空则按焦距排序展示,为空则退化成数码变焦。这样在老设备上新设备上都能跑,不会因为读不到某个字段就崩溃。
6.3 close 与回调注销的配对关系
资源泄漏在相机模块里特别隐蔽,因为泄漏的不是内存而是硬件句柄。源码里设备的关闭是异步的,调用关闭之后要等onClosed到达才算真正释放。如果在这之前就退出界面、丢掉引用,服务端那边的句柄可能还挂着,下次打开就会报占用。
我现在的写法是把关闭流程做成一个明确的状态机:调用关闭 → 等待onClosed→ 清理引用 → 通知上层可以退出。界面退出时如果还没收到onClosed,就延迟退出或者显示一个短暂的过渡。听起来很啰嗦,但确实消灭了"退出再进就黑屏"这类问题。
回调注销同样要配对。可用性回调、手电筒回调、设备回调,三个注册点对应三个注销点,缺一个就是泄漏。我在这块加了个简单的检查:模块销毁时断言所有回调都已注销,开发阶段直接抛异常暴露问题,上线前改成日志。这个小机制帮我抓出过好几处遗漏。
最后分享一个我在梳理过程中形成的习惯:读这类框架源码时,别从第一个方法开始顺序读,而是先找状态字段和回调派发点,把状态机的骨架画出来,再回头补细节。CameraManager 这几百行代码里,真正决定行为的就那么几个字段和几个派发分支,抓住它们,剩下的都是包装。我在实际项目里遇到相机相关的问题,现在基本能在半小时内判断出该往哪一层查,这个效率提升完全来自那次把源码读透的经历。