小团队的最优解:单 Module + 包内 Clean 架构

很多开发者一提到 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 项目:

ThirdPrince/CleanArc

相关推荐
爱和冰阔落1 小时前
【Linux】两个毫无关系的进程怎么通信?命名管道 FIFO 从原理到 Server/Client 实战
android·linux·数据库·c++·vim
年小个大11 小时前
受 go-zero 启发,我给 Flutter 写了一套 CLI 脚手架
android·flutter·架构
Android-Flutter12 小时前
为什么说ActivityThread是主线程?
android·kotlin
余额瞒着我当琳14 小时前
C++模板初阶与STL初探
android·java·c++
知行合一。。。17 小时前
DeepAgents--01--Agent和OpenClaw的相关概念
android·数据库
汪海游龙18 小时前
告别 Play Console 手动上传:从模拟器截图到自动发版的完整流水线
android·ci/cd·github
weixin_4407841118 小时前
【IntentSeivice实现原理】
android·java·开发语言·intentservice
delta_hell18 小时前
【阅读源码--Android】动画之ValueAnimator--2
android·源码·valueanimator
delta_hell18 小时前
【阅读源码--Android】动画之辅助类
android·源码·animator