06 - 典型页面全链路剖析:锁车控制页

本篇概览

前面几篇我们已经分别讲过「PreferenceController 范式」「路由框架」这些机制。这一篇换一种讲法——挑一个真实页面,把所有机制串成一条线,从用户点进入页面,到改一个车控开关,再到 UI 回流刷新,全链路走一遍。

选定页面settingsPage/micarLockSettings 模块下的「门窗锁控制页」(Fragment:VehicleLockSettingsFragment)。

为什么选它

  1. 它是车控类设置的「母版页」——一个 Fragment 上挂着二十多个 Controller,几乎涵盖了所有常见形态(开关型、Tab 型、跳转型、Slider 型)。看懂这一个页,其它车控页(灯光、驾驶、车身)都是同一套模板的复用。
  2. 它的开关型 Controller(如 WalkAwayLockController)极简,所有车控读/写/监听/可用性/回流逻辑都收敛在基类 BaseVehicleNewSwitchPrefController,读一个基类就把车控页 80% 的套路学完。
  3. 它同时呈现了「枚举型开关」(LockBeepController 把布尔开关映射成多态枚举车控值),可以对比理解「车控值 ≠ 布尔」的场景。

读完你能掌握什么

  • 看懂「首页 → 路由 → Activity → Fragment → XML → Controller → 车控接口 → 车控服务 → 回调 UI」这整条链路上每一段的真实代码位置和职责
  • 给锁车页新增一个「读车控属性 / 改车控属性 / 监听变化」的开关项,能立刻知道要在哪几个文件里加什么
  • 看到任何一个 xxxPrefController,能从它继承的基类一眼判断它的行为模式
  • 理解 AvailabilityStatusonHandlePropertyChangehandlePreferenceChangedsetVehicleProperty 这几个车控开发最核心的扩展点

1. 入口:用户怎么进到锁车页

锁车页一共有三种典型入口,最终都汇聚到同一个 Fragment 实例。

1.1 路由路径:一个常量 + 一个注解

锁车页的路由 URI 是一个字符串常量,定义在:

base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/CarSettingsJump.kt:78-81

// 门窗锁页跳转相关
object VehicleLockControlSettings {
    const val VEHICLE_LOCK_CONTROL_URI_PATH =
        "$SETTING_SUBPAGE_URI_PREFIX$PAGE_VEHICLE_LOCK"
}

其中前缀 SETTING_SUBPAGE_URI_PREFIX"micarsettings://homepage/?subPage="(同文件第 17 行),PAGE_VEHICLE_LOCK 是页面别名常量。最终拼出来的 URI 形如:

micarsettings://homepage/?subPage=vehicle_lock

这个 URI 就是锁车页的「身份证」。

1.2 三种入口殊途同归

flowchart LR
    A1["首页一级菜单<br/>点击「门窗锁」"] --> R["Router 路由表<br/>匹配 VEHICLE_LOCK_CONTROL_URI_PATH"]
    A2["语音/外部 App<br/>发送 deeplink Intent"] --> A2b["DeeplinkActivity<br/>(透明中转)"] --> R
    A3["语音搜索<br/>'打开离车自动锁车'"] --> A3b["VoiceSearchManager<br/>构造 extras"] --> R
    R --> F["VehicleLockSettingsFragment<br/>@RouterProvider(path=...)"]
    F --> H["handleJump(uri)<br/>处理滚动到指定项"]
  • 首页菜单点击BaseCarSettingsActivity.launchFragment(fragment) 直接把 VehicleLockSettingsFragment 实例压栈,详见 app/.../BaseCarSettingsActivity.java:367-380
  • 外部 URI / 语音:进入 app/.../miauto/common/DeeplinkActivity.kt——一个 alpha=0 的透明中转 Activity,它把 Intent extras 转交给 HomepageActivity,再由 Router 分发。DeeplinkActivity 关键代码在第 28-43 行,onResume 延迟 20ms 后 handleVoiceSearchExtra(),最终 startActivity(HomepageActivity intent)
  • 语音直达具体项:通过 VoiceSearchManager.createVoiceSearchExtras 构造带 widgetKey 的 extras,仍走 HomepageActivity 入口。

1.3 路由命中:Fragment 上的 @RouterProvider

VehicleLockSettingsFragment 类头上的三个注解决定了它的路由身份:

settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/vehicle/VehicleLockSettingsFragment.java:55-59

@MonitorFragment                       // 数据上报埋点
@VoiceSearchProvider                   // 允许被语音搜索框架解析
@Module(SettingsConstant.TAG_LOCK)     // 归属「锁车」业务模块
@RouterProvider(path = VEHICLE_LOCK_CONTROL_URI_PATH)  // ← 路由表登记
public class VehicleLockSettingsFragment extends TopLevelSettingsFragment
        implements IPageRouteHandler {
    ...
}

@RouterProviderrouterApt 在编译期扫描,生成 RouterHelper / RouterTabs(路径 → 类名映射表)。运行期 Router 单例启动时通过反射调用 RouterHelper.merge() 把所有路由项合并进内存,详见 settingsCommon/plugin/router/routerManager/.../Router.java:46-62

private Router() {
    Class clazz = Class.forName(ROUTER_HELPER_PATH); // 编译期生成的 RouterHelper
    Method method = clazz.getDeclaredMethod("merge");
    method.invoke(clazz);                             // 合并路由表
    Iterator<String> iterator = RouterTabs.tabs().values().iterator();
    while (iterator.hasNext()) {
        mControllerNameHash.add(iterator.next().hashCode());
    }
}

Fragment onResume 时,Router.FragmentCallback.onFragmentResumed 会拿当前 URI 去 RouterTabs.tabs() 里匹配,命中后调用 IPageRouteHandler.handleJump(uri),见 Router.java:194-254。锁车页的 handleJump 实现很简单——把 URI 里的滚动锚点交给 handleScrollUri,让页面滚到指定项:

VehicleLockSettingsFragment.java:241-249

@Override
public void handleJump(Uri data) {
    MLog.d("handleJump uri = " + data);
    if (data == null) return;
    handleScrollUri(data);  // 支持「语音直达某一项并高亮滚动」
}

📌 新手记住:在锁车页加新功能时,永远不需要改路由层。路由只负责「把外部 URI 变成 Fragment 实例 + 一个 handleJump 回调」,至于跳转后页面要做什么,是 Fragment 自己决定的。


2. Activity 层:谁承载 Fragment

锁车页所在 Activity 是 HomepageActivity(首页 Activity,多 Fragment 模式)。它继承自:

app/src/main/java/com/android/car/settings/common/BaseCarSettingsActivity.java

关键流程在 onCreate(第 149-194 行):

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.car_setting_activity);
    // ...
    if (mHasNewIntent) {
        Fragment initFragment = getInitialFragment();   // 子类决定首个 Fragment
        launchIfDifferent(initFragment);                // 把它压入 fragment_container
        // ...
    }
}

launchFragment 会根据当前是「单窗格 / 多窗格」决定是新开 Activity 还是替换 container(第 367-380 行):

@Override
public void launchFragment(Fragment fragment) {
    if (mIsSinglePane) {
        Intent intent = SubSettingsActivity.newInstance(this, fragment);
        startActivity(intent);                       // 独立 Activity 承载
    } else {
        launchIfDifferent(fragment);                 // 多窗格下原地替换
    }
}

如果走独立 Activity,承载的是 SubSettingsActivity——它本身几乎没逻辑,只是把 Intent 里携带的 Fragment 类名反序列化出来(SubSettingsActivity.java:49-61):

@Override
protected Fragment getInitialFragment() {
    String fragmentClass = getIntent().getStringExtra(KEY_SUB_SETTINGS_FRAGMENT);
    Bundle fragmentArgs = getIntent().getBundleExtra(KEY_SUB_SETTINGS_FRAGMENT_ARGS);
    Fragment fragment = getSupportFragmentManager().getFragmentFactory()
            .instantiate(getClassLoader(), fragmentClass);
    fragment.setArguments(fragmentArgs);
    return fragment;
}

📌 新手记住:车机大屏上锁车页一般走多窗格模式(mIsSinglePane=false),Fragment 被 replaceR.id.fragment_container 里。SubSettingsActivity 只在小屏 / 独立窗口场景出现。两条路最终都是「一个 Fragment 实例被压进布局」,所以业务代码无感。


3. Fragment 层:VehicleLockSettingsFragment

锁车页 Fragment 的核心代码非常简短,因为它把「显示什么、隐藏什么」拆给了 XML,把「怎么读写车控」拆给了 Controller。

VehicleLockSettingsFragment.java:59-80 是整个类最关键的几行:

public class VehicleLockSettingsFragment extends TopLevelSettingsFragment
        implements IPageRouteHandler {
 
    private BaseRightFragment mRightFragment;
 
    @Override
    public String getPageAlias() {
        return PageAlias.PAGE_VEHICLE_LOCK;          // 页面别名,用于埋点/路由识别
    }
 
    @Override
    @XmlRes
    public int getPreferenceScreenResId() {
        return R.xml.miauto_vehicle_lock_ctrl_settings_fragment;  // ← 唯一的 XML 真相
    }
 
    @Override
    public BaseRightFragment getRightFragment() {
        if (mRightFragment == null) {
            mRightFragment = new DoorAndWindowLockRightFragment(); // 右侧车模/可视化
        }
        return mRightFragment;
    }
    // ...
}

三件事:

  1. 页面别名 PAGE_VEHICLE_LOCK——埋点、首页高亮、锚点跳转都靠它。
  2. getPreferenceScreenResId() 返回 R.xml.miauto_vehicle_lock_ctrl_settings_fragment,告诉父类「我这个页面长这样」。
  3. getRightFragment() 返回右侧的 DoorAndWindowLockRightFragment——这是车控页常见的「左侧菜单 + 右侧车模示意图」布局,右侧负责显示车门/车窗状态的车模图(详见 DoorAndWindowLockRightFragment.kt,本篇不展开)。

Fragment 是怎么被「装好」的

TopLevelSettingsFragmentSettingsFragmentBaseXmlParserSettingsFragment,真正的装配逻辑在 BaseXmlParserSettingsFragment.onAttachbase/settingsBaseUi/.../common/BaseXmlParserSettingsFragment.java:274-334):

@Override
public void onAttach(Context context) {
    super.onAttach(context);
    // ... 校验 Activity 实现 UxRestrictionsProvider / FragmentHost ...
    int xmlResId = getPreferenceScreenResId();
    // 一次性解析 XML,提取 key / controller / dependKey / hiddenFeatures
    List<Bundle> prefMetadataList = PreferenceXmlParser.extractMetadata(...);
 
    // 用反射把 XML 里声明的 controller 类名实例化
    mPreferenceControllers.addAll(
            PreferenceControllerListHelper.getPreferenceControllersFromMetadata(
                    styledContext, prefMetadataList, /* fragmentController= */ this,
                    mUxRestrictions));
    // 把每个 controller 注册成 Lifecycle 观察者,后续 onCreate/onStart 会自动回调
    mPreferenceControllers.forEach(controller -> {
        lifecycle.addObserver(controller);
        // ...
    });
}

📌 新手记住:Fragment 类本身几乎不写业务逻辑。它做三件事就够:① 声明 XML、② 声明页面别名、③ 决定右侧 Fragment。剩下的事,XML 决定显示什么、Controller 决定怎么干


4. XML 层:声明式拼装页面

XML 文件是锁车页的「设计稿」。打开 settingsPage/micarLockSettings/src/main/res/xml/miauto_vehicle_lock_ctrl_settings_fragment.xml,你会看到一个 PreferenceScreen,里面挂着一堆 MiCarPreferenceCategory(分组),每个分组下挂着各种自定义 Preference,每个 Preference 通过 settings:controller="全类名" 把行为委托给一个 Controller

挑两段关键片段看:

<!-- 4.1 童锁 & 车窗锁,开关型,挂在 ButtonTogglePreference 上 -->
<com.android.car.settings.miauto.preferences.ButtonTogglePreference
    android:key="@string/pk_settings_vehicle_quick_ctrl_locks"
    android:layout="@layout/micar_ui_pref_button_layout"
    settings:debounceEnable="true"
    settings:controller="com.android.car.settings.miauto.vehicle.VehicleLockToggleCtrlPreferenceController" />
 
