04 - 语音搜索插件详解

本篇是 MiCarSettings 培训文档第 4 篇,专讲「语音搜索」(汽车问答)插件。建议先读完 00 总览 建立全局观。


本篇概览

MiCarSettings 的「语音搜索」(内部又叫「汽车问答」)能力,指的是用户对小爱说”打开灯光设置""打开能量管理”乃至”打开自动远近光”,App 能自动跳到对应设置页、并高亮具体某一项。要实现这个能力,需要先告诉系统:这个页面叫什么、包含哪些可被搜索的设置项

这套机制由 settingsCommon/plugin/voice/ 下的两个子模块实现:

  • voicesearchannotation:纯注解 + 数据类定义。
  • voicesearchapt:kapt 注解处理器,编译期扫描注解、生成「页面元数据映射表」Java 类。

运行期由 base/settingsBaseUi/.../voiceassist/VoiceSearchManager.java 消费这张映射表,把所有可搜索控件打包上报给小爱,并负责把语音助手回传的 widgetId 转化成实际页面跳转。

读完本篇你能掌握

  1. @VoiceSearchProviderVoiceSearchPageDataVoiceSearchConstants 三个核心类的作用与字段含义。
  2. VoiceSearchProcessor 在编译期扫描哪些注解、生成什么样的类、命名规律、生成到哪个包。
  3. 运行期语音跳转的完整链路:从小爱下发 intent → 查映射表 → 跳到目标 Fragment → 滚动并高亮具体项。
  4. 语音搜索框架与路由框架 @RouterProvider 的区别与协作方式。
  5. 给出一个新页面,怎么让它支持”小爱小爱,打开 XX”。
  6. 自定义 intent action 的命名红线(来自最近的 [Bugfix][voice][出海] 修复intent定义不规范问题)。

一、整体架构:编译期 + 运行期两段式

语音搜索的精髓是「编译期收集 + 运行期合并」,避免运行时反射扫描所有类的开销。

flowchart LR
    subgraph Compile["编译期(kapt)"]
        F1[Fragment 加上 \@VoiceSearchProvider]
        F2[VoiceSearchProcessor<br/>扫描注解]
        F3[生成 VoiceSearch_xxx.java<br/>每页一份元数据]
        F4[生成 VoiceSearchWidgetProvider.java<br/>汇总入口]
        F1 --> F2 --> F3 --> F4
    end
    subgraph Runtime["运行期"]
        R1[VoiceSearchManager.init<br/>反射调用聚合类]
        R2[解析 Preference XML<br/>构建 widgetId → 页面 映射]
        R3[上传给小爱]
        R4[小爱回传 widgetId]
        R5[DeeplinkActivity<br/>查表 + 跳转]
        R1 --> R2 --> R3 --> R4 --> R5
    end
    Compile -->|打包进 apk| Runtime

📌 新人记住:编译期产物是「Java 源码类」而不是 JSON 或 XML;运行期再把这些类的数据合并成最终的搜索表。这种「编译期生成代码」模式在 MiCarSettings 里很常见,路由框架 routerApt 也是同一套思路。


二、注解定义详解(voicesearchannotation 模块)

模块路径:settingsCommon/plugin/voice/voicesearchannotation/ 包名:com.android.car.settings.voice.search.annotation

整个模块只有三个文件,逻辑非常轻量。build.gradle 也只声明 java-library 插件,没有任何依赖,目的就是被 APT 和最终 app 同时引用。

// settingsCommon/plugin/voice/voicesearchannotation/build.gradle
plugins { id 'java-library' }
java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

2.1 @VoiceSearchProvider —— 标注”我这是个可被语音搜索的页面”

文件:settingsCommon/plugin/voice/voicesearchannotation/src/main/java/com/android/car/settings/voice/search/annotation/VoiceSearchProvider.java

@Retention(RetentionPolicy.CLASS)      // 保留到 .class,运行时不可见(APT 用)
@Target({ElementType.TYPE})            // 只能修饰类(即 Fragment)
public @interface VoiceSearchProvider {
    // 优先级:数值越大,优先级越高(同级页面搜索结果排序用)
    int priority() default 0;
}
字段类型默认含义
priorityint0页面优先级,数越大优先级越高。用于多个页面都含同名控件时的排序。例如 IotSettingsFragment 就用 priority = 1 提升排序权重。

唯一字段就一个 priority。这个注解是 APT 的入口标记——没打这个注解的 Fragment,APT 完全不会为它生成搜索元数据。

📌 新人记住@VoiceSearchProvider 只标 Fragment 类,不标方法、不标字段。RetentionPolicy.CLASS 意味着运行时通过反射拿不到它,它的存在仅供 javac 编译期被 APT 看到。

2.2 VoiceSearchPageData —— 一级页面元数据载体

文件:settingsCommon/plugin/voice/voicesearchannotation/src/main/java/com/android/car/settings/voice/search/annotation/VoiceSearchPageData.java

这是「数据类」而不是注解,用来描述一个可被搜索的页面长什么样。APT 生成的代码会 new 这个类的实例。

