Android 组件化模块间通信:从路由表到 SPI 服务发现

去年接手一个电商 App 的组件化改造,项目拆成了 12 个模块。第一个让人头疼的问题不是怎么拆,而是拆完之后模块之间怎么通信——壳工程不直接依赖子模块,但支付模块要调用户模块的登录态、商品模块要调搜索模块的接口。这几乎是所有组件化项目绕不过去的坎。

问题的本质

组件化通信的核心矛盾:模块间需要协作,但编译期不能产生直接依赖

如果模块 A 直接 implementation 模块 B,那就不是组件化了——B 的改动会导致 A 重新编译,并行开发的优势荡然无存。你需要的是:A 在编译期不知道 B 的存在,运行时却能调用 B 的能力。

解决这个矛盾的路线分两派:路由派接口下沉派。前者用 URL 字符串做中间层,后者用接口抽象解耦编译依赖。

路由方案:用 URL 做中间层

早期的组件化项目几乎都从路由起步,思路很直观——像 Web 一样用 URL 跳转,顺带处理通信。

// 模块A中调用
ARouter.getInstance()
    .build("/user/getUserInfo")
    .withString("userId", "12345")
    .navigation()

// 模块B中注册
@Route(path = "/user/getUserInfo")
class UserServiceImpl : UserService {
    override fun getUserInfo(userId: String): UserInfo {
        // 实际逻辑
    }
}

路由方案依赖中心化路由表:编译期通过 APT 扫描所有 @Route 标记的类,生成字符串路径到具体类的映射表,运行时查表分发。

我在两个项目里用过这种方式。接入成本低、前端同学也能理解,优点很实在。但坑也同样真实:

  • 字符串强依赖,路径拼错编译期零感知
  • 参数类型擦除,传参和取值全靠文档约定,没有编译期检查
  • 路由表膨胀后维护成本线性增长,多人协作时路径冲突时有发生

更深层的问题在于路由方案的通信模型是单向的。A 调 B 可以,B 回调 A 就得再走一遍路由,链式调用一多就成了字符串地狱。

接口下沉:让依赖反转

接口下沉的思路是 把接口定义抽到公共层,实现留在各自模块,运行时通过 SPI 机制组装

common 模块
├── IUserService(接口)
└── IOrderService(接口)

user 模块
├── UserServiceImpl(实现 IUserService)
└── 不依赖 order 模块

order 模块
├── OrderServiceImpl(实现 IOrderService)
├── 不依赖 user 模块
└── 但可以调用 IUserService

编译期的依赖关系被反转了:user 和 order 都只依赖 common 层的接口,互不感知。运行时由 SPI 把接口和实现关联起来。

SPI 服务发现的三种实现

Google AutoService 最轻量,基于注解加 ServiceLoader:

@AutoService(IUserService::class)
class UserServiceImpl : IUserService {
    override fun getUserInfo(userId: String): UserInfo {
        // 实现
    }
}

编译期在 META-INF/services/ 下生成接口全限定名文件,内容是实现类的全限定名,ServiceLoader 启动时读取完成加载。

Gradle 插件 + 字节码注入在 transform 阶段扫描接口实现,直接注入到注册类中。滴滴 VirtualAPK 早期版本用过类似方案。优势是不依赖反射,代价是得维护一套 Gradle 插件。

APT 生成注册代码是我最推荐的方式——编译期生成工厂类,运行时直接调用,性能最优:

// 编译期自动生成
object ServiceRegistry {
    private val services = mapOf(
        IUserService::class.java to UserServiceImpl(),
        IOrderService::class.java to OrderServiceImpl()
    )
    
    fun <T> get(cls: Class<T>): T = services[cls] as T
}

实战中三个绕不过去的决策

在项目里落地接口下沉方案时,有三个点必须想清楚。

一、服务注册中心的单例与生命周期

接口下沉后服务通常是单例的,但有些场景需要生命周期感知。比如用户在设置页退出登录,IUserService 缓存的用户数据必须失效。

我的做法是给服务接口增加 onUserChanged() 回调:

interface IUserService : LifecycleAware {
    fun getUserInfo(userId: String): UserInfo
    override fun onUserChanged() {
        // 清除缓存
    }
}

Application 层在登录态变化时遍历所有 LifecycleAware 服务触发回调,不侵入业务模块。

二、服务发现的初始化时序

SPI 注册必须在 Application 的 onCreate 最早阶段完成,否则其他模块初始化时调用服务直接 NPE。但 ServiceLoader 在冷启动时全量加载有几十毫秒的损耗,对启动速度敏感的项目是个痛点。

这个项目我最终选了 APT 方案:注册代码编译期硬编码到类中,运行时零扫描开销。代价是每新增一个服务实现需要 rebuild。实际项目中服务的增减频率远低于业务代码变更,这个 trade-off 完全可接受。

三、通信的降级与兜底

接口下沉后,如果 order 模块调用 IUserService.getUserInfo() 时 user 模块未安装(插件化或热修复场景),调用方必须兜住服务缺失的情况。

fun getUserName(userId: String): String {
    return try {
        ServiceRegistry.get(IUserService::class.java)
            .getUserInfo(userId).name
    } catch (e: ServiceNotFoundException) {
        "未知用户" // 降级策略
    }
}

我们在 ServiceRegistry 层统一做了容错,返回 NullObject 或抛出明确的 ServiceNotFoundException,避免隐式 NPE 在调用链里扩散后难以定位。

路由 vs 接口下沉:怎么选

踩了几个项目的坑后,我对选型有了明确的判断标准。

维度路由方案接口下沉
类型安全弱,运行时校验强,编译期校验
接入成本低,注解+路径即可中,需要公共接口层
代码可追踪性差,找引用靠搜索字符串好,IDE 能直接跳转
模块解耦程度中等
性能路径匹配有开销接近直接调用

我的立场很明确:优先选接口下沉。 不是因为路由方案不好,而是路由解决的问题域本身有限——它就是为页面跳转设计的,硬拿来当服务通信用属于职责越界。路由做页面导航,接口下沉做服务通信,各司其职才是合理的分工。

如果你的项目模块不超过 5 个、团队规模小、对编译速度不敏感,路由方案也能跑。架构选型的核心从来不是追新,而是匹配当前工程的复杂度和团队的维护能力

一个经验参数:当 common 层接口超过 20 个时,就该做接口分层了——按业务领域拆出 common-usercommon-order 等子模块,否则公共层会膨胀成第二个壳工程。

组件化通信没有银弹。路由是字符串的中间层,接口下沉是抽象的中间层——区别在于这个中间层是动态解析还是静态编译,而这一点直接决定了你后续的维护成本和调试体验。