文章

Android组件化,全面掌握

Android组件化,全面掌握

一、背景:为什么要组件化

随着项目逐渐扩展,业务功能越来越多,代码量越来越大,开发人员数量也越来越多。此过程中,你是否有过以下烦恼?

  1. 项目模块多且复杂,编译一次要 5 分钟甚至 10 分钟,太慢不能忍?
  2. 改了一行代码、只调了一点 UI,就要 run 整个项目,再忍受一次 10 分钟?
  3. 合代码经常发生冲突,很烦?
  4. 被人偷偷改了自己模块的代码,很不爽?
  5. 做一个需求,发现还要去改动很多别人模块的代码?
  6. 别的模块已实现的类似功能,自己要用只能去复制一份代码再改改?
  7. “这个不是我负责的,我不管”,代码责任范围不明确?
  8. 只改了一个模块的功能,但因为耦合太深,测试要完整回归一遍?
  9. 做了个需求,不知不觉导致其他模块出现 bug?

这些问题的根源都指向同一个东西:代码耦合。单一工程下所有业务揉在一起,模块间可以随意互相调用,久而久之依赖关系变成一张网,牵一发而动全身。当项目和团队规模到达一定程度时,就需要进行组件化改造了。

反过来说,如果项目本身很小、只有一两个人维护,组件化带来的工程复杂度反而会成为负担——组件化是为了解决规模问题,不是银弹,这一点要先想清楚。

二、什么是组件化

2.1 先说模块化

在 Android Studio 中,新建工程默认有一个 App module,还可以通过 File -> New -> New Module 新建 module。这里的 “module” 和我们说的”模块”基本是一个概念。模块化就是把原本一个 App module 承载的所有功能,拆分到多个 module 中,每个功能的代码都在自己所属的 module 中开发。

以京东为例,大致可以分为”首页”、”分类”、”发现”、”购物车”、”我的”、”商品详情”六个模块。通常还会有一个通用基础模块 module_common,提供 BaseActivity/BaseFragment、图片加载、网络请求等基础能力,每个业务模块都依赖它。

那么业务模块之间有没有依赖呢?很显然是有的:

  • “首页”、”分类”、”购物车”等都需要跳转到”商品详情”,必然依赖”商品详情”;
  • “商品详情”需要”加入购物车”的能力,又反过来依赖”购物车”。

于是模块之间形成了复杂甚至循环的依赖关系。模块化只是把代码物理上分了目录,模块间依然直接引用彼此的类,耦合并没有解除。高耦合加上大代码量,就极易出现第一节提到的那些问题。

2.2 组件化的定义

组件化 = 模块化 + 解耦。

即:在模块化拆分的基础上,去除业务模块之间的直接依赖,使得每个业务模块可以独立编译、独立运行、独立测试,像一个小 App 一样存在。此时业务模块就升级成了业务组件

按职责可以把组件分为三类:

组件类型说明举例
业务组件独立完整的业务功能首页、购物车、订单、我的
业务基础组件服务于业务但不独立成业务分享、登录、支付、推送、广告
基础组件(基础库)与业务无关的纯技术能力网络请求、图片加载、存储、工具类

2.3 期望的组件化架构

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────────────┐
│                壳工程 app                     │  ← 只做组装:集成组件、Application、打包配置
├──────────┬──────────┬──────────┬────────────┤
│  首页组件  │  购物车组件│  订单组件  │  我的组件   │  ← 业务组件:彼此之间无依赖
├──────────┴────┬─────┴─────┬────┴────────────┤
│    登录组件    │   分享组件  │     支付组件      │  ← 业务基础组件
├───────────────┴───────────┴─────────────────┤
│               common 组件                    │  ← Base 类、路由、通用 UI、统一依赖版本
├─────────┬──────────┬──────────┬─────────────┤
│  网络库  │  图片库    │  存储库   │   工具库     │  ← 基础组件,可跨项目复用
└─────────┴──────────┴──────────┴─────────────┘

这个架构有几条铁律:

  1. 依赖只能自上而下,上层依赖下层,禁止下层反向依赖上层;修改频率上层高于下层。
  2. 业务组件之间不允许存在直接依赖(这是组件化和模块化最本质的区别)。
  3. 基础组件修改频率极低,可作为 SDK 供公司所有项目复用。
  4. common 组件统一依赖所有基础组件并锁定版本号,业务组件只依赖 common,不直接依赖基础组件,避免版本混乱。
  5. 壳工程只负责组装:集成所有业务组件、提供 Application 的唯一实现、全局的 gradle/manifest/签名/混淆配置,不写任何业务代码。

