11-车设页面搭建入门:Preference 框架、Controller 装配与布局分层

面向读者:第一次打开 MiCarSettings 源码、想知道”WLAN 这个页面到底是怎么搭起来的”的同学。 本文回答五个新人高频问题:布局在哪设的 / Controller 哪来的 / preferenceTheme 那行干嘛的 / 列表条目谁加的 / 顶部第一张带按钮的卡片怎么来的。 代码锚点双基线核验:fuben@devfuben10@rls-xcd-u-ee-2603(MIICOSBUG-2759 分析分支)行号一致;框架层(settingsBaseUi)全 flavor 共用,flavor 差异处已标注。 关联:10-WiFi基础概念扫盲(名词世界观)→ 本文(App 骨架)→ 30-页面与导航(窗口四层注入链,本文不重复)→ 32-扫描与列表刷新(刷新机制全景)。


1. 布局不是一处设的:三层拼装

新人最直觉的期待是”Fragment 里应该有个 layout XML”——但 MiCarWifiListFragment 全文没有 setContentView 也没有 inflate 自己的页面布局。它的界面是三层拼出来的:

谁提供内容何时出现
① 内容结构getPreferenceScreenResId() = R.xml.miauto_wifi_list_fragment(MiCarWifiListFragment.kt:35)PreferenceScreen XML:开关条目/已连接分组/可搜分组(不是 View 布局,是”偏好项清单”)inflate 即出(骨架)
② 外壳容器父类继承链 DialogSettingsFragmentTopLevelSettingsFragmentBaseXmlParserSettingsFragmentRecyclerView、android.R.id.list_container、渐变蒙版等inflate 即出(骨架)
③ 空态叠加自己 onCreateView(MiCarWifiListFragment.kt:78-91)micar_bluetooth_nopaired_device(复用蓝牙页的)inflate 进 list_container,图标换成 micar_ic_wifi_off_indicatorWiFi 关闭时才 VISIBLE

第 ① 层的 miauto_wifi_list_fragment.xml 是理解整页的钥匙:

<PreferenceScreen>
    <MiCarNewSwitchPreference key=pk_wifi_settings_entry
        controller="...WifiSwitchController"/>                     <!-- WLAN 开关 -->
    <SpacePreference 8dp/>
    <MiCarPreferenceCategory key=pk_wifi_current_conn
        controller="...MiCarSavedWifiListController"/>             <!-- 已连接+已保存分组 -->
    <SpacePreference 10dp/>
    <CarUIPreferenceCategory key=pk_wifi_available_conn
        dependKey=pk_wifi_settings_entry
        controller="...MiCarAvailableWifiListController"/>         <!-- 可搜网络分组 -->
</PreferenceScreen>

★ 为什么”骨架秒出、内容空窗”:①②层是静态 XML,不依赖任何 WiFi 数据;真正的数据条目(第 4 节)是 Controller 后续动态 addPreference 挂进两个 Category 的——这就是 MIICOSBUG-2759”打开页面 0.5s 出骨架、内容空窗 3~6s”的结构根源。

窗口更外层(Activity 装饰、dialog 容器)的四层接力见 30-页面与导航 §2.4,本文不重复。


2. onAttach:页面的”装配车间”(Controller 的出生地)

XML 里那三个 settings:controller="类全名" 是谁实例化的?答案在基类 BaseXmlParserSettingsFragment.onAttach()(settingsBaseUi/…/common/BaseXmlParserSettingsFragment.java:278)——Fragment 生命周期第一个回调、主线程同步执行。逐段拆:

2.1 宿主契约校验(fail-fast)

if (!(getActivity() instanceof UxRestrictionsProvider)) throw ...  // 宿主必须提供驾驶限制
if (!(getActivity() instanceof FragmentHost)) throw ...            // 宿主必须能管理 fragment
if (theme == 0) throw "Must specify preferenceTheme in theme"      // 主题必须配 preferenceTheme

设计意图:把”宿主配错了”从深处的 NPE 提前到 attach 第一毫秒,报错信息明确。

2.2 XML 单次解析(性能优化点)

prefMetadataList = PreferenceXmlParser.extractMetadata(styledContext, xmlResId,
        FLAG_NEED_KEY | FLAG_NEED_PREF_CONTROLLER | FLAG_NEED_DEPEND_KEY | FLAG_NEED_HIDDE_FEATURES);
  • 原先 Controller/DependKey/HiddenFeatures 三个 helper 各解析一遍 XML = 3 次 IO;2026-06 优化为解析 1 次、产出 List<Bundle>(每节点一个)喂 3 个 helper(代码里有 AI 注释块);
  • PreferenceXmlParser.extractMetadata(settingsBaseLib/…/PreferenceXmlParser.java:99-164)流式遍历,只认以 Preference/PreferenceGroup/PreferenceCategory 结尾的 tag;
  • ⚠️ 踩坑警告(源码注释原文”踩坑兒了!“):自定义 Preference 类命名必须以 Preference 结尾,否则被静默跳过只打一行 log——“XML 配了却不生效”先查命名。

