1. 为什么要通过路由拿Fragment而不是直接new
1.1 组件化架构下的依赖困境
先说一个很典型的场景。如果你所在的App是组件化架构,主工程需要加载一个来自“用户模块”的Fragment,而主工程在编译期不应该依赖用户模块的代码,因为那样一来所谓的组件化就名存实亡了。但Fragment的展示又必须有一个实例对象,这就出现了一个矛盾:代码层面我需要它,架构层面我又不能直接引用它。
最常见的土办法是反射,用Class.forName拼出完整类名,再通过构造器创建实例。这招确实能破开编译期依赖,但代价很大:类名一改就崩、反射调用没有编译期检查、IDE里没法跟踪、升级重构的时候开发者根本不知道谁在用这些字符串。我见过不止一个项目因为反射拼类名拼错,发布以后线上白屏,查了半天发现是某个字符串多打了个字母。
ARouter的出现其实本质上就是在解决这件事。它把“路由”的概念从Activity扩展到了Fragment和Provider服务,通过一个公共的接口定义和一套注解声明,把组件之间的关系从“强依赖”降级成“按需查找”。你主工程只需要依赖一个纯接口模块,实现类在业务模块里藏好,运行的时候通过ARouter把实例拉出来,整个依赖关系就是干净的。
1.2 ARouter能为Fragment做的事
很多开发者对ARouter的理解停留在“跳转Activity”上,实际上它针对Fragment提供了三层能力,值得先说清楚,后面实操才不会懵。
第一层是通过路由路径直接创建Fragment实例,也就是形如build("/module/fragment").navigation()的用法。这种方式适合你已经拥有了目标模块的依赖,只是不想写死类名的情况。它返回的是Object,需要自己强转成Fragment。
第二层是在基础库中根据ServiceLoader思想定义的IProvider接口,这是ARouter专门为服务发现设计的一套机制,通过ARouter.getInstance().navigation(接口.class)拿到接口实现类。这种方式适合完全没有编译期依赖的场景,真正实现了组件的接口隔离,也是本文的核心内容。
第三层是通过依赖注入拿到Fragment相关的服务对象,比如在某个业务类里直接@Autowired一个Provider接口,ARouter会在初始化时自动注入实例,不需要手动获取,这适合在框架层集成的场景。
1.3 获取方式选型对比
我把几种常见的Fragment获取方式放在一起对比下,看完你就能理解为什么服务发现这种方式在组件化项目里最受用。
| 获取方式 | 编译期依赖 | 灵活性 | 可维护性 | 适用场景 |
|---|---|---|---|---|
| 直接new | 需要依赖模块 | 差 | 最好,IDE可跟踪 | 模块内部复用 |
| 反射Class.forName | 不需要 | 差 | 差,字符串拼错即崩 | 万不得已的兜底 |
| ARouter路径跳转 | 不需要 | 中 | 中,路径仍靠约定 | 临时获取场景 |
| ARouter服务发现 | 不需要 | 高 | 最好,接口归档统一 | 组件化高内聚场景 |
直接new的问题前面说了,编译期依赖无法根治。反射的问题在于没有约束,谁都能写,写错了也不报错。ARouter路径跳转虽然比反射安全,但它绕不开路径参数,路径本身就是个字符串,还是靠口头约定。服务发现方案则把“依赖面”缩减到一个接口文件上,实现类具体是谁、内部做了什么,调用方一概不关心,这是最符合依赖倒置原则的写法。
2. 核心原理:ARouter服务发现的运作机制
2.1 从ServiceLoader说起
如果你用过Java标准库里的ServiceLoader,那理解ARouter的IProvider机制就非常容易。ServiceLoader干的事情是:在jar包里的META-INF/services目录下放一个以接口全限定名为文件名的文件,文件内容写这个接口的实现类全限定名,运行时由框架自动加载并实例化。
ARouter的思路和这个如出一辙,只是更适合Android环境做了改良。它通过apt(注解处理器)在编译期扫描所有标注了@Route注解的类,把它们的元数据自动写入路由映射表文件,其中就包括实现了IProvider接口的类。App启动时初始化ARouter,框架会读取这些映射文件,把路径和类的关系、接口和实现类的关系全部加载到内存,形成一个路由仓库,之后navigation就是在仓库里查记录。
这么做有一个非常核心的收益:服务的注册不依赖任何手动代码。你只要在实现类上加一个@Route注解,框架自动帮你注册。这比ServiceLoader手动维护配置文件要优雅得多,也避免了配置文件写错、忘记添加实现类的问题。
2.2 和Activity路由相比,差异在哪
Activity路由的本质是目标路径映射和Intent导航。ARouter根据你传入的路径从映射表中找到目标Activity的类,然后构造Intent,通过startActivity去启动,参数通过Intent的Bundle传递。
而Fragment服务获取的本质是接口映射和实例返回。ARouter根据你传入的接口类从映射表中精确匹配实现类,拿到Class对象后走无参构造器创建一个实例返回来。整个过程不涉及页面栈、不涉及生命周期,纯粹是一个轻量级的依赖查找。
所以两者在底层是两套逻辑:Activity流程要处理标志位、RequestCode、转场动画这些;Provider流程则更贴近一个对象工厂。我在接入的时候感受特别明显,获取Fragment几乎没有任何副作用,你拿到的就是一个普通实例,完全纳入自己的生命周期管理里。
2.3 一张图看透调用链
虽然我们不能用流程图,但可以用文字把调用链捋一遍,这个链路值得刻在脑子里,因为后面排查问题全靠它。
第一步:编译期apt生成ARouter$$Root$$xxx和ARouter$$Providers$$xxx这些辅助类,其中Providers类里有这样一个方法:给所有实现了IProvider的类声明索引,类似registry.put(接口类名, 实现类信息)。
第二步:ARouter.init(application)时通过反射把这些辅助类加载进来,遍历其中所有的路由记录,填入Warehouse(仓库)这个全局注册表里。
第三步:你调用navigation(IUserComponent.class)时,框架从Warehouse的providersIndex里找到对应接口的实现类,然后通过Class.forName获得Class并反射创建一个新实例,如果是单例模式则为每个线程维护同一个实例。
第四步:返回对象交到你手里,之后的Fragment事务操作、参数设置、添加进容器,都是你自己的业务逻辑了。
2.4 性能开销有多大
这里给个经验数据。ARouter初始化时加载的映射表是做过分组懒加载的,也就是用到了才加载对应的组,App首屏启动时并不会把所有组都加载完,所以初始化对启动耗时的影响基本可以忽略。获取Fragment实例走一次反射,单次耗时通常在毫秒级以下,这在UI操作、点击跳转这种频率下完全没有感知。
但有一个点容易被忽略:Provider是按需创建的,整个仓库里所有Provider实现类在首次加载某一组时会一起被类加载器加载吗?实测下来,类是在navigation时才真正被Class.forName触发的,初始化只记录元数据。也就是说你只用其中一个实现类,不会把其他组类的Provider类全都提前加载,这在内存上的表现比较健康。
3. 实操步骤:从定义接口到拿到Fragment
3.1 第一步:在公共层抽取接口
整个方案的第一步不是在模块里写实现类,而是在公共依赖模块里定义一个接口。这个模块通常叫service-api或者base,它的特点是几乎不被业务逻辑污染,只放接口、常量、基础实体。
接口定义我建议这样写:
public interface IUserComponent extends IProvider { // 获取用户主页Fragment Fragment getUserHomeFragment(); // 获取用户信息 UserBean getUserInfo(); }要实现IProvider接口,IProvider本身继承自IComponent接口,内部定义了init(Context context)方法。所以你的接口继承了IProvider之后,就隐式宣告实现类需要具备init能力,但这里的返回值是业务自定义的,不局限于Fragment。
要特别注意的是:接口的粒度不要设计得太细。我见过有人把接口拆到每个方法一个接口的程度,结果是获取Fragment要查两三次路由仓库,代码极其繁琐。推荐的做法是以业务域为维度设计,比如用户域一个接口、订单域一个接口、消息域一个接口,每个域提供三五个方法,接口数量控制在十几个左右,维护成本直线下降。
3.2 第二步:模块里实现Provider并注册
有了接口定义,接下来在你的业务模块里写实现类:
@Route(path = "/service/user", name = "用户服务") public class UserComponentImpl implements IUserComponent { @Override public Fragment getUserHomeFragment() { UserHomeFragment fragment = new UserHomeFragment(); Bundle extras = new Bundle(); extras.putString("from", "userComponent"); fragment.setArguments(extras); return fragment; } @Override public void init(Context context) { // 服务初始化逻辑,可留空 } }注意几个细节。@Route的path是这个Provider的路由地址,它的前缀最好和业务相关,比如用户模块统一用/service/user起始,这样在路由表里一眼就能看出归属。path不是随便写的,它和Activity路由共用同一个映射表,所以尽量避免与Activity路径重复。init方法会在框架创建实例之后调用,如果你有需要在服务创建时做初始化的操作,就放在这里面,比如读取本地配置、预加载数据等。
如果你用的是Kotlin,类要继承IProvider接口,同样加上@Route注解:
@Route(path = "/service/user", name = "用户服务") class UserComponentImpl : IUserComponent { override fun getUserHomeFragment(): Fragment { return UserHomeFragment().apply { arguments = Bundle().apply { putString("from", "userComponent") } } } override fun init(context: Context?) { } }3.3 第三步:在主工程中获取Fragment
定义好接口和实现类,获取就是一行代码的事:
IUserComponent service = (IUserComponent) ARouter.getInstance().navigation(IUserComponent.class); if (service != null) { Fragment fragment = service.getUserHomeFragment(); getSupportFragmentManager() .beginTransaction() .replace(R.id.container, fragment) .commit(); }这里有一个容易被新手忽视的点:navigation(Class)返回的是Object,你说我明明拿到的就是IUserComponent,强转一下就好。但你更要关心的是返回null的情况。框架如果找不到接口对应的实现类,会返回null,不做任何提示。这就意味着你要在使用前判空,否则一个空指针直接打崩页面。
如果接口实现类注册了不止一个,比如两个模块都实现了同一个接口,那么ARouter会以后者为覆盖者,后扫描到的会把先扫描到的覆盖掉。这在大型项目中非常容易埋坑,后面我专门说怎么排查这个问题。
3.4 第四步:带参数与回调的场景扩展
光拿一个Fragment通常满足不了复杂业务,比如你还需要把用户ID传给Fragment来展示不同用户的信息。由于接口方法是你自己定义在IUserComponent里的,直接方法参数传递是自然的方式,我推荐的做法是这样:
public interface IUserComponent extends IProvider { Fragment getUserHomeFragmentWithParam(String userId); Fragment getUserProfileFragment(String userId, boolean editable); }你可能会想,我把参数塞到Fragment的Arguments里不行吗?也行,但方法参数传递和arguments各有用处——方法参数适合接口里做业务判断,比如根据userId判断当前用户能不能编辑,需要在接口实现类里处理逻辑的场景更合适;而arguments适合Fragment自己独立工作、不想依赖外面传参的场景。
还有一种场景是Fragment内部需要向外回调结果,比如用户操作完成要把结果返回到宿主页。遇到这种需求,我通用的做法是在接口里提供方法来获取回调设置器,或者直接把回调接口塞到方法参数里。比如这样:
public interface IUserComponent extends IProvider { Fragment getUserLoginFragment(LoginCallback callback); } public interface LoginCallback { void onLoginSuccess(String token); }但必须在接口层定义回调接口,不能把实现类里某个Fragment的自定义回调类型暴露出来,否则公共接口就反向依赖了业务模块,架构又乱了。
3.5 合理利用@Autowired注入接口
如果你不想每次用的时候都手动navigation一次,可以借助ARouter的注入能力。做法是:在属性上标注@Autowired,然后在初始化时调用ARouter.getInstance().inject(this):
public class MainFragment extends Fragment { @Autowired IUserComponent userComponent; @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); ARouter.getInstance().inject(this); if (userComponent != null) { // 直接使用 } } }这本质上还是调用了navigation,只是把繁琐的获取逻辑放到了框架层,代码更干净。注意一点,inject方法必须在userComponent使用之前调用,对于Fragment来说放在onAttach或者onCreate里是最稳妥的,因为onViewCreated里用就已经有先后顺序问题了。
4. 不同场景下的使用技巧与参数细节
4.1 Fragment和Activity混合路由
一个现实的情况是:你的路由表里既有Activity的路径,也有Fragment的路径,还有Provider的路径,它们都在同一个仓库里。因此你可以打通业务闭环——先从路由拿Provider,再从Provider拿Fragment,Fragment里内部跳转Activity又走路由路径。
举例来说,用户主页Fragment内部有个按钮要跳到用户设置Activity,那Fragment里直接写:
ARouter.getInstance().build("/user/setting").withString("userId", userId).navigation();这里的参数传递和activity跳转的细节不需要Provider参与,路径直接解决。所以Provider、Activity、Fragment三者形成进阶的依赖关系:Provider管服务发现,Activity管跨模块导航,Fragment管可复用页面单元。这种分层在大型项目里用起来是非常顺手的。
4.2 泛型与类型转换的处理
ARouter的navigation(Class)返回类型是Object,原因是它要兼容Provider和Activity混合查找的通用场景。这带来了一个小麻烦:比如在一个MVP模式的Presenter层写一个通用方法,想接收某个Provider并调用它的方法,Java的泛型在强转时容易出现警告。
一个规避方案是写一个通用的服务定位工具类:
public final class ServiceRouter { public static <T extends IProvider> T get(Class<T> serviceClass) { Object obj = ARouter.getInstance().navigation(serviceClass); if (obj == null) { throw new ServiceNotFoundException("服务未找到: " + serviceClass.getName()); } return serviceClass.cast(obj); } private ServiceRouter() { } }封装以后,调用方就可以完全忘掉强转和null判断:
IUserComponent userComponent = ServiceRouter.get(IUserComponent.class);这种方式特别适合团队规范统一,不推荐每个人都分头写一堆强转和判空代码,很容易因为判空遗漏出现空指针。抛异常也要注意时机,你封装的工具类建议先返回默认实现或者包装NullObject对象,直接抛RuntimeException会导致上层崩,酌情处理。
4.3 Kotlin侧的语法优化
Kotlin的集成没有障碍,但在语法上有些优化技巧。一个是属性委托,如果你觉得@Autowired每次都要inject还是繁琐,那可以自己写一个高级一点的Kotlin属性委托:
class ServiceDelegate<T : IProvider>(private val serviceClass: Class<T>) { private var cached: T? = null operator fun getValue(thisRef: Any, property: KProperty<*>): T { cached?.let { return it } return ServiceRouter.get(serviceClass).also { cached = it } } } class MainFragment : Fragment() { private val userComponent: IUserComponent by ServiceDelegate(IUserComponent::class.java) }cached字段保证了后续调用不重复查路由表,类反射只发生一次,比每次方法调用都去navigation要高效。当然框架层其实也有单例缓存逻辑,如果实现类用@Route(..., singleton = true)声明,仓库会默认缓存到线程局部变量里,类似ThreadLocal,每次返回同一个实例。
4.4 初始化时机与多进程场景
ARouter在两个地方容易被忽略,一个是必须在Application的onCreate里初始化,另一个是如果有多个进程,每个进程都要初始化。
我在之前一个项目里吃过亏:有个数据上报进程跑在单独的进程里,也用了组件内的ARouter服务,但Application只初始化了一次,导致那个进程的ARouter仓库是空的,服务获取直接返回null,排查了一下午才定位到是多进程初始化遗漏。
所以主进程和子进程都要调用ARouter.init(this)。如果你进程比较多,建议在Application的onCreate里主动判断是否需要初始化,可以直接不区分进程,所有进程统一初始化一遍,同时判断是否处于调试模式,决定是否开启日志。
4.5 路由冲突与分组
路由冲突是一个比较隐蔽的问题。前面说了所有实现类的@Route路径都注册在同一张表里,如果不同模块的Provider和Activity路径互相覆盖,ARouter在Debug模式下会直接弹窗提示,而Release模式下则是后者覆盖前者,行为非常危险。
我的处理方式是规范化路径命名:Activity统一前缀/activity/模块名/页面名,Provider统一前缀/service/模块名/服务名,Fragment统一前缀/fragment/模块名/页面名。命名规范不只是为了好看,更重要的是预压制肉眼可查的冲突风险。上面三个分类前缀一旦建立,在Review时发现跨模块路径归错类,就能直接打回修改。
5. 常见问题与排查技巧实录
5.1 navigation返回null,接口服务找不到
这是接入ARouter服务时遇到最多的异常。如果你在Debug模式下运行,ARouter的日志会打印完整的路由表加载情况,先开启日志看一眼。
# 开启Debug模式日志 ARouter.openDebug(); ARouter.openLog();日志文件里搜Providers或者接口全限定名,确认以下几点:实现类的@Route注解是否写了、path是否在模块自己的路由分组文件里、模块的gradle是否开启了apt/annotationProcessor依赖、模块是否在打包时被排除掉了。
特别要提醒一个很多团队会在代码混淆里踩的坑:开启混淆之后,ARouter的实现类被改名,路由表的字符串和类名对不上,导致运行时候找不到。解决办法是在proguard-rules.pro里增加保留规则:
-keep public class * implements com.alibaba.android.arouter.facade.template.IProvider { *; }如果你用了ARouter,混淆规则里保留注解和IProvider子类是最基本的,没有配置那Debug正常、Release必炸的情况基本上就是这一条造成的。
5.2 找得到服务,但拿到Fragment后空白
拿到服务了,Fragment也返回了,但replace进去以后一片白,这通常是路由衔接的问题。先自查Fragment的构造器,确认是否使用了无参构造器,如果Fragment定义里写了有参构造器,ARouter走反射实例化时是没法绕过构造器参数的限制的,会直接创建失败或者返回异常实例。
这里有个特别妖的坑:Fragment通过Provider方法返回时,已经有参数设置了,但你在宿主里又调用setArguments,会导致参数丢失或冲突。我曾经有个页面在外面setArguments传了一个key,Provider内部的Fragment也set了一个同key不同value的,后面读出来的是Provider里的那个,页面数据死活不对。
建议是参数的设置责任明确掉:Provider返回Fragment时负责设置默认参数,宿主需要覆盖时用fragment.getArguments().putAll(...)方式合并,不要重新setArguments。
再有一个经典场景是replace的时机和Fragment的onViewCreated不同步,尤其是Launcher模式变化时预加载的数据在自己管理的缓存里没准备好,表面上像路由框架问题,实际是页面自身生命周期管理问题,这种情况建议在Fragment顶部加一个加载态,数据就绪后再切换真实内容。
5.3 同一接口多个实现类,谁生效了
大型团队里经常出现接口和实现类的数量不匹配的问题。排查这类问题最好的方式是通过路由分组文件查看扫描记录,ARouter会把所有的@Route类信息在编译期生成到build/generated/source/apt目录里,打开对应组文件直接搜索接口全限定名,看出现几次,日志里面最后一次加载的优先级高,容易被覆盖掉。
如果真的出现覆盖,架构上大概率是业务域划分不清导致的。接口的职责边界就不该让两个模块可以同时实现,遇到这种情况我会先回看接口定义,猜测到底哪一个实现更符合该域的单一职责,另一个实现类要么改路径和接口,要么就明确降级为内部服务,不再挂到公共接口上。
5.4 代码混淆导致获取失败
混淆这个点重要到单独拿出来多说一句。除了前面说的保留IProvider子类规则,你还需要确保接口本身不被改名,因为路由仓库里按接口全限定名查找:
-keep interface com.yourpackage.service.api.** { *; }如果你的接口类名被混淆了,那仓库里的字符串还是源码时的全限定名,运行时用被混淆的类去对比,比对不上直接返回null。这个问题在Debug下永远不会现身,Release才会出现,建议App的CI流水线上加一个release构建后的冒烟测试,专门跑一遍核心服务的获取链路。
5.5 实测一个小技巧:延迟获取与缓存
如果你发现navigation这个动作在某些低端机上偶尔耗时偏高、导致首屏闪一下,可以做一个本地缓存,把获取到的Provider实例缓存在一个静态Map里,按接口Class做key。加载一次以后整个App生命周期内复用同一个实例,省掉后续的反射查找。如果你用的是singleton=true模式,框架自己会缓存,但navigation的查表路径还是走一遍。缓存代码很简单:
private static final Map<Class, Object> CACHE = new ConcurrentHashMap<>(); public static <T extends IProvider> T get(Class<T> clazz) { Object obj = CACHE.get(clazz); if (obj == null) { obj = ARouter.getInstance().navigation(clazz); if (obj != null) { CACHE.put(clazz, obj); } } return clazz.cast(obj); }这个缓存有个前提,你的Provider是无状态的或者状态可以在init里完成。如果你的Provider需要根据参数返回状态不同的结果,建议不要全局缓存整个接口,只缓存内部的Fragment创建逻辑,用静态工厂保护接口实现。
6. 关于封装与架构的几点个人经验
这套路由获取Fragment的能力其实单看使用很简单,但接入到真实项目里会有一些更深层的体会,我快速分享几个相对有价值的思考。
第一,接口层一旦发布就不要轻易改方法签名。Provider接口是公共依赖,业务模块彼此只认这个接口。你改了方法签名,所有依赖该接口的调用方在编译期就会报错,看起来有把关,但如果你的项目各业务模块版本发布节奏不一致,老版本模块还在用旧接口,新版本实现类不兼容,线上就出现NoSuchMethodError。所以接口的兼容性是架构级别的课题,增加方法可以,删改方法一定要走接口版本管理。
第二,判空逻辑不要省。我上面排查了一堆找不到服务的问题,在代码里统一要求ServiceRouter.get后先判null再使用。哪怕你的测试环境一切正常,主工程和组件模块的版本合并总有一堆意想不到的情况,服务获取失败时宁可展示一个友好的降级页,也不要在生产环境崩一个白屏。
第三,配合组件生命周期自动化注册方案会更顺手。如果你有做组件生命周期派发的框架,Provider的init里面可以放这个组件独有的初始化动作,比如预创建部分单例、注册状态监听。不过要注意init不只是App启动时被调用一次,多次调用navigation时如果框架不缓存,每个实例都会走init,所以init做幂等设计是个习惯问题。生产级框架一般实现Provider时都会自己控制内部单例,比如用DoubleCheckLock。
我实际用了几年这个方案,最大的感受是组件化的边界清晰了以后,跨模块页面复用的成本大幅下降。以前拉一个别人的页面过来要么拷代码要么搞反射,现在公共接口一声明,双方按契约开发,联调阶段基本不会因为类找不到而扯皮。如果你团队正打算做组件化,把ARouter的Provider机制用起来,会是性价比很高的第一步。