05 · 路由与语音搜索框架
两套独立的编译期 APT 框架,共享相同的「注解 + 处理器 + 反射合并」模式。
- 路由框架:外部 URI → 精准定位到某个 Fragment/Controller 内部。
- 语音搜索:把设置 UI 控件树上报给小爱,实现「可见即可说」。
一、路由框架
1.1 三个注解(settingsCommon/plugin/router/annotation)
| 注解 | 字段 | 贴在哪 | 作用 |
|---|---|---|---|
@RouterProvider | path(必填,URI)、classPath(默认=被注解类 FQN) | Fragment 或 Controller 类 | 声明「我响应哪个 URI」 |
@Module | value(模块名常量 SettingsConstant.TAG_xxx) | 每个子模块任一类(惯例贴入口 Fragment) | 声明「我所在模块叫什么」 |
@Modules | value[](所有要合并的子模块名数组) | 只在 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.java,merge()用反射调每个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() 填充 RouterTabs;init(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.onNewIntent→Router.dispatcher - 语音中转:action
android.settings.VEHICLE_PROPERTY_DELIVER→DeeplinkActivity(透明)→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>.java,getSearchDataList(): 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)