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元素,例如显示数据的活动和片段,与它们使用哪些API来执行此操作(视图或Jetpack Compose)无关。由于数据层的作用是保存、管理和提供对应用程序数据的访问,因此UI层必须执行以下步骤

  1. 使用应用程序数据并将其转换为UI可以轻松呈现的数据。
  2. 使用可渲染的UI数据并将其转换为UI元素,以供用户查看。
  3. 从这些组装的UI元素中使用用户输入事件,并在需要时反映它们对UI数据的影响。
  4. 根据需要重复步骤1到3。

本指南的其余部分演示了如何实现执行这些步骤的UI层。特别是,本指南涵盖了以下任务和概念

  • 如何定义UI状态。
  • 单向数据流 (UDF) 作为生成和管理UI状态的一种手段。
  • 如何根据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 获取要显示在屏幕上的正确文本,在用户单击按钮时导航到特定屏幕,或使用ToastSnackbar 在屏幕上显示用户消息。

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状态呈现给UI。由于您正在使用UDF来管理状态的生成,因此您可以将生成的UI状态视为流——换句话说,随着时间的推移将生成多个版本的UI状态。因此,您应该在可观察数据持有者(如 LiveDataStateFlow)中公开UI状态。这样做的原因是,UI可以对状态中进行的任何更改做出反应,而无需手动从ViewModel中直接提取数据。这些类型还具有始终缓存UI状态最新版本的好处,这对于在配置更改后快速恢复状态很有用。

视图

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

    val uiState: StateFlow<NewsUiState> = …
}

Compose

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

    val uiState: NewsUiState = …
}

有关LiveData 作为可观察数据持有者的介绍,请参见此代码实验室。有关Kotlin流的类似介绍,请参见Android上的Kotlin流

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

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

视图

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 启动协程,并在完成时更新可变状态。

视图

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 diffing: UiState 对象中的字段越多,由于其中一个字段更新而导致流发射的可能性就越大。由于视图没有 diffing 机制来理解连续发射是否不同或相同,因此每次发射都会导致视图更新。这意味着可能需要使用 Flow API 或 distinctUntilChanged() 等方法在 LiveData 上进行缓解。

使用 UI 状态

为了在 UI 中使用 UiState 对象流,您使用的是您正在使用的可观察数据类型的终端操作符。例如,对于 LiveData,您使用 observe() 方法,而对于 Kotlin 流,您使用 collect() 方法或其变体。

在 UI 中使用可观察数据持有者时,请确保考虑 UI 的生命周期。这很重要,因为当视图没有显示给用户时,UI 不应该观察 UI 状态。要了解有关此主题的更多信息,请参见 这篇博文。当使用 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
                }
            }
        }
    }
}

Compose

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

显示正在进行的操作

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

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

此标志的值表示 UI 中是否存在进度条。

视图

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 元素(如 snackbar)向用户呈现错误消息。由于这与 UI 事件的产生和使用方式有关,请参见 UI 事件 页面以了解更多信息。

线程和并发

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

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

应用导航中的更改通常由事件式发射驱动。例如,在 SignInViewModel 类执行登录后,UiState 可能有一个 isSignedIn 字段设置为 true。应该像上面 使用 UI 状态 部分中介绍的那样使用这些触发器,除了使用实现应该委托给 导航组件

分页

分页库 在 UI 中使用名为 PagingData 的类型进行使用。由于 PagingData 表示并包含可能随时间变化的项目(换句话说,它不是不可变类型),因此它不应在不可变的 UI 状态中表示。相反,您应该将其从 ViewModel 中独立地公开到它自己的流中。有关此的具体示例,请参见 Android 分页 代码实验室。

动画

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

示例

以下 Google 示例演示了 UI 层的使用。探索它们以在实践中看到这些指导。