72 · 监听侧 — ACTION_ACTIVE_DEVICE_CHANGED 广播到 onActiveDeviceChanged
本篇讲清什么
上一篇(71 章)讲了 pull 侧:车设主动调 profile.getActiveDevice() 拉取当前激活设备。但 pull 只能回答”此刻谁激活”,系统切歌、来电、语音抢占发生时,必须有谁来推这个变化。本篇讲 push 侧(监听侧/事件推送侧)的完整链路:
系统蓝牙进程发出
ACTION_ACTIVE_DEVICE_CHANGED广播 → settingslib 的BluetoothEventManager收到 →ActiveDeviceChangedHandler解析出设备和 profile →dispatchActiveDeviceChanged遍历缓存设备表 →CachedBluetoothDevice.onActiveDeviceChanged(isActive, profile)写 active 位 → 值变了才dispatchAttributesChanged()→ UI 收到onDeviceAttributesChanged刷新。
读完后你应能回答:
- active 相关广播在哪儿注册、注册了哪几条、dcddif 和 xcddif 差在哪;
- 广播 intent 里读了哪个 extra、
isActive这个 boolean 是怎么”推导”出来的(而不是直接读出来的); onActiveDeviceChanged的 switch 分支、“值变才通知”的短路逻辑、default 分支的告警日志;- 为什么
onAudioModeChanged()只 dispatch 不改字段; - 广播丢了会怎样、怎么用日志取证。
先纠正一个容易以讹传讹的前提:本仓 base/settingsLibAndroid/src/ 下只有 dcddif/ 和 xcddif/ 两个 flavor 源集(build.gradle 的 sourceSets 配置里 global flavor = main + xcddif + global 三个目录叠加,见 base/settingsLibAndroid/build.gradle:60-66),不存在 f3dif 目录(全仓 find/grep 均无)。如果你在别的资料里看到”f3dif 版 settingslib”,那不是本仓的东西。因此本文的”多 flavor 对照”是 dcddif(国内 DCD 平台)vs xcddif(XCD 平台,global 海外版复用同一份代码),LeAudio 第四分支在 xcddif 里,下文有逐行证据。
一、广播从哪来:发送方是谁
先说清楚一个方向性问题:这些广播不是车设发的,车设(settingslib)是纯接收方。
验证方法:在整个 MiCarSettings 仓里 grep ACTION_ACTIVE_DEVICE_CHANGED 和 ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED(排除 build 产物),命中文件只在 base/settingsLibAndroid/src/dcddif/ 和 base/settingsLibAndroid/src/xcddif/ 的 settingslib bluetooth 目录里——全部是”注册接收”的代码,没有任何 sendBroadcast 调用。
发送方在仓外:Android 的 profile 服务(A2dpService、HeadsetService、HearingAidService、LeAudioService)运行在系统蓝牙进程 com.android.bluetooth 里,当激活设备变化(用户点选、来电抢 SCO、路由策略切换)时,它们通过 framework 的 BluetoothAdapter 持久化状态并发送对应的 sticky/普通广播。本仓源码看不到发送端实现,这是 Android framework 侧知识(⚠️ 发送端的具体代码不在本仓,以上是基于 Android 公开 framework 机制的描述,非本仓源码可直接证明)。
对 App 视角来说,只需要知道三件事:
- 广播 action 字符串常量定义在 framework 类里:
BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED、BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED、BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED、BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED; - intent 里带
BluetoothDevice.EXTRA_DEVICE这个 extra,key 值就是字符串"android.bluetooth.device.extra.DEVICE",value 是BluetoothDeviceParcelable——settingslib 两个 flavor 都只读这一个 extra(代码见下文); - framework 广播里其实还有
EXTRA_ACTIVE(“android.bluetooth.device.extra.ACTIVE”,boolean)这类标记激活/去激活的 extra,但本仓 settingslib 根本不读它——两个 flavor 目录 grepEXTRA_ACTIVE均无结果。isActive是 settingslib 自己用”广播里的 device 是不是这台缓存设备”推导出来的(见第三节)。这一点排障时要知道:去激活(null device 或 extra 缺失)时走的是完全不同的路径。
全量广播清单(含 ACL、bond、connection 等非 active 广播)见 51-广播与事件清单.md,本篇只深讲 active 相关的几条。
二、注册:广播怎么挂上 Handler
2.1 注册点(dcddif)
BluetoothEventManager 构造函数里有一张”action → Handler”的注册表。dcddif 版的 active 相关注册段:
// dcddif BluetoothEventManager.java:113-117
// Active device broadcasts
addHandler(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED, new ActiveDeviceChangedHandler());
addHandler(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED, new ActiveDeviceChangedHandler());
addHandler(BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED,
new ActiveDeviceChangedHandler());三个要点:
- 三条广播共用同一个 Handler 类
ActiveDeviceChangedHandler(注意:类名里没有 “State”,不叫ActiveDeviceStateChangedHandler——有些资料按 AOSP 老版本记忆会写错,AOSP 上游确实曾叫ActiveDeviceStateChangedHandler,本仓两个 flavor 都已改名为ActiveDeviceChangedHandler)。谁是”哪条广播”靠intent.getAction()在 Handler 内部再分一次(见 2.4)。 - 三条广播分别对应 A2DP(媒体)、HEADSET(HFP 通话)、HEARING_AID(助听器)三个 profile 的激活变化。
- dcddif 没有 LeAudio 广播——LeAudio 相关代码只在 xcddif(见 2.2)。
紧随其后的是音频模式广播的注册(它不是 active 广播,但本篇第五节要讲它):
// dcddif BluetoothEventManager.java:119-123
// Headset state changed broadcasts
addHandler(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED,
new AudioModeChangedHandler());
addHandler(TelephonyManager.ACTION_PHONE_STATE_CHANGED,
new AudioModeChangedHandler());BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED(SCO 音频建立/断开)和 TelephonyManager.ACTION_PHONE_STATE_CHANGED(电话状态变化)都路由到 AudioModeChangedHandler——这两个信号共同构成”现在是不是通话中”的触发源。
2.2 注册点(xcddif)
xcddif 版多了 LeAudio:
// xcddif BluetoothEventManager.java:135-141
// Active device broadcasts
addHandler(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED, new ActiveDeviceChangedHandler());
addHandler(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED, new ActiveDeviceChangedHandler());
addHandler(BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED,
new ActiveDeviceChangedHandler());
addHandler(BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED,
new ActiveDeviceChangedHandler());第 4 条 BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED(xcddif BluetoothEventManager.java:140-141)是 xcddif 独有——这版 settingslib 来自 MIUI 的 LeAudio/CSIS 蓝牙框架大版本,支持 LE Audio 耳机。Handler 仍然是同一个类,内部分支多一路。
2.3 addHandler 做了什么
// dcddif BluetoothEventManager.java:242-246( xcddif 同名方法,行号略)
@VisibleForTesting
void addHandler(String action, Handler handler) {
mHandlerMap.put(action, handler);
mAdapterIntentFilter.addAction(action);
}两件事:
mHandlerMap.put(action, handler):Map<String, Handler>,广播到达后 O(1) 查表找到处理者;mAdapterIntentFilter.addAction(action):把 action 塞进 IntentFilter,后面统一注册给系统的 BroadcastReceiver。
真正的注册发生在构造尾部 registerAdapterIntentReceiver()(dcddif :128 附近调用,实现):
// dcddif BluetoothEventManager.java:156-164
private void registerIntentReceiver(BroadcastReceiver receiver, IntentFilter filter) {
if (mUserHandle == null) {
// If userHandle has not been provided, simply call registerReceiver.
mContext.registerReceiver(receiver, filter, null, mReceiverHandler);
} else {
// userHandle was explicitly specified, so need to call multi-user aware API.
mContext.registerReceiverAsUser(receiver, mUserHandle, filter, null, mReceiverHandler);
}
}registerReceiver(receiver, filter, broadcastPermission=null, mReceiverHandler) 的最后一个参数决定广播在哪个线程回调 onReceive。mReceiverHandler 来自构造函数(dcddif :88 mReceiverHandler = handler)。本仓拿 manager 的入口共三处(红队核验补全,LocalBluetoothManager.getInstance 全仓仅此三点、create(...) 工厂零调用):
| 入口 | 位置 | handler |
|---|---|---|
MiBluetoothUtils.getLocalBtManager() | micarConnectionSettings MiBluetoothUtils.kt:65-78(调 getInstance(context){...}) | null |
VoiceAssistProvider | base/settingsBaseUi VoiceAssistProvider.kt:416(调 getInstance(BaseApplication.getGlobalApp(), null),查 PBAP 状态) | null |
BluetoothRequestPermissionActivity | settingsPage/globalOnly BluetoothRequestPermissionActivity.java:97(调 getInstance(getApplicationContext(), null)) | null |
getInstance 内部 new LocalBluetoothManager(adapter, context, /* handler= */ null, /* userHandle= */ null)(dcddif LocalBluetoothManager.java:65-66)——三处全传 null,且 LocalBluetoothManager 是单例(整个 App 共享同一个 EventManager)。handler 为 null 时,registerReceiver 的语义是广播在主线程(main looper)分发(⚠️ 这是 Context.registerReceiver 的标准 API 语义推断,非本仓源码可直接证明)。所以整条 active 链路跑在 UI 主线程上,onActiveDeviceChanged 改字段、dispatch 回调都不需要额外加锁同步到 UI。
2.4 入口分发:BluetoothBroadcastReceiver
所有注册的广播先进这一个接收器:
// dcddif BluetoothEventManager.java:248-260
private class BluetoothBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
BluetoothDevice device = intent
.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
Handler handler = mHandlerMap.get(action);
if (handler != null) {
handler.onReceive(context, intent, device);
}
}
}逐段看:
intent.getAction():拿到广播 action 字符串,如android.bluetooth.a2dp.profile.action.ACTIVE_DEVICE_CHANGED;intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE):整条链路只读这一个 extra。取出来的是 framework 侧的BluetoothDevice(MAC 地址的封装)。注意广播里可能没有这个 extra(比如”断开所有激活设备”时 framework 可能发 device 为 null 的广播),这里不做 null 检查,device 为 null 会原样传给 Handler——null 的处理(或者说没处理)发生在下游findDevice(null)返回 null 的路径上;mHandlerMap.get(action)→ 命中ActiveDeviceChangedHandler→handler.onReceive(context, intent, device)。settingslib 里这个内部Handler是自定义接口(void onReceive(Context, Intent, BluetoothDevice)),不是 android.os.Handler,别混淆。
xcddif 版逻辑相同,但包了一层小米移植的 try-catch:
// xcddif BluetoothEventManager.java:377-395
private class BluetoothBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
BluetoothDevice device = intent
.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
Handler handler = mHandlerMap.get(action);
if (handler != null) {
// MICAR-PORTING-START
try {
handler.onReceive(context, intent, device);
} catch (Exception e) {
e.printStackTrace();
}
// MICAR-PORTING-END
}
}
}MICAR-PORTING-START/END 标记(xcddif :386-392)是小米车机移植改动:任何 Handler 抛异常都被吃掉只打堆栈,防止单条广播处理崩溃导致后续广播全军覆没(BroadcastReceiver.onReceive 抛异常会使 App 崩溃)。代价是:如果某个 Handler 真的出 bug,表现为”广播收到但没生效”,堆栈只在 logcat 里,排障时要留意(见第七节)。dcddif 版没有这层保护。
三、Handler 精讲:从 intent 到 dispatch
3.1 ActiveDeviceChangedHandler(dcddif 版)
// dcddif BluetoothEventManager.java:431-453
private class ActiveDeviceChangedHandler implements Handler {
@Override
public void onReceive(Context context, Intent intent, BluetoothDevice device) {
String action = intent.getAction();
if (action == null) {
Log.w(TAG, "ActiveDeviceChangedHandler: action is null");
return;
}
CachedBluetoothDevice activeDevice = mDeviceManager.findDevice(device);
int bluetoothProfile = 0;
if (Objects.equals(action, BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED)) {
bluetoothProfile = BluetoothProfile.A2DP;
} else if (Objects.equals(action, BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED)) {
bluetoothProfile = BluetoothProfile.HEADSET;
} else if (Objects.equals(action, BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED)) {
bluetoothProfile = BluetoothProfile.HEARING_AID;
} else {
Log.w(TAG, "ActiveDeviceChangedHandler: unknown action " + action);
return;
}
dispatchActiveDeviceChanged(activeDevice, bluetoothProfile);
}
}逐段拆:
第 1 段(:434-438)action 判空。 防御式代码,action 为 null 打 warn 日志直接返回。理论上注册表按 action 命中才进来,到这里 action 不会是 null,这层是双保险。
第 2 段(:439)找缓存设备——全链路唯一一次”查表”。
CachedBluetoothDevice activeDevice = mDeviceManager.findDevice(device);广播里带来的是 framework 的 BluetoothDevice(本质是 MAC 地址),车设 UI 认识的是 CachedBluetoothDevice(带名字、电量、profile 状态、active 位的富对象)。findDevice 负责换算:
// dcddif CachedBluetoothDeviceManager.java:78-91
public synchronized CachedBluetoothDevice findDevice(BluetoothDevice device) {
for (CachedBluetoothDevice cachedDevice : mCachedDevices) {
if (cachedDevice.getDevice().equals(device)) {
return cachedDevice;
}
// Check sub devices if it exists
CachedBluetoothDevice subDevice = cachedDevice.getSubDevice();
if (subDevice != null && subDevice.getDevice().equals(device)) {
return subDevice;
}
}
return null;
}- 所谓”查表”是线性遍历内存 List
mCachedDevices(已配对/已发现设备的缓存),用BluetoothDevice.equals比对——BluetoothDevice的 equals 比的是底层 MAC 地址字符串,所以等价于”按 MAC 找对象”。设备量大时是 O(n),但车机上已配对设备通常个位数,不构成性能问题; - 额外检查
getSubDevice():助听器左右耳在 settingslib 里建模为”主设备 + subDevice”,广播可能报的是另一只耳朵的 MAC,所以主设备不匹配时还要看它的 sub 设备; - 找不到返回 null,不创建新对象。null 会一路传进
dispatchActiveDeviceChanged(activeDevice=null, ...),它的语义是”当前 profile 上没有激活设备了”(所有设备都去激活)——这个设计很关键,见 3.2; - 方法加
synchronized:mCachedDevices 会被连接/配对等路径并发修改。
第 3 段(:440-450)action → profile 映射。 一个 Handler 服务多条广播,靠 if-else 链把 action 字符串翻译成 BluetoothProfile 整型常量(A2DP=2、HEADSET=1、HEARING_AID=21)。default 分支打 Log.w(TAG, "ActiveDeviceChangedHandler: unknown action " + action) 并 return——遇到未注册的 active 类广播直接放弃,这是排障时该盯的第一条日志(详见第七节)。
第 4 段(:451)交给分发器。 dispatchActiveDeviceChanged(activeDevice, bluetoothProfile)。
3.2 dispatchActiveDeviceChanged:isActive 是推导出来的
dcddif 版:
// dcddif BluetoothEventManager.java:224-234
@VisibleForTesting
void dispatchActiveDeviceChanged(CachedBluetoothDevice activeDevice,
int bluetoothProfile) {
for (CachedBluetoothDevice cachedDevice : mDeviceManager.getCachedDevicesCopy()) {
boolean isActive = Objects.equals(cachedDevice, activeDevice);
cachedDevice.onActiveDeviceChanged(isActive, bluetoothProfile);
}
for (BluetoothCallback callback : mCallbacks) {
callback.onActiveDeviceChanged(activeDevice, bluetoothProfile);
}
}这是监听侧最精巧的一段,三个要点:
- 遍历的是全量设备副本(
getCachedDevicesCopy(),防遍历中修改),不是只通知 activeDevice 一个。因为”激活设备切换”意味着旧设备要从 true 翻成 false——广播只报了新设备,旧设备的去激活就是靠”遍历所有人、非新即 false”实现的。 isActive = Objects.equals(cachedDevice, activeDevice)(:228)——这就是上文说的”推导”。framework 广播根本不告诉你哪台设备被去激活,settingslib 对每台缓存设备问一句”你是不是广播里那台”:是 → true,不是 → false。CachedBluetoothDevice.equals比较的是封装的BluetoothDevice(即 MAC)。特例:activeDevice 为 null(蓝牙关闭、全部去激活)时,所有设备都得到 false,恰好就是”清空”语义。这也解释了为什么不需要读 EXTRA_ACTIVE:真值表已经完备。- 先刷对象、再通知 Callback(:231-233)。第一轮循环把每台
CachedBluetoothDevice的 active 位更新完(内部自己会 dispatchAttributesChanged,见第四节),第二轮才通知注册在 EventManager 上的BluetoothCallback.onActiveDeviceChanged(activeDevice, bluetoothProfile)——保证 Callback 被调时,各设备的 active 位已经是新值。车设 UI 的BluetoothPreferenceController虽然实现了这个回调但是空实现(settingsPage/micarConnectionSettings/src/main/java/com/android/car/settings/miauto/bluetooth/controller/BluetoothPreferenceController.java:162-163),真正驱动 UI 的是第一轮里 CBD 内部的属性分发(见第四节末尾)。
3.3 xcddif 版 dispatch:多了 CSIS 组员回退和 DeviceManager 通知
// xcddif BluetoothEventManager.java:310-336
@VisibleForTesting
void dispatchActiveDeviceChanged(
@Nullable CachedBluetoothDevice activeDevice,
int bluetoothProfile) {
for (CachedBluetoothDevice cachedDevice : mDeviceManager.getCachedDevicesCopy()) {
Set<CachedBluetoothDevice> memberSet = cachedDevice.getMemberDevice();
boolean isActive = Objects.equals(cachedDevice, activeDevice);
if (!isActive && !memberSet.isEmpty()) {
for (CachedBluetoothDevice memberCachedDevice : memberSet) {
isActive = Objects.equals(memberCachedDevice, activeDevice);
if (isActive) {
Log.d(TAG,
"The active device is the member device "
+ activeDevice.getDevice().getAnonymizedAddress()
+ ". change activeDevice as main device "
+ cachedDevice.getDevice().getAnonymizedAddress());
activeDevice = cachedDevice;
break;
}
}
}
cachedDevice.onActiveDeviceChanged(isActive, bluetoothProfile);
mDeviceManager.onActiveDeviceChanged(cachedDevice);
}
for (BluetoothCallback callback : mCallbacks) {
callback.onActiveDeviceChanged(activeDevice, bluetoothProfile);
}
}和 dcddif 的两处差异:
- CSIS 组员回退(:314-329):LE Audio 耳机用 CSIS(协同组集合)建模,左右耳/组内成员是
memberDevice集合而不是 dcddif 那种单一 subDevice。广播报的可能是组内某台成员的 MAC,findDevice找回来的activeDevice是成员对象;这段循环发现”active 是某主设备的成员”后,把 activeDevice 替换成主设备(:325activeDevice = cachedDevice),让后续 Callback 拿到的是主设备对象。日志用getAnonymizedAddress()打的是匿名化 MAC(隐私合规)。 mDeviceManager.onActiveDeviceChanged(cachedDevice)(:331):每台设备刷完 active 位后额外通知 CachedBluetoothDeviceManager。它的实现只干一件事:
// xcddif CachedBluetoothDeviceManager.java:369-373
public synchronized void onActiveDeviceChanged(CachedBluetoothDevice cachedBluetoothDevice) {
if (cachedBluetoothDevice.isHearingAidDevice()) {
mHearingAidDeviceManager.onActiveDeviceChanged(cachedBluetoothDevice);
}
}助听器设备才转发给 HearingAidDeviceManager(用于助听器特有的双耳联动逻辑),其他设备直接忽略。dcddif 没有这一层通知。
xcddif 的 findDevice 也相应升级为检查 CSIS 成员集合:
// xcddif CachedBluetoothDeviceManager.java:92-106
public synchronized CachedBluetoothDevice findDevice(BluetoothDevice device) {
for (CachedBluetoothDevice cachedDevice : mCachedDevices) {
if (cachedDevice.getDevice().equals(device)) {
return cachedDevice;
}
// Check the member devices for the coordinated set if it exists
final Set<CachedBluetoothDevice> memberDevices =
new HashSet<CachedBluetoothDevice>(cachedDevice.getMemberDevice());
if (!memberDevices.isEmpty()) {
for (CachedBluetoothDevice memberDevice : memberDevices) {
if (memberDevice.getDevice().equals(device)) {
return memberDevice;
}
}
}
}
...主体逻辑与 dcddif 一致(线性遍历 + BluetoothDevice.equals 按 MAC 比对),只是”子设备”从 getSubDevice()(单个,助听器)换成 getMemberDevice()(集合,CSIS 组)。
四、onActiveDeviceChanged 逐行精讲
这是监听侧的终点站:active 位真正落字段的地方。
4.1 dcddif 版(三分支)
// dcddif CachedBluetoothDevice.java:640-663
/**
* Update the device status as active or non-active per Bluetooth profile.
*
* @param isActive true if the device is active
* @param bluetoothProfile the Bluetooth profile
*/
public void onActiveDeviceChanged(boolean isActive, int bluetoothProfile) {
boolean changed = false;
switch (bluetoothProfile) {
case BluetoothProfile.A2DP:
changed = (mIsActiveDeviceA2dp != isActive);
mIsActiveDeviceA2dp = isActive;
break;
case BluetoothProfile.HEADSET:
changed = (mIsActiveDeviceHeadset != isActive);
mIsActiveDeviceHeadset = isActive;
break;
case BluetoothProfile.HEARING_AID:
changed = (mIsActiveDeviceHearingAid != isActive);
mIsActiveDeviceHearingAid = isActive;
break;
default:
Log.w(TAG, "onActiveDeviceChanged: unknown profile " + bluetoothProfile +
" isActive " + isActive);
break;
}
if (changed) {
dispatchAttributesChanged();
}
}对应字段声明(dcddif CachedBluetoothDevice.java:147-149):
// dcddif CachedBluetoothDevice.java:147-149
private boolean mIsActiveDeviceA2dp = false;
private boolean mIsActiveDeviceHeadset = false;
private boolean mIsActiveDeviceHearingAid = false;逐行拆:
boolean changed = false(:641):本帧是否有实际变化的标记。初始 false,只有某个 case 里发现新旧值不同才会置 true。- switch 四分支(:642-659),每个 case 都是同一个三步模板,以 A2DP 为例:
changed = (mIsActiveDeviceA2dp != isActive)(:644):先比较后赋值——用”旧值 != 新值”算出这帧是否真的改变了状态。注意执行顺序:如果先赋值再比较,changed 永远是 false,这是一个极易写错的一行;mIsActiveDeviceA2dp = isActive(:645):落字段。每个 profile 一个独立 boolean,互不影响——同一台设备完全可以同时是 A2DP active 和 HEADSET active(边放歌边来电),两个位各自翻各自的;break:只翻自己 profile 的位。- 三个 case 分别操作
mIsActiveDeviceA2dp/mIsActiveDeviceHeadset/mIsActiveDeviceHearingAid。
- default 分支(:655-658):
Log.w(TAG, "onActiveDeviceChanged: unknown profile " + bluetoothProfile + " isActive " + isActive)——收到不认识的 profile 时不崩溃、不改字段、只打告警。changed 保持 false,所以也不会触发任何 UI 刷新。什么时候会走到这?dcddif 的 Handler 上游只映射出 A2DP/HEADSET/HEARING_AID 三种,正常到不了 default;能到 default 的情况包括:别的进程直接构造调用、未来加广播忘了加分支、或 profile 常量被误传。排障时这条 warn 是”active 位没刷”的直接线索。 if (changed) dispatchAttributesChanged()(:660-662)——“值变才分发”的短路逻辑:这是整个 push 链的降噪开关。回看 3.2:dispatch 遍历所有缓存设备,对每台都调一次本方法,但广播只涉及一台新 active(+一台隐式旧 active)。其余所有设备每帧都会收到一次onActiveDeviceChanged(false, profile)调用——如果无条件 dispatch,每次切歌全列表设备都会刷新一遍 UI。有了这个短路:值没变的设备静默返回,只有真正翻位的设备才往下游通知。
⚠️ “只有 2 台”的限定条件(红队核验补充):“一次广播 = 新旧两台翻位 = 2 次属性分发”只在设备都在顶层缓存列表、且无陈旧位时成立。两个反例:
- dcddif 助听器副耳:广播报副耳 MAC 时,
findDevice能通过getSubDevice()找到副耳对象(dcddif CachedBluetoothDeviceManager.java:84-87),但 dispatch 遍历的getCachedDevicesCopy()只含顶层 mCachedDevices——副耳被setSubDeviceIfNeeded吸收后不入列(dcddif CachedBluetoothDeviceManager.java:107-110)。结果:真正的”新 active”(副耳)0 次分发,主设备反被equals(main, sub)=false刷成 false——次数与语义双偏差。xcddif 靠 CSIP 回退把 activeDevice 替换成主设备(:325)才保住”2 次”。- 陈旧位残留:若上次广播丢失,某台设备的位残留 true,本次广播遍历时它也会翻位分发——分发次数 >2。
- 多 profile 同时切换(如来电同时触发 HEADSET+A2DP 两条广播)是两次独立广播、各自一遍遍历,不属”一次广播”范畴。
4.2 xcddif 版(四分支,含 LeAudio)
// xcddif CachedBluetoothDevice.java:835-868
public void onActiveDeviceChanged(boolean isActive, int bluetoothProfile) {
if (BluetoothUtils.D) {
Log.d(TAG, "onActiveDeviceChanged: "
+ "profile " + BluetoothProfile.getProfileName(bluetoothProfile)
+ ", device " + mDevice.getAnonymizedAddress()
+ ", isActive " + isActive);
}
boolean changed = false;
switch (bluetoothProfile) {
case BluetoothProfile.A2DP:
changed = (mIsActiveDeviceA2dp != isActive);
mIsActiveDeviceA2dp = isActive;
break;
case BluetoothProfile.HEADSET:
changed = (mIsActiveDeviceHeadset != isActive);
mIsActiveDeviceHeadset = isActive;
break;
case BluetoothProfile.HEARING_AID:
changed = (mIsActiveDeviceHearingAid != isActive);
mIsActiveDeviceHearingAid = isActive;
break;
case BluetoothProfile.LE_AUDIO:
changed = (mIsActiveDeviceLeAudio != isActive);
mIsActiveDeviceLeAudio = isActive;
break;
default:
Log.w(TAG, "onActiveDeviceChanged: unknown profile " + bluetoothProfile +
" isActive " + isActive);
break;
}
if (changed) {
dispatchAttributesChanged();
}
}LeAudio 第四分支的核实结论:存在,在 xcddif CachedBluetoothDevice.java:856-858(case BluetoothProfile.LE_AUDIO 操作 mIsActiveDeviceLeAudio,该字段声明在 xcddif :129)。注意任务描述里问的是”f3dif 版是否有 LeAudio 第四分支”——f3dif 目录本仓不存在,有第四分支的是 xcddif 版。除此之外还有一处差异:xcddif 在方法入口加了调试日志(:836-841),受 BluetoothUtils.D 开关控制,打印 profile 名、匿名化 MAC 和 isActive——排障时这是最有价值的一条日志(见第七节)。四分支骨架、“先比后赋”、default 告警、changed 短路与 dcddif 完全一致。
4.3 dispatch 之后:属性分发到 UI
// dcddif CachedBluetoothDevice.java:895-899
void dispatchAttributesChanged() {
for (Callback callback : mCallbacks) {
callback.onDeviceAttributesChanged();
}
}mCallbacks 是注册在这台 CBD 上的 CachedBluetoothDevice.Callback 集合。UI 侧的注册点(车设连接设置页):
BluetoothDevicePreferenceController.java:42:private final CachedBluetoothDevice.Callback mDeviceCallback = this::refreshUi;,:95处mCachedDevice.registerCallback(mDeviceCallback)—— 属性变了就 refreshUi 重读摘要(摘要里含”使用中”文案,见 6.2);NewBluetoothDevicePreference.java:58:mDeviceCallback = this::refreshDeviceUi;BluetoothPreferenceController.java:115:另一路,把 controller 自己注册到 EventManager(mBluetoothManager.getEventManager().registerCallback(this)),但它对onActiveDeviceChanged是空实现(:162-163),active 变化实际全靠 CBD 内部 Callback 这一路驱动。
dispatch 之后的完整故事(回调线程、注册时机、别的分发入口)见 65-属性刷新与回调分发.md。
五、onAudioModeChanged 与音频模式的关系
5.1 为什么只 dispatch 不改字段
// dcddif CachedBluetoothDevice.java:665-670
/**
* Update the profile audio state.
*/
void onAudioModeChanged() {
dispatchAttributesChanged();
}xcddif 相同(xcddif CachedBluetoothDevice.java:870-875)。它由 dispatchAudioModeChanged 对全量设备广播式调用:
// dcddif BluetoothEventManager.java:215-222
private void dispatchAudioModeChanged() {
for (CachedBluetoothDevice cachedDevice : mDeviceManager.getCachedDevicesCopy()) {
cachedDevice.onAudioModeChanged();
}
for (BluetoothCallback callback : mCallbacks) {
callback.onAudioModeChanged();
}
}触发源是 2.1 注册的两条广播(SCO 音频状态、电话状态),经 AudioModeChangedHandler(dcddif :495-506,action 判空后直接 dispatch,不看内容)。
为什么它不改任何字段? 因为”是否通话中”不是 CachedBluetoothDevice 的存量字段——它是读取时实时查询的环境状态(AudioManager.getMode()),不归这台设备管,也不需要在设备对象里维护副本。通话状态变化影响的不是 active 位本身,而是**“哪个 active 位算数”的展示规则**(下节)。所以这个方法只需要做一件事:踢一脚 dispatchAttributesChanged(),强迫 UI 重新执行摘要生成逻辑——重新跑一遍”现在通话中吗”的实时查询。对比 onActiveDeviceChanged 的”值变才 dispatch”短路,这里无条件 dispatch:因为它没有本地字段可比,宁可多刷一次也不能漏。
5.2 音频模式怎么消费 active 位
真正把”通话状态”和”active 位”合到一起的地方在摘要生成里:
// dcddif CachedBluetoothDevice.java:1119-1136(getCarConnectionSummary 内)
// Set active string in following device connected situation.
// 1. Hearing Aid device active.
// 2. Headset device active with in-calling state.
// 3. A2DP device active without in-calling state.
if (a2dpConnected || hfpConnected || hearingAidConnected) {
final boolean isOnCall = Utils.isAudioModeOngoingCall(mContext);
if ((mIsActiveDeviceHearingAid)
|| (mIsActiveDeviceHeadset && isOnCall)
|| (mIsActiveDeviceA2dp && !isOnCall)) {
if (isTwsBatteryAvailable(leftBattery, rightBattery) && !shortSummary) {
stringRes = R.string.bluetooth_active_battery_level_untethered;
} else if (batteryLevelPercentageString != null && !shortSummary) {
stringRes = R.string.bluetooth_active_battery_level;
} else {
stringRes = R.string.bluetooth_active_no_battery_level;
}
}
}规则(注释 :1119-1122 写得很清楚):
- 助听器设备:只要 HEARING_AID active 就显示”使用中”;
- HFP(通话 profile)设备:HEADSET active 且正在通话才显示”使用中”;
- A2DP(媒体 profile)设备:A2DP active 且不在通话才显示”使用中”。
Utils.isAudioModeOngoingCall 的实现:
// dcddif Utils.java(base/settingsLibAndroid/src/dcddif/java/com/android/settingslib/Utils.java:415-424)
/**
* get that {@link AudioManager#getMode()} is in ringing/call/communication(VoIP) status.
*/
public static boolean isAudioModeOngoingCall(Context context) {
final AudioManager audioManager = context.getSystemService(AudioManager.class);
final int audioMode = audioManager.getMode();
return audioMode == AudioManager.MODE_RINGTONE
|| audioMode == AudioManager.MODE_IN_CALL
|| audioMode == AudioManager.MODE_IN_COMMUNICATION;
}响铃/通话/VoIP 通信三种 mode 都算”通话中”(xcddif 的同款消费在 xcddif CachedBluetoothDevice.java:1430)。所以来电瞬间会发生两次 UI 刷新:SCO/电话状态广播 → onAudioModeChanged → dispatch(第一次,通话中变 true,摘要从”A2DP active”切到”HEADSET active”);若蓝牙服务同时把 SCO 路由到耳机,还会有 ACTION_ACTIVE_DEVICE_CHANGED(HEADSET)→ active 位翻转 → dispatch(第二次)。两个信号是独立的,顺序不保证——这就是 5.1 无条件 dispatch 的意义:无论谁先到,摘要都会重算。
六、LocalBluetoothProfileManager 在这条链上的角色
结论先行:push(广播)链路完全不经过 LocalBluetoothProfileManager。从上文所有代码可见,链路是 BluetoothEventManager → CachedBluetoothDeviceManager(findDevice/遍历)→ CachedBluetoothDevice,ProfileManager 一次都没出现。CachedBluetoothDevice.onActiveDeviceChanged 只写自己的 boolean 字段,不调任何 profile 服务接口。
ProfileManager 出场的是 pull 链路——CachedBluetoothDevice.fetchActiveDevices():
// 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);
}
}注意它写的是同一组 mIsActiveDevice* 字段——push 和 pull 殊途同归,都是维护这三个(xcddif 四个)boolean。pull 在对象创建/刷新时用 binder 调 getActiveDevice() 主动对齐一次,push 在运行期靠广播增量维护;车机重启、进程被杀后冷启动,靠 pull 找回真相。两条腿的详细互补关系(时序、谁先谁后、为什么缺一不可)见第 71 篇,本篇不展开。active 位的多设备语义(为什么每 profile 只有一台 active、多设备竞争)见 67-active设备与多设备.md。
七、三 flavor 对照表
如前述,本仓实际只有 dcddif、xcddif 两份源码副本(global flavor 的 java 源 = main + xcddif + global 目录叠加,base/settingsLibAndroid/build.gradle:60-66),下表为逐源码核对结果:
| 对照项 | dcddif(国内 DCD) | xcddif(XCD,global 复用) |
|---|---|---|
| 注册的 ACTIVE 广播 | 3 条:A2DP / HEADSET / HEARING_AID(BluetoothEventManager.java:114-117) | 4 条:A2DP / HEADSET / HEARING_AID / LE_AUDIO(BluetoothEventManager.java:136-141) |
| Handler 类名 | ActiveDeviceChangedHandler(:431) | ActiveDeviceChangedHandler(:706),同名同类 |
| Handler 内 action→profile 分支 | 3 分支 + default 告警(:441-450) | 4 分支 + default 告警(:717-728),多 BluetoothProfile.LE_AUDIO(:723-725) |
onActiveDeviceChanged switch 分支 | 3 分支(A2DP/HEADSET/HEARING_AID)+ default(CachedBluetoothDevice.java:642-659) | 4 分支(…+ LE_AUDIO,:856-858)+ default |
| active 字段 | 3 个 boolean(:147-149) | 4 个 boolean(:126-129,多 mIsActiveDeviceLeAudio) |
| dispatchActiveDeviceChanged 特点 | 纯遍历比对(:224-234) | 多 CSIS 组员→主设备回退(:316-329)+ mDeviceManager.onActiveDeviceChanged(:331) |
| findDevice 子设备处理 | 单个 getSubDevice()(CachedBluetoothDeviceManager.java:84-87) | getMemberDevice() 集合(:98-105) |
| DeviceManager.onActiveDeviceChanged | 无此方法 | 有,:369-373,助听器转发给 HearingAidDeviceManager |
| 入口接收器 try-catch | 无(:248-260,裸调) | MICAR-PORTING try-catch(:386-392) |
| onActiveDeviceChanged 调试日志 | 无 | BluetoothUtils.D 开关日志(:836-841) |
onAudioModeChanged | 只 dispatch(:668-670) | 只 dispatch(:873-875),一致 |
| isOnCall 消费点 | getCarConnectionSummary 内 :1124 | :1430,逻辑一致 |
| AudioMode 广播注册 | ACTION_AUDIO_STATE_CHANGED + ACTION_PHONE_STATE_CHANGED(:119-123) | 同(:144-147) |
| MICAR-PORTING/MIUI 标记(仅统计 active 链路相关方法) | BluetoothEventManager 全文无标记;CBD 的 onActiveDeviceChanged 无标记 | BluetoothBroadcastReceiver 有 MICAR-PORTING(:386-392);ActiveDeviceChangedHandler、dispatchActiveDeviceChanged、CBD.onActiveDeviceChanged 均无显式标记。BEM 文件里的其他 MIUI 标记:BT_MIUIBluetoothFrame(:78、:184)、BT_BluetoothHalfClose(:179、:409、:422、:456)——MIUI ADD: BT_LeAudio 标记不在 BEM,在 xcddif CachedBluetoothDevice.java:546/:1777 |
f3dif:本仓无此目录、无此源集,无法对照(不写未经核实的推测)。
八、时序图:一条广播的完整旅程
sequenceDiagram autonumber participant BT as 系统蓝牙进程<br/>com.android.bluetooth participant FW as Android framework<br/>(广播分发) participant EM as BluetoothEventManager<br/>BluetoothBroadcastReceiver participant H as ActiveDeviceChangedHandler participant DM as CachedBluetoothDeviceManager participant CBD as CachedBluetoothDevice<br/>(每台设备一个) participant CB as CachedBluetoothDevice.Callback<br/>(UI:PreferenceController) participant UI as 车设蓝牙界面 BT->>FW: 发送 ACTION_ACTIVE_DEVICE_CHANGED<br/>(EXTRA_DEVICE=新激活设备) FW->>EM: onReceive(context, intent)<br/>(主线程,handler=null) EM->>EM: 取 EXTRA_DEVICE → device<br/>mHandlerMap.get(action) EM->>H: handler.onReceive(ctx, intent, device)<br/>(xcddif 外层 try-catch) H->>DM: findDevice(device)<br/>(线性遍历,MAC equals,<br/>dcddif 查 subDevice / xcddif 查 memberDevice) DM-->>H: activeDevice(或 null=全部去激活) H->>H: action → profile 映射<br/>(A2DP/HEADSET/HEARING_AID[/LE_AUDIO]) H->>EM: dispatchActiveDeviceChanged(activeDevice, profile) loop 每台缓存设备 getCachedDevicesCopy() EM->>CBD: onActiveDeviceChanged(isActive, profile)<br/>isActive = (cachedDevice == activeDevice) CBD->>CBD: changed = (旧值 != isActive)<br/>写 mIsActiveDeviceXxx 字段 alt changed == true(只有新旧两台 active 翻位) CBD->>CB: dispatchAttributesChanged() →<br/>onDeviceAttributesChanged() CB->>UI: refreshUi() / refreshDeviceUi() else changed == false(其余设备) CBD-->>EM: 静默返回(短路,不通知) end end EM->>UI: BluetoothCallback.onActiveDeviceChanged<br/>(车设 BluetoothPreferenceController<br/>为空实现,备用扩展点)
注意图里 loop 的主体是 EventManager(它遍历),CBD 是被调方;mDeviceManager.onActiveDeviceChanged(xcddif 的助听器通知)未画入,避免主干混乱。
九、排障要点:广播丢了,active 位就陈旧
push 链的本质是增量维护:字段值 = 上次 pull 初值 + 之后所有广播的累计效果。任何一环断裂,active 位就停在旧状态,UI 的”使用中”标错设备。按链路顺序排查:
1. 症状识别。 设备列表/详情页”使用中”文案不切换,但音乐实际已在另一台设备播放(pull 一下 adb shell dumpsys bluetooth_manager 能看到真实 active 设备与 UI 不符)——典型的 push 断链。反之,冷启动后一次性全错是 pull(fetchActiveDevices)的问题,见 71 篇。
2. 广播到底发没有。 adb shell dumpsys bluetooth_manager | grep -i active 先确认蓝牙服务侧的 active 设备确实变了。若服务侧没变,问题在系统蓝牙,不在车设。
3. 车设进程收到没有。 抓广播:adb logcat | grep -E "ACTIVE_DEVICE|BluetoothEventManager",或 adb shell dumpsys activity broadcasts | grep -A 5 ACTIVE_DEVICE(看 receiver 队列)。收不到查两件事:进程活着吗(蓝牙页不在前台时车设进程可能被杀,广播无人收,这是设计内的——下次进页面靠 pull 对齐);action 在 IntentFilter 里吗(对照 2.1/2.2 的注册清单,LeAudio 广播 dcddif 天然收不到)。
4. Handler 走到哪一步。 按日志逐级定位,两条关键告警:
"ActiveDeviceChangedHandler: unknown action ..."(dcddif :448 / xcddif :727)——action 没映射到 profile,直接 return,什么都不会发生;"onActiveDeviceChanged: unknown profile ..."(dcddif :656 / xcddif :861)——profile 没进 switch 分支,default 只告警不改字段不刷新。
xcddif 有专属调试日志 "onActiveDeviceChanged: profile ..., device ..., isActive ..."(xcddif CachedBluetoothDevice.java:836-841,受 BluetoothUtils.D 开关),能看到每台设备每次调用的入参——dcddif 没有这条日志,排障只能靠断点或上面两条 warn,这是两 flavor 排障体验的最大差别。另外 xcddif 的 try-catch(xcddif :386-392)会吞掉 Handler 异常只留堆栈:如果 logcat 有 java.lang.xxx 堆栈紧跟蓝牙广播却”没有然后了”,先怀疑这里。
5. findDevice 返回 null 的双向含义。 null 不是错误:蓝牙全关/全去激活时 framework 发 device 为 null(或找不到对应)的广播,dispatch 会把所有设备刷成 false——这是清位路径。但如果服务侧明明有 active 设备、findDevice 却返回 null(设备不在 mCachedDevices 里,比如刚配对还没进缓存),这帧广播就等于丢了:UI 无感。排查 CachedBluetoothDeviceManager 缓存里有没有这台 MAC(adb shell dumpsys activity 或加日志打 getCachedDevicesCopy)。
6. “值变才 dispatch”的静默特性。 大量 onActiveDeviceChanged(false, ...) 调用是正常静默(短路),不要因为”没看到刷新”就断定链路断了——先确认新旧行值是否真的该翻转。同理 onAudioModeChanged 的无条件 dispatch 刷的是全列表,通话建立/挂断瞬间全列表摘要重算一次是预期行为。
7. 线程模型。 handler=null → 广播在主线程分发(见 2.3),所以 Handler/CBD 里打耗时日志会卡 UI;如果有人在别的进程/线程构造 LocalBluetoothManager 传了后台 handler,回调线程就变了——CachedBluetoothDevice.Callback 的实现方(如 this::refreshUi)若直接摸 View 会崩。全仓三个 getInstance 入口(MiBluetoothUtils/VoiceAssistProvider/BluetoothRequestPermissionActivity)都传 null handler,主线程成立。
8. 广播报的设备不在顶层缓存列表(副耳/组员)。 findDevice 找得到(它特意查了 subDevice/memberDevice),但 dispatchActiveDeviceChanged 遍历的只是顶层 mCachedDevices——被吸收的副耳收不到这次调用,它的 active 位自出生 fillData 后 push 永不更新(dcddif 无替换逻辑时,主设备还会被误刷成 false,见 §4.1 边界框)。症状:“UI 显示与实际输出不符”但日志里广播一切正常。验证:打印 getCachedDevicesCopy() 确认该 MAC 是否在列;这类设备的 active 位只能靠 pull 时机(fillData/onProfileStateChanged)自愈。
十、自测清单
- 能说出 active 相关广播 dcddif 注册 3 条、xcddif 注册 4 条(多 LE_AUDIO),并给出注册代码位置
- 能解释为什么三条(xcddif 四条)广播共用一个
ActiveDeviceChangedHandler,类名里没有 “State” - 能指出整条链路只读
EXTRA_DEVICE一个 extra,EXTRA_ACTIVE未被使用 - 能推导
isActive的来源:Objects.equals(cachedDevice, activeDevice),旧设备的 false 是遍历”捎带”出来的,不是广播直接说的 - 能解释
findDevice是线性遍历 +BluetoothDevice.equals(按 MAC),并说出 dcddif 查 subDevice、xcddif 查 memberDevice 的差异 - 能默写
onActiveDeviceChanged每个 case 的”先比较后赋值”两行,以及写反顺序会怎样(changed 永远 false,永不刷新) - 能解释
if (changed) dispatchAttributesChanged()的短路意义:一次广播 N 台设备通常只有新旧 2 台真翻位——以及这个”2 台”在副耳/陈旧位场景下为什么不成立(§4.1 边界框) - 能说出 default 分支只打
Log.w不改字段不刷新,以及两条 warn 日志的原文关键字 - 能解释
onAudioModeChanged为什么只 dispatch:通话态是实时查询的环境量,不是设备字段,无条件刷新兜底 - 能复述消费规则:助听器 active 即显示;HFP active 且通话中;A2DP active 且非通话中(
Utils.isAudioModeOngoingCall,三种 AudioManager mode) - 能说出 push 链不经 LocalBluetoothProfileManager,它与 pull(fetchActiveDevices)写同一组字段、互为补充
- 知道 f3dif 目录本仓不存在,LeAudio 分支在 xcddif(CachedBluetoothDevice.java:856-858)
- 排障时知道 dcddif 没有入参调试日志、xcddif 的 try-catch 会吞异常堆栈
十一、交叉引用
- 51-广播与事件清单.md —— 全量广播注册表;本篇只深讲了 active 相关子集
- 21-settingslib架构与事件流.md —— EventManager/CBD/ProfileManager 三层架构总览
- 65-属性刷新与回调分发.md —— dispatchAttributesChanged 之后回调怎么到 UI
- 67-active设备与多设备.md —— active 位的多设备语义
- 70-总览与闭环地图.md —— active 闭环总览(push + pull 全景)
- 71-查询侧-fetchActiveDevices与四维active位.md —— pull 侧 fetchActiveDevices,本章姊妹篇