01 · 蓝牙发现(扫描/Discovery)全链路

★ 用户重点关注章节。从打开蓝牙页 → 触发扫描 → 设备出现在列表 的端到端深度剖析。 核心文件:bluetooth/BluetoothScanManager.ktMiBluetoothUtils.ktcontroller/BluetoothUnbondedDevicesPrefController.javaview/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*1000LocalBluetoothAdapter.java:66)。节流由本仓自己实现。
  • 扫描结果不是一次性的,而是逐设备经 onDeviceAdded 推送(DeviceFoundHandlerBluetoothEventManager.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():217onStop():224
所有 BluetoothPreferenceController 子类onStartInternal():115onStopInternal():122
ScanStateListener(Unbonded 持有)onCreateInternal():183onDestroyInternal():220

即 Fragment 与每个 Controller 各自独立注册,一次 dispatchDeviceAdded 被所有收到(CopyOnWriteArrayList 遍历)。

扫描超时/停止

  • 无应用层定时器,走系统 BluetoothAdapter 默认 12s 发 ACTION_DISCOVERY_FINISHED
  • 应用层 onScanningStateChanged(false) 重新触发,形成「扫完→立即重开」循环,直到 onPause 或 BOND_BONDING
  • 停扫入口:onPauseInternal(Unbonded:194-210)、onDestroyInternal(:213-247 再保险)、点击设备发起配对(Unbonded:465 stopDiscovery()

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:

  1. mPendingDevices(synchronized LinkedHashMap)按 MAC 去重 put(:328)
  2. startAddDeviceTimer()mUIHandler.post(mAddDeviceRunnable)(:359)
  3. mAddDeviceRunnable 每 tick 取最多 MAX_DEVICES_PER_TICK=10 个(:363),锁内取 batch、锁外做 UI,剩余 postDelayed(500ms)(:399)
  4. 配对中设备(BOND_BONDING)在 tick 里被跳过(:378-383)
  5. 竞态修复: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):

#触发源位置时机
1ACTION_FOUND 新设备DeviceFoundHandler(BEM:505-528)→ findDevice==nullCBDM.addDevice(:123-141)锁内 dispatch(:135)扫描发现缓存没有的设备
2ACTION_FOUND 已缓存但 bonded+未连接BEM:519-524 补 dispatch给已配对列表显示用(Unbonded 侧被 scannedDeviceFilter 滤掉)
3readPairedDevices()BEM:245-261,LBM 构造收尾调用(LocalBluetoothManager.java:120蓝牙开/Managers 首建时读 bonded 逐台 addDevice
4CSIP 组 / 助听器CsipDeviceManager.java:346 / HearingAidDeviceManager.java:226,251 主动 dispatchLE 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 设递增 index
  • MiCarBluetoothBondedDevicePreference.compareTo:204-212已连接设备排最前,其余字母序
  • NewBluetoothDevicePreference.compareTo:230-239:title 字母序(忽略大小写)

5. 场景管控:谁禁止扫描(setCanExecuteScan(false) 穷尽)

全仓库 setCanExecuteScan(false) 仅 2 处,全在 CarPlay 配对链路

文件:行号调用场景原因
MiBluetoothPairingDialogActivity.kt:136setCanExecuteScan(false, "pairing_carplay_device")配对的正在是 CarPlay 设备紧接 CarPlay 类型选择弹窗,两弹窗动画叠加会卡顿
:200同上dismiss(配对成功) 且是 CarPlay 且非 MIS启动类型选择弹窗前再次阻断
:215setCanExecuteScan(true, "dismiss_pair_fail")启动类型选择 Activity 失败 catch异常恢复,防永久禁扫
MiCarplayConnectTypeChooseDialogActivity.kt:53setCanExecuteScan(true, "connect_type_choose_inflated")类型选择弹窗 onCreate 后 postDelayed(200ms)弹窗 inflate 后恢复

关键:普通蓝牙配对(非 CarPlay)不禁扫,只靠 mBondState==BOND_BONDINGonScanningStateChanged 里挡重扫(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. 设计要点与坑

  1. 持续扫描循环(Unbonded:417-420):onScanningStateChanged(false) 立即重扫,但 click 发起配对时须先 stopDiscovery() 再置 BOND_BONDING(:465-466),顺序很关键,否则配对开始瞬间又触发扫描。
  2. 防连点mIsHandlePair(volatile boolean,Unbonded:82,456-460)拦截重复配对。
  3. mIsControllerActive 双检(Unbonded:328,349):后台线程取设备列表、切回主线程时 Controller 可能已 stop,双重检查防 UI 操作崩溃。onStopInternal 置 false(BluetoothPreferenceController:121-122)。
  4. 内存泄漏防护ScanStateListener 弱持有;CachedBluetoothDevice.Callback 在 Preference onDetached + Controller onStopInternal 成对注销。
  5. UI 先行:关蓝牙时 preHandleBtOff()(BluetoothMainEntrySettingsFragment:192-206)先隐藏列表再真关;开关 setLoading(true) 先推动 UI。
  6. settingslib 回调线程模型:所有 dispatch 在主线程(BroadcastReceiver onReceive 默认主线程)。代码大量 postOnBackgroundThread + mUIHandler.post 是为把过滤/拷贝等重活挪后台、UI 增删切回主线程,非多此一举
  7. 双 flavorLocalBluetoothAdapterbase/settingsLibAndroid/src/{dcddif,xcddif}/ 各一份,startScanning 实现位置不同(dcddif:196, xcddif:225)但语义一致。改逻辑两套都要改。