简洁架构中的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可以增强模块化和可重用性.

相关推荐
小马爱打代码3 小时前
微服务外联Feign调用:第三方API调用的负载均衡与容灾实战
微服务·架构·负载均衡
雨白7 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
9527华安7 小时前
FPGA实现40G网卡NIC,基于PCIE4C+40G/50G Ethernet subsystem架构,提供工程源码和技术支持
fpga开发·架构·网卡·ethernet·nic·40g·pcie4c
kk爱闹8 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空10 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭10 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日11 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安11 小时前
Android Library Maven 发布完整流程指南
android
guojl11 小时前
深度解决大文件上传难题
架构
岁月玲珑11 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio