简洁架构中的UseCase红牌用法和最佳实践

关于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
    }
}

在我看来, 调用操作符更好, 因为...

  1. 它迫使开发人员为UseCase选择一个好的名称.
  2. 减少了为函数另取名称的麻烦.
  3. 更易于使用.
  4. 它使我们能够轻松地为相同的职责添加重载, 并为不同的职责添加不同的函数.

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可以增强模块化和可重用性.

相关推荐
mmsx2 小时前
android sqlite 数据库简单封装示例(java)
android·java·数据库
车载诊断技术3 小时前
电子电气架构 --- 什么是EPS?
网络·人工智能·安全·架构·汽车·需求分析
武子康3 小时前
大数据-258 离线数仓 - Griffin架构 配置安装 Livy 架构设计 解压配置 Hadoop Hive
java·大数据·数据仓库·hive·hadoop·架构
众拾达人5 小时前
Android自动化测试实战 Java篇 主流工具 框架 脚本
android·java·开发语言
吃着火锅x唱着歌6 小时前
PHP7内核剖析 学习笔记 第四章 内存管理(1)
android·笔记·学习
_Shirley7 小时前
鸿蒙设置app更新跳转华为市场
android·华为·kotlin·harmonyos·鸿蒙
9527华安8 小时前
FPGA多路MIPI转FPD-Link视频缩放拼接显示,基于IMX327+FPD953架构,提供2套工程源码和技术支持
fpga开发·架构·音视频
hedalei9 小时前
RK3576 Android14编译OTA包提示java.lang.UnsupportedClassVersionError问题
android·android14·rk3576
锋风Fengfeng9 小时前
安卓多渠道apk配置不同签名
android
枫_feng10 小时前
AOSP开发环境配置
android·安卓