资讯动态

CameraManager源码剖析:相机枚举、打开回调与可用性状态机

发布时间:2026/10/27 22:47:26 来源:尧图企业网站定制
接手一个相机相关的疑难问题最怕的就是现象只有一个原因可能有十几种。前阵子就遇到过一次同一份业务代码跑在两台设备上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)代码在应用进程内先做参数校验然后同步调用进 CameraManagerGlobalCameraManagerGlobal 立即创建出一个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–81–30.5–2单个相机特性全量解析15–408–204–10打开相机到 onOpened 回调300–1200150–50080–300设置手电筒模式往返5–152–81–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 spICameraDeviceCallbacks 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 这几百行代码里真正决定行为的就那么几个字段和几个派发分支抓住它们剩下的都是包装。我在实际项目里遇到相机相关的问题现在基本能在半小时内判断出该往哪一层查这个效率提升完全来自那次把源码读透的经历。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