UI 层

UI 的作用是在屏幕上显示应用数据,并作为用户交互的主要入口点。无论数据何时发生变化(无论是由于用户交互(例如按下按钮)还是外部输入(例如网络响应)),UI 都应更新以反映这些变化。实际上,UI 是从数据层检索到的应用状态的视觉表示。

但是,您从数据层获取的应用数据通常与您需要显示的信息格式不同。例如,您可能只需要部分数据用于 UI,或者您可能需要合并两个不同的数据源以呈现与用户相关的信息。无论您应用何种逻辑,您都需要将 UI 完全渲染所需的所有信息传递给它。UI 层是将应用数据更改转换为 UI 可以呈现的形式,然后显示它的管道。

In a typical architecture, the UI layer's UI elements depend on state
    holders, which in turn depend on classes from either the data layer or the
    optional domain layer.
图 1. UI 层在应用架构中的作用。

基本案例研究

考虑一个为用户获取新闻文章以供阅读的应用。该应用有一个文章屏幕,可以显示可供阅读的文章,还允许已登录的用户收藏特别出色的文章。鉴于任何时候都可能有大量文章,读者应该能够按类别浏览文章。总而言之,该应用允许用户执行以下操作:

  • 查看可供阅读的文章。
  • 按类别浏览文章。
  • 登录并收藏特定文章。
  • 如果符合条件,访问某些高级功能。
图 2. 用于 UI 案例研究的示例新闻应用。

以下各节将此示例作为案例研究,介绍单向数据流的原则,并阐明这些原则在 UI 层应用架构背景下帮助解决的问题。

UI 层架构

术语 UI 指的是显示数据的 UI 元素,例如 Activity 和 Fragment,无论它们使用哪种 API 来实现(Views 或 Jetpack Compose)。由于数据层的作用是持有、管理和提供对应用数据的访问,因此 UI 层必须执行以下步骤:

  1. 使用应用数据并将其转换为 UI 可以轻松渲染的数据。
  2. 使用可供 UI 渲染的数据并将其转换为 UI 元素以呈现给用户。
  3. 从这些组装好的 UI 元素中消费用户输入事件,并根据需要在 UI 数据中反映其效果。
  4. 根据需要重复步骤 1 到 3。

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

  • 如何定义 UI 状态。
  • 作为生成和管理 UI 状态的手段的单向数据流 (UDF)。
  • 如何根据 UDF 原则使用可观测数据类型公开 UI 状态。
  • 如何实现消费可观测 UI 状态的 UI。

其中最基本的是 UI 状态的定义。

定义 UI 状态

参考前面概述的案例研究。简而言之,UI 显示一个文章列表以及每篇文章的一些元数据。应用呈现给用户的这些信息就是 UI 状态。

换句话说:如果 UI 是用户看到的,那么 UI 状态就是应用告诉用户应该看到的。就像一枚硬币的两面,UI 是 UI 状态的视觉表示。UI 状态的任何更改都会立即反映在 UI 中。

UI is a result of binding UI elements on the screen with the UI state.
图 3. UI 是将屏幕上的 UI 元素与 UI 状态绑定的结果。

考虑案例研究;为了满足新闻应用的要求,完全渲染 UI 所需的信息可以封装在一个 NewsUiState 数据类中,定义如下:

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,
    ...
)

不可变性

上述示例中的 UI 状态定义是不可变的。这样做的主要好处是,不可变对象可以保证应用程序在某个时间点的状态。这使得 UI 能够专注于一个单一的角色:读取状态并相应地更新其 UI 元素。因此,除非 UI 本身是其数据的唯一来源,否则您不应直接在 UI 中修改 UI 状态。违反此原则会导致同一信息存在多个事实来源,从而导致数据不一致和细微错误。

例如,如果在案例研究中 UI 状态的 NewsItemUiState 对象中的 bookmarked 标志在 Activity 类中进行了更新,则该标志将与数据层竞争,成为文章收藏状态的来源。不可变数据类对于防止这种反模式非常有用。

本指南中的命名约定

在本指南中,UI 状态类的命名基于它们所描述的屏幕或屏幕部分的功。约定如下:

功能 + UiState

例如,显示新闻的屏幕状态可能称为 NewsUiState,新闻列表中的新闻项状态可能称为 NewsItemUiState

使用单向数据流管理状态