字段类型含义
mLayoutResint页面 Preference XML 布局资源 id(如 R.xml.miauto_lights_settings_fragment
mPageTagString页面唯一标识,即 PageAlias(如 "vehicle_light"
mParentPageTagString父页面 tag,用于小爱上报父子层级(一级页一般为空)
mPageNameString页面显示名(运行期 inflate XML 后用 PreferenceScreen.getTitle() 填充)
mPriorityint来自 @VoiceSearchProvider(priority)
mExcludeKeyIdsList<Integer>需要从搜索结果中排除的 Preference key 的 string resource id 列表(来自 getPreferenceKeyResIdsToRemove()
mExcludeKeysList<String>排除项解析后的真实 key 字符串(运行期由 VoiceSearchManager 填充)
mUnSupportVoiceAssistKeysList<String>明确「不支持汽车问答」的 key 子集(这些项连子项也不会上报)

两个关键构造方法:

// 一级页面用
public VoiceSearchPageData(int layoutRes, String pageTag, String parentPageTag,
        int priority, List<Integer> excludeKeyIds) { ... }
 
// 早期/无父页面用
public VoiceSearchPageData(int layoutRes, String pageTag, int priority,
        List<Integer> excludeKeyIds) { ... }

辅助方法 containExcludeKey(key) / containUnSupportVoiceAssistKeys(key) 在运行期解析 Preference 树时用来跳过被排除项(如某车型不支持的配置)。

📌 新人记住VoiceSearchPageData 的字段几乎都对应 Fragment 上的一个方法(getPreferenceScreenResIdgetPageAliasgetParentPageAliasgetPreferenceKeyResIdsToRemove)——APT 生成的代码就是通过 new Fragment() 后调这些方法把字段填进去的。

2.3 VoiceSearchConstants —— 生成类的命名约定

文件:settingsCommon/plugin/voice/voicesearchannotation/src/main/java/com/android/car/settings/voice/search/annotation/VoiceSearchConstants.java

public class VoiceSearchConstants {
    // 最终聚合入口类的全限定名(运行期反射要用)
    public static final String VOICE_SEARCH_WIDGET_PROVIDER_PATH =
            "com.android.car.settings.miauto.voiceassist.VoiceSearchWidgetProvider";
 
    public static final String PACKAGE = "com.android.car.settings.miauto.voiceassist";
    public static final String VOICE_SEARCH_WIDGET_PROVIDER = "VoiceSearchWidgetProvider";
    public static final String METHOD_GET_SEARCH_DATA_LIST = "getSearchDataList";
    public static final String MODULE_PREFIX = "VoiceSearch_";   // 模块类前缀
}

这一组常量是 APT 生成器与运行期反射调用者之间的契约

  • APT 生成时,类放在 PACKAGE 包下,模块元数据类形如 VoiceSearch_<Module>,聚合类叫 VoiceSearchWidgetProvider,方法名固定 getSearchDataList
  • 运行期 VoiceSearchManager 通过 VOICE_SEARCH_WIDGET_PROVIDER_PATH 反射拿到聚合类、调用 METHOD_GET_SEARCH_DATA_LIST

📌 新人记住:改这些常量等于 break 运行期反射,除非同时改 APT 和 VoiceSearchManager,否则不要动。


三、APT 注解处理器(voicesearchapt 模块)

模块路径:settingsCommon/plugin/voice/voicesearchapt/

3.1 模块依赖与 SPI 注册

build.gradle 里能看到它依赖了 voicesearchannotation(拿到注解定义)和 router:annotation(复用 @Module/@Modules),以及代码生成库 javapoet

// settingsCommon/plugin/voice/voicesearchapt/build.gradle
dependencies {
    implementation project(":settingsCommon:plugin:voice:voicesearchannotation")
    implementation project(":settingsCommon:plugin:router:annotation")
    implementation 'com.squareup:javapoet:1.13.0'
}

复用 router 的 @Module 是有意为之——见后面第五节,语音搜索按「业务模块」分包,跟路由是同一个模块切分维度。

APT 通过标准的 SPI 机制注册,文件: settingsCommon/plugin/voice/voicesearchapt/src/main/resources/META-INF/services/javax.annotation.processing.Processor

内容一行:

com.android.car.settings.voice.search.apt.VoiceSearchProcessor

这一行加上 kapt project(...) 引用就触发了 javac 调用 VoiceSearchProcessor。app 工程的 app/build.gradle:127kapt project(":settingsCommon:plugin:voice:voicesearchapt")(注释:「汽车问答」),各 settingsPage 子模块的 build.gradle 也都有同样的 kapt 引用,例如 settingsPage/micarChargeSettings/build.gradle:80

3.2 VoiceSearchProcessor 详解

文件:settingsCommon/plugin/voice/voicesearchapt/src/main/java/com/android/car/settings/voice/search/apt/VoiceSearchProcessor.java

扫描哪些注解

// VoiceSearchProcessor.java:82-88
@Override
public Set<String> getSupportedAnnotationTypes() {
    Set<String> annotationTypes = new LinkedHashSet<>();
    annotationTypes.add(VoiceSearchProvider.class.getCanonicalName());
    annotationTypes.add(Module.class.getCanonicalName());     // 来自 router
    annotationTypes.add(Modules.class.getCanonicalName());    // 来自 router
    return annotationTypes;
}

扫描 3 个注解

  • @VoiceSearchProvider —— 标记 Fragment;该 Fragment 会被收进搜索表。
  • @Module(name) —— 单模块名(普通 settingsPage 子模块用)。
  • @Modules({name1, name2}) —— 多模块名(app 主工程聚合时用,生成跨模块汇总类)。

处理流程:两轮 Round

// VoiceSearchProcessor.java:96-119(精简)
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment env) {
    if (env.processingOver()) {
        // 最后一轮:拿到 @Module/@Modules 的名字,开始生成代码
        processModuleAnnotations(env);              // 读取模块名 → mModule / mModules
        if (mModule != null && !mModule.isEmpty()) {
            createModuleProvider(env, MODULE_PREFIX + mModule);  // 生成 VoiceSearch_<module>
        }
        if (mModules != null && mModules.length > 0) {
            createProvider(mModules);               // 生成 VoiceSearchWidgetProvider(聚合)
        }
    } else {
        processAnnotations(env);                    // 累积被注解的 Element 到 mAutoRegisterSet
    }
    return false;
}

为什么分两轮?因为 javac 的注解处理是多 Round 的,只有 processingOver() 那一轮才能保证所有被注解的类都已就绪,这时统一收一遍元素并生成代码最稳妥。

生成物 1:VoiceSearch_<Module> 类(每个模块一份)

createModuleProvider() 遍历所有 @VoiceSearchProvider 元素,生成形如下面的类(命名规律:VoiceSearch_ + @Module 的 value,例如 VoiceSearch_CarSettingsVoiceSearch_Light):

// 生成代码示意(来自 VoiceSearchProcessor.java:34-63 注释)
public final class VoiceSearch_Light {
    public static List<VoiceSearchPageData> getSearchDataList() {
        List<VoiceSearchPageData> list = new ArrayList<>();
        try {
            LightsSettingsFragment fragment0 = new LightsSettingsFragment();
            list.add(new VoiceSearchPageData(
                fragment0.getPreferenceScreenResId(),     // 布局 XML
                fragment0.getPageAlias(),                 // 页面标识
                fragment0.getParentPageAlias(),           // 父页面标识
                0,                                         // priority(来自注解)
                fragment0.getPreferenceKeyResIdsToRemove() // 排除项
            ));
        } catch (Exception e) { e.printStackTrace(); }
        // ... 该模块内所有被 @VoiceSearchProvider 标注的 Fragment 都会对应一段
        return list;
    }
}

关键点:APT 生成的代码会 new XxxFragment() —— Fragment 此时并未 attach 到 Activity,所以这 4 个方法(getPreferenceScreenResIdgetPageAliasgetParentPageAliasgetPreferenceKeyResIdsToRemove)绝不能依赖 Context、PreferenceScreen、findViewById 等运行期对象。基类 BaseXmlParserSettingsFragmentgetPreferenceKeyResIdsToRemove() 上有明确注释:

// BaseXmlParserSettingsFragment.java:1063-1071
/**
 * 返回需要隐藏的 Preference key列表。
 * 此方法会被 VoiceSearchProcessor(APT)通过反射调用。
 * 此时 Fragment 未 attach,Context 不可用,Preference 树未初始化。
 * 禁止调用 getContext()、getString() 等依赖 Context 的方法;
 * 禁止调用 findPreference() 等依赖 Preference 树的方法。
 */
public List<Integer> getPreferenceKeyResIdsToRemove() { return null; }

生成物 2:VoiceSearchWidgetProvider(聚合类)

createProvider() 只在 @Modules({...}) 注解存在时生成,作用是合并各子模块VoiceSearch_<Module> 数据。它通过反射调用每个子模块的 getSearchDataList()

// 生成代码示意
public final class VoiceSearchWidgetProvider {
    public static List<VoiceSearchPageData> getSearchDataList() {
        List<VoiceSearchPageData> list = new ArrayList<>();
        try {
            Class clazz_CarSettings = Class.forName(
                "com.android.car.settings.miauto.voiceassist.VoiceSearch_CarSettings");
            Method method_CarSettings = clazz_CarSettings.getDeclaredMethod("getSearchDataList");
            method_CarSettings.setAccessible(true);
            list.addAll((List) method_CarSettings.invoke(null));
        } catch (ClassNotFoundException e) {
            // 模块没编进这个产物时静默跳过
        } catch (Exception e) { e.printStackTrace(); }
        // ... 其它模块
        return list;
    }
}

为什么用反射而不是直接调? 注释里写:「Make 不支持 Android Transform,所以只能通过反射获取生成的类」(VoiceSearchManager.java:186-187)。不同构建系统(Make / Gradle)下生成类的可见性不同,反射是最稳的兼容方式,且这段在子线程执行,性能影响可接受。

命名规律与生成位置一览

生成类命名规则生成条件包路径
VoiceSearch_<Module>固定前缀 + @Module.value()该模块至少有一个 @VoiceSearchProvider Fragmentcom.android.car.settings.miauto.voiceassist
VoiceSearchWidgetProvider固定名app 主工程用 @Modules({...}) 声明多模块时com.android.car.settings.miauto.voiceassist

📌 新人记住:APT 不会”运行”你的 Fragment 业务逻辑,它只 new 一个空对象调 4 个纯返回常量/资源 id 的方法。这 4 个方法一定要”轻”,绝不能在 getPageAlias() / getPreferenceScreenResId() / getPreferenceKeyResIdsToRemove() 里干任何重活。

3.3 编译期流程图

flowchart TD
    A[kapt 调用 VoiceSearchProcessor] --> B{Round 处理中?}
    B -- 是 --> C[processAnnotations:<br/>收集 \@VoiceSearchProvider / \@Module / \@Modules 元素]
    C --> D[放入 mAutoRegisterSet]
    B -- 否 processingOver --> E[processModuleAnnotations:<br/>读取 mModule / mModules 名字]
    E --> F{有 \@Module?}
    F -- 是 --> G[createModuleProvider:<br/>生成 VoiceSearch_&lt;Module&gt;.java<br/>内含本模块每个 Fragment 的元数据]
    F -- 否 --> H[跳过]
    G --> I{有 \@Modules 多模块聚合?}
    H --> I
    I -- 是 --> J[createProvider:<br/>生成 VoiceSearchWidgetProvider.java<br/>反射调用各子模块聚合]
    I -- 否 --> K[结束]
    J --> K

四、真实使用例子(在 settingsPage 里怎么标)

下面挑 3 个真实页面,看「语音说法/intent → 设置页」的映射怎么声明。

4.1 灯光页 LightsSettingsFragment

文件:settingsPage/micarLightSettings/src/main/java/com/android/car/settings/miauto/lights/LightsSettingsFragment.java

// LightsSettingsFragment.java:53-58
@VoiceSearchProvider                                      // ① 标记:本页支持语音搜索
@RouterProvider(path = LIGHT_URI_PATH)                    // ② 顺手也加路由(URI 入口)
@Module(SettingsConstant.TAG_Light)                       // ③ 归属"Light"模块
@MonitorFragment
public class LightsSettingsFragment extends AudioControlSettingsFragment
        implements IPageRouteHandler {
 
    @Override public String getPageAlias() { return PageAlias.PAGE_LIGHT; }   // "vehicle_light"
 
    @Override public int getPreferenceScreenResId() {
        return R.xml.miauto_lights_settings_fragment;                          // 页面 XML
    }
 
    // APT 生成代码会调用本方法,把要排除的 Preference key id 列表带进搜索表
    // LightsSettingsFragment.java:73-87
    @Override
    public List<Integer> getPreferenceKeyResIdsToRemove() {
        List<Integer> ids = new ArrayList<>();
        if (!MiCarSettings.isHeadlampHeightEnable()) {
            ids.add(R.string.pk_settings_exteriorlights_head_heighty_entry); // 大灯高度调节(车型不支持)
        }
        if (!MiCarSettings.isCfgARSEnabled()) {
            ids.add(R.string.pk_settings_lock_welcome_with_tail_wing);       // 迎宾尾翼随动
        }
        // ...
        return ids;
    }
}

效果:用户对小爱说”打开灯光设置” → 跳到 LightsSettingsFragment;说”打开大灯高度调节” → 跳到灯光页并滚动高亮该项;如果车型不支持,该子项根本不会上报给小爱,也就不会被识别。

4.2 IoT 页(带优先级)

文件:settingsPage/micarIotSettings/src/main/java/com/android/micar/settings/iot/IotSettingsFragment.java:43-48

@VoiceSearchProvider(priority = 1)                                       // ← 优先级 1
@Module(SettingsConstant.TAG_IOT)
@RouterProvider(path = CarSettingsJump.VehicleIOTSettings.IOT_DEVICE_CONTROL_URI_PATH)
@MonitorFragment
public class IotSettingsFragment extends BaseXmlParserSettingsFragment
        implements FragmentPageMark.Replace, OnIotDeviceCardUpdateListener {
    private static final String PAGE_ALIAS = PageAlias.PAGE_IOT;          // "iot_device"
    // ...
}

priority = 1 让 IoT 页在多页面命中同名/相近说法时排在前面。

4.3 充电页(Kotlin 写法)

文件:settingsPage/micarChargeSettings/src/main/java/com/android/micar/settings/energy/EnergyManagerFragment.kt

@VoiceSearchProvider
@Module(SettingsConstant.TAG_CHARGE)
@RouterProvider(path = ENERGY_MANAGER_URI_PATH)
@MonitorFragment
class EnergyManagerFragment : TopLevelSettingsFragment(), IPageRouteHandler {
    // getPageAlias() / getPreferenceScreenResId() 在父类或本类实现
}

📌 新人记住:95% 的「一级设置页」Fragment 同时挂 @VoiceSearchProvider + @RouterProvider + @Module 三个注解——语音和路由是同一页面的两个入口维度,分工不同但目标一致。


五、运行期:语音跳转完整链路

入口和出口分布在三个模块:

  • 上报:VoiceSearchManager(base/settingsBaseUi)。
  • 接收 intent:DeeplinkActivity(app/)。
  • 跳转与高亮:BaseXmlParserSettingsFragment(base/settingsBaseUi)。

5.1 上报阶段:把搜索表发给小爱

VoiceSearchManager 是单例。init()(子线程 + Looper.prepare())通过反射调用前面生成的 VoiceSearchWidgetProvider.getSearchDataList(),拿到 List<VoiceSearchPageData>

// VoiceSearchManager.java:190-216(精简)
private void init() {
    if (Looper.myLooper() == null) { Looper.prepare(); }
    try {
        Class clazz = Class.forName(VoiceSearchConstants.VOICE_SEARCH_WIDGET_PROVIDER_PATH);
        Method method = clazz.getMethod(VoiceSearchConstants.METHOD_GET_SEARCH_DATA_LIST);
        List<VoiceSearchPageData> list =
                (List<VoiceSearchPageData>) method.invoke(clazz.newInstance());
        if (list != null) {
            if (!mOriginDataList.isEmpty()) mOriginDataList.clear();
            mOriginDataList.addAll(list);
        }
        insertDelegateData();              // 合并动态注册的 SearchProvider 数据
        initPageDataExcludeKeys();         // 把 exclude key 的 resId 解析成真实 key 字符串
    } catch (Exception e) {
        MLog.tag(TAG).e(e, "VoiceSearchManager init fail!!");
    }
}

随后 parseTopFragmentPreferences()VoiceSearchManager.java:384)逐个 inflate 每个 VoiceSearchPageDatamLayoutRes,递归遍历 Preference 树,把带 title 的 Preference / PreferenceGroup / Tab / Button 全部转成 VoiceSearchResultData,填充到 mResultDataMap以 widget key 为键)。最后 uploadWidgets() 把数据打包成 JSON 并通过 VoiceAssistProvider.uploadSearchWidgets() 推给小爱:

