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)
用户在详情页点「删除设备」后,发生的事可以概括成三句话:
- 点击瞬间,界面什么都不删。App 只做两件小事:往内存里写两个”标记”(已配对列表的屏蔽标记 + 可用列表的一次性拦截标记),然后把
removeBond()命令丢给蓝牙栈。列表条目的移除不是点击驱动的,是后面广播驱动的。 - 蓝牙栈干活时很”粗暴”:先清掉这台设备的名字(alias→null),再依次广播”每个 profile 断开 → 物理链路(ACL)断开 → 配对状态变成 BOND_NONE”。名字被清掉后,App 的
getName()只能兜底返回 MAC 地址——这是一切”列表显示蓝牙地址” bug 的总源头。 - 两个列表各自收敛:已配对列表收到 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 到达蓝牙服务后:
- 删配对记录 + 清设备属性(
removeDeviceProperties):名字、UUID、电量等全部抹掉——名字就是在这步没的。这是 AOSP 规范行为,栈无责。 - 断连接:逐个 profile 断开 → 物理链路(ACL)断开。
- 发广播,典型顺序:
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_DISCONNECTED | AclStateChangedHandler(:566) | 物理链路断了。4005 加固:缓存里没有这台设备时,断链不新建缓存(:583-588),防无名缓存被渲染成 MAC |
ACTION_BOND_STATE_CHANGED | BondStateChangedHandler(:407) | 配对状态变了。删除设备的主链路,见下 |
ACTION_NAME_CHANGED / ACTION_ALIAS_CHANGED | NameChangedHandler(:400,两个 action 共用) | 名字回来了 → 触发条目重绘(§6.3) |
ACTION_FOUND | DeviceFoundHandler(:364) | 扫描发现设备 → 可用列表 +1(§6.1) |
4.3 BondStateChangedHandler:删除在 App 侧的落点
:407-509,按顺序做这些事(注意第 3、5 步的顺序):
- 空设备拦截;CSIP/助听器套装拦截;
- 2291 豁免:BOND_NONE 且带 repair 中间态标记 → 直接 return,不分发、不清 profile、不弹 toast(:425-432);
- 缓存里没有这台设备 → 先 addDevice 建缓存(:434-439)——“删除广播先建缓存再删”是正常时序;
- 先分发
onDeviceBondStateChanged给所有 UI 回调(:445-447)——已配对列表就在这里同步删条目; - 再更新缓存本体
cachedDevice.onBondingStateChanged(:448-450)——清 mProfiles、后台重置权限、refresh; - 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-548 | BOND_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)+ 底层缓存清(
clearNonBondedDevicesf3dif :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-2291 | repair 中间态 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 包打点。