NowInAndroid 学习

924 阅读51分钟

NowInAndroid 学习

移动应用用户体验

典型的 Android 应用包含多个应用组件,包括 ActivityFragmentService内容提供程序广播接收器

常见的架构原则

随着 Android 应用大小不断增加,架构务必要能允许应用扩缩、提升应用的稳健性并且方便对应用进行测试。

应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。为了满足上述需求,应该按照某些特定原则设计应用架构。

分离关注点

要遵循的最重要的原则是分离关注点。 一种常见的错误是在一个 Activity 或 Fragment 中编写所有代码。这些基于界面的类应仅包含处理界面和操作系统交互的逻辑。您应使这些类尽可能保持精简,这样可以避免许多与组件生命周期相关的问题,并提高这些类的可测试性。

请注意,您并非拥有 Activity 和 Fragment 的实现;它们只是表示 Android 操作系统与应用之间关系的粘合类。操作系统可能会根据用户互动或因内存不足等系统条件随时销毁它们。

通过数据模型驱动界面

另一个重要原则是您应该通过数据模型驱动界面(最好是持久性模型)。数据模型代表应用的数据。它们独立于应用中的界面元素和其他组件。这意味着它们与界面和应用组件的生命周期没有关联,但仍会在操作系统决定从内存中移除应用的进程时被销毁。

持久性模型是理想之选,原因如下:

  • 如果 Android 操作系统销毁应用以释放资源,用户不会丢失数据。
  • 当网络连接不稳定或不可用时,应用会继续工作。

单一数据源

在应用中定义新数据类型时,您应为其分配单一数据源 (SSOT)。SSOT 是该数据的所有者**,而且只有此 SSOT 可以修改或转变该数据。为了实现这一点,SSOT 会以不可变类型公开数据;而且为了修改数据,SSOT 会公开函数或接收其他类型可以调用的事件。

单向数据流

在我们的指南中,单一数据源原则常常与单向数据流 (UDF) 模式一起使用。在 UDF 中,状态仅朝一个方向流动。修改数据的事件朝相反方向流动。

在 Android 中,状态或数据通常从分区层次结构中较高的分区类型流向较低的分区类型。事件通常在分区层次结构中较低的分区类型触发,直到其到达 SSOT 的相应数据类型。例如,应用数据通常从数据源流向界面。用户事件(例如按钮按下操作)从界面流向 SSOT,在 SSOT 中应用数据被修改并以不可变类型公开。

此模式可以更好地保证数据一致性,不易出错、更易于调试,并且具备 SSOT 模式的所有优势。

推荐的应用架构

基于上一部分提到的常见架构原则,每个应用应至少有两个层:

  • **界面层 - 在屏幕上显示应用数据。
  • **数据层 - 包含应用的业务逻辑并公开应用数据。

可以额外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。

image.png

现代应用架构

现代应用架构鼓励采用以下方法及其他一些方法:

  • 反应式分层架构。
  • 应用的所有层中的单向数据流 (UDF)。
  • 包含状态容器的界面层,用于管理界面的复杂性。
  • 协程和数据流。
  • 依赖项注入最佳实践。

界面层

界面层(或呈现层)的作用是在屏幕上显示应用数据。**每当数据发生变化时,无论是因为用户互动(例如按了某个按钮),还是因为外部输入(例如网络响应),界面都应随之更新,以反映这些变化。

界面层由以下两部分组成:

  • 在屏幕上呈现数据的界面元素。您可以使用 View 或 Jetpack Compose 函数构建这些元素。
  • 用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。

image.png

基本案例研究

让我们以一个可获取新闻报道供用户阅读的应用为例。该应用有一个报道屏幕,用于显示可供阅读的报道;另外,该应用允许已登录的用户为真正出众的报道添加书签。考虑到随时都可能有大量的报道,读者应能够按类别浏览报道。总的来说,该应用可让用户执行以下操作:

  • 查看可供阅读的报道。
  • 按类别浏览报道。
  • 登录帐号并为特定报道添加书签。
  • 使用部分收费功能(如果符合相应条件)。

image.png

界面层架构

“界面”这一术语是指用于显示数据的 activity 和 fragment 等界面元素,无论它们使用哪个 API(Views 还是 Jetpack Compose)来显示数据。**由于数据层的作用是存储和管理应用数据,以及提供对应用数据的访问权限,因此界面层必须执行以下步骤:

  1. 使用应用数据,并将其转换为界面可以轻松呈现的数据。
  2. 使用界面可呈现的数据,并将其转换为用于向用户呈现的界面元素。
  3. 使用来自这些组合在一起的界面元素的用户输入事件,并根据需要反映它们对界面数据的影响。
  4. 根据需要重复第 1-3 步。

本指南的其余部分展示了如何实现用于执行这些步骤的界面层。具体来说,本指南涵盖以下任务和概念:

  • 如何定义界面状态。
  • 单向数据流 (UDF),作为提供和管理界面状态的方式。
  • 如何根据 UDF 原则使用可观察数据类型公开界面状态。
  • 如何实现使用可观察界面状态的界面。

定义界面状态

界面会显示一个报道列表,以及每篇报道的部分元数据。该应用向用户显示的这些信息便是界面状态。

换言之,如果界面是相对用户而言的,那么界面状态就是相对应用而言的。这就像同一枚硬币的两面,界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。

image.png 为了满足“新闻”应用的要求,可以将完全呈现界面所需的信息封装在如下定义的 NewsUiState 数据类中:

不可变性

以上示例中的界面状态定义是不可变的。这样的主要好处是,不可变对象可保证即时提供应用的状态。这样一来,界面便可专注于发挥单一作用:读取状态并相应地更新其界面元素。因此,切勿直接在界面中修改界面状态,除非界面本身是其数据的唯一来源。违反这个原则会导致同一条信息有多个可信来源,从而导致数据不一致和轻微的 bug。

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