2.4 组件化带来的好处

  1. 加快编译速度:每个业务组件可独立编译运行,开发时只编译自己负责的组件,代码量小,编译自然快;集成时未改动的组件还可以直接用 AAR 产物,进一步提速。
  2. 提高协作效率:组件之间彼此隔离,每个人/小组各自负责自己的组件,代码权限可以按组件收敛;新人只需熟悉自己负责的组件即可上手;测试也只需重点测试改动的组件。
  3. 功能复用:组件如同第三方库,维护好一份,多个 App(主 App、极速版、海外版)一键集成。
  4. 业务隔离,降低风险:组件不能直接调用其他组件的内部实现,改动的影响范围被物理限制在组件内部。
  5. 为后续架构演进铺路:插件化、动态下发、按需集成(打渠道包时裁剪某些组件)都要求组件先解耦。

2.5 组件化要解决的核心问题

好处的代价是:业务组件之间没有依赖了,原来直接 startActivity(Intent(this, DetailActivity::class.java))、直接 new 对方的类的写法全都失效了。于是产生了组件化的几个核心问题:

  1. 业务组件如何实现单独运行调试
  2. 组件间没有依赖,如何实现页面跳转
  3. 组件间没有依赖,如何实现组件间通信 / 方法调用
  4. 组件间没有依赖,如何获取对方的 Fragment 实例
  5. 组件不能反向依赖壳工程,如何拿到 Application 实例、如何在 Application.onCreate() 时机做组件初始化
  6. 多个组件的资源命名冲突怎么办?
  7. 各组件的依赖库版本如何统一?

下面逐一展开。

三、如何组件化:工程结构与独立调试

3.1 单工程方案 vs 多工程方案

方案形态优点缺点
单工程所有组件是同一工程下的 module,用开关切换 application/library改造成本低,代码都在一个仓库,调试方便代码物理上仍在一起,权限隔离弱;工程大了同步/编译仍慢
多工程每个业务组件是独立仓库/工程,源码用 git submodule 聚合,或产物发布到 maven 按版本号集成彻底隔离,权限清晰,壳工程编译极快需要 maven 私服、多仓库权限等基建;跨组件联调流程较重

一般演进路径是:先在单工程内完成组件化拆分和解耦,团队和基建成熟后再逐步迁移到多工程(源码用 git submodule 聚合,或产物走 maven 集成)

3.2 单工程方案:动态切换组件工程类型

Android Gradle 提供了两种常用插件:

  • com.android.application:打包输出 APK,可独立运行;
  • com.android.library:打包输出 AAR,只能被依赖。

经典做法是:想让业务组件独立调试,就把它配置成 application;集成调试时再变回 library。利用 gradle.properties 中定义的常量可以被所有构建脚本读取的特性,定义一个开关:

1
2
3
# gradle.properties
# 组件独立调试开关,每次修改后需要 Sync 工程
isModule=false
1
2
3
4
5
6
// 业务组件的 build.gradle.kts
// 注意:Kotlin DSL 的 plugins {} 块不允许写条件逻辑,动态切换插件只能退回老的 apply() 写法
val isModule = providers.gradleProperty("isModule").get().toBoolean()

apply(plugin = if (isModule) "com.android.application" else "com.android.library")
apply(plugin = "org.jetbrains.kotlin.android")

组件独立调试时是一个完整 App,需要 applicationId 和启动页;集成时则不能有(一个 App 只能有一个启动入口)。所以 applicationIdAndroidManifest 也要跟着开关走。但注意:用 apply() 方式引入插件后,android {} 的类型安全访问器不可用,只能改用 configure<BaseExtension> 这种别扭的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 业务组件的 build.gradle.kts(以购物车组件为例)
configure<BaseExtension> {
    defaultConfig {
        if (isModule) {
            // 独立调试时才配置 applicationId,集成时 library 不允许有
            applicationId = "com.hfy.componentlearning.cart"
        }
    }
    sourceSets {
        getByName("main") {
            // 独立调试与集成调试使用不同的 AndroidManifest
            manifest.srcFile(
                if (isModule) "src/main/moduleManifest/AndroidManifest.xml"
                else "src/main/AndroidManifest.xml"
            )
        }
    }
}
  • moduleManifest/AndroidManifest.xml(独立调试用):声明 Application、指定启动 Activity(LAUNCHER);
  • main/AndroidManifest.xml(集成用):只声明本组件的四大组件,不指定 Application 和启动入口。

