Clean Architecture 被捧上神坛五六年了。但我最近 review 了几个"按规范来的"项目,发现一个反直觉的规律:UseCase 文件越多,项目往往越难维护。不是架构本身的问题,是大家写歪了。这篇文章不讲概念,直接说怎么判断你的分层有没有写对。
分层不是目的,边界清晰才是
一、一个让很多人不舒服的结论
我在代码 review 里遇到过一种特别的"架构洁癖":ViewModel 调 Repository 太直接,感觉没有层次感,于是在中间加一个 UseCase。Repository 里调 API 太直接,再加一个 DataSource。最后项目有了五层,每层一个类,95% 的类都只有一行:调下一层。
这不是分层,这是俄罗斯套娃。
class GetUserUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(uid: String): User {
return repository.getUser(uid) // 转发一下,收工
}
}
class GetPostListUseCase @Inject constructor(
private val repository: PostRepository
) {
suspend operator fun invoke(uid: String): List
{
return repository.getPostList(uid) // 再转发一下,又收工
}
}
这不是 Clean Architecture,这是 Clean Architecture 的皮。100 个 UseCase 文件,100 个转发,没有一行业务逻辑——层数增加了,复杂度也增加了,好处是零。
所以问题不是"要不要 UseCase",而是**"什么情况下 UseCase 值得存在"**。
二、UseCase 存在的充分条件
给你一个判断标准,很简单:把这个 UseCase 删掉,ViewModel 直接调 Repository,代码会变差吗?
如果不会——UseCase 就不该存在。
UseCase 有价值的场景,通常是这三种:
① 多数据源聚合
这是最典型的情况。一个页面需要的数据来自三个不同的接口,单独放在 ViewModel 里会让 ViewModel 同时依赖三个 Repository,测试要 mock 三个对象。抽成 UseCase 后,ViewModel 只依赖一个,测试复杂度直降。
// ViewModel 直接调 3 个 Repository → 职责越权,测试痛苦
// ↓ 换成这个
class GetUserProfileUseCase @Inject constructor(
private val userRepo: UserRepository,
private val postRepo: PostRepository,
private val followRepo: FollowRepository
) {
operator fun invoke(uid: String): Flow = combine(
userRepo.getUserFlow(uid),
postRepo.getPostCountFlow(uid),
followRepo.getFollowStatsFlow(uid)
) { user, postCount, followStats ->
UserProfileData(
user = user,
postCount = postCount,
followerCount = followStats.followerCount,
followingCount = followStats.followingCount,
isFollowedByMe = followStats.isFollowedByMe
)
}
}
这段 combine 逻辑放在 ViewModel 里不是"不可以",而是"ViewModel 不该知道这些数据来自哪里"。数据聚合是业务决策,属于 Domain 层。
② 业务规则校验
发帖有字数限制、频率控制、敏感词过滤。这些规则放在哪?
放 ViewModel?ViewModel 会变成第二个业务入口,换个入口(比如 Widget 或者 Background Worker 发帖)就要复制一遍规则。
放 Repository?Repository 管数据,不管"用户能不能发帖"这种业务问题,越权了。
只有 UseCase 是合适的容器:
class PublishPostUseCase @Inject constructor(
private val postRepository: PostRepository,
private val userRepository: UserRepository,
private val contentFilter: ContentFilter
) {
sealed class Result {
data class Success(val postId: String) : Result()
data class ContentViolation(val reason: String) : Result()
object RateLimitExceeded : Result()
data class Failure(val cause: Throwable) : Result()
}
suspend operator fun invoke(content: String, uid: String): Result {
if (content.length > 2000) return Result.ContentViolation("不能超过2000字")
val violation = contentFilter.check(content)
if (violation != null) return Result.ContentViolation("含违规内容:${violation.keyword}")
val lastPost = userRepository.getLastPostTime(uid)
if (System.currentTimeMillis() - lastPost ()
private var lastPostTime = 0L
fun seedUser(user: User) { users[user.id] = user }
fun setLastPostTime(time: Long) { lastPostTime = time }
override suspend fun getUser(uid: String): User =
users[uid] ?: throw NoSuchElementException("No user $uid")
override fun getUserFlow(uid: String): Flow =
flowOf(users[uid] ?: throw NoSuchElementException())
override suspend fun getLastPostTime(uid: String): Long = lastPostTime
}
Fake 比 Mock 好用得多。Mock 是运行时动态代理,靠字符串绑定,重构时不会报编译错误;Fake 是真实 Kotlin 实现,重命名方法时编译器帮你检查。而且 Fake 可以有状态——你可以提前 seedUser() 塞进去测试数据,比 Mock 的 whenever(...).thenReturn(...) 直观多了。
这就是 Repository 接口真正的价值:让测试里的依赖替换变成编译期安全的操作,而不是运行时魔法。
Repository 真正应该做什么
很多项目的 Repository 是这样的:
class UserRepositoryImpl : UserRepository {
suspend fun getUser(uid: String): User {
return apiService.getUser(uid).toUser() // 直接调网络,没有缓存逻辑
}
}
Repository 的核心价值其实是:给上层一个稳定的数据契约,屏蔽"数据从哪来"的细节。包括:
-
先返回本地缓存(快速响应),再刷新网络数据(最终一致)
-
网络失败时有缓存则静默降级,无缓存才抛错
-
写操作的乐观更新(先更新本地,再同步网络)
override fun getUserFlow(uid: String): Flow = flow {
// 第一步:先发出本地缓存(毫秒级响应,用户不感知加载)
val cached = localDataSource.getUser(uid)
if (cached != null) emit(cached)
// 第二步:后台拉网络,更新本地,发出最新数据
try {
val remote = remoteDataSource.getUser(uid)
localDataSource.saveUser(remote)
emit(remote)
} catch (e: Exception) {
// 有缓存就吞掉网络错误,无缓存才往上抛
if (cached == null) throw e
}
}
这个"先缓存后网络"的模式,是 Repository 最应该做的事,也是它名字里"仓库"两字的本意:你问仓库要东西,仓库决定从哪里拿,你不用操心。
四、Compose 时代的 Clean Architecture:有什么变了,有什么没变
Compose 现在已经是 Android 新项目的默认选择了。那问题来了:分层架构在 Compose 时代还适用吗?
答案是:架构不变,但 ViewModel 和 UI 之间的契约变了。
View 时代,ViewModel 暴露 LiveData,Fragment 里 observe。Compose 时代,ViewModel 暴露 StateFlow,Composable 里 collectAsStateWithLifecycle()。本质没变,只是工具换了。
真正有意思的变化是 MVI 在 Compose 里变得更自然了。Compose 本来就是声明式的——你描述"当前状态下 UI 长什么样",框架帮你渲染。这和 MVI 的单向数据流(State → UI → Intent → State)简直天生一对。
// Compose + MVI:Composable 只消费 State,通过 lambda 发送 Intent
@Composable
fun UserProfileScreen(
viewModel: UserProfileViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 一次性事件(Toast、导航)
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UserProfileEvent.ShowToast -> { /* 显示 Toast */ }
is UserProfileEvent.NavigateBack -> { /* 导航 */ }
}
}
}
UserProfileContent(
state = uiState,
onFollowClick = viewModel::onFollowClick, // Intent
onRefresh = viewModel::onRefresh
)
}
// UI 和状态完全解耦:UserProfileContent 只认 uiState,不认 ViewModel
@Composable
fun UserProfileContent(
state: UserProfileUiState,
onFollowClick: () -> Unit,
onRefresh: () -> Unit
) {
// 纯 UI 渲染,可以 Preview,可以单独测试
}
注意这里把 UserProfileContent 和 UserProfileScreen 拆开了。前者是纯 UI,后者是"和 ViewModel 的胶水"。这个拆法让 Composable 可以脱离 ViewModel 做 Preview 和单独测试——这是 Compose 时代非常值得养成的习惯。
五、2026年的新变量:KMP 让分层的价值翻倍
Kotlin Multiplatform(KMP)在 2025 年底正式宣布稳定,2026 年已经有不少团队开始真正落地。如果你的项目有 iOS 端,或者将来可能有,Domain 层是你最值钱的资产。
理由很简单:如果 UseCase 和 Domain 模型里没有任何 import android.*,这些代码可以原封不动地跑在 iOS 上。反过来,如果 UseCase 里有 Context 或 LiveData,KMP 共享就没戏了,到时候要么重构 Domain 层,要么两端各写一遍业务逻辑。
💡 💡 一个简单的自查方法:搜
domain模块的 build.gradle,看 dependencies 里有没有androidx.*或android.*。有就清理掉。
不是说你现在就要做 KMP——而是说,保持 Domain 层干净这件事,成本接近零,但收益潜力很大。好习惯养成了,以后真的要跨平台时不用大改。
如果真的需要 Context(比如读取系统语言),正确的做法是在 Domain 层定义一个接口,在 Platform 层(Android 模块)实现它:
// domain 模块:定义接口,不知道 Android 的存在
interface LocaleProvider {
fun getLanguageCode(): String
}
// android app 模块:提供 Android 实现
class AndroidLocaleProvider @Inject constructor(
@ApplicationContext private val context: Context
) : LocaleProvider {
override fun getLanguageCode(): String {
return context.resources.configuration.locales[0].language
}
}
// UseCase 依赖接口,可以跑在任何平台上
class GetLocaleUseCase @Inject constructor(
private val localeProvider: LocaleProvider
) {
operator fun invoke(): String = localeProvider.getLanguageCode()
}
五、ViewModel 有多薄,架构就有多健康
有个粗糙但实用的判断标准:ViewModel 的行数,决定了你的架构有多混乱。
当 UseCase 和 Repository 各司其职,ViewModel 应该只做四件事:持有 UiState、响应 UI 事件、调用 UseCase、把结果映射到 UiState。
@HiltViewModel
class UserProfileViewModel @Inject constructor(
private val getUserProfileUseCase: GetUserProfileUseCase,
private val followUserUseCase: FollowUserUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val uid = checkNotNull(savedStateHandle.get("uid"))
private val _uiState = MutableStateFlow(UserProfileUiState())
val uiState: StateFlow = _uiState.asStateFlow()
// 一次性事件(页面跳转、Toast)用 Channel,不用 StateFlow
private val _events = Channel(Channel.BUFFERED)
val events = _events.receiveAsFlow()
init {
viewModelScope.launch {
getUserProfileUseCase(uid)
.catch { _uiState.update { it.copy(error = "加载失败,下拉重试") } }
.collect { profile ->
_uiState.update { it.copy(profile = profile, isLoading = false) }
}
}
}
fun onFollowClick() {
viewModelScope.launch {
when (val result = followUserUseCase(uid)) {
is FollowResult.Success ->
_events.send(UserProfileEvent.ShowToast("已关注"))
is FollowResult.AlreadyFollowed ->
_events.send(UserProfileEvent.ShowToast("已经关注过了"))
is FollowResult.Failure ->
_uiState.update { it.copy(error = "操作失败,请重试") }
}
}
}
}
注意这个 ViewModel 里没有任何业务逻辑——没有 if 判断什么情况下可以关注,没有计算什么,没有组合数据。它是纯粹的适配层,薄得透明。
如果你的 ViewModel 超过 200 行,大概率是某一层的职责溢出了。
六、分层测试:真正值得写的测试长什么样
最后说测试。Clean Architecture 最实际的好处不是"代码好看",而是每一层都可以用最低成本的方式测试。
UseCase 测试最纯粹:无需任何 Android 测试框架,纯 JVM,跑得飞快:
class PublishPostUseCaseTest {
private val fakePostRepo = FakePostRepository()
private val fakeUserRepo = FakeUserRepository()
private val contentFilter = ContentFilter() // 真实实现,不需要 mock
private val useCase = PublishPostUseCase(fakePostRepo, fakeUserRepo, contentFilter)
@Test
fun `超过2000字时直接返回ContentViolation,不调用网络`() = runTest {
val result = useCase("a".repeat(2001), "uid_123")
assertIs
(result)
assertEquals(0, fakePostRepo.publishCallCount) // 确认没有走到发布
}
@Test
fun `60秒内重复发布返回频率限制`() = runTest {
fakeUserRepo.setLastPostTime(System.currentTimeMillis() - 30_000) // 30秒前刚发过
val result = useCase("正常内容", "uid_123")
assertIs(result)
}
@Test
fun `正常内容发布成功,返回postId`() = runTest {
fakeUserRepo.setLastPostTime(0L) // 很久没发过
fakePostRepo.setNextPostId("post_abc")
val result = useCase("这是正常内容", "uid_123")
assertIs(result)
assertEquals("post_abc", result.postId)
}
}
这些测试用例直接对应产品需求,不是在测实现细节。测试挂了,你就知道是哪条业务规则坏了,不需要 debug。
七、总结:几条可以直接用的判断标准
不喜欢理论,就记这几条:
-
UseCase 里有没有真实的 if 判断或数据聚合?没有就删掉
-
Domain 模块的 build.gradle 里有没有
androidx.*?有就清理掉 -
Repository 的方法返回的是"最好的可用数据",还是只是调网络?前者对,后者重写
-
ViewModel 超过 200 行?找找哪层的职责溢出来了
-
测试 UseCase 需要
@RunWith(AndroidJUnit4::class)?说明 Domain 层被 Android 污染了
架构决策的终极评判标准,不是"符不符合某个模式",而是"下一个人接手时,能不能在 10 分钟内搞清楚一个功能的完整调用链"。如果能,架构就是好的;如果不能,再多的分层都是负担。
下一步值得探索的方向:如果项目走向多模块(Feature Module),Domain 层的角色会变得更有趣——公共 Domain Module 承载跨模块的业务规则,各 Feature Module 各自维护自己的 UseCase。这是目前大型 Android 项目演进的主流方向,也是 KMP 落地的自然结构,有机会单独展开。
— END — 觉得有用的话,点个在看 👇