一、组件化与模块化的通信困局

说实话,我做了这么多年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 SPI20-50ms5-10ms高(加载所有实现)
Android ServiceLoader5-10ms1-3ms中(按需加载)
ARouter IProvider1-3ms<1ms低(缓存+懒加载)

我的建议:如果项目已经用了ARouter,直接用它的IProvider机制就好,没必要再引入ServiceLoader。如果没路由框架,可以考虑用Android ServiceLoader,但记得做缓存。

四、懒加载与按需初始化

模块化项目最怕什么?一启动就把所有模块都初始化了。那冷启动速度肯定惨不忍睹。懒加载和按需初始化,就是解决这个问题的。

4.1 懒加载的核心思想

懒加载说白了就是「用到的时候再加载」。比如用户点击了「我的」页面,才去初始化用户模块。而不是一启动就把用户模块的数据库、网络库都准备好。

我常用的实现方式有两种:

  • 接口代理:用一个代理类占位,真正调用时才去获取真实实现。
  • 动态加载:通过Class.forName()或者ARouter的懒加载机制,按需加载类。

4.2 按需初始化的实践

按需初始化比懒加载更进一步。它不光延迟加载,还根据业务场景决定加载哪些模块。比如:

  • 首页只需要加载首页模块、基础模块、网络模块。
  • 支付页面才需要加载支付模块、风控模块。
  • 设置页面只需要加载设置模块。

我曾经在一个项目里,把初始化分成了三个阶段:

  1. 必选初始化:崩溃收集、日志、网络框架。这些必须启动时加载。
  2. 按需初始化:业务模块。用到才加载。
  3. 后台初始化:非核心模块。启动后空闲时加载。

这样一拆分,冷启动时间从原来的2.3秒降到了1.1秒。效果非常明显。

4.3 避坑指南

我曾经踩过的坑:懒加载虽然好,但要注意线程安全问题。多个线程同时触发懒加载,可能导致重复初始化。我的做法是加一个AtomicBoolean做状态标记,确保只初始化一次。

另外,懒加载不能滥用。如果一个模块很小,初始化成本很低,就没必要懒加载。否则反而增加了代码复杂度。我一般以「初始化耗时超过10ms」作为阈值,超过才考虑懒加载。

五、知识体系总览

最后,我用一张图总结一下本章的核心内容。这张图展示了模块间通信的完整链路,从路由表生成到服务发现,再到懒加载优化。

模块间通信知识体系 编译期:APT生成路由表 @Route注解 → 生成IRouteGroup实现类 → 打包到APK 运行时:路由查找与跳转 路径匹配 → 拦截器链 → Intent跳转 / 服务调用 服务发现:IProvider / ServiceLoader 接口代理 → 按需加载实现类 → 缓存复用 性能优化:懒加载与按需初始化 分组加载 → 异步初始化 → 状态标记防重复

这张图从左到右展示了模块间通信的四个阶段。每个阶段都有对应的优化点。你想想看,如果每个阶段都做到最优,整个项目的迭代效率能提升多少?

好了,这一章的内容就到这里。模块间通信是个大话题,但核心就三点:路由表怎么生成、服务怎么发现、初始化怎么优化。把这三点吃透了,组件化通信这块基本就没问题了。


公众号:蓝海资料掘金营,微信deep3321