例如,如果案例研究中来自界面状态的 NewsItemUiState 对象中的 bookmarked 标记在 Activity 类中已更新,那么该标记会与数据层展开竞争,以争取成为报道的“已添加书签”状态的来源。不可变数据类对于防止此类反模式非常有用。

界面状态类是根据其描述的屏幕或部分屏幕的功能命名的。具体命名惯例如下:

功能 + UiState。****

例如,用于显示新闻的屏幕的状态可以称为 NewsUiState,新闻报道列表中的新闻报道的状态可以为 NewsItemUiState

使用单向数据流管理状态

上一部分中指出,界面状态是呈现界面所需的详细信息的不可变快照。不过,应用中数据的动态特性意味着状态可能会随时间而变化。这可能是因为用户互动,也可能是因为其他事件修改了用于填充应用的底层数据。

这些互动可以受益于处理它们的 mediator,从而定义要为每个事件应用的逻辑,并对后备数据源执行必要的转换,以便创建界面状态。这些互动及其逻辑可以位于界面本身中,但随着界面开始担任其名称所表明的角色以外的角色(数据所有者、提供方、转换器等),这可能很快就会变得难以掌控。此外,这可能会影响可测试性,因为生成的代码是紧密耦合的代码,没有可辨别的边界。归根结底,界面能够受益于减轻的负担。除非界面状态非常简单,否则界面的唯一职责应该是使用和显示界面状态。

本部分介绍了单向数据流 (UDF),这是一种架构模式,有助于强制实施这种健康的职责分离。

状态容器

符合以下条件的类称为状态容器:负责提供界面状态,并且包含执行相应任务所必需的逻辑。**状态容器有多种大小,具体取决于所管理的界面元素的作用域(从底部应用栏等单个微件,到整个屏幕或导航目的地,不一而足)。

在后一种情况下,典型的实现是 ViewModel 的实例,不过根据应用的要求,使用简单的类可能就足够了。例如,案例研究中的“新闻”应用使用 NewsViewModel 类作为状态容器,以便为该部分显示的屏幕画面提供界面状态。

要点ViewModel 类型是推荐的实现,用于管理屏幕级界面状态,具有数据层访问权限。此外,它会在配置发生变化后自动继续存在。ViewModel 类用于定义要为应用中的事件应用的逻辑,并提供更新后的状态作为结果。

以通过多种方式为界面与其状态提供方之间的互相依赖关系建模。不过,由于界面与其 ViewModel 类之间的互动在很大程度上可以理解为事件输入及其随后的状态输出,因此这种关系可以按下图所示来表示: 状态向下流动、事件向上流动的这种模式称为单向数据流 (UDF)。这种模式对应用架构的影响如下:

image.png

  • ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据。
  • 界面会向 ViewModel 发送用户事件通知。
  • ViewModel 会处理用户操作并更新状态。
  • 更新后的状态将反馈给界面以进行呈现。
  • 系统会对导致状态更改的所有事件重复上述操作。

对于导航目的地或屏幕,ViewModel 会使用存储库或用例类来获取数据并将其转换为界面状态,同时纳入可能会导致状态更改的事件的影响。前面提到的案例研究包含一个报道列表,其中每篇报道都有标题、说明、来源、作者名称、发布日期,以及是否添加了书签。每篇报道的界面如下所示:

image.png

用户请求为报道添加书签就是一个可能会导致状态更改的事件示例。作为状态提供方,ViewModel 的职责是定义所有必需的逻辑,以便填充界面状态中的所有字段,并处理界面完全呈现所需的事件。

image.png

逻辑类型

为报道添加书签就是一个业务逻辑示例,因为这能够为应用带来价值。如需了解详情,请参阅数据层页面。**不过,还有其他类型的重要逻辑需要定义:

  • 业务逻辑决定着如何处理状态变化。**如前面所述,一个例子是在案例研究应用中为报道添加书签。业务逻辑通常位于网域层或数据层中,但绝不能位于界面层中。
  • 界面行为逻辑界面逻辑决定着如何在屏幕上显示状态变化。**示例包括:使用 Android Resources 获取要在屏幕上显示的正确文本、在用户点击某个按钮时转到特定屏幕,或使用消息框信息提示控件在屏幕上向用户显示消息。

界面逻辑(尤其是在涉及 Context 等界面类型时)应位于界面中,而非 ViewModel 中。如果界面变得越来越复杂,并且您希望将界面逻辑委托给另一个类,以便有利于进行测试和关注点分离,您可以创建一个简单的类作为状态容器。在界面中创建的简单类可以采用 Android SDK 依赖项,因为它们遵循界面的生命周期;ViewModel 对象具有更长的生命周期。

为何使用 UDF?

UDF 可为状态提供周期建模。它还可以将以下位置分离开来:状态变化来源位置、转换位置以及最终使用位置。这种分离可让界面只发挥其名称所表明的作用:通过观察状态变化来显示信息,并通过将这些变化传递给 ViewModel 来传递用户 intent。

换句话说,UDF 有助于实现以下几点:

  • 数据一致性。界面只有一个可信来源。
  • 可测试性。状态来源是独立的,因此可独立于界面进行测试。
  • 可维护性。状态的更改遵循明确定义的模式,即状态更改是用户事件及其数据拉取来源共同作用的结果。

公开界面状态

定义界面状态并确定如何管理相应状态的提供后,下一步是将提供的状态发送给界面。由于您使用 UDF 管理状态的提供,因此您可以将提供的状态视为数据流,换句话说,随着时间的推移,将提供状态的多个版本。因此,您应在 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做是为了使界面可以对状态的任何变化做出反应,而无需直接从 ViewModel 手动拉取数据。这些类型还有一个好处是,始终缓存界面状态的最新版本,这对于在配置发生变化后快速恢复状态非常有用。

class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = …
}

