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) 入队:346settingslib 持锁 dispatch(CBDM:126-135)不耐久
缓冲队列 + 500ms×10 台:72-76,:386,:422密集环境几十台并发不爆主线程
锁内取数锁外 UI:392-414持锁时间最小化
继续/结束判断入 synchronized:418-425消竞态窗口
enabled 300ms 防抖NewBluetoothDevicePreference:80-88,135-147MANNPROB-3205 闪烁
监听器弱引用+去重BluetoothScanManager.kt:33,132-136泄漏/重复注册

历史包袱提醒:这套批处理正是 [N801PROB-131124(蓝牙页卡顿风暴)/ 134132(锁互锁)系列单] 的演化产物;持锁 dispatch 的雷仍埋在 settingslib 侧(见 68 §5)。