71 · 查询侧 — fetchActiveDevices 与四维 active 位
0. 本篇讲清什么
“active 设备”是蓝牙音频的核心概念:连接着的设备可以有很多台,但同一时刻真正出声/通话的只有一台(或一组)。CachedBluetoothDevice(下称 CBD)用几个布尔字段把这个状态缓存在每个设备对象里,UI 的”正在使用""媒体/手机音频”角标全部依赖它。
本篇是 67-active设备与多设备.md §1.1 的深度展开,专注**查询侧(pull 拉取)**这一半闭环:
fetchActiveDevices()的完整实现,dcddif 与 xcddif 两份副本逐行 diff(注意:本仓实际只有两份副本,不是三份,见 §1 的前提修正);- 四个 active 布尔字段的定义位置;
isActiveDevice(int profile)的分支逻辑与”永远返回 false 而不崩溃”的设计;- 底层四个 Profile 类的
getActiveDevice()/getActiveDevices()实现,重点讲 mService / mBluetoothAdapter 为 null 时的返回语义(null vs 空 List)以及为什么上层绝不 NPE; - 单 active(A2DP/HFP)vs 双 active(助听器/LeAudio)的协议层原因;
fetchActiveDevices()的全部调用点;- 布尔位在 CBD 内部的两个消费点:
getConnectionSummary/getCarConnectionSummary; - pull(fetchActiveDevices)与 push(onActiveDeviceChanged)双轨模型的设计动机。
推送侧(onActiveDeviceChanged 的事件链路)由本系列的 72 篇展开,本篇只在 §8 给出对照面。
前提修正(重要):调研任务假设存在
f3dif三个 flavor 目录各有一份副本。实测本仓base/settingsLibAndroid/src/下只有dcddif/和xcddif/两个源集目录(ls验证;find -type d -name f3dif全仓无结果)。build.gradle:46-67的映射关系是:
Gradle 构建 flavor 实际源集 dcdsrc/main/java+src/dcddif/javaxcdsrc/main/java+src/xcddif/javaglobalsrc/main/java+src/xcddif/java(+ 预留的src/global/java覆盖层,当前不存在该目录)所以”LeAudio 第四维”在 xcddif 副本里(xcd 与 global 两个构建 flavor 共享),不存在第三份独立副本。本文所有”三 flavor 对照”均按”dcddif vs xcddif(= xcd/global 共用)“两列给出。 另一个前提修正:任务给的 dcddif 调用点”refreshBluetoothClass:597”实测不成立——dcddif
refreshBluetoothClass()(CachedBluetoothDevice.java:595-598)只调initAdapterDevice()+refresh(),并不调用fetchActiveDevices()。真实调用点见 §6。
1. 数据结构:四个 active 布尔字段
1.1 dcddif:三维
dcddif CachedBluetoothDevice.java:146-149:
// Active device state
private boolean mIsActiveDeviceA2dp = false;
private boolean mIsActiveDeviceHeadset = false;
private boolean mIsActiveDeviceHearingAid = false;三个字段,默认 false。字段上方的注释 // Active device state 把它们和下面的 // Media profile connect state(连接失败标志位)分组隔开——active 位和 connected 位是两套独立状态:连接是链路层的”在不在”,active 是音频策略层的”用不用”。
1.2 xcddif:四维
xcddif CachedBluetoothDevice.java:125-129:
// Active device state
private boolean mIsActiveDeviceA2dp = false;
private boolean mIsActiveDeviceHeadset = false;
private boolean mIsActiveDeviceHearingAid = false;
private boolean mIsActiveDeviceLeAudio = false;多出的第四维 mIsActiveDeviceLeAudio 对应 LE Audio profile(BluetoothProfile.LE_AUDIO,int 值 22)。xcddif 副本基于更新的 AOSP(Android T 代 settingslib),同期多出来的还有 CSIP 组管理、HearingAidInfo、TWS Plus 电池等一整套字段(xcddif CachedBluetoothDevice.java:111-156)。
要点:这四个布尔位是 CBD 对”本设备是否是某 profile 的 active 设备”的本地缓存,不是实时查询。谁来写它?两条路:pull(fetchActiveDevices(),本篇主角)和 push(onActiveDeviceChanged(),下一篇主角)。
2. 核心方法:fetchActiveDevices() 逐段精讲
2.1 dcddif 版(CachedBluetoothDevice.java:767-783)
private void fetchActiveDevices() {
if (mProfileManager == null) {
return;
}
A2dpProfile a2dpProfile = mProfileManager.getA2dpProfile();
if (a2dpProfile != null) {
mIsActiveDeviceA2dp = mDevice.equals(a2dpProfile.getActiveDevice());
}
HeadsetProfile headsetProfile = mProfileManager.getHeadsetProfile();
if (headsetProfile != null) {
mIsActiveDeviceHeadset = mDevice.equals(headsetProfile.getActiveDevice());
}
HearingAidProfile hearingAidProfile = mProfileManager.getHearingAidProfile();
if (hearingAidProfile != null) {
mIsActiveDeviceHearingAid = hearingAidProfile.getActiveDevices().contains(mDevice);
}
}逐段拆解:
第一段(:768-770)——mProfileManager 判空守卫。 mProfileManager 是构造函数注入的 final 字段(dcddif CachedBluetoothDevice.java:117),正常流程不会为 null;这个守卫主要保护单元测试直接反射构造 CBD 的场景。注意:为 null 时直接 return,四个布尔位保持原值(可能是陈旧值)——这是一个”宁可陈旧、不可崩溃”的取舍,排障时要意识到(见 §7.1)。
第二段(:771-774)——A2DP 维。 mProfileManager.getA2dpProfile() 返回 A2dpProfile 包装对象;为 null 说明该 profile 的代理还没建立(蓝牙刚开、proxy 未连上),此时跳过赋值(不是置 false!),A2dp 维保持上一次的值。非 null 时:
mIsActiveDeviceA2dp = mDevice.equals(a2dpProfile.getActiveDevice());mDevice是 CBD 自己包裹的BluetoothDevice,永不为 null(构造时必传);a2dpProfile.getActiveDevice()可能返回 null(无 active 设备时,见 §4.1);mDevice.equals(null)返回false,不 NPE——BluetoothDevice.equals()(框架代码)对 null 参数按 Object.equals 契约返回 false。
所以 A2DP 维的语义是:“当前系统 A2DP 的 active 设备恰好是我” → true;“没有 active 设备”或”active 的是别人” → false。
第三段(:775-778)——Headset(HFP)维。 与 A2DP 完全同构:equals(getActiveDevice()),单 active 语义。
第四段(:779-782)——HearingAid 维。 换了判断方式:
mIsActiveDeviceHearingAid = hearingAidProfile.getActiveDevices().contains(mDevice);返回类型从单个 BluetoothDevice 变成 List<BluetoothDevice>,判断从 equals 变成 contains。为什么?见 §5——助听器左右耳两只同时 active。而 null 安全靠两层保证:getActiveDevices() 在 mService 为 null 时返回空 ArrayList 而不是 null(dcddif HearingAidProfile.java:179),空 List 的 contains() 恒为 false。
2.2 xcddif 版(CachedBluetoothDevice.java:976-993)
private void fetchActiveDevices() {
A2dpProfile a2dpProfile = mProfileManager.getA2dpProfile();
if (a2dpProfile != null) {
mIsActiveDeviceA2dp = mDevice.equals(a2dpProfile.getActiveDevice());
}
HeadsetProfile headsetProfile = mProfileManager.getHeadsetProfile();
if (headsetProfile != null) {
mIsActiveDeviceHeadset = mDevice.equals(headsetProfile.getActiveDevice());
}
HearingAidProfile hearingAidProfile = mProfileManager.getHearingAidProfile();
if (hearingAidProfile != null) {
mIsActiveDeviceHearingAid = hearingAidProfile.getActiveDevices().contains(mDevice);
}
LeAudioProfile leAudio = mProfileManager.getLeAudioProfile();
if (leAudio != null) {
mIsActiveDeviceLeAudio = leAudio.getActiveDevices().contains(mDevice);
}
}与 dcddif 的 diff 只有两处:
- 删掉了 mProfileManager 判空(xcddif 版直接使用, :977);
- 多出第四段(:989-992)LeAudio 维,写法与 HearingAid 同构:
getActiveDevices().contains(mDevice)——因为 LE Audio 的 active 也是”一组”(CIS 组,见 §5)。
前两维(A2DP/Headset)的判断式与 dcddif 逐字相同,但底层 getActiveDevice() 的实现已经换了数据源(mService → mBluetoothAdapter,见 §4.2),上层代码无感知——这是包装层的价值。
3. 读出口:isActiveDevice(int profile)
3.1 dcddif 版(CachedBluetoothDevice.java:677-691)
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public boolean isActiveDevice(int bluetoothProfile) {
switch (bluetoothProfile) {
case BluetoothProfile.A2DP:
return mIsActiveDeviceA2dp;
case BluetoothProfile.HEADSET:
return mIsActiveDeviceHeadset;
case BluetoothProfile.HEARING_AID:
return mIsActiveDeviceHearingAid;
default:
Log.w(TAG, "getActiveDevice: unknown profile " + bluetoothProfile);
break;
}
return false;
}逐段拆解:
方法签名与注解(:677-678):入参是 BluetoothProfile 常量(A2DP=2、HEADSET=1、HEARING_AID=21、LE_AUDIO=22),不是 LocalBluetoothProfile 对象——调用方只需一个 int。@VisibleForTesting(otherwise = PACKAGE_PRIVATE) 表示它本来是包私有,开放为 public 仅为可测试。
三个 case(:680-685):纯读缓存,O(1),无任何 binder 调用。这就是”缓存 active 位”的意义:UI 每次刷新列表都要问一遍”我是不是 active 设备”,如果每次都穿透到蓝牙服务查一遍,列表滚动就会卡。
default 分支(:686-688):未知 profile 打 warning。注意一个日志文案瑕疵:打出来的前缀是 getActiveDevice:,但方法名是 isActiveDevice——搜日志时别被误导,这条 log 出现说明调用方传了不认识的 profile id,不是 getActiveDevice 出错。
兜底 return false(:690):default 落出来后到方法末尾统一 return false。语义是”不认识的 profile 一律回答’不是 active‘“而不是抛异常。这决定了调用侧永远不需要 try-catch。
3.2 xcddif 版(CachedBluetoothDevice.java:882-898)
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public boolean isActiveDevice(int bluetoothProfile) {
switch (bluetoothProfile) {
case BluetoothProfile.A2DP:
return mIsActiveDeviceA2dp;
case BluetoothProfile.HEADSET:
return mIsActiveDeviceHeadset;
case BluetoothProfile.HEARING_AID:
return mIsActiveDeviceHearingAid;
case BluetoothProfile.LE_AUDIO:
return mIsActiveDeviceLeAudio;
default:
Log.w(TAG, "getActiveDevice: unknown profile " + bluetoothProfile);
break;
}
return false;
}唯一 diff:多一个 case BluetoothProfile.LE_AUDIO: return mIsActiveDeviceLeAudio;(:891-892)。default 的日志文案瑕疵原样保留(两份副本是独立演化的拷贝,这类”化石特征”正好可以用来核对副本的 fork 时间点)。
本仓消费情况:全仓 grep 显示 app 各模块(micarConnectionSettings 等)没有直接调用 isActiveDevice(...);它的直接消费者是 settingslib 内部的 summary 生成(§7)以及框架侧的 BluetoothDevicePreference(不在本仓)。也就是说在 MiCarSettings 里,这个方法主要是”给 UI 摘要供料”的内部管道。
4. 底层 Profile 实现:null 语义逐个讲
这一层是”查询侧真正发生 binder 调用”的地方。四个 profile 两两一组:A2DP/Headset 是单 active(返回单个 device 或 null),HearingAid/LeAudio 是组 active(返回 List)。
4.1 dcddif:走 mService(profile 代理对象)
A2dpProfile.getActiveDevice() — dcddif A2dpProfile.java:175-178:
public BluetoothDevice getActiveDevice() {
if (mService == null) return null;
return mService.getActiveDevice();
}mService是BluetoothA2dp代理对象,在构造函数里通过mBluetoothAdapter.getProfileProxy(context, new A2dpServiceListener(), BluetoothProfile.A2DP)异步获取(dcddif A2dpProfile.java:108)。蓝牙服务未就绪/代理已死时为 null。- null 语义:mService 为 null → 返回 null → 上层
mDevice.equals(null)= false → 该维 active 位被刷成 false。合理:连 profile 代理都没有,谈不上 active。 mService.getActiveDevice()是 binder 调用,穿透到 com.android.bluetooth 进程的 A2dpService,返回它记录的唯一 active sink;无 active 时框架返回 null。
HeadsetProfile.getActiveDevice() — dcddif HeadsetProfile.java:137-142:
public BluetoothDevice getActiveDevice() {
if (mService == null) {
return null;
}
return mService.getActiveDevice();
}与 A2dpProfile 完全同构(唯一差别是换行风格),null 语义相同。
HearingAidProfile.getActiveDevices() — dcddif HearingAidProfile.java:178-181:
public List<BluetoothDevice> getActiveDevices() {
if (mService == null) return new ArrayList<>();
return mService.getActiveDevices();
}- 关键差异:mService 为 null 时返回空 List,不是 null。为什么这样设计?因为调用方是
getActiveDevices().contains(mDevice)——如果这里返回 null,.contains()直接 NPE。返回空 List,contains安全地给出 false。 mService.getActiveDevices()(BluetoothHearingAid binder 接口)按框架契约返回非 null List(0/1/2 个元素;两只配对的助听器会同时出现在里面)。⚠️ 待核验:这一”非 null”是框架源码契约,MiCarSettings 仓内无法直接验证;若某魔改框架违反契约返回 null,:781 的contains会 NPE——排障时若见此处崩溃,先查框架侧返回值。
小结 dcddif 的 null 安全链条:
| profile | mService == null 时返回 | CBD 侧判断式 | 结果 |
|---|---|---|---|
| A2DP | null | mDevice.equals(null) | false,无 NPE |
| Headset | null | mDevice.equals(null) | false,无 NPE |
| HearingAid | new ArrayList<>() | emptyList.contains(mDevice) | false,无 NPE |
两条路都到 false,但机制不同:单 active 靠 equals() 对 null 参数天然返回 false(Object.equals 契约);组 active 靠”永远不返回 null 集合”的编码约定。写新 profile 封装时必须二选一对齐,混用(比如组 active 返回 null)必炸。
4.2 xcddif:走 mBluetoothAdapter(adapter 级新 API)
xcddif 的四个 profile 查询全部改走 BluetoothAdapter.getActiveDevices(int profile)——Android T 引入的统一入口,由 adapter 按 profile 分发,不再依赖各 profile 的 mService 代理是否就绪。
A2dpProfile.getActiveDevice() — xcddif A2dpProfile.java:180-185:
public BluetoothDevice getActiveDevice() {
if (mBluetoothAdapter == null) return null;
final List<BluetoothDevice> activeDevices = mBluetoothAdapter
.getActiveDevices(BluetoothProfile.A2DP);
return (activeDevices.size() > 0) ? activeDevices.get(0) : null;
}注意这里的降维:adapter API 本身返回 List(统一形态),A2DP 的”单 active”语义靠 取 get(0) 否则 null 还原。判空对象从 mService 换成 mBluetoothAdapter(构造函数里由 BluetoothAdapterUtil.getAdapterByProfile(getProfileId()) 赋值,MICAR-PORTING 块 xcddif A2dpProfile.java:110-112,赋值行 :111)。BluetoothAdapter.getActiveDevices() 框架契约返回非 null List(无 active 时为空表),所以 activeDevices.size() 不会 NPE。
HeadsetProfile.getActiveDevice() — xcddif HeadsetProfile.java:137-144:
public BluetoothDevice getActiveDevice() {
if (mBluetoothAdapter == null) {
return null;
}
final List<BluetoothDevice> activeDevices = mBluetoothAdapter
.getActiveDevices(BluetoothProfile.HEADSET);
return (activeDevices.size() > 0) ? activeDevices.get(0) : null;
}与 A2DP 同构。
HearingAidProfile.getActiveDevices() — xcddif HearingAidProfile.java:212-217:
public List<BluetoothDevice> getActiveDevices() {
if (mBluetoothAdapter == null) {
return new ArrayList<>();
}
return mBluetoothAdapter.getActiveDevices(BluetoothProfile.HEARING_AID);
}组 active 直接透传 List,null 时同样返回空表——与 dcddif 的”空表约定”一致。
LeAudioProfile.getActiveDevices() — xcddif LeAudioProfile.java:174-179:
public List<BluetoothDevice> getActiveDevices() {
if (mBluetoothAdapter == null) {
return new ArrayList<>();
}
return mBluetoothAdapter.getActiveDevices(BluetoothProfile.LE_AUDIO);
}同构。这个类里还有一段对理解 LeAudio active 很有价值的注释(xcddif LeAudioProfile.java:181-199,节选):
Lead device is the device that can be used as an active device in the system. Active devices points to the Audio Device for the Le Audio group. … Note: getActiveDevice() returns the Lead device for the currently active LE Audio group.
即 LE Audio 的”active 设备”实际是”active 组”,组有一个 lead device,UI 侧 setActive 时要对 lead 设备操作。另一个细节:LeAudioProfile 构造用的是 BluetoothAdapter.getDefaultAdapter()(xcddif LeAudioProfile.java:114),而 A2DP/Headset/HearingAid 用 BluetoothAdapterUtil.getAdapterByProfile(...)——LE Audio 未接入 MICAR 双蓝牙适配体系, ⚠️ 待核验是否为有意为之(若后续做双适配器 LeAudio,这里是改造点)。
4.3 两 flavor 底层实现对照总表
| 项 | dcddif | xcddif(xcd/global 共用) |
|---|---|---|
| A2dpProfile.getActiveDevice | :175-178,mService null→null,否则 mService.getActiveDevice() | :180-185,adapter null→null,否则 adapter.getActiveDevices(A2DP) 取 [0] |
| HeadsetProfile.getActiveDevice | :137-142,同上模式 | :137-144,adapter 模式取 [0] |
| HearingAidProfile.getActiveDevices | :178-181,mService null→空表,否则 mService.getActiveDevices() | :212-217,adapter null→空表,否则 adapter.getActiveDevices(HEARING_AID) |
| LeAudioProfile.getActiveDevices | 不存在(无此类) | :174-179,adapter null→空表,否则 adapter.getActiveDevices(LE_AUDIO) |
| 数据通道 | profile 代理 binder(就绪才可用) | BluetoothAdapter.getActiveDevices(profile)(T 代统一入口) |
| 未就绪时 A2DP/Headset 返回 | null | null |
| 未就绪时组 active 返回 | 空 List | 空 List |
5. 单 active vs 组 active:协议层原因
为什么 A2DP/HFP 用 equals(getActiveDevice()),助听器/LeAudio 用 getActiveDevices().contains(mDevice)?这不是代码风格差异,是两类蓝牙音频协议的拓扑差异:
A2DP(Advanced Audio Distribution Profile)— 单点流。 A2DP 的音频拓扑是”一个 Source → 一个 Sink”的点对点流。手机/车机作为 Source,同一时刻只有一条 AVDTP 音频流在跑,所以框架里 BluetoothA2dp.getActiveDevice() 天生返回一个 device(或 null)。切换 active = 拆旧流建新流。
HFP(Headset Profile)— 单点 SCO。 通话音频走 SCO/ESCO 同步链路,一条链路只指向一个 AG↔HF 对,所以同样是单 active。
HearingAid(ASHA)— 左右一对。 助听器永远成对出售:左右两只耳机共享一个 hiSyncId(CBD 里有对应的 mHiSyncId/mSubDevice 机制,见 67 篇),框架会同时把两只都设为 active(左右声道各自一条流)。因此 BluetoothHearingAid.getActiveDevices() 返回 List,最多两个元素。如果用 equals(getActiveDevice()) 的单点写法,同一对里总有一只被误判为”非 active”——UI 上左右耳状态就会打架。
LE Audio — CIS 组。 LE Audio 用 CIS(Connected Isochronous Stream)做多设备同步音频,一个协调组(CSIP coordinated set,比如一副 TWS 耳机的主从两台)可以共享一组同步流,active 的语义落到”组 active”上,组成员(至少 lead)都会出现在 active 列表里。所以 LeAudio 从 API 形态上就抄了助听器的作业:getActiveDevices() 返回 List。
一个漂亮的佐证是 xcddif 的 A2DP/Headset:底层 API 已经统一成 List 形态(adapter.getActiveDevices(profile)),但代码仍然取 get(0) 否则 null主动降维回单 device(§4.2)——因为协议层语义就是单 active,保留单点形态让上层 equals() 判断不变形。
6. fetchActiveDevices() 的全部调用点
grep 两份副本,调用点各 3 处(方法定义本身除外):
| 调用点 | dcddif | xcddif | 触发场景 |
|---|---|---|---|
onProfileStateChanged() 末尾 | CachedBluetoothDevice.java:289 | CachedBluetoothDevice.java:348 | 任一 profile 连接态变化(CONNECTED/CONNECTING/…)回调时 |
fillData() | CachedBluetoothDevice.java:497 | CachedBluetoothDevice.java:667 | CBD 构造函数首次填充数据时(dcddif :188 调用;xcddif 主构造 :192、拷贝构造 :206 各调一次) |
switchSubDeviceContent() 末尾 | CachedBluetoothDevice.java:1328 | CachedBluetoothDevice.java:1751 | 助听器主/副设备内容互换后 |
逐个说人话:
- fillData(出生时对齐):CBD 对象刚创建时,active 位全是 false。如果这台设备在对象创建前就已经是 active(比如设置应用进程重启、列表重建),不主动拉一次,UI 会错误显示”未在使用”。
fillData()在构造函数里调用,保证出生即对齐现状。 - onProfileStateChanged(状态变化时对齐):profile 连接态变化是 active 变化的前导事件——设备连上后才可能被选为 active。蓝牙服务选 active 的时机与设置进程收到连接广播的时机没有严格先后保证,连接态回调到了就顺手重查一遍 active,可以把”广播乱序漏掉的 active 变化”补回来。这就是 pull 模型”兜底”价值的直接体现(§8)。
- switchSubDeviceContent(身份互换后对齐):助听器主/副设备互换 mDevice 后,“我”指向了另一个 BluetoothDevice,active 判断必须基于新身份重算。注意 dcddif :1328 / xcddif :1751 都在互换完字段的最后一步调用。
再次修正:refreshBluetoothClass()(dcddif :595-598 / xcddif :788-792)只做 initAdapterDevice() + refresh(),不在调用点列表里。它触发的 refresh() 只是重画(dispatchAttributesChanged),不重查 active——所以”蓝牙类刷新后 active 位陈旧”在理论上是可能的窗口(见 §7.2)。
7. 消费点:布尔位如何变成 UI 文案
四个布尔位在 CBD 内部有两个读出口,都服务于”设备列表/详情页的那一行摘要”。
7.1 getConnectionSummary(dcddif :1123-1136;xcddif :1429-1469)
dcddif 的核心判定(dcddif CachedBluetoothDevice.java:1123-1127):
if (a2dpConnected || hfpConnected || hearingAidConnected) {
final boolean isOnCall = Utils.isAudioModeOngoingCall(mContext);
if ((mIsActiveDeviceHearingAid)
|| (mIsActiveDeviceHeadset && isOnCall)
|| (mIsActiveDeviceA2dp && !isOnCall)) {三分支语义(配合上方注释 :1119-1122”1. Hearing Aid device active. 2. Headset device active with in-calling state. 3. A2DP device active without in-calling state”):
mIsActiveDeviceHearingAid:助听器 active,无条件算”在使用”(助听器永远在用);mIsActiveDeviceHeadset && isOnCall:HFP active 且当前正在通话——通话中 HFP 才是出声方;mIsActiveDeviceA2dp && !isOnCall:A2DP active 且不在通话——非通话时媒体流才是出声方。
isOnCall(Utils.isAudioModeOngoingCall)是音频模式(MODE_IN_CALL 等)查询,它把”同一时刻两个位都可能是 true(A2DP+HFP 双 active 很常见)“消解成”按当前场景挑一个说”。
xcddif 的对应段(xcddif CachedBluetoothDevice.java:1429-1434)加了第四项:
if (a2dpConnected || hfpConnected || hearingAidConnected || leAudioConnected) {
final boolean isOnCall = Utils.isAudioModeOngoingCall(mContext);
if ((mIsActiveDeviceHearingAid)
|| (mIsActiveDeviceHeadset && isOnCall)
|| (mIsActiveDeviceA2dp && !isOnCall)
|| mIsActiveDeviceLeAudio) {mIsActiveDeviceLeAudio 与助听器一样不受 isOnCall 约束(LeAudio 同时承载媒体与通话音频,active 即在使用)。此外 xcddif :1446-1469 还有一段”LeAudio 助听器(HapClient)active 时显示左/右耳”的细化,用 mIsActiveDeviceLeAudio && isConnectedHapClientDevice() 区分(:1447-1448)。
命中后选用的字符串资源(dcddif :1128-1134):有 TWS 双电池 → bluetooth_active_battery_level_untethered;有普通电量 → bluetooth_active_battery_level;否则 bluetooth_active_no_battery_level——即用户看到的”正在使用(电量 80%)“。
7.2 getCarConnectionSummary(dcddif :1213-1229;xcddif :1581-1603)
车机版摘要。dcddif(dcddif CachedBluetoothDevice.java:1214-1229):
String[] activeDeviceStringsArray = mContext.getResources().getStringArray(
R.array.bluetooth_audio_active_device_summaries);
String activeDeviceString = activeDeviceStringsArray[0]; // Default value: not active
if (mIsActiveDeviceA2dp && mIsActiveDeviceHeadset) {
activeDeviceString = activeDeviceStringsArray[1]; // Active for Media and Phone
} else {
if (mIsActiveDeviceA2dp) {
activeDeviceString = activeDeviceStringsArray[2]; // Active for Media only
}
if (mIsActiveDeviceHeadset) {
activeDeviceString = activeDeviceStringsArray[3]; // Active for Phone only
}
}
if (!hearingAidNotConnected && mIsActiveDeviceHearingAid) {
activeDeviceString = activeDeviceStringsArray[1];
return mContext.getString(R.string.bluetooth_connected, activeDeviceString);
}逻辑:数组 bluetooth_audio_active_device_summaries 有 4 个模板(未 active / 媒体+电话 / 仅媒体 / 仅电话)。A2DP 与 Headset 两个位自由组合出四种状态(车机上”连着但只有电话 active”很常见);助听器 active 则直接归并为”媒体+电话”(ASHA 双流,全占)。!hearingAidNotConnected 的前置条件排除”profile 已断开但位还没刷掉”的假阳性。
xcddif 对应段结构相同(:1585-1594),并在助听器分支后追加了 LeAudio 分支(xcddif CachedBluetoothDevice.java:1600-1603):
if (!leAudioNotConnected && mIsActiveDeviceLeAudio) {
activeDeviceString = activeDeviceStringsArray[1];
return mContext.getString(R.string.bluetooth_connected, activeDeviceString);
}同样归并为”媒体+电话”,同样有 !leAudioNotConnected 防陈旧位。
消费面总结:本仓 app 模块没有绕过这两个方法直接读布尔位的代码(全仓 grep 验证)——四维 active 位的最终出口就是这两条摘要管道,再由框架侧 BluetoothDevicePreference / 车机连接页面渲染。
8. pull vs push:双轨模型的设计动机
查询侧(本篇)只是闭环的一半。另一半是推送侧:
push 链路(简述,详见 72 篇):蓝牙服务 active 设备变化时发系统广播(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED 等),BluetoothEventManager 的 ActiveDeviceChangedHandler 接住(dcddif BluetoothEventManager.java:431-453;xcddif :706-732,xcddif 多注册了 BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED,:140),把 action 映射成 profile id,然后 dispatchActiveDeviceChanged()(dcddif :224-234)遍历所有 cached device,对每一台调用 cachedDevice.onActiveDeviceChanged(isActive, profile)——isActive 的计算就是 Objects.equals(cachedDevice, activeDevice)。CBD 侧的 onActiveDeviceChanged(dcddif CachedBluetoothDevice.java:640-663)只改对应那一维布尔位,且仅在值真的变化时 dispatchAttributesChanged 刷 UI。xcddif 的 dispatch(:310-336)还多了 CSIP 组员提升:如果广播里的 active 设备是某组的 member,会把 isActive 记到该组 main 设备头上(:316-329)。
为什么需要两条轨?
| pull(fetchActiveDevices) | push(onActiveDeviceChanged) | |
|---|---|---|
| 方向 | CBD 主动问蓝牙服务”现在谁是 active” | 蓝牙服务变化时广播推给 CBD |
| 实时性 | 差——只在 3 个时机拉(§6) | 好——变化即达 |
| 可靠性 | 高——查询的是当下事实,天然自愈 | 依赖广播必达;进程重启、注册空窗、广播丢失则状态陈旧 |
| 成本 | 每次 3-4 个 binder 调用 | 平摊,几乎零成本 |
| 解决什么 | 错过广播:出生时、连接态变化时对齐事实 | 实时性:正常使用中毫秒级跟进 |
典型协作场景:设置应用进程被杀重启 → 列表重建 → 每个 CBD 构造走 fillData → fetchActiveDevices 把 active 位一次性拉对——这期间没有任何 active 广播会重发,纯靠 push 的话状态永远是 false。反过来,用户在别处(比如语音助手)切换了音频输出设备 → ACTION_ACTIVE_DEVICE_CHANGED 广播 → push 毫秒级更新角标——此时若等下一次 pull 时机(下一次 profile 状态变化)就太迟了。
两者写的是同一组布尔位,写法不同:pull 用查询结果整维覆盖(true/false 都写),push 用广播参数整维覆盖,changed 检测避免无谓刷新。两轨在任何顺序下交错写都是幂等的——因为都收敛到”与事实一致”。
⚠️ 幂等性的一个不对称边界(红队核验补充):上述结论只对在顶层缓存列表里的设备成立。助听器副耳(dcddif 的 subDevice,被
setSubDeviceIfNeeded吸收后不入mCachedDevices,CachedBluetoothDeviceManager.java:107-110)与 xcddif 的部分 CSIP 组员,push 链遍历不到它们(dispatchActiveDeviceChanged 只遍历顶层列表)——这些设备的 active 位自出生 fillData 拉取一次后,push 永不更新,只能等下一次 pull 时机(该对象自身的 onProfileStateChanged/fillData)。这是一个”永不收敛窗口”:pull 有兜底、push 没有。详见 72 篇 §4.1 的边界分析。
9. 全链路图
flowchart TD subgraph BT["com.android.bluetooth 进程(系统蓝牙服务)"] AES["A2dpService<br/>单一 active sink"] HSS["HeadsetService<br/>单一 active HF"] HAS["HearingAidService<br/>左右耳一组 active"] LES["LeAudioService<br/>CIS 组 active(xcddif)"] end subgraph PL["settingslib Profile 包装层"] A2["A2dpProfile.getActiveDevice()<br/>dcddif: mService 判 null<br/>xcddif: adapter 取 List[0]"] HS["HeadsetProfile.getActiveDevice()<br/>同 A2DP 模式"] HA["HearingAidProfile.getActiveDevices()<br/>null→空 List 约定"] LE["LeAudioProfile.getActiveDevices()<br/>仅 xcddif 有此类"] end subgraph CBD["CachedBluetoothDevice"] FAD["fetchActiveDevices()<br/>dcddif :767-783 三维<br/>xcddif :976-993 四维"] B1["mIsActiveDeviceA2dp"] B2["mIsActiveDeviceHeadset"] B3["mIsActiveDeviceHearingAid"] B4["mIsActiveDeviceLeAudio(xcddif)"] IAD["isActiveDevice(profile)<br/>default→false"] GCS["getConnectionSummary<br/>三分支+isOnCall 消解"] CCS["getCarConnectionSummary<br/>数组 4 模板组合"] end CALL["调用点×3<br/>fillData / onProfileStateChanged /<br/>switchSubDeviceContent"] PUSH["push 侧对照:<br/>ActiveDeviceChangedHandler 广播<br/>→ onActiveDeviceChanged(72 篇)"] AES --> A2 HSS --> HS HAS --> HA LES --> LE CALL --> FAD FAD -->|"equals(getActiveDevice())"| B1 FAD -->|"equals(getActiveDevice())"| B2 FAD -->|"getActiveDevices().contains()"| B3 FAD -->|"getActiveDevices().contains()"| B4 PUSH -.->|"同一组位的另一写者"| B1 B1 & B2 & B3 & B4 --> IAD B1 & B2 & B3 --> GCS B1 & B2 & B3 & B4 --> CCS GCS --> UI["设备列表摘要文案"] CCS --> UI
10. 排障要点
- mService / mBluetoothAdapter 为 null 时的行为:A2DP/Headset 查询返回 null → 该维刷成 false;HearingAid/LeAudio 返回空表 → 同样 false。表现是”蓝牙刚开启的头几秒,active 角标全部消失,随后恢复”。如果用户报”角标闪灭”,先查 profile proxy 就绪时序,而不是 active 逻辑本身。
- active 位陈旧怎么发生:(a) pull 只在 §6 的 3 个时机执行,且 profile 包装对象为 null 时跳过赋值(保持旧值,dcddif :772-774 的 if 判空是”跳过”不是”置 false”);(b) push 侧广播丢失(进程重启空窗、action 未注册——dcddif 没注册 LeAudio action,收到也不会处理);(c)
refreshBluetoothClass()等路径只重画不重查。任一情况叠加,就出现”设备显示未在使用但音频其实在它上面”。自愈时机:下一次任一 profile 状态变化触发 pull。 - getCarConnectionSummary 的防陈旧设计:
!hearingAidNotConnected && mIsActiveDeviceHearingAid(dcddif :1227)说明作者已经意识到”位可能比连接态活得久”,用连接态做二次校验。排障时若见”断开的设备还显示 active”,对照检查这两个输入哪边滞后。 - default 分支静默 false:
isActiveDevice(未知 profile)只打一条getActiveDevice: unknown profile的 warning(注意文案名不副实,§3.1)就返回 false。如果上层传错 profile id,症状是”永远查不到 active”,log 里搜上述关键字。 - 组 active 的 NPE 边界:
contains()的安全建立在”实现永远返回空表而非 null”的约定上(§4.1)。新增组 active 型 profile 封装时必须遵守;review 类似代码时第一件事就是查 null 返回路径。 - dcddif 与 xcddif 判空守卫差异:dcddif fetchActiveDevices 有 mProfileManager null 守卫(:768-770),xcddif 没有(:977 直接用)。xcddif 若在 profileManager 为 null 的测试环境调用会 NPE——只影响测试脚手架,不影响产线。
- LeAudio 查询走默认 adapter(xcddif LeAudioProfile.java:114 用
getDefaultAdapter(),不走BluetoothAdapterUtil.getAdapterByProfile)——双蓝牙场景下若 LeAudio 需要跟非默认适配器,此处是已知差异点。⚠️ 待核验:是否有车型规划双适配器 LeAudio。
11. 自测清单
- 能说出四个 active 布尔字段的定义行号(dcddif :147-149 / xcddif :126-129),并解释 active 与 connected 是两套状态
- 能默写 fetchActiveDevices 两份 diff:dcddif 三维 + mProfileManager 守卫;xcddif 四维 + 无守卫
- 能解释 A2DP/Headset 用
equals(getActiveDevice())而 HearingAid/LeAudio 用getActiveDevices().contains(mDevice)的协议层原因(单点流 vs 左右耳成对/CIS 组) - 能画出 mService == null 时的完整链条:A2DP→null→equals(false);HearingAid→空表→contains(false),且都不 NPE
- 能说出 isActiveDevice 的 default 分支行为(warning + return false)及其日志文案陷阱
- 能列举 fetchActiveDevices 的 3 个调用点,并解释为什么 refreshBluetoothClass 不是调用点
- 能解释 getConnectionSummary 三分支中 isOnCall 如何消解 A2DP/HFP 双 active,以及 LeAudio 位为何不受 isOnCall 约束
- 能说清 pull 与 push 各自解决什么问题(错过广播 vs 实时性),以及两者写同一组位为何幂等
- 知道本仓只有 dcddif/xcddif 两份副本、xcd 与 global 共用 xcddif(build.gradle :46-67)
12. 交叉引用
- 67-active设备与多设备.md — 本篇是其 §1.1 的深度展开;mSubDevice/mHiSyncId 多设备机制在该篇
- 21-settingslib架构与事件流.md — settingslib 分层与事件分发总论
- 70-总览与闭环地图.md — active 设备闭环总览(pull+push 全景,本篇覆盖查询侧)
- 72-监听侧-广播到onActiveDeviceChanged.md — push 侧完整事件链(本篇 §8 的对照面展开)
- 22-双flavor差异-dcddif-vs-xcddif.md — 两副本的整体差异背景
源码基线:MiCarSettings dev 分支(2026-08-23 检出),所有 file:line 锚点均按本仓实际文件核对;dcddif 与 xcddif 行号不可混用。