组件化架构全景:从大泥球到分层模块化的工程革命
一个 Android 项目的早期通常只有一个 app 模块——所有代码都塞在这个「超级包裹」里。Activity 直接 new 出 Repository,Repository 又反过来引用 UI 层的常量,工具类散落在各个包名之下,任何人修改一个网络请求的回调,都可能意外地触发整个工程的全量编译。
这就是所谓的**「大泥球」(Big Ball of Mud)**——代码边界模糊、模块间高度耦合、牵一发而动全身的混沌状态。当团队规模从 3 人扩展到 30 人,当功能从 10 个页面膨胀到 200 个页面,这颗泥球就会变成所有工程师的噩梦:编译一次 8 分钟、合并代码必然冲突、修复一个 Bug 又引入三个新 Bug。
组件化架构正是解决这个问题的系统性工程方案。它不是一个框架、一个库,而是一套分而治之的设计哲学——把庞大的单体应用拆分为职责单一、边界清晰、可独立编译调试的模块组合。
本文作为「组件化架构」子方向的概览文章,将建立对整个组件化体系的全局认知:从为什么要这么做,到分层架构的设计逻辑,再到模块边界的底层保障机制。后续的深入文章将分别展开 ARouter 路由框架原理和 Gradle 依赖治理的细节。
大泥球的病理分析:为什么单模块架构必然崩塌
在深入架构方案之前,先彻底理解「大泥球」的病因——这决定了我们后面每一层设计的 Why。
症状一:编译雪崩
在单模块架构下,Gradle 的增量编译模型面临一个严峻的问题:编译边界等于整个工程。
Gradle 的增量编译依赖于 ABI(Application Binary Interface)变更检测。当一个 .kt 或 .java 文件被修改时,Gradle 会检查其公共 API 是否发生了变化。如果变化了,所有依赖该文件的编译单元都必须重新编译。在单模块架构下,所有文件都在同一个编译单元中——这意味着一个工具类的方法签名变更,可能触发上千个文件的重新编译。
单模块架构的编译影响范围:
修改 NetworkUtils.kt 的一个方法签名
↓
Gradle 检测到 ABI 变更
↓
重新编译整个 app 模块(2000+ 文件)
↓
编译时间:8 分钟
多模块架构将这个「爆炸半径」限制在模块内部:
多模块架构的编译影响范围:
修改 :core:network 模块中的 NetworkUtils.kt
↓
Gradle 检测到 ABI 变更
↓
仅重新编译 :core:network(50 个文件)
+ 直接依赖它且使用了变更 API 的模块
↓
编译时间:40 秒
症状二:依赖地狱
单模块中没有编译级别的访问控制。Kotlin 的 internal 修饰符在单模块下等同于 public——因为整个工程就是一个模块。这让所谓的「内部实现」形同虚设,任何开发者都可以直接引用任何类。
// 本意是 :feature:login 的内部实现
// 但在单模块下,:feature:order 可以直接 import 它
internal class LoginTokenManager {
fun getToken(): String = "..."
}
// 其他功能区域的代码随意引用
class OrderSubmitter {
// 直接跨「边界」调用——编译通过,架构腐化
val token = LoginTokenManager().getToken()
}
症状三:协作泥潭
10 个人改同一个模块的代码,Git 合并冲突是家常便饭。更要命的是,由于没有物理边界,很难定义「谁负责哪段代码」。一个 strings.xml 文件可能同时被三个团队修改,一个 Application.onCreate() 可能承载了十几个 SDK 的初始化逻辑。
将上述问题归纳为一张病理诊断表:
| 症状 | 根因 | 组件化的解法 |
|---|---|---|
| 编译耗时长 | 编译边界 = 整个工程 | 拆分为独立编译单元,限制变更传播范围 |
| 模块间随意引用 | 无编译级访问控制 | 通过 Gradle implementation 隔离可见性 |
| 合并冲突频繁 | 所有人改同一个模块 | 每个团队拥有独立的模块仓库 |
| 资源命名冲突 | 全局资源合并 | resourcePrefix 强制命名空间 |
| 无法独立调试 | 启动入口只有一个 | application / library 动态切换 |
模块化与组件化:概念辨析
在 Android 生态中,「模块化(Modularization)」和「组件化(Componentization)」经常被混用。它们的关系用一个比喻可以讲清楚:
模块化是「拆房间」——把一间大通铺隔成卧室、厨房、卫生间,每个房间有明确的用途。组件化是「造公寓」——每个单元不仅有独立的房间,还有独立的门、独立的水电表、可以独立出租。
更精确地说:
| 维度 | 模块化 | 组件化 |
|---|---|---|
| 核心目标 | 代码的逻辑分离 | 业务的物理隔离 |
| 独立运行 | 不要求 | 要求(可单独编译为 APK 调试) |
| 通信方式 | 可以直接依赖 | 必须通过路由 / 接口解耦 |
| Gradle 插件 | 通常固定为 library |
可动态切换 application / library |
| 团队边界 | 逻辑划分 | 物理划分(不同团队拥有不同组件) |
Google 官方文档中使用的术语是 Modularization,它更偏向「模块化」的概念。而中国 Android 社区中流行的「组件化」,则在模块化的基础上增加了独立编译调试和路由通信两个核心要求。
本文讨论的是广义的组件化——它涵盖了模块化的分层设计,同时涵盖了独立运行和解耦通信的工程实践。
四层架构模型:组件化的骨架
一个工业级的组件化架构通常分为四个层次,从上到下依次是:
┌─────────────────────────────────────────────┐
│ App Shell │ ← 壳工程:入口、集成、全局配置
├──────────┬──────────┬──────────┬─────────────┤
│ :feature │ :feature │ :feature │ :feature │ ← 业务组件层:各业务独立模块
│ :login │ :home │ :order │ :payment │
├──────────┴──────────┴──────────┴─────────────┤
│ Module-API 层 │ ← 契约层:接口、DTO、路由定义
│ (:login-api) (:order-api) (:pay-api) │
├──────────────────────────────────────────────┤
│ 基础组件层(Core) │ ← 基建层:网络、存储、UI 组件
│ :core:network :core:database :core:ui │
│ :core:common :core:designsystem │
└──────────────────────────────────────────────┘
第一层:App Shell(壳工程)
壳工程是应用的入口模块,它扮演的角色像一个总装车间:自己不生产零件,只负责把各个业务组件装配到一起。
// :app 模块的 build.gradle.kts
plugins {
id("com.android.application")
}
dependencies {
// 壳工程只"装配",依赖各个业务组件
implementation(project(":feature:login"))
implementation(project(":feature:home"))
implementation(project(":feature:order"))
implementation(project(":feature:payment"))
// 依赖基础组件
implementation(project(":core:network"))
implementation(project(":core:designsystem"))
}
壳工程的职责清单:
- 应用入口:声明
Application类,执行全局初始化 - 导航图组装:将各个 Feature 模块的页面注册到全局导航图
- 依赖注入根配置:配置 Hilt 或 Dagger 的顶级模块
- ProGuard / R8 规则聚合:集中管理混淆规则
壳工程有一条铁律:不包含任何业务逻辑。如果你在壳工程里写了一个 if (userType == VIP) 的判断分支,就说明有业务代码泄漏到了壳层——这是架构腐化的信号。
第二层:业务组件层(Feature Modules)
每个业务组件封装了一个完整的业务功能。它应该是高内聚的——登录组件包含登录相关的 UI、ViewModel、Repository、数据源,形成一个自包含的垂直切片。
:feature:login/
├── src/main/
│ ├── java/com/example/login/
│ │ ├── ui/ # 登录页面 UI
│ │ │ ├── LoginScreen.kt
│ │ │ └── LoginViewModel.kt
│ │ ├── data/ # 数据层
│ │ │ ├── LoginRepository.kt
│ │ │ └── AuthDataSource.kt
│ │ └── di/ # 依赖注入配置
│ │ └── LoginModule.kt
│ ├── res/ # 模块私有资源
│ └── AndroidManifest.xml
└── build.gradle.kts
业务组件之间的核心规则只有一条:业务组件之间绝对不能直接依赖。
✗ 禁止 ✓ 允许
:feature:login ──→ :feature:order :feature:login ──→ :order-api
:feature:order ──→ :order-api (实现它)
如果登录组件需要调用订单组件的某个能力(比如「查询用户是否有未完成的订单」),它不能 import 订单组件的任何类。它只能依赖订单组件对外暴露的契约接口(下面的 Module-API 层)。
第三层:Module-API 层(契约层)
这是整个架构中最精妙的一层设计。它的思想源自 DDD(领域驱动设计)中的防腐层(Anti-Corruption Layer)和 SOLID 中的依赖倒置原则(Dependency Inversion Principle, DIP)。
先用一个比喻理解它的作用:
Module-API 就像国际贸易中的「海关」。两个国家(业务组件)之间的货物往来,不允许直接「翻墙私运」,必须经过海关检查。海关定义了标准化的报关单格式(接口和 DTO),两国的货物必须转换为这种标准格式才能通关。即使一个国家内部的制度发生了翻天覆地的变化,只要它的报关单格式不变,另一个国家就不受影响。
具体实现方式:为每个业务组件创建一个轻量的 -api 模块,其中只包含三类内容:
- 接口定义(Interface):组件对外提供的能力
- 数据传输对象(DTO / Data Class):跨模块传递的数据结构
- 路由常量(Route Constants):页面跳转的 Path 定义
// :feature:order-api 模块
// 这个模块只有接口和数据类,没有任何实现
/**
* 订单服务的公共契约
* 其他模块通过依赖此接口获取订单能力,而不是依赖订单模块的实现
*/
interface IOrderService {
/** 查询用户是否有未完成的订单 */
suspend fun hasPendingOrder(userId: String): Boolean
/** 获取订单摘要信息 */
suspend fun getOrderSummary(orderId: String): OrderSummaryDTO
}
/**
* 跨模块传递的订单摘要数据
* 注意:这是 API 层的 DTO,不是实现层的数据库 Entity
*/
data class OrderSummaryDTO(
val orderId: String,
val totalAmount: Long, // 金额用 Long 表示分,避免浮点精度问题
val status: OrderStatus,
)
/** 订单状态枚举,属于公共契约的一部分 */
enum class OrderStatus {
PENDING, PAID, SHIPPED, COMPLETED, CANCELLED
}
而实现模块(:feature:order)则依赖并实现这个接口:
// :feature:order 模块中的实现
// 注意:这个类被标记为 internal,外部模块无法直接访问
internal class OrderServiceImpl @Inject constructor(
private val orderRepository: OrderRepository,
) : IOrderService {
override suspend fun hasPendingOrder(userId: String): Boolean {
return orderRepository.getOrdersByUser(userId)
.any { it.status == OrderStatus.PENDING }
}
override suspend fun getOrderSummary(orderId: String): OrderSummaryDTO {
val order = orderRepository.getOrder(orderId)
// 将内部的 OrderEntity 转换为公共的 OrderSummaryDTO
return OrderSummaryDTO(
orderId = order.id,
totalAmount = order.totalAmountInCents,
status = order.status,
)
}
}
依赖关系的流向:
:feature:login ───implementation───→ :feature:order-api ← 只看到接口
↑
:feature:order ───implementation───→ :feature:order-api ← 实现接口
这种设计带来了三个关键收益:
| 收益 | 说明 |
|---|---|
| 编译隔离 | :feature:order 内部代码怎么改,只要 order-api 的接口没变,依赖 order-api 的模块都不需要重新编译 |
| 实现可替换 | 可以轻松地为同一个接口提供不同的实现(如测试用的 Mock 实现) |
| 防止 API 泄漏 | 实现类被标记为 internal,只有接口和 DTO 对外可见 |
第四层:基础组件层(Core Modules)
基础组件提供与业务无关的通用能力——网络请求、数据库封装、图片加载、设计系统组件等。它们位于架构的最底层,被所有上层模块依赖。
:core:network/ # Retrofit/OkHttp 封装,拦截器,Token 刷新逻辑
:core:database/ # Room 数据库封装,通用 DAO 基类
:core:common/ # 纯 Kotlin 工具类(日期格式化、金额计算等),无 Android 依赖
:core:designsystem/ # Compose 主题、颜色、排版、通用 UI 组件
:core:testing/ # 测试工具类、Fake 实现、自定义 Test Rule
基础组件有一条铁律:基础组件绝不能反向依赖业务组件。依赖关系只允许从上往下流动。
✓ :feature:login → :core:network (上层依赖下层)
✗ :core:network → :feature:login (下层反向依赖上层——禁止)
Google 的 Now in Android 示例项目正是这种四层架构的最佳范例。它明确地划分了 :app、:feature:*、:core:* 三个层级(Now in Android 的业务模块相对简单,没有单独的 -api 层,但核心的分层思想是一致的)。
Gradle 模块类型的底层机制
组件化架构在 Gradle 构建系统中的落地,核心在于两种插件的正确选用:com.android.application 和 com.android.library。理解它们的底层差异,才能理解组件化工程中许多「奇怪」的配置。
Application vs Library:产物与构建流程的本质区别
com.android.application com.android.library
│ │
▼ ▼
编译 .kt/.java → .class 编译 .kt/.java → .class
│ │
▼ ▼
R8/ProGuard 混淆 不执行混淆
│ │
▼ ▼
DEX 转换 → .dex 打包为 .aar
│ (classes.jar + res/
▼ + AndroidManifest.xml
APK 打包与签名 + R.txt + proguard.txt)
│
▼
最终产物:.apk 最终产物:.aar
两者的关键差异:
| 维度 | application |
library |
|---|---|---|
| 产物格式 | APK(可安装运行) | AAR(供其他模块引用) |
| applicationId | 必须声明 | 不能声明 |
| DEX 转换 | 执行 | 不执行(由最终的 app 模块统一执行) |
| 资源 R 类 | R.java 中的字段为 static final(常量) |
R.java 中的字段为 static(非常量) |
| ProGuard/R8 | 在此阶段执行 | 仅提供规则文件,由 app 模块统一执行 |
| 签名 | 执行 | 不执行 |
这里有一个容易被忽视的细节:Library 模块的 R 类字段不是编译时常量。这意味着在 Library 模块中,你不能在 when 表达式(或 Java 的 switch)中使用 R.id.xxx 作为 case 值——因为 when 要求 case 必须是编译时常量。
// 在 Library 模块中,这段代码会报错!
when (view.id) {
R.id.btn_login -> { /* ... */ } // Error: R.id.btn_login 不是常量
R.id.btn_register -> { /* ... */ }
}
// 正确做法:使用 if-else 或资源名查找
if (view.id == R.id.btn_login) { /* ... */ }
为什么 Library 的 R 字段不能是常量? 因为 Library 可能被多个 Application 引用,最终打包时 AAPT2 会为所有资源重新分配 ID,Library 模块在编译期无法确定最终的资源 ID 值。只有 Application 模块才能确定最终的 ID 分配,所以只有 Application 的 R 字段才是 final。
Application / Library 动态切换:独立调试的底层实现
组件化的核心工程能力之一,是让每个业务组件都能独立编译为 APK 运行调试,而不需要编译整个工程。这通过 Gradle 插件的动态切换实现。
第一步:定义全局开关
在项目根目录的 gradle.properties 中:
# true = 组件模式(独立运行),false = 集成模式(作为 Library)
isModule=false
第二步:在模块的 build.gradle.kts 中根据开关动态应用插件
// :feature:login/build.gradle.kts
// 读取全局开关
val isModule: Boolean = project.properties["isModule"]?.toString()?.toBoolean() ?: false
plugins {
// 根据标志位选择插件
// 注意:Gradle Kotlin DSL 不支持在 plugins {} 中使用条件判断
// 实践中需要使用 apply() 方式
}
if (isModule) {
apply(plugin = "com.android.application")
} else {
apply(plugin = "com.android.library")
}
android {
namespace = "com.example.feature.login"
defaultConfig {
// 组件模式下需要 applicationId 才能独立安装
if (isModule) {
applicationId = "com.example.feature.login.debug"
}
}
// 关键:根据模式切换 AndroidManifest
sourceSets {
getByName("main") {
if (isModule) {
// 组件模式:使用带启动 Activity 的完整清单
manifest.srcFile("src/main/debug/AndroidManifest.xml")
} else {
// 集成模式:使用精简清单(不含启动入口)
manifest.srcFile("src/main/AndroidManifest.xml")
}
}
}
}
第三步:准备两套 AndroidManifest
<!-- src/main/AndroidManifest.xml(集成模式——精简版) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".ui.LoginActivity"
android:exported="false" />
</application>
</manifest>
<!-- src/main/debug/AndroidManifest.xml(组件模式——完整版) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".debug.LoginDebugApplication"
android:theme="@style/Theme.Login">
<activity android:name=".ui.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>
底层原理:当 isModule=true 时,Gradle 应用 com.android.application 插件,这触发了完全不同的构建任务链——包括 DEX 转换、APK 打包和签名。此时该模块成为一个可独立安装的应用,开发者可以只编译这一个模块就在设备上调试,极大缩短了编译-调试循环。
模块间通信:解耦的三种武器
业务组件之间不能直接依赖,那它们如何通信?工业实践中有三种主要方案,各有适用场景。
方案一:路由框架(以 ARouter 为代表)
路由框架是 Android 组件化中解决页面跳转的标准方案。其核心思想是用一个字符串 Path 替代 Java/Kotlin 的类引用,通过「路由表」间接寻址。
// 不使用路由(强耦合——模块间需要能 import 对方的类)
val intent = Intent(this, OrderDetailActivity::class.java)
intent.putExtra("orderId", "12345")
startActivity(intent)
// 使用 ARouter(解耦——只需要知道路径字符串)
ARouter.getInstance()
.build("/order/detail") // 通过路径寻址,无需 import 任何类
.withString("orderId", "12345")
.navigation()
ARouter 的工作原理可以概括为两个阶段:
- 编译期:APT(Annotation Processing Tool)注解处理器扫描所有
@Route注解,为每个模块生成路由映射表(本质是一个Map<String, Class<?>>) - 运行时:应用启动时加载所有模块的路由映射表,路由跳转时通过 Path 查表,获取目标 Activity 的
Class对象,再通过标准Intent完成跳转
关于 ARouter 如何利用 APT 在编译期生成路由映射表、运行时如何执行路由分发和拦截器链,详见下一篇文章《ARouter 路由框架的底层原理》。
方案二:接口下沉 + 依赖注入(Hilt / Dagger)
对于非页面跳转的组件间通信(如调用另一个模块的服务方法),推荐使用「接口下沉 + DI 注入」的方案。
这正是前文 Module-API 层的实战应用:
// 1. 在 :feature:order-api 中定义接口(已下沉到公共层)
interface IOrderService {
suspend fun hasPendingOrder(userId: String): Boolean
}
// 2. 在 :feature:order 中提供实现
internal class OrderServiceImpl @Inject constructor(
private val orderRepo: OrderRepository,
) : IOrderService {
override suspend fun hasPendingOrder(userId: String): Boolean =
orderRepo.countPendingOrders(userId) > 0
}
// 3. 在 :feature:order 的 Hilt Module 中绑定接口与实现
@Module
@InstallIn(SingletonComponent::class)
abstract class OrderModule {
@Binds
abstract fun bindOrderService(impl: OrderServiceImpl): IOrderService
}
// 4. 在 :feature:login 中通过注入获取——无需知道实现类是谁
@HiltViewModel
class LoginViewModel @Inject constructor(
private val orderService: IOrderService, // Hilt 自动注入 OrderServiceImpl
) : ViewModel() {
fun checkPendingOrders(userId: String) {
viewModelScope.launch {
val hasPending = orderService.hasPendingOrder(userId)
// ... 处理结果
}
}
}
Hilt 在编译期通过 KSP(Kotlin Symbol Processing)生成依赖注入的胶水代码,运行时通过 Dagger 的组件树完成对象的创建和注入。由于一切都在编译期确定,相比反射方案性能开销极小。
方案三:SPI 服务发现(ServiceLoader)
对于不想引入重量级 DI 框架的场景,可以使用 JDK 内置的 SPI(Service Provider Interface) 机制:
// 1. 在公共模块中定义接口
interface IPaymentService {
fun getPaymentMethods(): List<String>
}
// 2. 在 :feature:payment 模块中实现接口
class PaymentServiceImpl : IPaymentService {
override fun getPaymentMethods(): List<String> =
listOf("支付宝", "微信支付", "银行卡")
}
// 3. 在 :feature:payment 的 resources/META-INF/services/ 下注册
// 文件名:com.example.api.IPaymentService
// 文件内容:com.example.payment.PaymentServiceImpl
// 4. 在任意模块中通过 ServiceLoader 发现
val paymentService = ServiceLoader.load(IPaymentService::class.java)
.firstOrNull()
?: throw IllegalStateException("未找到 IPaymentService 的实现")
三种方案适用场景对比:
| 方案 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| ARouter 路由 | 跨模块页面跳转、参数传递 | 支持拦截器、降级策略 | 需要引入框架;类型安全偏弱 |
| Hilt/Dagger DI | 服务方法调用、对象生命周期管理 | 编译期安全检查、零反射 | 学习曲线陡峭 |
| SPI ServiceLoader | 轻量级接口发现 | 零框架依赖、JDK 原生支持 | 运行时反射、无生命周期管理 |
工业实践中通常混合使用:ARouter 负责页面路由,Hilt 负责服务注入与生命周期管理,两者协作覆盖所有跨模块通信场景。
资源隔离:命名冲突的防御机制
当多个模块各自拥有资源文件(布局、字符串、drawable 等),最终打包时 AAPT2 会将所有资源合并到同一个资源表中。如果两个模块都定义了 R.string.title,后者会悄悄覆盖前者——没有编译错误,只有运行时的莫名其妙。
resourcePrefix:编译期的命名空间约束
Gradle 提供了 resourcePrefix 配置,强制模块内的所有资源名称必须以指定前缀开头:
// :feature:login/build.gradle.kts
android {
resourcePrefix = "login_" // 强制所有资源名以 "login_" 开头
}
配置后,如果在 :feature:login 模块中定义了一个名为 bg_main 的 drawable,IDE 会报黄色警告(注意:这只是 Lint 警告,不是编译错误):
Resource named 'bg_main' does not start with the prefix 'login_'
正确的命名应为 login_bg_main。
Manifest 合并规则
当多个模块都声明了 AndroidManifest.xml,AGP 在构建时会按照优先级规则将它们合并为一个最终清单。合并优先级从高到低:
构建变体清单(src/fullDebug/)
↓ 覆盖
构建类型清单(src/debug/)
↓ 覆盖
产品风格清单(src/full/)
↓ 覆盖
主模块清单(src/main/AndroidManifest.xml)
↓ 覆盖
依赖库模块的清单
当属性冲突时,可以使用 tools 命名空间精确控制合并行为:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:theme="@style/AppTheme"
tools:replace="android:theme"> <!-- 强制使用本模块的 theme -->
</application>
</manifest>
api vs implementation:依赖传递的编译器级理解
Gradle 的 api 和 implementation 是控制模块边界的核心武器。理解它们的区别,不能停留在「一个传递一个不传递」的层面——需要理解 Gradle 构建系统中**编译类路径(Compile Classpath)和运行时类路径(Runtime Classpath)**的分离。
模块 A 依赖模块 B 依赖模块 C
情况一:B 使用 api(C)
┌──────────────────────────────────────┐
│ A 的 Compile Classpath: B + C │ ← A 编译时能看到 C 的类
│ A 的 Runtime Classpath: B + C │ ← A 运行时能用 C 的类
└──────────────────────────────────────┘
情况二:B 使用 implementation(C)
┌──────────────────────────────────────┐
│ A 的 Compile Classpath: B │ ← A 编译时看不到 C(编译隔离)
│ A 的 Runtime Classpath: B + C │ ← A 运行时仍然能用 C(类在 APK 里)
└──────────────────────────────────────┘
为什么 implementation 能加速编译? 当模块 C 的代码发生变更时:
- 如果 B 使用
api(C):Gradle 认为 A 可能引用了 C 的类 → A 需要重新编译 - 如果 B 使用
implementation(C):Gradle 知道 A 看不到 C → A 无需重新编译(除非 B 自身的公共 API 也变了)
这就是为什么组件化工程中的黄金规则是:默认使用 implementation,只有当你的 Public API 中直接暴露了依赖的类型时,才使用 api。
// 什么时候必须用 api?
// 当你的公共方法的参数或返回值使用了依赖的类型
// UserRepository 的公共方法返回了 Flow(来自 kotlinx-coroutines-core)
// 消费 UserRepository 的模块也需要在编译时看到 Flow 的类型定义
class UserRepository {
fun getUsers(): Flow<List<User>> = flow { /* ... */ }
// ↑ Flow 来自 kotlinx-coroutines-core
// → 必须用 api("org.jetbrains.kotlinx:kotlinx-coroutines-core:...")
}
Convention Plugins:大型多模块工程的构建一致性
当模块数量膨胀到 30、50 甚至上百个时,每个模块都手动配置 compileSdk、minSdk、Kotlin 编译选项、Compose 编译器参数——这本身就成了一个维护噩梦。
Convention Plugins(约定插件)是解决这个问题的工业级方案。它的思想很直接:把重复的构建配置抽取成可复用的 Gradle 插件。
项目根目录/
├── build-logic/ # 约定插件模块
│ ├── convention/
│ │ └── src/main/kotlin/
│ │ ├── AndroidLibraryConventionPlugin.kt
│ │ ├── AndroidApplicationConventionPlugin.kt
│ │ └── AndroidComposeConventionPlugin.kt
│ └── build.gradle.kts
├── gradle/
│ └── libs.versions.toml # 统一版本目录
├── feature/
│ ├── login/build.gradle.kts # 极简配置
│ └── order/build.gradle.kts
└── settings.gradle.kts
一个 Convention Plugin 的典型实现:
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// 统一应用插件
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
// 统一配置 Android 构建参数
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig {
minSdk = 24
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
// 统一配置 Kotlin 编译选项
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
// 将所有警告视为错误——强制代码质量
allWarningsAsErrors.set(true)
}
}
}
}
}
使用了 Convention Plugin 之后,业务模块的 build.gradle.kts 变得极其简洁:
// :feature:login/build.gradle.kts
plugins {
id("example.android.library") // 应用约定插件
id("example.android.compose") // 可叠加多个约定
}
android {
namespace = "com.example.feature.login"
}
dependencies {
implementation(project(":core:designsystem"))
implementation(libs.hilt.android) // 来自 Version Catalog
ksp(libs.hilt.compiler)
}
配合 Version Catalog(libs.versions.toml),所有模块的依赖版本统一管理,彻底消灭了「模块 A 用 Retrofit 2.9、模块 B 用 Retrofit 2.7」的版本地狱。
组件化落地的渐进式路线图
组件化不是一蹴而就的大工程。它应该像「城市规划」一样,随着城市(项目)的增长逐步推进:
第一阶段:基础分层(团队 ≤ 5 人)
├── :app
├── :core:network
├── :core:common
└── :core:designsystem
→ 目标:抽取通用能力,建立基础层
第二阶段:业务拆分(团队 5~15 人)
├── :app
├── :feature:login
├── :feature:home
├── :feature:order
├── :core:*
└── 引入 ARouter / Navigation 路由
→ 目标:业务组件独立,禁止交叉依赖
第三阶段:契约治理(团队 15+ 人)
├── :app
├── :feature:login + :feature:login-api
├── :feature:order + :feature:order-api
├── :core:*
├── build-logic/(Convention Plugins)
└── gradle/libs.versions.toml(Version Catalog)
→ 目标:Module-API 防腐层建立,构建配置统一
第四阶段:基建成熟(团队 30+ 人)
├── CI 并行编译各模块
├── 远程构建缓存(Gradle Remote Cache)
├── 自动化的模块依赖图检查
└── 可选:部分模块独立仓库(mono-repo → multi-repo)
→ 目标:构建性能极致优化,开发体验对齐小项目
每个阶段的推进都应该有明确的收益目标:如果当前阶段的编译速度、协作效率、代码质量已经满足需求,就不需要过早地迈入下一阶段。过度组件化带来的模块管理开销,可能比它解决的问题还多。
组件化架构的本质是一套分治策略——通过物理隔离(Gradle 模块边界)和契约约束(Module-API + 依赖倒置)将大泥球分解为可管理的小单元。其中,Gradle 的 application/library 机制提供了编译级的隔离保障,api/implementation 提供了可见性控制,Convention Plugins 和 Version Catalog 提供了大规模模块的配置一致性。
后续两篇文章将分别深入展开两个关键主题:ARouter 路由框架如何利用 APT 在编译期织入路由映射并在运行时分发路由请求(《ARouter 路由框架的底层原理》),以及多模块工程中的 Gradle 依赖治理策略——包括 api/implementation 的传递机制、Version Catalogs 的工作原理、以及循环依赖的根治方案(《组件化 Gradle 依赖治理》)。