01 · 蓝牙发现(扫描/Discovery)全链路
★ 用户重点关注章节。从打开蓝牙页 → 触发扫描 → 设备出现在列表 的端到端深度剖析。 核心文件:
bluetooth/BluetoothScanManager.kt、MiBluetoothUtils.kt、controller/BluetoothUnbondedDevicesPrefController.java、view/BluetoothMainEntrySettingsFragment.java
1. 三层架构
蓝牙发现 = settingslib 事件总线(把系统广播翻译成 BluetoothCallback)+ 本仓 BluetoothScanManager 扫描闸门(控制扫描时机)+ 两个列表 Controller(已配对 / 可用设备)。
关键事实
- 触发链:
BluetoothScanManager.triggerScan()→MiBluetoothUtils.startDiscovery()(:217)→LocalBluetoothAdapter.startScanning(true)(LocalBluetoothAdapter.java:225)→BluetoothAdapter.startDiscovery() startScanning(true)传force=true,绕过 settingslib 自带 5 分钟节流(SCAN_EXPIRATION_MS=5*60*1000,LocalBluetoothAdapter.java:66)。节流由本仓自己实现。- 扫描结果不是一次性的,而是逐设备经
onDeviceAdded推送(DeviceFoundHandler,BluetoothEventManager.java:118,517) - 所有
BluetoothCallback回调在主线程触发(注册时未指定 scheduler,默认绑定主 Looper)
端到端时序
sequenceDiagram autonumber participant U as 用户 participant Act as BluetoothSettingsActivity participant Frag as BluetoothMainEntrySettingsFragment participant ScanMgr as BluetoothScanManager participant Utils as MiBluetoothUtils participant LBM as LocalBluetoothManager participant Adapter as LocalBluetoothAdapter→BluetoothAdapter participant Unbond as BluetoothUnbondedDevicesPrefController U->>Act: 打开蓝牙页 Act->>ScanMgr: resetScanFlag("bluetooth_setting_onCreate") (Act:44) Act->>Frag: getInitialFragment() Frag->>LBM: getEventManager().registerCallback(this) (onStart:217) Frag->>Unbond: onResume→updateState Unbond->>Unbond: innerUpdateState 清空列表 (Unbonded:162-172) Unbond->>ScanMgr: triggerScan("update_state") (:171) ScanMgr->>ScanMgr: 写锁内读 mCanExecuteScan==true ScanMgr->>Utils: mUIHandler.post{ startDiscovery() } (:99) Utils->>Utils: stopDiscovery 先停 + setScanMode(CONNECTABLE_DISCOVERABLE) (:218-222) Utils->>Adapter: startScanning(true) Adapter->>Adapter: mAdapter.startDiscovery() Adapter-->>LBM: ACTION_DISCOVERY_STARTED LBM->>Unbond: onScanningStateChanged(true) loop 每个扫到的设备 Adapter-->>LBM: ACTION_FOUND (device,rssi,name) LBM->>LBM: DeviceFoundHandler→dispatchDeviceAdded LBM->>Unbond: onDeviceAdded(cachedDevice) Unbond->>Unbond: scannedDeviceFilter 过滤<br/>→ mPendingDevices → startAddDeviceTimer end Adapter-->>LBM: ACTION_DISCOVERY_FINISHED (系统默认12s后) LBM->>Unbond: onScanningStateChanged(false) Unbond->>ScanMgr: triggerScan("onScanningStateChanged") (:419) ← 重新触发! Note over Unbond: 形成持续扫描循环<br/>除非 mBondState==BOND_BONDING 或页面 pause U->>Act: 离开页面(onPause) Unbond->>Unbond: onPauseInternal stopDiscovery + clearNonBondedDevices (:194-210)
2. BluetoothScanManager 深度剖析(单例 object)
bluetooth/BluetoothScanManager.kt(180 行)。
四件套设计意图
| 机制 | 行号 | 设计意图 |
|---|---|---|
AtomicBoolean mCanExecuteBluetoothScan | :24 | 全局扫描闸门标志,无锁读 .get() |
ReentrantReadWriteLock mLock | :27 | 保护监听器列表增删改查 |
WeakReference<ScanStateListener> | :33 | 弱持有,Controller 忘记 unregister 仍可被 GC,防泄漏 |
Handler mUIHandler (MainLooper) | :30 | 把回调通知 post 到主线程 |
setCanExecuteScan 并发模型(:60-88)
写锁{
读 oldValue
mCanExecuteBluetoothScan.set(canScan)
复制 listenersToNotify = mScanStateListeners.toList() // 锁内快照
}
// 锁外:
if (listenersToNotify.isNotEmpty()) mUIHandler.post{ 逐个 onScanStateChanged }
为何锁内复制、锁外通知:若写锁内调外部回调,而回调里又调 registerScanStateListener(需写锁)会自死锁(ReentrantReadWriteLock 不允许写锁升级读锁)。post 到主线程更进一步把通知推迟到下一帧。
triggerScan 闸门语义(:94-104)
闸门检查与发起扫描不在同一原子动作(post 异步),故「闸门刚关但已 post 的 startDiscovery 仍执行」可能存在;主要挡「连续触发」而非「正在执行的那一次」。MiBluetoothUtils.startDiscovery() 自己先 stopDiscovery() 再开扫(:218)构成双保险。
闸门状态机
stateDiagram-v2 [*] --> Allowed: object 初始化 mCanExecuteScan=true Allowed --> Allowed: triggerScan → startDiscovery (正常扫描) Allowed --> Forbidden: setCanExecuteScan(false, "pairing_carplay_device") Forbidden --> Forbidden: triggerScan 被挡 (仅打日志) Forbidden --> Allowed: setCanExecuteScan(true, "dismiss_pair_fail" / "connect_type_choose_inflated") Allowed --> Allowed: resetScanFlag(...) 开关/页面 onCreate 无副作用 Forbidden --> Allowed: resetScanFlag("bluetooth_state_change" / "bluetooth_setting_onCreate") note right of Forbidden: 只有 CarPlay 配对场景进入此状态
3. 扫描触发与生命周期
triggerScan 调用点(全仓库穷尽)
| 文件:行号 | scenario | 触发条件 |
|---|---|---|
BluetoothUnbondedDevicesPrefController.java:128 | "scan_state_change" | 收到 ScanStateListener.onScanStateChanged(true) 且非 pause |
:171 | "update_state" | innerUpdateState 清空列表后 |
:419 | "onScanningStateChanged" | 扫描结束(onScanningStateChanged(false))且非配对中——持续扫描循环关键 |
resetScanFlag 调用点(恢复扫描能力·无条件置 true)
| 文件:行号 | 场景 |
|---|---|
BluetoothSettingsActivity.kt:44 | 进蓝牙页 "bluetooth_setting_onCreate" |
BluetoothSwitchController.java:209 | 开关状态变化 "bluetooth_state_change" |
resetScanFlag设计为「无条件重置 true」,作为兜底恢复点——若setCanExecuteScan(false)后恢复 post 因故未执行(进程被杀),下次进页/拨开关会兜底恢复。
BluetoothCallback 注册时机
| 对象 | 注册 | 注销 |
|---|---|---|
BluetoothMainEntrySettingsFragment(自身实现 BluetoothCallback) | onStart():217 | onStop():224 |
所有 BluetoothPreferenceController 子类 | onStartInternal():115 | onStopInternal():122 |
ScanStateListener(Unbonded 持有) | onCreateInternal():183 | onDestroyInternal():220 |
即 Fragment 与每个 Controller 各自独立注册,一次 dispatchDeviceAdded 被所有收到(CopyOnWriteArrayList 遍历)。
扫描超时/停止
- 无应用层定时器,走系统
BluetoothAdapter默认 12s 发ACTION_DISCOVERY_FINISHED - 应用层
onScanningStateChanged(false)重新触发,形成「扫完→立即重开」循环,直到 onPause 或 BOND_BONDING - 停扫入口:
onPauseInternal(Unbonded:194-210)、onDestroyInternal(:213-247 再保险)、点击设备发起配对(Unbonded:465stopDiscovery())
4. 扫描结果渲染
onDeviceAdded 分流
flowchart LR A[DeviceFoundHandler<br/>BluetoothEventManager:517] --> B[addDevice→CachedBluetoothDevice] B --> C[dispatchDeviceAdded:263] C --> D[所有 BluetoothCallback.onDeviceAdded] D --> E[Unbonded Controller] D --> F[Bonded Controller] E --> E1{scannedDeviceFilter?<br/>非配对+非本地+类型支持} E1 -->|是| E2[mPendingDevices.put<br/>startAddDeviceTimer] E2 --> E3[每500ms最多10个<br/>addPreference] E1 -->|否| E4[丢弃] F --> F1{bondedDeviceFilter?<br/>BOND_BONDED} F1 -->|是| F2[addPreference] F1 -->|否| F3[丢弃]
未配对列表批处理缓冲(防卡顿关键,Unbonded:73-77,354-404)
onDeviceAdded 不直接 addPreference:
mPendingDevices(synchronized LinkedHashMap)按 MAC 去重 put(:328)startAddDeviceTimer()→mUIHandler.post(mAddDeviceRunnable)(:359)mAddDeviceRunnable每 tick 取最多MAX_DEVICES_PER_TICK=10个(:363),锁内取 batch、锁外做 UI,剩余postDelayed(500ms)(:399)- 配对中设备(BOND_BONDING)在 tick 里被跳过(:378-383)
- 竞态修复:
isEmpty检查移入 synchronized 块(:393-402 注释”消除竞态窗口”)
设备过滤规则(对抗核实:预期设计,非 bug)
MiBluetoothUtils.isDeviceClassTypeSupport:131-160:
| 判定 | 结果 |
|---|---|
condition1:AUDIO_VIDEO / UNCATEGORIZED major → false;COMPUTER → 非 laptop;WEARABLE → 仅 pager;else(含 PHONE)→ true | |
condition2:AUDIO_VIDEO_CAR_AUDIO / HEADSET / HEADPHONES → false;else → true | |
res = condition1 && condition2 |
⚠️ 关键结论(对抗核实纠正了 Agent 疑问):因 AUDIO_VIDEO major 在 condition1 即 false,所有音视频类设备(耳机/音箱/车机蓝牙自身)都被屏蔽。车机蓝牙扫描列表只显示手机类设备——这是符合车机场景的有意设计(车机蓝牙主要用于手机互联:电话/通讯录/CarPlay,不连蓝牙耳机听歌)。蓝牙耳机配对走其他路径(手机主动发起/CarPlay 流程)。 副产物:condition2 的耳机分支(:151-154)因 condition1 已 false 而成死代码(防御性冗余)。
scannedDeviceFilter:166 = 非配对 + 非本地蓝牙 + 类型支持。
bondedDeviceFilter:177 = 仅判 BOND_BONDED。
onDeviceAdded 触发源穷尽与零分发不对称(2026-09-10 补,dev HEAD 2994ee8ba 行号)
上文流程图是主路径(扫描发现),契约级完整触发源为 4 个,唯一分发出口 dispatchDeviceAdded(xcddif BluetoothEventManager.java:263-267,遍历 mCallbacks):
| # | 触发源 | 位置 | 时机 |
|---|---|---|---|
| 1 | ACTION_FOUND 新设备 | DeviceFoundHandler(BEM:505-528)→ findDevice==null → CBDM.addDevice(:123-141)锁内 dispatch(:135) | 扫描发现缓存没有的设备 |
| 2 | ACTION_FOUND 已缓存但 bonded+未连接 | BEM:519-524 补 dispatch | 给已配对列表显示用(Unbonded 侧被 scannedDeviceFilter 滤掉) |
| 3 | readPairedDevices() | BEM:245-261,LBM 构造收尾调用(LocalBluetoothManager.java:120) | 蓝牙开/Managers 首建时读 bonded 逐台 addDevice |
| 4 | CSIP 组 / 助听器 | CsipDeviceManager.java:346 / HearingAidDeviceManager.java:226,251 主动 dispatch | LE Audio 组/主子设备关联建立 |
结构性不对称(排障锚点):已缓存且未配对的设备 findDevice()!=null → 既不走 addDevice 也不补 dispatch → 永不触发 onDeviceAdded。这是 68 §4 3492「分发盲区」的代码根,也是 innerUpdateState 要从缓存重建列表的原因(Unbonded:173-177 注释自述)。
回调有效窗口:controller 在 onStartInternal 注册(BluetoothPreferenceController.java:114-117)、onStopInternal 注销(:121-124)——页面不可见期间收不到任何 onDeviceAdded(3492 的”错过广播”税)。
lastUnboundDevice 拦截三件套(2026-09-10 补,dev HEAD 2994ee8ba 行号)
手动解除配对后防”设备秒回弹”的一次性拦截,读侧在 onDeviceAdded:321-334:
| 动作 | 作用 | 缺了会怎样 |
|---|---|---|
return 吞掉本次添加 | UX:删完不立刻回弹 | 刚删的设备马上出现在附近列表,像删除没生效 |
clearNonBondedDevices()(:331) | 逐出缓存对象,保证下轮 ACTION_FOUND 走 findDevice==null→addDevice→dispatch 新设备路径 | 缓存幸存→零分发→永久隐身(3492 形态) |
setLastUnboundDevice("")(:332) | 标记只拦一次 | 设备永远进不了附近列表 |
写侧两个入口(均”确认删除”路径):MiCarBluetoothDeviceDetailFragment.java:183(详情页删除弹窗确定键)、BluetoothOperationsController.java:202(Unpair 经 MIS 复合连接确认框)。存储 = ConnectionUtils.kt:65 进程内存单值 @JvmStatic var lastUnboundDevice,不持久化。
⚠️ 与 1922 修复方案的张力(改这段前必读):当前 dev HEAD 拦截分支的
clearNonBondedDevices仍在。68 §3 1922 认定它是连坐元凶(把 addDevice 刚建的新对象连同全表再清一遍 → 51ms 秒回失效 + 整列表 15s 空窗),建议删除;但 3492 证明没它又有永久隐身风险。真解不是”全清/不清”二选一,而是单设备精确移除——CBDM 没有单设备公开移除 API(68 教学点 1),这决定了修复形态。1922 修复 change 370575 截至 2026-09-10 未在 dev HEAD 出现。
排序(语音辅助角标)
refreshDeviceVoiceAssistViewOrder(Unbonded:532-556, Bonded:115-138):可见即可说场景给 preference 设递增 indexMiCarBluetoothBondedDevicePreference.compareTo:204-212:已连接设备排最前,其余字母序NewBluetoothDevicePreference.compareTo:230-239:title 字母序(忽略大小写)
5. 场景管控:谁禁止扫描(setCanExecuteScan(false) 穷尽)
全仓库 setCanExecuteScan(false) 仅 2 处,全在 CarPlay 配对链路:
| 文件:行号 | 调用 | 场景 | 原因 |
|---|---|---|---|
MiBluetoothPairingDialogActivity.kt:136 | setCanExecuteScan(false, "pairing_carplay_device") | 配对的正在是 CarPlay 设备 | 紧接 CarPlay 类型选择弹窗,两弹窗动画叠加会卡顿 |
:200 | 同上 | dismiss(配对成功) 且是 CarPlay 且非 MIS | 启动类型选择弹窗前再次阻断 |
:215 | setCanExecuteScan(true, "dismiss_pair_fail") | 启动类型选择 Activity 失败 catch | 异常恢复,防永久禁扫 |
MiCarplayConnectTypeChooseDialogActivity.kt:53 | setCanExecuteScan(true, "connect_type_choose_inflated") | 类型选择弹窗 onCreate 后 postDelayed(200ms) | 弹窗 inflate 后恢复 |
关键:普通蓝牙配对(非 CarPlay)不禁扫,只靠 mBondState==BOND_BONDING 在 onScanningStateChanged 里挡重扫(Unbonded:417)。
6. 类关系
classDiagram class BluetoothCallback {<<interface settingslib>> +onBluetoothStateChanged(int) +onScanningStateChanged(boolean) +onDeviceAdded(CachedBluetoothDevice) } class BluetoothPreferenceController~V~ {<<abstract>> #AtomicBoolean mIsControllerActive +onStartInternal() registerCallback(this)} class BluetoothUnbondedDevicesPrefController { -Map mPendingDevices -ScanStateListener mScanStateListener} class BluetoothBondedDevicesPrefController class BluetoothSwitchController {+resetScanFlag()} class BluetoothScanManager {<<object singleton>> -AtomicBoolean mCanExecuteScan +triggerScan(scenario) +setCanExecuteScan(canScan,scenario)} class ScanStateListener {<<interface>> +onScanStateChanged(canScan)} BluetoothCallback <|.. BluetoothPreferenceController BluetoothPreferenceController <|-- BluetoothUnbondedDevicesPrefController BluetoothPreferenceController <|-- BluetoothBondedDevicesPrefController BluetoothUnbondedDevicesPrefController ..> BluetoothScanManager : triggerScan/register BluetoothSwitchController ..> BluetoothScanManager : resetScanFlag BluetoothScanManager o--> ScanStateListener : WeakReference
7. 设计要点与坑
- 持续扫描循环(Unbonded:417-420):
onScanningStateChanged(false)立即重扫,但 click 发起配对时须先stopDiscovery()再置BOND_BONDING(:465-466),顺序很关键,否则配对开始瞬间又触发扫描。 - 防连点:
mIsHandlePair(volatile boolean,Unbonded:82,456-460)拦截重复配对。 mIsControllerActive双检(Unbonded:328,349):后台线程取设备列表、切回主线程时 Controller 可能已 stop,双重检查防 UI 操作崩溃。onStopInternal置 false(BluetoothPreferenceController:121-122)。- 内存泄漏防护:
ScanStateListener弱持有;CachedBluetoothDevice.Callback在 PreferenceonDetached+ ControlleronStopInternal成对注销。 - UI 先行:关蓝牙时
preHandleBtOff()(BluetoothMainEntrySettingsFragment:192-206)先隐藏列表再真关;开关setLoading(true)先推动 UI。 - settingslib 回调线程模型:所有 dispatch 在主线程(BroadcastReceiver onReceive 默认主线程)。代码大量
postOnBackgroundThread + mUIHandler.post是为把过滤/拷贝等重活挪后台、UI 增删切回主线程,非多此一举。 - 双 flavor:
LocalBluetoothAdapter在base/settingsLibAndroid/src/{dcddif,xcddif}/各一份,startScanning实现位置不同(dcddif:196, xcddif:225)但语义一致。改逻辑两套都要改。