目录
- 一、先看一个“为了架构而架构”的真实案例
- 二、UseCase到底是什么:一个精准的比喻
- 2.1 食谱与食材的比喻
- 2.2 UseCase的三个本质
- 2.3 官方的态度
- 三、什么时候该加UseCase:四种真正有价值的场景
- 3.1 场景一:包含业务规则
- 3.2 场景二:组合多个Repository
- 3.3 场景三:跨ViewModel复用
- 3.4 场景四:逻辑复杂到需要独立测试
- 四、什么时候是画蛇添足:三种应该省略的场景
- 4.1 场景一:纯转发,无逻辑
- 4.2 场景二:简单的CRUD操作
- 4.3 场景三:只有一个ViewModel使用,且逻辑简单
- 五、一张决策清单:该加还是不该加
- 六、UseCase的正确写法:几个工程细节
- 6.1 用operator fun invoke
- 6.2 不要返回原始异常
- 6.3 不要在UseCase里持有Context
- 6.4 不要做UI逻辑
- 七、测试UseCase:比测试ViewModel更简单
- 八、常见陷阱速查
- 九、综合实战:一个“该加”和一个“不该加”
- 9.1 该加的场景:推荐商品
- 9.2 不该加的场景:删除商品
- 9.3 改造后的ViewModel
- 十、UseCase的思维模型
- 十一、小结与下一课预告
一、先看一个“为了架构而架构”的真实案例
第31课我们把数据层交给了Repository,ViewModel通过接口获取数据。现在有人告诉你:“Clean Architecture必须有UseCase层,每个Repository方法都要包一个UseCase。”
于是你写出了这样的代码:
class GetUsersUseCase @Inject constructor(
private val repository: UserRepository
) {
operator fun invoke(): Flow<List<User>> = repository.observeUsers()
}
class RefreshUsersUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(): Result<Unit> = repository.refreshUsers()
}
class DeleteUserUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Long): Result<Unit> = repository.deleteUser(id)
}
class GetUserUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Long): Result<User> = repository.getUser(id)
}
四个UseCase,每个都只有一行代码,做的事就是原封不动转发Repository调用。
ViewModel从这样:
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel()
变成了这样:
class UserListViewModel @Inject constructor(
private val getUsers: GetUsersUseCase,
private val refreshUsers: RefreshUsersUseCase,
private val deleteUser: DeleteUserUseCase
) : ViewModel()
多了三个依赖,代码一行没少,逻辑一点没变。这就是典型的“画蛇添足” ——为了“符合架构”而加了一层没有价值的抽象。
但另一方面,如果你遇到这样的场景:
用户列表需要先检查登录状态,然后从网络拉取数据,如果网络失败则读缓存,最后根据用户会员等级过滤部分内容,并按最近活跃时间排序。
这段逻辑放在Repository里太重,放在ViewModel里又会让ViewModel膨胀,而且另一个页面也要用同样的逻辑。这时候,UseCase就是雪中送炭。
这一课,我们就来把UseCase的“该加”和“不该加”彻底讲清楚。
下面是本课的整体脉络图,先建立全局认知:
#mermaid-svg-KhC0QwoZPHqIf0g3{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KhC0QwoZPHqIf0g3 .error-icon{fill:#552222;}#mermaid-svg-KhC0QwoZPHqIf0g3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KhC0QwoZPHqIf0g3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .marker.cross{stroke:#333333;}#mermaid-svg-KhC0QwoZPHqIf0g3 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KhC0QwoZPHqIf0g3 p{margin:0;}#mermaid-svg-KhC0QwoZPHqIf0g3 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .cluster-label text{fill:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .cluster-label span{color:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .cluster-label span p{background-color:transparent;}#mermaid-svg-KhC0QwoZPHqIf0g3 .label text,#mermaid-svg-KhC0QwoZPHqIf0g3 span{fill:#333;color:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .node rect,#mermaid-svg-KhC0QwoZPHqIf0g3 .node circle,#mermaid-svg-KhC0QwoZPHqIf0g3 .node ellipse,#mermaid-svg-KhC0QwoZPHqIf0g3 .node polygon,#mermaid-svg-KhC0QwoZPHqIf0g3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .rough-node .label text,#mermaid-svg-KhC0QwoZPHqIf0g3 .node .label text,#mermaid-svg-KhC0QwoZPHqIf0g3 .image-shape .label,#mermaid-svg-KhC0QwoZPHqIf0g3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-KhC0QwoZPHqIf0g3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .rough-node .label,#mermaid-svg-KhC0QwoZPHqIf0g3 .node .label,#mermaid-svg-KhC0QwoZPHqIf0g3 .image-shape .label,#mermaid-svg-KhC0QwoZPHqIf0g3 .icon-shape .label{text-align:center;}#mermaid-svg-KhC0QwoZPHqIf0g3 .node.clickable{cursor:pointer;}#mermaid-svg-KhC0QwoZPHqIf0g3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .arrowheadPath{fill:#333333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KhC0QwoZPHqIf0g3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KhC0QwoZPHqIf0g3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KhC0QwoZPHqIf0g3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KhC0QwoZPHqIf0g3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .cluster text{fill:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 .cluster span{color:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KhC0QwoZPHqIf0g3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KhC0QwoZPHqIf0g3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-KhC0QwoZPHqIf0g3 .icon-shape,#mermaid-svg-KhC0QwoZPHqIf0g3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KhC0QwoZPHqIf0g3 .icon-shape p,#mermaid-svg-KhC0QwoZPHqIf0g3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KhC0QwoZPHqIf0g3 .icon-shape .label rect,#mermaid-svg-KhC0QwoZPHqIf0g3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KhC0QwoZPHqIf0g3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KhC0QwoZPHqIf0g3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KhC0QwoZPHqIf0g3 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
是
否
是
否
是
否
一个操作
有业务规则?
加 UseCase
组合多个 Repository?
被多个 ViewModel 复用?
复杂到需要独立测试?
不加,直接调 Repository
UseCase 写法:invoke + Result + 不持 Context + 不做 UI
二、UseCase到底是什么:一个精准的比喻
2.1 食谱与食材的比喻
第31课我们把Repository比作管家——负责跑腿取数据。那UseCase是什么?
UseCase是食谱。
- Repository(管家) :负责把食材(数据)从仓库(网络/数据库)取回来。它不关心食材要做什么菜。
- UseCase(食谱) :定义了“怎么组合食材”。比如“番茄炒蛋”这道菜,需要先打蛋、再切番茄、先炒蛋再炒番茄、最后混合。食谱不负责买菜,也不负责端菜,但它定义了加工步骤和业务规则。
- ViewModel(主人) :决定“今天吃什么”,调用对应的食谱,把结果展示给客人(UI)。
关键理解:如果一道菜就是“把食材原样装盘”(比如水果拼盘),那不需要食谱,管家直接把水果端上来就行。只有当“加工”有价值时,食谱才有存在的意义。
2.2 UseCase的三个本质
| 封装业务规则 | 包含“怎么加工”的逻辑 | 按会员等级过滤内容 |
| 组合多个数据源 | 协调多个Repository | 用户信息+订单记录合并 |
| 跨ViewModel复用 | 同一段逻辑多处使用 | 搜索页和首页都用同一套推荐算法 |
如果一个UseCase只是转发Repository调用,不包含以上任何一条,它就是多余的。
2.3 官方的态度
Android官方架构指南对UseCase的表述非常克制。官方明确说:UseCase是“可选的” ,用于“封装复杂的业务逻辑”或“被多个ViewModel复用的逻辑”。官方从未说过“每个Repository方法都要包一个UseCase”。
相反,官方在“推荐应用架构”文档中给出的示例里,很多场景ViewModel直接调用Repository。UseCase是工具,不是教条。
用一张图看清 Repository、UseCase、ViewModel 的分工:
#mermaid-svg-oJbFgk1Ze4FnQ1zN{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .error-icon{fill:#552222;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .marker.cross{stroke:#333333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN p{margin:0;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .cluster-label text{fill:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .cluster-label span{color:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .cluster-label span p{background-color:transparent;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .label text,#mermaid-svg-oJbFgk1Ze4FnQ1zN span{fill:#333;color:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .node rect,#mermaid-svg-oJbFgk1Ze4FnQ1zN .node circle,#mermaid-svg-oJbFgk1Ze4FnQ1zN .node ellipse,#mermaid-svg-oJbFgk1Ze4FnQ1zN .node polygon,#mermaid-svg-oJbFgk1Ze4FnQ1zN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .rough-node .label text,#mermaid-svg-oJbFgk1Ze4FnQ1zN .node .label text,#mermaid-svg-oJbFgk1Ze4FnQ1zN .image-shape .label,#mermaid-svg-oJbFgk1Ze4FnQ1zN .icon-shape .label{text-anchor:middle;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .rough-node .label,#mermaid-svg-oJbFgk1Ze4FnQ1zN .node .label,#mermaid-svg-oJbFgk1Ze4FnQ1zN .image-shape .label,#mermaid-svg-oJbFgk1Ze4FnQ1zN .icon-shape .label{text-align:center;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .node.clickable{cursor:pointer;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .arrowheadPath{fill:#333333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oJbFgk1Ze4FnQ1zN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oJbFgk1Ze4FnQ1zN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oJbFgk1Ze4FnQ1zN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .cluster text{fill:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .cluster span{color:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oJbFgk1Ze4FnQ1zN rect.text{fill:none;stroke-width:0;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .icon-shape,#mermaid-svg-oJbFgk1Ze4FnQ1zN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .icon-shape p,#mermaid-svg-oJbFgk1Ze4FnQ1zN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .icon-shape .label rect,#mermaid-svg-oJbFgk1Ze4FnQ1zN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oJbFgk1Ze4FnQ1zN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oJbFgk1Ze4FnQ1zN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oJbFgk1Ze4FnQ1zN :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Data 层
Domain 层
UI 层
ViewModel(主人)
UseCase(食谱)
Repository A(管家)
Repository B(管家)
三、什么时候该加UseCase:四种真正有价值的场景
3.1 场景一:包含业务规则
这是UseCase最核心的价值。业务规则不属于Repository(Repository只管存取),也不属于ViewModel(ViewModel只管状态和事件)。
class GetRecommendedProductsUseCase @Inject constructor(
private val productRepository: ProductRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<List<Product>> {
val user = userRepository.getCurrentUser()
?: return Result.Error(DataError.Local.NotFound)
val allProducts = productRepository.getAllProducts()
// 业务规则:根据会员等级过滤 + 按评分排序 + 取前20
return Result.Success(
allProducts
.filter { product ->
when (user.membershipLevel) {
MembershipLevel.FREE -> product.isFree
MembershipLevel.PREMIUM -> true
MembershipLevel.VIP -> true
}
}
.sortedByDescending { it.rating }
.take(20)
)
}
}
判断标准:如果这段逻辑里出现了 if、when、filter、sortedBy、take 这类“加工”操作,而且这些操作不是纯粹的UI展示逻辑,那它就值得抽成UseCase。
3.2 场景二:组合多个Repository
当一个操作需要协调多个数据源时,UseCase是天然的协调者。
class GetUserDashboardUseCase @Inject constructor(
private val userRepository: UserRepository,
private val orderRepository: OrderRepository,
private val notificationRepository: NotificationRepository
) {
suspend operator fun invoke(userId: Long): Result<Dashboard> {
return try {
// 并行请求三个数据源
coroutineScope {
val user = async { userRepository.getUser(userId) }
val orders = async { orderRepository.getRecentOrders(userId) }
val notifications = async { notificationRepository.getUnread(userId) }
Result.Success(
Dashboard(
user = user.await(),
recentOrders = orders.await(),
unreadCount = notifications.await().size
)
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.Error(DataError.Network.ServerError)
}
}
}
为什么不放在Repository里:Repository的职责是“单一数据源的存取”。让 UserRepository 去调用 OrderRepository 和 NotificationRepository,会导致Repository之间互相依赖,形成网状耦合。UseCase作为上层协调者,依赖多个Repository,但Repository之间互不依赖——依赖方向是单向的。
3.3 场景三:跨ViewModel复用
同一段逻辑被两个以上的ViewModel使用时,抽成UseCase能避免重复代码和逻辑不一致。
// 搜索页和首页都需要“搜索建议”功能
class GetSearchSuggestionsUseCase @Inject constructor(
private val searchRepository: SearchRepository,
private val historyRepository: HistoryRepository
) {
suspend operator fun invoke(query: String): List<String> {
if (query.isBlank()) {
return historyRepository.getRecentSearches().take(5)
}
val remote = searchRepository.getSuggestions(query)
val local = historyRepository.searchHistory(query)
return (remote + local).distinct().take(10)
}
}
// 搜索页使用
class SearchViewModel @Inject constructor(
private val getSuggestions: GetSearchSuggestionsUseCase
) : ViewModel()
// 首页使用
class HomeViewModel @Inject constructor(
private val getSuggestions: GetSearchSuggestionsUseCase
) : ViewModel()
判断标准:如果你发现自己在两个ViewModel里复制粘贴了同一段逻辑,就该抽成UseCase了。
3.4 场景四:逻辑复杂到需要独立测试
当一段逻辑足够复杂,你希望不启动ViewModel就能测试它时,UseCase提供了隔离的测试单元。
class CalculateDiscountUseCase @Inject constructor(
private val promotionRepository: PromotionRepository
) {
suspend operator fun invoke(
cart: Cart,
user: User
): Result<DiscountResult> {
// 复杂的价格计算逻辑:满减、折扣券、会员折扣、叠加规则、互斥规则
// …
}
}
这个UseCase可以单独测试:给定不同的购物车、用户、促销规则,断言折扣计算结果。不需要ViewModel,不需要UI,不需要网络。
四种“该加”的场景,满足任意一条即可:
#mermaid-svg-uvrFgYie3TDQynMY{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uvrFgYie3TDQynMY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uvrFgYie3TDQynMY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uvrFgYie3TDQynMY .error-icon{fill:#552222;}#mermaid-svg-uvrFgYie3TDQynMY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uvrFgYie3TDQynMY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uvrFgYie3TDQynMY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uvrFgYie3TDQynMY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uvrFgYie3TDQynMY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uvrFgYie3TDQynMY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uvrFgYie3TDQynMY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uvrFgYie3TDQynMY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uvrFgYie3TDQynMY .marker.cross{stroke:#333333;}#mermaid-svg-uvrFgYie3TDQynMY svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uvrFgYie3TDQynMY p{margin:0;}#mermaid-svg-uvrFgYie3TDQynMY .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-uvrFgYie3TDQynMY .cluster-label text{fill:#333;}#mermaid-svg-uvrFgYie3TDQynMY .cluster-label span{color:#333;}#mermaid-svg-uvrFgYie3TDQynMY .cluster-label span p{background-color:transparent;}#mermaid-svg-uvrFgYie3TDQynMY .label text,#mermaid-svg-uvrFgYie3TDQynMY span{fill:#333;color:#333;}#mermaid-svg-uvrFgYie3TDQynMY .node rect,#mermaid-svg-uvrFgYie3TDQynMY .node circle,#mermaid-svg-uvrFgYie3TDQynMY .node ellipse,#mermaid-svg-uvrFgYie3TDQynMY .node polygon,#mermaid-svg-uvrFgYie3TDQynMY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uvrFgYie3TDQynMY .rough-node .label text,#mermaid-svg-uvrFgYie3TDQynMY .node .label text,#mermaid-svg-uvrFgYie3TDQynMY .image-shape .label,#mermaid-svg-uvrFgYie3TDQynMY .icon-shape .label{text-anchor:middle;}#mermaid-svg-uvrFgYie3TDQynMY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uvrFgYie3TDQynMY .rough-node .label,#mermaid-svg-uvrFgYie3TDQynMY .node .label,#mermaid-svg-uvrFgYie3TDQynMY .image-shape .label,#mermaid-svg-uvrFgYie3TDQynMY .icon-shape .label{text-align:center;}#mermaid-svg-uvrFgYie3TDQynMY .node.clickable{cursor:pointer;}#mermaid-svg-uvrFgYie3TDQynMY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uvrFgYie3TDQynMY .arrowheadPath{fill:#333333;}#mermaid-svg-uvrFgYie3TDQynMY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uvrFgYie3TDQynMY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uvrFgYie3TDQynMY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uvrFgYie3TDQynMY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uvrFgYie3TDQynMY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uvrFgYie3TDQynMY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uvrFgYie3TDQynMY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uvrFgYie3TDQynMY .cluster text{fill:#333;}#mermaid-svg-uvrFgYie3TDQynMY .cluster span{color:#333;}#mermaid-svg-uvrFgYie3TDQynMY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uvrFgYie3TDQynMY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uvrFgYie3TDQynMY rect.text{fill:none;stroke-width:0;}#mermaid-svg-uvrFgYie3TDQynMY .icon-shape,#mermaid-svg-uvrFgYie3TDQynMY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uvrFgYie3TDQynMY .icon-shape p,#mermaid-svg-uvrFgYie3TDQynMY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uvrFgYie3TDQynMY .icon-shape .label rect,#mermaid-svg-uvrFgYie3TDQynMY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uvrFgYie3TDQynMY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uvrFgYie3TDQynMY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uvrFgYie3TDQynMY :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
什么时候该加 UseCase?
包含业务规则
组合多个 Repository
跨 ViewModel 复用
逻辑复杂需独立测试
满足任意一条 → 加 UseCase
四、什么时候是画蛇添足:三种应该省略的场景
4.1 场景一:纯转发,无逻辑
// 画蛇添足:只有一行转发
class GetUsersUseCase @Inject constructor(
private val repository: UserRepository
) {
operator fun invoke(): Flow<List<User>> = repository.observeUsers()
}
这是最常见的问题。 很多人觉得“架构要有UseCase层”,就机械地为每个Repository方法创建一个UseCase。结果就是多了一层没有价值的间接调用。
官方态度:UseCase是“可选的”。如果它只是转发,直接调用Repository就好。
// 更简洁:ViewModel直接调Repository
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
val users = repository.observeUsers()
}
4.2 场景二:简单的CRUD操作
// 画蛇添足:删除就是删除,没有业务规则
class DeleteUserUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Long) = repository.deleteUser(id)
}
如果删除操作没有“删除前需要检查权限”“删除后需要发通知”“软删除还是硬删除”这类规则,那它就是一个纯粹的CRUD操作。CRUD操作不需要UseCase。
4.3 场景三:只有一个ViewModel使用,且逻辑简单
// 画蛇添足:这个UseCase只被一个ViewModel用,逻辑就是一次调用
class GetUserProfileUseCase @Inject constructor(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Long) = repository.getUser(id)
}
判断标准:如果一段逻辑只被一个地方使用,而且逻辑本身很简单(没有规则、没有组合、没有复杂计算),那它就应该是ViewModel的一个私有方法,而不是一个独立的类。
三种“不该加”的场景,命中任意一条就省略:
#mermaid-svg-rDveJJ2SAvtY0M7s{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rDveJJ2SAvtY0M7s .error-icon{fill:#552222;}#mermaid-svg-rDveJJ2SAvtY0M7s .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rDveJJ2SAvtY0M7s .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rDveJJ2SAvtY0M7s .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rDveJJ2SAvtY0M7s .marker.cross{stroke:#333333;}#mermaid-svg-rDveJJ2SAvtY0M7s svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rDveJJ2SAvtY0M7s p{margin:0;}#mermaid-svg-rDveJJ2SAvtY0M7s .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s .cluster-label text{fill:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s .cluster-label span{color:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s .cluster-label span p{background-color:transparent;}#mermaid-svg-rDveJJ2SAvtY0M7s .label text,#mermaid-svg-rDveJJ2SAvtY0M7s span{fill:#333;color:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s .node rect,#mermaid-svg-rDveJJ2SAvtY0M7s .node circle,#mermaid-svg-rDveJJ2SAvtY0M7s .node ellipse,#mermaid-svg-rDveJJ2SAvtY0M7s .node polygon,#mermaid-svg-rDveJJ2SAvtY0M7s .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rDveJJ2SAvtY0M7s .rough-node .label text,#mermaid-svg-rDveJJ2SAvtY0M7s .node .label text,#mermaid-svg-rDveJJ2SAvtY0M7s .image-shape .label,#mermaid-svg-rDveJJ2SAvtY0M7s .icon-shape .label{text-anchor:middle;}#mermaid-svg-rDveJJ2SAvtY0M7s .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rDveJJ2SAvtY0M7s .rough-node .label,#mermaid-svg-rDveJJ2SAvtY0M7s .node .label,#mermaid-svg-rDveJJ2SAvtY0M7s .image-shape .label,#mermaid-svg-rDveJJ2SAvtY0M7s .icon-shape .label{text-align:center;}#mermaid-svg-rDveJJ2SAvtY0M7s .node.clickable{cursor:pointer;}#mermaid-svg-rDveJJ2SAvtY0M7s .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rDveJJ2SAvtY0M7s .arrowheadPath{fill:#333333;}#mermaid-svg-rDveJJ2SAvtY0M7s .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rDveJJ2SAvtY0M7s .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rDveJJ2SAvtY0M7s .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rDveJJ2SAvtY0M7s .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rDveJJ2SAvtY0M7s .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rDveJJ2SAvtY0M7s .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rDveJJ2SAvtY0M7s .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rDveJJ2SAvtY0M7s .cluster text{fill:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s .cluster span{color:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rDveJJ2SAvtY0M7s .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rDveJJ2SAvtY0M7s rect.text{fill:none;stroke-width:0;}#mermaid-svg-rDveJJ2SAvtY0M7s .icon-shape,#mermaid-svg-rDveJJ2SAvtY0M7s .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rDveJJ2SAvtY0M7s .icon-shape p,#mermaid-svg-rDveJJ2SAvtY0M7s .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rDveJJ2SAvtY0M7s .icon-shape .label rect,#mermaid-svg-rDveJJ2SAvtY0M7s .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rDveJJ2SAvtY0M7s .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rDveJJ2SAvtY0M7s .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rDveJJ2SAvtY0M7s :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
什么时候不该加 UseCase?
纯转发,无逻辑
简单 CRUD 操作
单调用方且逻辑简单
命中任意一条 → 直接调 Repository
五、一张决策清单:该加还是不该加
拿到一个操作,按顺序问自己五个问题:
| 1. 它包含业务规则吗?(过滤、排序、计算、条件判断) | 加UseCase | 继续问 |
| 2. 它需要组合多个Repository吗? | 加UseCase | 继续问 |
| 3. 它被两个以上ViewModel使用吗? | 加UseCase | 继续问 |
| 4. 它复杂到需要独立测试吗? | 加UseCase | 继续问 |
| 5. 它只是转发Repository调用吗? | 不加,直接调Repository | 可以不加 |
简单记忆:有逻辑就加,没逻辑就不加;多处用就加,一处用就不加。
把决策清单画成流程图,照着走一遍就知道答案:
#mermaid-svg-4N7uaphWgey1tjcI{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4N7uaphWgey1tjcI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4N7uaphWgey1tjcI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4N7uaphWgey1tjcI .error-icon{fill:#552222;}#mermaid-svg-4N7uaphWgey1tjcI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4N7uaphWgey1tjcI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4N7uaphWgey1tjcI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4N7uaphWgey1tjcI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4N7uaphWgey1tjcI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4N7uaphWgey1tjcI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4N7uaphWgey1tjcI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4N7uaphWgey1tjcI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4N7uaphWgey1tjcI .marker.cross{stroke:#333333;}#mermaid-svg-4N7uaphWgey1tjcI svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4N7uaphWgey1tjcI p{margin:0;}#mermaid-svg-4N7uaphWgey1tjcI .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-4N7uaphWgey1tjcI .cluster-label text{fill:#333;}#mermaid-svg-4N7uaphWgey1tjcI .cluster-label span{color:#333;}#mermaid-svg-4N7uaphWgey1tjcI .cluster-label span p{background-color:transparent;}#mermaid-svg-4N7uaphWgey1tjcI .label text,#mermaid-svg-4N7uaphWgey1tjcI span{fill:#333;color:#333;}#mermaid-svg-4N7uaphWgey1tjcI .node rect,#mermaid-svg-4N7uaphWgey1tjcI .node circle,#mermaid-svg-4N7uaphWgey1tjcI .node ellipse,#mermaid-svg-4N7uaphWgey1tjcI .node polygon,#mermaid-svg-4N7uaphWgey1tjcI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4N7uaphWgey1tjcI .rough-node .label text,#mermaid-svg-4N7uaphWgey1tjcI .node .label text,#mermaid-svg-4N7uaphWgey1tjcI .image-shape .label,#mermaid-svg-4N7uaphWgey1tjcI .icon-shape .label{text-anchor:middle;}#mermaid-svg-4N7uaphWgey1tjcI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4N7uaphWgey1tjcI .rough-node .label,#mermaid-svg-4N7uaphWgey1tjcI .node .label,#mermaid-svg-4N7uaphWgey1tjcI .image-shape .label,#mermaid-svg-4N7uaphWgey1tjcI .icon-shape .label{text-align:center;}#mermaid-svg-4N7uaphWgey1tjcI .node.clickable{cursor:pointer;}#mermaid-svg-4N7uaphWgey1tjcI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4N7uaphWgey1tjcI .arrowheadPath{fill:#333333;}#mermaid-svg-4N7uaphWgey1tjcI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4N7uaphWgey1tjcI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4N7uaphWgey1tjcI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4N7uaphWgey1tjcI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4N7uaphWgey1tjcI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4N7uaphWgey1tjcI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4N7uaphWgey1tjcI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4N7uaphWgey1tjcI .cluster text{fill:#333;}#mermaid-svg-4N7uaphWgey1tjcI .cluster span{color:#333;}#mermaid-svg-4N7uaphWgey1tjcI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4N7uaphWgey1tjcI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4N7uaphWgey1tjcI rect.text{fill:none;stroke-width:0;}#mermaid-svg-4N7uaphWgey1tjcI .icon-shape,#mermaid-svg-4N7uaphWgey1tjcI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4N7uaphWgey1tjcI .icon-shape p,#mermaid-svg-4N7uaphWgey1tjcI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4N7uaphWgey1tjcI .icon-shape .label rect,#mermaid-svg-4N7uaphWgey1tjcI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4N7uaphWgey1tjcI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4N7uaphWgey1tjcI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4N7uaphWgey1tjcI :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
是
否
是
否
是
否
是
否
拿到一个操作
1. 包含业务规则?
加 UseCase
2. 组合多个 Repository?
3. 被两个以上 ViewModel 使用?
4. 复杂到需要独立测试?
5. 只是转发 Repository 调用?
不加,直接调 Repository
六、UseCase的正确写法:几个工程细节
6.1 用operator fun invoke
class GetRecommendedProductsUseCase @Inject constructor(
private val productRepository: ProductRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<List<Product>> {
// …
}
}
// 调用时像函数一样
val products = getRecommendedProducts()
operator fun invoke 让UseCase可以像函数一样调用,比 execute() 或 getData() 更简洁。这是Kotlin社区和官方示例的通用惯例。
6.2 不要返回原始异常
和第31课一样,UseCase也应该返回类型化的 Result,不要抛出原始异常:
// 不推荐
suspend operator fun invoke(): List<Product> // 抛异常怎么办?
// 推荐
suspend operator fun invoke(): Result<List<Product>>
6.3 不要在UseCase里持有Context
UseCase是纯Kotlin类,不应该依赖Android框架。如果需要Context,通过接口抽象,在平台层实现。
6.4 不要做UI逻辑
UseCase返回的是数据,不是展示文案。
// 错误:UseCase不该关心展示
suspend operator fun invoke(): Result<String> {
return Result.Success("加载成功,共${list.size}条")
}
// 正确:返回数据,文案由UI层决定
suspend operator fun invoke(): Result<List<Product>>
七、测试UseCase:比测试ViewModel更简单
UseCase的测试是纯JVM单元测试,用假的Repository:
class GetRecommendedProductsUseCaseTest {
@Test
fun `filters free products for free users`() = runTest {
val fakeProductRepo = FakeProductRepository(
products = listOf(
Product(1, "免费商品", isFree = true, rating = 4.5),
Product(2, "付费商品", isFree = false, rating = 5.0)
)
)
val fakeUserRepo = FakeUserRepository(
user = User(membershipLevel = MembershipLevel.FREE)
)
val useCase = GetRecommendedProductsUseCase(fakeProductRepo, fakeUserRepo)
val result = useCase()
assertTrue(result is Result.Success)
assertEquals(1, result.data.size)
assertEquals("免费商品", result.data.first().name)
}
}
测试的核心思路:给不同的Repository返回值(会员等级、商品列表),断言UseCase的输出符合业务规则。不启动UI,不启动网络,毫秒级完成。
八、常见陷阱速查
| 为每个Repository方法创建UseCase | GetUsersUseCase、DeleteUserUseCase……全是转发 | 多了一层无价值抽象 | 有逻辑才加,没逻辑直接调Repository |
| UseCase里持有Context | class MyUseCase(private val context: Context) | 内存泄漏、无法测试 | 用接口抽象,平台层实现 |
| UseCase返回原始异常 | suspend fun invoke(): List<User> 抛IOException | ViewModel无法类型化处理 | 返回 Result<T> + DataError |
| UseCase做UI逻辑 | 返回 "加载中"、"成功" 字符串 | 耦合、难复用 | 返回数据,文案由UI层决定 |
| UseCase之间互相调用 | AUseCase 依赖 BUseCase | 依赖混乱、难追踪 | UseCase只依赖Repository,不依赖其他UseCase |
| UseCase依赖ViewModel | UseCase里调ViewModel | 依赖倒置、架构崩塌 | UseCase不感知上层 |
| 所有逻辑都塞进一个大UseCase | AppUseCase 有20个方法 | 职责混乱 | 按单一职责拆分 |
| 为了“层数”而加UseCase | 项目里一个逻辑都没有,却有一堆UseCase | 画蛇添足 | 从没有UseCase开始,需要时再加 |
九、综合实战:一个“该加”和一个“不该加”
9.1 该加的场景:推荐商品
// 有业务规则:按会员等级过滤、按评分排序、取前20
class GetRecommendedProductsUseCase @Inject constructor(
private val productRepository: ProductRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<List<Product>> {
val user = userRepository.getCurrentUser()
?: return Result.Error(DataError.Local.NotFound)
val allProducts = productRepository.getAllProducts()
val filtered = allProducts.filter { product ->
when (user.membershipLevel) {
MembershipLevel.FREE -> product.isFree
else -> true
}
}
return Result.Success(
filtered
.sortedByDescending { it.rating }
.take(20)
)
}
}
为什么该加:有业务规则(会员过滤),组合了两个Repository(商品和用户),逻辑复杂度值得独立测试。
9.2 不该加的场景:删除商品
// 画蛇添足:纯转发,无逻辑
class DeleteProductUseCase @Inject constructor(
private val repository: ProductRepository
) {
suspend operator fun invoke(id: Long) = repository.deleteProduct(id)
}
为什么不该加:删除就是删除,没有业务规则,没有组合,只有一个调用方。ViewModel直接调 repository.deleteProduct(id) 就够了。
9.3 改造后的ViewModel
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val repository: ProductRepository, // 直接用于简单操作
private val getRecommended: GetRecommendedProductsUseCase // 复杂逻辑用UseCase
) : ViewModel() {
fun loadRecommendations() {
viewModelScope.launch {
when (val result = getRecommended()) {
is Result.Success -> _uiState.update { it.copy(products = result.data) }
is Result.Error -> _uiState.update { it.copy(error = result.error) }
}
}
}
fun delete(id: Long) {
viewModelScope.launch {
repository.deleteProduct(id) // 简单操作直接调Repository
}
}
}
设计要点:同一个ViewModel里,复杂逻辑走UseCase,简单操作直接调Repository。不是“要么全用UseCase,要么全不用”,而是“按需使用”。
十、UseCase的思维模型
第一层:UseCase是什么。 它是食谱,不是食材仓库。它定义“怎么加工数据”,不负责“从哪里取数据”。
第二层:什么时候该加。 有业务规则、组合多个数据源、跨ViewModel复用、逻辑复杂到需要独立测试。四条满足一条就加。
第三层:什么时候不该加。 纯转发、简单CRUD、只有一个调用方且逻辑简单。有逻辑就加,没逻辑就不加;多处用就加,一处用就不加。
第四层:怎么写。 operator fun invoke,返回 Result<T> + DataError,不持有Context,不做UI逻辑,不依赖其他UseCase。
贯穿始终的原则:UseCase是工具,不是教条。 它的价值在于“加工”,不在于“存在”。一个只有一行转发的UseCase,不是架构的体现,而是架构的负担。
十一、小结与下一课预告
这一课我们搞定了UseCase的“该加”与“不该加”。关键点回顾:
- UseCase的本质:封装业务规则、组合多个数据源、跨ViewModel复用、独立测试。它是“食谱”,不是“食材仓库”。
- 该加的场景:有业务规则(过滤、排序、计算)、组合多个Repository、被多个ViewModel使用、逻辑复杂到需要独立测试。
- 不该加的场景:纯转发Repository调用、简单CRUD操作、只有一个调用方且逻辑简单。
- 决策清单:有逻辑就加,没逻辑就不加;多处用就加,一处用就不加。
- 正确写法:operator fun invoke、返回类型化 Result、不持有Context、不做UI逻辑、不依赖其他UseCase。
- 测试:纯JVM单元测试,用假的Repository,断言输出符合业务规则。
- 常见陷阱:为每个Repository方法创建UseCase、UseCase里持有Context、返回原始异常、UseCase之间互相调用、为了“层数”而加UseCase。
UseCase是工具,不是教条。 它的价值在于“加工”,不在于“存在”。一个只有一行转发的UseCase,不是架构的体现,而是架构的负担。
下一课,我们会讲依赖注入在Compose中的落地:Hilt从入门到实战。ViewModel、Repository、UseCase都定义好了,但谁来创建它们、谁来注入依赖?答案是Hilt。我们会讲Hilt的核心概念、Module的写法、ViewModel注入、与Navigation的配合、测试时的替换、以及常见坑。这是把前面所有分层“组装”起来的关键一课。
课后练习建议:打开你的项目,数一数有多少个UseCase是纯转发的。如果有,删掉它们,让ViewModel直接调Repository。然后再找一段真正的业务逻辑(过滤、排序、组合多数据源),把它抽成一个UseCase。做完之后,你会发现代码少了一些,但清晰了很多。



