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/routerAptkapt 注解处理器,编译期生成路由表RouterUriProcessor / Constants
settingsCommon/plugin/router/routerManager运行期路由管理Router / RouterTabs / IPageRouteHandler

整体走的是 “编译期收集 + 运行期查表 + 反射合并” 的经典套路,和 ARouter、WMRouter 思路一致,但更轻量,只解决”设置内部页面定位”,不处理跨进程。


读完你能掌握什么

  1. 看到 @RouterProvider(path = ...)@Module(...)@Modules({...}) 三种注解时,清楚地知道每个字段填什么、起什么作用
  2. 看懂编译期生成的 Module_xxx.javaRouterHelper.java 长什么样、生成到哪里、为什么这么命名
  3. 能复述一个 carsettings://homepage/?subPage=volume URI 从外部 App 调用 startActivity 开始,经过 HomepageActivityRouter.dispatcher → Fragment onResumeIPageRouteHandler.handleJump 的完整时序
  4. 能独立给自己的页面接入路由,让外部 URI 能打开你自己写的 Fragment 或 Controller
  5. 知道路由与 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 静态 HashMapRouter 构造函数
运行期分发外部 URI 进入 BaseCarSettingsActivity,调用 Router.dispatcher(uri) 暂存;Fragment onResumeRouter 查表,命中后回调 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 的类全限定名,空则默认用被注解类自己
}

字段含义:

字段类型必填含义
pathString该页面对外暴露的 URI。支持精确匹配,也支持 Router.dispatcherUri 中的 contains/startsWith 模糊匹配。例:carsettings://homepage/?subPage=volume
classPathString处理 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.routerModule_<@Module 的 value>扫描到 @Module
总合并类com.android.car.settings.routerRouterHelper(固定名)扫描到 @Modules

例如 @Module("micarVolumeSettings") 会生成 com.android.car.settings.router.Module_micarVolumeSettings

3.2 RouterUriProcessor 处理流程

源码:routerApt/.../RouterUriProcessor.java:75-289

RouterUriProcessor 继承自 AbstractProcessor,核心流程:

  1. getSupportedAnnotationTypes() 声明要处理三种注解:RouterProviderModuleModules(RouterUriProcessor.java:98-104)
  2. 多轮处理(process 方法,Javac 会在每轮注解处理后回调):
    • 非最后一轮:processAnnotations() 把当前模块扫描到的所有被注解元素收集到 mAutoRegisterSet,并解析 @Module/@Modules 的值(RouterUriProcessor.java:163-176)
    • 最后一轮(processingOver()):此时所有注解元素都已收集齐,正式生成代码
  3. 最后一轮生成:
    • 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_&lt;mModule&gt;.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=volumecom.android.car.settings.miauto.volume.VolumeSettingsFragment声音页
carsettings://homepage/?subPage=vehicle_lightcom.android.car.settings.miauto.lights.LightsSettingsFragment灯光页
carsettings://homepage/?subPage=iot_devicecom.android.micar.settings.iot.IotSettingsFragmentIoT 页
carsettings://homepage/?subPage=autopilotcom.android.micar.settings.autopilot.AutopilotSettingsFragment驾驶辅助页
carsettings://homepage/?subPage=quick_controlcom.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 调用)做两件事:

  1. 注册 Application.ActivityLifecycleCallbacks,每个 FragmentActivity 创建时给它的 FragmentManager 注册 FragmentLifecycleCallbacks
  2. 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) 只暂存不分发,真正分发在 Fragment onResume,如果你的页面跳转时机不对,先检查 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-132createAndStartHomepageIntent)。

可以理解为:DeeplinkActivity 是语音搜索的”前置转译层”,最终落地还是回到 HomepageActivity,再由 Router 处理 URI。

5.3 三种典型入口对比

入口场景谁启动入口 Activity是否走 Router
其他 App / 桌面快捷方式跳具体页startActivity(Intent(action=android.settings.SETTINGS, data=carsettings://...))HomepageActivity
语音助手跳具体页(传 widgetKey)语音 App 启动 DeeplinkActivityDeeplinkActivityHomepageActivity是(转译后)
设置内部跳子页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:235VOLUME_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 → 设 datacarsettings://... 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 是否 kaptgrep 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 常量 + CarSettingsJump URI 常量 + @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.newInstanceURI 跳到一级页后,再展开二级 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.javaAPT 处理器,生成路由表
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.javadispatcher 调用点
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.javaholdController 调用点
base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/CarSettingsJump.ktURI 路径常量定义
base/settingsBaseUi/src/main/java/com/android/car/settings/common/pageroute/PageAlias.kt页面 alias 常量
app/build.gradle主 App 接入 kapt routerApt 的位置
app/src/main/AndroidManifest.xmlHomepageActivity 的 carsettings scheme 声明