作者:madroid
名词解释:
UI Elements
:View 或者是 Compose 函数,需要被添加到 Activity 或者 Fragment 中。UI State
:是 UI Elements
中需要展示的状态数据,基本和前者一一对应;和 UiState
是同一概念,UiState
强调对类的命名上。State Holder
:是用来提供UI State
的类,并且会包含处理对应任务所必须的逻辑;ViewModel 就是最常见的 State Holder
类。UI Layer 主要是做了一下几件事情:
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 Events
?UI Elements
、UI Events
、UI State
三者之间应该如何通讯?在讲述着三者关系之前,还是要回顾下在没有架构指南的情况下的编码习惯。通常并不会有明确的职责区分,所有的代码逻辑都是写在 Activity 或 Fragment 之中的,这其中就包括对用户操作的的响应、数据的产生及转换。这就是原本负责绘制的 Activity 或 Fragment 负责了其职责之外的事情。除此之外,主要有:
所以就需要根据其负责的事情对其进行职责拆分,这也是定义UI Elements
、UI Events
、UI State
三部分的原因,这也是符合单一职责的设计原则的。
为了实现职责分离,可以采用 UDF(Unidirectional Data Flow)
方式,即单向数据流的方式。UDF
表示 Event 从 UI 层流向数据层,UiState
从数据层流向 UI 层的一种方式单方向的数据流。
以新闻列表中的功能为例,展示单向数据流的大致流程如下:
使用单向数据流 (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
...
}
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
: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 中产生的 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 层的。
UiState
在多处被消费时,并且担心其会被消费多次时,你应该调整设计。在 UI 上层将实体拆分成更小的单元。限于篇幅原因,有些逻辑并没有展开来讲,其中包括线程的处理、路由跳转、Paging、动画等,更多详细内容可移步至官网链接进行查看:
另外就是发表下自己对新版架构的一个感受吧。整体上而言是比较满意的,一是新增了 Domain 层的定义,虽然这部分内容很在就在 Google I/O 和 Android 开发者大会上提出,但是落到官方文档上还是显得更正统点,也能够让更多的人看到。另外就是这次文档的更新非常的详细,详细到你可能没有耐心仔细、逐字读完文档中的内容,这里还是建议大家抽时间多读几遍官方文档(不知道是不是机翻,有些语句读取来有些拗口,这种情况对比英文查看即可)。
当然,还是会有一些不足的地方,比如并没有提供一些完整的示例,都是一些代码片段,文档中贴出的几个仓库完整度也是不够的,没办法通过一个 App 来全面了解所有内容。
看大家的反馈及个人时间,会补充剩余的内容解读。