很多开发者一提到 Clean Architecture,第一反应就是:
"那得拆多少个 Module?"
于是,一个 2~3 人的小团队,为了所谓的"架构整洁",硬生生把项目拆成 5、6 个甚至更多 Module。
最后发现:
改一个功能,要跨好几个 Module;
Gradle 配置越来越复杂;
Debug 链路越来越长;
真正写业务的时间,反而越来越少。
Clean Architecture 真正解决的是依赖和职责问题,而不是 Module 数量问题。
所以我的观点非常明确:
对于 2~3 人的 Android 团队,不拆 Module,在一个 Module 内通过清晰的 Package 边界落地 Clean Architecture,往往才是性价比最高的方案。
本文就以我的实战项目 CleanArc 为例,聊聊这套方案到底应该怎么落地。
一、先说结论:Clean Architecture ≠ 多 Module
这是很多人最容易产生的误解。
Clean Architecture 的核心,是:
控制依赖方向,让业务规则不要被具体实现绑架。
它关注的是:
- 谁依赖谁?
- 业务逻辑放在哪里?
- 数据访问放在哪里?
- UI 能不能直接依赖数据实现?
- 数据源发生变化时,业务逻辑需不需要跟着修改?
而 Module 只是实现隔离的一种工程手段。
所以:
text
Clean Architecture
≠
Multi Module
完全可以:
text
Single Module
+
Package Boundary
+
Clean Architecture
同样可以做到职责清晰、依赖清晰。
二、为什么 2~3 人的小团队不建议一开始就拆 Module?
Module 主要解决的是:
- 编译隔离
- 团队协作边界
- 依赖隔离
- 大型工程治理
但对于一个只有 2~3 个人的 Android 团队来说,很多时候这些问题根本没有严重到需要 Module 来解决。
1. 人少,协作冲突本来就少
如果只有两三个人:
text
A → 登录
B → 首页
C → 个人中心
大家对整个项目都比较熟悉。
这时候为了所谓的"团队边界"拆 Module,收益并不大。
2. 项目规模不大,编译隔离收益有限
Module 最明显的收益之一就是:
修改一个 Module,不需要重新编译整个工程。
但如果项目本身并不大,这个收益可能没有想象中那么明显。
反而可能增加:
text
Gradle 配置
依赖关系
版本管理
Module 间跳转
Debug 成本
3. 业务本身就比较集中
小团队项目通常业务边界并没有复杂到:
text
feature-a
feature-b
feature-c
feature-d
core-network
core-database
core-common
common-ui
一开始就建立这么复杂的结构。
你甚至可能还没搞清楚业务边界,就已经把工程边界建立起来了。
这就容易出现一个问题:
为了未来可能发生的问题,提前把复杂度搞起来了。
三、真正推荐的方案:Feature + Package
既然不拆 Module,那怎么保证代码不会乱?
答案是:
用 Feature 做业务聚合,用 Package 做架构隔离。
例如实战项目可以按照这样的思路组织:
text
app
└── src/main/java/com/sample/clean
│
├── core
│ ├── network
│ ├── db
│ └── common
│
└── feature
│
├── login
│ ├── presentation
│ │ ├── LoginViewModel.kt
│ │ └── LoginUiState.kt
│ │
│ ├── domain
│ │ ├── LoginUseCase.kt
│ │ ├── LoginRepository.kt
│ │ └── model/
│ │
│ └── data
│ ├── LoginRepositoryImpl.kt
│ ├── datasource/
│ └── model/
│
└── user
├── presentation
├── domain
└── data
核心思想只有一句话:
按 Feature 聚合,Feature 内部再进行 Clean 分层。
也就是:
text
feature
↓
presentation
domain
data
而不是一上来把整个项目拆成:
text
presentation module
domain module
data module
四、为什么我更推荐 Feature 内部 Clean?
因为它解决了一个非常现实的问题:
修改一个业务功能时,相关代码应该尽可能聚集在一起。
例如修改登录:
text
feature/login
里面同时可以找到:
text
LoginScreen
LoginViewModel
LoginUiState
LoginUseCase
LoginRepository
User
LoginRepositoryImpl
LoginApi
你不需要:
text
presentation module
↓
domain module
↓
data module
↓
network module
来回跳。
这对于小团队非常重要。
因为小团队真正需要优化的不是:
"架构图看起来有多漂亮。"
而是:
"一个人接手代码之后,能不能快速找到它。"
五、Clean Architecture 真正重要的是依赖方向
目录结构只是形式。
真正重要的是:
依赖方向。
我们希望:
text
Presentation
↓
Domain
↑
Data
也可以理解为:
text
Presentation → Domain ← Data
这里有一个非常重要的点:
Domain 不应该依赖 Data。
因为 Data 是具体实现。
比如:
text
Retrofit
Room
DataStore
OkHttp
这些都属于技术细节。
而 Domain 应该只关心:
text
我要什么数据
我要执行什么业务
至于:
数据到底从 Retrofit 来,还是 Room 来?
Domain 不应该关心。
六、Repository 为什么要放在 Domain?
这是 Clean Architecture 中非常关键的一点。
例如我们定义:
kotlin
interface UserRepository {
suspend fun getUsers(): Result<List<User>>
}
这个接口应该放在:
text
domain/repo/UserRepository.kt
而不是:
text
data/repo/UserRepository.kt
为什么?
因为:
Repository 接口描述的是 Domain 对数据能力的需求。
Domain 是"老板"。
它说:
"我需要一个能获取 User 的能力。"
于是定义:
kotlin
interface UserRepository {
suspend fun getUsers(): Result<List<User>>
}
Data 是"打工人"。
它负责告诉 Domain:
"这个能力我可以通过 Retrofit 实现。"
于是:
kotlin
class UserRepositoryImpl(
private val apiService: ApiService
) : UserRepository {
override suspend fun getUsers(): Result<List<User>> {
return try {
val userList = apiService.getUsers()
Result.success(
userList.toDomainList()
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
}
关系就变成:
text
UserRepository
/ \
Domain Data
接口 实现
也就是经典的:
依赖倒置原则(DIP)
七、Repository 放错位置,会发生什么?
错误方式:
text
data
└── UserRepository.kt
然后:
text
domain
↓
data.UserRepository
这就意味着:
text
Domain → Data
Domain 开始依赖具体的数据层。
整个依赖方向就反过来了。
正确方式:
text
domain
└── UserRepository.kt
data
└── UserRepositoryImpl.kt
形成:
text
Domain
↑
Data
Data 依赖 Domain 的接口。
这才是真正的依赖倒置。
八、完整看一次登录流程
假设我们有一个登录功能。
最终代码结构:
text
feature/login
│
├── presentation
│ ├── LoginScreen.kt
│ ├── LoginViewModel.kt
│ └── LoginUiState.kt
│
├── domain
│ ├── LoginUseCase.kt
│ ├── LoginRepository.kt
│ └── model/
│
└── data
├── LoginRepositoryImpl.kt
├── LoginApi.kt
└── model/
调用链:
text
LoginScreen
↓
LoginViewModel
↓
LoginUseCase
↓
LoginRepository
↓
LoginRepositoryImpl
↓
LoginApi
↓
Retrofit
注意这里最关键的一点:
text
LoginUseCase
↓
LoginRepository
UseCase 只认识接口。
它根本不知道:
text
Retrofit
OkHttp
Room
DataStore
这些东西的存在。
九、Presentation:ViewModel 不需要知道 Data
例如:
kotlin
class LoginViewModel(
private val loginUseCase: LoginUseCase
) : ViewModel() {
fun login(username: String, password: String) {
viewModelScope.launch {
loginUseCase(username, password)
}
}
}
ViewModel 只依赖:
text
LoginUseCase
而不是:
text
LoginApi
LoginRepositoryImpl
Retrofit
Room
这样 UI 层就不会和具体技术实现产生强耦合。
十、Domain:UseCase 到底应该干什么?
UseCase 最容易被滥用。
很多项目最后会变成:
kotlin
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke() =
repository.getUser()
}
然后每一个 Repository 方法都配一个 UseCase:
text
GetUserUseCase
DeleteUserUseCase
UpdateUserUseCase
SaveUserUseCase
QueryUserUseCase
如果 UseCase 只是简单透传:
text
ViewModel
↓
UseCase
↓
Repository
中间没有任何业务价值。
那它很可能只是增加了一层代码。
UseCase 真正适合做什么?
当存在业务流程时,它的价值就非常明显。
例如:
kotlin
class LoginUseCase(
private val repository: LoginRepository
) {
suspend operator fun invoke(
username: String,
password: String
) {
require(username.isNotEmpty())
val user = repository.login(
username,
password
)
repository.saveUser(user)
}
}
这里就不是简单透传了。
它包含:
text
参数校验
↓
登录
↓
保存用户
这就是业务流程。
所以我更推荐:
UseCase 承载业务流程,而不是为了让架构看起来完整。
十一、Data 层也不要过度设计
Clean Architecture 很容易走向另一个极端:
text
RemoteDataSource
LocalDataSource
CacheDataSource
RemoteDataSourceImpl
LocalDataSourceImpl
CacheStrategy
Repository
RepositoryImpl
Factory
Provider
一个简单的接口调用,最后变成:
text
ViewModel
↓
UseCase
↓
Repository
↓
DataSource
↓
RemoteDataSource
↓
Api
↓
Retrofit
这已经不是 Clean Architecture 了。
这是:
Clean Architecture 形式主义。
如果业务真的复杂:
text
网络 + 本地缓存 + 多数据源 + 离线策略
当然应该抽象。
但如果只是:
text
Repository → Retrofit
那就直接:
kotlin
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository
足够了。
没有复杂度,就不要提前抽象。
十二、依赖注入:小团队为什么可以选择 Koin?
对于 2~3 人的小团队,我比较倾向于:
优先选择简单的 DI 方案。
例如 Koin。
原因不是:
Koin 一定比 Hilt 好。
而是:
小团队更应该关注业务复杂度,而不是 DI 框架本身的工程复杂度。
一个简单的模块定义就可以完成:
kotlin
val appModule = module {
single<UserApi> {
RetrofitClient.create()
}
single<UserRepository> {
UserRepositoryImpl(get())
}
factory {
LoginUseCase(get())
}
}
整个依赖关系非常直观:
text
LoginUseCase
↓
UserRepository
↓
UserRepositoryImpl
↓
UserApi
对于小型项目来说,这已经足够。
十三、为什么这种结构以后还能平滑拆 Module?
这是这套方案非常重要的一个优势。
现在:
text
app
└── feature
└── login
├── presentation
├── domain
└── data
以后项目真的变大了:
text
feature/login
完全可以逐渐演变成:
text
:feature:login
甚至:
text
:feature:login:presentation
:feature:login:domain
:feature:login:data
也就是说:
Package 是未来 Module 的候选边界。
你现在不是在拒绝 Module。
而是:
在真正需要 Module 之前,不提前支付 Module 的成本。
十四、这套架构真正解决的是什么?
最终我们得到:
text
┌──────────────┐
│ Presentation │
└──────┬───────┘
↓
┌──────────────┐
│ Domain │
│ │
│ UseCase │
│ Repository │
│ Model │
└──────┬───────┘
↑
┌──────┴───────┐
│ Data │
│ │
│ RepositoryImpl
│ API / DB │
└──────────────┘
它真正建立的是:
text
职责边界
+
依赖边界
+
业务边界
而不是:
text
Module 越多越高级
十五、最终推荐的项目结构
如果让我给一个 2~3 人 Android 团队设计一个可以直接落地的结构,我会选择:
text
app
└── src/main/java/xxx
│
├── core
│ ├── network
│ ├── database
│ └── common
│
└── feature
│
├── login
│ ├── presentation
│ ├── domain
│ └── data
│
├── home
│ ├── presentation
│ ├── domain
│ └── data
│
└── profile
├── presentation
├── domain
└── data
依赖关系:
text
Presentation
↓
Domain
↑
Data
其中:
text
Repository Interface → Domain
Repository Impl → Data
UseCase → Domain
ViewModel → Presentation
Retrofit / Room → Data
这套结构已经足够支撑绝大多数中小型 Android 项目。
实战代码
参考我的 Clean Architecture 项目: