93 · 首屏数据:开关状态与已配对列表

系列:90-总览 | 91-启动骨架 | 92-Controller 装配 | 93-首屏数据 | 94-扫描上屏 行号基线:fuben_micarsettings dev HEAD(2026-09-13)。

0. 一句话结论

首帧数据 100% 来自进程内缓存和同步 binder 直读,与广播无关——广播只负责首帧之后的增量修正。开关约 4 跳(全主线程),已配对列表约 7 跳(主→后台→主分段)。

1. 总览:两条首帧链路

flowchart TD
    subgraph SW["开关首帧 (~4跳, 全主线程)"]
        S1["onAttach 构造 controller"] --> S2["onCreateInternal :97-101<br/>adapter.getState() (binder)"]
        S2 --> S3["handleStateChanged :205-266<br/>直推 setChecked/setLoading/setEnabled"]
        S3 --> S4["notifyChanged → bind<br/>MiCarNewSwitchPreference:181-240"]
    end

    subgraph BD["已配对列表首帧 (~7跳, 主→后台→主)"]
        B1["①onAttach: LBM单例+readPairedDevices<br/>(主线程同步建缓存)"] --> B2["②onResume: mIsViewInitialized=true"]
        B2 --> B3["③updateState: 主线程延迟200ms"]
        B3 --> B4["④后台线程: getCachedDevicesCopy<br/>+ bondedDeviceFilter(binder实时查)"]
        B4 --> B5["⑤主线程: removeAll+逐个addPreference<br/>(binarySearch排序插入)"]
        B5 --> B6["⑥onAttached→refreshDeviceUi<br/>定title/summary/icon"]
        B6 --> B7["⑦notifyHierarchyChanged→onBindViewHolder"]
    end

2. 开关首帧:直读 adapter,不绕弯

2.1 数据源

  • mBluetoothAdapter = MiBluetoothUtils.getAdapter()(BluetoothSwitchController.java:46;本体 = getSystemService(BluetoothManager).adapter,MiBluetoothUtils.kt:107-111)
  • 首帧读取点在 onCreateInternal(:97-101)——三个 controller 里唯一在 onCreate 就首刷的(两个列表被 mIsViewInitialized 挡到 onResume,见 92 章 §5)
  • 注意:没用 MiBluetoothUtils.isEnabled(),直接 adapter.getState()(binder 到蓝牙服务);STATE_ON/OFF 之外一律映射 BLUETOOTH_UI_STATE_SWITCHING
  • 前置干扰项:父类 BaseVehicleNewSwitchPrefController.onCreateInternal 会先用车辆信号 CAR_MODE_STATUS setChecked 一次(BaseVehicleNewSwitchPrefController.kt:212-215),随后被 adapter 真实状态覆盖——首帧以 adapter 为准

2.2 状态映射(handleStateChanged :205-266)

stateDiagram-v2
    [*] --> 已开启: STATE_ON<br/>checked=true/loading=false/enabled=看限制 :228-234
    [*] --> 已关闭: STATE_OFF<br/>checked=false/loading=false :256-263
    [*] --> 切换中: TURNING_ON/TURNING_OFF<br/>loading=true+提前拨动checked :222-255
    切换中 --> 已开启: 广播回流 handleStateChanged
    切换中 --> 已关闭: 广播回流

冷知识:开关整条继承链无人重写 updateState(已 grep 核实)→ refreshUi() 对开关是空操作,开关状态全由 handleStateChanged 直推 preference。这就是为什么开关能在 onCreate 就出状态,不受 mIsViewInitialized 影响。

2.3 渲染与点击

  • 布局 connection_switch_layout.xml(PreCheckSwitch + title + summary);setChecked → persistBoolean + mUiHandler.post { notifyChanged() }(MiCarNewSwitchPreference.kt:148-156)
  • summary 在 onStart 设:"可被发现为"<设备名>"(:117-118;DeviceUtil.getDeviceName 同步读 Settings.Global)
  • 点击:onCreateInternal:94 用 setOnPreferenceChangeListener(this) 覆盖了基类装的派发器 → 直达 onPreferenceChange(:152-199):互联确认框(Mirror/AA 连接中关蓝牙需确认)→ setLoading(true) → Trace.beginSection + enable()/disable()(:187-194)→ 广播回流 handleStateChanged 校准 + Trace.endSection(:208-211,开关耗时是有 trace 埋点的)
  • 防跳变:页面回前台时若 SWITCHING 态,不按 adapter state 刷新(:121-127)

3. settingslib 数据底座:单例何时建、缓存何时填

sequenceDiagram
    autonumber
    participant F as Fragment onAttach (:210)
    participant U as MiBluetoothUtils
    participant LBM as LocalBluetoothManager
    participant BEM as BluetoothEventManager
    participant CBDM as CachedBluetoothDeviceManager

    F->>U: getLocalBtManager() [MiBluetoothUtils.kt:75-91]
    U->>LBM: getInstance(context, cb) 单首创 [:57-74]
    Note over LBM: 构造 :109-121 全在主线程:
    LBM->>CBDM: new (:113)
    LBM->>BEM: new (:114) → registerAdapterIntentReceiver<br/>handler=null → 回调全在主线程
    LBM->>LBM: LocalBluetoothProfileManager<br/>+updateLocalProfiles() :116-119<br/>(各profile proxy 异步bind开始)
    LBM->>BEM: readPairedDevices() :120
    BEM->>CBDM: getBondedDevices(binder)逐个addDevice :245-261
    CBDM->>CBDM: new CachedBluetoothDevice→fillData :123-141
    Note over CBDM: ★此时 mCallbacks 为空(controller onStart才注册)<br/>dispatchDeviceAdded 无人接收<br/>→ 首帧=纯缓存,与广播无关

