蓝牙已配对设备摘要状态机 — AOSP 与 MiCar 实现全解
知识库文档 · 2026-08-11 · 锚点:MANNPROB-3187 / MANNPROB-3205 代码基线:AOSP 侧 =
MiCarSettings/base/settingsLibAndroid/src/f3dif(与 AOSP 一致,2026-09-07 另与 upstream main 逐行核对);MiCar 侧 = dev_a17_0610 出货线 + change 362953 PS3(修复后) v2(2026-09-07)增补:MIICOSBUG-2471 —— 双源结构第二种失败模式(同设备跨 profile 覆写,§5);AOSP 线程模型支柱(§2.2 性质 4) 用途:蓝牙状态显示类 bug 的归因底座;改这块代码前的必读。
1. 底层数据模型(两方共用):per-profile 状态机
AOSP settingslib 的 CachedBluetoothDevice 为每个设备维护 profile → 连接状态 的映射,这是所有上层显示的唯一真相源:
每台设备:HFP=?, A2DP=?, PBAP=?, MAP=? ...
每个 profile 四态:DISCONNECTED(0) / CONNECTING(1) / CONNECTED(2) / DISCONNECTING(3)
两个派生存量查询(实时遍历,无缓存):
// CachedBluetoothDevice.java:1003 —— 任一 profile 连上即 true
public boolean isConnected() { 任一 profile == CONNECTED }
// CachedBluetoothDevice.java:1043 —— 任一 profile 在过渡中即 true(含 DISCONNECTING!)
public boolean isBusy() { 任一 profile ∈ {CONNECTING, DISCONNECTING} || BOND_BONDING }⚠️ isBusy 语义是”过渡中”而非”连接中”——断开期间也是 true。这是 3205 断开采灰波动的根因输入。
事件驱动链(两方共用)
sequenceDiagram participant Stack as 蓝牙协议栈 participant EM as BluetoothEventManager participant CD as CachedBluetoothDevice participant Pref as 设备 Preference Stack->>EM: 广播 ACTION_CONNECTION_STATE_CHANGED<br/>(device, profile, state) EM->>CD: onProfileStateChanged(profile, state) :277 Note over CD: 更新 per-profile 状态机<br/>isConnected/isBusy 即刻变化 CD->>Pref: refresh() :877 → mDeviceCallback Pref->>Pref: refreshDeviceUi() → setSummary(重算摘要) EM-->>Pref: (MiCar 另有 Controller 旁路,见 §3)
2. AOSP 原生实现
2.1 摘要决策:CachedBluetoothDevice.getConnectionSummary()(:1654)
flowchart TD A[遍历设备全部 profile] --> B{任一 profile<br/>CONNECTING?} B -->|是| C[正在连接...] B -->|否| D{任一 profile<br/>DISCONNECTING?} D -->|是| E[正在断开连接...] D -->|否| F{配对中<br/>BOND_BONDING?} F -->|是| G[正在配对...] F -->|否| H{任一 profile<br/>CONNECTED?} H -->|是| I[已连接<br/>+电量/使用中变体] H -->|否| J["return null<br/>(摘要空白!)"]
文案映射(BluetoothUtils.getConnectionStateSummary() :106):
| profile 状态 | AOSP 文案资源 | 中文 |
|---|---|---|
| CONNECTED | bluetooth_connected | 已连接 |
| CONNECTING | bluetooth_connecting | 正在连接… |
| DISCONNECTED | bluetooth_disconnected | (列表场景被 null 覆盖,不显示) |
| DISCONNECTING | bluetooth_disconnecting | 正在断开连接…(独立文案!) |
2.2 AOSP 设计的三个关键性质
- 无「已保存」概念:已配对+全空闲 = 摘要
null,行只显示设备名。回连启动前的等待窗口视觉空白、用户无感。 - 无旁路状态位:摘要 100% 由 per-profile 状态机实时推导,不存在 Controller/Preference 维护的第二开关——状态只有一个来源,不存在解耦竞态的土壤。
- 连接中与断开中是两个文案:CONNECTING→正在连接…、DISCONNECTING→正在断开…,互不污染。
- 重计算在后台线程(v2 增补):
getConnectionSummary()/isBusy()是逐 profile 活 binder 调用(profile.getConnectionStatus(mDevice)=mService.getConnectionState(device))。AOSP Settings 侧BluetoothDevicePreference.onPreferenceAttributesChanged()把整个计算包进ThreadUtils.postOnBackgroundThread,仅在postOnMainThread里setSummary/setEnabled—— binder 成本用线程模型吸收,而不是引入状态缓存。这与”状态单源”并列为 AOSP 两大支柱:单源保证算得对,换线程保证不算卡(MiCar 主线程直算 = N801PROB-139360 风暴病灶的结构性成因)。
3. MiCar 实现(车机设置)
3.1 三态文案 + 动画
| 态 | 文案(zh-rCN) | 资源位置 | 附加视觉 |
|---|---|---|---|
| 已连接 | bluetooth_bonded_device_connected = 已连接 | strings.xml:66 | 行蓝色选中态(refreshBondedDeviceDisplay),排序置顶(compareTo) |
| 正在连接 | bluetooth_bonded_device_connecting = 正在连接… | strings.xml:67 | 旋转/位移动画 micar_ui_item_connecting_indicator_img(TranslateXAnimation 800ms 无限循环) |
| 已保存 | bluetooth_bonded_device_saved = 已保存 | strings.xml:68 | 无(else 兜底) |
3.2 摘要决策:MiCarBluetoothBondedDevicePreference.getDeviceSummary()(kt:97-113)
flowchart TD A[getDeviceSummary] --> B{isConnected?<br/>任一 profile CONNECTED<br/>实时读状态机} B -->|是| C[已连接] B -->|否| D{"isBusy && mIsConnectingState?<br/>状态机 × 旁路开关 双条件"} D -->|是| E[正在连接...] D -->|否| F[已保存<br/>else 兜底]
与 AOSP 的结构差异:「正在连接」从单条件变成双条件——第二条件 mIsConnectingState 是一个由 Controller 旁路维护的 boolean(NewBluetoothDevicePreference:66),与 profile 状态机解耦:
3.3 mIsConnectingState 旁路的生命周期(全部写入点)
flowchart LR subgraph 写入点 W1["① 默认 false<br/>preference 重建时(innerUpdateState)"] W2["② setConnecting(state==CONNECTING)<br/>Controller onProfileConnectionStateChanged<br/>(PS3 起按设备过滤)"] W3["③ !isBusy 时自动清 false<br/>refreshDeviceUi(kt:75-76)"] end S[mIsConnectingState] W1 --> S W2 --> S W3 --> S
3.4 MiCar 完整事件链(双通道)
sequenceDiagram participant Stack as 协议栈 participant CD as CachedBluetoothDevice participant Ctrl as BluetoothBondedDevicesPrefController participant Pref as 设备 Preference Stack->>CD: 广播 → onProfileStateChanged Note over CD: 通道①设备自身链:<br/>isConnected/isBusy 更新 CD->>Pref: refresh() → refreshDeviceUi() Stack->>Ctrl: 通道②Controller 链:<br/>onProfileConnectionStateChanged(device, state, profile) Note over Ctrl: PS3 前:无设备过滤,套给所有行(3187 根因)<br/>PS3 起:cachedDevice.equals 过滤 Ctrl->>Pref: setConnecting(state==CONNECTING)<br/>+ refreshDeviceUi()(仅触发设备) Pref->>Pref: getDeviceSummary() 三分支重算
完整代码调用链(XCD 变体,file:line 可直接跳转对照;当前树已含 PS3 修复):
协议栈 (com.android.bluetooth)
│ 广播: android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED 等
│ (注册: LocalBluetoothProfileManager.java:177-257 addProfileHandler 逐 profile 挂)
▼
BluetoothEventManager$mProfileBroadcastReceiver.onReceive() ← 查 mHandlerMap 分发
▼
LocalBluetoothProfileManager$StateChangedHandler.onReceiveInternal()
│
├─(0) cachedDevice.onProfileStateChanged(mProfile, newState) :493
│ └ 更新 mProfiles/失败标志/fetchActiveDevices(§1/§2 的状态机本体)
│
├─(1)【通道① 设备自身链】cachedDevice.refresh() :503
▼
CachedBluetoothDevice.refresh() CachedBluetoothDevice.java:793
└ 后台线程缓存图标 → postOnMainThread(dispatchAttributesChanged()) :806
└ for (Callback cb : mCallbacks) cb.onDeviceAttributesChanged() :1171
└ NewBluetoothDevicePreference.mDeviceCallback = this::refreshDeviceUi :60
(注册时机: onAttached/setCachedDevice :109/:116,注销 onDetached :123)
└ refreshDeviceUi() :154
├ setTitle/setIcon(设备名、图标)
├ setSummary(getDeviceSummary())
├ setEnabledWithDebounce(!isBusy && !mIsConnectingState) ← 3205 300ms 防抖
└ [MiCar 子类复写] MiCarBluetoothBondedDevicePreference.kt:66
├ !isBusy → mIsConnectingState=false (+失败Toast)
└ mIsConnecting = isBusy && mIsConnectingState && BOND_BONDED
└─(2)【通道② Controller 链】
mEventManager.dispatchProfileConnectionStateChanged(...) :504
▼
BluetoothEventManager.dispatchProfileConnectionStateChanged BluetoothEventManager.java:275
└ for (BluetoothCallback cb : mCallbacks) ← §3.4 序列图的订阅来源
cb.onProfileConnectionStateChanged(device, state, profile)
▼
BluetoothBondedDevicesPrefController.onProfileConnectionStateChanged :459
└ for (遍历列表每一行)
├ PS3前: 无过滤 → 所有行都被 setConnecting+refresh ← 3187 根因
└ PS3起: cachedDevice.equals(devicePref.getCachedDevice())) :484
├ bondedPref.setConnecting(state == STATE_CONNECTING) :486
│ └ mIsConnectingState = isConnecting NewBluetoothDevicePreference.java:305
└ devicePref.refreshDeviceUi() :488
└ getDeviceSummary() 三分支 MiCarBluetoothBondedDevicePreference.kt:97
① isConnected → "已连接"
② isBusy && mIsConnectingState → "连接中"
③ else → "已保存"两条通道推的是两份不同性质的状态,这是理解双通道的关键:
| 通道 | 订阅粒度 | 推送内容 | 数据源 |
|---|---|---|---|
| ① 设备自身链 | 对象级(设备→自己的行,onAttached 注册) | 事实态 isConnected/isBusy | 从 mProfiles 实时算(:912/:931) |
| ② Controller 链 | 全局(Controller 收所有设备所有事件) | 意图态 mIsConnectingState | 唯一写入点 setConnecting(:305),调用方仅通道②:486 |
两态在 getDeviceSummary() 三分支汇合:单靠①区分不了”正在连接”和”正在断开”(两者 isBusy 都为 true,语义是”过渡中”),必须叠上②的 mIsConnectingState(只认 state==CONNECTING)才能出”连接中”文案——这正是 §3.3 旁路存在的原因。3187 即通道②把意图态错灌给无关行(PS3 前 for 循环无 :484 过滤)导致”已保存/连接中”闪变。
3.5 视觉与交互的配套设计
| 机制 | 代码 | 说明 |
|---|---|---|
| 置灰防抖 | NewBluetoothDevicePreference setEnabledWithDebounce | 300ms 防抖消除断开瞬间置灰闪烁;PS3 修复:同目标不重计时(P14,防回调流中置灰被无限推迟) |
| 点击守卫 | Controller handlePreferClick / onRightIconClicked(PS3) | isBusy 期间忽略主行点击与进详情页 |
| 失败提示 | kt:77-83 mStartToConnect && !isConnected → toast | 用户主动连接失败时提示 |
| 动画判据 | kt:90 mIsConnecting = isBusy && mIsConnectingState && BOND_BONDED | 与文案判据对齐(3205 修复点) |
4. 同一关开场景,两方行为对比(实测时序)
stateDiagram-v2 state "已连接" as C state "已保存(MiCar)/空白(AOSP)" as S state "正在连接" as G state "已保存(MiCar)/空白(AOSP)" as S2 [*] --> C : 双设备已连接 C --> S : 用户关蓝牙<br/>profile DISCONNECTING→DISCONNECTED S --> S2 : 用户开蓝牙<br/>Adapter ON,重建 preference<br/>(MiCar: mIsConnectingState 默认 false) S2 --> G : 栈启动回连<br/>首个 profile CONNECTING<br/>(广播 40~77ms 到;建链 1.2~3.8s/台,A14 更快) G --> C : 首个 profile CONNECTED<br/>(注意:不需全部连完)
关键认知:「已保存/空白」窗口的长度由协议栈建链耗时决定。实测修正(2026-08-11 三周期):ON→首个 CONNECTING 广播仅 4077ms(广播不慢);慢的是建链——HFP 单台 1.23.8s、双设备串行全连完 4~5.5s,且 A14 平台实测明显更快 → 差异在协议栈建链性能(A17 fermi 待栈侧优化),不在 App 层控制内;AOSP 显示空白所以无感,MiCar 显示「已保存」所以可见。isBusy 与 CONNECTING 回调同源(同一广播事件),任何基于状态机的判据都无法提前覆盖这个窗口——唯一途径是乐观标记(ON 时已配对设备直接置 connecting,UX 变更需产品裁决)。
5. 缺陷模式对照(为什么这些 bug 只在 MiCar 发生)
| Bug | 机理 | AOSP 免疫原因 |
|---|---|---|
| 3187 双设备回连先「已保存」 | Controller 循环无设备过滤,旁路开关被跨设备清零 → 双条件失败落 else | 无旁路开关,无「已保存」文案 |
| 3205 断开采灰波动 | isBusy(含 DISCONNECTING)误驱动画+setEnabled 翻转 | 断开有独立「正在断开」文案,不需要 isBusy 混用 |
| 「已保存」等待窗口可见 | 「已保存」else 兜底文案把栈层回连延迟可视化 | idle = null 空白 |
| 2471 连接中回落「已保存」160~200ms(v2) | 同设备跨 profile 覆写:Controller:486 setConnecting(state==CONNECTING) 按最近一次回调整体覆写设备级单布尔;HFP16/A2DP11/MAP18/PBAP17 并行连接时,某 profile 真实首试失败 DISCONNECTED(栈内 70~100ms 重试)清 flag,其余 profile 撑住 isBusy → 双条件失败落 else。07-20 iPhone 两窗(59.354/04.179)、07-07 K90 一窗(37.266)实证;触发器=iPhone MAP 8 试 0 成(含无点击栈侧自发重试),把偶发变必现。362953 只修跨设备污染,不覆盖本模式;dev/dev_a17_0610/rls-xcd-x-ee-2608-2609 全带 | 无旁路;读取侧逐 profile 现算,单 profile DISCONNECTED 不影响其余 CONNECTING 的判定;且断开中有独立文案,根本不需要旁路区分 |
6. 速查表
| 问题 | 答案 | 代码 |
|---|---|---|
| 「已连接」什么时候显示? | 任一 profile CONNECTED,不等全部连完 | CachedBluetoothDevice:1003 |
| 「正在连接」什么时候显示? | isBusy && mIsConnectingState(动画+BOND_BONDED)。⚠️ 2471:同设备他 profile DISCONNECTED 会清 flag → 中途回落「已保存」 | kt:108 / kt:90 |
| 「已保存」什么时候显示? | 前两者都不满足(else 兜底) | kt:110 |
| mIsConnectingState 谁写? | setConnecting(Controller 回调)/ !isBusy 自清 / 重建默认 false | NBDPreference:303 / kt:75 / :66 |
| 等待窗口多长? | 栈层建链耗时(广播 40 | 非 App 代码 |
| 断开采灰防抖? | 300ms,同目标不重计时(PS3) | NBDPreference setEnabledWithDebounce |
| 2471 怎么修?(v2) | 方案 A:per-profile 连接中集合替代单布尔(回调喂入,!isBusy 终态 clear),三消费点同步 —— 回调缓存复刻 AOSP 聚合语义,零新增 binder。方案 B:直接调 getConnectionSummary()+后台线程计算 —— 留待 139360 风暴整改。禁止:isBusy 单判(断开误显)/主线程现查 profile 状态(binder 风暴) | Controller:486 / kt:108 / NBDPreference:171 |