<!-- 4.2 离车自动锁车,布尔开关,最典型形态 -->
<com.android.car.settings.miauto.preferences.MiCarNewSwitchPreference
    android:key="@string/pk_settings_lock_walk_away_lock"
    android:title="@string/settings_lock_walk_away_lock"
    android:summary="@string/settings_lock_walk_away_lock_summary"
    settings:controller="com.android.car.settings.miauto.lock.WalkAwayLockController" />
 
<!-- 4.3 锁车时鸣笛,本质是「枚举型」开关:LIGHT / LIGHT_AND_HORN / LIGHTS_AND_TUNE -->
<com.android.car.settings.miauto.preferences.MiCarNewSwitchPreference
    android:key="@string/pk_settings_lock_lock_beep"
    android:title="@string/settings_lock_lock_beep"
    settings:controller="com.android.car.settings.miauto.lock.LockBeepController" />

三个值得关注的属性:

属性作用示例
android:keyPreference 唯一标识,必须与 Controller 构造参数 preferenceKey 对应(系统按 key 匹配 Controller 与 Preference)@string/pk_settings_lock_walk_away_lock
settings:controller该 Preference 由哪个 Controller 驱动,写全限定类名com.android.car.settings.miauto.lock.WalkAwayLockController
settings:dependKey依赖的另一个 Preference 的 key,用于「父开关关闭则子项隐藏」电动车门模式控制着「车门开启角度」的显隐

Controller 是怎么从字符串变成对象的?PreferenceControllerListHelper.createInstancebase/settingsBaseUi/.../common/PreferenceControllerListHelper.java:110-129):

private static PreferenceController createInstance(String controllerName, ...) {
    Class<?> clazz = Class.forName(controllerName);
    // 约定:所有 Controller 必须有这个 4 参数构造器
    Constructor<?> preferenceConstructor = clazz.getConstructor(
            Context.class, String.class, FragmentController.class, CarUxRestrictions.class);
    Object[] params = new Object[]{context, key, fragmentController, restrictionInfo};
    PreferenceController preferenceController =
            (PreferenceController) preferenceConstructor.newInstance(params);
    Router.getInstance().holdController(preferenceController, fragmentController.getHostFragment());
    preferenceController.setDependKey(dependKey);
    return preferenceController;
}

这段反射代码揭示了一个强制约定:锁车页里所有 Controller(包括你将来新加的),都必须有一个 (Context, String, FragmentController, CarUxRestrictions) 构造器。回头去看 WalkAwayLockController.kt:10-15,它的构造器签名正是这四个参数。

4.4 一个页面二十多个 Controller,怎么知道哪些该显示

锁车页 XML 里挂了二十多个 Preference,但车型不同,支持的配置也不同——增程车才有「解锁加油口」,Kunlun 车型才有「车顶帐篷」。Fragment 用一个非常直白的方式做过滤:返回「要移除的 key 列表」,父类会把这些 key 对应的 Preference 从屏幕上删掉。

VehicleLockSettingsFragment.java:83-238,摘一段:

@Override
public List<Integer> getPreferenceKeyResIdsToRemove() {
    List<Integer> ids = new ArrayList<>();
    // 非增程车型:隐藏加油口
    if (CarConfigManager.INSTANCE.getPropulsionTypeConfig()
            != Driving.PropulsionTypeConfig.EXTENDED_RANGE_ELECTRIC) {
        ids.add(R.string.pk_settings_fuel_lid);
    }
    // 海外版:隐藏自定义锁车音效(无 toybox)
    if (DeviceUtil.isOverseasRegion()) {
        ids.add(R.string.pk_settings_lock_lock_beep_custom);
    }
    // 不支持电动门:隐藏整个「电动门」分组
    if (!MiCarSettings.isElectricDoorSupported()) {
        ids.add(R.string.pk_settings_quickctrl_door_category);
    }
    // ... 几十个配置字 / 车型判断 ...
    return ids;
}

📌 新手记住:在锁车页加新功能,三步:① XML 里加一个 Preference + settings:controller="全类名";② 实现这个 Controller 类(参考下一节);③ 如果某些车型不该显示,在 getPreferenceKeyResIdsToRemove() 里加一个判断,把 key 扔进列表。


5. Controller 层:锁车页的「行为大脑」

这是这一篇最重要的一节。锁车页有二十多个 Controller,但它们的行为模式其实只有少数几种。先看懂最简单的 WalkAwayLockController,再看枚举型 LockBeepController,就能举一反三。

5.1 类继承关系(先看全局)

classDiagram
    direction LR
    class PreferenceController~V~ {
        <<abstract>>
        +getAvailabilityStatus() int
        #onCreateInternal()
        #handlePreferenceChanged(V, Object) boolean
        #refreshUi()
    }
    class CarPropertyMgrPreferenceController~V~ {
        <<abstract>>
        #SettingsCarPropertyManager mCarPropertyManager
        +onPropertyChanged(CarPropertyValue)
        +onSetPropertyTimeout(int,int)
        #setVehicleProperty(int,Object,int) boolean
        #abstract getPropertyIdSet() ArraySet
    }
    class BaseVehicleNewSwitchPrefController {
        <<abstract>>
        #onHandlePropertyChange(CarPropertyValue, boolean)
        #handlePreferenceChanged(MiCarNewSwitchPreference, Any) boolean
        #setCarProperty(boolean)
        #isPropertyOpen(int) boolean
    }
    class BaseLockBeepController {
        <<abstract>>
        #isValidPropVal(CarPropertyValue) boolean
        #isPropertyOpen(int) boolean
        #getPropertyVal(boolean) int
    }
    class WalkAwayLockController {
        +getPropertyIdSet() ArraySet
    }
    class PGearAutoLockController {
        +getPropertyIdSet() ArraySet
    }
    class LockBeepController {
        +getPropertyIdSet() ArraySet
        +isPropertyOpen(int) boolean
    }

    PreferenceController <|-- CarPropertyMgrPreferenceController
    CarPropertyMgrPreferenceController <|-- BaseVehicleNewSwitchPrefController
    BaseVehicleNewSwitchPrefController <|-- BaseLockBeepController
    BaseVehicleNewSwitchPrefController <|-- WalkAwayLockController
    BaseVehicleNewSwitchPrefController <|-- PGearAutoLockController
    BaseLockBeepController <|-- LockBeepController

