37 · 删除蓝牙设备全流程 — 从点击删除到两个列表收敛(小白向)

源码基线:MiCarSettings dev 分支(HEAD=59d69a8ba,f3dif 源集,xcd_cn_a17)。 交叉引用:34-已配对列表摘要状态机、66-bond状态与自动回连、63-对象的一生、94-扫描发现与可用设备上屏、11-配对与SSP安全机制。 本章价值:把”删除设备”从用户点击到两个列表收敛的端到端链路一次性讲透——这是 MANNPROB-3972/4005/4924/5118 一族 4+ 个”删完设备列表显示异常” bug 的公共地图。

0. 结论先行(TL;DR)

用户在详情页点「删除设备」后,发生的事可以概括成三句话:

  1. 点击瞬间,界面什么都不删。App 只做两件小事:往内存里写两个”标记”(已配对列表的屏蔽标记 + 可用列表的一次性拦截标记),然后把 removeBond() 命令丢给蓝牙栈。列表条目的移除不是点击驱动的,是后面广播驱动的。
  2. 蓝牙栈干活时很”粗暴”:先清掉这台设备的名字(alias→null),再依次广播”每个 profile 断开 → 物理链路(ACL)断开 → 配对状态变成 BOND_NONE”。名字被清掉后,App 的 getName() 只能兜底返回 MAC 地址——这是一切”列表显示蓝牙地址” bug 的总源头。
  3. 两个列表各自收敛:已配对列表收到 BOND_NONE 后同步删条目(有 3972 三件套护航,窗口期也不会闪 MAC);可用列表靠”一次性拦截”挡住删除后的第一次回扫,第二次回扫就放行——此时名字若还没被对端回填,新建条目首挂就以 MAC 上屏(5118 的残余 bug),直到名字回来才恢复。

一句话:删除 = App 发令 + 栈清名 + 广播驱动收敛;两个列表一前一后,中间隔着一段”名字真空期”,谁赶上谁显示 MAC。

1. 名词表(小白必读,后面全靠它)

名词大白话解释
删除设备 / 解绑 / unpair / removeBond同一件事的四层说法:用户叫”删除”,App 方法叫 unpair(),发到蓝牙栈的命令叫 removeBond(),结果叫”解除配对”。配对关系存在蓝牙栈里,不是在 App 里。
cancelBondProcess取消正在进行中的配对(还没配上),跟 removeBond(删除已存在的配对)是两回事。代码里会先判断:配对到一半点删除,先取消配对再删。
CachedBluetoothDevice(CBD)App 侧”一台蓝牙设备”的内存化身。列表里每一行背后都有一个它。配对删了它不一定死——它还身兼”扫描结果缓存”的身份,可能继续活着。
alias(别名)蓝牙栈保存的设备名(用户改名也存这)。名字的权威在栈里,App 不缓存;App 每次显示名字都是现问栈要。栈把 alias 清了,App 只能显示 MAC。
已配对列表(Bonded 列表)“我的设备”区。条目进出由配对状态驱动:配上进、删了出。
可用列表(Unbonded/附近设备)“搜索到的设备”区。条目进出由扫描驱动:扫到进、扫不到(清理时)出。同一台物理设备删除配对后会从”已配对”掉到”可用”。
BOND_NONE / BOND_BONDING / BOND_BONDED配对状态机三态:没配对 / 配对中 / 已配对。删除设备=从 BONDED 掉到 NONE。
MLog 裁剪正式(release)ROM 里 App 的调试日志(MLog.d)会被编译期剥掉,所以 bugreport 里有些环节”没有日志”不等于”没发生”。
双栈/双适配器车机同时跑两颗蓝牙(adapter0 默认栈 + adapter1 第二栈,代客/互联场景)。设备归属哪个栈由平台代理动态决定;删除时平台会顺手把另一颗栈的残留配对也清掉。

2. 全景图:三大阶段

flowchart LR
    subgraph S1[第一阶段:发起 App 侧,同步]
        A[用户点删除设备] --> B[确认弹窗<br/>MIS 二次确认 / 普通确认]
        B --> C[写两个内存标记<br/>markUnbondPending + setLastUnboundDevice]
        C --> D[performUnpair → unpair<br/>→ removeBond 发给栈]
    end
    subgraph S2[第二阶段:栈处理 蓝牙服务进程,异步]
        D --> E[栈清除配对记录<br/>removeDeviceProperties 清名字]
        E --> F[广播风暴:<br/>profile 断开×N → ACL 断 → BOND_NONE]
    end
    subgraph S3[第三阶段:列表收敛 App 侧,广播驱动]
        F --> G[已配对列表:<br/>BOND_NONE 到达同步删条目 ✅]
        F --> H[可用列表:<br/>一次性拦截首扫 → 二扫放行]
        H --> I{名字回来了吗}
        I -- 回来了 --> J[显示真名 ✅]
        I -- 没回来 --> K[显示 MAC ❌<br/>直到名字回填]
    end
    style K fill:#ffe4e1
    style G fill:#e6ffe6
    style J fill:#e6ffe6

为什么要这么绕? 因为配对关系存在蓝牙栈(另一个进程)里,App 删它必须跨进程发命令、等栈处理完再广播回来。这一去一回是异步的,中间的”真空期”就是所有问题的温床。

3. 第一阶段:发起删除(App 侧,同步)

3.1 入口:只有详情页一个主入口

已配对列表没有长按/菜单删除(负证据:MiCarBluetoothBondedDevicePreference.kt 全文 grep LongClick|unpair 零命中)。条目右侧 ”>” 只进详情页(BluetoothBondedDevicesPrefController.java:152-166)。删除的唯一主入口是详情页底部「删除设备」按钮:

详情页删除按钮(BluetoothDeviceOperationPreference.java:97-103)
  → callChangeListener(PreferenceAction.Unpair)          // 把"删除"抽象成事件抛给控制器
  → BluetoothOperationsController.handlePreferenceChanged(:194-213)
  → 先判:这台设备是不是正连着小米互联/CarPlay/AndroidAuto?

3.2 两条互斥的确认弹窗

路径 A:MIS 在场二次确认路径 B:普通确认弹窗
触发条件有复合连接、类型≠CarLink、被删 MAC=互联蓝牙 MAC(ConnectionUtils.kt:301-337)不满足左边条件时
弹窗”删除将同时断开小米互联/CarPlay…”(AndroidAuto 还会先主动断互联,防删完立刻弹配对框)DIALOG_DEVICE_UNPAIR=0x1000 普通确认
确认后回调里 performUnpair + 同步 goBack(:208)MiCarBluetoothDeviceDetailFragment.onCreateDialog:168-194 里 performUnpair;不在按钮里 goBack,等 BOND_NONE 回调后由 updateConnectionButtonState:82-98 查系统配对态退页(另有 onResume:141-152 防御性 popBackStack)
为什么不同MIS 操作重,先退页让位等系统真解绑完再退,避免”页面还在、设备已没”的中间态

3.3 performUnpair:先打标记,再发命令

MiBluetoothExt.kt:68-78(全文就这点东西,顺序是精髓):

fun CachedBluetoothDevice.performUnpair(appendAction: Runnable? = null) {
    MiBluetoothUtils.markUnbondPending(this.address)   // ① 先打"解绑进行中"标(3972)
    this.unpair()                                       // ② 再真正发解绑命令
    appendAction?.let { ThreadPoolUtils.ASYNC.execute(it) }  // ③ 附加动作投异步,不等结果
}

① markUnbondPending(MiBluetoothUtils.kt:217-246):把地址放进一个内存集合,已配对列表后续重建/刷新时查到这个地址就跳过(防窗口期以 MAC 闪现);带 10s 超时兜底——万一 removeBond 失败或广播丢失,10s 后强制摘除,防设备被永久屏蔽。

② unpair()(f3dif CachedBluetoothDevice.java:656-688):配对中途先 cancelBondProcess();然后 mUnpairing=true(:666,f3dif 里是一次性锁存,唯一消费方是助听器管理)→ dev.removeBond()(:677)同步发给蓝牙栈。之后的事全归栈管。

