在 Android 项目里待得越久,我对“分层架构”这句话的警惕心就越重。不是因为它不对,而是我见过太多团队把架构做成了“照章办事”,最后被自己的严谨拖垮。最典型的,就是 Clean Architecture 里的 UseCase(也叫 Interactor)。这个原本用来承载独立业务过程的抽象,一旦被当成“每有一个按钮就写一个类”的模板,项目就会迅速从一个容易理解的状态,滑向一个看起来规范、实则谁也改不动的泥潭。我前两年接手过一个电商项目的购物车模块,代码里密密麻麻躺了几十个 UseCase 文件。刚打开时我还挺感动,觉得这个团队很专业。可当我真的开始改需求,才发现这些 UseCase 大部分只是把 ViewModel 的调用转发给 Repository,中间没有业务校验,没有状态聚合,也没有任何领域逻辑。改一个“删除购物车项但不弹提示”的小需求,我跨了 5 个类才找回那行 if。事后我在群里感慨:“UseCase 越多,项目越烂。”有人以为我是开玩笑,但这是我看过太多项目之后,一句很真实的总结。
今天我不讲那种“一定要用/一定不能用”的绝对结论,而是把“为什么会产生这个反直觉现象”拆开讲:为什么很多团队会把 UseCase 当成银弹,它又是如何在不知不觉中让项目变烂的。
1. 先从现象说起:那些我经历过的 UseCase 灾难现场
在讨论理论之前,我先描述三个反复出现的现场。这些现象如果你经历过任何一个,就说明你的项目已经踩进了同一个坑。
1.1 一个目录里躺了几十个“只有方法名不同”的 UseCase
最典型的情况,是用户资料页有 10 个字段,于是代码里出现 10 个“保存字段”的 UseCase:
class SaveUserNameUseCase(private val userRepository: UserRepository) { suspend operator fun invoke(name: String) { userRepository.updateName(name) } } class SaveUserAvatarUseCase(private val userRepository: UserRepository) { suspend operator fun invoke(avatarUrl: String) { userRepository.updateAvatar(avatarUrl) } }看到问题了吗?这两个 UseCase 内部没有做任何状态判断、错误处理、事件聚合,只是把参数转交给 Repository。它们的存在没有让代码更清晰,反而让“谁在什么条件下能改名字”这个业务规则,散落到页面控制器和多个调用方手里。我记得自己第一次审这种代码时还不太敢反对,因为“分层整洁”四个字太有说服力了。后来翻 Commit 历史才发现,前一位同事这样做的原因是架构规范里写了“业务逻辑必须封装在 UseCase 里”。可是当 UseCase 本身没有业务逻辑时,这就从封装变成了包装,而包装唯一带来的东西,是每个 ViewModel 构造器里多出几十个依赖。
1.2 ViewModel 与 UseCase 之间的链路:代码量没少,理解成本翻三倍
过度使用 UseCase 的项目里,你很常见这种调用链:
ViewModel -> LoadCartBadgeUseCase -> CartRepository -> CartLocalDataSource
然后你发现 LoadCartBadgeUseCase 和 CartRepository 的方法签名一模一样,连返回类型都是Int。为了展示购物车角标,系统创建了一个类,这个类没有计算角标数字,没有判断商品条数上限,也没有任何业务规则,它只是把“得到已选商品数量”这个调用从 Repository 转发给 ViewModel。
有一次我让实习生统计项目里某个列表页的完整数据流,他的结论是:5 个界面操作,对应 11 个 UseCase,但真正有逻辑差异的只有 3 个。其余 8 个就是从 ViewModel 到 Repository 的一层透明的“复制粘贴”。这层复制粘贴不仅有文件数量成本,还有阅读和跳转成本:你去看 ViewModel,发现它只是调 UseCase;你点进 UseCase,发现它只是调 Repository;你点进 Repository,发现它只是调 DataSource。每一步都很“干净”,但每多一步,你用来理解业务的时间就多一些。这种项目表面上是分层清晰,实际上是给最基础的调用套了无数层读者需要翻开的壳。
1.3 当“UseCase 数量”被当成架构 KPI,故事就开始变味
还见过一个让我哭笑不得的情况:团队周会上,有人把“这个迭代我们新增了 7 个 UseCase”当作成果来汇报。听上去像是一份严谨的架构设计,但仔细看代码,其中有 4 个是单方法透传类,2 个是重复判断业务状态的旧 UseCase 的变体,只有 1 个真正承载了新的业务决策。
当架构讨论变成数量指标时,大家的设计冲动就会被扭曲。新人为了不被说“没有架构思维”,看见一个按钮就创建 UseCase;老手为了不承担“破坏分层”的指责,也不敢把那些空的 UseCase 合并。半年下来,类文件数量稳步上涨,可大家改需求的效率反而一路下降。到了后来,大家甚至默认“模块成熟度 = UseCase 数量”,谁要减少这些类,反而会被质疑是不是在走捷径。
问题就出在这里:UseCase 存在的目的,从来不是证明“我遵守了架构”,而是把容易变的业务决策放到一个不容易被忽略的地方。数量上去了,这个目的反而丢了。
2. “UseCase 越多越烂”为什么成立:三大根因
很多人刚听到这个结论会反问:UseCase 是 Clean Architecture 的标准组件,为什么反而会让项目变烂?这个问题问得很好。原因不是 UseCase 这个模式本身有问题,而是它在 Android 工程里被大量滥用时,通常会触发三种连锁反应。
2.1 第一个根因:UseCase 被窄化成“方法包装类”,业务规则根本没有入场
Clean Architecture 的作者在讨论用例时,强调的是“系统如何对外部输入做出响应”。换句话说,UseCase 应该是一个完整的业务过程,比如“提交订单”,它可能要校验库存、计算折扣、生成订单号、调用库存接口、创建支付单。整个过程里全是决策和状态变化,绝不是“把参数丢给仓库”这种机械动作。
但很多 Android 项目落地时,把 UseCase 理解成了“每个仓库方法的门面”。于是 UseCase 变成了方法包装类:类名等于 Repository 方法名拼接 UseCase,方法体只有一行 return,没有任何 if/else,也不抛任何特定异常。这种包装类有三个即时危害:
第一,它让领域规则没地方放。你需要判断“购物车已满”时怎么办,结果找一个遍都找不到,因为规则要么写在 ViewModel 里,要么写在 DataSource 的某个注释里。第二,它让依赖方向变成装饰。你打开类图,所有箭头都指向 Repository,看起来很规范,但真正要改的业务点全在类图之外。第三,它给后续重构造成了心理负担。因为大家都在“按标准做”,谁也不敢删,架构像滚雪球一样越滚越大。
用一个生活类比来说,这就像每个房间门口都装了三层中转站。你把钥匙从门缝递出去,它先被送到前台,再被送到保安亭,最后才到快递员手里。看起来每一层都有存在的意义,可钥匙本身根本没有被处理过。当你有一天要找钥匙时,却要跑遍整栋楼。
2.2 第二个根因:业务上下文被切碎,改动点变成了洋葱式追踪
一个业务过程被拆成多个 UseCase 时,最让人头疼的是上下文丢失。我举个例子:购物车的“删除商品”在语义上是“删除被选中的商品行”,但如果项目里同时有 DeleteCartItemUseCase、ClearCartUseCase、ClearSelectedUseCase 三个类,而且各自独立调用 CartRepository 的不同方法,你就无法在一个地方回答“用户点了删除之后,角标到底怎么变”。
真实事故比例子更夸张。我处理过一个线上 Bug:用户批量取消选择后购物车角标没刷新。定位后发现,取消选择走的是 UpdateCartSelectionUseCase,而角标刷新走的是 LoadCartBadgeUseCase,前者调完仓储后没有通知后者。要是在一个叫 ChangeCartItemUseCase 的内部,把“更新选区 + 重新计算数量 + 返回最新角标值”作为一个完整业务过程来处理,根本不会出现信号断档。
这类问题本质上就是“操作被切得太碎,状态变化没有汇聚点”。当你需要解决的问题跨越三四个 UseCase 时,你要像剥洋葱一样一层一层追,误伤概率自然高得多。更麻烦的是,团队里每个人对“哪个 UseCase 属于哪一层”都有自己的理解,于是同一个业务逻辑在不同模块里被拆成不同形状,时间一长,代码库本身就变成了一个谜题集合。
2.3 第三个根因:测试看起来更独立了,实际扛不住业务状态流转
滥用 UseCase 的团队通常还会沾沾自喜地展示单元测试。我第一次看这种测试时也挺佩服,几十个测试文件,每个 UseCase 都覆盖了。直到我发现大量测试只是在验证“有没有调用仓库方法”:
@Test fun `invoke should call repository updateName`() = runTest { useCase("Alice") verify(userRepository).updateName("Alice") }这种测试与业务行为几乎无关。它不验证“当名字为空时是否拒绝保存”,不验证“保存成功后是否需要发事件”,只验证“转发确实发生了”。而当项目把行为拆散到多个 UseCase 中时,真正的行为驱动测试会比聚合式设计复杂得多:你得同时 mock 三个 Repository,还要考虑调用顺序、状态回滚、异常在不同层级之间的传播。于是团队很快就放弃写行为测试,转身继续写简单却低价值的“透传测试”。
所以我常说,“UseCase 可测试性强”这个优点,在过度拆分时变成了一个假象:测试文件多了,但业务保障没跟上。真正需要测试的“某一个完整业务行为的输入输出”,反而因为逻辑分散而变得难以测试。你表面上看到测试覆盖率很高,可一旦业务规则变化,你改完 UseCase,还要同步改五六个测试文件,而改完的测试依然没有告诉你“这个业务过程是否保证完整”。
3. 什么逻辑才配得上一个 UseCase:我给团队的三条硬性标准
既然 UseCase 不能滥用,那它到底什么时候该出现?我这些年慢慢收敛出三条硬性标准,缺一条都不建议新建 UseCase 类。
3.1 判断“业务语义增量”:UseCase 有没有带来方法之外的东西
唯一能让 UseCase 合法存在的理由,是它承载了“Repository 组合之后才会产生的业务决策”。我常拿“保存用户名”和“提交订单”做对比:
// 不推荐:没有业务语义增量 class SaveUserNameUseCase( private val userRepository: UserRepository ) { suspend operator fun invoke(name: String) { userRepository.updateName(name) } } // 推荐:有真正的业务语义增量 class SubmitOrderUseCase( private val cartRepository: CartRepository, private val orderRepository: OrderRepository, private val userRepository: UserRepository ) { suspend operator fun invoke(): SubmitOrderResult { val user = userRepository.currentUser() ?: return SubmitOrderResult.LoginRequired val cartItems = cartRepository.fetchItems() val checkout = cartItems.map { it.toCheckoutItem() } val orderId = orderRepository.placeOrder(checkout, user.id) return SubmitOrderResult.Success(orderId) } }看到区别了吗?SubmitOrderUseCase 里有明显的业务过程:检查登录状态、拉购物车、转换订单项、调下单接口。即使这些操作最后也只是一个个方法调用,至少它把“提交订单”这个完整意图聚合在了一起。而 SaveUserNameUseCase 里没有决策、没有校验、没有顺序,就是把参数透传。这样的类放得越多,项目就越像一层没有意义的壳。
3.2 一张决策表:什么时候走 ViewModel → Repository,什么时候引入 UseCase
为了避免争议,我做了一张决策表,团队评审时直接按表说话。它不是铁律,但对大多数场景都适用。
| 场景 | 是否新建 UseCase | 理由 |
|---|---|---|
| 简单 CRUD,无额外规则,只有页面调用 | 否 | ViewModel 直接依赖 Repository 即可,UseCase 只是多一层透传 |
| 同一业务逻辑会被多个入口共用 | 是 | 避免复制粘贴,把规则收敛到一个可测单元 |
| 需要协调多个 Repository/DataSource | 是 | UseCase 是天然的业务编排层 |
| 需要显式处理失败、回滚、重试 | 是 | 这类行为应该属于业务过程,而不属于页面控制器 |
| 操作之间存在顺序依赖,且后续会复用 | 是 | 先用 UseCase 固化顺序,后续调用方不会绕行 |
| 不确定时,先按最小实现写,等出现第二个调用方再提取 | 先不建 | 过早抽象比晚抽象更难维护 |
这张表不一定百分之百覆盖所有场景,但大多数纠结时刻都可以靠它消解。核心原则只有一条:没有明确的业务语义增量,就不要新增设计元素。
提示:UseCase 类存在的前提,是它能承载除了“方法转发”以外的东西。如果它只是一层壳,删掉它往往才是架构的进步。
3.3 识别“伪 UseCase”的四个特征
我在 Code Review 时一般会快速做这个判断:如果一个类同时出现下面四个特征,我就会在评论里建议删除或合并。
- 类名是“某个 Repository 方法 + UseCase”,而不是某个业务动词;
- 方法体只有一行 return,没有分支、校验、异常处理;
- 构造函数里只注入了一个 Repository;
- 测试用例里只能写出“verify(repository).xxx()”。
四个特征同时命中,说明这个类只是在给方法穿外套。真正要做的不是“再包一层”,而是直接让 ViewModel 面对 Repository,或者把该方法放入已有的业务 UseCase 中。这种“删除”操作听起来像是在破坏架构,其实是把原本就空洞的层拿掉,让真正的业务过程浮出水面。
4. 一次真实的重构:把购物车模块的 46 个 UseCase 收敛到 8 个
理论说再多,不如看一次实际操作。下面这个案例来自我接手的那个电商项目,为了不涉及业务隐私,我做了脱敏,但流程和结论都是真实的。
4.1 第一步:先给所有 UseCase 做“体检”,画出依赖地图
我没有一上来就删代码,而是先把购物车相关模块里所有 UseCase 文件列出来,按“输入、输出、内部有没有分支、调用方有哪些”四个维度做表。结果很有意思:46 个 UseCase 里,有 21 个是纯透传型,方法体只有一行;14 个只有简单的 if 判空;剩余 11 个才真正包含业务过程或跨仓库协调。
接着我把这些 UseCase 的调用方也标了出来。发现很多“看起来不同”的 UseCase,其实服务的是同一个业务过程。比如一个 GetCartCountUseCase 和一个 LoadCartItemsUseCase,在 UI 上总是成对出现,因为商品数和商品列表是一起渲染的。把它们的依赖地图画出来之后,哪些类该合并、哪些类该保留,方向就非常明显。
这一步千万不要跳过。直接删类很容易,但你以为没用的类,可能正被某个后台任务或者另一个 feature 模块悄悄调用。先画依赖地图,能避免重构时的低概率爆炸。
4.2 第二步:按业务域合并,而不是按方法合并
合并时最忌讳的是把几个方法强行塞进一个大类。正确的做法是按“业务过程”来