资讯动态

CameraManager源码梳理:连接、枚举与openCamera时序

发布时间:2026/10/27 6:38:48 来源:尧图企业网站定制
调相机的问题调多了你会发现一个挺反直觉的现象真正让人在原地卡上半天的往往不是图像参数调优也不是算法耗时而是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 这层的代码好读但它依赖的时序关系难懂。所有看起来“多余”的判断——状态回填前的检查、列表对比、缓存、防御性拷贝——背后都对应着一类真实发生过的线上问题。读的时候不要只记方法名多问一句“这个判断是为了防什么”收获会大得多。

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

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

免费获取报价 →
↑