③ appendAction:两个入口在这里行为不同——详情页弹窗路径在点确定同步、无条件 setLastUnboundDevice(:183,在 performUnpair 之前);Controller 的 MIS 路径在 appendAction 异步回调里、且要求 isConnected 才记录(:202)。⚠️ 后者有竞态:断链快于回调执行时,这个拦截标记根本不会被写上。

3.4 点击瞬间,列表条目还在吗?——在

整条删除链路上没有任何代码同步把条目从列表里抠掉(删除链路四文件 grep removePreference 零命中)。条目移除完全等后面的 BOND_NONE 广播。这和 WiFi 侧不同:WiFi 删网络已是”乐观移除”(点击即删条目,MICOCKPIT-147257),蓝牙侧没对齐。

4. 第二阶段:栈处理与广播风暴(异步)

4.1 栈干了什么

removeBond 到达蓝牙服务后:

  1. 删配对记录 + 清设备属性(removeDeviceProperties):名字、UUID、电量等全部抹掉——名字就是在这步没的。这是 AOSP 规范行为,栈无责。
  2. 断连接:逐个 profile 断开 → 物理链路(ACL)断开。
  3. 发广播,典型顺序:profile 断开 ×N(每个 profile 一条)→ ACL_DISCONNECTED → BOND_STATE_CHANGED(BOND_NONE)。

双栈清理:若设备归属第二栈(adapter1),栈侧 BondStateMachine.java:684-691 会顺手调 cleanupDefaultAdapterUnbondedDevice 把默认栈的残留配对也删掉(bugreport 里那行 Log.i 就是它打的)——防双栈幽灵配对(4924 族)。

4.2 App 怎么收广播:一张 handler 表,主线程串行

App 侧所有蓝牙广播都进 BluetoothEventManager(f3dif :111-152 注册表),按 action 一对一分发。两个 receiver 用同一个 handler,且 app 注册时传 handler=null → 全部广播在 Settings 主线程串行处理,顺序=AMS 送达顺序,代码层不重排(LocalBluetoothManager.java:58-69)。和删除相关的几条:

广播Handler它来了意味着什么
*_CONNECTION_STATE_CHANGED(各 profile)LocalBluetoothProfileManager.StateChangedHandler某个 profile(音乐/电话/通讯录)断了 → 刷新条目状态
ACTION_ACL_DISCONNECTEDAclStateChangedHandler(:566)物理链路断了。4005 加固:缓存里没有这台设备时,断链不新建缓存(:583-588),防无名缓存被渲染成 MAC
ACTION_BOND_STATE_CHANGEDBondStateChangedHandler(:407)配对状态变了。删除设备的主链路,见下
ACTION_NAME_CHANGED / ACTION_ALIAS_CHANGEDNameChangedHandler(:400,两个 action 共用)名字回来了 → 触发条目重绘(§6.3)
ACTION_FOUNDDeviceFoundHandler(:364)扫描发现设备 → 可用列表 +1(§6.1)

4.3 BondStateChangedHandler:删除在 App 侧的落点

:407-509,按顺序做这些事(注意第 3、5 步的顺序):

  1. 空设备拦截;CSIP/助听器套装拦截;
  2. 2291 豁免:BOND_NONE 且带 repair 中间态标记 → 直接 return,不分发、不清 profile、不弹 toast(:425-432);
  3. 缓存里没有这台设备 → 先 addDevice 建缓存(:434-439)——“删除广播先建缓存再删”是正常时序;
  4. 先分发 onDeviceBondStateChanged 给所有 UI 回调(:445-447)——已配对列表就在这里同步删条目;
  5. 再更新缓存本体 cachedDevice.onBondingStateChanged(:448-450)——清 mProfiles、后台重置权限、refresh;
  6. BOND_NONE 收尾:套装连带解绑 + 按 UNBOND_REASON 弹 toast(如 PIN 错误)。

⚠️ 它从不调 dispatchDeviceDeleted——onDeviceDeleted 只服务套装/助听器连带移除。所以”删除设备”在 UI 上的落点是 onDeviceBondStateChanged(BOND_NONE),不是 onDeviceDeleted。

5. 第三阶段 A:已配对列表收敛(3972 三件套护航)