// VoiceSearchManager.java:718-748(节选)
JSONObject widgetJsonObj = new JSONObject();
widgetJsonObj.put(KEY_WIDGET_ID, resultData.getWidgetId());        // "id"
widgetJsonObj.put(KEY_WIDGET_NAME, resultData.getTitle());         // "name"
widgetJsonObj.put(KEY_WIDGET_LEVEL, resultData.getPriority());     // "level"
widgetJsonObj.put(KEY_PARENT_WIDGET_ID, resultData.getParentWidgetKey()); // "parentId"
 
JSONObject groupJson = new JSONObject();
groupJson.put(KEY_PAGE_ACTION, Constants.Actions.DELIVER_PAGE_ACTION);  // "android.settings.VEHICLE_PROPERTY_DELIVER"
groupJson.put(KEY_PAGE_ID, pageResultData.mPageTag);                    // 页面 alias
groupJson.put(KEY_PAGE_NAME, pageResultData.mPageName);
groupJson.put(KEY_PARENT_PAGE_ID, pageResultData.mParentPageTag);
groupJson.put(KEY_WIDGETS, pageResultData.mWidgetJsonArray);

5.2 跳转阶段:从 widgetId 到达目标页面

小爱识别出用户说法后,回传一个 intent,其 action 为 android.settings.VEHICLE_PROPERTY_DELIVER,extras 携带 widget_id 和/或 widget_key。AndroidManifest 把它路由到 DeeplinkActivity