可以看到,这套”开关方案”在 Groovy 时代很顺手,但在 Kotlin DSL 时代已经水土不服:条件插件、双 manifest、每次切换开关都要重新 Sync。如今更推荐下面的”独立调试壳”方案。

3.3 更现代的做法:独立调试壳(runner module)

思路反过来:业务组件永远保持 library 不变,给每个需要独立调试的组件配一个极轻量的调试壳(runner)module:

1
2
module_cart/          # 购物车业务组件(com.android.library,永远不变)
module_cart_runner/   # 购物车调试壳(com.android.application,只依赖 module_cart)

调试壳只包含一个 AndroidManifest(声明调试用 Application 和 LAUNCHER 入口)和少量调试代码(模拟登录态、测试入口页等),它的构建脚本非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// module_cart_runner/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
}

android {
    namespace = "com.hfy.cart.runner"
    compileSdk = libs.versions.compileSdk.get().toInt()
    defaultConfig {
        applicationId = "com.hfy.cart.runner"
        minSdk = libs.versions.minSdk.get().toInt()
    }
}

dependencies {
    // 调试壳唯一的职责:把业务组件当 App 跑起来
    implementation(project(":module_cart"))
}

这个方案的优点:

  1. 不需要任何开关,独立调试直接 run module_cart_runner,集成调试直接 run app,切换零成本、不用重新 Sync;
  2. 不需要双 manifest,组件的 manifest 始终是集成形态,调试入口都在壳里;
  3. 调试代码天然隔离:模拟数据、测试页都放 runner,绝不会混进集成包;
  4. 壳工程 app 不依赖任何 runner,集成打包与它们完全无关。

如果 runner 多了拖慢 Sync,可以在 settings.gradle.kts 里按需挂载:

1
2
3
4
5
6
// settings.gradle.kts
include(":app", ":module_common", ":module_cart", ":module_order")
// 本地开发时才挂载调试壳,CI 集成构建不加载
if (providers.gradleProperty("includeRunners").orNull == "true") {
    include(":module_cart_runner", ":module_order_runner")
}

3.4 多工程方案:maven 发布与集成

多工程方案中,每个业务组件是一个独立工程(工程内部是一个可独立运行的 app module + 一个 library module,或直接用变体切换),天然支持独立调试。原本的主工程则退化为只有 app 模块的壳工程

集成方式是把组件 AAR 发布到公司 maven 私服,壳工程像依赖第三方库一样依赖它:

1
2
3
4
5
6
// 壳工程 app/build.gradle.kts
dependencies {
    implementation("com.hfy.component:module_cart:1.0.2")
    // 联调阶段使用快照版本,每次构建拉取最新
    implementation("com.hfy.component:module_order:1.1.0-SNAPSHOT")
}

AAR 一般分两种版本:

  • SNAPSHOT 快照版:开发联调阶段使用,同一版本号可反复覆盖发布;
  • Release 正式版:提测/发版使用,版本号唯一且不可覆盖,保证构建可复现。

3.5 多仓库的代码组织:git submodule

多工程方案下,各组件拆成了独立的 git 仓库,随之而来的问题是:主工程如何把这些仓库聚合起来? 除了上面”maven 集成产物(AAR)”的方式,还有一种常见方式是用 git submodule 聚合源码:主仓库(壳工程)把各组件仓库以子模块的形式挂载进来,clone 下来就是一个完整可编译的工程,既保留了多仓库的权限隔离,又能源码级调试。

3.5.1 git submodule 的原理

理解 submodule 的关键在于:主仓库并不存储子仓库的代码,只存两样东西——

  1. 一个 .gitmodules 文件:记录每个子模块的本地路径和远端 URL;
  2. 每个子模块路径上的一个 gitlink 指针:记录子仓库的某个具体 commit 的哈希值。

也就是说,主仓库锁定的是”子仓库在某一时刻的快照”,而不是某个分支。这是 submodule 绝大多数”坑”的根源,后面注意事项会反复用到这一点。

3.5.2 常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1. 添加子模块(生成 .gitmodules,并在主仓库记录子仓库当前 commit)
git submodule add git@github.com:xxx/module_cart.git module_cart

# 2. 克隆带子模块的主工程(一步到位,递归拉取所有子模块)
git clone --recurse-submodules git@github.com:xxx/shell-project.git
# 如果已经普通 clone 了主工程,再补拉子模块:
git submodule update --init --recursive