收到 BOND_NONE 后,已配对列表 BluetoothBondedDevicesPrefController 的完整动作:

flowchart TD
    A[BOND_NONE 到达<br/>onDeviceBondStateChanged :534] --> B[clearUnbondPending<br/>摘除屏蔽标记 :547]
    B --> C[removePreference 同步删条目 :548]
    C --> D[refreshUi 全量重建]
    D --> E{重建过滤}
    E --> F[bondedDeviceFilter:<br/>实时查系统配对表,不信缓存 :207-215]
    F --> G[isUnbondPending 排除:<br/>解绑中的设备不得加回 :377-379]
    G --> H[列表收敛 ✅]

窗口期(点击→BOND_NONE,实测 150~270ms)内的防护就是 3972 三件套:

防线位置干什么
P0 发起即屏蔽markUnbondPending + 重建排除 :377-379窗口内全量重建不得把解绑设备加回
P1b 标题防回退NewBluetoothDevicePreference.java:159-165窗口内条目被 profile 断开回调刷新时,getName() 已退成 MAC,但旧标题是真实名就不许 MAC 顶替上屏
P2 同步移除:546-548BOND_NONE 到达立即删条目,不等”两跳全量重建”(3972 前移除延迟 150~270ms,是 MAC 闪现的窗口)

至此,已配对列表侧是闭环的(244-dev 起)。5118 里田子豪在日志看到 name=C8:1F:...,那是控制器打日志时 getName() 的取值,不是用户看到的标题——P1b 把标题挡住了。

6. 第三阶段 B:可用列表重现与”名字真空期”

6.1 刚删的设备,会不会立刻出现在可用列表?——会挡第一次

删除后扫描照跑(页面在前台时 onScanningStateChanged 每轮结束都续扫,:456),刚删的设备就在旁边,很快会被 DeviceFoundHandler 扫到 → onDeviceAdded:

flowchart TD
    S[onDeviceAdded :315] --> Q1{addr == lastUnboundDevice? :321}
    Q1 -- 命中(删除后第一次回扫) --> B1[拦截不上屏 ✅<br/>+ clearNonBondedDevices 清缓存<br/>+ setLastUnboundDevice 自消耗<br/>⚠️ 只拦这一次]
    Q1 -- 未命中(第二次及以后) --> Q2{scannedDeviceFilter 三关<br/>MiBluetoothUtils.kt:194-199}
    Q2 -- 未配对+非本机+CoD白名单<br/>⚠️ 无名字门槛 --> Q3[入 mPendingDevices 队列]
    Q2 -- 不过 --> X[不上屏]
    Q3 --> T[批处理定时器:<br/>每 500ms 最多 10 台上屏 :386-427]
    T --> A[addPreference :461 新建条目]

也就是说:“刚删又出现”只靠一个一次性标记挡一次。设计意图是”别立刻重现”(给用户删除成功的反馈感),不是”永不重现”——设备客观上确实还在旁边,第二次扫到就该出现了。

6.2 名字真空期:MAC 什么时候上屏

关键在新建条目首挂那一刻:

flowchart TD
    A[新建 MiCarBluetoothUnbondDevicePreference] --> B[onAttached :113<br/>注册回调 + 立即 refreshDeviceUi]
    B --> C[getName 实时问栈要 alias<br/>CachedBluetoothDevice.java:753]
    C --> D{alias 有值吗}
    D -- 有 --> E[setTitle 真名 ✅]
    D -- 空(栈清名后未回填) --> F{P1b 判定:<br/>旧标题是真实名? :159-165}
    F -- 是(老条目刷新) --> G[保留旧标题 ✅<br/>=3972 防回退生效]
    F -- 否(新条目首挂,无旧标题) --> H[setTitle MAC 上屏 ❌<br/>=5118 残余 bug]

P1b 的保护范围只有”老条目被刷新”这一种情况——它依赖”旧标题是真实名”这个前提。可用列表里这台设备是新建的条目,没有旧标题,守卫天然失效。这就是 3972 修复的天然边界:它修好了”已配对列表”和”老条目”,但盖不住”可用列表新建条目首挂”。

6.3 名字怎么回来:恢复链路

