一、组件化与模块化的通信困局
说实话,我做了这么多年Android开发,最头疼的不是业务逻辑有多复杂,而是模块之间怎么优雅地通信。你想想看,一个App拆成几十个模块,每个模块各自为政,但业务又需要它们互相调用——这就像让一群互不相识的人合作盖房子,没有个统一的沟通机制,肯定乱套。
我早期参与过一个电商项目,模块化做得挺早,但通信方式全靠接口硬依赖。结果呢?每次改一个模块,关联模块就得重新编译。那叫一个痛苦。后来我才意识到,模块间通信的性能和设计,直接决定了整个项目的迭代效率。
1.1 模块间通信的几种方式
先看看我们有哪些选择。说白了,模块间通信就三种主流方案:
- 接口直接依赖:最原始的方式,A模块直接引用B模块的接口。简单粗暴,但耦合度高。
- 事件总线:比如EventBus、RxBus。解耦效果好,但调试困难,性能也有损耗。
- 路由框架:ARouter、WMRouter这类。通过URL映射实现解耦,是目前的主流选择。
我个人习惯是,小项目用事件总线就够了,但中大型项目必须上路由框架。为什么?因为路由框架不仅能解耦,还能做拦截、降级、参数注入,这些在复杂业务里太重要了。
1.2 通信性能的关键指标
模块间通信,性能到底看什么?我总结了三项:
| 指标 | 说明 | 我的经验值 |
|---|---|---|
| 调用耗时 | 从发起调用到目标方法执行的时间 | 路由框架通常<1ms,事件总线约2-5ms |
| 内存开销 | 通信过程中额外分配的对象 | 路由框架每次调用约分配3-5个对象 |
| 初始化耗时 | 框架启动时扫描和注册的时间 | ARouter约50-100ms(100个模块) |
嗯,这里要注意:初始化耗时往往被忽略,但它直接影响冷启动速度。我曾经在一个项目里发现,ARouter的初始化占了启动时间的15%,后来通过懒加载优化才降下来。
二、ARouter原理深度拆解
ARouter是目前用得最多的路由框架,我前后在三个项目里用过它。它的核心原理其实不复杂,但细节很值得琢磨。
2.1 编译期:APT生成路由表
ARouter在编译期通过APT(Annotation Processing Tool)扫描所有加了@Route注解的类,然后生成一个路由映射文件。这个文件里存的是:路径 → 类的对应关系。
// 编译期生成的代码示例
public class ARouter$$Group$$app implements IRouteGroup {
@Override
public void loadInto(Map<String, RouteMeta> atlas) {
atlas.put("/app/main", RouteMeta.build(
RouteType.ACTIVITY,
MainActivity.class,
"/app/main",
"app",
null, 0, 0
));
}
}
你看,每个模块都会生成这样一个类。运行时把这些类加载进来,就拿到了完整的路由表。这个过程有点像「提前把地图画好,运行时直接查地图」。
2.2 运行时:拦截器与参数注入
ARouter的运行时分为两步:路由跳转和参数注入。路由跳转就是根据路径找到目标类,然后通过Intent启动。参数注入则是把URL里的参数自动赋值给Activity的字段。
我遇到过一个问题:参数注入时,如果字段类型是自定义对象,ARouter默认不支持。后来我看了源码,发现它用的是SerializationService接口,可以自己实现JSON解析。嗯,这个坑我踩过,所以提醒大家注意。
2.3 性能优化点
ARouter的性能瓶颈主要在初始化阶段。它需要遍历所有生成的类文件,然后加载到内存。模块越多,耗时越长。我常用的优化手段有两个:
- 按需分组加载:只加载当前模块的路由表,其他模块延迟加载。
- 异步初始化:把初始化放到子线程,不阻塞主线程。
核心结论:ARouter的性能瓶颈不在运行时,而在初始化。优化好初始化,整体性能就能提升30%以上。
三、ServiceLoader vs SPI:服务发现的两种思路
模块间通信除了路由跳转,还有一种场景:服务发现。比如模块A需要获取模块B提供的某个服务接口的实现。这时候就需要ServiceLoader或者SPI(Service Provider Interface)机制。
3.1 Java SPI:标准但笨重
Java的SPI机制,说白了就是通过META-INF/services/目录下的配置文件,声明接口的实现类。运行时通过ServiceLoader.load()加载。
// 定义接口
public interface ILoginService {
boolean isLoggedIn();
}
// 实现类
public class LoginServiceImpl implements ILoginService {
@Override
public boolean isLoggedIn() {
return true;
}
}
// 使用SPI加载
ServiceLoader<ILoginService> loader = ServiceLoader.load(ILoginService.class);
for (ILoginService service : loader) {
boolean result = service.isLoggedIn();
}
Java SPI的问题很明显:它会加载所有实现类,即使你只需要一个。而且每次调用load()都会重新扫描,性能很差。我在一个项目里用过,结果发现每次调用都耗时几十毫秒,果断弃用了。
3.2 ServiceLoader:Android的改良版
Android官方后来推出了ServiceLoader,其实是对Java SPI的改良。它通过编译期生成索引文件,避免了运行时扫描。但说实话,用起来还是不够灵活。
我个人更推荐用ARouter自带的IProvider机制。它本质上也是一种SPI,但做了缓存和懒加载,性能好很多。
3.3 性能对比
| 方案 | 初始化耗时 | 调用耗时 | 内存开销 |
|---|---|---|---|
| Java SPI | 20-50ms | 5-10ms | 高(加载所有实现) |
| Android ServiceLoader | 5-10ms | 1-3ms | 中(按需加载) |
| ARouter IProvider | 1-3ms | <1ms | 低(缓存+懒加载) |
我的建议:如果项目已经用了ARouter,直接用它的IProvider机制就好,没必要再引入ServiceLoader。如果没路由框架,可以考虑用Android ServiceLoader,但记得做缓存。
四、懒加载与按需初始化
模块化项目最怕什么?一启动就把所有模块都初始化了。那冷启动速度肯定惨不忍睹。懒加载和按需初始化,就是解决这个问题的。
4.1 懒加载的核心思想
懒加载说白了就是「用到的时候再加载」。比如用户点击了「我的」页面,才去初始化用户模块。而不是一启动就把用户模块的数据库、网络库都准备好。
我常用的实现方式有两种:
- 接口代理:用一个代理类占位,真正调用时才去获取真实实现。
- 动态加载:通过Class.forName()或者ARouter的懒加载机制,按需加载类。
4.2 按需初始化的实践
按需初始化比懒加载更进一步。它不光延迟加载,还根据业务场景决定加载哪些模块。比如:
- 首页只需要加载首页模块、基础模块、网络模块。
- 支付页面才需要加载支付模块、风控模块。
- 设置页面只需要加载设置模块。
我曾经在一个项目里,把初始化分成了三个阶段:
- 必选初始化:崩溃收集、日志、网络框架。这些必须启动时加载。
- 按需初始化:业务模块。用到才加载。
- 后台初始化:非核心模块。启动后空闲时加载。
这样一拆分,冷启动时间从原来的2.3秒降到了1.1秒。效果非常明显。
4.3 避坑指南
我曾经踩过的坑:懒加载虽然好,但要注意线程安全问题。多个线程同时触发懒加载,可能导致重复初始化。我的做法是加一个AtomicBoolean做状态标记,确保只初始化一次。
另外,懒加载不能滥用。如果一个模块很小,初始化成本很低,就没必要懒加载。否则反而增加了代码复杂度。我一般以「初始化耗时超过10ms」作为阈值,超过才考虑懒加载。
五、知识体系总览
最后,我用一张图总结一下本章的核心内容。这张图展示了模块间通信的完整链路,从路由表生成到服务发现,再到懒加载优化。
这张图从左到右展示了模块间通信的四个阶段。每个阶段都有对应的优化点。你想想看,如果每个阶段都做到最优,整个项目的迭代效率能提升多少?
好了,这一章的内容就到这里。模块间通信是个大话题,但核心就三点:路由表怎么生成、服务怎么发现、初始化怎么优化。把这三点吃透了,组件化通信这块基本就没问题了。