另一个 readPairedDevices 触发点:每次蓝牙打开(LocalBluetoothAdapter.setBluetoothStateInt(STATE_ON) :251-257)。

4. 已配对列表首帧:缓存 × 实时过滤

4.1 innerUpdateState 分段模型(BluetoothBondedDevicesPrefController.java:352-401)

sequenceDiagram
    autonumber
    participant M as 主线程
    participant BG as 后台线程(ThreadUtils)
    participant Sys as 蓝牙服务(binder)

    M->>M: updateState :332-348<br/>mIsViewInitialized门控 :333-335<br/>首开 postDelayed 200ms :337-344
    M->>BG: postOnBackgroundThread :356
    BG->>BG: getCachedDevicesCopy() :363-365<br/>(synchronized 拷贝)
    BG->>Sys: bondedDeviceFilter:<br/>getAdapter().bondedDevices.contains(device)<br/>[MiBluetoothUtils.kt:203-212]
    Note over BG: ★过滤器不是读缓存bondState<br/>而是后台线程上实时查系统配对表<br/>(注释自称"对齐AOSP BondedDeviceTypeFilter")
    BG->>M: mHandler.post :377
    M->>M: removeAll + mPreferenceMap.clear :383-384
    M->>Sys: adapter.isEnabled() 才逐个 addPreference :387-391
    M->>M: 组可见性(count>0 && isEnabled) :393-396<br/>refreshVoiceAssistViewOrder :397

4.2 单条创建与排序插入

addPreference(:71-87)→ createDevicePreference(:142-202):新建 MiCarBluetoothBondedDevicePreference → setRightIcon(右箭头 :148)→ 点击监听(右箭头进详情 :162-164;整行走连接/断开 :167-199,isBusy 防抖忽略)→ PreferenceGroup.addPreference(micarx 库,javap 实证:Collections.binarySearch 排序插入)→ onAttached → registerCallback + refreshDeviceUi(NewBluetoothDevicePreference.java:113-118)。

排序规则(MiCarBluetoothBondedDevicePreference.compareTo:209-217):已连接排最前,其余按 title 字母序(忽略大小写)。注意:只在插入瞬间排序——运行中连接状态变化不重排,只刷单行(onProfileConnectionStateChanged:469-501);整表重排只在全量路径(STATE_ON/配对态变化/生命周期的 refreshUi)。

5. 三态摘要:首帧取值与 profile-proxy 竞态

判据(MiCarBluetoothBondedDevicePreference.getDeviceSummary:97-113):

flowchart LR
    A{"cachedDevice.isConnected<br/>CBD:912-923 遍历mProfiles实时问proxy"} -->|是| B["已连接"]
    A -->|否| C{"isBusy && mIsConnectingState"}
    C -->|是| D["正在连接..."]
    C -->|否| E["已保存"]

首帧两个确定性结论(取值时刻 = onAttached → refreshDeviceUi,主线程同步求值):

  1. 首帧”正在连接”不可能出现:mIsConnectingState 初值 false(NewBluetoothDevicePreference.java:66),唯一置位点在 controller 的 onProfileConnectionStateChanged(:493-497)——首帧新建 preference 时必为 false。
  2. 首帧可能短暂”已保存”实为已连接(profile-proxy 竞态):isConnected 实时问 profile proxy,而 proxy 是异步 bind 的(§3 第 4 步);proxy 未就绪时 A2dpProfile.getConnectionStatus:164-169 直接返回 DISCONNECTED → 首帧”已保存”;proxy bind 完成后 onServiceConnected → device.refresh() → dispatchAttributesChanged(CachedBluetoothDevice.java:1169-1173)→ preference 回调 refreshDeviceUi 纠正为”已连接”。这是首帧固有的自愈窗口,不是 bug;但如果纠正迟迟不来,查 proxy bind 链。

6. 装饰元素首帧填充时刻表

元素首帧时刻说明
右箭头创建时 setRightIcon(:148)→ 首次 bind 画出;已连接换蓝色(kt:186-190)本地资源
右侧文字首帧为空(bonded 从未 setRightText)—
电量/信号主页布局里不存在(layout 已核实,无此 view)非未核实,是没有
语音序号角标首帧 GONE;refreshVoiceAssistViewOrder(:117-140)在 innerUpdateState 末尾与 onPreferenceUpdateFinish 时补见 91 章 §4 / 36 篇
连接动画条首帧 GONE(mIsConnecting=false)§5

7. 线程模型速查

链路线程锚点
LBM 创建 + readPairedDevices + fillData主线程(onAttach)Fragment:210;LBM:109-121
开关 getState / enable/disable(binder)主线程SwitchController:97,190-193
settingslib 全部广播 dispatch主线程(handler=null)BEM:227-237
已配对数据读取 + bondedDeviceFilter(binder)后台线程池(显式设计)Bonded:356-374
已配对 UI 变更(removeAll/addPreference/排序)主线程(mHandler.post)Bonded:377-398
CachedBluetoothDevice.refresh()(设备元数据)后台→主线程两段CBD:793-809

8. 排障要点

  1. 首帧数据与广播无关——“主页打开列表是空的”先查 readPairedDevices 是否跑过(LBM 是否本进程首创)和 bondedDeviceFilter 的 binder 查询结果,别去查广播丢没丢。
  2. 开关状态不走 updateState——开关显示异常直接查 handleStateChanged 和自有 receiver(:104-109),别在 refreshUi 链上浪费时间。
  3. “已保存”一闪而过变”已连接” = profile proxy 竞态自愈(§5),属固有设计;一直停在”已保存” 才是真问题(proxy bind 失败/34 篇状态机缺陷)。
  4. 首开 200ms 延迟每次进页都有(onPause 重置 mIsFirstOpen)——做启动性能测量时别把延迟当回归。