关于UseCase的详细指南.
简洁架构非常有用, 尤其是在大型项目中. 然而, 错误地使用它就像一生都在向错误的神灵祈祷 -- 结果只有痛苦, 没有收获:)
在本系列中, 我们将探讨每一层的所有最佳实践和红牌用法, 解释为什么一种方法优于另一种方法, 并找出会引起挫败感的红牌用法, 但就像恋爱关系一样, 我们无论如何都会忽略它们 🤦♂️
UseCase的职责是什么?
UseCase负责封装系统必须执行的单个可重用任务的业务逻辑.
以下是这一定义的细分...
1- 业务逻辑 侧重于产品团队向我们描述任务时需要实现的内容. 如果你可以在不同的平台(如 iOS)上使用你的UseCase, 则表明你的UseCase只执行业务逻辑.
举个例子: 如果我们要付款, 业务逻辑看起来像...
- 开始交易
- 发送付款
- 完成交易
- 必要时处理任何错误
kotlin
class SendPayment(private val repo: PaymentRepo) {
suspend operator fun invoke(
amount: Double,
checkId: String,
): Boolean {
val transactionId = repo.startTransaction(params.checkId)
repo.sendPayment(
amount = params.amount,
checkId = params.checkId,
transactionId = transactionId
)
return repo.finalizeTransaction(transactionId)
}
}
2- 单个任务: 一个UseCase只需关注一个任务, 主要是一个公共函数.
为什么? 当一个UseCase只负责一个任务时, 它将是可重用的, 可测试的, 而且会迫使开发人员选择一个好名字, 而不是一个通用的名字.
不专注于一项任务的UseCase通常最终会成为repository的包装器 + 难以放入正确的包中 + 迫使开发人员阅读所有UseCase来了解它的作用.
kotlin
// DON'T ❌ - Generic use case for mulitple tasks
// The functionality hard to discover if the developer
// didn't read the use case, which is very hard in a big code base.
class GalleryUseCase @Inject constructor(
/*...*/
) {
fun saveImage(file: File)
fun downloadFileWithSave(/*...*/)
fun downloadImage(/*...*/): Image
fun getChatImageUrl(messageID: String)
}
// DO ✅ - Each use case should have only one responsiblity
class SaveImageUseCase @Inject constructor(
/*...*/
) {
operator fun invoke(file: File): Single<Boolean>
// Overloading is fine for same use-case responsibility
// but with different set of params.
operator fun invoke(path: String): Single<Boolean>
}
class GetChatImageUrlByMessageIdUseCase() {
operator fun invoke(messageID: String): Url {...}
}
注意: 重载是一件很传统的事情, 所以你可能需要与你的团队讨论一下.
另一种解决方案是为每个UseCase创建一个UseCase, 例如 GetUser, GetUserByUserId, GetUserByUsername 等.
但我认为重载更有意义, 因为当我们有 4-5 个参数时就很难命名了: )
2- 命名 🔤
UseCase类的命名很简单 : 动词 + 的一般现在时 + 名词 (可选) + UseCase.
例如: FormatDateUseCase, GetChatUserProfileUseCase, RemoveDetektRulesUseCase 等.
函数名可以使用调用操作符或普通函数名...
函数可以使用invoke 运算符 或普通函数名.
kotlin
class SendPaymentUseCase(private val repo: PaymentRepo) {
// using operator function
suspend operator fun invoke(): Boolean {}
// normal names
suspend fun send(): Boolean {}
}
// --------------Usage--------------------
class HomeViewModel(): ... {
fun startPayment(...) {
sendPaymentUseCase() // using invoke
sendPaymentUseCase.send() using normal functions
}
}
在我看来, 调用操作符更好, 因为...
- 它迫使开发人员为UseCase选择一个好的名称.
- 减少了为函数另取名称的麻烦.
- 更易于使用.
- 它使我们能够轻松地为相同的职责添加重载, 并为不同的职责添加不同的函数.
3- 线程安全 🧵
UseCase应该是主线程安全的, 这意味着任何繁重的操作都在单独的线程上处理, 并且UseCase仍可从主线程调用.
kotlin
// DON'T ❌ - Adding big lists and sorting operations are heavy
// and should be done on different thread.
class AUseCase @Inject constructor() {
suspend operator fun invoke(): List<String> {
val list = mutableListOf<String>()
repeat(1000) {
list.add("Something $it")
}
return list.sorted()
}
}
// DO ✅
class AUseCase @Inject constructor(
// or default dispatcher
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(): List<String> = withContext(dispatcher) {
val list = mutableListOf<String>()
repeat(1000) {
list.add("Something $it")
}
list.sorted()
}
}
// DON'T ❌
// Don't switch context when u are not doing a heavy operation or
// just calling a repositor since repo functions should be main-thread safe.
class AUseCase @Inject constructor(
private val repository: ChannelsRepository,
// or default dispatcher
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(): List<String> = withContext(dispatcher) {
repository.getSomething()
}
}
4- UseCase红牌用法 🚩🚩🚩🚩
1- 使用任何非domain类作为输入, 输出, 或者在UseCase主体中, 这些主体如 ui-models, data-models, Android 相关导入或 ui-domain/domain-data 映射器.
为什么? 这违反了UseCase的责任, 并降低了UseCase的可重用性.
例 1: data-domain 的映射是repository的职责, 而 ui-domain 的映射是 ViewModel/Presenter的职责.
例 2: 返回特定于屏幕的UI模型将使UseCase只能在该屏幕上使用.
例 3: 决定UseCase中的实际错误信息. 这是presentation的责任, UseCase只应返回错误, 而不是可显示的信息.
kotlin
// DON'T ❌ - Don't use any android related classes/imports
class AddToContactsUseCase @Inject constructor(
@ApplicationContext private val context: Context,
) {
operator fun invoke(
name: String?,
phoneNumber: String?,
) {
context.addToContacts(
name = name,
phoneNumber = phoneNumber,
)
}
2- 拥有一个以上的公共函数, 除非它是同一职责的重载...
为什么? 这会使UseCase难以发现, 并迫使开发人员阅读大量UseCase才能找到他/她要找的东西.
3- UseCase中定义了非通用的业务规则, 通常意味着屏幕特定的逻辑.
为什么? 这使得UseCase无法在其他屏幕/UseCase中重复使用.
4- UseCase不应包含可变数据. 你应该在UI或数据层中处理可变数据. 这可能会产生串屏行为, 尤其是在多个屏幕中使用时.
kotlin
// DON't ❌
class PerformeSomethingUseCase @Inject constructor() {
val list = mutableListOf<String>()
suspend operator fun invoke(): List<String> {
repeat(1000) {
list.add("Something $it")
}
return list.sorted()
}
}
7- 用一个通用名称来命名UseCase, 如 LivestreamUseCase, UserUseCase, GalleryUseCase 等.
为什么? 这违反了单一责任原则, 迫使开发人员阅读大量UseCase才能找到他/她要找的内容.
常见问题 !?
1- 我是否应该在UseCase中使用抽象?
在许多文章和指南中, 你会看到这样的UseCase的抽象实现...
kotlin
interface GetSomethingUseCase {
suspend operator fun invoke(): List<String>
}
class GetSomethingUseCaseImpl(
private val repository: ChannelsRepository,
) : GetSomethingUseCase {
override suspend operator fun invoke(): List<String> = repository.getSomething()
}
// Than bind this implementation to the interface using dependecy injection
抽象具有多种优势, 例如可以提供多种实现, 并能为单元测试提供模拟.
不过, UseCase通常只有一个实现, 不需要模拟, 因为它们只是规定了领域规则. 测试 ViewModels 时, 最好测试真实的应用逻辑, 而不是模拟它.
我认为, 除非需要多个实现, 否则应避免使用UseCase进行抽象.
2- 如何处理无用的UseCase?
有时, 你会遇到很多UseCase, 但它们只封装了一个repository函数...
kotlin
class GetSomethingUseCase @Inject constructor(
private val repository: ChannelsRepository,
) {
suspend operator fun invoke(): List<String> = repository.getSomething()
}
你可能会"理直气壮"地问, 不如在 ViewModel 中使用这个该死的repository函数吧?
好吧, google 在这一点上同意你的观点:) 但是, 使用UseCase访问数据层的利弊如下...
- 许多UseCase会增加复杂性, 而好处却很少(大-).
- 始终使用UseCase可以保护代码, 避免将来发生变化(+).
例如, 如果发送付款需要另一个步骤, 你需要创建一个新的UseCase, 然后重构每个使用该 repo 函数的 ViewModel, 使其使用该UseCase. - 经常使用UseCase就像使用文档一样, 可以帮助你更好地理解项目的功能(+).
例如, 新开发人员只需阅读UseCase的名称, 就能了解项目的工作内容. - 始终使用UseCase有助于保持一致性并减少决策疲劳(+).
决定权在你和你的团队, 可能会因项目规模而异.
3- 我能否在另一个UseCase中使用UseCase?
可以, 这不仅可以接受, 而且值得推荐. 将任务分解成更小, 更易于管理的UseCase可以增强模块化和可重用性.