对端回连 / EIR 带名 / 属性重读
  → 栈侧重新写入名字属性
  → 广播 ACTION_NAME_CHANGED(或 ALIAS_CHANGED)
  → NameChangedHandler(:123-124 共用)→ CachedBluetoothDeviceManager.onDeviceNameUpdated(:77-82)
  → cachedDevice.refreshName()(:815-820,只是触发重绘,app 不缓存名字)
  → dispatchAttributesChanged(:1347)
  → 条目 mDeviceCallback = refreshDeviceUi(:60)
  → getName() 取到真名 → P1b 放行分支 → setTitle(真名)✅

恢复时长完全取决于对端:5118 案里 iPhone 删除后 1.1s 主动回连,名字秒回;但即便如此,从栈回填(15:29:14.000)到 settings 标题可见(15:29:23.018)也隔了 ~9s(该案经双栈另一颗适配器的属性分发才触达)。对端不回连、EIR 又迟迟不带 Complete Local Name 时,MAC 就常驻——这就是”偶现”的结构性来源。

6.4 可用列表的其他收敛机制(背景)

  • 首开延迟 200ms(BLUETOOTH_DATA_LAZY_TIME,:143-162):每次回页面都延迟 200ms 再全量重建,给缓存就位时间;
  • 重建三连:UI 清(removeAll)+ 底层缓存清(clearNonBondedDevices f3dif :284-294,把 BOND_NONE 设备连根拔掉)+ 从缓存重建(:178-187,BONDING 时跳过防误判);
  • clearNonBondedDevices 三个调用点:刷新重建前(:169)、离页(:226,顺带停扫省电)、销毁(:245)。

7. 真实案例走读:MANNPROB-5118 完整时序

设备:Modena-L(E4U2,fermi/A17),settings=1.0.0.244-dev(已含 3972+4005 双修复)。用户在蓝牙页 4.4s 内连删 3 台已连接设备,第 3 台 iPhone(C8:1F:E8:17:B0:4E)的完整时间线:

sequenceDiagram
    autonumber
    participant U as 用户
    participant App as CarSettings
    participant F as 蓝牙栈(双栈)
    participant P as iPhone

    U->>App: 15:29:12.870 点删除(主线程 removeBond)
    Note over App: markUnbondPending 已标记<br/>lastUnboundDevice 已记录
    App->>F: removeBond(C8:1F:...:B0:4E)
    F->>F: 13.024~028 removeDeviceProperties 清名
    F-->>App: 13.006~13.096 profile 断开回调×N
    Note over App: 已配对条目刷新:getName=MAC<br/>P1b 保留旧标题 ✅(用户无感)
    F-->>App: 13.034 ACL_DISCONNECTED(缓存已有该设备,4005 路径未触发)
    F->>F: 13.028 cleanupDefaultAdapterUnbondedDevice(双栈互清)
    F-->>App: BOND_NONE
    Note over App: 已配对列表:同步 removePreference ✅<br/>可用列表:refreshUi 重建,该设备已被清出缓存
    P->>F: 13.971 ACL 主动回连(1.1s 后!)
    F->>F: 14.000 名字属性回填(Update property type:1)
    P->>F: 15.433 ACL Disconnected reason:19(对端又拆)
    F-->>App: 23.002 属性分发(adapter1 Added new device property)
    Note over App: 23.018 标题恢复真名 ✅<br/>清名→UI 恢复 ≈ 10s
    Note over App,P: 另一可能世界线:iPhone 不回连 → 可用列表二扫放行时<br/>新建条目首挂 getName=MAC → MAC 常驻 ❌(报单场景)

读图要点:

  • 已配对侧全程无感(3972 三件套),MAC 只出现在控制器日志里;
  • 名字真空期 = 13.028(栈清名)→ 14.000(栈回填)= 栈内不到 1s,但到 UI 可见拉长到 ~10s;
  • 本案能”自愈”纯属 iPhone 配合(1.1s 回连);换个不回连的设备,可用列表就顶着 MAC 直到下次带名广播——这就是”偶现”。

8. 族谱:这张地图上的 bug 们

