11-车设页面搭建入门:Preference 框架、Controller 装配与布局分层
面向读者:第一次打开 MiCarSettings 源码、想知道”WLAN 这个页面到底是怎么搭起来的”的同学。 本文回答五个新人高频问题:布局在哪设的 / Controller 哪来的 / preferenceTheme 那行干嘛的 / 列表条目谁加的 / 顶部第一张带按钮的卡片怎么来的。 代码锚点双基线核验:
fuben@dev与fuben10@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 即出(骨架) |
| ② 外壳容器 | 父类继承链 DialogSettingsFragment→TopLevelSettingsFragment→BaseXmlParserSettingsFragment | RecyclerView、android.R.id.list_container、渐变蒙版等 | inflate 即出(骨架) |
| ③ 空态叠加 | 自己 onCreateView(MiCarWifiListFragment.kt:78-91) | 把 micar_bluetooth_nopaired_device(复用蓝牙页的)inflate 进 list_container,图标换成 micar_ic_wifi_off_indicator | WiFi 关闭时才 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 页实例 |
|---|---|---|
mDependKeyMap | onCreatePreferences:591 setDependKeyList | 可搜分组 dependKey=开关——开关关闭→可搜列表跟着隐藏 |
mHiddenFeatures | onCreatePreferences→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。
第②阶段(主线程上屏):
| Controller | updateState 实现 | 行为 |
|---|---|---|
| 可搜列表 | MiCarAvailableWifiListController.kt:39-48 | removeAll() → forEach addPreference(createWifiEntryPreference(it, index++)) |
| 已连接+已保存 | MiCarSavedWifiListController.kt:63-71 | isVisible = 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→ newMiCarSavedWifiEntryPreference(:38-50),其layoutResource = micar_ui_preference_wifi_item_saved(专属布局); - 布局多出的详情按钮 =
micar_ui_pref_icon_rightImageView(>图标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 案例卡 |
五个认知坑:
settings:controller类名写错/构造器签名不对 → attach 期直接崩(不是运行到那才崩);- 自定义 Preference 不以 Preference 结尾 → XML 解析被静默跳过;
- Controller 的
getInstance()依赖 ON_CREATE 派发时序(§2.5),乱挪 tracker 创建位置会 NPE; - 列表每轮全量重建无 diff,别假设”只刷新变化项”;
- 可搜分组
dependKey挂在开关上——排查”列表不见了”先看开关状态,再看 15s 新鲜窗。