94 · 扫描发现与可用设备上屏流水线
系列:90-总览 | 91-启动骨架 | 92-Controller 装配 | 93-首屏数据 | 94-扫描上屏 行号基线:fuben_micarsettings dev HEAD(2026-09-13)。核心文件:
BluetoothUnbondedDevicesPrefController.java(下称 Unbonded)、BluetoothScanManager.kt、MiBluetoothUtils.kt、CarUIPreferenceCategory.java。
0. 一句话结论
附近设备列表 = 首开延迟 200ms 让首帧 → 清缓存重建 → 后台触发扫描 → ACTION_FOUND 逐台回调(主线程、持锁)→ 拦截/过滤 → 缓冲队列 → 每 500ms 一批 ≤10 台上屏。整条链是”事件驱动 + 批处理防卡顿”的教科书样本。
1. 首帧延迟策略:为什么首开要等 200ms
sequenceDiagram participant FS as Fragment onResume participant C as Unbonded Controller participant UI as 主线程 FS->>C: onResumeInternal (:209-213)<br/>清pause标志 + startLoading(动画起转) FS->>C: updateState (:144-162) C->>C: setVisible(isEnabled) (:146) Note over C: mIsViewInitialized=false? 直接return (:147) alt 首次打开 (mIsFirstOpen=true) C->>UI: postDelayed(innerUpdateState, 200ms) (:152-157) Note over UI: BLUETOOTH_DATA_LAZY_TIME=200<br/>MiBluetoothUtils.kt:59 else 非首次(蓝牙状态变化等) C->>C: innerUpdateState 立即执行 (:160) end Note over C: onPauseInternal 重置 mIsFirstOpen=true (:231)<br/>★每次进页都重新走延迟,非仅冷启动
设计意图:removeAll + 清缓存 + 重建 + 触发扫描是一组重操作,推迟 200ms 让标题栏、开关、已配对列表先上屏——首帧感知优先。
2. innerUpdateState:清空重建六步(:164-194,主线程)
flowchart LR S1["① removeAll 清UI组 :167"] --> S2["② mPreferenceMap.clear :168"] S2 --> S3["③ clearNonBondedDevices 清settingslib缓存 :169"] S3 --> S4["④ resetPendingDevices 清队列摘runnable :170"] S4 --> S5["⑤ refreshVoiceAssistViewOrder 重算角标偏移 :171"] S5 --> S6{"⑥ 配对中?<br/>mBondState==BOND_BONDING :178"} S6 -->|是| SK["跳过缓存重建 :188-190<br/>(防刚配对设备被误判未配对)"] S6 -->|否| RB["从 getCachedDevicesCopy 重建<br/>逐台过 scannedDeviceFilter → addPreference :179-187"] SK --> T["⑦ 后台线程 triggerScan('update_state') :193"] RB --> T
为什么需要⑥的缓存重建:DeviceFoundHandler 对已缓存设备不再触发 onDeviceAdded(见 01 篇 §4 零分发不对称)——不重建列表就是空的。
3. 扫描闸门 BluetoothScanManager(单例)
sequenceDiagram participant Any as 任意调用方 participant M as BluetoothScanManager participant U as MiBluetoothUtils participant A as BluetoothAdapter Any->>M: triggerScan(scenario) [:94-109] M->>M: 写锁内读 mCanExecuteBluetoothScan [:95-98] alt 闸门关闭 M-->>Any: 仅打日志,不扫 else 闸门开启 M->>M: mUIHandler.post 到主线程 M->>U: isDiscovering()? [:101-102] alt 已在扫描 M-->>Any: 跳过(幂等) else U->>U: startDiscovery [:249-261]<br/>入口再次 isDiscovering 幂等 [:253-256] U->>A: startDiscovery() [:258] U->>U: mAlwaysDiscoverable.start() [:260] end end
闸门三件套:
mCanExecuteBluetoothScan(AtomicBoolean,:24)+ReentrantReadWriteLock(:27)——写锁内改标志+复制监听器快照,锁外 post 主线程通知(防回调里再注册导致写锁升级死锁)。- 监听器
WeakReference存储(:33)+System.identityHashCode去重(:132-136)。 - 纠正旧认知:
startDiscovery内没有stopDiscovery;双保险 = triggerScan/startDiscovery 两处isDiscovering幂等 +AlwaysDiscoverable监听SCAN_MODE_CHANGED自动恢复可发现模式(:432-496)。入口幂等同时阻断stopDiscovery→FINISHED→重扫的递归循环(:250-252 注释自述)。
扫描循环(扫完即重扫):
stateDiagram-v2 [*] --> 扫描中: startDiscovery 扫描中 --> 扫描结束: 系统12s后 ACTION_DISCOVERY_FINISHED 扫描结束 --> 判定: onScanningStateChanged(false) [:433-459] 判定 --> 扫描中: 非pause & 非BOND_BONDING<br/>& 列表无配对中设备 → triggerScan [:456] 判定 --> [*]: pause态 (:439) / 配对中 (:443,:445-454)
4. 发现→上屏流水线(核心)
线程前提:settingslib 广播接收器 mReceiverHandler=null(LocalBluetoothManager.java:66 → BluetoothEventManager.java:105,230)——ACTION_FOUND 整条回调链跑在主线程。
sequenceDiagram autonumber participant Stack as 蓝牙协议栈 participant BEM as BluetoothEventManager participant CBDM as CachedBluetoothDeviceManager participant C as Unbonded Controller participant Q as mPendingDevices 队列 Stack->>BEM: ACTION_FOUND BEM->>CBDM: findDevice (DeviceFoundHandler :514) alt 已缓存 CBDM-->>BEM: 仅更新RSSI/justDiscovered (:525-526)<br/>★不触发 onDeviceAdded else 未缓存 BEM->>CBDM: addDevice (:515-516) Note over CBDM: synchronized 持锁 (:126)<br/>new + 入表 (:129-134)<br/>★持锁中 dispatchDeviceAdded (:135) CBDM-->>C: onDeviceAdded(主线程,持锁!) (:315-349) C->>C: ①lastUnboundDevice拦截? 丢弃+清缓存+清标记 (:321-334) C->>C: ②scannedDeviceFilter 过滤 (:335) C->>Q: ③put(address, device) (:346) C->>C: startAddDeviceTimer (:347,377-383) Note over C: ★持锁回调里只做 O(1) 入队,UI 全部后置 end loop 每 500ms 一批 ≤10 台 C->>Q: 锁内取 batch(MAX_DEVICES_PER_TICK=10) (:392-409) C->>C: 锁外 addPreference 逐台上屏 (:412-414) C->>Q: 非空? postDelayed(500ms) (:418-425) end
scannedDeviceFilter 过滤规则(MiBluetoothUtils.kt:191-196):非 BONDED + 非本机另一蓝牙(isSameDevice,反射 BluetoothAdapterUtil,:129-137)+ isDeviceClassTypeSupport(:143-185)——屏蔽 AUDIO_VIDEO 大类/UNCATEGORIZED(国内)/LAPTOP/CAR_AUDIO/耳机,WEARABLE 仅放行 PAGER。结果:附近列表只显示手机类设备,有意设计(详见 01 篇 §4)。
addPreference(:461-548):BONDED 跳过(:462-467)→ 按地址去重(已存在仅 setCachedDevice 重绑刷新,:472-481)→ 新建 MiCarBluetoothUnbondDevicePreference(:490-493)→ 设语音激活标志(:494)→ 挂配对点击监听(:496-545)→ addPreference 上屏(:546)。排序走 compareTo title 字母序(NewBluetoothDevicePreference.java:280-289)。
5. 单条渲染与搜索动画
单条渲染(MiCarBluetoothUnbondDevicePreference / NewBluetoothDevicePreference.refreshDeviceUi :154-194):
- 标题=设备名(:156);summary 仅 BOND_BONDING 时显示”配对中”(
MiCarBluetoothUnbondDevicePreference.kt:18-24) - 图标按 majorDeviceClass 选型统一 tint(
MiBluetoothUtils.kt:343-368) setEnabledWithDebounce(!isBusy && !isConnecting)(:171,300ms 防抖 MANNPROB-3205)- 无名设备默认隐藏(:172);不渲染 RSSI 信号强度(布局里没有信号视图)
搜索动画:startLoading(:600-605)仅在 onResumeInternal 调,stopLoading 仅在 onPauseInternal 调——动画从 resume 转到 pause,扫描结束并不停。CarUIPreferenceCategory 的实现细节:状态去重(:142-143)、视图未绑定时挂起 pending(:132-137)、tag 身份校验防复用错绑(:139)、匀速 RotateAnimation(:38-42 注释称减少重复卡顿感)。
6. 语音角标在上屏链路的三个设置点
| 时机 | 位置 | 设了什么 |
|---|---|---|
| 建 Preference 时 | addPreference :494 | 仅 setIsVoiceAssistActive(不含序号) |
| innerUpdateState 清空后 | :171 → refreshVoiceAssistViewOrder :574-598 | 重算 offset(已配对数)+ 逐台设序号 |
| Fragment 主动 | updateVoiceAssistActive :568-572 / refreshDeviceVoiceAssistOrder :614-618 | 全量重排 |
⚠️ 时间差观察点:批处理上屏(mAddDeviceRunnable→addPreference)只带 active 标志,序号要等下一次 refreshDeviceVoiceAssistOrder 或 innerUpdateState 才补齐——语音激活瞬间新增设备可能短暂有角标无序号。(详见 36-可见即可说)
7. 页面退出清理
flowchart LR subgraph Pause["onPauseInternal :216-232"] P1[stopLoading 停动画 :219] P2[mIsPauseState=true 阻断重扫 :220] P3["后台: clearNonBondedDevices + stopDiscovery :221-230"] P4[mIsFirstOpen=true :231] end subgraph Stop["父类 onStopInternal :121-124"] S1[注销 EventManager callback<br/>断 ACTION_FOUND 来源] end subgraph Destroy["onDestroyInternal :235-259"] D1[注销配对回调 :236-239] D2[注销扫描监听 :242] D3[清队列+清缓存 :244-245] D4["后台: stopDiscovery try/catch :249-258"] end Pause --> Stop --> Destroy
8. 防卡顿设计清单(性能篇索引)
| 设计 | 位置 | 解决什么 |
|---|---|---|
| 首开 200ms 延迟 | :152-157 | 首帧让路 |
| 扫描触发抛后台 | :193 | 闸门检查不堵主线程 |
| 锁内回调只 O(1) 入队 | :346 | settingslib 持锁 dispatch(CBDM:126-135)不耐久 |
| 缓冲队列 + 500ms×10 台 | :72-76,:386,:422 | 密集环境几十台并发不爆主线程 |
| 锁内取数锁外 UI | :392-414 | 持锁时间最小化 |
| 继续/结束判断入 synchronized | :418-425 | 消竞态窗口 |
| enabled 300ms 防抖 | NewBluetoothDevicePreference:80-88,135-147 | MANNPROB-3205 闪烁 |
| 监听器弱引用+去重 | BluetoothScanManager.kt:33,132-136 | 泄漏/重复注册 |
历史包袱提醒:这套批处理正是 [N801PROB-131124(蓝牙页卡顿风暴)/ 134132(锁互锁)系列单] 的演化产物;持锁 dispatch 的雷仍埋在 settingslib 侧(见 68 §5)。