注意:在 Jetpack Compose 应用中,您可以使用 Compose 的可观察状态 API(例如 mutableStateOf 或 snapshotFlow),以便公开界面状态。在 Compose 中,您可以通过适当的扩展程序,轻松使用本指南中提到的任何类型的可观察数据容器,例如 StateFlow 或 LiveData

如果向界面公开的数据相当简单,通常值得将数据封装在界面状态类型中,因为它能传达状态容器的发出与其关联的屏幕或界面元素之间的关系。此外,随着界面元素变得越来越复杂,添加界面状态的定义来容纳呈现界面元素所需的额外信息始终会更加容易。

创建 UiState 流的一种常用方法是,将后备可变数据流作为来自 ViewModel 的不可变数据流进行公开,例如将 MutableStateFlow<UiState> 作为 StateFlow<UiState> 进行公开。

class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

这样一来,ViewModel 便可以公开在内部更改状态的方法,以便发布供界面使用的更新。以需要执行异步操作的情况为例,可以使用 viewModelScope 启动协程,并且可以在操作完成时更新可变状态。

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}

在上面的示例中,NewsViewModel 类会尝试获取特定类别的报道,然后在界面状态中反映尝试结果(成功或失败),其中界面可以对其做出适当反应。

其他注意事项

除了前面的指南之外,公开界面状态时还要考虑以下事项:

  • 界面状态对象应处理彼此相关的状态。  这样可以减少不一致的情况,并让代码更易于理解。如果您在两个不同的数据流中分别公开新闻报道列表和书签数量,可能会发现其中一个已更新,但另一个没有更新。当您使用单个数据流时,这两个元素都会保持最新状态。此外,某些业务逻辑可能需要组合使用数据源。例如,可能只有在用户已登录并且是付费新闻服务订阅者时,您才需要显示书签按钮。**您可以按如下方式定义界面状态类:
data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf()
)

val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
  • 在此声明中,书签按钮的可见性是两个其他属性的派生属性。随着业务逻辑变得越来越复杂,拥有单个 UiState 类,并且其中的所有属性都是立即可用的,变得越来越重要。

  • 界面状态:单个数据流还是多个数据流? 是选择在单个数据流中还是在多个数据流中公开界面状态,关键指导原则是前面提到的要点:发出的内容之间的关系。在单个数据流中进行公开的最大优势是便捷性和数据一致性:状态的使用方随时都能立即获取最新信息。不过,在有些情况下,可能适合使用来自 ViewModel 的单独的状态流:

    • 不相关的数据类型:呈现界面所需的某些状态可能是完全相互独立的。在此类情况下,将这些不同的状态捆绑在一起的代价可能会超过其优势,尤其是当其中某个状态的更新频率高于其他状态的更新频率时。
    • UiState diffingUiState 对象中的字段越多,数据流就越有可能因为其中一个字段被更新而发出。由于视图没有 diffing 机制来了解连续发出的数据流是否相同,因此每次发出都会导致视图更新。这意味着,可能必须要对 LiveData 使用 Flow API 或 distinctUntilChanged() 等方法来缓解这个问题。

使用界面状态

如需在界面中使用 UiState 对象流,您可以对所使用的可观察数据类型使用终端运算符。例如,对于 LiveData,您可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。

在界面中使用可观察数据容器时,请务必考虑界面的生命周期。这非常重要,因为当未向用户显示视图时,界面不应观察界面状态。如需详细了解此主题,请参阅这篇博文。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用数据流时,最好通过适当的协程作用域和 repeatOnLifecycle API 来处理这一任务:

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

注意:在没有处于活跃状态的收集器时,本示例中使用的具体 StateFlow 对象不会停止执行工作,但您在处理数据流时,可能并不知道它们是如何实现的。借助生命周期感知型数据流收集功能,您可以在以后对 ViewModel 数据流进行这些类型的更改,而无需重新访问下游收集器代码。

显示正在执行的操作

在 UiState 类中表示加载状态的一种简单方法是使用布尔值字段:

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

此标记的值表示界面中是否存在进度条。

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

在屏幕上显示错误

在界面中显示错误与显示正在执行的操作类似,因为无论是错误,还是正在执行的操作,都能通过用于表明它们是否存在的布尔值来轻松表示。不过,错误可能还包括要传回给用户的关联消息,或包含与其关联的操作(旨在重试失败的操作)。因此,无论正在执行的操作是否正在加载,可能都需要使用托管以下数据的数据类对错误状态进行建模:适合错误上下文的元数据。

以上一部分中的示例为例,它在获取报道时会显示进度条。如果此操作导致错误,您可能希望向用户显示一条或多条消息,详细说明出现了什么错误。

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

然后,错误消息便能够以界面元素(例如信息提示控件)的形式呈现给用户。

线程处理和并发

在 ViewModel 中执行的所有工作都应具有主线程安全性(即从主线程调用是安全的)。**这是因为数据层和网域层负责将工作移至其他线程。

如果 ViewModel 执行长时间运行的操作,则还要负责将相应逻辑移至后台线程。Kotlin 协程是管理并发操作的绝佳方式,Jetpack 架构组件则为其提供内置支持。

导航

应用导航的变化通常是由类似于事件的发出操作驱动的。例如,在 SignInViewModel 类执行登录后,UiState 可能会有一个 isSignedIn 字段被设为 true。此类触发器的使用方式应与上面使用界面状态部分介绍的方式相同,不过使用实现应遵从导航组件

Paging

Paging 库通过一个称为 PagingData 的类型在界面中使用。由于 PagingData 表示并包含可以随时间变化的内容(换句话说,它不是不可变类型),因此它不应以不可变界面状态表示。相反,您应在单独的流中独立地从 ViewModel 中公开它。

动画