记住这个层级,它就是车控类 Controller 的「家谱」:

  • PreferenceController:所有 Controller 的根,定义生命周期 + AvailabilityStatus
  • CarPropertyMgrPreferenceController:所有「需要读写车控」的 Controller 的根,封装信号注册/回调/超时
  • BaseVehicleNewSwitchPrefController:开关型 Controller 的根,封装「点击 → 下发 → 回流 → 刷新勾选」
  • 具体子类只负责「声明我关心哪个车控属性 ID」

5.2 WalkAwayLockController:极简的开关型

完整代码只有 26 行settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/lock/WalkAwayLockController.kt):

class WalkAwayLockController(
    context: Context?,
    preferenceKey: String?,
    fragmentController: FragmentController?,
    uxRestrictions: CarUxRestrictions?
) : BaseVehicleNewSwitchPrefController(
    context, preferenceKey, fragmentController, uxRestrictions
) {
    override fun getPropertyIdSet(): ArraySet<Int> {
        val ids = ArraySet<Int>()
        ids.add(MiCarPropertyIds.LockCtrl.WALK_AWAY_LOCK)   // 离车自动锁车属性 ID
        return ids
    }
}

它只做一件事:告诉基类「我关心 WALK_AWAY_LOCK 这个车控属性」。所有其它逻辑——什么时候 enable、点击怎么下发、信号回流怎么刷新 UI——全部继承自基类。

MiCarPropertyIds.LockCtrl.WALK_AWAY_LOCK 的真实值(base/settingsBaseLib/.../vehicle/MiCarPropertyIds.java:879-897):

public static final int WALK_AWAY_LOCK    = Door.LEAVING_AUTO_LOCK;     // 离车自动锁车
public static final int P_GEAR_AUTO_LOCK  = Door.PARK_AUTO_UNLOCK;      // P 挡解锁
public static final int LOCK_BEEP         = Door.LOCK_INDICATION;       // 锁车鸣笛
public static final int DOOR_CINCH_ENABLE_STATUS = Door.DOOR_CINCH_ENABLE_STATUS;

这些常量最终对应到车控 HAL 层的 Vehicle Property ID(由车控系统团队维护)。

5.3 基类做了什么:BaseVehicleNewSwitchPrefController

这是车控开关的 80% 套路所在。 它继承自 CarPropertyMgrPreferenceController<MiCarNewSwitchPreference>,下面三个方法覆盖了「读、写、回流」全链路。

