欢迎光临
我们一直在努力

【Jetpack Compose娓娓道来】第27课:UseCase——什么时候该加,什么时候是画蛇添足

目录

  • 一、先看一个“为了架构而架构”的真实案例
  • 二、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。做完之后,你会发现代码少了一些,但清晰了很多。

赞(0)
未经允许不得转载:171主机测评 » 【Jetpack Compose娓娓道来】第27课:UseCase——什么时候该加,什么时候是画蛇添足
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址