# 3. 把所有子模块检出到主仓库记录的 commit(切分支、拉代码之后常跑)
git submodule update --recursive

# 4. 把子模块更新到其远端分支的最新提交(而不是主仓库记录的 commit)
git submodule update --remote module_cart

# 5. 对每个子模块批量执行命令(如统一切分支、拉代码)
git submodule foreach 'git checkout develop && git pull'

# 6. 查看子模块状态(前面带 + 号表示本地 commit 与主仓库记录的不一致)
git submodule status

3.5.3 日常开发流程

在子模块里改代码时,提交顺序是固定的”先里后外“:

1
2
3
4
5
6
7
8
9
10
# ① 进入子模块,确保在具体分支上(而不是游离 HEAD),修改并提交
cd module_cart
git checkout feature/cart-badge
# ...改代码...
git add . && git commit -m "feat: 购物车角标" && git push

# ② 回到主仓库,此时 git status 会显示 module_cart 有"新的 commit"
cd ..
git add module_cart          # 把主仓库记录的指针更新到子模块的新 commit
git commit -m "chore: 更新购物车组件指针" && git push

3.5.4 注意事项(都是血泪教训)

  1. detached HEAD(游离头指针)git submodule update 会把子模块检出到指定 commit,此时子模块不在任何分支上。如果直接在这个状态下写代码并 commit,一旦再次 update,这些提交就”看不见”了(只能靠 reflog 找回)。在子模块里开发前,务必先 git checkout 到具体分支。
  2. 先 push 子模块,再 push 主仓库:主仓库记录的只是 commit 哈希。如果你 push 了主仓库却忘了 push 子模块,同事拉代码后执行 update 会报 fatal: reference is not a tree——因为那个 commit 在远端根本不存在。可以配置 git push --recurse-submodules=check(检查并阻止)或 on-demand(自动帮你先推子模块)来兜底。
  3. 主仓库切分支,子模块不会自动跟着切:切换分支后子模块工作区还停在原来的 commit,必须习惯性补一句 git submodule update --init --recursive;也可以一劳永逸地配置 git config submodule.recurse true,让 checkout/pull 自动递归处理子模块。
  4. 子模块的合并冲突落在”指针”上:两个人在各自分支都更新了同一个子模块,合并主仓库时冲突的是 commit 哈希。解决办法不是改文件,而是进入子模块把两边的提交合并/选定出正确的 commit,再回主仓库更新指针。
  5. CI 必须递归拉取:流水线的 clone 命令要加 --recurse-submodules,且 CI 账号要有所有子仓库的读权限,否则构建直接失败。
  6. 删除子模块很繁琐,需要三连操作,别只删目录:
    1
    2
    3
    
    git submodule deinit -f module_cart
    git rm -f module_cart
    rm -rf .git/modules/module_cart
    
  7. 子模块嵌套要克制:子模块里再挂子模块(比如组件仓库又挂了 common 仓库)会让 update/权限/CI 复杂度成倍上升,层级尽量控制在一层,common 这类公共库更适合走 maven 依赖。

3.5.5 替代方案一览

  • git subtree:把子仓库代码真正合入主仓库,没有指针概念、clone 即完整,但子仓库历史会混入主仓库,双向同步命令更繁琐;
  • Google repo:用一份 manifest 清单管理几十上百个仓库(AOSP 的方式),适合超大规模团队;
  • maven 集成:稳定的、低频改动的组件直接发 AAR 走 maven,不必以源码形式挂进主工程——实践中常见的是 submodule(活跃业务组件源码)+ maven(稳定基础组件产物)混用

四、组件间页面跳转:路由(ARouter)

4.1 为什么需要路由

组件间没有依赖,A 组件拿不到 B 组件的 DetailActivity.class,显式 Intent 走不通。隐式 Intent(action/scheme)虽然可行,但 action 要在 manifest 集中管理、无法方便地传递复杂参数和获取跳转回调,也容易被外部应用拉起,并不适合作为组件化的内部跳转方案。

业界通用做法是引入路由框架,核心思想是:用一个字符串路径(如 /cart/CartActivity)作为页面的唯一标识,框架在编译期收集”路径 → Activity 类”的映射表,运行时根据路径查表跳转。这样组件之间只依赖字符串协议,不依赖具体类,实现了解耦。最常用的是阿里的 ARouter

4.2 ARouter 基本使用

