第9章:UseCase层设计:单一职责的用例,UseCase的输入输出规范

好,咱们今天聊聊UseCase层。说实话,很多刚接触Clean架构的开发者,最容易搞混的就是这一层。我见过不少项目,UseCase里塞了几百行代码,什么网络请求、数据库操作、UI状态更新全搅在一起——那还不如不用Clean架构呢。

UseCase的核心思想其实很简单:一个用例只做一件事。你想想看,现实生活中的用例也是这样——"登录"是一个用例,"获取用户信息"是另一个用例,它们不应该混在一起。

9.1 什么是UseCase?

用大白话说,UseCase就是你的App里一个完整的业务操作。比如:

  • 用户登录
  • 获取商品列表
  • 提交订单
  • 刷新Token

每个操作都是一个独立的UseCase。它们不关心数据从哪来,也不关心UI怎么展示,只关心"这个业务逻辑该怎么执行"。

核心原则:UseCase只依赖Repository接口,不依赖具体实现。这样你的业务逻辑就和数据源解耦了。

9.2 单一职责:一个UseCase只做一件事

我在项目中遇到过这样的场景:一个同事写了个UserUseCase,里面包含了登录、注册、获取资料、修改密码、退出登录五个方法。看起来挺方便对吧?但问题来了——

  • 测试的时候,你想单独测登录逻辑,得把整个类都mock了
  • 修改注册逻辑时,可能不小心影响到登录
  • 代码复用性差,别的模块想用登录功能,得依赖整个UserUseCase

所以我的建议是:一个UseCase只暴露一个公共方法。比如:

class LoginUseCase(
    private val authRepository: AuthRepository
) {
    suspend operator fun invoke(request: LoginRequest): Result<LoginResponse> {
        return authRepository.login(request)
    }
}

你看,这个UseCase只有一个invoke方法。调用方用起来特别清爽:

viewModelScope.launch {
    val result = loginUseCase(LoginRequest("user", "pass"))
}

小技巧:operator fun invoke可以让UseCase像函数一样调用,代码更简洁。我个人习惯所有UseCase都统一用这个命名。

9.3 UseCase的输入输出规范

嗯,这里要注意。很多初学者喜欢把UseCase的输入输出定义得很随意——输入直接传原始参数,输出直接返回数据实体。这样做的问题在于:

  • 参数多了以后,方法签名变得很长
  • 返回的数据可能包含调用方不需要的字段
  • 难以扩展——比如登录时突然需要传个设备ID,你得改所有调用方

我建议的做法是:每个UseCase定义自己的输入和输出数据类

输入规范

data class LoginRequest(
    val username: String,
    val password: String,
    val deviceId: String? = null  // 可选参数,不影响现有调用
)

输出规范

data class LoginResponse(
    val token: String,
    val userId: String,
    val expiresAt: Long
)

这样做的优势很明显:

  • 参数变化时,只需要改Request类,调用方不受影响
  • 返回的数据是调用方真正需要的,不多不少
  • 每个UseCase的输入输出自成一体,便于单元测试

避坑指南:我曾经犯过一个错误——把Repository返回的Entity直接作为UseCase的输出。后来UI层需要的数据变了,我不得不改Repository、改UseCase、改ViewModel,牵一发而动全身。所以记住:UseCase的输出应该是为调用方量身定制的

9.4 UseCase的典型结构

一个标准的UseCase通常长这样:

class GetProductListUseCase(
    private val productRepository: ProductRepository
) {
    suspend operator fun invoke(
        request: GetProductListRequest
    ): Result<GetProductListResponse> {
        // 可以在这里加一些业务逻辑
        // 比如缓存策略、数据转换、权限检查等
        return productRepository.getProducts(
            page = request.page,
            size = request.size
        ).map { products ->
            GetProductListResponse(
                items = products.map { it.toProductItem() },
                totalCount = products.size
            )
        }
    }
}

data class GetProductListRequest(
    val page: Int = 1,
    val size: Int = 20
)

data class GetProductListResponse(
    val items: List<ProductItem>,
    val totalCount: Int
)

你看,这个UseCase做了三件事:

  1. 接收请求参数
  2. 调用Repository获取数据
  3. 把数据转换成调用方需要的格式

仅此而已。没有网络请求,没有数据库操作,没有UI更新——那些是其他层的事。

9.5 用SVG理清UseCase的定位

下面这张图展示了UseCase在Clean架构中的位置,以及它和上下游的关系:

UI层 (ViewModel / Fragment) 调用UseCase,获取结果,更新UI状态 依赖:UseCase接口 调用 UseCase层 单一职责:一个UseCase只做一件事 输入:Request数据类 | 输出:Response数据类 调用 数据层 (Repository) 封装数据源(网络、数据库、缓存) 返回Entity,由UseCase转换为Response

从这张图可以清楚看到:UseCase处于中间层,它不直接操作数据源,也不直接更新UI。它的职责就是编排业务逻辑

9.6 什么时候需要UseCase?

你可能会问:是不是每个操作都要写一个UseCase?我的答案是:看情况

场景 是否需要UseCase 原因
简单的数据获取(如获取用户信息) 建议需要 方便后续加缓存、权限检查等逻辑
复杂的业务操作(如提交订单) 必须需要 涉及多个Repository调用、数据校验、状态管理
纯UI操作(如点击按钮播放动画) 不需要 这属于UI层职责,不需要业务逻辑

我的经验:刚开始写项目时,我倾向于为每个Repository方法都建一个UseCase。后来发现有些UseCase只是简单转发,反而增加了代码量。现在我遵循的原则是:如果这个操作未来可能增加业务逻辑,就建UseCase;如果确定只是简单转发,可以直接在ViewModel里调用Repository。但团队统一风格更重要,别一半用一半不用。

9.7 总结

UseCase层设计其实就三个要点:

  • 单一职责:一个UseCase只暴露一个公共方法
  • 输入输出规范:用Request/Response数据类封装
  • 只依赖Repository接口:不依赖具体实现

做到这三点,你的业务逻辑就会变得清晰、可测试、易维护。说白了,UseCase就是你的业务逻辑的"守门员"——它确保所有业务操作都按照规范来,不会乱成一锅粥。


公众号:蓝海资料掘金营,微信deep3321