UI Layer
UI的角色:1.在屏幕上显示数据的地方,2.用户交互的地方。
用户交互(如按下按钮),外部输入(如网络响应)都会让数据变化,UI应该更新以反映这些变化。实际上,可以认为UI是从data layer检索到的应用程序状态后的可视化展示。
这里有一个问题,从data layer检索到的数据,其格式和要显示的数据通常是不一样的。例如,UI上要展示的只是检索到的数据的一部分,或者要合并两个不同的数据源来供UI使用。不管是哪一种,你都要给UI提供其所需的所有信息。所以UI层需要一个管道,管道一端是从data层拿到的数据,另一端是整理好的合乎UI格式的数据。
A basic case study
来设计一个新闻App,根据需求列出如下列表:
下面章节使用该案例来介绍单向数据流(UDF)的原理,并说明UDF在UI层架构的上下文中是如何解决问题的。
UI Layer architecture
这里的UI指的是UI element,例如activity和fragment,指用来展示数据的容器,要和API中各种View或Jetpack Compose区分开。data layer的角色是持有,管理数据,并为其他部分提供访问数据的接口。UI layer必须完成下面的步骤(这里用例子来替代原文的翻译,感觉更直观):
- data layer 中拿到的数据A --> UI可以使用的数据B
- 把数据B传给UI element,比如set data to the adapter of RecyclerView.
- 处理用户和UI element的交互,比如用户bookmark了一条新闻
- 根据需要,重复1-3
剩下的指南描述了如何实现UI layer,来完成上面的步骤,涵盖了如下任务和概念:
- 如何定义UI State
- 用单向数据流的方式生成和管理UI state
- 如何使用单向数据流原则,把UI state和observable data type暴露出去
- 如何实现UI来消费observable UI state
下面来看看最基本问题:对UI State的定义。
Define UI State
在新闻App中,UI会显示文章列表以及每篇文章的一些元数据,呈现给用户的这些信息就是UI状态。
换句话说:UI状态决定了呈现给用户的UI,UI是UI状态的可视化表示,任何UI状态的变化会立即反应在UI上。
为满足news app的要求,要在UI上展示所有的信息,可以封装一个类NewsUiState,如下:
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List = listOf(),
val userMessages: List = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
Immutability
上面UI State定义是不可变的。这样做的好处是,不可变对象保证了无论application状态在任意时刻变化无法影响UI state。这就使得UI可以专注于自己单一的职责:读取state并更新UI element。因此,除非UI本身是其数据的唯一来源,否则不应该直接在UI中修改UI状态。违反这一原则会导致同一信息的多个真实来源,从而导致数据不一致和微妙的bug。
例如,如果案例中NewsItemUiState
对象的bookmarked 在activity类中更新,那么该标记就会和数据层竞争,也要去争夺作为书签状态的数据源。而Immutability data class
就阻止了这种麻烦。
注:只有数据源或者数据的所有者才能更新它们公开出去的数据。
本指南中的命名规范
本指南对UI state的命名规范基于界面的功能或者界面上某部分的描述,规范如下:functionality + UIState
例如,例子中的新闻首页,状态就叫NewsUiState
,新闻条目的状态就叫NewsItemUiState
.
使用UDF管理状态
上一节确定了UI状态是UI的不可变快照。但是数据的动态特性意味着状态会随时变化。用户交互或其他event都会修改用于填充应用的基础数据。
这些交互可能会由一个mediator来负责处理,mediator对每一个event定义相应的逻辑,转换数据源格式并创建UI state。这些交互和逻辑可能包含在UI本身中(虽然UI的名称暗示了只在这里做某件事,但是随着UI变得更复杂,你不知不觉就放进去很多其他代码,完美的成为生产者,所有者,转换器。。。)。很快它就变得很笨拙了,成为了紧密耦合的混合物,没有可辨识的边界,影响可测试性。要避免这些,除非UI State很简单,否则UI唯一的职责就是消费并显示UI状态。
State Holder状态持有者
State Holder称为状态持有者,是一个类。该类负责生成UI State,并包含了该过程所需的逻辑。State Holder有大有小,取决于其管理的UI element的范围,小到单一的小部件(如bottom app bar),大到整个屏幕或者导航组件。
典型的实现就是ViewModel的一个实例。例如news app中使用NewsViewModel类作为状态持有者为显示在屏幕上的部分生成UI state。
注:推荐使用ViewModel来管理屏幕级的UI状态并访问数据层。此外,它还会自动处理更改配置的情况。ViewModel类定义逻辑去处理APP中发生的各种events,并生成更新后的状态。
虽然有很多方式可以对UI与状态生成器之间的相互依赖关系建模,但是由于UI和ViewModel类之间的交互在很大程度上可以理解为事件输入以及随之而来的状态输出,因此可以将其关系表示为下图:
状态向下流动而事件向上流动的模式称为单向数据流 (UDF)。这种模式对应用架构的影响如下:
- ViewModel保存并提供UI所需的状态,UI状态是ViewModel从data layer拿到的数据经过ViewModel转换得到的
- UI 把用户事件反馈给ViewModel
- ViewModel处理上面的事件并更新state
- 更新后的state被反馈给UI进行渲染
- 对任何导致状态变化的事件,上述步骤重复执行
ViewModel中会注入repository或者其他use case类,VM在它们的帮助下获得data并把data转换成UI state;上面的步骤里VM也接收来自UI的事件,有些事件会导致state变化,VM也要处理这些变化。前面的例子中,屏幕上有文章列表,每篇文章有标题,描述,来源,坐着,日期,是否添加了书签等信息:
可能导致状态变化的示例:某用户要为某篇文章添加书签。
作为状态生产者,VM的责任:1.定义所有所需的逻辑,以便填充UI状态中所有字段 2.处理来自UI的事件。
下图显示了单向数据流中data和event的流动周期
下面的章节我们来了解一下event引起state变化,并且如何在UDF中处理它们。
Types of logic
给文章加书签是一个典型的业务逻辑。这里有几个重要的逻辑类型需要定义:
- Business logic: 业务逻辑就是状态改变后做什么。例如给某篇文章加书签。业务逻辑通常放在domain层或者data层,但是一定不要放在UI层。
- UI behavior logic / UI logic: UI逻辑是如何把状态改变展示在屏幕上。例如:通过Android资源获取正确的文本然后显示在屏幕上;当用户点击按钮时打开特定的页面;通过toast或snackbar展示消息。
UI逻辑应该放在UI中(尤其当涉及到Context时),不要放在ViewModel。如果UI变得越来越复杂,就要稍微重构一下,将UI逻辑委托给一个类,这样便于测试并遵循了SOC原则。可以创建一个简单的类作为状态持有者。在 UI中创建的简单类可以采用 Android SDK 依赖项,因为它们遵循 UI 的生命周期; ViewModel 对象的生命周期更长。
有关状态持有者以及它们如何融入帮助构建UI的上下文的更多信息,请参阅 Jetpack Compose State 指南。
为什么使用单向数据流(UDF)?
UDF对状态生产周期进行建模。它还对每个部分进行了隔离:状态变化的源头、转换的地方和最终消费的地方。这种分离让 UI 完全符合其名称的含义:通过观察状态变化来显示信息,并通过将这些变化传递给 ViewModel 来传递用户意图。
换句话说,UDF带来了以下好处:
- 数据一致性。 UI有一个单一的事实来源。
- 可测试性。状态源被隔离开,因此可独立于 UI 进行测试。
- 可维护性。状态的突变遵循一个明确定义的模式,突变是用户事件和提取的数据源的结果。
公布UI 状态
定义UI状态并确定了如何管理该状态的生成,下面就要将生成的状态呈现给UI。因为使用 UDF 来管理状态的产生,所以可以将产生的状态视为一个流——换句话说,随着时间的推移会产生多个版本的状态。因此,应该在 LiveData 或 StateFlow 等可观察数据持有者中公开 UI 状态。这样做的原因是 UI 可以对状态中所做的任何更改做出反应,而无需直接从 ViewModel 手动提取数据。这些类型还具有始终缓存最新版本的 UI 状态的好处,这对于在配置更改后快速恢复状态很有用。
class NewsViewModel(...) : ViewModel() {
val uiState: StateFlow = …
}
有关 LiveData 作为可观察数据持有者的介绍,请参阅此codhttps://developer.android.com...elab。有关 Kotlin 流的类似介绍,请参阅 Android 上的 Kotlin 流。
注:在 Jetpack Compose 应用中,您可以使用 Compose 的 mutableStateOf 或 snapshotFlow 等可观察状态 API 来进行 UI 状态的暴露。您在本指南中看到的任何类型的可观察数据持有者(例如 StateFlow 或 LiveData)都可以使用适当的扩展在 Compose 中轻松使用。
如果暴露给 UI 的数据相对简单,通常可将数据包装在 UI 状态类型中,因为它传达了状态持有者的发射与其关联的UI元素间的关系。此外,随着UI元素变得越来越复杂,可以很easy的添加需要的UI状态,里面带上渲染UI元素所需的额外信息即可。
创建 UiState流的一种常见方法是将支持的可变流公开为来自 ViewModel 的不可变流——例如,将 MutableStateFlow
公开为 StateFlow
。
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
...
}
然后 ViewModel 可以公开内部改变状态的方法,发布更新以供 UI 使用。例如要进行异步操作,可以使用 viewModelScope 启动协程,并且可以在完成后更新可变状态。
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow = _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类尝试获取某个类别的文章,然后把尝试的结果更新到UI state中(无论尝试结果成功与否)。请参阅Show errors on the screen部分以了解错误处理的更多信息。
注:上面示例中显示的模式通过ViewModel中的函数改变状态,这种实现方式是实现单向数据流的较流行的方式。
其他注意事项
除了前面的部分,其他值得注意的还有:
UI 状态对象应该处理彼此相关的状态。
这样可以减少不一致,并使代码更易于理解。如果在两个不同的流中公开新闻项目列表和书签数量,您最终可能会遇到一个已更新而另一个未更新的情况。当您使用单个流时,两个元素都会保持最新。此外,某些业务逻辑可能需要源的组合。例如,仅当用户已登录并且该用户是高级新闻服务的订阅者时,您可能才需要显示书签按钮。您可以按如下方式定义 UI 状态类:data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List
= listOf() ) val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium 在此声明中,书签按钮的可见性是其他两个属性的派生属性。随着业务逻辑变得越来越复杂,拥有一个所有属性都立即可用的单一 UiState 类变得越来越重要。
UI 状态:单流还是多流?
在单个流或多个流中公开 UI 状态之间进行选择的关键指导原则是前面的要点:发射的项目之间的关系。单流公开的最大优势是便利性和数据一致性:状态的消费者始终拥有随时可用的最新信息。但是,在某些情况下,来自 ViewModel 的单独状态流可能是合适的:- 不相关的数据类型:渲染 UI 所需的某些状态可能彼此完全独立。在这种情况下,将这些不同的状态捆绑在一起的成本可能会超过收益,尤其是如果这些状态中的一个比另一个更频繁地更新。
- UiState 差异:UiState 对象中的字段越多,流越有可能由于其字段之一被更新而发出。因为视图没有区分机制来理解连续发射是不同还是相同,所以每次发射都会导致视图更新。这意味着可能需要在 LiveData 上使用 Flow API 或 distinctUntilChanged() 等方法进行缓解。
消费UI state
要在UI中使用UiState对象流,可以使用终端运算符来表示您正在使用的可观察数据类型。例如,对于 LiveData,使用 observe() 方法,对于 Kotlin 流,使用 collect() 方法或其变体。
在 UI 中使用 observable 数据持有者时,请确保将 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
}
}
}
}
}
注:此示例中使用的特定 StateFlow 对象在没有活动收集器时不会停止执行工作,但是当您使用流时,您可能不知道它们是如何实现的。使用生命周期感知流收集可以让您稍后对 ViewModel 流进行此类更改,而无需重新访问下游收集器代码。
显示loading
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 }
}
}
}
}
显示错误信息
在UI中显示错误类似于显示loading的操作,它们都可以用布尔值表示,这些值表示它们的存在或不存在。但是,错误还可能包括要转发给用户的关联消息,或与它们关联的重试失败操作的操作。因此,当正在进行的操作正在加载或未加载时,可能需要使用数据类对错误状态进行建模,这些数据类承载适合于错误上下文的元数据。
例如,考虑上一节中在获取文章时显示进度条的示例。如果此操作导致错误,您可能希望向用户显示一条或多条消息,详细说明问题所在。
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List = listOf(),
...
)
错误消息可能会以UI元素(如snackbars)的形式呈现给用户。因为这与UI事件的产生和使用方式有关,请参阅 UI事件页面了解更多信息。
线程和并发
在ViewModel中执行的任何工作都应该是主线程安全的。这是因为数据层和域层负责将工作转移到不同的线程。
如果 ViewModel 执行长时间运行的操作,那么它还负责将该逻辑移动到后台线程。 Kotlin 协程是管理并发操作的好方法,Jetpack 架构组件为它们提供了内置支持。要了解有关在 Android 应用程序中使用协程的更多信息,请参阅 Android 上的 Kotlin 协程。
导航
应用导航的变化通常是由类似事件发射驱动的。例如,在 SignInViewModel 类执行登录后,UiState 可能会将 isSignedIn 字段设置为 true。这些触发器应该像上面的使用 UI 状态部分中介绍的那样被使用,除了消费实现应该遵循导航组件。
分页
Paging 库在 UI 中使用名为 PagingData 的类型。因为 PagingData 表示并包含可以随时间变化的项目——换句话说,它不是immutable type——它不应该以immutable UI state表示。相反,您应该在 ViewModel 自己的流中独立地公开它。有关这方面的具体示例,请参阅 Android Paging 代码。
动画
为了提供流畅和平滑的顶级导航转换,您可能希望在开始动画之前等待第二个屏幕加载数据。 Android 视图框架提供了 hooks 来延迟片段目的地之间的转换,并使用 preventEnterTransition() 和 startPostponedEnterTransition() API。这些 API 提供了一种方法来确保第二个屏幕上的 UI 元素(通常是从网络获取的图像)在 UI 动画过渡到该屏幕之前准备好显示。有关更多详细信息和实现细节,请参阅 Android Motion 示例。