1)添加依赖(Kotlin 项目中 ARouter 的注解处理器走 kapt,每个用到 ARouter 的组件都要配置):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 组件的 build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.kapt)
}

kapt {
    arguments {
        // 注解处理器需要知道当前 module 名,用于生成路由表类名
        arg("AROUTER_MODULE_NAME", project.name)
    }
}

dependencies {
    implementation(libs.arouter.api)
    kapt(libs.arouter.compiler)
}

2)初始化(尽早,一般在 Application 中):

1
2
3
4
5
if (BuildConfig.DEBUG) {
    ARouter.openLog()    // 开启日志
    ARouter.openDebug()  // 开启调试模式(线上必须关闭,否则有安全风险)
}
ARouter.init(application)

3)声明路由:目标 Activity 加 @Route 注解,路径至少两级(第一级会作为分组 group):

1
2
@Route(path = "/cart/CartActivity")
class CartActivity : AppCompatActivity() { ... }

4)发起跳转

1
2
3
4
5
6
7
8
9
// 简单跳转
ARouter.getInstance().build("/cart/CartActivity").navigation()

// 携带参数跳转
ARouter.getInstance()
    .build("/cart/CartActivity")
    .withString("goodsId", "10086")
    .withInt("count", 2)
    .navigation()

目标页用 @Autowired 接收参数,并在 onCreate 中调用 ARouter.getInstance().inject(this) 完成注入。注意 Kotlin 中被注入的字段要对框架可见、可赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Route(path = "/cart/CartActivity")
class CartActivity : AppCompatActivity() {

    // Kotlin 属性默认编译成私有字段+getter/setter,
    // 需要 @JvmField 暴露成公开字段,ARouter 才能注入
    @Autowired
    @JvmField
    var goodsId: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ARouter.getInstance().inject(this)
    }
}

实际项目中,建议把所有路由路径常量下沉到 common 组件统一管理(如 RouterPath.Cart.CART_ACTIVITY),避免字符串散落各处、拼写出错难以排查。

4.3 ARouter 原理简述

  • 编译期arouter-compiler 通过 APT(注解处理器) 扫描所有 @Route 注解,为每个 module 按 group 生成路由表类(如 ARouter$$Group$$cart),本质是往 Map 里填 “path → RouteMeta(目标类、类型、group)” 的代码。
  • 初始化ARouter.init() 时扫描 dex 中 com.alibaba.android.arouter.routes 包下的类,先只加载 group 索引(按需加载,避免一次性加载全部路由拖慢启动)。
  • 运行时build(path).navigation() 时先按 group 找到对应路由表,懒加载填充,再从表中取出目标 Activity 类,最终还是走系统的 startActivity
  • ARouter 还支持拦截器@Interceptor,可实现全局登录校验:未登录时拦截跳转、先跳登录页)和降级策略(路径找不到时的全局回调,可跳转到统一的”页面不存在”页或 H5)。

由于路由表是运行时查表,编译期无法校验路径是否存在,路径写错只能在运行时发现,所以拦截器 + 降级回调在生产环境是必配项。

另外要注意:ARouter 官方已长期停止维护,注解处理只支持 kapt(拖慢 Kotlin 构建)。新项目可以考虑接口完全兼容 ARouter、支持 KSP 的 TheRouter 等新一代路由框架——路由的思想是一样的,换框架成本很低。

五、组件间通信:接口下沉 + 服务发现

页面跳转之外,更常见的是方法调用,例如订单组件要获取登录组件的用户信息。组件间无依赖,如何调用对方的方法?

5.1 核心思路:接口下沉

把”能力”抽象成接口,下沉到公共层;实现留在组件内部;调用方只面向接口编程。 这就是依赖倒置原则在组件化中的应用。

通常做法是建立一个只放接口和实体类的轻量 module(常叫 module_export / module_api,或直接放 common 中):

1
2
3
4
5
6
7
// 下沉到公共层的接口(登录组件对外暴露的服务)
interface ILoginService : IProvider {
    /** 是否已登录 */
    fun isLogin(): Boolean
    /** 获取当前用户信息,未登录返回 null */
    fun getUserInfo(): UserInfo?
}

登录组件内部实现该接口,并通过 ARouter 的 IProvider 机制注册:

1
2
3
4
5
6
7
8
9
10
11
12
// 登录组件内部的实现,@Route 注册为服务
@Route(path = "/login/LoginService")
class LoginServiceImpl : ILoginService {

    override fun isLogin(): Boolean = LoginManager.isLogin()