为了提供流畅的顶级导航过渡,您可能需要等待第二个屏幕加载数据,然后再启动动画。Android 视图框架提供了一些钩子,以便通过 postponeEnterTransition() 和 startPostponedEnterTransition() API 延迟 fragment 目的地之间的过渡。这些 API 提供了一种方法来确保做到以下一点:在界面通过动画过渡到第二个屏幕之前,第二个屏幕上的界面元素(通常是从网络获取的图片)已做好显示准备。

数据层

应用的数据层包含业务逻辑。业务逻辑决定应用的价值,它包含决定应用如何创建、存储和更改数据的规则。

数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。** **您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。例如,您可以为与电影相关的数据创建一个 MoviesRepository 类,或者为与付款相关的数据创建一个 PaymentsRepository 类。

image.png

存储库类负责以下任务:

  • 向应用的其余部分公开数据。
  • 集中处理数据变化。
  • 解决多个数据源之间的冲突。
  • 对应用其余部分的数据源进行抽象化处理。
  • 包含业务逻辑。

每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。数据源类是应用与数据操作系统之间的桥梁。

层次结构中的其他层绝不能直接访问数据源;数据层的入口点始终是存储库类。状态容器类(请参阅界面层指南)或用例类(请参阅网域层指南)绝不能将数据源作为直接依赖项。如果使用存储库类作为入口点,架构的不同层便可以独立扩缩。

该层公开的数据应该是不可变的,这样就可以避免数据被其他类篡改,从而避免数值不一致的风险。不可变数据也可以由多个线程安全地处理。如需了解详情,请参阅线程处理部分

按照依赖项注入方面的最佳实践,存储库应在其构造函数中将数据源作为依赖项:

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

注意:通常,如果存储库只包含单个数据源并且不依赖于其他存储库,开发者会将存储库和数据源的职责合并到存储库类中。这种情况下,在应用的更高版本中,如果存储库需要处理来自其他来源的数据,请不要忘记拆分这些功能。

公开 API

数据层中的类通常会公开函数,以执行一次性的创建、读取、更新和删除 (CRUD) 调用,或接收关于数据随时间变化的通知。对于每种情况,数据层都应公开以下内容:

  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数,或公开 RxJava SingleMaybe 或 Completable 类型。
  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流;对于 Java 编程语言,数据层应公开用于发出新数据的回调,或公开 RxJava Observable 或 Flowable 类型。
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

存储库类以其负责的数据命名。具体命名惯例如下:

数据类型 + Repository。****

例如:NewsRepositoryMoviesRepository 或 PaymentsRepository

数据源类以其负责的数据以及使用的来源命名。具体命名惯例如下:

数据类型 + 来源类型 + DataSource。******

对于数据的类型,可以使用 Remote 或 Local,以使其更加通用,因为实现是可以变化的。例如:NewsRemoteDataSource 或 NewsLocalDataSource。在来源非常重要的情况下,为了更加具体,可以使用来源的类型。例如:NewsNetworkDataSource 或 NewsDiskDataSource

请勿根据实现细节来为数据源命名(例如 UserSharedPreferencesDataSource),因为使用相应数据源的存储库应该不知道数据是如何保存的。如果您遵循此规则,便可以更改数据源的实现(例如,从 SharedPreferences 迁移到 DataStore),而不会影响调用相应数据源的层。

注意:迁移到数据源的新实现时,您可以为数据源创建接口,并使用两种数据源实现:一种用于旧的后备技术,另一种用于新的技术。在这种情况下,您可以将技术名称用作数据源类名称(尽管它是一个实现细节),因为存储库只能看到接口,而看不到数据源类本身。完成迁移后,您可以重命名新类,使其名称中不包含实现细节。

多层存储库

在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。这可能是因为所涉及的数据是来自多个数据源的数据聚合,或者是因为相应职责需要封装在其他存储库类中。

例如,负责处理用户身份验证数据的存储库 UserRepository 可以依赖于其他存储库(例如 LoginRepository 和 RegistrationRepository),以满足其要求。

image.png

可信来源

每个存储库都只定义单个可信来源,这一点非常重要。可信来源始终包含一致、正确且最新的数据。实际上,从存储库公开的数据应始终是直接来自可信来源的数据。

可信来源可以是数据源(例如数据库),甚至可以是存储库可能包含的内存中缓存。存储库可合并不同的数据源,并解决数据源之间的所有潜在冲突,以便定期更新或因应用户输入事件更新单个可信来源。

应用中的不同存储库可以具有不同的可信来源。例如,LoginRepository 类可以将其缓存用作可信来源,PaymentsRepository 类则可以使用网络数据源。

为了提供离线优先支持,建议使用本地数据源(例如数据库)作为可信来源

线程处理

调用数据源和存储库应该具有主线程安全性(即从主线程调用是安全的)。**在执行长时间运行的阻塞操作时,这些类负责将其逻辑的执行移至适当的线程。例如,对于数据源,从文件读取数据应该具有主线程安全性;对于存储库,对大列表执行非常耗费资源的过滤应该具有主线程安全性。

请注意,大部分数据源都已提供具有主线程安全性的 API,例如 Room 或 Retrofit 提供的挂起方法调用。在这些 API 可用时,您的存储库可以充分利用它们。

生命周期

数据层中的类的实例会保留在内存中,前提是它们可以从垃圾回收根访问 - 通常是从应用中的其他对象引用。

如果某个类包含内存中的数据(例如缓存),您可能希望在特定时间段内重复使用该类的同一实例。这也称为类实例的生命周期。**

如果该类的职责对于整个应用至关重要,您可以将该类的实例的作用域限定为 Application 类。**这可让该实例遵循应用的生命周期。或者,如果您只需要在应用内的特定流程(例如注册流程或登录流程)中重复使用同一实例,则应将该实例的作用域限定为负责相应流程的生命周期的类。例如,您可以将包含内存中数据的 RegistrationRepository 的作用域限定为 RegistrationActivity,或限定为注册流程的导航图

