08 · 开发实战:新增一个设置页

本篇是培训文档集的实战篇。读完了 00(总览)、02(PreferenceController 范式)、03(路由)、05(车辆接口)之后,这一篇把它们串起来:手把手带你从零新增「一个二级设置页 + 一个车辆开关」,并支持语音打开、支持外部 URI 跳转,最后通过 detekt、推到 Gerrit。


一、本篇概览

需求(模拟)

在「灯光」一级页里,新增一个二级入口「智能灯光节能」(Smart Light Eco),点击进入新的设置页;新页面里有一个开关,开/关时读写车辆属性 LightCtrl.LIGHT_ECO_MODE,并在属性回流时更新 UI;同时:

  • 支持外部 URI 跳转:其它 App / 桌面快捷方式用 carsettings://homepage/?subPage=vehicle_light_eco 直接打开这个页。
  • 支持语音:用户说「打开智能灯光节能」由小爱路由进来。

读完你能掌握什么

  • settingsPage/新增模块复用现有模块的取舍与各自操作步骤
  • 写一个 Fragment + 一份页面 XML + 一个 Controller,并让它们被框架正确装配
  • @RouterProvider + @Module 注册路由,用 @VoiceSearchProvider 接入语音
  • 多语言字符串的标准落位(values-zh-rCN / values-en-rGB
  • 本地 ./gradlew checkStyleCode 自查,按规范写 commit 并推 Gerrit
  • 至少 10 条「新手最容易踩」的坑

前置知识

  • 已经能把工程编出来(见 01 篇
  • 知道 PreferenceController 范式:Fragment 加载 XML,XML 里用 settings:controller="全限定类名" 声明 Controller,由 PreferenceControllerListHelper 反射实例化(见 02 篇
  • 知道 @RouterProvider 编译期被 routerApt 扫描、生成路由表(见 03 篇

二、完整工作流(先看全景)

flowchart TD
    A["1. 选模块<br/>新建 or 复用 settingsPage/*"] --> B["2. 新增 Fragment<br/>继承 TopLevelSettingsFragment/SettingsFragment"]
    B --> C["3. 新增页面 XML<br/>src/main/res/xml/*.xml<br/>用 settings:controller 挂 Controller"]
    C --> D["4. 新增 Controller<br/>继承 BaseSwitchPreferenceController<br/>或 BaseVehicleNewSwitchPrefController"]
    D --> E["5. 注册路由<br/>@RouterProvider + @Module"]
    E --> F["6. 接入语音<br/>@VoiceSearchProvider"]
    F --> G["7. 多语言字符串<br/>values-zh-rCN / values-en-rGB"]
    G --> H["8. 一级页加入口<br/>在父页面 XML 加一个 NewTabPreference"]
    H --> I["9. 质量自查<br/>./gradlew checkStyleCode"]
    I --> J["10. 提交 & 推送<br/>git commit -s + push origin HEAD:refs/for/dev"]
    J --> K{"Gerrit 评审"}
    K -->|detekt 打回| I
    K -->|通过| L["合入 ✅"]

核心心智Fragment 是页面的”名”,XML 是页面的”形”,Controller 是页面的”魂”。三者靠 XML 里 settings:controller 这个属性串起来——这一点理解了,整篇就顺了。


三、文件改动 Checklist(一张图)

mindmap
  root((新增设置页))
    新模块(若新建)
      settings.gradle include
      settingsPage/xxx/build.gradle
      settingsPage/xxx/AndroidManifest.xml
    Fragment
      XxxFragment.kt
    页面 XML
      src/main/res/xml/xxx_fragment.xml
    Controller
      XxxSwitchController.kt
    路由
      Fragment 加 @RouterProvider
      Fragment 加 @Module
      CarSettingsJump 加 URI 常量
      PageAlias 加 pageAlias
    语音
      Fragment 加 @VoiceSearchProvider
    字符串
      values/strings.xml(key)
      values-zh-rCN/strings.xml
      values-en-rGB/strings.xml
    一级页入口
      父页面 XML 加 Preference 项
    质量
      checkStyleCode 通过
    提交
      commit -s
      push Gerrit

四、Step 1 · 选模块:新建还是复用?

settingsPage/ 下已经有 16 个模块(灯光、充电、锁车、驾驶……)。绝大多数新需求都应该加到现有模块里,不要新建——新建意味着新的 build.gradle、新的 kapt 配置、新的依赖图、新的编译时间,没有充分理由不划算。

什么时候新建模块?

  • 这是一个独立的产品域,会有 5 个以上页面、自己的产品同学 owner
  • 与现有任何模块的关注点都不同(例如新增”账号与隐私”域)
  • 需要独立的 flavor 资源隔离策略

本例决定:复用 micarLightSettings

“智能灯光节能”属于灯光域,直接加到 settingsPage/micarLightSettings/ 即可

如果真要新建模块,需要做两件事

1) 在 settings.gradle 注册(参考 settings.gradle:55 已有写法):

// settings.gradle —— 在其它 include 后面追加一行
include ":settingsPage:micarNewFeature"

2) 新模块的 build.gradle 必须接入路由/语音 kapt(直接抄 settingsPage/micarLightSettings/build.gradle):

// settingsPage/micarNewFeature/build.gradle(精简版)
plugins {
    id 'com.android.library'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'   // ★ 必须,路由/语音靠 kapt 编译期扫注解
}
 
android {
    compileSdkVersion rootProject.ext.compileSdkVersion
    flavorDimensions = ["miPlatform"]
    productFlavors {
        dcd    { dimension "miPlatform" }
        xcd    { dimension "miPlatform" }
        global { dimension "miPlatform" }
    }
    // ... sourceSets 三 flavor 的 srcDir 叠加规则见 07 篇
    kotlinOptions { jvmTarget = '1.8' }
}
 
dependencies {
    implementation project(":base:settingsBaseUi")
    api project(":base:settingsBaseLib")
    implementation project(":base:settingsVehicleLib")
    // ★★★ 这两行是路由和语音的命脉,漏了就注解不生效
    kapt project("${MiCarSettingsCommon_LibProjectName}:plugin:router:routerApt")
    kapt project("${MiCarSettingsCommon_LibProjectName}:plugin:voice:voicesearchapt")
}

📌 新手记住:本例不新建模块,直接在 micarLightSettings 里加文件即可。下面所有 Step 都以复用现有模块为前提。


五、Step 2 · 新增 Fragment

参考真实例子

  • Java 版:settingsPage/micarLightSettings/src/main/java/com/android/car/settings/miauto/lights/LightsSettingsFragment.java
  • Kotlin 版(推荐新代码用 Kotlin):settingsPage/micarChargeSettings/src/main/java/com/android/micar/settings/energy/EnergyManagerFragment.kt:31-50

关键观察(从真实代码里读出来的事实)

  1. Fragment 顶部叠了 4 个注解:@VoiceSearchProvider@Module(SettingsConstant.TAG_Light)@RouterProvider(path = ...)@MonitorFragment(埋点用,可省)
  2. 继承 TopLevelSettingsFragment(或 SettingsFragment),实现 IPageRouteHandler
  3. 必须实现两个方法:
    • getPageAlias() → 返回页面别名,用于路由 & 语音
    • getPreferenceScreenResId() → 返回页面 XML 资源 id(编译期 R.xml.xxx)
  4. 可选覆写 getPreferenceKeyResIdsToRemove():返回一组 R.string.pk_xxx,框架会按 key 把对应 Preference 从页面上动态隐藏(这是车型差异化的标准手段)

骨架代码(可照抄)

// AI(claude) auto generated at 2026-07-02
// 类名: LightEcoSettingsFragment
// 用途: 「智能灯光节能」二级设置页入口,加载 XML、声明路由与语音
// 作用: 路由/语音跳转的目标 Fragment,承担页面装配
 
package com.android.car.settings.miauto.lights
 
import android.net.Uri
import androidx.annotation.XmlRes
import com.android.car.settings.common.pageroute.CarSettingsJump
import com.android.car.settings.common.pageroute.PageAlias
import com.android.car.settings.common.SettingsConstant
import com.android.car.settings.miauto.TopLevelSettingsFragment
import com.android.car.settings.router.annotation.Module
import com.android.car.settings.router.annotation.RouterProvider
import com.android.car.settings.router.manager.IPageRouteHandler
import com.android.car.settings.voice.search.annotation.VoiceSearchProvider
import com.android.micar.settings.light.R
 
@VoiceSearchProvider                                   // ① 接入语音搜索
@Module(SettingsConstant.TAG_Light)                    // ② 归属到「灯光」模块
@RouterProvider(path = CarSettingsJump.CarLightEco.LIGHT_ECO_URI_PATH)  // ③ 注册 URI 路由
class LightEcoSettingsFragment : TopLevelSettingsFragment(), IPageRouteHandler {
 
    override fun getPageAlias(): String = PageAlias.PAGE_LIGHT_ECO
 
    @XmlRes
    override fun getPreferenceScreenResId(): Int = R.xml.miauto_light_eco_settings_fragment
 
    /** 车型差异化:不支持的配置项在这里隐藏 */
    override fun getPreferenceKeyResIdsToRemove(): List<Int> = emptyList()
 
    /** 处理外部 URI 携带的参数(如滚动定位、子项定位) */
    override fun handleJump(data: Uri?) {
        if (data != null) handleScrollUri(data)
    }
}
// AI(claude) auto generated at 2026-07-02 END

还要补两个常量(在对应的常量类里加)

(a) 在 PageAlias.kt 加 pageAlias(参考 base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/PageAlias.kt:15 已有的 PAGE_LIGHT):

// AI(claude) auto generated at 2026-07-02 BEGIN
const val PAGE_LIGHT_ECO = "vehicle_light_eco" // 智能灯光节能
// AI(claude) auto generated at 2026-07-02 END

(b) 在 CarSettingsJump.kt 加 URI 常量(参考 CarSettingsJump.kt:72-74LIGHT_URI_PATH):

// AI(claude) auto generated at 2026-07-02 BEGIN
object CarLightEco {
    const val LIGHT_ECO_URI_PATH =
        "$SETTING_SUBPAGE_URI_PREFIX$PAGE_LIGHT_ECO"   // = "carsettings://homepage/?subPage=vehicle_light_eco"
}
// AI(claude) auto generated at 2026-07-02 END

📌 新手记住:URI 协议头固定是 carsettings://homepage/?subPage=<别名>,由 CarSettingsJump.SETTING_SUBPAGE_URI_PREFIX 拼出来。别名(pageAlias)必须全局唯一,建议带模块前缀避免冲突。


六、Step 3 · 新增页面 XML

参考真实文件

settingsPage/micarLightSettings/src/main/res/xml/miauto_lights_settings_fragment.xml

真实的属性命名(已从源码确认,不要凭感觉写

  • 根节点命名空间:xmlns:settings="http://schemas.android.com/apk/res-auto"
  • 挂 Controller 的属性是:settings:controller="全限定类名"(不是 controllerClass、不是 app:controller
  • 开关用的 Preference 类:com.android.car.settings.miauto.preferences.MiCarNewSwitchPreference(项目自定义的,比 AOSP 原生 SwitchPreference 多了埋点/前置拦截能力)
  • android:key@string/pk_xxx 而不是硬编码字符串(这是项目硬规范,便于 getPreferenceKeyResIdsToRemove() 动态隐藏)

本例页面 XML

新建文件:settingsPage/micarLightSettings/src/main/res/xml/miauto_light_eco_settings_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<!-- AI(claude) auto generated at 2026-07-02 BEGIN -->
<!-- 文件: miauto_light_eco_settings_fragment.xml -->
<!-- 用途: 智能灯光节能设置页面布局,承载开关项 -->
<!-- 作用: 由 LightEcoSettingsFragment 加载,声明 Preference 与 Controller 的绑定关系 -->
<PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:settings="http://schemas.android.com/apk/res-auto"
    android:key="@string/psk_settings_light_eco_entry"
    android:title="@string/preference_screen_title_light_eco">
 
    <com.android.car.settings.miauto.preferences.SpacePreference
        settings:spaceHeight="@dimen/micar_ui_middle_content_top_padding"/>
 
    <com.android.car.settings.miauto.preferences.MiCarPreferenceCategory
        android:key="@string/pk_settings_light_eco_category"
        android:title="@string/micar_vehicle_control_light_eco">
 
        <!-- 智能灯光节能总开关 -->
        <com.android.car.settings.miauto.preferences.MiCarNewSwitchPreference
            android:key="@string/pk_settings_light_eco_switch_entry"
            android:title="@string/settings_light_eco_switch_title"
            android:summary="@string/settings_light_eco_switch_summary"
            settings:controller="com.android.car.settings.miauto.lights.LightEcoSwitchController" />
 
    </com.android.car.settings.miauto.preferences.MiCarPreferenceCategory>
 
    <com.android.car.settings.miauto.preferences.SpacePreference
        settings:spaceHeight="@dimen/micar_ui_middle_content_bottom_padding"/>
</PreferenceScreen>
<!-- AI(claude) auto generated at 2026-07-02 END -->

📌 新手记住android:key 一律用 @string/pk_xxx(pk = preference key),android:title@string/...严禁硬编码字符串——detekt 和 checkstyle 都会管,且海外版翻译靠它。


七、Step 4 · 新增 Controller(核心)

两种基类,二选一

场景基类路径
开关与车辆属性绑定(读写 CarProperty)BaseVehicleNewSwitchPrefControllerbase/settingsVehicleLib/src/main/java/com/android/car/settings/miauto/vehicle/BaseVehicleNewSwitchPrefController.kt
开关与本地状态绑定(Settings.Global / SP / 自管)BaseSwitchPreferenceControllerbase/settingsBaseUi/src/main/java/com/android/car/settings/miauto/preferences/BaseSwitchPreferenceController.kt

本例是车辆开关 → 继承 BaseVehicleNewSwitchPrefController

真实范本(直接抄结构)

settingsPage/micarLightSettings/src/main/java/com/android/car/settings/miauto/lights/KunLunWelcomeLightSwitchController.kt 只有 30 行就完事——它把”读属性、写属性、判定开/关”抽象成三个 override,子类只关心业务映射。

本例 Controller 代码

// AI(claude) auto generated at 2026-07-02
// 类名: LightEcoSwitchController
// 用途: 「智能灯光节能」开关 Controller,读写车辆属性 LightCtrl.LIGHT_ECO_MODE
// 作用: 把开关 UI 的状态与车辆属性双向同步
 
package com.android.car.settings.miauto.lights
 
import android.car.drivingstate.CarUxRestrictions
import android.content.Context
import android.util.ArraySet
import com.android.car.settings.common.FragmentController
import com.android.car.settings.miauto.vehicle.BaseVehicleNewSwitchPrefController
import com.android.car.settings.miauto.vehicle.MiCarPropertyIds
import mi.car.config.Light
 
class LightEcoSwitchController(
    context: Context?,
    preferenceKey: String?,
    fragmentController: FragmentController?,
    uxRestrictions: CarUxRestrictions?
) : BaseVehicleNewSwitchPrefController(
    context, preferenceKey, fragmentController, uxRestrictions
) {
 
    /** 声明本 Controller 关心哪些车辆属性;属性变化时框架会回调 onHandlePropertyChange */
    override fun getPropertyIdSet(): ArraySet<Int> = ArraySet<Int>().apply {
        add(MiCarPropertyIds.LightCtrl.LIGHT_ECO_MODE)
    }
 
    /** 把"属性值"翻译成"开关是否打勾"。这里约定 ON=1 */
    override fun isPropertyOpen(propVal: Int): Boolean {
        return propVal == Light.OnOff.ON
    }
 
    /** 把"用户想要的开关状态"翻译成"要下发的属性值" */
    override fun getPropertyVal(isChecked: Boolean): Int {
        return if (isChecked) Light.OnOff.ON else Light.OnOff.OFF
    }
}
// AI(claude) auto generated at 2026-07-02 END

Controller 的工作机制(你要记清楚的几件事)

基类 BaseVehicleNewSwitchPrefController 已经替你做完了:

  1. 构造函数签名固定:必须是 (Context, String, FragmentController, CarUxRestrictions)——PreferenceControllerListHelper.createInstance() 反射调用的是这个签名,写错参数个数 / 类型就实例化失败,开关直接不显示(这是新手最高频踩的坑,见第十节)。 真实代码可对照:base/settingsBaseUi/src/main/java/com/android/car/settings/common/PreferenceControllerListHelper.java:108-113

  2. onCreateInternal():页面创建时基类已经读一次属性并 setChecked(...),所以子类通常不用自己写 onCreate。

  3. handlePreferenceChanged(preference, newValue):开关被打勾时回调,基类已经把 Boolean 转成属性值并 setVehicleProperty(...) 下发,子类一般不用覆写。

  4. onHandlePropertyChange(...):车辆属性变化(无论是不是本 App 改的)时框架回调,基类会自动 preference.setChecked(...)——这就是”UI 回流”的入口

  5. getAvailabilityStatus():决定设置项的可见性 / 可用性,默认是 AVAILABLE。常量定义在 PreferenceController.java:79-104

常量效果
AVAILABLE0正常显示、可点
CONDITIONALLY_UNAVAILABLE1暂时不可用(条件不满足),后续可能恢复
UNSUPPORTED_ON_DEVICE2彻底隐藏(不占位)
DISABLED_FOR_PROFILE3显示但禁用
AVAILABLE_FOR_VIEWING4只读

要做车型差异化时,在子类 override 它:

override fun getAvailabilityStatus(): Int {
    // AI(claude) auto generated at 2026-07-02 BEGIN
    // 不支持灯光节能的车型,直接让设置项消失(不占位)
    return if (CarConfigManager.isLightEcoSupported()) AVAILABLE else UNSUPPORTED_ON_DEVICE
    // AI(claude) auto generated at 2026-07-02 END
}

真实范本:settingsPage/micarDrivingSettings/src/main/java/com/android/micar/settings/driving/TowModeWarningPreferenceController.kt:43-46

📌 新手记住:Controller 的构造函数签名settings:controller 必须写全限定类名,这两件事是新手 80% 翻车的地方。先把这两件事做对,再谈业务。


八、Step 5 · 注册路由 @RouterProvider(已在 Fragment 完成)

回顾一下第五节 Fragment 顶部这两个注解:

@RouterProvider(path = CarSettingsJump.CarLightEco.LIGHT_ECO_URI_PATH)
@Module(SettingsConstant.TAG_Light)
class LightEcoSettingsFragment : ...

它怎么生效的?

  1. @RouterProvider / @Module 定义在 settingsCommon/plugin/router/annotation/...RetentionPolicy.CLASS,编译期会被 routerAptsettingsCommon/plugin/router/routerApt)扫到。
  2. routerApt 为每个带注解的类生成一段”路由表条目”代码,编译产物里就有一张全局 URI → Fragment 的映射表。
  3. 运行期外部 URI(如 carsettings://homepage/?subPage=vehicle_light_eco)进来,Router 查表 → 找到 LightEcoSettingsFragment → 启动 SubSettingsActivity → 加载该 Fragment。
  4. @Module 的 value 是模块标签(SettingsConstant.kt 里维护),用于路由按模块过滤、命中优先级。

前提:模块的 build.gradle 必须有 kapt project("...:router:routerApt"),否则注解不被处理(见第四节)。micarLightSettings/build.gradle 已配置好。

📌 新手记住:写完注解重新 build 一次./gradlew :settingsPage:micarLightSettings:assembleXcdDebug),让 kapt 跑完,路由才进表。改了 URI 路径要再 build。


九、Step 6 · 接入语音 @VoiceSearchProvider(已在 Fragment 完成)

@VoiceSearchProvider
class LightEcoSettingsFragment : ...

它怎么生效的?

  • @VoiceSearchProvider 定义在 settingsCommon/plugin/voice/voicesearchannotation/.../VoiceSearchProvider.java,可带 priority()(数值越大优先级越高)。
  • voicesearchaptsettingsCommon/plugin/voice/voicesearchapt)编译期扫到这个注解,会把该 Fragment 对应页面 XML 的所有 <Preference>(除了在 getPreferenceKeyResIdsToRemove() 里被隐藏的)作为”可被语音命中的设置项”写进 VoiceSearchWidgetProvider
  • 小爱侧拉起设置时,就靠这张表把”语音说法”映射到具体 Preference。

控制语音可见性

XML 里在某个 Preference 上加 settings:supportVoiceAssist="false",可单独让某个项不出现在语音问答里(真实例子:miauto_lights_settings_fragment.xml 里氛围灯相关项大量使用了该属性)。

📌 新手记住@VoiceSearchProvider 加在 Fragment 上,不是 Controller 上settings:supportVoiceAssist 加在 XML 里的 Preference 上,不是 Fragment 上。两者别混。


十、Step 7 · 多语言字符串

落位规则

文件用途
values/strings.xmlkey 必须存在(fallback,通常写英文,也可省略内容只占位)
values-zh-rCN/strings.xml国内中文(主语言,国内 dcd/xcd 走它)
values-en-rGB/strings.xml海外英文(global flavor 走它)
其它 values-xx/海外多语言翻译,由翻译平台回填(见 07 篇

参考真实文件:

  • 中文:settingsPage/micarLightSettings/src/main/res/values-zh-rCN/strings.xml:8
  • 英文:settingsPage/micarLightSettings/src/main/res/values-en-rGB/strings.xml:6

本例需要新增的 string(每个文件都加,key 必须一致

values-zh-rCN/strings.xml

<!-- AI(claude) auto generated at 2026-07-02 BEGIN -->
<string name="preference_screen_title_light_eco">"智能灯光节能"</string>
<string name="psk_settings_light_eco_entry">"light_eco_screen"</string>
<string name="pk_settings_light_eco_category">"light_eco_category"</string>
<string name="pk_settings_light_eco_switch_entry">"light_eco_switch"</string>
<string name="micar_vehicle_control_light_eco">"灯光节能"</string>
<string name="settings_light_eco_switch_title">"智能灯光节能"</string>
<string name="settings_light_eco_switch_summary">"在夜间自动降低不必要的灯光功耗"</string>
<!-- AI(claude) auto generated at 2026-07-02 END -->

values-en-rGB/strings.xml

<!-- AI(claude) auto generated at 2026-07-02 BEGIN -->
<string name="preference_screen_title_light_eco">"Smart light eco"</string>
<string name="psk_settings_light_eco_entry">"light_eco_screen"</string>
<string name="pk_settings_light_eco_category">"light_eco_category"</string>
<string name="pk_settings_light_eco_switch_entry">"light_eco_switch"</string>
<string name="micar_vehicle_control_light_eco">"Light eco"</string>
<string name="settings_light_eco_switch_title">"Smart light eco"</string>
<string name="settings_light_eco_switch_summary">"Reduce unnecessary light power at night"</string>
<!-- AI(claude) auto generated at 2026-07-02 END -->

⚠️ pk_* / psk_*Preference Key 的字符串值(不是给用户看的文案),但仍然要走 string 资源,方便代码用 R.string.pk_xxx 引用、方便 getPreferenceKeyResIdsToRemove() 隐藏。这些不要翻译,三种语言里写一样的值即可。

📌 新手记住:每个新 key 至少要在 values/values-zh-rCN/values-en-rGB/ 三处都加,少一处就有 lint 报错或翻译平台告警。文案翻译不要自己写其它语种——交给翻译平台回填。


十一、Step 8 · 在父页面加入口

要让用户能点进来,需要在「灯光」一级页 XML 里加一个入口项。打开 settingsPage/micarLightSettings/src/main/res/xml/miauto_lights_settings_fragment.xml,在合适位置追加:

<!-- AI(claude) auto generated at 2026-07-02 BEGIN -->
<!-- 智能灯光节能入口,点击路由到 LightEcoSettingsFragment -->
<com.android.car.settings.miauto.preferences.NewTabPreference
    android:key="@string/pk_settings_light_eco_entry"
    android:title="@string/preference_screen_title_light_eco"
    settings:controller="com.android.car.settings.miauto.lights.LightEcoEntryPreferenceController" />
<!-- AI(claude) auto generated at 2026-07-02 END -->

入口 Controller 一般继承 PreferenceController,点击时由路由框架跳转(具体跳转方式由 NewTabPreference / 父 Fragment 处理;这里给出最简骨架):

// AI(claude) auto generated at 2026-07-02 BEGIN
class LightEcoEntryPreferenceController @JvmOverloads constructor(
    context: Context,
    preferenceKey: String,
    fragmentController: FragmentController? = null,
    uxRestrictions: CarUxRestrictions? = null
) : PreferenceController<Preference>(context, preferenceKey, fragmentController, uxRestrictions) {
 
    override fun getPreferenceType(): Class<Preference> = Preference::class.java
 
    override fun handlePreferenceClicked(preference: Preference): Boolean {
        // 通过路由框架跳到 LightEcoSettingsFragment
        fragmentController?.launchFragment(LightEcoSettingsFragment())
        return true
    }
}
// AI(claude) auto generated at 2026-07-02 END

(具体跳转 API 视项目里的 FragmentController 封装而定;如果项目用 URI 跳转,改成 context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(CarSettingsJump.CarLightEco.LIGHT_ECO_URI_PATH))) 也可。)

📌 新手记住只有父页面入口项的 android:key 会被 LightsSettingsFragment.getPreferenceKeyResIdsToRemove() 控制显隐。新入口想跟着车型配置走,记得在父 Fragment 的 remove 列表里加判断。


十二、Step 9 · 质量自查

# 全量(慢但全)
./gradlew checkStyleCode
 
# 只检查本次改动的文件(推荐,秒级)—— 见 checkstyle.gradle:88-92
./gradlew checkStyleCode -PchangedKotlinFiles="$(git diff --name-only HEAD | tr '\n' ' ')"
./gradlew javaCheckstyle  -PchangedJavaFiles="$(git diff --name-only HEAD | tr '\n' ' ')"

checkStyleCode 这个 task 在 checkstyle.gradle:96-100 定义,本质是:

task('checkStyleCode') {
    dependsOn("javaCheckstyle")   // Java:用 settingsCommon/tools/checkstyle/checkstyle.xml
    dependsOn("detekt")           // Kotlin:用 settingsCommon/tools/checkstyle/detekt.yml
}

常见 detekt 规则打回原因

  • 类/方法命名不符合规范(驼峰、Controller 后缀等)
  • 文件末尾没空行 / import 顺序乱(detekt:formatting 插件管)
  • when 必须有 else禁止用 !!(项目 detekt.yml 比默认更严)
  • 过长方法 / 过多参数LongMethodParameterListWrapping
  • 魔法数字没命名成常量

Gerrit 上的自动检查(PREUPLOAD.cfg

项目根目录 PREUPLOAD.cfg 配置了:

  • ktlint_hook:对所有改动的 .kt 文件跑 ktlint
  • checkstyle_hook:对 commit 跑 checkstyle
  • commit_msg_changeid_field:commit message 必须有 Change-Id:

所以本地过了 detekt 还不够,push 后 Gerrit 还会再跑一遍 ktlint,不过照样打回。

📌 新手记住:push 前在本地跑一次 ./gradlew checkStyleCode -PchangedKotlinFiles=...,比 Gerrit 上挂红牌快十倍。


十三、开关 → 写车辆属性 → UI 回流(时序图)

sequenceDiagram
    autonumber
    actor U as 用户
    participant XML as MiCarNewSwitchPreference<br/>(XML 里的开关)
    participant Ctrl as LightEcoSwitchController
    participant Base as BaseVehicleNewSwitchPrefController
    participant Car as CarPropertyManager<br/>(android.car.*)
    participant Veh as 车控服务

    Note over U,Veh: 阶段一:进入页面,读属性初始化开关
    U->>XML: 进入「智能灯光节能」页
    XML->>Base: PreferenceControllerListHelper 反射创建 Ctrl<br/>(传 Context,key,FragmentCtrl,UxRestrict)
    Base->>Ctrl: onCreateInternal()
    Ctrl->>Base: getPropertyIdSet() → {LIGHT_ECO_MODE}
    Base->>Car: getIntProperty(LIGHT_ECO_MODE, areaId)
    Car->>Veh: 读取车辆属性
    Veh-->>Car: 当前值 = ON
    Car-->>Base: 1
    Base->>Ctrl: isPropertyOpen(1) → true
    Ctrl-->>Base: true
    Base->>XML: preference.setChecked(true)

    Note over U,Veh: 阶段二:用户点击开关,写属性
    U->>XML: 点击开关 → false
    XML->>Base: handlePreferenceChanged(pref, false)
    Base->>Ctrl: getPropertyVal(false) → OFF(0)
    Ctrl-->>Base: 0
    Base->>Car: setVehicleProperty(LIGHT_ECO_MODE, 0, areaId)
    Car->>Veh: 下发到车控
    Base-->>XML: return true(开关保持 false)

    Note over U,Veh: 阶段三:属性回流,UI 同步
    Veh-->>Car: CarPropertyValue 回调 (异步)
    Car->>Base: onHandlePropertyChange(value=0, updateUI=true)
    Base->>Ctrl: isPropertyOpen(0) → false
    Base->>XML: preference.setChecked(false)(确认 UI 一致)
    Note right of Base: 即使用户没点、属性被外部改了,<br/>UI 也会自动同步

这张图要记住三件事:

  1. 初始化onCreateInternal → getIntProperty → isPropertyOpen → setChecked
  2. handlePreferenceChanged → getPropertyVal → setVehicleProperty
  3. 回流onHandlePropertyChange → isPropertyOpen → setChecked(异步、外部改动也触发)

十四、Step 10 · 提交与推送

14.1 检查改动

git status
git diff HEAD

14.2 写 commit message(按 CLAUDE.md / AGENTS.md 规范)

git add settingsPage/micarLightSettings/src/main/java/com/android/car/settings/miauto/lights/LightEcoSettingsFragment.kt \
        settingsPage/micarLightSettings/src/main/java/com/android/car/settings/miauto/lights/LightEcoSwitchController.kt \
        settingsPage/micarLightSettings/src/main/res/xml/miauto_light_eco_settings_fragment.xml \
        settingsPage/micarLightSettings/src/main/res/values/strings.xml \
        settingsPage/micarLightSettings/src/main/res/values-zh-rCN/strings.xml \
        settingsPage/micarLightSettings/src/main/res/values-en-rGB/strings.xml \
        base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/PageAlias.kt \
        base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/CarSettingsJump.kt
 
git commit -s -m "[Feature][All-Vehicle][micar] 新增智能灯光节能设置页
 
新增 LightEcoSettingsFragment 二级页面,包含一个开关,
读写车辆属性 LightCtrl.LIGHT_ECO_MODE,并支持 carsettings:// URI 跳转
与小爱语音打开。
 
AI: Claude 2026-07-02
Co-authored-by: Claude <noreply@anthropic.com>
 
Jira: MICOCKPIT-XXXXX"

字段对照(来自 CLAUDE.md 提交规范):

  • [Feature]:新功能(修 bug 用 [Bugfix],重构用 [Refactor],详见 CLAUDE.md)
  • [All-Vehicle]:未指定车型时默认;指定车型写 [Lemans-RC09] 之类
  • [micar]:模块名,可选
  • AI: Claude 日期:紧跟详细描述,与 Jira 之间空一行
  • Jira: / Meego::bugfix 用 Jira,需求用 Meego,二选一
  • -s:自动注入 Signed-off-byChange-Id 由 Gerrit hook 自动加,不要手写

14.3 推到 Gerrit

# 远程名先用 git remote -v 确认(本项目实际是 origin)
git remote -v
#  origin  ssh://...slave.auto.mioffice.cn:29418/.../MiCarSettings
 
git push origin HEAD:refs/for/dev

📌 新手记住:分支名(这里 dev)一定要跟团队当前主干对齐,推错分支要回滚很麻烦。Gerrit 评审页:https://gerrit.auto.mioffice.cn


十五、踩坑清单(10 条,都是真实血泪)

1. 忘了在 settings.gradle 注册新模块

症状:Gradle sync 报 Project ':settingsPage:micarNewFeature' not found. 解法settings.gradleinclude ":settingsPage:micarNewFeature",与现有 16 个模块写法一致(settings.gradle:46-61)。

2. 模块 build.gradle 漏了 kapt 路由/语音

症状@RouterProvider 写了,但外部 URI 跳不进来;@VoiceSearchProvider 写了,语音也命不中。 解法build.gradle 必须有:

kapt project("${MiCarSettingsCommon_LibProjectName}:plugin:router:routerApt")
kapt project("${MiCarSettingsCommon_LibProjectName}:plugin:voice:voicesearchapt")

改完要 clean rebuild,否则旧的生成代码还在。

3. Controller 没在 XML 里 settings:controller="..." 声明

症状:Fragment 能进,但开关”看不见”或”点了没反应”——其实 Controller 根本没被实例化。 解法PreferenceControllerListHelper.java:90-92 会跳过 controller 字段为空的 Preference。检查 XML 里这一行确实写了全限定类名。

4. Controller 构造函数签名写错

症状:页面进来直接 crash,日志 NoSuchMethodException解法PreferenceControllerListHelper.createInstance()PreferenceControllerListHelper.java:108-113)反射调用的是 (Context, String, FragmentController, CarUxRestrictions) 四参构造,类型 / 顺序 / 可空性都不能错。Kotlin 里参数加 ? 与不加是两个不同的签名。

5. getAvailabilityStatus() 返回错值,开关”消失”

症状:开关凭空不见,但 XML 明明写了。 解法UNSUPPORTED_ON_DEVICE彻底隐藏(不占位);想”显示但灰显”用 DISABLED_FOR_PROFILEAVAILABLE_FOR_VIEWING;想”暂时隐藏以后再出现”用 CONDITIONALLY_UNAVAILABLE。常量定义在 PreferenceController.java:79-104

6. android:key 硬编码字符串

症状:detekt/checkstyle 报 Do not use hard-coded strings,或 getPreferenceKeyResIdsToRemove() 拿不到 key。 解法:所有 android:key 一律 @string/pk_xxx,并在 values*/strings.xml 同步新增。参考 miauto_lights_settings_fragment.xml 全篇都是这种写法。

7. 字符串只加了中文,没加 values/values-en-rGB/

症状:Lint 报 MissingTranslation;海外版本构建时 key 直接 missing。 解法:每个新 key 在 values/values-zh-rCN/values-en-rGB/ 三处都加,海外其它语种交给翻译平台回填。

8. flavor 资源放错目录

症状:dcd 上能看到的功能,xcd/global 编译报 Cannot resolve symbol,或运行报 Resource not found解法:看模块 build.gradlesourceSetsmicarLightSettings/build.gradle:42-62):

  • 公共代码/资源放 src/main/
  • 仅 dcd 用的放 src/dcddif/
  • 仅 xcd 用的放 src/xcddif/
  • 仅 global 用的放 src/global/ AIDL 文件也要按 flavor 放 aidl srcDir——global flavor 的 aidl 路径是 src/main/aidl + src/xcddif/aidl + src/global/aidl,跟 java/res 都不同,特别坑。

9. 改了 @RouterProvider 的 path 没重新 build

症状:URI 改了路径,外部跳转还是命中老路径。 解法:路由表是 kapt 编译期生成的,改完注解必须 ./gradlew :settingsPage:micarLightSettings:assembleXcdDebug 触发 APT,否则运行期查到的还是旧表。如果 IDE 增量编译抽风,跑一次 ./gradlew clean 再编。

10. Change-Id 重复或丢失

症状:Gerrit commit_msg_changeid_field hook 报错;或同一改动推上去变成两个 change。 解法

  • 首次提交用 git commit -s,hook 自动注入 Change-Id不要手写
  • 追加改动用 git commit --amend,保留原 Change-Id不要 -s 重新生成
  • cherry-pick 到别的分支时手动改 Change-IdI 开头那串,避免冲突

附:少见的坑(备查)

  • 11. 平台签名缺失 → 调用 CarPropertyManager 静默失败:本 App 必须是 platform 签名的系统特权应用,调试包用 platform.keystore 签(项目根目录已有)。换 debug 默认签名会导致 Car API 大量不可用。
  • 12. AIDL 接口跨 flavor 不一致global flavor 少了某个 aidl 文件,编译报 cannot find symbol:检查 sourceSets.global.aidl.srcDirs 是否包含了对应目录。

十六、新增设置页 Checklist(可勾选)

把这一节复制到你的 MR 描述里,逐条勾选,评审会快很多。

□ 1. 模块决策:新建模块已在 settings.gradle 注册;复用模块已确认归属
□ 2. Fragment:继承 TopLevelSettingsFragment,实现 IPageRouteHandler
□ 3. Fragment 注解:@RouterProvider / @Module / @VoiceSearchProvider 三个齐
□ 4. pageAlias 与 URI 常量:PageAlias.kt + CarSettingsJump.kt 各加一条
□ 5. 页面 XML:src/main/res/xml/xxx_fragment.xml,根节点 PreferenceScreen
□ 6. Preference 的 settings:controller 写了全限定类名
□ 7. Preference 的 android:key 用 @string/pk_xxx,未硬编码
□ 8. Controller:构造函数签名 = (Context, String, FragmentController, CarUxRestrictions)
□ 9. Controller:getPreferenceType() 返回正确类型(MiCarNewSwitchPreference)
□ 10. Controller:getPropertyIdSet() / isPropertyOpen() / getPropertyVal() 全实现
□ 11. Controller:车型差异通过 getAvailabilityStatus() 返回 UNSUPPORTED_ON_DEVICE 隐藏
□ 12. 父页面入口:在父页面 XML 加 Preference 项
□ 13. 多语言:每个 key 在 values/、values-zh-rCN/、values-en-rGB/ 三处都加
□ 14. AI 注释规范:所有 AI 生成代码都有 BEGIN/END 标记(// AI(claude) auto generated at YYYY-MM-DD)
□ 15. 本地 ./gradlew checkStyleCode 通过(增量用 -PchangedKotlinFiles=...)
□ 16. commit message:[类型][车型][模块] 简述 + 详细 + AI: Claude 日期 + Jira/Meego
□ 17. git commit -s 自动注入 Signed-off-by 与 Change-Id
□ 18. git push origin HEAD:refs/for/<分支>,Gerrit ktlint/detekt 通过

十七、参考文件索引(按出现顺序)

文件用途
settings.gradle模块注册
settingsPage/micarLightSettings/build.gradle模块 build 模板(kapt 路由/语音)
settingsPage/micarLightSettings/.../LightsSettingsFragment.javaJava Fragment 范本
settingsPage/micarChargeSettings/.../EnergyManagerFragment.kt:31-50Kotlin Fragment 范本
settingsPage/micarLightSettings/src/main/res/xml/miauto_lights_settings_fragment.xmlXML 范本(settings:controller 真实写法)
settingsPage/micarLightSettings/.../KunLunWelcomeLightSwitchController.kt车辆开关 Controller 范本(Kotlin)
settingsPage/micarLightSettings/.../HighBeamAutoAdjustPrefController.java车辆开关 Controller 范本(Java)
settingsPage/micarDisplaySettings/.../HudSnowModePrefController.kt非车辆开关 Controller 范本
base/settingsVehicleLib/.../BaseVehicleNewSwitchPrefController.kt车辆开关基类
base/settingsBaseUi/.../BaseSwitchPreferenceController.kt普通开关基类
base/settingsBaseUi/.../PreferenceController.java:79-104AvailabilityStatus 常量
base/settingsBaseUi/.../PreferenceControllerListHelper.java:90-113XML → Controller 反射实例化
settingsCommon/plugin/router/annotation/.../RouterProvider.java@RouterProvider 定义
settingsCommon/plugin/router/annotation/.../Module.java@Module 定义
settingsCommon/plugin/voice/voicesearchannotation/.../VoiceSearchProvider.java@VoiceSearchProvider 定义
base/settingsBaseUi/.../pageroute/SettingsConstant.kt模块标签
base/settingsBaseUi/.../pageroute/PageAlias.kt:15pageAlias 命名规范
base/settingsBaseUi/.../pageroute/CarSettingsJump.kt:16-17,72-74URI 协议头与拼装
settingsPage/micarDrivingSettings/.../TowModeWarningPreferenceController.kt:43-46getAvailabilityStatus() 真实用法
checkstyle.gradle:96-100checkStyleCode task 定义
PREUPLOAD.cfgGerrit 提交钩子(ktlint + changeid)

最后一句话:新增设置页这件事,第一次做会觉得”步骤好多”,做完一次就发现全是套路。把这个 Checklist 跑通一遍,之后所有新需求都是复制粘贴 + 改业务。祝顺利合入 🚗。