    override fun getUserInfo(): UserInfo? = LoginManager.user

    override fun init(context: Context) {
        // ARouter 首次获取该服务时回调,可做初始化
    }
}

订单组件按接口类型发现服务,全程不接触实现类:

1
2
3
4
5
// 调用方:按类型获取服务,拿到的是接口
val loginService = ARouter.getInstance().navigation(ILoginService::class.java)
if (loginService?.isLogin() == true) {
    val user = loginService.getUserInfo()
}

这样依赖关系变成:订单组件 → 接口层 ← 登录组件,两个业务组件之间依然零依赖。除 ARouter 外,也可以用 Java 原生的 ServiceLoader(SPI)或自己维护一个”服务注册中心”单例实现同样的效果,原理一致。

5.2 事件总线:适合”通知”类通信

对于一对多、无需返回值的场景(如”登录成功”后各组件刷新自己的数据),用接口调用反而繁琐,更适合事件总线(EventBus / LiveEventBus / 基于 RxJava 的 RxBus):

1
2
3
4
5
6
7
8
// 登录组件发出事件
EventBus.getDefault().post(LoginSuccessEvent(userInfo))

// 其他组件订阅
@Subscribe(threadMode = ThreadMode.MAIN)
fun onLoginSuccess(event: LoginSuccessEvent) {
    refreshUserData(event.userInfo)
}

纯 Kotlin 项目也可以不引三方库,在 common 组件里用协程的 SharedFlow 自建一个轻量事件总线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * 基于 SharedFlow 的全局事件总线。
 * Global event bus based on SharedFlow.
 */
object FlowBus {
    // 支持粘性可把 replay 设为 1
    private val events = MutableSharedFlow<Any>(extraBufferCapacity = 64)

    /**
     * 发送事件。
     * Post an event.
     * @param event 事件对象
     */
    fun post(event: Any) {
        events.tryEmit(event)
    }

    /**
     * 订阅指定类型的事件。
     * Subscribe to events of the given type.
     * @return Flow<T> 指定类型的事件流。
     */
    inline fun <reified T> on(): Flow<T> = events.filterIsInstance<T>()
}

// 订阅方(自动跟随生命周期取消)
lifecycleScope.launch {
    FlowBus.on<LoginSuccessEvent>().collect { refreshUserData(it.userInfo) }
}

注意事件类也必须下沉到公共层,否则订阅方引用不到。事件总线要克制使用:它是隐式依赖,滥用后事件满天飞,排查”谁发的、谁收的”会非常痛苦。原则上:请求-响应式调用用服务接口,广播式通知用事件总线

5.3 获取其他组件的 Fragment

首页往往是一个 Activity 装多个来自不同组件的 Fragment(首页、分类、购物车、我的),拿不到对方 Fragment 类怎么办?给 Fragment 也加 @Route,通过路由获取实例:

1
2
@Route(path = "/cart/CartFragment")
class CartFragment : Fragment() { ... }
1
2
3
4
// 壳工程/首页组件中获取,navigation() 返回 Any?,强转即可
val cartFragment = ARouter.getInstance()
    .build("/cart/CartFragment")
    .navigation() as Fragment

六、Application 生命周期分发

组件在集成模式下没有自己的 Application(唯一的 Application 在壳工程),但组件常常需要:

  1. 获取全局 Context;
  2. 在 App 启动时做自己的初始化(例如购物车组件初始化角标监听、推送组件注册通道)。

又因为组件不能反向依赖壳工程,不能直接在壳工程 Application 里 import 各组件的初始化类硬编码调用(这样壳工程就依赖了组件内部实现,而且组件增减都要改壳工程)。通用解法仍是接口下沉 + 注册分发

1)在 common 组件定义生命周期接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * 组件级 Application 生命周期接口,定义在 common 组件中。
 * Application lifecycle interface for components, defined in the common module.
 */
interface IAppLifecycle {
    /** 优先级,数值小的先初始化(如登录组件需先于订单组件) */
    val priority: Int

    /**
     * App 启动时回调,组件在此完成自己的初始化。
     * Called on app startup for component initialization.
     * @param app Application 实例
     */
    fun onCreate(app: Application)

    /**
     * App 终止时回调。
     * Called on app termination.
     */
    fun onTerminate()
}

2)各组件实现自己的初始化逻辑:

1
2
3
4
5
6
7
8
9
10
11
class CartAppLifecycle : IAppLifecycle {

    override val priority = 5

