ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ARouter服务发现机制:组件化架构下获取Fragment的最佳实践

ARouter服务发现机制:组件化架构下获取Fragment的最佳实践 1. 为什么要通过路由拿Fragment而不是直接new1.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. 实操步骤从定义接口到拿到Fragment3.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拿FragmentFragment里内部跳转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(ClassT 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 ServiceDelegateT : IProvider(private val serviceClass: ClassT) { 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传了一个keyProvider内部的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生命周期内复用同一个实例省掉后续的反射查找。如果你用的是singletontrue模式框架自己会缓存但navigation的查表路径还是走一遍。缓存代码很简单private static final MapClass, Object CACHE new ConcurrentHashMap(); public static T extends IProvider T get(ClassT 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机制用起来会是性价比很高的第一步。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进