05 · 路由与语音搜索框架

两套独立的编译期 APT 框架,共享相同的「注解 + 处理器 + 反射合并」模式。

  • 路由框架:外部 URI → 精准定位到某个 Fragment/Controller 内部。
  • 语音搜索:把设置 UI 控件树上报给小爱,实现「可见即可说」。

一、路由框架

1.1 三个注解(settingsCommon/plugin/router/annotation)

注解字段贴在哪作用
@RouterProviderpath(必填,URI)、classPath(默认=被注解类 FQN)Fragment 或 Controller 类声明「我响应哪个 URI」
@Modulevalue(模块名常量 SettingsConstant.TAG_xxx每个子模块任一类(惯例贴入口 Fragment)声明「我所在模块叫什么」
@Modulesvalue[](所有要合并的子模块名数组)只在 SettingsApplication 贴一次声明「App 要合并哪些子模块」

@RouterProvider 注释里写的 IPageHandler历史笔误,实际接口是 IPageRouteHandler

1.2 APT 生成物(routerApt,javapoet,包 com.android.car.settings.router

编译期(kapt 处理 @RouterProvider/@Module/@Modules):

  • 每个子模块Module_<name>.java,含 static map(),每个 @RouterProvider 调一次 RouterTabs.map(path, classPath)

    // 生成于 micarVolume/build/generated/source/kapt/.../Module_micarVolumeSettings.java
    public final class Module_micarVolumeSettings {
      public static void map() {
        RouterTabs.map("carsettings://soundsetting/", "...SoundFieldCustomFragment");
        RouterTabs.map("carsettings://homepage/?subPage=volume", "...VolumeSettingsFragment");
      }
    }
  • app 主模块RouterHelper.javamerge()反射调每个 Module_xxx.map()

    // 反射 + ClassNotFoundException,兼容某些 flavor 缺失的子模块
    try {
      Class c = Class.forName("...Module_micarVolumeSettings");
      Method m = c.getDeclaredMethod("map");
      m.setAccessible(true); m.invoke(null);
    } catch (ClassNotFoundException e) { /* 忽略 */ }

1.3 运行时(routerManager)

角色
RouterTabs静态 HashMap<String, String>(URI → 类名),map() 写入。设计前提:启动时一次性合并完,无并发同步
Router单例(静态内部类 holder)。构造时反射调 RouterHelper.merge() 填充 RouterTabsinit(app) 注册 Activity/Fragment 生命周期回调
IPageRouteHandler业务回调契约:void handleJump(Uri data)

1.4 分发机制(核心)

Router.getInstance().dispatcher(uri)   // 只暂存 mPostUri,不分发!
    │
    ▼  Fragment onFragmentResumed 时
dispatcherUri(fragment)
    │
    ├─ 遍历 RouterTabs,URI 匹配规则: contains / startsWith / equals(无正则,子串匹配)
    ├─ 收集匹配的 routers(支持一对多)
    ├─ 若匹配类是当前 Fragment 且 implements IPageRouteHandler → handleJump(mPostUri)
    ├─ 或匹配类是 Fragment 持有的 controller → 找到实例调 handleJump
    ├─ 递归分发到子 Fragment(最多二级嵌套)
    └─ 处理后清空 mPostUri

关键特性:路由器本身不 startActivity、不 replace Fragment。它只是把 Uri 交给已存活的 Fragment/Controller(持有 WeakReference),由业务在 handleJump(Uri) 里自行处理(滚动到某项、切 Tab 等)。

1.5 Controller 类型路由的必要条件

PreferenceControllerListHelper.createInstance 反射创建 Controller 时,会调 Router.holdController(controller, hostFragment)如果 Controller 没经过这个流程,Router 找不到实例,路由失效。

1.6 URI 入口

carsettings://homepage/?subPage=<alias>
   + 可选参数: preference_key / landing_page / tab / sub_tab / source
  • 主页路由:action android.settings.SETTINGS + URI → HomepageActivity.onNewIntentRouter.dispatcher
  • 语音中转:action android.settings.VEHICLE_PROPERTY_DELIVERDeeplinkActivity(透明)→ HomepageActivity
  • App 内部下钻子页SubSettingsActivity.newInstance(ctx, fragment)不走 Router

URI 常量集中在 base/settingsBaseUi/.../common/pageroute/CarSettingsJump.kt

const val MICAR_SETTINGS_SCHEME = "carsettings"   // ⚠️ 是 carsettings,不是 micarsettings
const val SETTING_SUBPAGE_URI_PREFIX = "$MICAR_SETTINGS_SCHEME://homepage/?subPage="

二、语音搜索框架(「可见即可说」)

2.1 注解(voicesearchannotation)

@Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE)
public @interface VoiceSearchProvider {
    int priority() default 0;   // 数值越大优先级越高
}

贴在顶级设置 Fragment 上。复用路由框架的 @Module/@Modules(voicesearchapt 依赖 router/annotation)。

2.2 APT 生成物(voicesearchapt)

  • 每个子模块VoiceSearch_<name>.javagetSearchDataList(): List<VoiceSearchPageData>。反射 new Fragment() 调用其 getPreferenceScreenResId() / getPageAlias() / getParentPageAlias() / getPreferenceKeyResIdsToRemove()
  • app 主模块VoiceSearchWidgetProvider.getSearchDataList(),反射合并各模块。

⚠️ 约束:被 APT 反射调用的 4 个 Fragment 方法绝不能依赖 Context(Fragment 未 attach,会崩)。

2.3 运行时机制:控件树上报(不是热词)

VoiceSearchManager.init()(settingsBaseUi):

1. 反射调 VoiceSearchWidgetProvider.getSearchDataList()
2. 对每个 VoiceSearchPageData,inflate 其 PreferenceScreen XML
3. 递归遍历 Preference/PreferenceGroup 树
4. 每个可见 Preference → VoiceSearchResultData(title, pageTag, widgetId=key, tab...)
5. 批量 JSON 上传小爱(每批 max 300),通过 VoiceAssistProvider.uploadSearchWidgets
6. 本地缓存 VoiceSearchDBManager.cacheWidgets

闭环

设置上报 UI 树 → 小爱索引 → 用户说话 → 小爱解析出 page+widget
   → 返回 Bundle(widgetId, subPage)
   → VoiceSearchManager.createVoiceSearchExtras 转 carsettings:// URI
   → 命中路由框架 → Fragment.handleJump

handleStickyWidgetId() 处理控件树未解析完的冷启动情况。

2.4 Intent 命名红线

自定义 action 严禁 android.* 前缀(出海 CTS/GMS 检测),统一用 com.mi.car.voiceassist.action.*

三、路由注册四件套(实战模板)

VolumeSettingsFragment 为例:

@VoiceSearchProvider                                          // ① 语音可索引
@Module(SettingsConstant.TAG_VOLUME)                          // ② 模块归属
@RouterProvider(path = CarSettingsJump.VolumeSettings.VOLUME_SETTINGS_URI_PATH)  // ③ URI 路由
@MonitorFragment                                              // ④ 埋点
public class VolumeSettingsFragment extends AudioControlSettingsFragment
        implements IPageRouteHandler {                        // ⑤ 必须实现,接收路由
 
    @Override public int getPreferenceScreenResId() { return R.xml.miauto_volume_settings_fragment; }
    @Override public String getPageAlias() { return PageAlias.PAGE_VOLUME; }
 
    @Override public void handleJump(Uri data) {              // ⑥ 路由命中时调用
        if (data == null) return;
        handleScrollUri(data);                                // 滚动到 preference_key
    }
}

四、端到端时序

编译期: @RouterProvider + @Module + @Modules ─kapt→ Module_<name>.java + RouterHelper.java
        @VoiceSearchProvider + @Module        ─kapt→ VoiceSearch_<name>.java + VoiceSearchWidgetProvider.java

启动期: MainThreadStartTask → Router.getInstance().init(app)
          └─ 反射 RouterHelper.merge() → 填充 RouterTabs
          └─ 注册 ActivityLifecycleCallbacks + FragmentLifecycleCallbacks

页面期: Fragment onAttach → PreferenceControllerListHelper.createInstance
          └─ Router.holdController(controller, fragment)  // 登记活实例

外部跳转: 别人发 Intent(data="carsettings://homepage/?subPage=volume&preference_key=pk_xxx")
          → BaseCarSettingsActivity.onNewIntent/handleRouterIntent
          → Router.dispatcher(intent.getData())  // 暂存
          → 下个相关 Fragment onFragmentResumed
          → dispatcherUri 匹配 → VolumeSettingsFragment.handleJump(uri)