67 · active 设备与多设备 — 输出目标、CSIP 组、助听双耳与电量

源码基线 f3dif。交叉引用:32-双蓝牙上限与Picker(上限业务)、63 §2.3 组归档68 §7 3960(两套账务) 本篇讲清:多台设备并存时”谁是当前”、耳机左右耳怎么组团、电量从哪读。

0. 三个容易混淆的”多”

概念层面本篇章节
active device(当前输出目标)同类 profile 维度上的”选一台”§1
设备组(CSIP member / 助听 sub)一副耳机的两只在数据结构里是一个团队§2
双蓝牙上限(最多连 2 台手机)车机业务规则,UI 层实现§3

1. active device:四维度的”当前”

mIsActiveDeviceA2dp/Headset/HearingAid/LeAudio(:157-160)四个位独立维护——“音乐从谁出”和”通话走谁”可以不是同一台

1.1 三条更新路径

路径方法说明
被动(广播驱动)onActiveDeviceChanged(isActive, profile):918-951入口:四个 ACTION_ACTIVE_DEVICE_CHANGED(BEM:135-140 注册,EventManager:296-304 分发)。值变才 dispatch
主动对账fetchActiveDevices():1087-1104向各 profile 查当前 active 设备比对。A2DP/HFP 用 equals(getActiveDevice())(单 active);HA/LeAudio 用 getActiveDevices().contains(mDevice)(双 active——两台助听器同时 active 合法)fillData()onProfileStateChanged 尾部(:400)触发
主动设置setActive():782-813对四个 profile 依次 setActiveDevice(getDevice())已连接才设,任一成功返回 true

单/双 active 语义并存是读代码时的隐形坑:同一个”active”概念,A2DP 是独木桥(一台),助听器是双人桥(一对)。fetchActiveDevices 里两种判定方式(:1090-1102)就是为此。

📌 本节四条路径在 70-active设备闭环篇 有 dcddif/xcddif 双基线的逐代码深讲(71 查询/72 监听/73 设置/74 消费),并核实在 MiCar 车机 UI 上 setActive 与 AOSP 文案管道均为死链。

1.2 谁消费 active 位

isActiveDevice(int profile)(:974)读四个位;UI 层”当前播放设备”标记、音频路由提示都从这来。onAudioModeChanged()/onAudioDeviceCategoryChanged()(:956/:963)变化时仅 dispatch(拉一次全量重渲染)。

2. 设备组:一副耳机的两只耳

车机 UI 上 TWS 耳机显示为一个条目,底下却是 2-3 个真实蓝牙设备。数据结构上有两条互斥通道(63 §2.3 已讲归档流程,本节讲组内怎么运作):

主表 mCachedDevices(CBDM)
   └── main CBD ──┬── mSubDevice(助听器 ASHA 场景,:173)
                  └── mMemberDevices(CSIP 场景,:175,裸 HashSet)

互斥规则:HearingAidDeviceManager.setSubDeviceIfNeeded(:300-322,CSIP 检查段 :302-309)见设备带 CsipSetCoordinatorProfile 直接 return false(Log.w 日志原话 “Skip ASHA grouping since this device supports CSIP”,:307)——支持 CSIP 的设备组关系只走 member 通道。

2.1 组的 key 与 main 选举

CSIP(LE Audio 协调组)助听(ASHA)
keygroupIdgetBaseGroupId CsipDeviceManager:69-87,问 CSIS 服务取 value==CAP 的 key)hiSyncIdgenerateHearingAidInfo HearingAidDeviceManager:634-676)
main 选举getPreferredMainDevice(CsipDeviceManager:259-320):已连接 DUAL(同时有 LeAudio+A2DP/HFP)> LeAudio 组 lead > 任一已连接 > 未连接 DUAL > 组内第一个已连接者为 main、未连接者被移出主表挂 subonHiSyncIdChanged :365-414,:391-405 注释 “We will choose the device that is not connected to be removed”;两边都不连时逆序遍历下后加入者留主表)
组名member 入组时从 main 拷名同左

2.2 换壳:组的灵魂操作(switchXxxDeviceContent)

组内”谁是 main”会随连接状态变化——换 main 不是换引用,是物理交换两个 CBD 对象的字段

switchSubDeviceContent(CBD:2494-2520)switchMemberDeviceContent(CBD:2553-2598)
互换字段4 个:mDevice/mRssi/mJustDiscovered/mHearingAidInfo7 个:前 4 + mConnectAttempted/mIsAclConnectedBrEdr/mIsAclConnectedLe
不换mProfiles(:125-126 注释:副设备只有助听器场景、profile 必同)同左;但双方各跑一次 fillData()(:2583/:2594)重建
前置双方 release() 清 Handler 消息removeMemberDevice 防 hash 错配(:2554-2555,注释 “prevent hash mismatch problem due to mDevice switch”),事后 addMemberDevice 回补(:2597)
电量 listenerflag 开时先注销旧再注册新(:2502-2509)同左

为什么必须先 remove 再 add? hashCode = mDevice.getAddress().hashCode()(:1380)——换壳改了 mDevice,对象在 HashSet 里的 hash 随之改变,不先摘出来就会”丢失”在错误的桶里。“包装器身份 = 被包装对象身份”设计的经典反面教材62 §9)。