<!-- app/src/main/AndroidManifest.xml:231-241 -->
<activity android:name=".miauto.common.DeeplinkActivity"
    android:launchMode="standard"
    android:taskAffinity=":deeplink"
    android:theme="@style/CarSettingTheme.Translucent"
    android:exported="true">
    <intent-filter android:priority="1">
        <action android:name="android.settings.VEHICLE_PROPERTY_DELIVER" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
</activity>

DeeplinkActivity 是一个”中转透明 Activity”,它拿到 widgetId 后查 VoiceSearchManager 的搜索表,把页面 alias + widgetKey 装进新 extras,再启动真正的 HomepageActivity

// app/src/main/java/com/android/car/settings/miauto/common/DeeplinkActivity.kt:70-112(精简)
private fun handleVoiceSearchExtra() {
    val widgetId = intent?.getStringExtra(BUNDLE_KEY_WIDGET_ID)   // "widget_id"
    if (!TextUtils.isEmpty(widgetId)) {
        processVoiceSearchExtra(widgetId!!, shouldIncludeWidgetId = true)
    }
}
 
private fun processVoiceSearchExtra(key: String, shouldIncludeWidgetId: Boolean) {
    val mappedKey = PrefKeyMapUtils.mapPrefKey(key)               // 智驾某些 key 的兼容映射
    val extras = VoiceSearchManager.getInstance()
        .createVoiceSearchExtras(mappedKey, shouldIncludeWidgetId)  // ← 查表
    createAndStartHomepageIntent(extras)                          // 启动 HomepageActivity
}

