作者:madroid
概述
📌 名词解释:
UI Elements
:View 或者是 Compose 函数,需要被添加到 Activity 或者 Fragment 中。UI State
:是UI Elements
中需要展示的状态数据,基本和前者一一对应;和UiState
是同一概念,UiState
强调对类的命名上。State Holder
:是用来提供UI State
的类,并且会包含处理对应任务所必须的逻辑;ViewModel 就是最常见的State Holder
类。
UI Layer 主要是做了一下几件事情:
- 将 App 中的数据转换成容易被
UI Elements
渲染的数据(UiState
)。这部分转换主要发生在State Holder
中。 - 将
UiState
转换为对应的UI elements
展示给用户。这部分主要发生在 Activity 或 Fragment 中,不论 Activity 和 Fragment 使用的是 View 还是 Jetpack Compose 构建的。 - 接收
UI elements
中的输入事件,并且根据需要做出响应。
想要把上述几件事情做好,首先就需要梳理清楚三者之间的逻辑关系以及通讯方式,其次就是三者各自的一些基本要求及最佳实践。也就是要回答以下几个问题:
UI Elements
、UI Events
、UI State
三者之间应该如何通讯?- 什么是 UiState 以及如何定义
UiState
? - 如何在 UI elements 使用
UiState
? - 如何处理
UI Events
?
UI Elements
、UI Events
、UI State
三者之间应该如何通讯?
在讲述着三者关系之前,还是要回顾下在没有架构指南的情况下的编码习惯。通常并不会有明确的职责区分,所有的代码逻辑都是写在 Activity 或 Fragment 之中的,这其中就包括对用户操作的的响应、数据的产生及转换。这就是原本负责绘制的 Activity 或 Fragment 负责了其职责之外的事情。除此之外,主要有:
- 我们无法完全掌控 Activity 和 Fragment 的行为逻辑,Android 系统会根据当前系统的运行状态对其进行回收。
- 业务逻辑耦合在一起,职责不清晰,增加代码的复杂度不利于维护迭代。
- 业务逻辑依赖 Android 相关类,不利于进行单元测试。
所以就需要根据其负责的事情对其进行职责拆分,这也是定义UI Elements
、UI Events
、UI State
三部分的原因,这也是符合单一职责的设计原则的。
单向数据流
为了实现职责分离,可以采用 UDF(Unidirectional Data Flow)
方式,即单向数据流的方式。UDF
表示 Event 从 UI 层流向数据层,UiState
从数据层流向 UI 层的一种方式单方向的数据流。
以新闻列表中的功能为例,展示单向数据流的大致流程如下:
- 初始化阶段(白色流程)
- 数据层返回当前 App 的一些数据给到 ViewModel;
- ViewModel 将其转换为 UI 层需要状态数据(UI State);
- UI State 传递给对应的 Activity 或 Fragment 中,供其绘制;
- 响应事件阶段(红色流程)
- Activity 或 Fragment 将用户的点击事件传递到 ViewModel 中;
- ViewModel 将事件传递到数据层(Data Layer);
- 数据层将会根据业务逻辑对其进行处理,并更新 App 数据;
- 数据层返回更新后的 App 数据给到 ViewModel;
- ViewModel 将其转换为 UI 层需要状态数据(UI State);
- UI State 传递给对应的 Activity 或 Fragment 中,供其绘制;
使用单向数据流 (UDF
),有助于强制实施这种健康的职责分离,将状态变化来源位置(Data)、转换位置(State Holders
)以及最终使用位置(UI Elements
)分散到不同的类中。同时也会有以下几点好处:
- 数据一致性。界面只有一个可信来源。
- 可测试性。状态来源是独立的,因此可独立于界面进行测试。
- 可维护性。状态的更改遵循明确定义的模式,即状态更改是用户事件及其数据拉取来源共同作用的结果。
PS:关于 State Holder
这里可以多说几点:
ViewModel
就是最常见的State Holder
类,但并不是唯一的类;- 只要能提供
UiState
并且能够处理对应的逻辑就行,可以是一个普通的类; Compose
声明式 UI 编码方式,使得Compose
函数并不需要定义在统一的一个类中(像 Activity、Fragment就是集中管理所有 View),而是可以通过自由组合的方式来构建页面,所以对这些 UiState 的管理放在统一的ViewModel
中也会有些冲突,所以为了解决这个问题,就引入了State Holder
的概念来兼容ViewModel
,并且会允许其他的类来管理Compose
函数,说不好后面这部分会不会像 Flutter 一样百花齐放(各种状态管理的框架)。ViewModel
不会被轻易替代,因为其处理了不少生命周期相关的操作,当然,在往前想一步,Compose
需要这些生命周期的处理么?
如何定义 UiState
?
UI 页面上展示的一些可变信息就是 UiState
,通常会被定义为 data class
,UI 元素需要根据 UiState
来绘制对应的元素。除了这些静态的绘制状态,还会包含一些动作的处理,比如UiState
类中包含 isUserLoggedIn
字段,根据这个字段需要处理页面跳转相关的逻辑。
在定义 UiState
的同时,需要考虑 UI 到底需要展示、处理哪些信息。也有一些原则需要遵守:
📌 不可变性
不可变性是说 UiState 在定义的时候,要定义为常量而非变量,这样接可以杜绝在数据传递的过程中有其他的逻辑对其产生修改。
确保只有数据源或数据所有者才应负责更新其公开的数据。
📌 使用统一的命名
统一的命名规范在多人协助的团队中可以快速对齐上下文。UiState
类是根据其描述的 UI元素(可以是整个页面也可以是部分页面)功能命名的。具体命名惯例如下:
功能 + UiState
例如,用于显示新闻的屏幕的状态可以称为 NewsUiState
,新闻报道列表中的新闻报道的状态可以为 NewsItemUiState
。
📌 UiState 应处理彼此相关的状态(单一数据流)
相关联的数据状态应定义在同一个 UiState
中,防止其定义在不同的 UiState
中导致其中一处修改儿另一处没有修改的情况,从而导致数据不一致的情况。并且可以对其关联数据做整合处理。
如只有登录并且订阅的用户才可以添加书签功能:
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
)
val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
📌 合理使用单数据流与多个数据流
使用单一数据流的最大优势是便捷性及数据一致性。但是强行把不相关的数据捆绑在一个 UiState
中的代价会超过其优势,尤其是在刷新频率不一致的情况下。因为某一个字段的变化会导致整个 UiState
相关的 UI element 都会刷新一次。插一句,这也是 Flutter 开发中最容易被忽视的一个问题。
UiState
对象中的字段越多,数据流就越有可能因为其中一个字段被更新而发出,可以使用 Flow 的 distinctUntilChanged
函数来尽量过滤这种情况。
如何使用可观察数据类型提供 UiState
?
定义的 UiState
一般是通过可观察的 LiveData、Flow 提供给 UI element
进行使用。这样做的好处是不用手动从 ViewModel 中查询 UI 的状态。同时,当数据发生变化的时候 UI 也能够及时的刷新。
在提供 LiveData、Flow 时,通常是使用后备属性来限制其操作权限,这样仅在 ViewModel 内部才可以修改数据,UI element
只能监听数据变化。防止违背 UDF
的数据流向。如下示例:
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
private val _uiStateLiveData = MutableLiveData()
val uiStateLiveData: StateFlow<NewsUiState> = _uiStateLiveData
...
}
如何在 UI elements 使用 UiState
?
在 UI 使用可观察数据容器时,需要考虑界面的生命周期的状态。因为当未向用户显示视图时,界面不应观察界面状态。使用 LiveData
时,LifecycleOwner
已经帮我们处理好这部分;在使用 Flow 的时候需要我们使用 lifecycleScope
及 repeatOnLifecycle
****来处理这些任务,如下:
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
viewModel.liveData.observer {
// Update UI elements
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
如何处理 UI Events
📌 名称解释:
UI
:Activity或者是 Fragment 中包含的 View 或者是 Compose 代码逻辑;UI events
:在 UI 层自己能够处理的动作,如嵌套 List 展开的逻辑;User events
:用户与 App 交互时产生的事件,如onClickedListener
事件等;
不同 UI 事件会有不同的处理方式,大致原则如下图:
简单一句话总结下来就是,在 UI 层事件的处理逻辑仅仅是在 UI 层能够完成的,并且不需要 ViewModel 再做额外处理事件就在 UI 层自己解决,否则事件传递给 ViewModel 进行处理。
下面看一下具体的例子
处理用户事件
如果用户事件与修改界面元素的状态(如可展开项的状态)相关,界面便可以直接处理这些事件。如果事件需要执行业务逻辑(如刷新屏幕上的数据),则应用由 ViewModel 处理此事件。
以下示例展示了如何使用不同的按钮来展开界面元素(界面逻辑)和刷新屏幕上的数据(业务逻辑):
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
// 扩展部分的展示与否,与业务逻辑无关,直接在 UI 层处理
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// 刷新事件交由 ViewModel 来处理
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
在处理 RecycleView 的 Item 点击事件的时候,不要将 ViewModel 的引用传入,这会将两者耦合在一起。相反的,应该将 Item 的点击事件通过回调的方式暴露出去。
不过官方也提供了另外的一种处理方式:
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
) {
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
个人并不建议这么处理,这种方式会让 UiState 变得不在纯粹。
📌 Event 命名规范
用于处理用户事件的 ViewModel 函数根据其处理的操作以
动词命名
如 addBookmark(id)
、 logIn(username, password)
等。
处理 ViewModel 事件
从 ViewModel 中产生的 UI Action 要通过更新 UiState 的方式来实现,这是符合单向数据流准则的。这更多的是编程思想的转变,不要想着 UI 需要响应哪些 Action,而是要想着 ViewModel 如果更新 UiState。这也是符合关注点分离的,ViewModel 关注如何更新 UiState,UI 元素关注如何根据 UiState 做对应的展示。
例如,要考虑在用户登录时从登录屏幕切换到主屏幕的情况,代码如下:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// 跳转到主屏幕
}
...
}
}
}
}
}
注意,页面跳转的逻辑是属于 UI 层的。
其他原则
- 每个类都应各司其职,不能越界。界面负责屏幕专属行为逻辑,例如页面跳转、点击事件以及获取权限请求(简而言之就是和 Activity、Context 相关的逻辑)。ViewModel 包含业务逻辑,并将结果从层次结构的较低层转换为界面状态。
- 考虑事件的发起点。请遵循本指南开头介绍的决策树,并让每个类各司其职。例如,如果事件源自界面并导致出现导航事件,则必须在界面中处理该事件。某些逻辑可能会委托给 ViewModel,但事件的处理无法完全委托给 ViewModel。
- 当同一个
UiState
在多处被消费时,并且担心其会被消费多次时,你应该调整设计。在 UI 上层将实体拆分成更小的单元。
总结
限于篇幅原因,有些逻辑并没有展开来讲,其中包括线程的处理、路由跳转、Paging、动画等,更多详细内容可移步至官网链接进行查看:
另外就是发表下自己对新版架构的一个感受吧。整体上而言是比较满意的,一是新增了 Domain 层的定义,虽然这部分内容很在就在 Google I/O 和 Android 开发者大会上提出,但是落到官方文档上还是显得更正统点,也能够让更多的人看到。另外就是这次文档的更新非常的详细,详细到你可能没有耐心仔细、逐字读完文档中的内容,这里还是建议大家抽时间多读几遍官方文档(不知道是不是机翻,有些语句读取来有些拗口,这种情况对比英文查看即可)。
当然,还是会有一些不足的地方,比如并没有提供一些完整的示例,都是一些代码片段,文档中贴出的几个仓库完整度也是不够的,没办法通过一个 App 来全面了解所有内容。
看大家的反馈及个人时间,会补充剩余的内容解读。