每个实例的生命周期都是决定如何在应用内提供依赖项的关键因素。建议您遵循依赖项注入方面的最佳实践来管理依赖项,并可以将依赖项的作用域限定为依赖项容器。

表示业务模式

您想要从数据层公开的数据模型可能是您从不同数据源获取的信息的子集。理想情况下,不同数据源(网络数据源和本地数据源)应该只返回应用需要的信息;但通常并非如此。

例如,假设有一个 News API 服务器,它不仅返回报道信息,还会返回修改记录、用户评论和部分元数据:

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

该应用不需要这么多关于报道的信息,因为它在屏幕上只显示报道内容,以及关于作者的基本信息。一种很好的做法是,分离模型类,并让存储库仅公开层次结构的其他层所需的数据。例如,以下代码段展示了您可以如何从网络中删减 ArticleApiModel,以便将 Article 模型类公开给网域层和界面层:

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

分离模型类可以带来以下好处:

  • 将数据减少到只包含需要的内容,从而节省应用内存。
  • 根据应用所使用的数据类型来调整外部数据类型 - 例如,应用可以使用不同的数据类型来表示日期。
  • 更好地分离关注点 - 例如,如果预先定义了模型类,大型团队的成员便可以在功能的网络层和界面层单独开展工作。

您可以扩展这种做法,并可以在应用架构的其他部分(例如,在数据源类和 ViewModel 中)定义单独的模型类。不过,这需要您定义额外的类和逻辑,并且您应正确记录和测试这些类和逻辑。至少,我们建议您在数据源接收的数据与应用其余部分所需的数据不符时,创建新模型。

数据操作类型

数据层可以处理的操作类型会因操作的重要程度而异:面向界面的操作、面向应用的操作和面向业务的操作。

面向界面的操作

面向界面的操作仅在用户位于特定屏幕上时才相关,当用户离开相应屏幕时便会被取消。例如,显示从数据库获取的部分数据。

面向界面的操作通常由界面层触发,并且遵循调用方的生命周期,例如 ViewModel 的生命周期。如需查看面向界面的操作的示例,请参阅发出网络请求部分。

面向应用的操作

只要应用处于打开状态,面向应用的操作就一直相关。如果应用关闭或进程终止,这些操作将会被取消。例如,缓存网络请求结果,以便在以后需要时使用。如需了解详情,请参阅实现内存中数据缓存部分。

这些操作通常遵循 Application 类或数据层的生命周期。如需查看示例,请参阅让操作拥有比屏幕更长的生命周期部分。

面向业务的操作

面向业务的操作无法取消。它们应该会在进程终止后继续执行。例如,完成上传用户想要发布到其个人资料的照片。

对于面向业务的操作,建议使用 WorkManager。如需了解详情,请参阅使用 WorkManager 调度任务部分。

公开错误

与存储库和数据源的互动可能会成功,也可能会在出现故障时抛出异常。对于协程和数据流,您应使用 Kotlin 的内置错误处理机制。对于可以由挂起函数触发的错误,可以在适当时使用 try/catch 块;在数据流中,可以使用 catch 运算符。如果使用这种方式,界面层应负责处理在调用数据层时出现的异常。

数据层可以理解和处理不同类型的错误,并可以使用自定义异常(例如 UserNotAuthenticatedException)公开这些错误。

注意:若要为与数据层的互动结果建模,另一种方法是使用 Result 类。此模式会为在处理结果时可能出现的错误和其他信号进行建模。在此模式中,数据层会返回 Result<T> 类型,而非 T,以便让界面知道在特定情况下可能发生的已知错误。**对于没有适当异常处理机制的反应式编程 API(例如 LiveData)来说,必须要使用这种方法。

常见任务

以下几个部分举例说明了如何使用和构建数据层来执行 Android 应用中常见的特定任务。这些示例基于本指南前面提到的典型“新闻”应用。

发出网络请求

发出网络请求是 Android 应用可能执行的最常见任务之一。“新闻”应用需要向用户提供从网络获取的最新新闻。因此,该应用需要一个数据源类来管理网络操作:NewsRemoteDataSource。为了向该应用的其余部分公开信息,我们创建了一个用于处理新闻数据操作的新存储库:NewsRepository

该应用需要满足的要求是,当用户打开屏幕时,该应用一律需要更新最新新闻。因此,这是一项面向界面的操作。**

创建数据源

数据源需要公开一个用于返回最新新闻(ArticleHeadline 实例的列表)的函数。数据源需要提供一种具有主线程安全性的方式,以便从网络获取最新新闻。为此,它需要依赖于 CoroutineDispatcher 或 Executor 来运行任务。

发出网络请求是由新的 fetchLatestNews() 方法处理的一次性调用:

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }
}

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

NewsApi 接口会隐藏网络 API 客户端的实现;接口是由 Retrofit 还是由 HttpURLConnection 提供支持,并没有区别。依赖于接口能够使 API 实现在应用中可交换。

要点依赖于接口能够使 API 实现在应用中可交换。 除了提供可扩缩性并可让您更轻松地替换依赖项之外,这还有利于进行测试,因为您可以在测试时注入虚构的数据源实现。

创建存储库

存储库类中不需要任何额外的逻辑,即可执行此任务,因此 NewsRepository 可充当网络数据源的代理。内存中缓存部分介绍了添加这一额外抽象层的好处。

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

实现内存中数据缓存

假设为“新闻”应用引入了一项新的要求:当用户打开屏幕时,如果用户之前已发出请求,那么该应用必须向用户显示缓存的新闻。否则,该应用应发出网络请求以获取最新新闻。

鉴于这项新的要求,当用户已打开该应用时,该应用必须在内存中保留最新新闻。因此,这是一项面向应用的操作。**

缓存