单号现象在地图上的位置状态
MANNPROB-3972删设备后已配对列表闪 MAC§5 窗口期两跳重建 + 条目可见性无 alias 判据✅ 已修(三件套,379415)
MANNPROB-4005手机侧取消配对后可用列表闪 MAC§4.2 ACL 断链为未知设备建无名缓存✅ 已修(收窄守卫,9d12509f8)
MANNPROB-2291repair 中间态 BOND_NONE 误弹 toast§4.3 第 2 步豁免✅ 已修
MANNPROB-4924小米互联场景删设备变地址§4.1 MIS 未断直接 removeBond + OobHelper 9s 静默重配✅ 已修(379441)
MANNPROB-5118删设备后可用列表显示 MAC(偶现)§6.2 新建条目首挂 P1b 盲区🔧 待修(方案=解绑记忆名)
MICOCKPIT-147257(WiFi)删 WiFi 条目停留对照组:WiFi 已是乐观移除✅ 已修
N801PROB-140052 族(纯MAC排查)可用列表大量陌生设备只显 MAC(92%)不同根:陌生设备广播本就不带名(名从未存在),稳态/量大;本章 5118 是”名被删除动作抹掉”,瞬态/单台/偶现。共享地基=L1 getName 兜底+L2 无名字门槛+L3 无主动补名待产品决策(方案A/D)

辨析(5118 vs 140052 族):两者共享同一段代码地基但触发源相反——140052 族是”从来没名”(BLE 匿名广播),5118 是”名被 removeDeviceProperties 抹掉”。修复关系:140052 的方案 A(无名不上屏)若落地会连带覆盖 5118(真空期内设备不上屏,语义变”消失后再出现”);5118 的解绑记忆名只覆盖”本机刚删的同一地址”,不影响陌生设备策略,两者不互斥,可独立先修。

9. 设计遗留问题(供评审)

  • W1 无乐观移除:点击→BOND_NONE 的 150~270ms 内条目仍在列表(标题被 P1b 保住)。WiFi 侧已对齐乐观移除,蓝牙侧没有。
  • W2 lastUnboundDevice 一次性语义:只挡删除后第一次回扫;持续广播环境下等于只延迟一个扫描周期。
  • W3 scannedDeviceFilter 无名字门槛:任何无名设备都能进可用列表(族级问题;对讲机类合法无名配对设备 CARFENGYU-1230 风险导致不能简单一刀切,待产品决策)。
  • W4 P1b 首挂盲区(5118 根因):新建条目无旧标题,防回退失效。修复方向:解绑记忆名——performUnpair 时记 address→lastName(短 TTL 30~60s),可用列表首挂时 getName()==address 且命中记忆表则用记忆名上屏。exact-address+短 TTL,无 BLE RPA 误配顾虑(与 4005 否决的”任意无名设备全局兜底”不同)。
  • W5 两个删除入口写 lastUnboundDevice 语义不对称:弹窗路径前置无条件(:183)vs MIS 路径异步+isConnected(:202),后者竞态下拦截标记可能根本没写上。
  • W6/W7 名字恢复不可控且链路长:恢复靠对端回连/EIR;栈回填≠UI 可见(本案差 ~9s)。

10. 日志锚点速查(bugreport grep 用)

想看什么grep 模式
用户点删除removeBond() for device=.*called by pid=<settings pid>
栈清名BluetoothRemoteDevices: removeDeviceProperties
双栈互清cleanupDefaultAdapterUnbondedDevice
已配对侧同步删除BondedDevicesPrefController: onDeviceBondStateChanged(I 级可见;removePreference 是 MLog,release 不可见)
4005 路径是否触发AclStateChangedHandler created new / skip creating cache
名字回填BluetoothRemoteDevices: Update property, device=.* type: 1 / Added new device property
标题恢复onPreferenceHierarchyChange(preference:
可用列表回扫UnbondedDevicesPrefController: onDeviceAdded(I 级部分可见,addPreference 细节 release 不可见)

⚠️ release ROM MLog.d 全裁剪:可用列表的 addPreference/lastUnboundDevice 拦截在正式包里看不到日志,“无日志≠未发生”;要钉死窗口需 debug 包打点。