    override fun onCreate(app: Application) {
        // 购物车组件的初始化逻辑
        CartManager.init(app)
    }

    override fun onTerminate() = Unit
}

3)壳工程 Application 中统一分发。 收集各组件实现类的方式有几种:

  • 手动注册:壳工程按全类名字符串反射实例化,配置集中、直观,缺点是新增组件要改一行配置;
  • APT 自动注册:自定义注解(如 @AppLifecycle)+ 注解处理器,编译期收集所有实现类生成注册代码,参考开源库 AppInit 或 JIMU 的 AppLifecycle 方案;
  • 字节码插桩自动注册:用 Gradle Transform + ASM 在编译期扫描实现类并注入注册逻辑,无运行时反射开销,是不少大厂框架的做法。

无论哪种方式,最终效果都是:

1
2
3
4
5
6
7
8
class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 按优先级排序后依次分发 onCreate
        AppLifecycleManager.init(this)
        AppLifecycleManager.dispatchOnCreate(this)
    }
}

顺带一提,初始化的依赖编排与异步调度(谁先谁后、哪些可以放子线程/延迟到首页展示后)也可以交给 Jetpack 的 App Startup 或各大厂开源的启动框架,与上面的分发机制并不冲突。

至于全局 Context,最简单的做法是在 common 组件里放一个 AppGlobals/Utils 持有 Application 单例,由上述 onCreate(Application app) 分发时注入;也可以通过反射 ActivityThread.currentApplication() 兜底获取。

七、组件化落地中的其他问题与解决

7.1 资源命名冲突

多个 library module 中如果存在同名资源(如都有 title_bar.xmlcolors.xml 里都定义了 colorPrimary),集成打包时会按依赖顺序后者覆盖前者,且不报错,极易出现”我的布局怎么变成别人的了”这类诡异问题。

解决办法是给每个组件配置资源前缀

1
2
3
4
// 购物车组件 build.gradle.kts
android {
    resourcePrefix = "cart_"
}

配置后,资源名不以 cart_ 开头时 IDE 会给出 lint 警告(注意:只是警告不是编译错误,需要团队约定强制执行;图片资源不受该配置检查,同样要人工遵守前缀规范)。

7.2 依赖版本统一管理

各组件各自声明依赖版本,很容易出现”A 组件用 OkHttp 3.12、B 组件用 4.12”的冲突。解决思路是在根工程统一定义版本,所有组件引用同一份配置。老项目里常见的做法是根目录建 config.gradle + ext 变量,但它没有类型安全、没有 IDE 补全,现在 Gradle 官方的标准方案是 Version Catalog(版本目录):在根目录 gradle/libs.versions.toml 中集中声明,所有 module 自动获得类型安全的 libs.xxx 访问器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# gradle/libs.versions.toml
[versions]
compileSdk = "35"
minSdk = "24"
agp = "8.10.1"
kotlin = "2.1.21"
okhttp = "4.12.0"
arouter = "1.5.2"

[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.16.0" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version = "2.11.0" }
arouter-api = { module = "com.alibaba:arouter-api", version.ref = "arouter" }
arouter-compiler = { module = "com.alibaba:arouter-compiler", version.ref = "arouter" }

[bundles]
# 总是一起使用的依赖可以打成 bundle,一次引入
network = ["okhttp", "retrofit"]

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }

各组件的 build.gradle.kts 里类型安全地引用(写错了直接编译报错,而不是运行时才发现):

1
2
3
4
5
6
7
8
9
10
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.android)
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.bundles.network)  // 一次引入 okhttp + retrofit
    implementation(libs.arouter.api)
}

这样版本号只有 toml 这一个来源,升级依赖改一处全工程生效。多工程方案下它同样适用:把这份 toml 发布成一个”版本目录组件”(或放在独立仓库),各组件工程在 settings.gradle.kts 中共享同一份:

1
2
3
4
5
6
7
8
9
// 各组件工程的 settings.gradle.kts
dependencyResolutionManagement {
    versionCatalogs {
        create("libs") {
            // 所有组件工程共享公司统一发布的版本目录
            from("com.hfy.platform:version-catalog:2.3.0")
        }
    }
}

此外,基础库依赖统一由 common 组件用 api 向上暴露,业务组件不直接声明基础库依赖,从源头避免版本分裂。

7.3 library module 中 R 字段不是常量