2.3 组操作的完整闭环示例(换 main)

CsipDeviceManager.addMemberDevicesIntoMainDevice(:323-395):dispatchDeviceRemoved(旧main)switchMemberDeviceContent(新main)dispatchDeviceAdded(:346-351)——UI 先删条目、内容互换、再以新身份加回。顶层多个同组则合并进 preferred main 并 mCachedDevices.remove(:369-387),随后 syncAudioSharingSourceIfNeeded(:402-444)同步音频分享源。

3. 双蓝牙上限:业务层的”多”

车机最多同时连 2 台手机(DOUBLE_BLUETOOTH_DEVICE_LIMIT=2,ConnectionConstants.kt:19)——这条规则 UI 层实现,settingslib 无感知:五处调用点、三类场景的完整地图在 32 篇

与 active 的关系:上限管”能连几台”(连接集合的势),active 管”当前用谁”(每维度的选择)。两套约束叠加:连了 2 台手机,音乐同一时刻仍只从 1 台出。

3960 的教训68 §7):判断”是否达上限”用了服务侧 endpoint 计数而非真实连接账务——count ≠ 实际连接数,判并发须看时间窗交集

4. 电量:三条来源 + 一个守卫

4.1 事件源:mBatteryMetadataListener(:204-224)

注册进 framework 的 12 种电量 metadata key(METADATA_MAIN_BATTERY_LEVEL、左右耳、充电盒等)变化 → 过滤命中 → dispatchAttributesChanged()(:222)——汇入统一的通知总线(65 §5)。

守卫mIsListeningBatteryChange(:166)防重复注册——registerMainDeviceBatteryMetadataListener(:2671-2695)注册成功置 true、unregister...(:2697-2718)注销成功置 false;重复注册捕获 IllegalArgumentException 仅打日志(:2689-2694)。unregisterCallback两个回调集合都空才注销电量 listener(:1340-1344)——多注册方互不踩踏。换壳时 listener 跟着字段一起迁移(:2502-2509)。

4.2 读数:metadata 优先,BT 服务兜底

getBatteryLevelsInfo()(:1881)
  ├─ getBatteryFromMetadata()(:1892)   ← 左/右/盒三电量 或 输入设备主电量(framework metadata)
  └─ getBatteryFromBluetoothService()(:1951)  ← 兜底:
       助听器按侧(getHearingAidSideBattery :2006)
       LE Audio 按声道(getAudioLocation 的 LEFT/RIGHT_DEVICE_ID,入口 :2017、判定 :2036-2038)
       组最小值

组语义:getMinBatteryLevelWithMemberDevices()(:845)/getValidMinBatteryLevelWithMemberDevices()(:863,过滤 UNKNOWN)——一副耳机显示的是组内最低电量(短板效应,符合直觉)。

低电判定 isBatteryLow(level, key)(:2155-2161):阈值按 metadata key(*_LOW_BATTERY_THRESHOLD)取,<=0 回落 DEFAULT_LOW_BATTERY_THRESHOLD=20着色只发生在 TV 摘要链路addBatterySpan :2144-2153,SUMMARY_NO_COLOR_FOR_LOW_BATTERY=0 为”不着色”哨兵)——车机 UI 不用这套。

5. 多设备排障清单

  • “连上了但没声音”:先查 active——onActiveDeviceChanged 有没有把这台设为 active(四个维度分别查);双 active 语义的 profile(助听/LE Audio)查 contains 而非 equals。
  • “耳机只显示一只”:组归档问题——另一只挂在 mMemberDevices/mSubDevice 里(主表只有一条是设计如此);查 setMemberDeviceIfNeeded/setSubDeviceIfNeeded 为什么没归上组(groupId/hiSyncId 从哪来)。
  • “副耳机状态不同步”:换壳路径(switchXxxDeviceContent)看有没有跑——mDevice 换了但 mProfiles 没换(设计如此)会不会是你要的解释。
  • “电量显示怪”:三条来源哪个在供数(metadata / 服务兜底 / 组最小值);isBatteryLow 的阈值来源。
  • “第二台手机连不上”:上限判定(UI 层五处调用点,32 篇)+ 3960 式两套账务背离。

6. 自测

  • “音乐从谁出”和”通话走谁”能是两台设备吗?(能——四个 active 位独立)
  • A2DP 和助听器的 active 语义差在哪?(单 active vs 双 active)
  • TWS 副耳机为什么不在主表?(挂 mMemberDevices/mSubDevice,通过 main 露脸)
  • 换壳为什么必须先 removeMemberDevice?(hashCode 随 mDevice 变化)
  • 副设备的 mProfiles 换壳时换吗?为什么?(不换——副设备只有助听器场景,profile 必同)
  • 耳机显示的电量是什么?(组内最小值;metadata 优先、BT 服务兜底)
  • 双蓝牙上限在哪层实现?(UI 业务层,settingslib 无感知)

至此 60-67 章机制篇完结。最后一站:68-实战案例走读——用 8 条真实 JIRA 检验你学到的一切。