VoiceSearchManager.createVoiceSearchExtras() 拿 widgetId 在 mResultDataMap / mLocalCacheResultDataMap 中查 VoiceSearchResultData,取出 pageTag(页面 alias)和 widgetKey(具体 Preference key)放入 Bundle:

// VoiceSearchManager.java:767-795(精简)
public Bundle createVoiceSearchExtras(String widgetId, Boolean shouldIncludeWidgetId) {
    VoiceSearchResultData resultData = getSearchData(realWidgetId);
    if (resultData == null) { /* 解析未完成时记下来等会儿再处理 */ return null; }
    Bundle bundle = new Bundle();
    bundle.putString(BUNDLE_KEY_SUB_PAGE, resultData.getPageTag());          // "key_sub_page"
    bundle.putString(BUNDLE_KEY_WIDGET_SUB_PAGE, resultData.getPageTag());   // "key_widget_sub_page"
    bundle.putString(BUNDLE_KEY_WIDGET_KEY, resultData.getWidgetKey());      // "key_widget_key"
    if (shouldIncludeWidgetId) {
        bundle.putString(BUNDLE_KEY_WIDGET_ID, realWidgetId);                // "widget_id"
    }
    return bundle;
}

HomepageActivity 收到这个 Bundle 后,在 HomePageActivityReal.handlePageSwitchIntent() 里根据 BUNDLE_KEY_SUB_PAGE 找到对应 Preference key 并切换到对应一级页(HomePageActivityReal.java:111-159)。最后 BaseXmlParserSettingsFragment.handleWidgetKeyExtra() 比对 BUNDLE_KEY_WIDGET_SUB_PAGE 与自身 getPageAlias(),命中则把列表滚动到具体项并显示”汽车问答高亮提示”(BaseXmlParserSettingsFragment.java:1168-1307)。

5.3 运行期时序图

