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_CHANGEDACTION_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 视角来说,只需要知道三件事:

  1. 广播 action 字符串常量定义在 framework 类里:BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGEDBluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGEDBluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGEDBluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED;
  2. intent 里带 BluetoothDevice.EXTRA_DEVICE 这个 extra,key 值就是字符串 "android.bluetooth.device.extra.DEVICE",value 是 BluetoothDevice Parcelable——settingslib 两个 flavor 都只读这一个 extra(代码见下文);
  3. framework 广播里其实还有 EXTRA_ACTIVE(“android.bluetooth.device.extra.ACTIVE”,boolean)这类标记激活/去激活的 extra,但本仓 settingslib 根本不读它——两个 flavor 目录 grep EXTRA_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());

三个要点:

  1. 三条广播共用同一个 Handler 类 ActiveDeviceChangedHandler(注意:类名里没有 “State”,不叫 ActiveDeviceStateChangedHandler——有些资料按 AOSP 老版本记忆会写错,AOSP 上游确实曾叫 ActiveDeviceStateChangedHandler,本仓两个 flavor 都已改名为 ActiveDeviceChangedHandler)。谁是”哪条广播”靠 intent.getAction() 在 Handler 内部再分一次(见 2.4)。
  2. 三条广播分别对应 A2DP(媒体)、HEADSET(HFP 通话)、HEARING_AID(助听器)三个 profile 的激活变化。
  3. 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) 的最后一个参数决定广播在哪个线程回调 onReceivemReceiverHandler 来自构造函数(dcddif :88 mReceiverHandler = handler)。本仓拿 manager 的入口共三处(红队核验补全,LocalBluetoothManager.getInstance 全仓仅此三点、create(...) 工厂零调用):

入口位置handler
MiBluetoothUtils.getLocalBtManager()micarConnectionSettings MiBluetoothUtils.kt:65-78(调 getInstance(context){...})null
VoiceAssistProviderbase/settingsBaseUi VoiceAssistProvider.kt:416(调 getInstance(BaseApplication.getGlobalApp(), null),查 PBAP 状态)null
BluetoothRequestPermissionActivitysettingsPage/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) → 命中 ActiveDeviceChangedHandlerhandler.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=2HEADSET=1HEARING_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);
    }
}

这是监听侧最精巧的一段,三个要点:

  1. 遍历的是全量设备副本(getCachedDevicesCopy(),防遍历中修改),不是只通知 activeDevice 一个。因为”激活设备切换”意味着旧设备要从 true 翻成 false——广播只报了新设备,旧设备的去激活就是靠”遍历所有人、非新即 false”实现的。
  2. isActive = Objects.equals(cachedDevice, activeDevice)(:228)——这就是上文说的”推导”。framework 广播根本不告诉你哪台设备被去激活,settingslib 对每台缓存设备问一句”你是不是广播里那台”:是 → true,不是 → false。CachedBluetoothDevice.equals 比较的是封装的 BluetoothDevice(即 MAC)。特例:activeDevice 为 null(蓝牙关闭、全部去激活)时,所有设备都得到 false,恰好就是”清空”语义。这也解释了为什么不需要读 EXTRA_ACTIVE:真值表已经完备
  3. 先刷对象、再通知 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 替换成主设备(:325 activeDevice = 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 次属性分发”只在设备都在顶层缓存列表、且无陈旧位时成立。两个反例:

  1. 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 次”。
  2. 陈旧位残留:若上次广播丢失,某台设备的位残留 true,本次广播遍历时它也会翻位分发——分发次数 >2。
  3. 多 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;,:95mCachedDevice.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 会吞异常堆栈

十一、交叉引用