上一节指出,UI 状态是 UI 渲染所需详细信息的不可变快照。然而,应用中数据的动态特性意味着状态可能会随时间变化。这可能是由于用户交互或修改用于填充应用的基础数据的其他事件所致。

这些交互可能受益于一个中介来处理它们,定义应用于每个事件的逻辑,并对后台数据源执行必要的转换以创建 UI 状态。这些交互及其逻辑可能存在于 UI 本身中,但这会很快变得难以管理,因为 UI 开始不仅仅是其名称所暗示的:它成为数据所有者、生产者、转换器等等。此外,这会影响可测试性,因为生成的代码是紧密耦合的混合体,没有可识别的边界。最终,UI 将受益于负担的减轻。除非 UI 状态非常简单,否则 UI 的唯一职责应该是消费和显示 UI 状态。

本节讨论单向数据流 (UDF),这是一种有助于强制执行这种健康的职责分离的架构模式。

状态持有者

负责生成 UI 状态并包含该任务所需逻辑的类称为状态持有者。状态持有者的大小各不相同,具体取决于它们所管理的相应 UI 元素的范围,从单个小部件(如底部应用栏)到整个屏幕或导航目标。

在后一种情况下,典型的实现是 ViewModel 的实例,尽管根据应用程序的要求,一个简单的类可能就足够了。案例研究中的新闻应用例如使用 NewsViewModel 类作为状态持有者,为该部分中显示的屏幕生成 UI 状态。

有许多方法可以对 UI 及其状态生产者之间的相互依赖关系进行建模。然而,由于 UI 及其 ViewModel 类之间的交互在很大程度上可以理解为事件输入及其随后的状态输出,因此这种关系可以用下图表示:

Application data flows from the data layer to the ViewModel. UI state
    flows from the ViewModel to the UI elements, and events flow from the UI
    elements back to the ViewModel.
图 4. UDF 在应用架构中的工作原理图。

状态向下流动、事件向上流动的模式称为单向数据流 (UDF)。此模式对应用架构的影响如下:

  • ViewModel 持有并公开要由 UI 消费的状态。UI 状态是 ViewModel 转换后的应用数据。
  • UI 通知 ViewModel 用户事件。
  • ViewModel 处理用户操作并更新状态。
  • 更新后的状态反馈给 UI 进行渲染。
  • 对于任何导致状态变异的事件,上述过程都会重复。

对于导航目标或屏幕,ViewModel 与仓库或用例类协作,获取数据并将其转换为 UI 状态,同时结合可能导致状态变异的事件的影响。前面提到的案例研究包含一个文章列表,每篇文章都有标题、描述、来源、作者姓名、发布日期以及是否已收藏。每个文章项的 UI 如下所示:

图 5. 案例研究应用中文章项的 UI。

用户请求收藏文章是可能导致状态变异的事件示例。作为状态生产者,ViewModel 的职责是定义填充 UI 状态中所有字段以及处理 UI 完全渲染所需事件的所有逻辑。

A UI event occurs when the user bookmarks an artcile. The ViewModel
    notifies the data layer of the state change. The data layer persists the
    data change and updates the application data. The new app data with the
    bookmarked article is passed up to the ViewModel, which then produces the
    new UI state and passes it to the UI elements for display.
图 6. 说明 UDF 中事件和数据周期的图表。

以下各节将更详细地探讨导致状态更改的事件以及如何使用 UDF 处理它们。

逻辑类型

收藏文章是业务逻辑的一个示例,因为它为您的应用带来价值。要了解更多信息,请参阅数据层页面。然而,还有不同类型的逻辑需要定义:

  • 业务逻辑是应用数据产品要求的实现。如前所述,其中一个示例是在案例研究应用中收藏文章。业务逻辑通常放在领域层或数据层中,但绝不能放在 UI 层中。
  • UI 行为逻辑UI 逻辑如何在屏幕上显示状态更改。示例包括使用 Android Resources 获取要在屏幕上显示的正确文本、在用户点击按钮时导航到特定屏幕,或者使用浮动通知消息条在屏幕上显示用户消息。

