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用作可观察数据持有者的介绍,请参阅此codelab。有关Kotlin flows的类似介绍,请参阅Android上的Kotlin flows

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

使用UI状态

要在UI中使用UiState对象流,请使用您正在使用的可观察数据类型的终端运算符。例如,对于LiveData,您使用observe()方法,对于Kotlin flows,您使用collect()方法或其变体。

在UI中使用可观察数据持有者时,请务必考虑UI的生命周期。这很重要,因为当视图未显示给用户时,UI不应观察UI状态。要了解有关此主题的更多信息,请参阅这篇博文。使用LiveData时,LifecycleOwner会隐式处理生命周期问题。使用flows时,最好使用相应的协程作用域和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 Architecture Components为此提供了内置支持。要了解有关在Android应用中使用协程的更多信息,请参阅Android上的Kotlin协程

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

分页

分页库在UI中使用名为PagingData的类型进行使用。因为PagingData表示并包含会随着时间推移而改变的项目——换句话说,它不是不可变类型——所以它不应在不可变的UI状态中表示。相反,您应该将其从ViewModel中独立地在其自己的流中公开。请参阅Android 分页 codelab,了解此方面的具体示例。

动画

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

示例

以下Google示例演示了UI层的使用。请浏览它们以实际了解本指南。