A. 创建时读当前值(onCreateInternal

base/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/BaseVehicleNewSwitchPrefController.kt:199-216

override fun onCreateInternal() {
    super.onCreateInternal()
    // 注册一个「开关拦截器」,用于点击前置校验(例如行车中禁止切换)
    preference.setPreCheck(object : PreCheckSwitch.PreCheckInterceptor {
        override fun isPrevent(targetState: Boolean): Boolean {
            return preference.isEnabled && onPreCheck(targetState)
        }
        override fun onCallback() { onPrevent() }
    })
    // 用当前车控属性值初始化开关状态
    obtainSetPropertyId()?.let {
        preference.setChecked(isPropertyOpen(getIntProperty(it, areaId)))
    }
}

obtainSetPropertyId() 默认返回 getPropertyIdSet() 里第一个属性 ID(也就是子类声明的那个)。isPropertyOpen(int) 默认判断「值 == CommonParams.OnOff.ON」,所以 WalkAwayLock 这种「布尔型」车控完全不用重写。

B. 用户点击 → 下发车控(handlePreferenceChanged

BaseVehicleNewSwitchPrefController.kt:128-149

override fun handlePreferenceChanged(
    preference: MiCarNewSwitchPreference, newValue: Any
): Boolean {
    if (newValue is Boolean) {
        if (needSecondConfirm()) {
            showConfirmDialog(newValue)       // 需要二次确认的场景
            return false
        } else {
            // 埋点上报
            getTrackTipKey()?.let { ... TrackEvent.create(Event.CLICK, it).report() }
            setCarProperty(newValue)          // → 真正下发车控
        }
        return true
    }
    return super.handlePreferenceChanged(preference, newValue)
}

setCarProperty 把布尔值映射成车控值,再调基类的 setVehicleProperty(第 189-197 行):

protected open fun setCarProperty(isChecked: Boolean) {
    if (needIntermediateState()) {
        preference.isEnabled = false          // 切换中禁用,避免连点
    }
    setVehicleProperty(obtainSetPropertyId(), getPropertyVal(isChecked), areaId)
    updatePreferencesVisibleWhenStatusChange(isChecked)   // 联动子项显隐
}

getPropertyVal(boolean) 默认 isChecked ? OnOff.ON : OnOff.OFF,对布尔型车控不用改。

C. 信号回流 → 刷新 UI(onHandlePropertyChange

BaseVehicleNewSwitchPrefController.kt:48-68

override fun onHandlePropertyChange(
    propertyValue: CarPropertyValue<*>?,
    updateUIByPropVal: Boolean
) {
    // 同一信号不同 area 过滤(避免一个信号触发多个开关)
    if (VehiclePropertyUtil.getAreaId(propertyValue) != areaId) return
 
    val propVal = VehiclePropertyUtil.getIntPropertyValue(propertyValue)
    preference.isEnabled = VehiclePropertyUtil.isPropertyAvailable(propertyValue)
                && isInnerLogicAvailable(propertyValue)
    if (updateUIByPropVal) {
        preference.setChecked(isPropertyOpen(propVal))   // ← 把车控值反映到开关
        mIsPreventOperationWhenEnable = isPreventSwitchSlide(propVal)
    }
    if (needIntermediateState()) {
        preference.setLoading(isInLoadingState(propertyValue))   // 中间加载态
    }
    super.onHandlePropertyChange(propertyValue, updateUIByPropVal)
}

📌 新手记住:写一个新开关型车控 Controller,只需要三件事:① 继承 BaseVehicleNewSwitchPrefController;② 实现 getPropertyIdSet() 返回关心的属性 ID;③ 如果车控值不是简单布尔,重写 isPropertyOpen / getPropertyVal。仅此而已。

5.4 LockBeepController:枚举型开关的样板

「锁车时鸣笛」对应的车控属性 LOCK_BEEP 不是布尔,而是一个枚举:可以是 LIGHT(仅灯光)、LIGHT_AND_HORN(灯光+鸣笛)、LIGHTS_AND_TUNE(灯光+音效)。开关只能表达「开 / 关」,所以需要双向映射。

settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/lock/LockBeepController.kt

class LockBeepController(...) : BaseLockBeepController(...) {
    override fun getPropertyIdSet(): ArraySet<Int> {
        val ids = ArraySet<Int>()
        ids.add(MiCarPropertyIds.LockCtrl.LOCK_BEEP)
        return ids
    }
    override fun isPropertyOpen(propVal: Int): Boolean {
        // UI 开关勾选 ⇔ 车控值为「灯光+鸣笛」
        return propVal == Door.LockIndicationMode.LIGHT_AND_HORN
    }
}

更通用的逻辑收敛在中间基类 BaseLockBeepController.../lock/BaseLockBeepController.kt):

abstract class BaseLockBeepController(...) : BaseVehicleNewSwitchPrefController(...) {
 
    // 哪些值是「合法可见」的(保留值/不支持值会让 UI 保持上一状态)
    override fun isValidPropVal(vehicleProperty: CarPropertyValue<*>?): Boolean {
        val unlockState = VehiclePropertyUtil.getIntPropertyValue(it)
        return unlockState == Door.LockIndicationMode.LIGHT
            || unlockState == Door.LockIndicationMode.LIGHT_AND_HORN
            || unlockState == Door.LockIndicationMode.LIGHTS_AND_TUNE
    }
 
    // 开关勾选 ↔ LIGHT_AND_HORN,开关未勾选 ↔ LIGHT(只闪灯不响)
    override fun isPropertyOpen(propVal: Int): Boolean {
        return propVal == Door.LockIndicationMode.LIGHT_AND_HORN
    }
 
    // 用户切换开关时,把布尔翻译成车控枚举值下发
    override fun getPropertyVal(isChecked: Boolean): Int {
        return if (isChecked) Door.LockIndicationMode.LIGHT_AND_HORN
               else Door.LockIndicationMode.LIGHT
    }
}

对比 WalkAwayLock,多了一层「布尔 ↔ 枚举」映射,仅此而已。

5.5 Controller 的 AvailabilityStatus 状态流转

PreferenceController 用一个 @AvailabilityStatus 注解的 int 决定 Preference 的命运(base/settingsBaseUi/.../common/PreferenceController.java:117-148):

public static final int AVAILABLE = 0;                  // 正常可用
public static final int CONDITIONALLY_UNAVAILABLE = 1;  // 当前不可用(隐藏)
public static final int UNSUPPORTED_ON_DEVICE = 2;      // 设备永远不支持(隐藏)
public static final int DISABLED_FOR_PROFILE = 3;       // 当前用户不能改(隐藏)
public static final int AVAILABLE_FOR_VIEWING = 4;      // 可见但不可改(置灰)

锁车页的开关型 Controller 默认 AVAILABLE,但页面是否真的可见,还取决于 Fragment 的 getPreferenceKeyResIdsToRemove()(第 4.4 节);是否真的可点,还取决于信号回流时 onHandlePropertyChange 设置的 preference.isEnabled(第 5.3.C)。

stateDiagram-v2
    [*] --> UNSUPPORTED_ON_DEVICE: 车型/配置字不支持\n(Fragment 把 key 加入 RemoveList)
    [*] --> AVAILABLE: 车型支持
    UNSUPPORTED_ON_DEVICE --> [*]: onCreate 时 Preference.setVisible(false)\n生命周期方法都不回调

    AVAILABLE --> DISABLED: 信号回流时\nisPropertyAvailable=false\n或 isValidPropVal=false
    DISABLED --> AVAILABLE: 信号恢复\n(preference.isEnabled=true)

    AVAILABLE --> SWITCH_ON: 车控值 == ON\n(preference.setChecked(true))
    AVAILABLE --> SWITCH_OFF: 车控值 == OFF\n(preference.setChecked(false))

    SWITCH_ON --> SWITCH_OFF: 用户点击 / 车控主动上报
    SWITCH_OFF --> SWITCH_ON: 用户点击 / 车控主动上报

    SWITCH_ON --> LOADING: 下发中(needIntermediateState)\npreference.setLoading(true)
    LOADING --> SWITCH_ON: 收到信号回流\nonHandlePropertyChange
    LOADING --> SWITCH_OFF: 超时回滚\nonSetPropertyTimeout

注意两条隐式规则:

  1. UNSUPPORTED_ON_DEVICE 会跳过所有生命周期回调PreferenceController.java:401-412),所以这种状态的 Controller 不会去注册车控监听,节省开销。

  2. 车控类 Controller 的 enable/disable 不走原生 AVAILABLE_FOR_VIEWING,而是由 onHandlePropertyChange 根据 isPropertyAvailable 直接改 preference.isEnabledPreferenceController.refreshUi() 第 369 行注释写明了这一点:

    // Note: 我们根据信号来决定 preference 是否 enable,故去掉此处原生逻辑
    // mPreference.setEnabled(getAvailabilityStatus() != AVAILABLE_FOR_VIEWING);

📌 新手记住:写新 Controller 时不要在 getAvailabilityStatus() 里写复杂的车控可用性判断——那是 onHandlePropertyChange 的职责。getAvailabilityStatus 一般只用来处理「这个设备/这个用户根本不该看见这个项」的静态判断。


6. 车辆接口层:从 Controller 到底层 HAL

Controller 调到的最直接类是 SettingsCarPropertyManager车控层 facade,单例封装)。

6.1 分层关系

