【现代 Android APP 架构】04. Domain 中间层——UI & Data 之间的桥梁

561 阅读3分钟

项目经理和技术总监可能是同一个人。这种方式可以非常容易地应用在小型团队中,这样的团队可能有3~6个程序员,而在更大的项目中则不容易获得应用。原因有两个:第一,同时具有管理技能和技术技能的人很难找到。第二,更大的项目中,每个角色必须全职工作,甚至还要加班。——《人月神话》

什么是 Domain 层

中间层(Domain)是用在UI & Data 之间的可选层。在两种情况下,可以考虑增加 Domain 层:

  • 复杂业务逻辑封装
    • 提升代码可读性
    • 提升可测试性
    • 避免类的体积膨胀
  • 业务逻辑在不同 ViewModel 之间复用
    • 避免代码重复

Domain 层的命名

Domain 层是概念,而 UseCase 则是它的具体实现。

命名格式:动词现代时+名词+UseCase,例如

  • FormatDateUseCase
  • LogOutUserUseCase
  • GetLatestNewsWithAuthorsUseCase
  • MakeLoginRequestUseCase

Domain 层所处的位置与依赖关系

Domain 层位于 UI 层的 ViewModel 与 Data 层的 Repository 之间,它依赖 Repository,并向 ViewModel 提供数据操作的接口,在两者之间起到隔离作用

思考这样一个用例:UseCase 从新闻 Repository 中获取新闻列表、从作者 Repository 中获取作者信息,并且将两者组装在一起

class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository
) { /* ... */ }

不同的 UseCase 之间,可以存在依赖关系,例如这里增加上一个日期格式化的用例,用来在 UI 层展示不同市区的日期信息。

class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository,
  private val formatDateUseCase: FormatDateUseCase
) { /* ... */ }

利用 Kotlin 中的 invoke 进行操作符重载

可以利用invoke对 UseCase 类进行重载,使其具备同名的函数,从而简化代码写法。 例如在下面这个例子中,可以将 formatDateUseCase.format(date) 简化为 formatDateUseCase(data)

// ===> 定义 UseCase
class FormatDateUseCase(userRepository: UserRepository) {

    private val formatter = SimpleDateFormat(
        userRepository.getPreferredDateFormat(),
        userRepository.getPreferredLocale()
    )

    operator fun invoke(date: Date): String { // ===> 重载 FormatDateUseCase()函数
        return formatter.format(date)
    }
}

// ===> 使用该 UseCase
class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {
    init {
        val today = Calendar.getInstance()
        val todaysDate = formatDateUseCase(today) // ===> 直接通过参数对象调用 invoke 函数
        /* ... */
    }
}

线程切换

UseCase 应当对自身的线程切换负责,做到主线程安全,防止因为耗时操作产生 ANR。

对于需要耗时操作的场景,可以在参数里传入(借助依赖翻转)Dispatcher,将控制权交由外部。

另外一种做法是在参数里传入 CoroutineScope,这样便于从外部控制任务取消。

class MyUseCase(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {

    suspend operator fun invoke(...) = withContext(defaultDispatcher) { // ===> 不新建协程,只是切换上下文
        // Long-running blocking operations happen on a background thread.
    }
}

常见的业务需求开发用例

不要在 Util 类中填写业务逻辑: 在某些情况下,UseCase 中存在的逻辑可以改为使用 Util 类中的静态方法。然而,后者并不推荐,因为 Util 类通常很难找到,而且它们的功能也很难发现。此外,UseCase 可以共享基类中的常见功能,例如线程和错误处理,这可以使规模更大的团队受益。

考虑前文提到的,将新闻与作者信息进行拼装的GetLatestNewsWithAuthorsUseCase

/**
 * This use case fetches the latest news and the associated author.
 */
class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository,
  private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend operator fun invoke(): List<ArticleWithAuthor> =
        withContext(defaultDispatcher) {
            val news = newsRepository.fetchLatestNews()
            val result: MutableList<ArticleWithAuthor> = mutableListOf()
            // ===> 由于这里没有并行,导致 UseCase 执行时非常耗时
            for (article in news) {
                // The repository exposes suspend functions
                val author = authorsRepository.getAuthor(article.authorId)
                result.add(ArticleWithAuthor(article, author))
            }
            result // ===> withContext 代码块需要最终显式返回
        }
}

仅仅在需要时才添加 Domain 层

在代码中一旦设计了 Domain 层,就必须在后续的逻辑中坚持使用它,而不是试图绕过。这是维护架构统一性所必须付出的代价,要么不用,要么一直用。

因此,请谨记仅仅在必需 Domain 层时才添加它。

参考资料