03 - 路由框架详解
适用对象:刚接手 MiCarSettings 项目的新工程师 阅读前置:已大致了解项目模块结构(参见
01-项目结构总览)、AOSP Car Settings 的 Activity + 多 Fragment 架构
本篇概览
MiCarSettings 的”路由框架”是一套 自研的、基于 APT(注解处理器)的 URI 路由系统,用来解决一个核心问题:
语音助手、桌面快捷方式、车控 App、其他三方 App 通过一个
carsettings://...形式的 URI 跳到车辆设置某个具体页面时,设置 App 内部该如何识别这个 URI、并把请求分发到正确的 Fragment 或 PreferenceController?
这套框架拆成三个 Gradle 子模块,各司其职:
| 子模块 | 角色 | 关键类 |
|---|---|---|
settingsCommon/plugin/router/annotation | 编译期注解定义 | RouterProvider / Module / Modules |
settingsCommon/plugin/router/routerApt | kapt 注解处理器,编译期生成路由表 | RouterUriProcessor / Constants |
settingsCommon/plugin/router/routerManager | 运行期路由管理 | Router / RouterTabs / IPageRouteHandler |
整体走的是 “编译期收集 + 运行期查表 + 反射合并” 的经典套路,和 ARouter、WMRouter 思路一致,但更轻量,只解决”设置内部页面定位”,不处理跨进程。
读完你能掌握什么
- 看到
@RouterProvider(path = ...)、@Module(...)、@Modules({...})三种注解时,清楚地知道每个字段填什么、起什么作用 - 看懂编译期生成的
Module_xxx.java和RouterHelper.java长什么样、生成到哪里、为什么这么命名 - 能复述一个
carsettings://homepage/?subPage=volumeURI 从外部 App 调用startActivity开始,经过HomepageActivity→Router.dispatcher→ FragmentonResume→IPageRouteHandler.handleJump的完整时序 - 能独立给自己的页面接入路由,让外部 URI 能打开你自己写的 Fragment 或 Controller
- 知道路由与
SubSettingsActivity的边界:哪些页面走路由,哪些页面走SubSettingsActivity.newInstance
第一节 整体架构与编译期/运行期分工
1.1 一张图看清三模块职责
flowchart LR subgraph 编译期 A["业务代码<br/>@RouterProvider<br/>@Module<br/>@Modules"] --> B["routerApt<br/>RouterUriProcessor"] B --> C["生成 Java 文件<br/>Module_xxx.java<br/>RouterHelper.java"] end subgraph 运行期 D["routerManager<br/>Router (单例)"] E["RouterTabs<br/>静态 HashMap"] F["业务 Fragment/Controller<br/>实现 IPageRouteHandler"] C -.反射合并.-> E D --> E D -.分发 URI.-> F end
1.2 编译期 vs 运行期职责划分
| 阶段 | 做什么 | 谁来做 |
|---|---|---|
| 编码 | 业务在 Fragment/Controller 类上贴 @RouterProvider(path=...),在 Application 类上贴 @Modules({...}) | 业务工程师 |
| 编译期 (APT) | 扫描所有 @RouterProvider,按 @Module 分组,生成 Module_xxx.java(每模块一份路由表)和 RouterHelper.java(总合并类) | RouterUriProcessor |
| App 启动 (运行期) | Router 单例被首次获取时,反射调用 RouterHelper.merge(),把所有 Module_xxx.map() 灌进 RouterTabs 静态 HashMap | Router 构造函数 |
| 运行期分发 | 外部 URI 进入 BaseCarSettingsActivity,调用 Router.dispatcher(uri) 暂存;Fragment onResume 时 Router 查表,命中后回调 IPageRouteHandler.handleJump(uri) | Router + 业务 Fragment |
📌 新手记住:这套框架的核心抽象就是一张
HashMap<String, String>—— key 是 URI(或 URI 片段),value 是要处理的类的全限定名。所有花活都是围绕”怎么把这张表填好”和”怎么在合适时机查这张表”展开的。
第二节 注解定义详解(annotation 模块)
annotation 模块非常薄,只有 3 个文件,全部位于:
settingsCommon/plugin/router/annotation/src/main/java/com/android/car/settings/router/annotation/
2.1 @RouterProvider —— 单页路由声明
源码:annotation/.../RouterProvider.java:13-20
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.TYPE})
public @interface RouterProvider {
String path() default ""; // 该页面能拦截的外部 URI(或 URI 片段)
String classPath() default ""; // 处理该 URI 的类全限定名,空则默认用被注解类自己
}字段含义:
| 字段 | 类型 | 必填 | 含义 |
|---|---|---|---|
path | String | 是 | 该页面对外暴露的 URI。支持精确匹配,也支持 Router.dispatcherUri 中的 contains/startsWith 模糊匹配。例:carsettings://homepage/?subPage=volume |
classPath | String | 否 | 处理 URI 的类全限定名。留空时,APT 默认用被注解类自己的全限定名。当 Fragment 内的某个 PreferenceController 来处理跳转、而不是 Fragment 本身时,才需要显式填 controller 的全限定名 |
作用域: ElementType.TYPE,只能贴在类上(通常是 Fragment 或 PreferenceController)。
保留策略: RetentionPolicy.CLASS,注解信息进 class 但不进运行时,APT 在编译期读得到即可。
注释里提到 “需要实现 IPageHandler”,这是历史遗留笔误,实际接口名是
IPageRouteHandler(见第四节)。不实现该接口的类即使被注解,Router 在分发时也不会回调。
2.2 @Module —— 单模块名声明
源码:annotation/.../Module.java:13-16
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
public @interface Module {
String value(); // 模块名,如 "micarVolumeSettings"
}用途: 标记”当前编译单元(Gradle 模块)叫什么名字”。APT 用这个值拼出生成类的名字 Module_<value>。
规则: 一个 Gradle 模块只需贴一次(任意一个类上即可,惯例贴在该模块的入口 Fragment 上)。同一模块内多个类贴同一个 @Module("xxx") 是允许的,APT 会以扫描到的值为准。
取值约定: 直接用 SettingsConstant 里预定义的常量,例如:
// settingsCommon/plugin/router/routerManager/src/main/java/com/android/car/settings/common/SettingsConstant.kt:5-47
object SettingsConstant {
const val TAG = "CarSettings"
const val TAG_ROUTER = "routerManager"
const val TAG_VOLUME = "micarVolumeSettings"
const val TAG_Light = "micarLightSettings"
// ... 每个业务模块一个常量
}2.3 @Modules —— 主模块合并清单
源码:annotation/.../Modules.java:13-16
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
public @interface Modules {
String[] value(); // 所有需要合并路由表的子模块名数组
}用途: 只在主 App(app 模块)的 Application 类上贴一次,列出所有参与了路由的业务子模块。APT 看到这个注解才会生成合并类 RouterHelper.java。
真实例子: app/src/main/java/com/android/car/settings/miauto/SettingsApplication.java:28-35
@Module(SettingsConstant.TAG) // 声明 app 模块本身的名字
@Modules({SettingsConstant.TAG, SettingsConstant.TAG_AUTOPILOT,
SettingsConstant.TAG_Light, SettingsConstant.TAG_VEHICLECONTROL,
SettingsConstant.TAG_LOCK, SettingsConstant.TAG_DRIVING,
SettingsConstant.TAG_CHARGE, SettingsConstant.TAG_IOT,
SettingsConstant.TAG_XIAOAI, SettingsConstant.TAG_DISPLAY,
SettingsConstant.TAG_SAFETY_SERVICE, SettingsConstant.TAG_CONNECTION,
SettingsConstant.TAG_VOLUME, SettingsConstant.TAG_SYSTEM})
public class SettingsApplication extends BaseApplication { ... }2.4 三个注解的关系图
graph TB subgraph app 主模块 SA["@Modules({...14 个模块名...})<br/>@Module('CarSettings')<br/>贴在 SettingsApplication"] end subgraph settingsPage 各业务子模块 V["VolumeSettingsFragment<br/>@Module('micarVolumeSettings')<br/>@RouterProvider(path=volume_uri)"] L["LightsSettingsFragment<br/>@Module('micarLightSettings')<br/>@RouterProvider(path=light_uri)"] I["IotSettingsFragment<br/>@Module('micarIotSettings')<br/>@RouterProvider(path=iot_uri)"] O["...其他业务 Fragment..."] end SA -->|@Modules 数组声明要合并哪些子模块| V SA --> L SA --> I SA --> O V -.生成 Module_micarVolumeSettings.-> G(("RouterHelper<br/>+ 各 Module_xxx")) L -.生成 Module_micarLightSettings.-> G I -.生成 Module_micarIotSettings.-> G
📌 新手记住:
@RouterProvider—— “我这个页面要响应哪个 URI”(贴在 Fragment/Controller 上)@Module—— “我所在的 Gradle 模块叫什么名字”(每个子模块贴一次)@Modules—— “App 总共要合并哪些子模块的路由表”(只在 SettingsApplication 上贴一次) 三者必须配合使用,缺一不可;尤其@Modules漏掉某个模块,那个模块的 URI 路由就完全失效。
第三节 编译期:APT 如何生成路由表(routerApt 模块)
3.1 Constants —— 生成类的命名与包名
源码:routerApt/.../Constants.java:3-12
public class Constants {
public static final String MODULE_PREFIX = "Module_"; // 子模块生成类前缀
public static final String PACKAGE = "com.android.car.settings.router"; // 生成类的统一包名
public static final String TABS_PATH_PACKAGE = "com.android.car.settings.router.manager";
public static final String TABS_PATH_CLASS_NAME = "RouterTabs";
}生成类的统一规律:
| 生成类 | 包名 | 类名规律 | 触发条件 |
|---|---|---|---|
| 子模块路由表 | com.android.car.settings.router | Module_<@Module 的 value> | 扫描到 @Module |
| 总合并类 | com.android.car.settings.router | RouterHelper(固定名) | 扫描到 @Modules |
例如 @Module("micarVolumeSettings") 会生成 com.android.car.settings.router.Module_micarVolumeSettings。
3.2 RouterUriProcessor 处理流程
源码:routerApt/.../RouterUriProcessor.java:75-289
RouterUriProcessor 继承自 AbstractProcessor,核心流程:
getSupportedAnnotationTypes()声明要处理三种注解:RouterProvider、Module、Modules(RouterUriProcessor.java:98-104)- 多轮处理(
process方法,Javac 会在每轮注解处理后回调):- 非最后一轮:
processAnnotations()把当前模块扫描到的所有被注解元素收集到mAutoRegisterSet,并解析@Module/@Modules的值(RouterUriProcessor.java:163-176) - 最后一轮(
processingOver()):此时所有注解元素都已收集齐,正式生成代码
- 非最后一轮:
- 最后一轮生成:
- 调
processModuleAnnotations()读出@Module的 value → 赋给mModule;读出@Modules的数组 → 赋给mModules - 如果
mModule非空,调createrModuleHelper()生成Module_<mModule>.java - 如果
mModules数组非空,调createrRouterHelper()生成RouterHelper.java
- 调
3.3 生成的 Module_xxx.java 长什么样
源码逻辑见 RouterUriProcessor.createrModuleHelper()(RouterUriProcessor.java:253-288)。APT 源文件里给了示例(RouterUriProcessor.java:40-48),整理如下:
// 生成包:com.android.car.settings.router
// 生成类名:Module_<模块名>,如 Module_CarSettings
public final class Module_CarSettings {
public static void map() {
// 对当前模块扫描到的每个 @RouterProvider,生成一行
RouterTabs.map("carsettings://hudsetting",
"com.android.car.settings.miauto.display.HudPhysicalTabLayPrefController");
RouterTabs.map("carsettings://homepage/?subPage=energy_manage",
"com.android.car.settings.miauto.energy.EnergySettingFragment");
// ...
}
}关键细节:createrModuleHelper 里(RouterUriProcessor.java:272-276)对 classPath 有一个默认填充逻辑:
String classPath = path.classPath();
if (classPath == "" || classPath.isEmpty()) {
classPath = type.getQualifiedName().toString(); // 没填则用被注解类的全限定名
}
mapBuilder.addStatement("$T.map($S,$S)", tabs, path.path(), classPath);也就是说,只要你 @RouterProvider(path="xxx") 不显式写 classPath,生成代码里 value 就是你贴注解的那个类自己。这也是项目里绝大多数 Fragment 类型路由的写法。
3.4 生成的 RouterHelper.java 长什么样
源码逻辑见 createrRouterHelper()(RouterUriProcessor.java:178-246)。APT 源文件里同样给了完整示例(RouterUriProcessor.java:50-72)。
由于 RouterHelper 要调各子模块的 Module_xxx.map(),但 APT 编译 app 模块时未必能直接 import 到 settingsPage 子模块的类(在某些 make 编译场景会找不到类),作者放弃了直接调用,改用反射:
public final class RouterHelper {
public static void merge() {
try {
Class clazz_CarSettings = Class.forName(
"com.android.car.settings.router.Module_CarSettings");
java.lang.reflect.Method method_CarSettings =
clazz_CarSettings.getDeclaredMethod("map");
method_CarSettings.setAccessible(true);
method_CarSettings.invoke(null);
} catch (ClassNotFoundException e) {
// ignore —— 子模块没参与编译时静默跳过
} catch (Exception e) {
e.printStackTrace();
}
// 对 @Modules 数组里每一个模块名,都生成上面这样一段
}
}为什么用反射? 源码里有注释(RouterUriProcessor.java:186):“主动调用在 make 编译存在找不到类的情况”。子模块路由表是按需合并的,反射 + catch ClassNotFoundException 能让某个子模块未参与编译时不至于让整个 App 崩溃。
3.5 编译期流程图
flowchart TD A["业务工程师编码<br/>贴 @RouterProvider/@Module/@Modules"] --> B[kapt 触发 RouterUriProcessor] B --> C{"多轮处理"} C -->|"非最后一轮"| D["processAnnotations()<br/>收集被注解元素到 mAutoRegisterSet<br/>解析 @Module/@Modules 的值"] C -->|"最后一轮 processingOver()"| E["processModuleAnnotations()<br/>确定 mModule 和 mModules"] D --> C E --> F{"mModule 非空?"} F -->|是| G["createrModuleHelper()<br/>用 JavaPoet 生成<br/>Module_<mModule>.java"] F -->|否| H[跳过] G --> I{"mModules 数组非空?"} H --> I I -->|是| J["createrRouterHelper()<br/>用 JavaPoet 生成<br/>RouterHelper.java(反射合并)"] I -->|否| K[跳过] J --> L["生成产物落到<br/>build/generated/source/kapt/"]
3.6 路由表的数据结构
最终运行期 RouterTabs 持有的就是这样一个 HashMap(以项目里真实路径常量为例,见 CarSettingsJump.kt):
| key(path) | value(classPath) | 说明 |
|---|---|---|
carsettings://homepage/?subPage=volume | com.android.car.settings.miauto.volume.VolumeSettingsFragment | 声音页 |
carsettings://homepage/?subPage=vehicle_light | com.android.car.settings.miauto.lights.LightsSettingsFragment | 灯光页 |
carsettings://homepage/?subPage=iot_device | com.android.micar.settings.iot.IotSettingsFragment | IoT 页 |
carsettings://homepage/?subPage=autopilot | com.android.micar.settings.autopilot.AutopilotSettingsFragment | 驾驶辅助页 |
carsettings://homepage/?subPage=quick_control | com.android.car.settings.miauto.vehicle.VehicleControlSettingsFragment | 车辆控制页 |
| … | … | … |
数据结构图:
classDiagram class RouterTabs { -HashMap~String,String~ mHostInClassPath +map(alias, classPath)$ +tabs()$ HashMap +clearMap()$ } class RouterHelper { +merge()$ } class Module_micarVolumeSettings { +map()$ } class Module_micarLightSettings { +map()$ } RouterHelper ..> Module_micarVolumeSettings : 反射调用 map() RouterHelper ..> Module_micarLightSettings : 反射调用 map() Module_micarVolumeSettings ..> RouterTabs : 调 map() 填表 Module_micarLightSettings ..> RouterTabs : 调 map() 填表 note for RouterTabs "key=URI path<br/>value=处理类全限定名<br/>Fragment 或 PreferenceController"
📌 新手记住:
- 生成类固定放在
com.android.car.settings.router包下,子模块表叫Module_<模块名>,合并类叫RouterHelper- classPath 留空时,生成代码会自动填被注解类自己的全限定名,所以 Fragment 类型的路由一般都不写 classPath
- 合并类用反射调用子模块表,这是为了兼容某些子模块未参与编译时的容错,改这一段代码前请先搞清楚编译系统
- 想看自己新增的路由有没有进表,编译后到
app/build/generated/source/kapt/<flavor>/debug/com/android/car/settings/router/下找生成类
第四节 运行期:Router 是如何工作的(routerManager 模块)
4.1 RouterTabs —— 静态路由表
源码:routerManager/.../RouterTabs.java:5-24
public class RouterTabs {
private static HashMap<String, String> mHostInClassPath = new HashMap<>();
public static void map(String alias, String classPath) {
mHostInClassPath.put(alias, classPath); // APT 生成代码调这个填表
}
public static HashMap<String, String> tabs() {
return mHostInClassPath; // Router 查表时调这个
}
public static void clearMap() {
mHostInClassPath.clear();
}
}非常薄,就是个全局静态 Map。多线程并发读安全(只读不写时),但 map() 写入没有任何同步,所以框架的设计前提是:所有 map() 都在 App 启动时一次性完成,运行期不再写入。
4.2 IPageRouteHandler —— 业务回调契约
源码:routerManager/.../IPageRouteHandler.java:6-8
// 标识可以三方跳转的接口
public interface IPageRouteHandler {
void handleJump(Uri data);
}契约: Fragment 或 PreferenceController 实现该接口后,当 Router 查表命中并确认实例匹配时,会回调 handleJump(uri),业务在里面做实际跳转动作(滚动到某个 Preference、打开子页、刷新数据等)。
注意:RouterProvider 注释里写的
IPageHandler是历史笔误,项目里实际不存在 IPageHandler 这个接口,正确名字就是IPageRouteHandler。
4.3 Router —— 单例路由外观
源码:routerManager/.../Router.java:28-305。Router 是整个路由框架运行期的核心,核心字段、构造、对外 API 拆开讲。
4.3.1 关键字段(Router.java:30-43)
public class Router {
private static final String ROUTER_HELPER_PATH = "com.android.car.settings.router.RouterHelper";
private HashSet<Integer> mControllerNameHash = new HashSet<>(); // 所有 classPath 的 hashCode,快速判断一个类是否在路由表里
private HashMap<String, WeakReference<Fragment>> mControllerInFragment; // classPath → 它所在的 Fragment(弱引用)
private HashSet<Integer> mFragmentHash = new HashSet<>(); // 已注册路由的 Fragment 实例 hashCode
private HashMap<String, WeakReference<Object>> mControllerInstances; // controller 类名 → 实例(弱引用)
private HashMap<Integer, HashSet<String>> mFragmentControllers; // Fragment hashCode → 它名下所有 controller 类名
private Uri mPostUri; // 待分发的 URI
private FragmentCallback mFragmentCallback;
// ...
}注意全部用 WeakReference,Fragment/Controller 销毁后能自动 GC,避免内存泄漏。
4.3.2 构造函数:反射合并路由表(Router.java:46-62)
private Router() {
try {
Class clazz = Class.forName(ROUTER_HELPER_PATH);
Method method = clazz.getDeclaredMethod("merge");
method.setAccessible(true);
method.invoke(clazz); // 1. 调 RouterHelper.merge() 把所有 Module_xxx 灌进 RouterTabs
Iterator<String> iterator = RouterTabs.tabs().values().iterator();
while (iterator.hasNext()) {
String name = iterator.next();
mControllerNameHash.add(name.hashCode()); // 2. 把所有 classPath 的 hashCode 缓存起来,用于后续快速过滤
}
} catch (Exception e) {
Log.i("Router", "merge router failed");
}
}单例用静态内部类持有(Router.java:64-70):
private static final class RouterHolder {
private final static Router sRouter = new Router();
}
public static Router getInstance() {
return RouterHolder.sRouter;
}4.3.3 初始化:监听全局 Fragment 生命周期(Router.java:72-188)
Router.getInstance().init(application)(在 MainThreadStartTask.java:74 调用)做两件事:
- 注册
Application.ActivityLifecycleCallbacks,每个FragmentActivity创建时给它的FragmentManager注册FragmentLifecycleCallbacks FragmentCallback.onFragmentResumed是 URI 分发的真正触发点
为什么选 onFragmentResumed? 源码注释(
Router.java:151)原话:“由于项目是 Activity 多 Fragment 架构,因此分发过早会导致 UI 处理需要 delay,Resume Fragment 可见,时机比较晚,业务侧如果时机不是特别靠后,无需额外处理”。
4.3.4 dispatcher:暂存待分发 URI(Router.java:190-192)
public void dispatcher(Uri uri) {
mPostUri = uri; // 注意:这里只是暂存,不立即处理
}很关键的设计:外部 URI 进来时只暂存到 mPostUri,真正的查表和回调等到下一个 Fragment onResume 时才发生。这是因为 URI 通常是要交给某个已经存在或即将创建的 Fragment 处理,Fragment 没准备好时分发也没用。
4.3.5 dispatcherUri:Fragment Resume 时查表回调(Router.java:194-254)
精简后的核心逻辑:
private boolean dispatcherUri(Fragment f) {
if (mPostUri == null || f == null) return false;
Integer fragmentHash = f.hashCode();
Integer fragmentNameHash = f.getClass().getTypeName().hashCode();
// 优化:当前 Fragment 既不在已注册 Fragment 集合里,类名也不在路由表里,直接返回
if (!mFragmentHash.contains(fragmentHash)
&& !mControllerNameHash.contains(fragmentNameHash)) {
return false;
}
String uri = mPostUri.toString();
HashMap<String, String> classHashMap = RouterTabs.tabs();
LinkedList<String> routers = new LinkedList<>();
// 遍历路由表,找出所有可能匹配的 key(支持 contains / startsWith / 精确 三种匹配)
for (String saveUri : classHashMap.keySet()) {
if (uri.contains(saveUri) || uri.startsWith(saveUri)
|| TextUtils.equals(uri, saveUri)) {
routers.add(saveUri);
}
}
boolean dealUri = false;
for (String key : routers) {
String className = classHashMap.get(key);
// 情况 A:命中 Fragment 自己,且 Fragment 实现 IPageRouteHandler
if (TextUtils.equals(f.getClass().getTypeName(), className)
&& f instanceof IPageRouteHandler) {
((IPageRouteHandler) f).handleJump(mPostUri);
dealUri = true;
}
// 情况 B:命中的是 Controller,且该 Controller 当前挂在 Fragment f 上
WeakReference<Fragment> fragmentWeakReference = mControllerInFragment.get(className);
if (fragmentWeakReference == null || fragmentWeakReference.get() != f) continue;
WeakReference<Object> weakObject = mControllerInstances.get(className);
Object preferenceControler = weakObject == null ? null : weakObject.get();
if (preferenceControler instanceof IPageRouteHandler) {
((IPageRouteHandler) preferenceControler).handleJump(mPostUri);
dealUri = true;
}
}
return dealUri;
}两个分支对应路由表 value 的两种类型:
- value 是 Fragment 类名 → 命中时直接回调 Fragment 的
handleJump - value 是 Controller 类名 → 需要先通过
mControllerInFragment找到该 Controller 实例所挂的 Fragment,确认是当前 Fragment 后再回调 Controller 的handleJump
4.3.6 holdController:记录 Controller 实例(Router.java:258-279)
Controller 类型的路由需要知道实例,这一步在 PreferenceControllerListHelper.createInstance()(base/settingsBaseUi/.../PreferenceControllerListHelper.java:120)里完成:
preferenceController = (PreferenceController) preferenceConstructor.newInstance(params);
if (fragmentController != null) {
Router.getInstance().holdController(preferenceController,
fragmentController.getHostFragment()); // 告诉 Router:这个 controller 实例属于哪个 Fragment
}Router 拿到后(Router.java:258-279):
public synchronized void holdController(Object controller, Fragment controllerFragment) {
String controllerName = controller.getClass().getTypeName();
String fragmentName = controllerFragment.getClass().getTypeName();
if (mControllerNameHash.contains(controllerName.hashCode())
|| mControllerNameHash.contains(fragmentName.hashCode())) {
if (mControllerNameHash.contains(controllerName.hashCode())) {
mControllerInstances.put(controllerName, new WeakReference<>(controller));
mControllerInFragment.put(controllerName, new WeakReference<>(controllerFragment));
// 把 controller 名字挂到 Fragment 名下,Fragment 销毁时统一清理
int fragmentHash = controllerFragment.hashCode();
HashSet<String> controllerNames = mFragmentControllers.get(fragmentHash);
if (controllerNames == null) {
controllerNames = new HashSet<>();
mFragmentControllers.put(fragmentHash, controllerNames);
}
controllerNames.add(controller.getClass().getTypeName());
}
mFragmentHash.add(controllerFragment.hashCode());
}
}4.6 运行期 URI 跳转完整时序图
sequenceDiagram autonumber participant Caller as 外部调用方<br/>(语音/桌面/其他App) participant DL as DeeplinkActivity<br/>(语音搜索分支) participant HA as HomepageActivity<br/>(BaseCarSettingsActivity) participant R as Router (单例) participant RT as RouterTabs<br/>(静态路由表) participant F as 业务 Fragment<br/>(实现 IPageRouteHandler) participant C as 业务 Controller<br/>(实现 IPageRouteHandler) Caller->>HA: startActivity(Intent{<br/>action=android.settings.SETTINGS,<br/>data=carsettings://homepage/?subPage=volume}) Note over HA: AndroidManifest 已注册<br/>scheme=carsettings host=homepage HA->>R: Router.getInstance().dispatcher(intent.getData()) Note over R: 仅把 uri 暂存到 mPostUri Note over F: Fragment 生命周期推进 F->>F: onResume() R->>F: FragmentCallback.onFragmentResumed 触发<br/>dispatcherUri(f) R->>RT: tabs() 取全表 RT-->>R: HashMap<path, classPath> R->>R: 遍历 key,用 contains/startsWith/equal 匹配 mPostUri alt value 命中 Fragment 类名 R->>F: ((IPageRouteHandler)f).handleJump(uri) F->>F: 滚动到指定 Preference / 其他业务 else value 命中 Controller 类名 R->>C: 通过 mControllerInstances 找到实例<br/>((IPageRouteHandler)c).handleJump(uri) C->>C: Controller 处理跳转 end Note over R: dealUri=true 时清空 mPostUri Note over DL: 注:DeeplinkActivity 走的是另一条<br/>语音 widgetKey 流程,最终也启 HomepageActivity
📌 新手记住:
- Router 是单例,首次
getInstance()时才反射合并路由表,别在 ContentProvider 里过早触发(Router 持有 Application 引用)dispatcher(uri)只暂存不分发,真正分发在 FragmentonResume,如果你的页面跳转时机不对,先检查 Fragment 是否走到了 Resume- 匹配规则是
contains/startsWith/equals三种任一,所以 path 既是 URI 也是 URI 片段,设计 path 时要小心歧义(短 path 可能误命中长 URI)- Controller 类型路由必须让
PreferenceControllerListHelper走到holdController,否则 Router 找不到实例,永远不会回调
第五节 外部 URI 如何进入路由(DeeplinkActivity 与 HomepageActivity)
项目里有两个对外暴露的 Activity,职责不同,容易混淆,务必分清。
5.1 HomepageActivity —— carsettings:// URI 的真正入口
app/src/main/AndroidManifest.xml:205-229 中,HomepageActivity 注册了两条 intent-filter:
<activity android:name=".common.CarSettingActivities$HomepageActivity"
android:launchMode="singleTask" ... android:exported="true">
<!-- 1. 不带 scheme 的 action 跳转 -->
<intent-filter android:priority="1">
<action android:name="android.settings.SETTINGS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- 2. 带 carsettings scheme 的 URI 跳转(路由主入口) -->
<intent-filter android:priority="1">
<action android:name="android.settings.SETTINGS" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="carsettings" android:host="homepage" />
</intent-filter>
...
</activity>外部 App 想打开声音页,标准调用方式:
// 见 settingsPage/VehicleBodyControl/.../childseat/ChildSeatEntryController.kt:265-280 真实例子
val intent = Intent("android.settings.SETTINGS")
intent.addCategory(Intent.CATEGORY_DEFAULT)
intent.setData(Uri.parse("carsettings://homepage/?subPage=volume"))
startActivity(intent)URI 进入 HomepageActivity(其父类 BaseCarSettingsActivity)后:
// app/src/main/java/com/android/car/settings/common/BaseCarSettingsActivity.java:274-285
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleRouterIntent(intent);
}
private void handleRouterIntent(Intent intent) {
if (intent != null) {
PrefKeyMapUtils.INSTANCE.mapUriPrefKey(intent); // 顺便把 URI 里的 prefKey 映射成 voiceSearch 用
Router.getInstance().dispatcher(intent.getData());// 交给 Router 暂存
}
}注意是
onNewIntent处理,冷启动走onCreate路径,会在BaseCarSettingsActivity其他生命周期里调到dispatcher(详见该类)。热启动(HomepageActivity 已在栈顶)走onNewIntent。
5.2 DeeplinkActivity —— 语音搜索专用分支
app/src/main/java/com/android/car/settings/miauto/common/DeeplinkActivity.kt 的 manifest(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 不直接处理 carsettings:// URI,它处理的是另一种语义:语音助手通过 extra 传 widgetId / widgetKey,DeeplinkActivity 把它转成 voiceSearch extras,再 startActivity(HomepageActivity)(见 DeeplinkActivity.kt:117-132 的 createAndStartHomepageIntent)。
可以理解为:DeeplinkActivity 是语音搜索的”前置转译层”,最终落地还是回到 HomepageActivity,再由 Router 处理 URI。
5.3 三种典型入口对比
| 入口场景 | 谁启动 | 入口 Activity | 是否走 Router |
|---|---|---|---|
| 其他 App / 桌面快捷方式跳具体页 | startActivity(Intent(action=android.settings.SETTINGS, data=carsettings://...)) | HomepageActivity | 是 |
| 语音助手跳具体页(传 widgetKey) | 语音 App 启动 DeeplinkActivity | DeeplinkActivity → HomepageActivity | 是(转译后) |
| 设置内部跳子页 | SubSettingsActivity.newInstance(ctx, fragment) | SubSettingsActivity | 否(直接加载 Fragment,不经过 Router) |
📌 新手记住:
carsettings://URI 的入口是 HomepageActivity,不是 DeeplinkActivity- DeeplinkActivity 是语音 widgetKey 流程的转译层,最终也启 HomepageActivity
- 想从外部 App 打开设置某个页面,统一用
Intent("android.settings.SETTINGS")+data=carsettings://homepage/?subPage=<pageAlias>这个套路(参考ChildSeatEntryController.navigateToIotPage())
第六节 实战接入:让自己的页面能被外部 URI 打开
6.1 Fragment 类型路由接入步骤(最常见)
假设你新增了 FooSettingsFragment,想让它响应 carsettings://homepage/?subPage=foo:
第 1 步:在 PageAlias.kt(base/settingsBaseUi/.../pageroute/PageAlias.kt)加常量:
const val PAGE_FOO = "foo" // 新页面 alias第 2 步:在 CarSettingsJump.kt 加 URI 路径常量(参考 CarSettingsJump.kt:235 的 VOLUME_SETTINGS_URI_PATH):
const val FOO_URI_PATH = "$SETTING_SUBPAGE_URI_PREFIX$PAGE_FOO"
// 实际值:carsettings://homepage/?subPage=foo第 3 步:在你的 Fragment 上贴注解、实现 IPageRouteHandler:
// 参考真实例子 LightsSettingsFragment.java:53-58
@RouterProvider(path = CarSettingsJump.FooSettings.FOO_URI_PATH)
@Module(SettingsConstant.TAG_<你的模块>) // 你的模块在 SettingsConstant 里注册的常量
class FooSettingsFragment : TopLevelSettingsFragment(), IPageRouteHandler {
override fun handleJump(data: Uri) {
// 业务:根据 uri 滚动到指定 item、刷新数据等
}
}第 4 步:确认你的模块 build.gradle 里挂了 kapt(参考 settingsPage/micarVolume/build.gradle:79):
kapt project("${MiCarSettingsCommon_LibProjectName}:plugin:router:routerApt")并依赖了 annotation(运行期可见):
implementation project("${MiCarSettingsCommon_LibProjectName}:plugin:router:annotation")
implementation project("${MiCarSettingsCommon_LibProjectName}:plugin:router:routerManager")第 5 步:如果是新增业务子模块,还要在 SettingsApplication 的 @Modules({...}) 数组里把你的模块名加上(app/src/main/java/com/android/car/settings/miauto/SettingsApplication.java:29-35)。否则 RouterHelper 不会合并你的路由表!
第 6 步:编译,验证。到 app/build/generated/source/kapt/<flavor>/debug/com/android/car/settings/router/Module_<你的模块>.java 里看是否生成了一行 RouterTabs.map("carsettings://homepage/?subPage=foo", "...FooSettingsFragment")。
6.2 真实案例:三个对照样本
样本 1:VolumeSettingsFragment(典型 Fragment 路由,Kotlin/Java 混合)
位置:settingsPage/micarVolume/src/main/java/com/android/car/settings/miauto/volume/VolumeSettingsFragment.java:44-49
@VoiceSearchProvider
@Module(SettingsConstant.TAG_VOLUME)
@RouterProvider(path = CarSettingsJump.VolumeSettings.VOLUME_SETTINGS_URI_PATH)
@MonitorFragment
public class VolumeSettingsFragment extends AudioControlSettingsFragment implements
IPageRouteHandler {
// ...
@Override
public void handleJump(Uri data) {
if (data == null) return;
handleScrollUri(data); // 仅滚动到对应 Preference
}
}VOLUME_SETTINGS_URI_PATH 真实值(CarSettingsJump.kt:235):
const val VOLUME_SETTINGS_URI_PATH = "$SETTING_SUBPAGE_URI_PREFIX$PAGE_VOLUME"
// = "carsettings://homepage/?subPage=volume"样本 2:LightsSettingsFragment(显式 import 静态常量)
位置:settingsPage/micarLightSettings/src/main/java/com/android/car/settings/miauto/lights/LightsSettingsFragment.java:54-58
import static com.android.car.settings.common.pageroute.CarSettingsJump.CarLightLockSettings.LIGHT_URI_PATH;
// ...
@RouterProvider(path = LIGHT_URI_PATH)
@Module(SettingsConstant.TAG_Light)
public class LightsSettingsFragment extends AudioControlSettingsFragment implements
IPageRouteHandler { ... }LIGHT_URI_PATH(CarSettingsJump.kt:73-75):
const val LIGHT_URI_PATH = "$SETTING_SUBPAGE_URI_PREFIX$PAGE_LIGHT"
// = "carsettings://homepage/?subPage=vehicle_light"样本 3:外部调用方 —— ChildSeatEntryController 触发跳 IoT 页
位置:settingsPage/VehicleBodyControl/.../childseat/ChildSeatEntryController.kt:265-280
private fun navigateToIotPage() {
val intent = Intent("android.settings.SETTINGS")
intent.addCategory(Intent.CATEGORY_DEFAULT)
intent.setData(Uri.parse(CarSettingsJump.VehicleIOTSettings.IOT_DEVICE_CONTROL_URI_PATH))
intent.putExtra(CarSettingsJump.VehicleIOTSettings.CHILD_SEAT_MODE_ID, firstChildSeatDid)
getContext().startActivity(intent)
}这个例子完整展示了”其他业务如何跳进设置”:构造 Intent → 设 data 为 carsettings://... URI → 可选带 extra → startActivity。
6.3 Controller 类型路由(了解即可)
当某个 URI 需要由 PreferenceController 处理、而不是 Fragment 处理时,把 @RouterProvider 贴在 Controller 类上,并显式指定 classPath(默认值就是 Controller 自己的全限定名,所以一般也不写)。APT 源文件里给的示例就是 Controller 类型(RouterUriProcessor.java:43):
RouterTabs.map("carsettings://hudsetting",
"com.android.car.settings.miauto.display.HudPhysicalTabLayPrefController");Controller 类型路由依赖 PreferenceControllerListHelper.createInstance() 在创建 controller 时调 Router.holdController(...) 把实例注册进 Router。当前项目里绝大多数路由都是 Fragment 类型,Controller 类型较少。
6.4 接入检查清单
| 检查项 | 命令/位置 |
|---|---|
| 注解有没有贴 | grep @RouterProvider 你的新文件 |
| Module 名对不对 | 用 SettingsConstant.kt 里的常量,别硬编码 |
| 子模块是否在 @Modules 里 | app/src/main/java/com/android/car/settings/miauto/SettingsApplication.java:29-35 |
| build.gradle 是否 kapt | grep routerApt 你的模块/build.gradle |
| 编译后路由表是否生成 | 看 app/build/generated/source/kapt/.../Module_<模块>.java |
| 外部调用是否生效 | adb 验证:adb shell am start -a android.settings.SETTINGS -d "carsettings://homepage/?subPage=你的alias" |
📌 新手记住:
- Fragment 路由接入五件套:
PageAlias常量 +CarSettingsJumpURI 常量 +@RouterProvider+@Module+implements IPageRouteHandler- 接入后
adb shell am start -a android.settings.SETTINGS -d "<你的 uri>"是最快的验证方式- 新增模块一定记得在
SettingsApplication.@Modules数组里加名字,这是最容易漏的一步,漏了不会编译报错,只是路由静默失效
第七节 路由与 SubSettingsActivity 的关系
新同事最容易混淆的一点:路由框架和 SubSettingsActivity 各管一摊,不要混用。
7.1 SubSettingsActivity 是什么
源码:app/src/main/java/com/android/car/settings/common/SubSettingsActivity.java:30-62
public class SubSettingsActivity extends BaseCarSettingsActivity {
private static final String KEY_SUB_SETTINGS_FRAGMENT = "key_sub_settings_fragment";
private static final String KEY_SUB_SETTINGS_FRAGMENT_ARGS = "key_sub_settings_fragment_args";
public static Intent newInstance(@NonNull Context context, @NonNull Fragment fragment) {
String fragmentClass = fragment.getClass().getName();
Intent intent = new Intent(context, SubSettingsActivity.class);
intent.putExtra(KEY_SUB_SETTINGS_FRAGMENT, fragmentClass);
intent.putExtra(KEY_SUB_SETTINGS_FRAGMENT_ARGS, fragment.getArguments());
return intent;
}
@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;
}
}它就是一个通用 Fragment 容器:收到 Intent → 通过 extra 取出 Fragment 类名 → 反射实例化 → 作为内容 Fragment 显示。它完全不走 Router,也不需要 carsettings:// URI。
7.2 边界划分
| 场景 | 用什么 | 例子 |
|---|---|---|
| 从 App 内部点 Preference 跳子页 | SubSettingsActivity.newInstance(ctx, fragment) | 设置里点”位置调节”二级页 |
| 从 App 内部代码启动一个新 Fragment 页 | 同上,或 BaseCarSettingsActivity.switchToFragment(...) | 内部业务跳转 |
| 从外部 App / 语音 / 桌面快捷方式跳具体页面 | carsettings:// URI + Router | 语音说”打开灯光设置” |
| 路由命中后,Fragment 内部还需要打开更深子页 | handleJump 里再调 SubSettingsActivity.newInstance | URI 跳到一级页后,再展开二级 dialog |
7.3 二者配合的典型模式
flowchart LR Ext[外部 App] -->|"Intent data=carsettings://..."| HA[HomepageActivity] HA -->|dispatcher| R[Router] R -->|查表| RT[(RouterTabs)] R -->|handleJump| F1[目标一级 Fragment<br/>如 VolumeSettingsFragment] F1 -.需要进二级页.-> SSA[SubSettingsActivity] SSA -->|newInstance + 反射| F2[二级 Fragment]
简单说:Router 负责”定位到一级页面 Fragment”,Fragment 内部如果要再下钻,用 SubSettingsActivity。
📌 新手记住:
- 外部 URI → 走 Router;App 内部跳转 → 走 SubSettingsActivity
- 路由命中后的
handleJump里可以再调SubSettingsActivity,二者不冲突- 别把”自己写的 Fragment 类名”硬塞进 Intent extra 当路由用,这是绕开框架,后续没法被语音、桌面等外部入口复用
第八节 常见坑与 FAQ
Q1: 我贴了 @RouterProvider,但路由就是不生效。
按 6.4 检查清单逐项排查。最高频三个原因:(1) 模块没在 @Modules 数组里;(2) build.gradle 没加 kapt project(...:routerApt);(3) Fragment 没实现 IPageRouteHandler。
Q2: 我用 adb shell am start 启动,设置打开了但没跳到目标页。
多半是 URI 没匹配上。Router 用的是 contains/startsWith/equals 三种匹配,你的 path 必须出现在实际 URI 字符串里。打印一下 RouterTabs.tabs() 看实际 key,对比 adb 命令里的 URI。
Q3: 一个 URI 能不能同时对应多个处理器?
可以。dispatcherUri 里用 LinkedList<String> routers 收集所有匹配的 key(Router.java:210-219),然后遍历回调。但实务中不建议故意设计一对多,会让行为难以预测。
Q4: 多重嵌套 Fragment 路由能处理吗?
源码注释(Router.java:156-160)明确说:只处理到二级嵌套,再深的不支持。“多重嵌套会导致 Fragment 生命周期的复杂性和不确定性,暂时不考虑”。
Q5: 路由表的写入是线程安全的吗?
RouterTabs.map() 是非同步写入。设计前提是”App 启动时单线程合并完毕”,运行期不要再调 map()。别在业务代码里手动调 RouterTabs.map(),会让表污染。
Q6: Router 的 URI 匹配为什么不用正则?
源码注释(Router.java:225):“全匹配,暂无做正则”。当前 contains/startsWith/equals 已能满足业务,正则会引入性能和歧义问题。
附录:关键文件清单
| 文件 | 作用 |
|---|---|
settingsCommon/plugin/router/annotation/src/main/java/com/android/car/settings/router/annotation/RouterProvider.java | 路由声明注解 |
settingsCommon/plugin/router/annotation/src/main/java/com/android/car/settings/router/annotation/Module.java | 模块名注解 |
settingsCommon/plugin/router/annotation/src/main/java/com/android/car/settings/router/annotation/Modules.java | 模块合并清单注解 |
settingsCommon/plugin/router/routerApt/src/main/java/com/android/car/settings/router/apt/Constants.java | 生成类命名常量 |
settingsCommon/plugin/router/routerApt/src/main/java/com/android/car/settings/router/apt/RouterUriProcessor.java | APT 处理器,生成路由表 |
settingsCommon/plugin/router/routerManager/src/main/java/com/android/car/settings/router/manager/Router.java | 运行期路由单例外壳 |
settingsCommon/plugin/router/routerManager/src/main/java/com/android/car/settings/router/manager/RouterTabs.java | 静态路由表 HashMap |
settingsCommon/plugin/router/routerManager/src/main/java/com/android/car/settings/router/manager/IPageRouteHandler.java | 业务回调接口 |
settingsCommon/plugin/router/routerManager/src/main/java/com/android/car/settings/common/SettingsConstant.kt | 模块名常量定义 |
app/src/main/java/com/android/car/settings/miauto/SettingsApplication.java | @Modules 主入口 |
app/src/main/java/com/android/car/settings/common/BaseCarSettingsActivity.java | dispatcher 调用点 |
app/src/main/java/com/android/car/settings/common/SubSettingsActivity.java | 内部跳转容器 |
app/src/main/java/com/android/car/settings/miauto/common/DeeplinkActivity.kt | 语音搜索转译入口 |
base/settingsBaseUi/src/main/java/com/android/car/settings/common/PreferenceControllerListHelper.java | holdController 调用点 |
base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/CarSettingsJump.kt | URI 路径常量定义 |
base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/PageAlias.kt | 页面 alias 常量 |
app/build.gradle | 主 App 接入 kapt routerApt 的位置 |
app/src/main/AndroidManifest.xml | HomepageActivity 的 carsettings scheme 声明 |