1. 从一次组件化改造说起:为什么路由框架里还要搞服务调用和拦截器
做过Android组件化的人大概都有这种体会:项目初期大家和和气气,所有代码塞在一个module里,想怎么调就怎么调。等到业务膨胀、团队扩到十几号人、编译一次要等五分钟的时候,拆分module就成了不得不做的事。可拆完之后问题立刻来了——A模块想调B模块的某个功能,直接import会形成循环依赖,编译直接报错。这时候路由框架就登场了。
ARouter是阿里开源的一套Android路由框架,核心解决的就是组件化之后模块间通信的问题。它提供了三个层面的能力:页面跳转(ARouter.getInstance().build("/test/activity").navigation())、服务调用(通过IProvider接口暴露跨模块能力)、以及拦截器(IInterceptor在跳转过程中做切面处理)。很多人对ARouter的理解停留在“页面跳转”这一层,实际上IProvider和IInterceptor才是它在组件化架构里真正发挥威力的地方。
这篇文章要聊的就是后两块:怎么用IProvider做跨模块的服务调用,怎么用IInterceptor做登录拦截。我会从设计思路、核心原理、实操步骤、踩坑记录几个维度展开,尽量把每个“为什么这么设计”讲清楚。适合正在做组件化改造、或者已经用了ARouter但只用来跳页面的同学。读完你应该能直接把这套方案搬到自己的项目里。
2. 整体设计思路:服务调用与拦截器到底解决什么问题
2.1 组件化通信的三种典型场景
在拆完module之后,模块间的通信需求大致可以归为三类。第一类是页面跳转,比如从首页跳到详情页,这种用ARouter的build().navigation()就够了。第二类是功能调用,比如订单模块需要调用用户模块的“获取当前登录用户信息”方法,这种不是跳页面,而是要执行一段逻辑并拿到返回值,就得靠IProvider。第三类是切面处理,比如某些页面必须登录才能进、某些操作需要埋点统计、某些跳转需要动态重定向,这些横切逻辑用IInterceptor最合适。
这三类场景对应ARouter的三套API,设计上是正交的。页面跳转走Postcard,服务调用走IProvider,拦截逻辑走Interceptor。理解这个正交关系很重要,因为很多新手会把服务调用也写成跳转,结果拿不到返回值,绕一大圈。
2.2 为什么不用反射或EventBus
有人会问,跨模块调用我直接用反射不就行了?或者用EventBus发个事件?反射的问题在于性能差、没有编译期检查、代码难维护,而且组件化规范里通常禁止直接反射调用其他模块的类。EventBus的问题是它是单向的、异步的、没有返回值的,适合做通知不适合做调用。IProvider的设计恰好补上了这块:它通过接口下沉到base模块,实现类留在各自业务模块,调用方只依赖接口不依赖实现,编译期就能检查类型,运行时通过ARouter的映射表找到实现类,性能和可维护性都更好。
拦截器的设计思路也类似。如果不用拦截器,你会在每个需要登录的页面里写if (!isLogin) { jumpToLogin(); return; },代码重复且容易漏。IInterceptor把这些判断集中到一处,通过process()方法决定是继续放行还是中断跳转,符合AOP的思想。
2.3 核心角色与调用链路
整个链路涉及几个关键角色:ARouter是入口,Postcard是跳转信息的载体,InterceptorService是拦截器服务,IProvider是服务接口。一次带拦截的跳转大致是这样的:调用build()生成Postcard,navigation()触发跳转,ARouter先找到所有IInterceptor按优先级排序,依次调用process(),如果某个拦截器调用了interceptor.onContinue()之外的逻辑(比如跳登录页),跳转就中断;全部放行后才真正执行页面跳转或服务调用。
服务调用的链路更直接:ARouter.getInstance().navigation(IUserService.class)或者build("/service/user").navigation(),ARouter根据注解生成的映射表找到实现类,实例化后返回。理解这两条链路,后面的实操就不会迷路。
3. IProvider服务调用:从接口下沉到跨模块拿数据
3.1 接口下沉:把契约放在base模块
IProvider用法的第一步是接口下沉。假设用户模块要对外暴露“获取用户信息”的能力,你需要在base模块(或者叫common、api模块,总之是被所有业务模块依赖的那个)里定义一个接口:
public interface IUserService extends IProvider { UserInfo getUserInfo(); boolean isLogin(); }注意这个接口必须继承IProvider,这是ARouter识别服务接口的标记。接口里只放方法签名,不放实现。这样订单模块依赖base模块就能拿到IUserService的类型,但完全不知道实现类在哪。
提示:接口下沉的粒度要控制好。不要把所有模块的接口都塞到一个base里,那样base会越来越臃肿。常见做法是按业务域拆多个api模块,比如user-api、order-api,业务模块依赖对应的api模块即可。
3.2 实现类注解与路径命名规范
在用户模块里写实现类:
@Route(path = "/user/service") public class UserServiceImpl implements IUserService { @Override public UserInfo getUserInfo() { return UserManager.getInstance().getCurrentUser(); } @Override public boolean isLogin() { return UserManager.getInstance().isLogin(); } @Override public void init(Context context) { // 初始化逻辑,ARouter在实例化后会调用 } }@Route注解的path就是服务的唯一标识。命名上建议用/模块名/service或者/模块名/功能名的格式,和页面跳转的path区分开。页面跳转通常用/模块名/activity名,服务用/模块名/service,这样一眼能看出是跳页面还是调服务。
init(Context)方法是IProvider接口自带的,ARouter在创建实例后会调用它,适合做一些轻量初始化。注意不要在这里做耗时操作,因为它是在调用线程同步执行的。
3.3 两种调用方式:byType与byPath的取舍
调用方有两种写法。第一种是按类型调用:
IUserService userService = ARouter.getInstance().navigation(IUserService.class); if (userService != null) { UserInfo info = userService.getUserInfo(); }第二种是按路径调用:
IUserService userService = (IUserService) ARouter.getInstance().build("/user/service").navigation();两种方式各有适用场景。byType更符合面向接口编程的习惯,编译期就能检查类型,推荐优先用。byPath更灵活,适合在配置化场景下动态决定调哪个服务。但byPath有个坑:如果path写错了,navigation()返回null,强转的时候不会报错,等到调用方法时才抛NPE,排查起来麻烦。byType如果找不到实现,返回的也是null,但至少类型是明确的。
注意:byType调用时,如果同一个接口有多个实现类,ARouter会取优先级最高的那个。可以通过
@Route的priority属性控制,值越小优先级越高。
3.4 服务调用的线程与生命周期
IProvider实例默认是单例的,ARouter内部用SingletonPool缓存。这意味着实现类里不要持有Activity的Context,否则会内存泄漏。如果确实需要Context,用init(Context)传进来的Application Context。
线程方面,navigation()是同步调用,在哪个线程调就在哪个线程执行。如果服务方法里有耗时操作,调用方要自己切到子线程,或者服务内部用异步回调。我见过有人在init()里做网络请求,结果主线程卡顿,这种要避免。
4. IInterceptor登录拦截:把登录判断收拢到一处
4.1 拦截器的注册与优先级
写一个拦截器类:
@Interceptor(priority = 1, name = "loginInterceptor") public class LoginInterceptor implements IInterceptor { @Override public void process(Postcard postcard, InterceptorCallback callback) { if (postcard.getExtras().getBoolean("needLogin", false)) { if (UserManager.getInstance().isLogin()) { callback.onContinue(postcard); } else { // 跳登录页 ARouter.getInstance().build("/login/activity").navigation(); callback.onInterrupt(new RuntimeException("未登录")); } } else { callback.onContinue(postcard); } } @Override public void init(Context context) { } }@Interceptor注解的priority决定执行顺序,值越小越先执行。name是拦截器名字,方便调试。process()方法里拿到Postcard和InterceptorCallback,通过callback.onContinue()放行,通过callback.onInterrupt()中断。
优先级的设计很关键。比如登录拦截器应该排在埋点拦截器之前,因为未登录的跳转可能不需要埋点。又比如灰度拦截器应该排在登录拦截器之前,因为灰度可能决定用户走哪个版本的登录页。一般建议:灰度/ABTest(priority 1-3)→ 登录(priority 4-6)→ 埋点(priority 7-9)→ 其他(10+)。
4.2 如何标记需要登录的页面
标记方式有两种。第一种是在跳转时通过withBoolean("needLogin", true)传参:
ARouter.getInstance() .build("/order/detail") .withBoolean("needLogin", true) .navigation();第二种是在目标页面的@Route注解里配置extras:
@Route(path = "/order/detail", extras = 1) public class OrderDetailActivity extends AppCompatActivity { }然后在拦截器里通过postcard.getExtras().getInt(ARouterConst.KEY_EXTRA_FLAG)判断。第二种方式更优雅,因为标记写在目标页面自己身上,调用方不用关心。但需要自己定义一套extras的位标记规范,比如第0位表示需要登录,第1位表示需要实名。
我个人的做法是两者结合:默认用extras标记,特殊场景用withBoolean覆盖。这样大部分页面不用改调用方代码,少数动态场景也能灵活处理。
4.3 拦截后的跳转与结果回调
拦截后跳登录页有个细节要注意:登录成功后要不要自动跳回原页面?如果要,需要在拦截时把原path和参数存下来,登录成功后重新navigation。实现上可以在onInterrupt()之前把postcard序列化存到SP或内存,登录页完成后读取并跳转。
另一个细节是onInterrupt()的参数。传RuntimeException只是ARouter的约定,实际不会抛出去,只是标记中断。但如果你在调用方用了navigation(Context, NavigationCallback),onLost()会被回调,可以在这里做提示。
ARouter.getInstance() .build("/order/detail") .navigation(this, new NavigationCallback() { @Override public void onFound(Postcard postcard) {} @Override public void onLost(Postcard postcard) { Toast.makeText(ctx, "请先登录", Toast.LENGTH_SHORT).show(); } @Override public void onArrival(Postcard postcard) {} @Override public void onInterrupt(Postcard postcard) {} });4.4 拦截器里的异步陷阱
process()方法默认在主线程执行,如果拦截器里要做网络请求(比如校验token是否有效),不能直接同步请求,否则会ANR。正确做法是用CountDownLatch或者把callback传到子线程:
@Override public void process(Postcard postcard, InterceptorCallback callback) { new Thread(() -> { boolean valid = checkTokenSync(); if (valid) { callback.onContinue(postcard); } else { callback.onInterrupt(null); } }).start(); }但要注意,callback可以在任意线程调用,ARouter内部做了线程切换。不过如果多个拦截器都异步,执行顺序会乱,所以异步拦截器最好只有一个,且优先级最高。
5. 完整实操:从零搭一个带登录拦截的服务调用Demo
5.1 模块划分与依赖配置
假设我们有四个模块:app(壳工程)、base(基础库)、user(用户模块)、order(订单模块)。依赖关系是app依赖user和order,user和order都依赖base,user和order之间不互相依赖。
每个业务模块的build.gradle里要加ARouter的注解处理器:
android { defaultConfig { javaCompileOptions { annotationProcessorOptions { arguments = [AROUTER_MODULE_NAME: project.getName()] } } } } dependencies { implementation 'com.alibaba:arouter-api:1.5.2' annotationProcessor 'com.alibaba:arouter-compiler:1.5.2' }AROUTER_MODULE_NAME这个参数必须传,否则注解处理器生成的类名会冲突。base模块不需要加annotationProcessor,因为它只放接口不放实现。
5.2 初始化与混淆配置
在app的Application里初始化:
public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); if (BuildConfig.DEBUG) { ARouter.openLog(); ARouter.openDebug(); } ARouter.init(this); } }openDebug()只在debug包开,release包必须关,否则会有安全和性能问题。混淆规则:
-keep public class com.alibaba.android.arouter.routes.**{*;} -keep public class com.alibaba.android.arouter.facade.**{*;} -keep class * implements com.alibaba.android.arouter.facade.template.ISyringe{*;} -keep interface * implements com.alibaba.android.arouter.facade.template.IProvider最后一条特别重要,漏了会导致release包服务调用返回null。
5.3 服务接口定义与实现落地
base模块里定义接口:
public interface IUserService extends IProvider { boolean isLogin(); String getUserId(); void logout(); }user模块里实现:
@Route(path = "/user/service") public class UserServiceImpl implements IUserService { private String userId; @Override public boolean isLogin() { return !TextUtils.isEmpty(userId); } @Override public String getUserId() { return userId; } @Override public void logout() { userId = null; } @Override public void init(Context context) { userId = SPUtils.getString(context, "user_id", ""); } }order模块里调用:
IUserService userService = ARouter.getInstance().navigation(IUserService.class); if (userService != null && userService.isLogin()) { String uid = userService.getUserId(); // 用uid查订单 }5.4 登录拦截器与目标页面标记
拦截器:
@Interceptor(priority = 4, name = "login") public class LoginInterceptor implements IInterceptor { @Override public void process(Postcard postcard, InterceptorCallback callback) { int extra = postcard.getExtra(); boolean needLogin = (extra & FlagConst.FLAG_LOGIN) != 0; if (!needLogin) { callback.onContinue(postcard); return; } IUserService userService = ARouter.getInstance().navigation(IUserService.class); if (userService != null && userService.isLogin()) { callback.onContinue(postcard); } else { ARouter.getInstance().build("/user/login") .withString("target", postcard.getPath()) .navigation(); callback.onInterrupt(null); } } @Override public void init(Context context) { } }目标页面标记:
@Route(path = "/order/detail", extras = FlagConst.FLAG_LOGIN) public class OrderDetailActivity extends AppCompatActivity { }FlagConst.FLAG_LOGIN定义为1,postcard.getExtra()返回的就是@Route里extras的值。这样标记写在页面自己身上,调用方无感知。
5.5 参数传递与返回值处理
服务调用的参数传递直接通过方法参数,返回值直接return,比页面跳转的withXxx()清爽很多。但要注意,如果服务方法返回的是复杂对象,确保这个对象在base模块里定义,否则调用方拿不到类型。
页面跳转的参数传递还是走withString()等,接收方用@Autowired注解自动注入:
@Route(path = "/order/detail") public class OrderDetailActivity extends AppCompatActivity { @Autowired public String orderId; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ARouter.getInstance().inject(this); // 使用orderId } }@Autowired的字段不能是private,否则注入失败。这是新手最容易踩的坑之一。
6. 常见问题与排查技巧实录
6.1 服务调用返回null的几种原因
服务调用返回null是最常见的问题,排查思路按优先级来。第一,检查实现类有没有加@Route注解,path有没有写错。第二,检查annotationProcessor有没有配置,AROUTER_MODULE_NAME有没有传。第三,检查混淆规则有没有漏,特别是IProvider那条。第四,检查是不是在ARouter.init()之前调用的,初始化是异步的,虽然大部分情况很快,但极端情况下可能没完成。第五,检查是不是多进程,ARouter默认不支持多进程,如果服务在另一个进程,需要额外处理。
我遇到过一次特别隐蔽的:实现类被写成了内部类,且是static的,ARouter能生成映射但实例化失败。后来改成顶层类就好了。所以实现类一定要是public的顶层类。
6.2 拦截器不生效的排查清单
拦截器不生效,先看@Interceptor注解有没有加,priority有没有重复。然后看拦截器类有没有被混淆,混淆规则里com.alibaba.android.arouter.facade.template.IInterceptor要keep。再看是不是在debug模式下,openDebug()开了之后拦截器日志会打印,方便定位。
还有一个坑:如果拦截器里调用了callback.onContinue()之后又调用了onInterrupt(),行为是未定义的。必须保证每个分支只调用一次。我见过有人在if和else里都写了onContinue,结果跳转执行了两次。
6.3 登录成功后回跳的实现细节
回跳的实现有两种。简单版是在登录页接收target参数,登录成功后ARouter.getInstance().build(target).navigation()。但这样会丢失原页面的参数。完整版是把整个Postcard序列化存起来,登录后反序列化再navigation。Postcard实现了Parcelable,可以存到Intent或SP。
但要注意,Postcard里可能包含Bundle,序列化时如果Bundle里有不可序列化的对象会失败。所以更稳妥的做法是只存path和关键参数,回跳时重新build。
6.4 性能与内存的注意事项
ARouter的映射表是在编译期生成的,运行时通过反射加载,首次调用会有一定耗时。如果服务调用在启动阶段频繁发生,可以考虑提前预热,在Application里调一次ARouter.getInstance().navigation(IUserService.class)。
内存方面,IProvider是单例,实现类里不要持有Activity、Fragment、View的引用。如果服务需要Context,用init()传进来的Application Context。拦截器也是单例,同样注意。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 服务调用返回null | 实现类无@Route或path错 | 检查注解和path拼写 |
| 服务调用返回null | 混淆规则漏IProvider | 检查proguard配置 |
| 拦截器不执行 | 注解priority重复 | 检查所有拦截器priority |
| 拦截器不执行 | 混淆导致类被删 | keep IInterceptor接口 |
| @Autowired注入失败 | 字段是private | 改成public |
| 跳转后参数丢失 | 未调inject | onCreate里调inject |
| release包服务失效 | 未keep routes类 | 补全混淆规则 |
| 拦截器ANR | process里做网络请求 | 切子线程调callback |
7. 一些个人实践中的体会
这套方案我在几个项目里都用过,最大的感受是:接口下沉的粒度决定了后期维护的成本。一开始图省事把所有接口塞一个base,结果base越来越大,改一个接口所有模块都要重新编译。后来改成按业务域拆api模块,虽然前期多建了几个module,但后期每个模块可以独立发版,编译也快了很多。
拦截器的优先级管理也是个容易乱的地方。建议在团队里约定一个优先级区间规范,比如1-10留给基础框架,11-20留给业务通用,21以上留给临时需求。并且每个拦截器的name要写清楚用途,方便在日志里定位。
还有一个细节:IProvider的init()方法不要做耗时操作,我见过有人在里面读SP、查数据库,导致首次调用卡顿。如果确实需要初始化数据,用懒加载,在真正用到的时候再读。
最后说个调试技巧。ARouter在debug模式下会打印路由表,可以在logcat里搜ARouter看所有注册的path。如果某个path没打印出来,说明注解处理器没生效,检查gradle配置。这个技巧帮我省了很多排查时间。