2.3 反射创建 Controller

PreferenceControllerListHelper.createInstance(:108-130)六步:

Class.forName(controllerName);                                   // ① 按名加载
clazz.getConstructor(Context, String, FragmentController, CarUxRestrictions);  // ② 签名约定★
newInstance(context, key, fragmentController, restrictionInfo);  // ③ 实例化
Router.getInstance().holdController(...)                         // ④ 语音(可见即可说)路由登记
setDependKey(dependKey);                                         // ⑤ 依赖键注入
// ⑥ 任何反射异常 → IllegalArgumentException("Invalid preference controller")

硬约定:所有 Controller 必须有 (Context, String, FragmentController, CarUxRestrictions) 四参构造器——不写,attach 期直接崩。WiFi 页的三个 Controller 全是这个签名。

2.4 生命周期注册与三张辅助表

lifecycle.addObserver(controller);                    // Controller=DefaultLifecycleObserver,事件自动派发
mPreferenceControllersLookup.computeIfAbsent(...)     // Class→实例列表,支撑 use(XxxController::class, key) 查询
消费点WiFi 页实例
mDependKeyMaponCreatePreferences:591 setDependKeyList可搜分组 dependKey=开关——开关关闭→可搜列表跟着隐藏
mHiddenFeaturesonCreatePreferences→removePreferenceByKey:1060车型/构建裁剪,配置了 hiddenFeatures 的条目直接移除
mRestrictedWhileDrivingMessage行车限制触发时”行驶中受限”文案预取

2.5 ★ 精妙的时序依赖(改代码前必读)

onAttach            解析 XML + 反射建 3 个 Controller + addObserver
   ↓
Fragment.onCreate   super.onCreate(...) → :74 new MiWifiTrackerUtils(tracker 诞生)
   ↓                (androidx:ON_CREATE 事件在 Fragment.onCreate() 整体返回后才派发)
ON_CREATE 派发   →  controller.onCreateInternal()
                    MiCarBaseWifiEntryController:82 经 InstanceGetter 拿 tracker —— 此时已非 null ✓
   ↓
onCreatePreferences 绑 preference、注入 dependKey、裁剪 hidden
   ↓
onStart → ON_START 同时派发两路:
   ├ controller.onStartInternal → mWifiTrackerTools.addListener(this)
   └ tracker.onStart(同一 lifecycle) → post handleOnStart(重活入 worker 线程)

MiCarWifiListFragment 实现 InstanceGetter 接口把 :74 才创建的 tracker 交给 Controller——能这么写全靠 androidx 的 ON_CREATE 派发时机晚于整个 onCreate()。若把 new MiWifiTrackerUtils 挪到 onCreateView,Controller 的 getInstance() 会拿到 null 直接 NPE。


3. 一行冷知识:preferenceTheme 解析

TypedValue tv = new TypedValue();
getActivity().getTheme().resolveAttribute(androidx.preference.R.attr.preferenceTheme, tv, true);
int theme = tv.resourceId;

做什么:沿 Activity 主题的 parent 继承链向上查找 preferenceTheme 属性,因其值是引用(@style/xxx),resolveRefs=true 继续解析成数字资源 ID 存入 tv.resourceId

为什么 preference 店需要它:androidx.preference 强制要求宿主主题声明一个”preference 专属子主题”——每个 Preference 构造时从主题取默认样式(布局/textAppearance 等),集中放这里避免污染全局主题。

WiFi 页的实际解析链:

WifiSettingsActivity 主题=@style/MiCarWifiDialog (micarConnectionSettings/AndroidManifest.xml:126)
  └─ <item name="preferenceTheme">@style/CarUiPreferenceTheme.WithToolbar.Auto</item>
CarUiPreferenceTheme.WithToolbar.Auto (settingsBaseUi/res/values/themes.xml:29-36):
  preferenceStyle / switchPreferenceStyle / seekBarPreferenceStyle / preferenceCategoryStyle
  → 全部重定义为 Preference.CarUi.*.Auto 家族(车机 preference 控件默认外观)

解析出的 ID 随后用于 new MyContextThemeWrapper(getActivity(), lifecycle, theme)——包一层”只叠 preference 子主题”的 Context,Controller 在 onAttach 阶段就能安全 new Preference(context...)theme==0 抛异常 = 新 Activity 忘了继承设置主题族时 fail-fast。


4. WiFi 条目是谁加进去的:两阶段 fill

真正把条目挂进 UI 的只有两处(两个 Controller 的 updateState(preference, list)addPreference),但前面有一整条流水线:

[W] tracker 工作线程: handleOnStart 完成 → updateWifiEntries → post 主线程
        ↓
[M] onWifiEntriesChanged()  ← 两 Controller 均 implements MiWifiTrackerUtils.Listener
        ↓ refreshUi()