通过添加内存中数据缓存,您可以在用户位于您的应用中时保留数据。缓存旨在使一些信息在内存中保存特定的时间长度,在此示例中,只要用户位于该应用中,就一直保存相应信息。缓存实现可以采用不同的形式。从简单的可变变量,到更为复杂、可以防止在多个线程上进行读/写操作的类,不一而足。可以在存储库中实现缓存,也可以在数据源类中实现缓存,具体取决于用例。

缓存网络请求结果

为了简单起见,NewsRepository 使用可变变量来缓存最新新闻。为了保护来自不同线程的读取和写入操作,我们使用了 Mutex。如需详细了解共享的可变状态和并发,请参阅 Kotlin 文档

以下实现会将最新新闻信息缓存到存储库中的一个变量,该变量由 Mutex 提供写保护。如果网络请求结果是成功,数据将分配给 latestNews 变量。

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

让操作拥有比屏幕更长的生命周期

如果用户在网络请求正在进行时离开屏幕,系统将取消该请求,并且不会缓存结果。NewsRepository 不应使用调用方的 CoroutineScope 来执行此逻辑。NewsRepository 应使用附加到其生命周期的 CoroutineScope获取最新新闻必须是面向应用的操作。

为了遵循依赖项注入方面的最佳实践,NewsRepository 应在其构造函数中接收一个作用域作为参数,而不是创建自己的 CoroutineScope。由于存储库应在后台线程中执行大部分工作,因此您应使用 Dispatchers.Default 或您自己的线程池来配置 CoroutineScope

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

由于 NewsRepository 已准备好使用外部 CoroutineScope 来执行面向应用的操作,因此它必须调用数据源,并使用由相应作用域启动的新协程来保存其结果:

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        }
    }
}

async 用于在外部作用域内启动协程。await 在新的协程上调用,以便在网络请求返回结果并且结果保存到缓存中之前,一直保持挂起状态。如果届时用户仍位于屏幕上,就会看到最新新闻;如果用户已离开屏幕,await 将被取消,但 async 内部的逻辑将继续执行。

如需详细了解 CoroutineScope 的模式,请参阅这篇博文

将数据保存到磁盘以及从磁盘检索数据

假设您要保存一些数据,例如添加了书签的新闻和用户偏好设置。这种类型的数据需要在进程终止后继续保留,并且即使用户未连接到网络,也必须可供用户访问。

如果您处理的数据需要在进程终止后继续保留,则您需要通过以下方式之一将其存储在磁盘上:

  • 对于需要查询、需要实现引用完整性或需要部分更新的大型数据集,请将数据保存在 Room 数据库中。** **在“新闻”应用示例中,新闻报道或作者信息可以保存在该数据库中。
  • 对于只需要检索和设置(不需要查询,也不需要部分更新)的小型数据集,请使用 DataStore。** **在“新闻”应用示例中,用户的首选日期格式或其他显示偏好设置可以保存在 DataStore 中。
  • 对于数据块(例如 JSON 对象),可以使用文件。****

可信来源部分所述,每个数据源都只能处理一个来源,并且与特定的数据类型(例如 NewsAuthorsNewsAndAuthors 或 UserPreferences)相对应。使用数据源的类应该不知道数据是如何保存的,例如是保存在数据库中,还是保存在文件中。

使用 Room 作为数据源

由于每个数据源都应只负责处理一种特定类型的数据的一个数据源,因此 Room 数据源会接收数据访问对象 (DAO) 或数据库本身作为参数。例如,NewsLocalDataSource 可以接收 NewsDao 的实例作为参数,AuthorsLocalDataSource 则可以接收 AuthorsDao 的实例。

在某些情况下,如果不需要额外的逻辑,您可以直接将 DAO 注入存储库,因为 DAO 是一种可以在测试中轻松替换的接口。

如需详细了解如何使用 Room API,请参阅 Room 指南

使用 DataStore 作为数据源

DataStore 非常适合存储键值对,例如用户设置,具体示例可能包括时间格式、通知偏好设置,以及是显示还是隐藏用户已阅读的新闻报道。DataStore 还可以使用协议缓冲区来存储类型化对象。

与任何其他对象一样,由 DataStore 提供支持的数据源应包含与特定类型相对应或与应用的特定部分相对应的数据。对于 DataStore 来说更是如此,因为 DataStore 读取操作会作为一个每次值更新后都会发出的数据流进行公开。因此,您应将相关偏好设置存储在同一个 DataStore 中。

例如,您可以创建一个仅处理通知相关偏好设置的 NotificationsDataStore,并创建一个仅处理新闻屏幕相关偏好设置的 NewsPreferencesDataStore。这样,您就可以更好地限定更新作用域,因为只有当与相应屏幕相关的偏好设置发生变化时,newsScreenPreferencesDataStore.data 流才会发出。这也意味着,该对象的生命周期可以更短,因为它只能在新闻屏幕显示时存在。

如需详细了解如何使用 DataStore API,请参阅 DataStore 指南

使用文件作为数据源

处理大型对象(例如 JSON 对象或位图)时,您需要使用 File 对象并处理线程切换。

如需详细了解如何使用文件存储空间,请参阅存储空间概览页面。

使用 WorkManager 调度任务

假设为“新闻”应用引入了一项新的要求:只要设备正在充电并且已连接到不按流量计费的网络,该应用就必须为用户提供用于选择定期自动获取最新新闻的选项。这会使此操作成为一项面向业务的操作。**如果该应用实现了这一要求,那么在用户打开该应用时,即使设备没有连接到网络,用户仍然可以看到最近的新闻。

借助 WorkManager,可以轻松调度异步的可靠工作,并可以负责管理约束条件。我们建议使用该库执行持久性工作。为了执行上面定义的任务,我们创建了一个 Worker 类:FetchLatestNewsWorker。此类以 NewsRepository 作为依赖项,以便获取最新新闻并将其缓存到磁盘中。

