蓝牙已配对设备摘要状态机 — 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 文案资源中文
CONNECTEDbluetooth_connected已连接
CONNECTINGbluetooth_connecting正在连接…
DISCONNECTEDbluetooth_disconnected(列表场景被 null 覆盖,不显示)
DISCONNECTINGbluetooth_disconnecting正在断开连接…(独立文案!)

2.2 AOSP 设计的三个关键性质

  1. 无「已保存」概念:已配对+全空闲 = 摘要 null,行只显示设备名。回连启动前的等待窗口视觉空白、用户无感
  2. 无旁路状态位:摘要 100% 由 per-profile 状态机实时推导,不存在 Controller/Preference 维护的第二开关——状态只有一个来源,不存在解耦竞态的土壤
  3. 连接中与断开中是两个文案:CONNECTING→正在连接…、DISCONNECTING→正在断开…,互不污染。
  4. 重计算在后台线程(v2 增补):getConnectionSummary()/isBusy() 是逐 profile 活 binder 调用(profile.getConnectionStatus(mDevice) = mService.getConnectionState(device))。AOSP Settings 侧 BluetoothDevicePreference.onPreferenceAttributesChanged() 把整个计算包进 ThreadUtils.postOnBackgroundThread,仅在 postOnMainThreadsetSummary/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/isBusymProfiles 实时算(:912/:931)
② Controller 链全局(Controller 收所有设备所有事件)意图态 mIsConnectingState唯一写入点 setConnecting(:305),调用方仅通道②:486

两态在 getDeviceSummary() 三分支汇合:单靠①区分不了”正在连接”和”正在断开”(两者 isBusy 都为 true,语义是”过渡中”),必须叠上②的 mIsConnectingState(只认 state==CONNECTING)才能出”连接中”文案——这正是 §3.3 旁路存在的原因。3187 即通道②把意图态错灌给无关行(PS3 前 for 循环无 :484 过滤)导致”已保存/连接中”闪变。

3.5 视觉与交互的配套设计

机制代码说明
置灰防抖NewBluetoothDevicePreference setEnabledWithDebounce300ms 防抖消除断开瞬间置灰闪烁;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 自清 / 重建默认 falseNBDPreference:303 / kt:75 / :66
等待窗口多长?栈层建链耗时(广播 4077ms 到,慢在建链),实测 1.55s非 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