[M] MiCarBaseWifiEntryController.updateState(pref)      ← 基类模板 :87-111
        │ 丢进 ThreadPoolUtils.SINGLE_ASYNC 单线程池 :110
        ↓
[P] 子线程 fillWifiEntries()  ← 从 tracker 搬数据(要抢 tracker 的 mLock)
        ↓ mHandler.sendMessage(:104-108)
[M] updateState(preference, list)  ← 主线程 removeAll + 逐条 addPreference ★

第①阶段(子线程取数)——数据在 MiWifiTrackerUtils.getWifiEntries() 里已经过 isNeedAddInList(:122-134)过滤:UNREACHABLE(15s 扫描过期)的条目根本到不了 Controller;且每次取数还会多一次 HotspotUtils.refreshHotspotInfo() binder。

第②阶段(主线程上屏):

ControllerupdateState 实现行为
可搜列表MiCarAvailableWifiListController.kt:39-48removeAll() → forEach addPreference(createWifiEntryPreference(it, index++))
已连接+已保存MiCarSavedWifiListController.kt:63-71isVisible = list.isNotEmpty()(空窗期整个分组不可见的出处)→ removeAll → 逐条 add

每轮都是全清重建全部 preference(无 diff),靠 PreferenceComparisonCallback(MiCarWifiListFragment.kt:49-73)减少视觉闪烁——防闪机制的完整版见 32-扫描与列表刷新


5. 顶部第一张卡片:已连接网络(带按钮的那条)

为什么它排第一——MiCarSavedWifiListController.fillWifiEntries()(:73-87)的添加顺序:

wifiEntries.add(connectedWifiEntry)     // 已连接 entry 无条件插队第一个
wifiEntries.addAll(savedWifiEntries)    // 已保存的排后面

它和普通条目不一样在哪:

  • 对象:重写 createWifiEntryPreference → new MiCarSavedWifiEntryPreference(:38-50),其 layoutResource = micar_ui_preference_wifi_item_saved(专属布局);
  • 布局多出的详情按钮 = micar_ui_pref_icon_right ImageView(> 图标 micar_ic_view_detail_indicator);
  • 按钮接线在 MiCarWifiEntryPreference.onBindViewHolder(:92-99):仅 wifiEntry.isSaved && mIsProvisioned 时可见,点击 → onDetailBtnClick 回调(在 Saved controller :52-61 赋值)→ launchFragment(MiCarWifiEntryDetailFragment) 进详情页;
  • 蓝色高亮:同文件 refreshSavedEntryDisplay(:102+)按 isConnected()||isConnecting() 切 OnPrimary 配色。

数据依赖:已连接 entry 由 tracker handleOnStart 串行链末段(getConnectionInfo/getNetworkCapabilities)构建——所以这张卡片也被整条链路门控,空窗期”已连接”都不显示,是 2759 空窗最直观的受害者。


6. 一图流:从点击到内容可见

sequenceDiagram
    autonumber
    participant M as 主线程
    participant C as 3个Controller
    participant W as Worker线程(BACKGROUND)
    participant P as SINGLE_ASYNC池

    M->>M: onAttach:解析XML+反射建Controller
    M->>M: onCreate:74 new MiWifiTrackerUtils(新线程+新tracker)
    M->>C: ON_CREATE→onCreateInternal(经InstanceGetter拿tracker)
    M->>M: onCreateView/onCreatePreferences:骨架上屏≈0.5s
    M->>W: ON_START→post{注册+handleOnStart}
    Note over W: 串行 binder 链(O(AP数)×8~10次/entry)
    W-->>M: post onWifiEntriesChanged
    M->>C: refreshUi
    C->>P: fillWifiEntries(子线程取数,等mLock)
    P-->>M: sendMessage(过滤后列表)
    M->>M: removeAll+addPreference 逐条上屏

7. 新人速记卡

想做的事去哪改
判断”显示慢”卡在哪先分层:①②层静态骨架(秒出)/ 数据条目(等第 4 节链路)
改某个条目样式micar_ui_preference_wifi_item*.xml(普通 vs saved 两套)
加新设置条目XML 加节点 + 写 Controller(四参构造器!) + 命名以 Preference 结尾
查某条目逻辑XML 节点的 settings:controller 属性 → 类名直达
改”已连接卡片”MiCarSavedWifiListController + MiCarSavedWifiEntryPreference
理解空窗/性能tracker 链路:见 WLAN列表重建风暴 + 40 篇 2759 案例卡

五个认知坑:

  1. settings:controller 类名写错/构造器签名不对 → attach 期直接崩(不是运行到那才崩);
  2. 自定义 Preference 不以 Preference 结尾 → XML 解析被静默跳过;
  3. Controller 的 getInstance() 依赖 ON_CREATE 派发时序(§2.5),乱挪 tracker 创建位置会 NPE;
  4. 列表每轮全量重建无 diff,别假设”只刷新变化项”;
  5. 可搜分组 dependKey 挂在开关上——排查”列表不见了”先看开关状态,再看 15s 新鲜窗。