flowchart TD
    subgraph 业务层
        WC["WalkAwayLockController<br/>(具体子类)"]
        BC["BaseVehicleNewSwitchPrefController"]
        CM["CarPropertyMgrPreferenceController<br/>(CarPropertyManagerCallback 实现)"]
    end
    subgraph 车控Facade
        SC["SettingsCarPropertyManager<br/>注册/解注回调、缓存、超时监测"]
    end
    subgraph 车控底层封装
        VC["VehicleControlManager<br/>extends NewBaseCarServiceManager"]
        LC["LocalCarManager / CarPropertyCacheManager"]
    end
    subgraph AOSP
        CP["CarPropertyManager<br/>(Car.PROPERTY_SERVICE)"]
        CH["CarService(HAL)<br/>车辆属性实际读写"]
    end

    WC --> BC
    BC --> CM
    CM -- "setVehicleProperty / getProperty / registerPropertyCallback" --> SC
    SC -- "setIntProperty / getProperty / registerPropertyCallback" --> VC
    VC --> LC
    LC --> CP
    CP -- "Binder" --> CH

6.2 真实代码位置

  • CarPropertyMgrPreferenceControllerbase/settingsVehicleLib/.../common/CarPropertyMgrPreferenceController.java)持有 mCarPropertyManager: SettingsCarPropertyManager,在 onCreateInternal(第 511-518 行)创建实例:
    mCarPropertyManager = new SettingsCarPropertyManager(getContext(),
            getFragmentController().getSettingsLifecycle());
    mCarPropertyManager.setHolderName(getImplClassName() + "@" + Objects.hashCode(this));
  • SettingsCarPropertyManagerbase/settingsVehicleLib/.../vehicle/SettingsCarPropertyManager.kt)持有 mCarPropMgr: VehicleControlManager(第 96-98 行),所有方法最终都委托过去。
  • VehicleControlManagerbase/settingsVehicleLib/.../vehicle/VehicleControlManager.java)继承 NewBaseCarServiceManager<CarPropertyManager>getServiceName() 返回 Car.PROPERTY_SERVICE(第 51-54 行):
    @Override
    protected String getServiceName() {
        return Car.PROPERTY_SERVICE;   // 拿到 AOSP 的 CarPropertyManager
    }
  • 真正的读写在 VehicleControlManager 第 114-127 行:
    public void setIntPropertyBySync(int propertyId, int propVal, int areaId) {
        try {
            mCarServiceManager.setIntProperty(propertyId, areaId, propVal);
        } catch (Exception e) {
            MLog.e(e, "Can't set %d for %s, Car service disconnect.", propVal, toHexId(propertyId));
        }
    }

6.3 SettingsCarPropertyManager 三件关键事

它不只是个转发器,还做了三件 Controller 不关心但必须有的「脏活」:

  1. 信号监听注册registerPropertyCallback(ids) 把 Controller 关心的所有属性 ID 注册到底层 CarPropertyManager(第 237-255 行),底层信号一变,回调进 SettingsCarPropertyEventCallback.onChangeEvent(第 662-685 行)。
  2. 超时监测:每次 setCarPropertystartTimeOutMonitor(第 530-562 行)开一个 Handler 延时任务。如果在窗口期内没收到回流信号,触发 onSetPropertyTimeout 让 Controller 回滚 UI。
  3. 值缓存mPropertiesCachepropId#areaId 缓存最近一次的 CarPropertyValue,避免频繁跨进程查询(第 580-609 行)。

📌 新手记住:业务代码永远不要直接 new VehicleControlManager,也永远不要直接碰 AOSP 的 CarPropertyManager。统一通过 SettingsCarPropertyManager 走。它对外的 API 就是 getProperty / setCarProperty / registerPropertyCallback 三件套,外加一个 CarPropertyManagerCallback 回调接口(base/settingsVehicleLib/.../vehicle/CarPropertyManagerCallback.java)。


7. 数据回流:车控属性变化怎么回到 UI

现在把第 5、6 节的「写」反向走一遍——车控系统主动上报属性变化,最终是怎么让 MiCarNewSwitchPreference 的勾选状态改变的。

7.1 回调接口:CarPropertyManagerCallback

CarPropertyMgrPreferenceController 实现了这个接口(base/settingsVehicleLib/.../vehicle/CarPropertyManagerCallback.java):

public interface CarPropertyManagerCallback {
    default void onPropertyManagerPrepared() {}      // CarService 连接就绪
    default void onPropertyChanged(CarPropertyValue<?> value) {}  // 信号变化
    default void onSetPropertyTimeout(int propId, int areaId) {}  // 下发超时
    default void onSetPropertyError(int propId, int areaId) {}    // 下发错误
    default CarPropertyTimeoutFlag getTimeoutFlag(CarPropertyValue<?> value) { ... }
    default boolean isIgnoreCache(CarPropertyValue<?> value) { return false; }
}

7.2 信号回流的完整路径

底层 CarPropertyManagerCarPropertyEventCallback.onChangeEvent 被触发后(SettingsCarPropertyManager.kt:662-685):

override fun onChangeEvent(carPropertyValue: CarPropertyValue<*>?) {
    carPropertyValue?.let {
        // ... mock 测试分支 ...
        cachePropVal(it)
        processTimeout(it, mCallback?.getTimeoutFlag(it))   // 取消或重启超时监测
        if (mObservedPropertyIdSet.contains(it.propertyId)) {
            mCallback?.onPropertyChanged(it)   // → 业务层回调
        }
    }
}

CarPropertyMgrPreferenceController.onPropertyChanged(第 346-384 行)做了几个判断(依赖信号映射、是否忽略此次回流、是否要更新 UI),最后调到 onHandlePropertyChange(propertyValue, shouldUpdateUI)——这就是第 5.3.C 节看到的方法。

7.3 三类回流场景

场景触发Controller 回调UI 表现
用户自己点击开关成功下发 → HAL 上报新值onPropertyChangedonHandlePropertyChangesetChecked(新值)
别处修改了同一属性(如车控面板)HAL 主动上报同上UI 自动同步
下发超时(信号没回来)mTimeoutHandler 触发onSetPropertyTimeoutrestorePropertyToPrevious开关回退到旧值
下发错误(属性不可写等)onErrorEventonSetPropertyErrorrestorePropertyToPrevious开关回退到旧值

CarPropertyMgrPreferenceController.java:392-408 的超时/错误处理:

