系列三:组件化与模块化进阶 | 第8篇
组件化与模块化核心实战区别:大型项目架构的必由之路(企业级全案)
阅读警告
本文为超深度技术长文,预计阅读时长 40-60 分钟,代码量极大。
在前七篇中,我们解决了**"代码怎么写"的问题(架构思想、MVVM、状态管理)。
从这一篇开始,我们要解决 "代码放在哪"和 "团队怎么协作"**的问题。如果你的项目编译一次要 5 分钟,改一行代码要等半天,或者两个人同时改代码天天冲突,那么这一篇就是为你写的。
我们将彻底厘清 模块化(Modularization) 与 组件化(Componentization) 的区别,并从零搭建一套 可独立运行、可插拔、可并行编译、可灰度发布 的企业级工程架构。
全文包含:Gradle 黑魔法、路由源码级剖析、资源隔离方案、组件生命周期管理、以及大厂落地血泪史。
1 引子:单体工程的死亡螺旋
让我们先看一个典型的"巨型工程"在 2 年后的样子。这不是虚构,这是 90% 成长型公司的必经之路。
1.1 症状诊断
- 编译速度的黑洞:全量编译从 1 分钟变成 10 分钟。因为任何一个模块的改动,Gradle 都会认为整个 App 需要重新编译。开发者的时间成本呈指数级上升。
- Git 冲突的噩梦 :
app模块下有 50 个开发人员同时在改,每天合并代码时,冲突文件多达几十个。AndroidManifest.xml永远是冲突的重灾区。 - 业务耦合的毒瘤:登录模块调用了支付模块的类,支付模块又依赖了商品模块的资源。代码像意大利面条一样纠缠在一起。想删一个功能?不敢删,因为不知道删了哪个地方会崩。
- 测试的地狱:改了一个工具类,回归测试要跑遍所有业务线。测试团队永远在加班。
- 发布的枷锁:一个业务线出了紧急 Bug,必须全量发包。没办法只更新某一个业务模块。
1.2 单体 vs 组件化 对比表
| 维度 | 单体工程 (Monolith) | 组件化工程 (Component) |
|---|---|---|
| 编译速度 | 慢(全量编译,10分钟+) | 快(增量编译,只编改动的模块,1分钟) |
| 并行开发 | 难(互相阻塞,Git 冲突多) | 易(每人负责一个组件,互不干扰) |
| 代码边界 | 模糊(互相引用,无强制约束) | 清晰(通过路由通信,物理隔离) |
| 独立调试 | 必须跑整个 App(启动慢) | 组件可单独运行(秒启) |
| 版本迭代 | 牵一发动全身(全量回归) | 组件可独立发版(灰度发布) |
| 技术栈升级 | 风险极大(牵一发而动全身) | 风险可控(单个组件试点) |
2 核心概念辨析:模块化 vs 组件化
这是 90% 的团队都会混淆的概念。请务必花 5 分钟理解透彻,这是后续所有架构的基石。
2.1 模块化(Modularization):按"技术职能"拆
定义 :将 App 拆分成多个 Library Module 。这些 Module 通常按 技术职能 划分,目的是为了代码复用。
例子:
module-network(网络封装:Retrofit、OkHttp、拦截器)module-database(数据库操作:Room、GreenDAO)module-utils(工具类:StringUtil、DateUtil)module-ui(自定义 View、Style、Theme)module-base(BaseActivity、BaseViewModel、BaseApplication)
特点:
- 不能独立运行(没有 Application,没有 Launcher Activity)。
- 依赖关系:业务层依赖基础模块。
- 目的:结构清晰,避免重复造轮子。
架构图:
2.2 组件化(Componentization):按"业务线"拆
定义 :将 App 拆分成多个 Business Component 。这些 Component 通常按 业务线 划分,目的是为了业务解耦。
例子:
component-login(登录业务:手机号登录、微信登录、注册)component-pay(支付业务:支付宝、微信、银联)component-home(首页业务:Feed流、Banner、导航)component-user(用户中心:个人信息、设置、收货地址)component-order(订单业务:列表、详情、物流)
特点:
- 可以独立运行(有自己的 Application,有自己的 Launcher Activity)。
- 不能直接互相依赖(通过路由跳转,通过接口下沉通信)。
- 目的:业务隔离,并行开发,独立发布。
架构图:
2.3 终极关系图(企业标准)
双层架构(这是大厂的标准答案):
- 底层(Level 1):基础模块(Modularization)。纯技术能力,无业务逻辑。
- 中层(Level 2):业务组件(Componentization)。纯业务逻辑,独立运行。
- 顶层(Level 3):壳工程(Shell)。空壳,只负责组装和配置。
3 实战:从零搭建组件化工程(手把手,含 Gradle 黑魔法)
现在,我们动手把一个单体工程拆成组件化工程。请跟着我的步骤操作。
3.1 第一步:工程目录规划
创建一个干净的 Project,目录结构如下:
ruby
ProjectRoot/
├── app-shell/ # 壳工程(空壳,只组装)
│ └── src/main/java/
│ └── AppShell.kt
│
├── components/ # 业务组件(可独立运行)
│ ├── component-login/
│ │ ├── src/main/java/
│ │ ├── src/debug/java/ # 独立运行时的配置
│ │ └── build.gradle
│ ├── component-home/
│ ├── component-pay/
│ └── component-mine/
│
├── modules/ # 基础模块(不可独立运行)
│ ├── module-base/ # 基类、路由、工具
│ ├── module-network/ # 网络封装
│ ├── module-storage/ # 数据库、SP
│ └── module-ui/ # 自定义 View、Style
│
├── build.gradle
├── settings.gradle
└── gradle.properties
3.2 第二步:创建模块(Gradle 配置)
在 settings.gradle 中注册所有模块。这是总控开关。
gradle
// settings.gradle.kts
include(":app-shell")
include(":component-login")
include(":component-home")
include(":component-pay")
include(":component-mine")
include(":module-base")
include(":module-network")
include(":module-storage")
include(":module-ui")
3.3 第三步:定义组件的"独立运行"开关(核心黑魔法)
这是组件化的灵魂。我们需要一个开关,控制组件是 独立运行 (开发时)还是 集成运行(打包时)。
在 gradle.properties 中定义全局变量:
properties
# 组件独立运行开关
# true = 独立运行(开发时,有 Application 和 Launcher)
# false = 集成运行(打包时,作为 Library 被壳工程依赖)
isLoginComponentDebug = true
isHomeComponentDebug = false
isPayComponentDebug = false
isMineComponentDebug = false
3.4 第四步:配置组件的 build.gradle
这是最关键的一步。组件需要根据开关切换 application 和 library 插件。
component-login/build.gradle.kts:
kotlin
plugins {
// 根据开关动态应用插件
if (isLoginComponentDebug.toBoolean()) {
id("com.android.application")
} else {
id("com.android.library")
}
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.component.login"
compileSdk = 34
defaultConfig {
minSdk = 21
// 只有作为 Application 时才需要 applicationId
if (isLoginComponentDebug.toBoolean()) {
applicationId = "com.example.component.login"
}
targetSdk = 34
}
// 源集配置:区分 debug 和 release
sourceSets {
getByName("main") {
// Manifest 文件分两套
if (isLoginComponentDebug.toBoolean()) {
manifest.srcFile("src/debug/AndroidManifest.xml")
} else {
manifest.srcFile("src/main/AndroidManifest.xml")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
dependencies {
implementation(project(":module-base")) // 依赖基础模块
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
}
3.5 第五步:壳工程(Shell)的配置
壳工程是一个空的 Application,只负责组装组件。它不写任何业务逻辑。
app-shell/build.gradle.kts:
kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.appshell"
compileSdk = 34
defaultConfig {
applicationId = "com.example.appshell"
minSdk = 21
targetSdk = 34
}
}
dependencies {
implementation(project(":module-base"))
// 根据开关依赖组件
// 注意:如果组件是独立运行模式,壳工程就不能依赖它
if (!isLoginComponentDebug.toBoolean()) {
implementation(project(":component-login"))
}
if (!isHomeComponentDebug.toBoolean()) {
implementation(project(":component-home"))
}
if (!isPayComponentDebug.toBoolean()) {
implementation(project(":component-pay"))
}
if (!isMineComponentDebug.toBoolean()) {
implementation(project(":component-mine"))
}
}
3.6 第六步:Manifest 的隔离策略
组件作为 Library 时,不能有 applicationId,也不能有自己的 Launcher Activity。
1. 组件的公共 Manifest (component-login/src/main/AndroidManifest.xml):
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 注意:没有 applicationId -->
<application>
<!-- 这里只注册组件内的 Activity,不要写 Launcher -->
<activity
android:name=".LoginActivity"
android:exported="true" />
</application>
</manifest>
2. 组件的 Debug Manifest (component-login/src/debug/AndroidManifest.xml):
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".LoginApplication"
android:allowBackup="true"
android:label="登录组件(Debug)">
<!-- 独立运行时的入口 -->
<activity
android:name=".LoginActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
3. 壳工程的 Manifest (app-shell/src/main/AndroidManifest.xml):
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".AppShell"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<!-- 壳工程的唯一入口 -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
4 组件通信:ARouter 源码级剖析与实战
组件不能互相依赖,那怎么跳转?答案是 路由(Router) 。市面上路由框架很多,但 ARouter 是阿里出品,最成熟,也是大厂标配。
4.1 为什么不用 Intent 隐式跳转?
- 无法传递复杂对象:Intent 只能传基本类型和 Serializable/Parcelable。像 Bitmap、自定义对象集合很难传。
- 无法获取返回值 :
StartActivityForResult在组件化下很难用,因为不知道目标 Activity 的类名。 - URL 硬编码 :
Intent intent = new Intent("com.example.login.LoginActivity")容易写错,且重构困难。 - 无法拦截:无法统一做登录校验、权限校验、埋点。
4.2 ARouter 的核心原理(源码级)
ARouter 通过 注解处理器(APT) 在编译期生成映射表。
流程详解:
-
编译期(APT):
- 扫描所有
@Route(path = "/login/activity")。 - 生成类
ARouter$$Group$$login,里面有一个HashMap<String, RouteMeta>。 - Key 是
"/login/activity",Value 是LoginActivity.class。
- 扫描所有
-
运行期(Init):
ARouter.init(application)被调用。- 通过反射加载所有生成的
ARouter$$Group$$*类。 - 把 HashMap 加载到内存中。
-
运行期(Navigation):
ARouter.getInstance().build("/login/activity").navigation()。- 在内存 Map 中查找
"/login/activity"对应的 Class。 - 调用
startActivity(new Intent(context, LoginActivity.class))。
流程图:
4.3 实战:集成 ARouter(企业级配置)
1. 基础模块 module-base 中添加依赖(作为统一出口):
gradle
// module-base/build.gradle.kts
dependencies {
// Arouter API
implementation("io.github.alibaba:arouter-api:1.5.2")
// 注意:Compiler 不能放在 base 里,因为每个组件都要用自己的 Compiler
}
2. 每个业务组件中添加 Compiler 依赖:
gradle
// component-login/build.gradle.kts
dependencies {
// Arouter Compiler (注解处理器)
kapt("io.github.alibaba:arouter-compiler:1.5.2")
}
3. 初始化(壳工程中):
kotlin
// app-shell/AppShell.kt
class AppShell : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
ARouter.openLog()
ARouter.openDebug() // 开启调试模式(如果在 Instant Run 模式下运行,必须开启)
}
ARouter.init(this)
}
}
4. 使用(跳转与传参):
kotlin
// 跳转
ARouter.getInstance()
.build("/pay/activity")
.withString("orderId", "123456")
.withInt("price", 999)
.navigation()
// 接收参数
@Route(path = "/pay/activity")
class PayActivity : AppCompatActivity() {
@Autowired(name = "orderId")
lateinit var orderId: String
@Autowired(name = "price")
var price: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ARouter.getInstance().inject(this) // 自动注入
Log.d("PayActivity", "orderId=$orderId, price=$price")
}
}
4.4 拦截器(企业级核心:登录校验)
场景:未登录用户点击"我的订单",直接跳转到登录页。
kotlin
@Interceptor(priority = 8, name = "登录拦截器")
class LoginInterceptor : IInterceptor {
override fun process(postcard: Postcard?, callback: InterceptorCallback?) {
val path = postcard?.path
// 需要登录的页面
if (path == "/order/activity" || path == "/pay/activity") {
if (!UserManager.isLogin) {
// 中断路由,跳转到登录
ARouter.getInstance().build("/login/activity").navigation()
callback?.onInterrupt(RuntimeException("未登录"))
return
}
}
// 放行
callback?.onContinue(postcard)
}
override fun init(context: Context?) {}
}
5 资源隔离与冲突解决(大坑预警)
组件化最大的坑不是代码,而是 资源。资源冲突会导致编译直接失败,或者运行时出现诡异的样式错乱。
5.1 资源命名冲突
如果组件 A 和组件 B 都有一个 btn_confirm.xml,编译时会报错:Resource entry is already defined。
解决方案 :强制资源前缀(Resource Prefix)。
在 gradle.properties 中配置:
properties
# component-login
resourcePrefix = login_
# component-pay
resourcePrefix = pay_
# component-home
resourcePrefix = home_
在 build.gradle 中强制:
gradle
android {
resourcePrefix 'login_'
}
命名规范(强制执行):
- Layout:
login_activity_main.xml,pay_activity_index.xml - Drawable:
login_ic_wechat.png,pay_bg_alipay.webp - String:
login_btn_confirm,pay_title_price - Style:
LoginTheme,PayButtonStyle
5.2 公共资源下沉
有些资源是全局通用的(如 colors.xml, styles.xml, ic_launcher.png, strings.xml 中的 App 名称)。
策略:
- 放在
module-base或module-common中。 - 组件依赖
module-base。 - 组件自己的资源只给自己用。
注意 :module-base 中的资源越少越好,否则会成为新的瓶颈。
5.3 Theme 隔离
每个组件可以有自己的 Theme,但最终要继承壳工程的 Theme。
xml
<!-- module-base/themes.xml -->
<style name="BaseAppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- 全局定义:颜色、字体、形状 -->
<item name="colorPrimary">@color/base_color_primary</item>
</style>
<!-- component-login/themes.xml -->
<style name="LoginTheme" parent="BaseAppTheme">
<!-- 登录页特有:比如背景是白色 -->
<item name="android:windowBackground">@color/white</item>
</style>
6 组件初始化:Application 的拆分
以前我们只有一个 Application。现在组件化后,每个组件都需要初始化(如推送、地图、数据库、IM SDK)。
问题:组件没有 Application,怎么初始化?
6.1 方案一:ContentProvider(推荐,无侵入)
Android 在初始化 Application 时,会先初始化所有 ContentProvider。我们可以利用这个机制。
原理:
- 每个组件定义一个
InitProvider。 - App 启动时,系统自动调用所有 Provider 的
onCreate。
实现:
kotlin
// module-base/BaseInitProvider.kt
class BaseInitProvider : ContentProvider() {
override fun onCreate(): Boolean {
// 初始化基础库(网络、日志、数据库)
initBaseLibs()
return true
}
// ... 其他方法空实现
}
// component-login/LoginInitProvider.kt
class LoginInitProvider : ContentProvider() {
override fun onCreate(): Boolean {
// 初始化登录模块(推送、IM)
initLoginSdk()
return true
}
}
优点 :无侵入,自动调用。
缺点:Provider 过多会影响启动速度(需优化)。
6.2 方案二:接口代理(手动调用,可控)
定义一个初始化接口,壳工程启动时依次调用。
实现:
kotlin
// module-base/IComponentApplication.kt
interface IComponentApplication {
fun onCreate(app: Application)
fun onTerminate() {}
}
// component-login/LoginApplication.kt
class LoginApplication : IComponentApplication {
override fun onCreate(app: Application) {
initLoginSdk()
}
}
// AppShell.kt
class AppShell : Application() {
override fun onCreate() {
super.onCreate()
// 手动调用(可以通过反射,或者维护一个列表)
LoginApplication().onCreate(this)
PayApplication().onCreate(this)
HomeApplication().onCreate(this)
}
}
优点 :启动顺序可控,方便排查问题。
缺点:需要手动维护调用列表。
6.3 方案三:Jetpack Startup(官方推荐,替代 ContentProvider)
Google 推出了 App Startup 库,专门解决组件初始化问题。
实现:
kotlin
// module-base/BaseInitializer.kt
class BaseInitializer : Initializer<Unit> {
override fun create(context: Context) {
initBaseLibs()
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
// component-login/LoginInitializer.kt
class LoginInitializer : Initializer<Unit> {
override fun create(context: Context) {
initLoginSdk()
}
override fun dependencies(): List<Class<out Initializer<*>>> {
// 依赖 BaseInitializer,确保先初始化基础库
return listOf(BaseInitializer::class.java)
}
}
优点 :官方支持,性能好,依赖关系清晰。
缺点:需要引入新库。
7 企业级组件化工程结构(最终形态)
ruby
ProjectRoot/
├── app-shell/ # 壳工程(空壳,只组装)
│ └── src/main/java/
│ └── AppShell.kt
│
├── components/ # 业务组件(可独立运行)
│ ├── component-login/
│ │ ├── src/main/java/
│ │ │ └── LoginActivity.kt
│ │ ├── src/debug/java/ # 独立运行时的配置
│ │ └── build.gradle
│ ├── component-home/
│ ├── component-pay/
│ └── component-mine/
│
├── modules/ # 基础模块(不可独立运行)
│ ├── module-base/ # 基类、路由、工具
│ ├── module-network/ # 网络封装
│ ├── module-storage/ # 数据库、SP
│ └── module-ui/ # 自定义 View、Style
│
├── build.gradle
├── settings.gradle
└── gradle.properties
8 总结:组件化的"军规"
- 组件之间零依赖 :只能通过路由通信,不能
implementation project(:component-login)。 - 资源必须加前缀:防止冲突,这是红线。
- 基础模块下沉:通用代码往下沉,业务代码往上浮。
- 独立运行优先:开发时组件能独立跑,不依赖壳工程。
- 壳工程要薄:壳工程只做组装和全局配置,不包含业务逻辑。
- 初始化要收敛:统一用 Startup 或 Provider,不要在 Application 里写一堆 init。
下一篇预告 :
系列三:组件化与模块化进阶 | 第9篇:组件化架构从零搭建实战(Gradle 极速配置与编译加速)
我们将深入 Gradle 的 Configuration Cache、Build Cache、并行编译 ,把 10 分钟的编译缩短到 1 分钟以内。同时会讲 多环境配置(Dev/Test/Prod) 和 多渠道打包。
如果你的项目已经到了"编译一次去喝杯咖啡"的阶段,请把这篇转给技术负责人。组件化不是选择题,而是生存题。