UI 逻辑,特别是当它涉及 Context 等 UI 类型时,应存在于 UI 中,而不是 ViewModel 中。如果 UI 变得复杂,并且您希望将 UI 逻辑委托给另一个类以利于可测试性和关注点分离,您可以创建一个简单的类作为状态持有者。在 UI 中创建的简单类可以接受 Android SDK 依赖项,因为它们遵循 UI 的生命周期;ViewModel 对象的生命周期更长。

有关状态持有者以及它们如何融入帮助构建 UI 的更多信息,请参阅 Jetpack Compose 状态指南

为什么要使用 UDF?

UDF 模拟了图 4 所示的状态生成周期。它还将状态变化的源头、转换位置和最终消费位置分离开来。这种分离使得 UI 能够准确地完成其名称所暗示的任务:通过观察状态变化来显示信息,并通过将这些变化传递给 ViewModel 来传达用户意图。

换句话说,UDF 允许以下几点:

  • 数据一致性。 UI 只有一个事实来源。
  • 可测试性。 状态来源是独立的,因此可以独立于 UI 进行测试。
  • 可维护性。 状态的变异遵循明确定义的模式,其中变异是用户事件和它们从中提取的数据源共同作用的结果。

公开 UI 状态

定义 UI 状态并确定如何管理该状态的生成后,下一步是将生成的状态呈现给 UI。由于您使用 UDF 管理状态的生成,因此您可以将生成的状态视为一个流——换句话说,随着时间的推移将生成多个版本的状态。因此,您应该在可观测数据持有者(如 LiveDataStateFlow)中公开 UI 状态。这样做的原因是,UI 可以对状态中发生的任何更改做出反应,而无需手动直接从 ViewModel 拉取数据。这些类型还具有始终缓存最新版本 UI 状态的优点,这对于配置更改后快速恢复状态很有用。

View

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

    val uiState: StateFlow<NewsUiState> = 
}

Compose

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

    val uiState: NewsUiState = 
}

要了解 LiveData 作为可观测数据持有者的简介,请参阅此 Codelab。要了解 Kotlin 流的类似简介,请参阅Android 上的 Kotlin 流

在暴露给 UI 的数据相对简单的情况下,通常值得将数据包装在 UI 状态类型中,因为它传达了状态持有者的发出与其关联的屏幕或 UI 元素之间的关系。此外,随着 UI 元素变得越来越复杂,向 UI 状态定义中添加额外信息以适应渲染 UI 元素所需的需求总是更容易。

创建 UiState 流的常见方法是将支持的可变流作为不可变流从 ViewModel 公开——例如,将 MutableStateFlow<UiState> 公开为 StateFlow<UiState>

View

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

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

    ...

}

Compose

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

    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

然后,ViewModel 可以公开在内部改变状态的方法,发布更新供 UI 消费。例如,需要执行异步操作时;可以使用 viewModelScope 启动协程,并且在完成时可以更新可变状态。

View

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)
                 }
            }
        }
    }
}

Compose

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

   var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

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

在上面的示例中,NewsViewModel 类尝试获取某个类别的文章,然后将尝试的结果(无论是成功还是失败)反映在 UI 状态中,以便 UI 可以适当地对其做出反应。有关错误处理的更多信息,请参阅在屏幕上显示错误一节。

其他注意事项

