别把UseCase当模板:Android项目越用越烂的整洁架构反思 📅 发布时间:2026/9/9 12:25:07 👁 浏览次数: 在 Android 项目里待得越久我对“分层架构”这句话的警惕心就越重。不是因为它不对而是我见过太多团队把架构做成了“照章办事”最后被自己的严谨拖垮。最典型的就是 Clean Architecture 里的 UseCase也叫 Interactor。这个原本用来承载独立业务过程的抽象一旦被当成“每有一个按钮就写一个类”的模板项目就会迅速从一个容易理解的状态滑向一个看起来规范、实则谁也改不动的泥潭。我前两年接手过一个电商项目的购物车模块代码里密密麻麻躺了几十个 UseCase 文件。刚打开时我还挺感动觉得这个团队很专业。可当我真的开始改需求才发现这些 UseCase 大部分只是把 ViewModel 的调用转发给 Repository中间没有业务校验没有状态聚合也没有任何领域逻辑。改一个“删除购物车项但不弹提示”的小需求我跨了 5 个类才找回那行 if。事后我在群里感慨“UseCase 越多项目越烂。”有人以为我是开玩笑但这是我看过太多项目之后一句很真实的总结。今天我不讲那种“一定要用/一定不能用”的绝对结论而是把“为什么会产生这个反直觉现象”拆开讲为什么很多团队会把 UseCase 当成银弹它又是如何在不知不觉中让项目变烂的。1. 先从现象说起那些我经历过的 UseCase 灾难现场在讨论理论之前我先描述三个反复出现的现场。这些现象如果你经历过任何一个就说明你的项目已经踩进了同一个坑。1.1 一个目录里躺了几十个“只有方法名不同”的 UseCase最典型的情况是用户资料页有 10 个字段于是代码里出现 10 个“保存字段”的 UseCaseclass 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 第二步按业务域合并而不是按方法合并合并时最忌讳的是把几个方法强行塞进一个大类。正确的做法是按“业务过程”来