sequenceDiagram
    autonumber
    participant U as 用户
    participant X as 小爱
    participant DA as DeeplinkActivity
    participant VSM as VoiceSearchManager
    participant HA as HomepageActivity
    participant F as 目标 Fragment<br/>(如 LightsSettingsFragment)

    Note over VSM,F: 阶段 A:App 启动后异步上报控件表
    VSM->>VSM: init() 反射调用<br/>VoiceSearchWidgetProvider.getSearchDataList()
    VSM->>VSM: inflate 每个 PageData 的 XML<br/>递归解析 Preference 树
    VSM->>X: uploadWidgets()<br/>{action, pageId, pageName, widgets[]}

    Note over U,F: 阶段 B:用户语音跳转
    U->>X: "打开大灯高度调节"
    X->>DA: Intent(action=VEHICLE_PROPERTY_DELIVER)<br/>extras: widget_id / widget_key
    DA->>VSM: createVoiceSearchExtras(widgetId)
    VSM-->>DA: Bundle { key_sub_page, key_widget_sub_page, key_widget_key }
    DA->>HA: startActivity(SETTINGS action, Bundle)
    HA->>HA: handlePageSwitchIntent()<br/>根据 key_sub_page 切到对应一级页
    HA->>F: 实例化并展示 Fragment
    F->>F: handleWidgetKeyExtra()<br/>比对 getPageAlias() 与 key_widget_sub_page
    F->>F: smoothScrollToPosition(widgetKey)<br/>显示高亮提示

📌 新人记住

  • action android.settings.VEHICLE_PROPERTY_DELIVER 是”语音/外部 → 设置内部页”的入口 action,对应 DeeplinkActivity
  • action android.settings.SETTINGS 是 App 主入口 action,对应 HomepageActivity。两者在 AndroidManifest.xml:214,237 注册。
  • widgetIdwidgetKey 不是同一个东西:widgetId 是给小爱用的全局唯一 id(一般是 Preference key 字符串),widgetKey 是设置页内部用来定位某行的 key。绝大多数场景两者相等,少数 Tab 场景会通过 TabKeyMapHelper 做一次映射。

六、与路由框架 @RouterProvider 的关系

很多同事第一次看会懵:既然语音和路由都把”输入 → Fragment”做了映射,那它俩到底什么关系?能不能合并?

答案:两者是独立的映射体系,但目标 Fragment 通常重叠,于是在每个一级页上”同居”。