class RefreshLatestNewsWorker(
    private val newsRepository: NewsRepository,
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {
        newsRepository.refreshLatestNews()
        Result.success()
    } catch (error: Throwable) {
        Result.failure()
    }
}

此类任务的业务逻辑应封装在其自己的类中,并且应被视为单独的数据源。这样一来,WorkManager 将仅负责确保工作会在所有约束条件都得到满足时在后台线程中执行。通过遵循此模式,您可以根据需要在不同环境中快速交换实现。

在此示例中,必须从 NewsRepository 调用这个与新闻相关的任务,前者会将一个新的数据源作为依赖项:NewsTasksDataSource。实现方式如下:

private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(
    private val workManager: WorkManager
) {
    fun fetchNewsPeriodically() {
        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
            REFRESH_RATE_HOURS, TimeUnit.HOURS
        ).setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
                .setRequiresCharging(true)
                .build()
        )
            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(
            FETCH_LATEST_NEWS_TASK,
            ExistingPeriodicWorkPolicy.KEEP,
            fetchNewsRequest.build()
        )
    }

    fun cancelFetchingNewsPeriodically() {
        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
    }
}

这些类型的类以其负责的数据命名,例如 NewsTasksDataSource 或 PaymentsTasksDataSource。与特定类型的数据相关的所有任务都应封装在同一个类中。

如果任务需要在应用启动时触发,建议使用从 Initializer 调用存储库的 App Startup 库触发 WorkManager 请求。

如需详细了解如何使用 WorkManager API,请参阅 WorkManager 指南

测试

遵循依赖项注入方面的最佳实践有助于您测试自己的应用。对于与外部资源进行通信的类,依赖于接口也很有帮助。测试某个单元时,您可以注入其依赖项的虚构版本,以使测试具有确定性和可靠性。

单元测试

测试数据层时,请遵循常规测试指南。对于单元测试,可以在需要时使用真实对象,并虚构所有会联系外部来源(例如从文件读取内容或从网络读取内容)的依赖项。

集成测试

需要访问外部来源的集成测试往往不太具有确定性,因为它们需要在实际设备上运行。建议您在受控环境中执行这些测试,以便使集成测试更加可靠。

对于数据库,Room 允许创建一个您可以在测试时完全控制的内存中数据库。如需了解详情,请参阅测试和调试数据库页面。

对于网络,有一些常用的库(例如 WireMock 或 MockWebServer)可用于虚构 HTTP 和 HTTPS 调用并验证请求是否已按预期发出。

网域层

网域层是位于界面与数据层之间的可选层。

网域层负责封装复杂的业务逻辑,或者由多个 ViewModel 重复使用的简单业务逻辑。此层是可选的,因为并非所有应用都有这类需求。请仅在需要时使用该层,例如处理复杂逻辑或支持可重用性。

image.png

此层中的类通常称为“用例”或“交互方”。** **每个用例都应仅负责单个功能。**例如,如果多个 ViewModel 依赖时区在屏幕上显示适当的消息,则您的应用可能具有 GetTimeZoneUseCase 类。

网域层具有以下优势:

  • 避免代码重复。
  • 改善使用网域层类的类的可读性。
  • 改善应用的可测试性。
  • 让您能够划分好职责,从而避免出现大型类。

为了使这些类保持简单轻量化,每个用例都应仅负责单个功能,且不应包含可变数据。您应在界面或数据层中处理可变的数据。

依赖关系

在典型的应用架构中,用例类适合界面层的 ViewModel 与数据层的代码库。这意味着用例类通常依赖于代码库类,并且它们与界面层的通信方式与代码库的通信方式相同 - 使用回调(Java 代码)或协程(Kotlin 代码)。如需了解详情,请参阅数据层页面

例如,在您的应用中,可能会有一个用例类,用于从新闻代码库和作者代码库中提取数据并对它们进行组合:

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

由于用例包含可重复使用的逻辑,因此其他用例也可以使用这些用例。在网域层有多个用例层级是正常现象。例如,如果界面层中的多个类依赖时区在屏幕上显示适当的消息,则以下示例中定义的用例可以使用 FormatDateUseCase 用例:

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

image.png

调用 Kotlin 中的用例

在 Kotlin 中,您可以通过使用 operator 修饰符定义 invoke() 函数,将用例类实例作为函数进行调用。请参阅以下示例:

class FormatDateUseCase(userRepository: UserRepository) {

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

    operator fun invoke(date: Date): String {
        return formatter.format(date)
    }
}

在此示例中,您可以通过 FormatDateUseCase 中的 invoke() 方法将类的实例作为函数一样调用。invoke() 方法不限于任何特定签名,它可以接受任意数量的参数并返回任何类型。您还可以在类中使用不同的签名使 invoke() 重载。您可以调用上述示例中的用例,如下所示:

class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {
    init {
        val today = Calendar.getInstance()
        val todaysDate = formatDateUseCase(today)
        /* ... */
    }
}

生命周期

用例没有自己的生命周期,而是受限于使用它们的类。这意味着,您可以从界面层中的类、服务或 Application 类本身调用用例。由于用例不应包含可变数据,因此您每次将用例类作为依赖项传递时,都应该创建一个新实例。

线程处理

来自网域层的用例必须具有主线程安全性;换句话说,它们必须能安全地从主线程调用。如果用例类执行长期运行的阻塞操作,那么它们负责将该逻辑移至适当的线程。不过,在执行此操作之前,请检查这些阻塞操作是否最好放置在层次结构的其他层中。通常,数据层中会进行复杂的计算,以促进可重用性或缓存。例如,如果某项结果需要缓存起来,以便在应用的多个屏幕上重复使用,那么在数据层中对大列表执行资源密集型操作比在网域层中执行会更好。

以下示例显示了一个在后台线程上执行工作的用例:

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

    suspend operator fun invoke(...) = withContext(defaultDispatcher) {
        // Long-running blocking operations happen on a background thread.
    }
}

常见任务