除了之前的指导,公开 UI 状态时还要考虑以下几点:

  • UI 状态对象应处理相互关联的状态。 这会减少不一致性,并使代码更容易理解。如果您在两个不同的流中公开新闻项列表和书签数量,您最终可能会遇到一个已更新而另一个未更新的情况。当您使用单个流时,两个元素都会保持最新。此外,某些业务逻辑可能需要组合多个来源。例如,您可能需要仅当用户已登录并且该用户是高级新闻服务的订阅者时才显示书签按钮。您可以按如下方式定义 UI 状态类:

    data class NewsUiState(
        val isSignedIn: Boolean = false,
        val isPremium: Boolean = false,
        val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
    

    在此声明中,收藏按钮的可见性是另外两个属性的派生属性。随着业务逻辑变得越来越复杂,拥有一个所有属性都立即可用的单一 UiState 类变得越来越重要。

  • UI 状态:单个流还是多个流? 选择在单个流还是多个流中公开 UI 状态的关键指导原则是上一个要点:发出的项之间的关系。单个流公开的最大优点是便利性和数据一致性:状态的消费者在任何时刻都能始终获得最新信息。然而,在某些情况下,ViewModel 的单独状态流可能更合适:

    • 不相关的数据类型: 渲染 UI 所需的一些状态可能彼此完全独立。在这种情况下,将这些不同的状态捆绑在一起的成本可能大于收益,尤其是当其中一个状态比另一个状态更新更频繁时。

    • UiState 差异比较: UiState 对象中的字段越多,流因其一个字段更新而发出的可能性就越大。由于视图没有差异比较机制来判断连续发出是不同还是相同,因此每次发出都会导致视图更新。这意味着可能需要使用 Flow API 或 LiveData 上的 distinctUntilChanged() 等方法进行缓解。

消费 UI 状态

要在 UI 中消费 UiState 对象的流,您可以使用正在使用的可观测数据类型的末端操作符。例如,对于 LiveData,您可以使用 observe() 方法,对于 Kotlin 流,您可以使用 collect() 方法或其变体。

在 UI 中消费可观测数据持有者时,请务必考虑 UI 的生命周期。这很重要,因为当视图未向用户显示时,UI 不应观察 UI 状态。要了解有关此主题的更多信息,请参阅这篇博文。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用流时,最好使用适当的协程作用域和 repeatOnLifecycle API 来处理此问题。

View

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

Compose

@Composable
fun LatestNewsScreen(
    viewModel: NewsViewModel = viewModel()
) {
    // Show UI elements based on the viewModel.uiState
}

显示进行中的操作

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

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

此标志的值表示 UI 中进度条的存在或不存在。

View

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

Compose

@Composable
fun LatestNewsScreen(
    modifier: Modifier = Modifier,
    viewModel: NewsViewModel = viewModel()
) {
    Box(modifier.fillMaxSize()) {

        if (viewModel.uiState.isFetchingArticles) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }

        // Add other UI elements. For example, the list.
    }
}

在屏幕上显示错误

在 UI 中显示错误与显示进行中的操作类似,因为它们都可以通过布尔值轻松表示其存在或不存在。然而,错误还可能包含要传回给用户的相关消息,或者与它们相关的操作,用于重试失败的操作。因此,虽然进行中的操作要么正在加载,要么未加载,但错误状态可能需要使用数据类进行建模,这些数据类承载适合错误上下文的元数据。

例如,考虑上一节中在获取文章时显示进度条的示例。如果此操作导致错误,您可能希望向用户显示一条或多条消息,详细说明出了什么问题。

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

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

然后,错误消息可能会以 消息条 等 UI 元素的形式呈现给用户。由于这与 UI 事件的产生和消费方式相关,请参阅UI 事件页面了解更多信息。

线程和并发

在 ViewModel 中执行的任何工作都应该是主线程安全的——可以安全地从主线程调用。这是因为数据层和领域层负责将工作移动到不同的线程。

如果 ViewModel 执行耗时操作,则它也负责将该逻辑移动到后台线程。Kotlin 协程是管理并发操作的绝佳方式,Jetpack 架构组件提供了对它们的内置支持。要了解有关在 Android 应用中使用协程的更多信息,请参阅Android 上的 Kotlin 协程

应用导航中的更改通常由类似事件的发出驱动。例如,在 SignInViewModel 类执行登录后,UiState 可能有一个 isSignedIn 字段设置为 true。此类触发器应像上面消费 UI 状态一节中介绍的那样进行消费,只是消费实现应遵循 Navigation 组件

分页

Paging 库在 UI 中通过一个名为 PagingData 的类型进行消费。由于 PagingData 表示并包含随时间变化的项(换句话说,它不是不可变类型),因此它不应在不可变的 UI 状态中表示。相反,您应该从 ViewModel 中独立地以其自己的流公开它。有关此示例的具体说明,请参阅 Android Paging Codelab。

动画

为了提供流畅的顶级导航过渡,您可能希望等待第二个屏幕加载数据后再开始动画。Android 视图框架提供了钩子,可以使用 postponeEnterTransition()startPostponedEnterTransition() API 延迟 Fragment 目标之间的过渡。这些 API 提供了一种方法,确保第二个屏幕上的 UI 元素(通常是从网络获取的图像)在 UI 为过渡到该屏幕设置动画之前已准备好显示。有关更多详细信息和实现细节,请参阅 Android Motion 示例

示例

以下 Google 示例演示了 UI 层的使用。前往探索它们,以了解此指南的实际应用。