Android 架构 UseCase 最佳实践
前言
Android 官方的最新架构中引入了 Domain (网域层 or 领域层),Domain Layer 由一个个 UseCase 组成。但是由于团队套用官方新架构后没有正确地定义 UseCase,无法发挥 Domain Layer 应有的架构价值。
本文就带大家一起梳理 UseCase 常见的使用误区和最佳实践。
UseCase 的职责
一句话概括,UseCase 用来封装可复用的单一业务逻辑。这里的两个关键词一个是单一、一个是业务逻辑。
首先 UseCase 应该用来定义一段 Logic,这段 Logic 与 UI 以及 Data 的访问方式无关,是独立于 UI 和 Data 之外的 Business。
我们都知道良好的架构应该做到关注点分离,即表现层和数据层的解耦。领域层一定程度扮演着这个解耦的角色,但是如果仅仅是为了做隔离和解耦,只要定义好 ViewModel 即可,没必要引入 Domain 和 UseCase 这一新的层级概念,所以官方文档也说了 Domina 层是可选的。
UseCase 如果存在,则其逻辑应该有一定的复杂度,这样才有被“封装”的价值。举一个例子,一个支付相关的业务逻辑,应该包含事务的发起和结束以及,以及事务过程中的异常处理:
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)
}
}
此外,一个 UseCase 应该是单一职责,甚至可以就是一个 Functioin,这样才能以更小颗粒度被复用,提升复用范围也更易于测试。一个检验 UseCase 是否职责单一的方法是看它的命名是否语义明确,好的命名应该是一个具体动作。
一个名词命名的 UseCase 很难做到职责单一,如 GalleryUseCase,这类对象往往基于 OOP 思想设计,内部多个成员方法。经验告诉我们,方法越多,单一方法的价值越低,有的多方法的 UseCase 没提供什么业务价值,甚至沦为了一个 Repository 的 Wrapper。
好的 UseCase 只要完成一件有价值的业务即可,Repository 只是它完成业务工具。价值体现在业务逻辑具备一定的复杂度,何为“复杂”,前面已经举例了。
下面是 UseCase 是否职责单一的正反例子
// DON'T ❌ - 名词命名,
// 一般是OOP思想下的产物,功能多,容易违背单一职责
class GalleryUseCase @Inject constructor(
/*...*/
) {
fun saveImage(file: File)
fun downloadFileWithSave(/*...*/)
fun downloadImage(/*...*/): Image
fun getChatImageUrl(messageID: String)
}
// DON'T ❌ - 只是一个 Repository 的包装器
class GetSomethingUseCase @Inject constructor(
private val repository: ChannelsRepository,
) {
suspend operator fun invoke(): List<String> = repository.getSomething()
}
// DO ✅ - 动词命名,单一职责
class SaveImageUseCase @Inject constructor(
/*...*/
) {
operator fun invoke(file: File): Single<Boolean>
// 这里虽然有多个方法,但其实是重载方法,职责上仍然是单一的
operator fun invoke(path: String): Single<Boolean>
}
class GetChatImageUrlByMessageIdUseCase() {
operator fun invoke(messageID: String): Url {...}
}
单一职责下的 UseCase 可以更好地被其他 UseCase 使用,官方文档也鼓励通过 UseCase 的组合调用实现更复杂的业务逻辑。
UseCase 的命名
前面提过,UseCase 的命名通常是一个语义明确的动作:动词(一般现在时) + 名词() + UseCase
例如 FormatDateUseCase
, GetChatUserProfileUseCase
, RemoveDetektRulesUseCase
等。UseCase 类中的函数可以直接使用 invoke
操作符重载,也可以给一个动词作为名字
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
}
}
invoke 操作符更优于常规函数,因为:
开发者只要给 UseCase 一个合适的命名即可,无需考虑函数的命名 调用起来非常简单 便于重载,当增加新的非 invoke 方法时也比较容易被察觉,避免单一职责的劣化
UseCase 的线程安全
官方文档提到 UseCase 应该是 Main-safe 的,即可以在主线程安全的调用,其中的耗时处理应该自动切换到后台线程。
// DON'T ❌ - add 和 sort 都是耗时操作,不能直接在主线程执行
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 ❌ - 避免过度切换线程
// Repository 应该也是 main safe 的,所以没必要再切换一次 Context, 直接调用节课
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()
}
}
UseCase 的签名依赖
UseCase 应该是一段纯业务逻辑,它的函数签名(输入输出)不应该依赖 UI 或平台设备相关的依赖,包括 Context
类,这样才具备更好的可复用性。
此外,UseCase 的签名不应该以来 UI层的 Model,这会让 UseCase 沦为从 Data Model 到 UI Model 映射的工具,这是 ViewModel 的事情。
对于异常,UseCase 只需要返回 error code 类型而不是具体的 message,error messag 应该由 UI 基于 error code 生成。
// DON'T ❌ - 不应该依赖任何 Android 平台相关对象,甚至 Context
class AddToContactsUseCase @Inject constructor(
@ApplicationContext private val context: Context,
) {
operator fun invoke(
name: String?,
phoneNumber: String?,
) {
context.addToContacts(
name = name,
phoneNumber = phoneNumber,
)
}
UseCase 的引用透明
如果将 UseCase 认为是一个函数,那么它最好具备一个纯函数的特性,内部不应该包含 mutable 的数据。
UseCase 本身不持有可监听的状态,它内部如果隐藏了可变数据,且在业务逻辑会受到内部可变数据的影响,会破坏 UseCase 的幂等性,在多场景复用时会出现相同输入但输出不同的情况。
// 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()
}
}
好的 UseCase 其唯一输入只会得到唯一输出,这被称为引用透明。
UseCase 的接口抽象
有一些文章会看到对 UseCase 做接口抽象和派生。
//定义 UseCase 接口
interface GetSomethingUseCase {
suspend operator fun invoke(): List<String>
}
//UseCase 派生类
class GetSomethingUseCaseImpl(
private val repository: ChannelsRepository,
) : GetSomethingUseCase {
override suspend operator fun invoke(): List<String> = repository.getSomething()
}
如上,定义 UseCase 的接口和对应实现,然后在 DI 容器中,可以通过 @Bind
提供实例注入。其实这种属于过度设计,单一职责的 UseCase 应该只有一个方法或一类重载方法,而且方法最好是纯函数逻辑,不依赖 UseCase 对象的任何状态,因此从这个角度讲,UseCase 可以是一个单例,直接使用 object
定义。
有时候 UseCase 需要动态依赖不同的 Repository,此时可以使用 class
定义 UseCase,按需实例化使用,或者在 DI 容器中被动注入,此时依赖的 Repository 可以从 DI 容器中自动获取。在 class 之外再定义一个 interface
必要性不大。
当然,还有一种使用 Kotlin 的 function interface
来定义 UseCase 的技巧,这里的 interface 主要目的不是为了抽象,而是想利用其单方法接口的特性,约束 UseCase class 里定义太多方法。
比如 , 像下面这样定义一个 UseCase 的单方法接口:
fun interface GetSomethingUseCase : suspend () -> List<String>
此时需要像下面这样实例化,只能定义一个方法,强制确保单一职责
val getSomethingUseCase = GetSomethingUseCase {
repository.getSomething()
}
关于 function interface
定义 UseCase 的更多内容,可以参考我的另一篇文章:Android 最新官方架构推荐引入 UseCase,这是个啥?该怎么写?
-- END --
推荐阅读