界面的作用是在屏幕上显示应用程序数据,同时也充当与用户交互的角色。每当数据发生变化时,不管是由于用户交互(如按下按钮)还是外部输入(如网络响应),都应该更新界面以反映这些变化。
不过,从数据层获取的应用数据的格式通常不同于界面显示的信息的格式。例如,可能只需要在界面中显示部分数据,或者可能需要合并两个不同的数据源,以便提供切合用户需求的信息。
以一个可获取新闻报道供用户阅读的应用为例。该应用程序有一个屏幕,显示可供阅读的新闻报道,也允许登录用户将真正引人注目的收藏起来。考虑到在任何时候都可能有很多的新闻报道,读者应该能够按类别浏览。总之,该应用程序允许用户做以下事情:
- 查看可供阅读的新闻报道。
- 按类别浏览。
- 登录帐号并为特定的新闻报道添加书签。
- 如果符合条件,可以访问一些高级功能。
下面将使用这个例子作为案例研究,介绍单向数据流的原则,并说明这些原则在界面层的应用程序架构中帮助解决的问题。
界面这一术语是指用于显示数据的activity和fragment等界面元素,无论它们使用哪个API(
Views
还是jetpack compose)来显示数据。由于数据层的作用是存储和管理应用数据,以及提供对应用数据的访问权限,因此界面层必须执行以下步骤:
- 使用应用数据,并将其转换为界面可以轻松呈现的数据。
- 使用界面可呈现的数据,并将其转换为界面元素,以便呈现给用户。
- 使用来自这些组合在一起的界面元素的用户输入事件,并根据需要反映它们对界面数据的影响。
- 根据需要重复第1-3步。
章节的其余部分展示了如何实现用于执行这些步骤的界面层。具体来说,涵盖以下任务和概念:
- 如何定义界面状态。
- 单向数据流(UDF),作为提供和管理界面状态的方式。
- 如何根据UDF原则使用可观察数据类型公开界面状态。
- 如何实现使用可观察界面状态的界面。
其中最基本的便是定义界面状态。
请参阅前面概述的案例研究。简而言之,界面显示了一个文章列表以及每篇文章的一些元数据。应用程序呈现给用户的信息便是界面状态。
换句话说,如果界面是相对用户而言的,那么界面状态就是相对应用而言的。这就像同一枚硬币的两面,界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。
以案例研究为例,为了满足新闻应用的要求,可以将完全呈现界面所需的信息封装在如下定义的
NewsUiState
数据类中:
public class NewsUiState {
private boolean isSignedIn;
private boolean isPremium;
private List<NewsItemUiState> newsItems = new ArrayList<>();
private List<Message> userMessages= new ArrayList<>();
// ...
}
public class NewsItemUiState {
private String title;
private String body;
private boolean bookmarked;
// ...
}
上面示例中的界面状态定义是不可变的。这样做的关键好处是,不可变对象可以保证应用程序在某一时刻的状态。这样界面就可以专注于一个角色:读取状态并相应地更新它的界面元素。因此,永远不要在界面中直接修改界面状态,除非界面本身是其数据的唯一来源。违反这一原则会导致同一条信息有多个来源,导致数据不一致和微妙的bug。
例如,如果案例研究中界面状态中的NewsItemUiState
对象中的bookmarked
标记在Activity
类中更新,那么该标记将与数据层竞争作为文章书签状态的源。不可变数据类对于防止这种反模式非常有用。关键在于,只有数据源或数据所有者才应负责更新其公开的数据。
上一节介绍了界面状态是界面呈现所需细节的不可变快照。不过,应用中数据的动态特性意味着状态可能会随时间而变化。这可能是因为用户互动,也可能是因为其他事件修改了应用中的底层数据。
这些互动可以受益于处理它们的中介者(mediator),从而定义要应用于每个事件的逻辑,并对后备的数据源执行必要的转换,以创建界面状态。这些互动及其逻辑可以位于界面本身中,但随着界面开始扮演其他的角色,例如数据所有者、提供方、转换器等,这可能很快就会变得难以掌控。此外,这可能会影响可测试性,因为生成的代码紧密耦合在一起,没有明显的边界。因此,除非界面状态非常简单,否则界面的唯一职责应该是使用和显示界面状态。
本部分介绍了单向数据流(UDF),这是一种架构模式,有助于强制执行职责分离的原则。
负责生成界面状态并包含该任务所需逻辑的类称为状态容器,其有着不同的大小,这取决于它们所管理的界面元素的范围,从单个小部件(如底部应用栏)到整个屏幕或导航目的地。
在后一种情况下,典型的实现是ViewModel
的实例,尽管根据应用程序的需求,一个简单的类可能就足够了。例如,基本案例研究中的新闻应用使用NewsViewModel
类作为状态容器,以便为该部分显示的屏幕画面提供界面状态。
ViewModel
类型是管理屏幕界面状态并访问数据层的推荐实现。此外,它会在配置发生变化后自动继续存在。ViewModel
类用于定义应用中事件的逻辑,并提供更新后的状态作为结果。
有很多方法可以对界面和它的状态生产者之间的相互依赖关系进行建模。然而,因为界面和它的
ViewModel
类之间的交互在很大程度上可以理解为事件输入和随后的状态输出,关系可以表示为如下图所示:
状态向下流动而事件向上流动的模式称为单向数据流(UDF)。这种模式对应用架构的影响如下:
ViewModel
保存并公开状态供界面使用。界面状态是由ViewModel
转换的应用程序数据。- 界面会向
ViewModel
发送用户事件通知。ViewModel
处理用户操作并更新状态。- 更新后的状态将反馈给界面进行呈现。
- 系统会对导致状态更改的所有事件重复上述操作。
对于导航目的地或屏幕,
ViewModel
与存储库或用例类一起工作,以获取数据并将其转换为界面状态,同时合并可能导致状态变化的事件的影响。前面提到的案例研究包含一个文章列表,每个文章都有标题、描述、来源、作者姓名、出版日期,以及是否添加了书签。每个条目的界面看起来像这样:
用户请求收藏一篇文章就是可能导致状态变化的事件的一个例子。作为状态的提供方,
ViewModel
负责定义所有所需的逻辑,以填充界面状态中的所有字段,并处理界面完全呈现所需的事件。
下面将详细介绍导致状态变化的事件,以及如何使用UDF处理这些事件。
收藏一篇文章是一个业务逻辑的例子,因为它为应用程序提供了价值。要了解更多,请参阅数据层页面。然而,还有其他类型的重要逻辑需要定义:
- 业务逻辑决定着如何处理状态变化。例如,为报道添加书签。业务逻辑通常位于网域层或数据层中,但绝不能位于界面层中。
- 界面的行为逻辑或界面逻辑决定着如何在屏幕上显示状态的变化。示例包括:使用android Resources获取要在屏幕上显示的正确文本、在用户点击某个按钮时转到特定的屏幕,以及使用消息框或信息提示控件在屏幕上向用户显示消息。
界面逻辑,特别是当它涉及到像Context这样的界面类型时,应该存在于界面而不是
ViewModel
中。如果界面变得越来越复杂,而此时希望将界面逻辑委托给另一个类,以支持可测试性和关注点分离,可以创建一个简单的类作为状态容器。在界面中创建的简单类可以依赖android SDK,因为它们遵循界面的生命周期;ViewModel
对象有更长的生命周期。
如需详细了解状态容器以及如何利用它们更好地构建界面,请参阅jetpack compose状态指南。
UDF可以为状态提供周期性的建模。它还可以将状态变化的来源位置、转换位置以及最终使用位置分离开来。这种分离可以让界面只发挥其名称所表明的作用:通过观察状态变化来显示信息,并通过将这些变化传递给ViewModel来传递用户的意图。
换句话说,UDF有助于实现以下几点:
- 数据一致性。界面只有一个可信来源。
- 可测试性。状态来源是独立的,因此可独立于界面进行测试。
- 可维护性。状态的更改遵循定义良好的模式,即状态更改是用户事件及其获取的数据来源共同作用的结果。
在定义界面状态并确定如何管理该状态的生成之后,下一步是将生成的状态呈现给界面。因为使用UDF来管理状态的产生,所以可以将产生的状态视为流。换句话说,状态会随着时间的推移产生多个版本。
因此,你应该在LiveData
或StateFlow
这样的可观察数据容器中公开界面状态。这样做的原因是,界面可以对状态中的任何更改做出反应,而不必直接手动从ViewModel
中获取数据。这些类型还具有总是缓存最新版本的界面状态的优点,这对于配置更改后的快速恢复状态非常有用。
class NewsViewModel(...) : ViewModel() {
val uiState: StateFlow<NewsUiState> = ...
}
有关
LiveData
作为可观察数据容器的介绍,请参阅此codelab。有关kotlin数据流的类似介绍,请参阅android上的kotlin数据流。
注意:在jetpack compose应用中,可以使用Compose
的可观察状态API,如mutableStateOf
或snapshotFlow
来公开界面状态。在本指南中看到的任何类型的可观察数据容器,如StateFlow
或LiveData
,都可以使用适当的扩展轻松地在Compose
中使用。
在向界面公开的数据相对简单的情况下,通常有必要将数据包装在界面状态类型中,因为它表达了状态持有者与其关联的屏幕或界面元素之间的关系。此外,随着界面元素变得越来越复杂,添加到界面状态定义中从而容纳呈现界面元素所需的额外信息总是更容易的。
创建UiState
流的一种常用方法是,将可变数据流作为来自ViewModel
的不可变数据流进行公开,例如将MutableStateFlow
作为StateFlow
进行公开。
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)
}
}
}
}
}
在上面的示例中,
NewViewModel
类尝试获取某个类别的文章,然后在界面状态中反映尝试的结果,无论成功与否,界面可以对其做出适当的响应。单向数据流有多种常用的实现,这里(通过ViewModel
上的函数更改状态)便是其中的一种。
除了前面的指南之外,在公开界面的状态时还应考虑以下几点:
- 界面状态对象应该处理彼此相关的状态。这将减少不一致性,并使代码更易于理解。如果在两个不同的流中公开新闻项列表和书签的数量,则可能会出现一个更新,另一个未更新的情况。使用单个流时,两个元素都会保持最新的状态。此外,一些业务逻辑可能需要不同源的组合。例如,仅当用户已登录并且订阅了高级新闻服务时,此时才需要显示书签按钮。可以如下定义界面状态类:
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
diffing:UiState
对象中的字段越多,流就越有可能因为其中一个字段被更新而触发。因为视图没有相应的区分机制来理解连续的触发是不同的还是相同的,所以每次触发都会导致视图的更新。这意味着可能需要在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
对象在没有处于活跃状态的收集器(collectors)时不会停止执行工作,但当你在处理数据流时,可能不知道它们底层是如何实现的。借助生命周期感知型的数据流集合的功能可以让你稍后对ViewModel
数据流进行此类更改,而无需重新访问下游收集器(collector)代码。
在
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架构组件为它们提供了内置的支持。要了解更多关于在android应用程序中使用协程的信息,请参阅android上的kotlin协程。
应用导航的变化通常是由类似于事件的触发操作驱动的。例如,在
SignInViewModel
类执行登录后,UiState
可能会有一个isSignedIn
字段被设为true
。此类触发器的使用方式应与上面使用界面状态部分介绍的方式相同,不过使用时应遵从导航组件。
Paging库通过一个名为
PagingData
的类型在界面中使用。由于PagingData
表示并包含可以随时间变化的内容(换句话说,它不是不可变的类型),因此它不应以不可变的界面状态表示。相反,你应该在它自己的流中从ViewModel
中独立地公开它。有关这方面的具体示例,请参阅Android Paging Codelab。
为了提供流畅和平滑的导航过渡,你可能希望在启动动画之前等待第二个屏幕加载数据。Android视图框架通过
postponeEnterTransition()
和startPostponedEnterTransition()
这类API提供了钩子来延迟fragment目的地之间的过渡。这些API提供了一种方法来确保第二个屏幕上的界面元素(通常是从网络获取的图像)在界面动画过渡到该屏幕之前已经准备好显示。要了解更多细节,请参阅android motion示例。