application module 中 R.id.xxxstatic final 常量,而 library module 中不是 final。影响:

  • Java 代码不能在 switch-case 里使用 R.id(Kotlin 的 when 不要求分支是编译期常量,不受影响);
  • 注解参数也不能直接引用 library 的 R 值,老项目里 ButterKnife 的 R2 类就是为此打的补丁。

如今直接使用 ViewBinding(或 Compose)即可完全绕开这个问题,组件化改造时顺手把 findViewById/ButterKnife 淘汰掉。

7.4 混淆配置

组件化后混淆规则不要全堆在壳工程。每个组件通过 consumerProguardFiles 声明自己的混淆规则,打包时会自动合并:

1
2
3
4
5
6
// 组件 build.gradle.kts
android {
    defaultConfig {
        consumerProguardFiles("consumer-rules.pro")
    }
}

这样组件自带混淆规则、随 AAR 分发,壳工程无需关心各组件内部该 keep 什么。

7.5 重复依赖与 manifest 合并

  • 多个组件依赖同一库的不同版本时,Gradle 默认选择最高版本,可用 ./gradlew :app:dependencies 查看依赖树排查,必要时 exclude 或用 resolutionStrategy.force 强制统一。
  • 各组件的 AndroidManifest 会在打包时合并,四大组件声明、权限声明各自写在自己组件的 manifest 中即可;冲突时可用 tools:replacetools:node="remove" 等合并策略处理。

7.6 公共资源与公共 UI

通用的颜色、尺寸、样式、通用控件(标题栏、空页面、加载弹窗)应下沉到 common 组件统一提供,避免各组件重复定义导致视觉不一致。但注意 common 不能变成垃圾场:只放真正通用且稳定的东西,业务相关的资源坚决留在业务组件内。

八、如何推进已有项目的组件化改造

对存量大项目,一步到位是不现实的,推荐渐进式改造

  1. 先梳理依赖:画出现有模块/包之间的依赖关系图,识别耦合点(互相引用的类、隐式的全局单例)。
  2. 搭好地基:先抽基础组件(网络、图片、存储、工具)和 common 组件,引入路由框架,并用 Version Catalog(libs.versions.toml)统一版本管理。
  3. 从新业务/边缘业务开刀:新业务直接按组件规范开发;老业务挑依赖最少的模块先拆,积累经验。
  4. 接口下沉解决历史耦合:老代码里跨模块的直接调用,逐个替换成”路由跳转 + 服务接口”,这是改造中工作量最大的部分,可按迭代分批做。
  5. 最后拆壳:当业务代码全部下沉到组件后,app module 自然只剩组装配置,壳工程就形成了。
  6. 配套建设:CI 上增加”组件独立编译”流水线,防止有人偷偷加回跨组件依赖;有条件的团队再演进到多仓库(git submodule 聚合源码 / maven 集成产物)。

改造过程中最重要的其实不是技术,而是约定和守护:组件边界靠团队纪律 + CI 检查共同维护,否则耦合会悄悄长回来。

九、总结

最后把整篇文章浓缩成一张问题-方案对照表:

问题解决方案
什么是组件化模块化 + 解耦:业务模块间零依赖,可独立编译运行
为什么组件化编译提速、并行协作、功能复用、业务隔离、支撑架构演进
组件独立调试推荐:组件保持 library,配独立调试壳(runner module);经典:isModule 开关切换插件 + 双 manifest
多仓库管理git submodule 聚合组件仓库(先 push 子模块再 push 主仓库;切分支后记得 submodule update),稳定组件走 maven
页面跳转路由框架(ARouter):APT 生成路由表,运行时按 path 查表跳转,配拦截器与降级
组件间方法调用接口下沉到公共层 + IProvider 服务发现,面向接口编程
广播式通知事件总线(EventBus 等),事件类下沉公共层,克制使用
获取 FragmentFragment 声明 @Route,通过路由按 path 获取实例
Application 生命周期生命周期接口下沉 + 壳工程统一按优先级分发(手动注册/APT/字节码插桩)
资源冲突resourcePrefix 约定组件资源前缀
版本冲突Version Catalog(gradle/libs.versions.toml)统一声明,多工程可共享同一份版本目录;基础依赖由 common 统一暴露
混淆各组件 consumerProguardFiles 自带规则,打包自动合并

组件化不是把工程拆成一堆 module 就完事了,它的本质是用工程手段固化架构边界:依赖单向、业务隔离、面向接口。理解了这一点,无论路由框架换成什么、构建工具怎么升级,方案都能举一反三。

参考资料

本文由作者按照 CC BY 4.0 进行授权