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) | |
|---|---|---|
| key | groupId(getBaseGroupId CsipDeviceManager:69-87,问 CSIS 服务取 value==CAP 的 key) | hiSyncId(generateHearingAidInfo HearingAidDeviceManager:634-676) |
| main 选举 | getPreferredMainDevice(CsipDeviceManager:259-320):已连接 DUAL(同时有 LeAudio+A2DP/HFP)> LeAudio 组 lead > 任一已连接 > 未连接 DUAL > 组内第一个 | 已连接者为 main、未连接者被移出主表挂 sub(onHiSyncIdChanged :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/mHearingAidInfo | 7 个:前 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) |
| 电量 listener | flag 开时先注销旧再注册新(: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 检验你学到的一切。