Android组件化,全面掌握
一、背景:为什么要组件化
随着项目逐渐扩展,业务功能越来越多,代码量越来越大,开发人员数量也越来越多。此过程中,你是否有过以下烦恼?
- 项目模块多且复杂,编译一次要 5 分钟甚至 10 分钟,太慢不能忍?
- 改了一行代码、只调了一点 UI,就要 run 整个项目,再忍受一次 10 分钟?
- 合代码经常发生冲突,很烦?
- 被人偷偷改了自己模块的代码,很不爽?
- 做一个需求,发现还要去改动很多别人模块的代码?
- 别的模块已实现的类似功能,自己要用只能去复制一份代码再改改?
- “这个不是我负责的,我不管”,代码责任范围不明确?
- 只改了一个模块的功能,但因为耦合太深,测试要完整回归一遍?
- 做了个需求,不知不觉导致其他模块出现 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、统一依赖版本
├─────────┬──────────┬──────────┬─────────────┤
│ 网络库 │ 图片库 │ 存储库 │ 工具库 │ ← 基础组件,可跨项目复用
└─────────┴──────────┴──────────┴─────────────┘
这个架构有几条铁律:
- 依赖只能自上而下,上层依赖下层,禁止下层反向依赖上层;修改频率上层高于下层。
- 业务组件之间不允许存在直接依赖(这是组件化和模块化最本质的区别)。
- 基础组件修改频率极低,可作为 SDK 供公司所有项目复用。
- common 组件统一依赖所有基础组件并锁定版本号,业务组件只依赖 common,不直接依赖基础组件,避免版本混乱。
- 壳工程只负责组装:集成所有业务组件、提供 Application 的唯一实现、全局的 gradle/manifest/签名/混淆配置,不写任何业务代码。
2.4 组件化带来的好处
- 加快编译速度:每个业务组件可独立编译运行,开发时只编译自己负责的组件,代码量小,编译自然快;集成时未改动的组件还可以直接用 AAR 产物,进一步提速。
- 提高协作效率:组件之间彼此隔离,每个人/小组各自负责自己的组件,代码权限可以按组件收敛;新人只需熟悉自己负责的组件即可上手;测试也只需重点测试改动的组件。
- 功能复用:组件如同第三方库,维护好一份,多个 App(主 App、极速版、海外版)一键集成。
- 业务隔离,降低风险:组件不能直接调用其他组件的内部实现,改动的影响范围被物理限制在组件内部。
- 为后续架构演进铺路:插件化、动态下发、按需集成(打渠道包时裁剪某些组件)都要求组件先解耦。
2.5 组件化要解决的核心问题
好处的代价是:业务组件之间没有依赖了,原来直接 startActivity(Intent(this, DetailActivity::class.java))、直接 new 对方的类的写法全都失效了。于是产生了组件化的几个核心问题:
- 业务组件如何实现单独运行调试?
- 组件间没有依赖,如何实现页面跳转?
- 组件间没有依赖,如何实现组件间通信 / 方法调用?
- 组件间没有依赖,如何获取对方的 Fragment 实例?
- 组件不能反向依赖壳工程,如何拿到 Application 实例、如何在
Application.onCreate()时机做组件初始化? - 多个组件的资源命名冲突怎么办?
- 各组件的依赖库版本如何统一?
下面逐一展开。
三、如何组件化:工程结构与独立调试
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 只能有一个启动入口)。所以 applicationId 和 AndroidManifest 也要跟着开关走。但注意:用 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"))
}
这个方案的优点:
- 不需要任何开关,独立调试直接 run
module_cart_runner,集成调试直接 runapp,切换零成本、不用重新 Sync; - 不需要双 manifest,组件的 manifest 始终是集成形态,调试入口都在壳里;
- 调试代码天然隔离:模拟数据、测试页都放 runner,绝不会混进集成包;
- 壳工程
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 的关键在于:主仓库并不存储子仓库的代码,只存两样东西——
- 一个
.gitmodules文件:记录每个子模块的本地路径和远端 URL; - 每个子模块路径上的一个 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 注意事项(都是血泪教训)
- detached HEAD(游离头指针):
git submodule update会把子模块检出到指定 commit,此时子模块不在任何分支上。如果直接在这个状态下写代码并 commit,一旦再次 update,这些提交就”看不见”了(只能靠 reflog 找回)。在子模块里开发前,务必先git checkout到具体分支。 - 先 push 子模块,再 push 主仓库:主仓库记录的只是 commit 哈希。如果你 push 了主仓库却忘了 push 子模块,同事拉代码后执行 update 会报
fatal: reference is not a tree——因为那个 commit 在远端根本不存在。可以配置git push --recurse-submodules=check(检查并阻止)或on-demand(自动帮你先推子模块)来兜底。 - 主仓库切分支,子模块不会自动跟着切:切换分支后子模块工作区还停在原来的 commit,必须习惯性补一句
git submodule update --init --recursive;也可以一劳永逸地配置git config submodule.recurse true,让 checkout/pull 自动递归处理子模块。 - 子模块的合并冲突落在”指针”上:两个人在各自分支都更新了同一个子模块,合并主仓库时冲突的是 commit 哈希。解决办法不是改文件,而是进入子模块把两边的提交合并/选定出正确的 commit,再回主仓库更新指针。
- CI 必须递归拉取:流水线的 clone 命令要加
--recurse-submodules,且 CI 账号要有所有子仓库的读权限,否则构建直接失败。 - 删除子模块很繁琐,需要三连操作,别只删目录:
1 2 3
git submodule deinit -f module_cart git rm -f module_cart rm -rf .git/modules/module_cart
- 子模块嵌套要克制:子模块里再挂子模块(比如组件仓库又挂了 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 在壳工程),但组件常常需要:
- 获取全局 Context;
- 在 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.xml、colors.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.xxx 是 static 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:replace、tools:node="remove"等合并策略处理。
7.6 公共资源与公共 UI
通用的颜色、尺寸、样式、通用控件(标题栏、空页面、加载弹窗)应下沉到 common 组件统一提供,避免各组件重复定义导致视觉不一致。但注意 common 不能变成垃圾场:只放真正通用且稳定的东西,业务相关的资源坚决留在业务组件内。
八、如何推进已有项目的组件化改造
对存量大项目,一步到位是不现实的,推荐渐进式改造:
- 先梳理依赖:画出现有模块/包之间的依赖关系图,识别耦合点(互相引用的类、隐式的全局单例)。
- 搭好地基:先抽基础组件(网络、图片、存储、工具)和 common 组件,引入路由框架,并用 Version Catalog(
libs.versions.toml)统一版本管理。 - 从新业务/边缘业务开刀:新业务直接按组件规范开发;老业务挑依赖最少的模块先拆,积累经验。
- 接口下沉解决历史耦合:老代码里跨模块的直接调用,逐个替换成”路由跳转 + 服务接口”,这是改造中工作量最大的部分,可按迭代分批做。
- 最后拆壳:当业务代码全部下沉到组件后,app module 自然只剩组装配置,壳工程就形成了。
- 配套建设:CI 上增加”组件独立编译”流水线,防止有人偷偷加回跨组件依赖;有条件的团队再演进到多仓库(git submodule 聚合源码 / maven 集成产物)。
改造过程中最重要的其实不是技术,而是约定和守护:组件边界靠团队纪律 + CI 检查共同维护,否则耦合会悄悄长回来。
九、总结
最后把整篇文章浓缩成一张问题-方案对照表:
| 问题 | 解决方案 |
|---|---|
| 什么是组件化 | 模块化 + 解耦:业务模块间零依赖,可独立编译运行 |
| 为什么组件化 | 编译提速、并行协作、功能复用、业务隔离、支撑架构演进 |
| 组件独立调试 | 推荐:组件保持 library,配独立调试壳(runner module);经典:isModule 开关切换插件 + 双 manifest |
| 多仓库管理 | git submodule 聚合组件仓库(先 push 子模块再 push 主仓库;切分支后记得 submodule update),稳定组件走 maven |
| 页面跳转 | 路由框架(ARouter):APT 生成路由表,运行时按 path 查表跳转,配拦截器与降级 |
| 组件间方法调用 | 接口下沉到公共层 + IProvider 服务发现,面向接口编程 |
| 广播式通知 | 事件总线(EventBus 等),事件类下沉公共层,克制使用 |
| 获取 Fragment | Fragment 声明 @Route,通过路由按 path 获取实例 |
| Application 生命周期 | 生命周期接口下沉 + 壳工程统一按优先级分发(手动注册/APT/字节码插桩) |
| 资源冲突 | resourcePrefix 约定组件资源前缀 |
| 版本冲突 | Version Catalog(gradle/libs.versions.toml)统一声明,多工程可共享同一份版本目录;基础依赖由 common 统一暴露 |
| 混淆 | 各组件 consumerProguardFiles 自带规则,打包自动合并 |
组件化不是把工程拆成一堆 module 就完事了,它的本质是用工程手段固化架构边界:依赖单向、业务隔离、面向接口。理解了这一点,无论路由框架换成什么、构建工具怎么升级,方案都能举一反三。