我见过的最反直觉的 Android 架构问题:UseCase 越多,项目越烂

19 阅读9分钟

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,可以单独测试
}

注意这里把 UserProfileContentUserProfileScreen 拆开了。前者是纯 UI,后者是"和 ViewModel 的胶水"。这个拆法让 Composable 可以脱离 ViewModel 做 Preview 和单独测试——这是 Compose 时代非常值得养成的习惯。

五、2026年的新变量:KMP 让分层的价值翻倍

Kotlin Multiplatform(KMP)在 2025 年底正式宣布稳定,2026 年已经有不少团队开始真正落地。如果你的项目有 iOS 端,或者将来可能有,Domain 层是你最值钱的资产。

理由很简单:如果 UseCase 和 Domain 模型里没有任何 import android.*,这些代码可以原封不动地跑在 iOS 上。反过来,如果 UseCase 里有 ContextLiveData,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 — 觉得有用的话,点个在看 👇