@Override
public void onSetPropertyTimeout(int propId, int areaId) {
    restorePropertyToPrevious(propId, areaId);   // 把 UI 回退到缓存里的旧值
    updateChildVisibleWhenSetPropertyFail(propId, areaId);
}
 
@Override
public void onSetPropertyError(int propId, int areaId) {
    if (shouldIgnorePropertyFeedback(propId, areaId)) {
        onIgnorePropertyFeedback(propId, areaId);
        return;
    }
    restorePropertyToPrevious(propId, areaId);
    updateChildVisibleWhenSetPropertyFail(propId, areaId);
}

restorePropertyToPrevious(第 125-133 行)会重新读一次缓存里的 CarPropertyValue,再喂回 onHandlePropertyChange,让 UI 回到下发前的状态。

📌 新手记住:你写 Controller 时不需要自己处理失败回滚——基类已经做了。你只需要在 onHandlePropertyChange 里正确地「把车控值翻译成 UI 状态」,剩下的事基类全包了。


8. 全链路串联:一次「打开离车自动锁车」的完整旅程

把前 7 节拼起来。下面这张时序图就是这一篇的核心,建议反复看。

sequenceDiagram
    autonumber
    actor U as 用户
    participant H as HomepageActivity<br/>(BaseCarSettingsActivity)
    participant R as Router
    participant F as VehicleLockSettingsFragment<br/>(IPageRouteHandler)
    participant XML as miauto_vehicle_lock_ctrl_...xml
    participant HL as PreferenceControllerListHelper
    participant WC as WalkAwayLockController
    participant BC as BaseVehicleNewSwitchPrefController
    participant CM as CarPropertyMgrPreferenceController
    participant SC as SettingsCarPropertyManager
    participant VC as VehicleControlManager
    participant CP as CarPropertyManager<br/>(AOSP)
    participant HAL as CarService/HAL

    Note over U,HAL: 阶段 A:进入锁车页
    U->>H: 点击「门窗锁」入口
    H->>H: onCreate(): getInitialFragment() = VehicleLockSettingsFragment
    H->>F: launchFragment(VehicleLockSettingsFragment)
    F->>F: onAttach(): 解析 XML 提取 metadata
    F->>HL: getPreferenceControllersFromMetadata(xml)
    HL->>HL: 反射创建 WalkAwayLockController(Context,key,frag,UxR)
    HL-->>F: 返回 20+ 个 Controller 列表
    F->>F: lifecycle.addObserver(每个 Controller)

    Note over WC,HAL: 阶段 B:Controller 初始化( onCreate )
    WC->>BC: onCreateInternal()
    BC->>CM: super.onCreateInternal(): new SettingsCarPropertyManager
    BC->>WC: getIntProperty(WALK_AWAY_LOCK)
    WC->>CM: getIntProperty(propId)
    CM->>SC: getProperty(propId)
    SC->>VC: getProperty(propId, areaId)
    VC->>CP: mCarServiceManager.getProperty(...)
    CP-->>VC: CarPropertyValue
    VC-->>SC: CarPropertyValue
    SC-->>CM: 缓存并返回
    CM-->>BC: 当前属性值(ON/OFF)
    BC->>BC: preference.setChecked(isPropertyOpen(value))
    BC->>CM: registerPropertyCallback({WALK_AWAY_LOCK})
    CM->>SC: setCarManagerCallback(this)
    SC->>VC: registerCarServiceListener
    Note over SC: CarService 就绪后触发 onPropertyManagerPrepared<br/>→ 真正 registerPropertyCallback(ids)

    Note over U,HAL: 阶段 C:用户切换开关
    U->>BC: 点击 MiCarNewSwitchPreference (newValue=true)
    BC->>BC: handlePreferenceChanged(preference, true)
    BC->>BC: setCarProperty(true)
    BC->>WC: getPropertyVal(true) → CommonParams.OnOff.ON
    BC->>CM: setVehicleProperty(WALK_AWAY_LOCK, ON, areaId)
    CM->>SC: setCarProperty(propId, ON, areaId)
    SC->>VC: setIntProperty(propId, ON, areaId)
    VC->>CP: mCarServiceManager.setIntProperty(...)
    CP->>HAL: Binder → CarService → 写车辆属性
    SC->>SC: startTimeOutMonitor(propId, ON, areaId)
    Note over BC: 如果 needIntermediateState<br/>preference.isEnabled=false(中间加载态)

    Note over HAL,BC: 阶段 D:信号回流刷新 UI
    HAL-->>CP: 车控属性变化事件
    CP-->>VC: onChangeEvent(CarPropertyValue)
    VC-->>SC: (经 CarPropertyCacheManager 转发)
    SC->>SC: cachePropVal + processTimeout(取消超时计时)
    SC->>CM: onPropertyChanged(CarPropertyValue)
    CM->>CM: 检查 shouldUpdateUIByPropVal / 依赖映射
    CM->>BC: onHandlePropertyChange(value, true)
    BC->>BC: areaId 校验、isPropertyAvailable
    BC->>BC: preference.setChecked(isPropertyOpen(ON))
    BC-->>U: 开关 UI 切换为「开启」状态 ✅

    Note over U,HAL: 阶段 E(异常):下发超时
    SC->>SC: mTimeoutHandler 触发
    SC->>CM: onSetPropertyTimeout(propId, areaId)
    CM->>CM: restorePropertyToPrevious(propId, areaId)
    CM->>BC: onHandlePropertyChange(旧缓存值)
    BC->>BC: preference.setChecked(false)  ⏪ 回滚

把这张图读成一句话

「点击 → Controller.handlePreferenceChanged → SettingsCarPropertyManager.setCarProperty → VehicleControlManager.setIntProperty → AOSP CarPropertyManager → Binder 进 CarService → 写车辆属性;HAL 上报 → CarPropertyEventCallback.onChangeEvent → SettingsCarPropertyManager.onPropertyChanged → Controller.onHandlePropertyChange → preference.setChecked 刷新 UI」

整条链路里,WalkAwayLockController 这个具体类只贡献了「属性 ID」这一行代码。


9. 「Fragment + N 个 Controller」结构图

最后用一张结构图收尾,让你一眼看清锁车页的组成:

flowchart TB
    subgraph Activity
        HA["HomepageActivity<br/>(BaseCarSettingsActivity)"]
    end

    subgraph "VehicleLockSettingsFragment (左侧菜单)"
        F["VehicleLockSettingsFragment<br/>extends TopLevelSettingsFragment<br/>implements IPageRouteHandler"]
        F -->|"getPreferenceScreenResId()"| XML["R.xml.miauto_vehicle_lock_ctrl_settings_fragment"]
        F -->|"getPreferenceKeyResIdsToRemove()"| RM["按车型/配置字移除项<br/>(燃油车隐藏加油口等)"]
        F -->|"handleJump(uri)"| HS["handleScrollUri<br/>(语音直达滚动)"]
    end

    subgraph "DoorAndWindowLockRightFragment (右侧车模)"
        RF["DoorAndWindowLockRightFragment<br/>extends BaseRightFragment"]
    end

    HA --> F
    HA --> RF

    subgraph "XML 声明的 Controller 群"
        XML --> C1["WalkAwayLockController<br/>(离车自动锁车·布尔)"]
        XML --> C2["PGearAutoLockController<br/>(P挡解锁·布尔)"]
        XML --> C3["LockBeepController<br/>(锁车鸣笛·枚举)"]
        XML --> C4["BaseLockBeepController<br/>(中间基类)"]
        XML --> C5["VehicleDoorToggleCtrlPreferenceController<br/>(车门·按钮型)"]
        XML --> C6["DoorModeLockController<br/>(车门模式·Tab型)"]
        XML --> C7["ElectricReleaseHeadGateController<br/>(前备箱·电释放)"]
        XML --> C8["HeadGateHeightPrefController<br/>(前备箱高度·Slider)"]
        XML --> C9["RoofPreferenceController<br/>(升降车顶)"]
        XML --> C10["... 共 20+ Controller"]
    end

    subgraph "公共基类"
        BC["BaseVehicleNewSwitchPrefController<br/>读写/回流/二次确认/中间态"]
        CM["CarPropertyMgrPreferenceController<br/>注册监听/回调/超时/缓存"]
        PC["PreferenceController<br/>生命周期/AvailabilityStatus"]
        BC --> CM
        CM --> PC
    end

    C1 --> BC
    C2 --> BC
    C3 --> C4
    C4 --> BC
    C5 --> CM
    C6 --> CM
    C7 --> CM
    C8 --> CM
    C9 --> CM

    subgraph "车控 Facade"
        SC["SettingsCarPropertyManager<br/>统一入口"]
    end
    CM --> SC

10. 实战清单:在锁车页加一个新开关

把全篇要点收成一个可操作的 checklist:

  1. 明确车控属性:拿到系统车控团队给的 propertyId(如 Door.XXX_YYY),把它加到 MiCarPropertyIds.LockCtrl 常量类(base/settingsBaseLib/.../vehicle/MiCarPropertyIds.java)。
  2. 写 Controller:在 settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/lock/ 下新建 XxxLockController.kt
    • 继承 BaseVehicleNewSwitchPrefController
    • 构造器签名 (Context, String, FragmentController, CarUxRestrictions)(必填)
    • 实现 getPropertyIdSet() 返回 {你的属性ID}
    • 如果是枚举型,重写 isPropertyOpen / getPropertyVal / isValidPropVal
  3. 加 Preference:在 miauto_vehicle_lock_ctrl_settings_fragment.xml 里加一个 <MiCarNewSwitchPreference>,写 android:keysettings:controller="全类名"
  4. 配置字/车型过滤:在 VehicleLockSettingsFragment.getPreferenceKeyResIdsToRemove() 里加判断,把不该显示的车型的 key 加进移除列表。
  5. 验证回流:手动改车控值(用车控命令工具或实车),观察开关是否同步切换;点开关,观察是否真正下发(看 logcat 中 >>> Done set propVal 字样)。
  6. 超时/失败:故意拔掉车控连接,点开关,确认 UI 会自动回滚(基类 onSetPropertyTimeout 兜底)。
  7. 埋点:如果需要,重写 getTrackTipKey / getTrackParamKey 即可自动上报点击事件。

11. 关键文件速查表

角色文件(相对仓库根)
FragmentsettingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/vehicle/VehicleLockSettingsFragment.java
页面 XMLsettingsPage/micarLockSettings/src/main/res/xml/miauto_vehicle_lock_ctrl_settings_fragment.xml
布尔开关 Controller 样板settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/lock/WalkAwayLockController.kt
枚举开关 Controller 样板settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/lock/LockBeepController.kt
枚举开关中间基类settingsPage/micarLockSettings/src/main/java/com/android/car/settings/miauto/lock/BaseLockBeepController.kt
开关 Controller 根基类base/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/BaseVehicleNewSwitchPrefController.kt
车控通用基类base/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/common/CarPropertyMgrPreferenceController.java
Controller 根基类base/settingsBaseUi/src/main/java/com/android/car/settings/common/PreferenceController.java
车控 Facadebase/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/SettingsCarPropertyManager.kt
车控底层封装base/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/VehicleControlManager.java
车控回调接口base/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/CarPropertyManagerCallback.java
路由 URI 常量base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/CarSettingsJump.kt
路由运行期settingsCommon/plugin/router/routerManager/src/main/java/com/android/car/settings/router/manager/Router.java
Controller 反射实例化base/settingsBaseUi/src/main/java/com/android/car/settings/common/PreferenceControllerListHelper.java
入口 Activityapp/src/main/java/com/android/car/settings/common/BaseCarSettingsActivity.java
Deeplink 中转app/src/main/java/com/android/car/settings/miauto/common/DeeplinkActivity.kt
车控属性 ID 表base/settingsBaseLib/src/main/java/com/android/car/settings/miauto/vehicle/MiCarPropertyIds.java

结语:锁车页之所以复杂,是因为它项多;但每一项背后的套路其实只有「布尔开关」「枚举开关」「Tab 切换」「Slider 调节」「按钮型」这几种模板。看懂 WalkAwayLockController 这 26 行代码 + 它继承链上的三个基类,你就拿走了车控类设置的「万能钥匙」。其它页面(灯光、驾驶、车身)只是属性 ID 和 XML 不同,套路完全一致。