维度语音搜索(voicesearchapt路由(routerApt
入口标记注解@VoiceSearchProvider@RouterProvider
模块归属注解复用 @Module / @Modules@Module / @Modules
输入形式语音说法 → widgetId → 页面URI(carsettings://...)→ 页面
生成的关键类VoiceSearch_<Module>.javaVoiceSearchWidgetProvider.javaModule_<Module>.javaRouterHelper.java
生成的核心 APIgetSearchDataList() 返回 List<VoiceSearchPageData>map() 把 path → classPath 写入 RouterTabs
运行期入口小爱 intent android.settings.VEHICLE_PROPERTY_DELIVERandroid.settings.SETTINGS + uri data 或 carsettings:// scheme
运行期查表方VoiceSearchManager(in-memory map)RouterTabs(HashMap)
主要目的把”页面可搜索控件”上报给小爱在 App 内/外部用 URI deep-link 跳页

6.1 关系图

flowchart LR
    subgraph Anno["注解层(编译期)"]
        AV["\@VoiceSearchProvider<br/>(voicesearchannotation)"]
        AR["@RouterProvider<br/>(router:annotation)"]
        AM["@Module / \@Modules<br/>router:annotation<br/>两边复用"]
    end

    subgraph Apt["APT 层"]
        VA[VoiceSearchProcessor<br/>生成 VoiceSearch_xxx]
        RA[RouterUriProcessor<br/>生成 Module_xxx + RouterHelper]
    end

    subgraph RT["运行期"]
        VSM[VoiceSearchManager<br/>维护 widgetId → PageData 表]
        RTB[RouterTabs<br/>维护 URI → classPath 表]
        DA[DeeplinkActivity<br/>接 VEHICLE_PROPERTY_DELIVER]
        HA[HomepageActivity<br/>接 SETTINGS + uri]
    end

    subgraph F["同一目标 Fragment"]
        LF[LightsSettingsFragment<br/>同时挂 3 个注解]
    end

    AV --> VA --> VSM
    AR --> RA --> RTB
    AM -.归属相同模块.-> VA
    AM -.归属相同模块.-> RA

    VSM --> DA
    RTB --> HA

    LF -.同时被扫描.-> VA
    LF -.同时被扫描.-> RA
    DA -->|启动| HA
    HA -->|切换| LF

6.2 二者如何协作?

  1. 同 Fragment 双注解:业务侧不需要”为语音写一遍,为路由再写一遍”,一个 Fragment 同时打 @VoiceSearchProvider + @RouterProvider 即可——见 LightsSettingsFragment.java:53-54IotSettingsFragment.java:43-45
  2. 共享模块切分:两边都通过 @Module(SettingsConstant.TAG_xxx) 表明自己属于哪个子模块(Light/IOT/Charge 等),APT 会按模块分别生成聚合类。
  3. 入口 intent 不同,但都收敛到 HomepageActivity
    • 语音路径:DeeplinkActivity(VEHICLE_PROPERTY_DELIVER)→ 查 VoiceSearchManager → 携带 Bundle 启动 HomepageActivity
    • 路由路径:HomepageActivity 直接收到带 carsettings:// data 的 SETTINGS intent → handlePageSwitchIntent() 解析 URI → 调 switchToSettingPage(prefKey) 切页。
  4. 职责边界:路由框架不负责”具体 Preference 的高亮滚动”——那是语音路径在 BaseXmlParserSettingsFragment.handleWidgetKeyExtra() 里完成的。反过来,语音搜索不负责外部 URI 跳转。

📌 新人记住@RouterProvider 是 “URI → 页面”,@VoiceSearchProvider 是 “说法/widget → 页面”。一个管”打开某页”(URI),一个管”找到某项”(widget)。两者并行存在、互不替代。


七、Intent 命名规范(来自最近一次 Bugfix)

最近有提交 [Bugfix][voice][出海] 修复intent定义不规范问题1c25b4e83,Jira MIICOSBUG-308),改了两个 VoiceConst.java 文件。看 diff 就能直观感受 intent 命名的红线。

修改前(错误写法):

// settingsPage/settingsSystem/.../xiaoai/VoiceConst.java
public static final String ACTION_VOICE_CTA =
        "android.intent.action.MICAR_VOICE_ASSISTANT_CTA";
 
// settingsPage/xiaoAiSettings/.../voice/utils/VoiceConst.java
public static final String ACTION_VOICE_RECORD_PERMISSION =
        "android.intent.action.MICAR_VOICE_RECORD_PERMISSION";
public static final String ACTION_VOICE_RECORD_MICROPHONE_PERMISSION =
        "android.intent.action.MICAR_VOICE_RECORD_MICROPHONE_PERMISSION";
public static final String ACTION_VOICE_CTA =
        "android.intent.action.MICAR_VOICE_ASSISTANT_CTA";

修改后(正确写法):

public static final String ACTION_VOICE_CTA =
        "com.mi.car.voiceassist.action.ASSISTANT_CTA";
public static final String ACTION_VOICE_RECORD_PERMISSION =
        "com.mi.car.voiceassist.action.RECORD_PERMISSION";
public static final String ACTION_VOICE_RECORD_MICROPHONE_PERMISSION =
        "com.mi.car.voiceassist.action.RECORD_MICROPHONE_PERMISSION";
public static final String ACTION_VOICE_CAT_CLOSE =
        "com.mi.car.voiceassist.action.CTA_CLOSE";

命名规范总结

规则说明
禁止 android.intent.action.*这是 Android 系统 intent 的保留命名空间,自定义 action 用了会被系统当作系统 action,海外版本(出海)过 GMS/CTS 检测时会被打回。
禁止 android.settings.* 之外自造 android.xxx.*同上,android.* 是系统命名空间。android.settings.SETTINGSandroid.settings.VEHICLE_PROPERTY_DELIVER 这两个确实是本 App 在 manifest 注册的,但它们是早期约定,新加的 action 不要再往 android.* 里塞。
推荐 com.mi.car.voiceassist.action.<NAME>这是本 App 语音助手相关 action 的统一命名空间。大写 NAME,下划线分词,动词为主。
同一类 action 加统一前缀例如权限相关都叫 RECORD_PERMISSION / RECORD_MICROPHONE_PERMISSION,CTA 相关叫 ASSISTANT_CTA / CTA_CLOSE

海外版本对此更敏感:CTS/GMS 兼容性测试会扫描 manifest 里的 action 命名,发现自定义 action 顶 android.* 前缀就会 fail。这也是为什么这条 bug 上标了「出海」标签。

📌 新人记住:新加自定义 broadcast/intent action 时,默认走 com.mi.car.<sub>.action.<NAME> 命名空间,绝对不要蹭 android.intent.action.*


八、实操:让一个新设置页支持”小爱打开 XX”

把前面的内容倒过来,落地成步骤。假设你要新增 FooSettingsFragment

步骤清单

  1. 继承合适的基类:通常继承 BaseXmlParserSettingsFragmentTopLevelSettingsFragment,从而拿到 handleWidgetKeyExtra 等语音跳转能力。

  2. 挂三个注解(语音 + 路由 + 模块):

    @VoiceSearchProvider                      // 必加:语音入口
    @RouterProvider(path = FOO_URI_PATH)      // 推荐加:方便 URI 跳转
    @Module(SettingsConstant.TAG_FOO)         // 必加:归属模块
    class FooSettingsFragment : TopLevelSettingsFragment(), IPageRouteHandler { ... }
  3. 实现 4 个方法(这 4 个方法会被 APT 生成的代码调用):

    override fun getPageAlias(): String = PageAlias.PAGE_FOO          // 新增一个 PageAlias 常量
    override fun getPreferenceScreenResId(): Int = R.xml.foo_settings_fragment
    override fun getParentPageAlias(): String = ""                    // 一级页留空
    override fun getPreferenceKeyResIdsToRemove(): List<Int>? {
        // 返回不希望被搜索/显示的 Preference key 的 string resource id 列表
        // 例如车型不支持的项、license 关掉的项
        return null
    }
  4. PageAlias.kt 里注册页面 aliasbase/settingsBaseUi/.../pageroute/PageAlias.kt 加一行 const val PAGE_FOO = "foo"(注释提示”不能修改,涉及一级页面 DeepLink”)。

  5. 确认 kapt 接入:你的 settingsPage 子模块 build.gradle 要有:

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

    以及 app/build.gradle:127 的 kapt 引用(已经在了)。

  6. app 主工程的 Modules 聚合类确认包含你的 @Module(TAG_FOO) —— 通常 app 主工程有一处 @Modules({CarSettings, Light, IOT, ...}),新模块要加进去(否则 VoiceSearchWidgetProvider 不会反射到你的 VoiceSearch_FOO)。

  7. Preference XML 里给每个可搜索的 Preference 加上 android:key="@string/pk_xxx"(必须有 key,否则不会被解析上报)和 android:title(必须非空,否则不会被收录)。

  8. 编译并验证

    • ./gradlew :app:assembleXcdDebug,构建后查 app/build/generated/source/kapt/.../com/android/car/settings/miauto/voiceassist/VoiceSearch_<Module>.java 是否包含你的 Fragment。
    • 跑起来后看 logcat tag VoiceSearchManager 的输出(parseTopFragmentPreferences 会打印每个页面的解析日志),确认你的页面被收录、且 excludeKeys 正确。
    • 用 adb 模拟 intent:adb shell am start -a android.settings.VEHICLE_PROPERTY_DELIVER --es widget_id "<你的preference key>" 看是否能跳转。

排查清单(语音搜不到 / 跳不对)

现象排查方向
APT 没生成类检查 Fragment 类是否打了 @VoiceSearchProvider,kapt 引用是否在子模块 build.gradle 里
生成了但页面没出现在上报列表检查 getPageAlias() 是否返回了非空;Preference 是否都有 key 和 title;是否被 getPreferenceKeyResIdsToRemove 误排除
上报了但小爱识别不到小爱侧的”说法”训练是否包含了你的页面名(PreferenceScreenandroid:title
识别到了但跳不到具体项检查 widgetId 是否在 mResultDataMap 里;handleWidgetKeyExtragetPageAlias() 与 intent 里的 key_widget_sub_page 是否一致
跳过去没高亮确认 BUNDLE_KEY_WIDGET_ID 是否携带(影响 showSearchNotifyView);Adapter 是否是 HighlightablePreferenceGroupAdapter

📌 新人记住新页面支持语音的最小工作量 = 加 3 个注解 + 实现 4 个方法 + 注册 PageAlias + 在 Modules 聚合类里登记。Preference XML 里给每个项写好 keytitle,剩下的事情 APT 和 VoiceSearchManager 会自动做完。


九、速查附录

关键文件索引

文件作用
settingsCommon/plugin/voice/voicesearchannotation/.../VoiceSearchProvider.java注解定义(仅 priority 字段)
settingsCommon/plugin/voice/voicesearchannotation/.../VoiceSearchPageData.java页面元数据载体
settingsCommon/plugin/voice/voicesearchannotation/.../VoiceSearchConstants.java生成类的命名常量
settingsCommon/plugin/voice/voicesearchapt/.../VoiceSearchProcessor.javaAPT 注解处理器
settingsCommon/plugin/voice/voicesearchapt/.../META-INF/services/javax.annotation.processing.ProcessorSPI 注册
base/settingsBaseUi/.../voiceassist/VoiceSearchManager.java运行期单例:解析、上报、查表
base/settingsBaseUi/.../voiceassist/VoiceSearchPrinter.kt关键日志打印(防抖 3s)
base/settingsBaseLib/.../search/SearchDelegate.java动态注册搜索数据的扩展点
base/settingsBaseUi/.../common/BaseXmlParserSettingsFragment.javaFragment 基类,提供 getPreferenceKeyResIdsToRemove 等契约方法及运行期高亮逻辑
base/settingsBaseUi/.../common/pageroute/PageAlias.kt所有页面 alias 常量集中定义
app/src/main/java/com/android/car/settings/miauto/common/DeeplinkActivity.ktintent 入口(VEHICLE_PROPERTY_DELIVER)
app/src/main/java/com/android/car/settings/miauto/HomePageActivityReal.java主 Activity,根据 extras 切页
app/src/main/AndroidManifest.xmlintent-filter 注册(214 行 SETTINGS,237 行 VEHICLE_PROPERTY_DELIVER)
app/build.gradle:127kapt 引入 voicesearchapt

三张图回顾

  1. 3.3 编译期流程图:注解 → APT 两轮处理 → 生成 VoiceSearch_<Module>VoiceSearchWidgetProvider
  2. 5.3 运行期时序图:小爱 intent → DeeplinkActivityVoiceSearchManager 查表 → HomepageActivity → 目标 Fragment 高亮。
  3. 6.1 语音 vs 路由关系图:两套独立 APT + 独立运行期表,但挂同一个 Fragment 上,最终都汇到 HomepageActivity

📌 新人记住(全篇三句话版):

  1. @VoiceSearchProvider 只是开关,真正提供给 APT 的页面元数据来自 Fragment 实现的 getPageAlias / getPreferenceScreenResId / getParentPageAlias / getPreferenceKeyResIdsToRemove 这 4 个方法——它们必须「无副作用、不依赖 Context」。
  2. 语音跳转的入口 action 是 android.settings.VEHICLE_PROPERTY_DELIVER(→ DeeplinkActivity),主入口 action 是 android.settings.SETTINGS(→ HomepageActivity);自定义 action 严禁使用 android.* 前缀,统一用 com.mi.car.voiceassist.action.*
  3. 语音和路由是”同一页面的两个入口”,不是替代关系:新加一级页一般同时挂 @VoiceSearchProvider + @RouterProvider + @Module,分别把”说法”和”URI”映射到这同一个 Fragment。