本部分介绍如何执行常见网域层任务。

可重复使用的简单业务逻辑

您应将界面层中存在的可重复业务逻辑封装到用例类中。这样您就可以更轻松地在使用该逻辑的所有位置应用任何更改,还可以单独测试逻辑。

考虑前面介绍的 FormatDateUseCase 示例。如果将来关于数据格式的业务要求发生变化,您只需在一个地方更改代码。

注意:在某些情况下,用例中可能存在的逻辑可以成为 Util 类中静态方法的一部分。不过,不建议采用后者,因为 Util 类通常很难找到,而且其功能也很难发现。此外,用例还可以共享通用功能(例如基类中的线程处理和错误处理),这对规模较大的大型团队很有助益。

合并代码库

在新闻应用中,您可能拥有分别处理新闻和作者数据操作的 NewsRepository 和 AuthorsRepository 类。NewsRepository 提供的 Article 类仅包含作者的姓名,但您希望在屏幕上显示关于作者的更多信息。作者信息可通过 AuthorsRepository 获取。

image.png

由于该逻辑涉及多个代码库并且可能会变得很复杂,因此您可以创建 GetLatestNewsWithAuthorsUseCase 类,将逻辑从 ViewModel 中提取出来并提高其可读性。这也使得逻辑更易于单独测试,并且可在应用的不同部分重复使用。

/**
 * 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()
            // This is not parallelized, the use case is linearly slow.
            for (article in news) {
                // The repository exposes suspend functions
                val author = authorsRepository.getAuthor(article.authorId)
                result.add(ArticleWithAuthor(article, author))
            }
            result
        }
}

该逻辑会映射 news 列表中的所有项;因此,即使数据层是主线程安全的,此工作不应该阻止主线程,因为您并不知道它会处理多少项。正因如此,该用例使用默认调度程序将工作移到后台线程。

注意:借助 Room 库,您可以查询数据库中不同实体之间的关系。如果数据库是可信来源,您可以创建一个查询,让系统为您执行所有操作。在这种情况下,最好创建代码库类(例如 NewsWithAuthorsRepository),而不是用例。

管理组件之间的依赖关系

应用中的类要依赖其他类才能正常工作。您可以使用以下任一设计模式来收集特定类的依赖项:

  • 依赖注入 (DI):依赖注入使类能够定义其依赖项而不构造它们。在运行时,另一个类负责提供这些依赖项。
  • 服务定位器:服务定位器模式提供了一个注册表,类可以从中获取其依赖项而不构造它们。

您可以借助这些模式来扩展代码,因为它们可提供清晰的依赖项管理模式(无需复制代码,也不会增添复杂性)。 此外,您还可以借助这些模式在测试和生产实现之间快速切换。

我们建议在 Android 应用中采用依赖项注入模式并使用 Hilt 库 Hilt 通过遍历依赖项树自动构造对象,为依赖项提供编译时保证,并为 Android 框架类创建依赖项容器。

常见的最佳实践

虽然以下建议不是强制性的,但在大多数情况下,遵循这些建议会使您的代码库更强大、可测试性更高且更易维护:

不要将数据存储在应用组件中。

请避免将应用的入口点(如 activity、Service 和广播接收器)指定为数据源。相反,您应只将其与其他组件协调,以检索与该入口点相关的数据子集。每个应用组件存在的时间都很短暂,具体取决于用户与其设备的交互情况以及系统当前的整体运行状况。

减少对 Android 类的依赖

您的应用组件应该是唯一依赖于 Android 框架 SDK API(例如 Context 或 Toast)的类。将应用中的其他类与这些类分离开来有助于改善可测试性,并减少应用中的耦合

在应用的各个模块之间设定明确定义的职责界限。

例如,请勿在代码库中将从网络加载数据的代码散布到多个类或软件包中。同样,也不要将不相关的职责(如数据缓存和数据绑定)定义到同一个类中。遵循推荐的应用架构可以帮助您解决此问题。

尽量少公开每个模块中的代码。

例如,请勿试图创建从模块提供内部实现细节的快捷方式。短期内,您可能会省点时间,但随着代码库的不断发展,您可能会反复陷入技术上的麻烦。

专注于应用的独特核心,以使其从其他应用中脱颖而出。

不要一次又一次地编写相同的样板代码,这是在做无用功。 相反,您应将时间和精力集中放在能让应用与众不同的方面上,并让 Jetpack 库以及建议的其他库处理重复的样板。

考虑如何使应用的每个部分可独立测试。

例如,如果使用明确定义的 API 从网络获取数据,将会更容易测试在本地数据库中保留该数据的模块。如果您将这两个模块的逻辑混放在一处,或将网络代码分散在整个代码库中,那么即便能够进行有效测试,难度也会大很多。

类型负责其并发政策

如果某种类型正在执行长时间运行的阻塞工作,则应负责将该计算移至正确的线程。该特定类型知道它正在执行的计算类型及其应在哪个线程中执行。类型应该具有主线程安全性,这意味着,您可以安全地从主线程调用这些类型而不会阻塞。

保留尽可能多的相关数据和最新数据

这样,即使用户的设备处于离线模式,他们也可以使用您应用的功能。请记住,并非所有用户都能享受到稳定的高速连接 - 即使有时可以使用,在比较拥挤的地方网络信号也可能不佳。

架构的优势

在应用中实现良好的架构会为项目和工程团队带来诸多好处:

  • 提高整个应用的可维护性、质量和稳健性。
  • 允许应用扩缩。尽可能减少代码冲突,使更多人和更多团队可以为同一代码库做贡献。
  • 有助于新手上手。架构能使您的项目保持一致性,让团队中的新成员可以快速上手,并在更短时间内提高效率。
  • 更易于测试。良好的架构鼓励使用更简单的类型,这些类型通常更易于测试。
  • 可以使用明确定义的流程有条理地调查 bug。