人员数量和时间的互换仅仅适用于以下情况:某个任务可以分解完全给参与人员,并且他们之间不需要相互的交流。 这在收割小麦或采摘棉花的工作中是可行的,而在系统编程中近乎不可能。------《人月神话》
UI 层整体架构
首先要明确一个概念,尽管这个概念听起来可能有一些抽象 ------
一切皆是数据 / Everything is data
是的,我们在手机上看到的一切内容,都可以认为是数据,甚至就连人类大脑的认知、神经元突触也可以理解成电信号这一数据的传输和保存。
UI 层本质上也是一种数据 ,它是数字信息在人类视觉层面的呈现。围绕着数据,UI 层的职责有两点:
- 把数据展现为用户可以看到并理解的视图内容。
- 将用户的操作映射为数据变更,并将其传输给数据仓库。

然而,UI 层使用的数据可能只是 数据仓库(Repository) 传输数据的子集,或者它需要将 两个不同来源的数据融合后 进行显示,这部分逻辑应当写在 UI 层的 ViewModels
当中。
考虑以下这个场景,一个资讯阅读类 APP,类似 NowInAndroid,具备以下功能:
- 查看在线的新闻列表。
- 按照不同分类进行浏览。
- 登录并且收藏文章。
- 对于会员,提供高级功能,例如浏览付费文章等。

本文剩下的部分将基于这个典型案例,提供 UI 层的架构设计建议。在这样一个经典的场景下,围绕着"呈现数据"、"传递指令"的目标,UI 层主要有以下职责:
- 消费数据层传来的原始数据,并将其转换为 UI 层所需要的数据格式。
- 将 UI 层所需要的数据格式渲染为用户能够看到的 UI 组件。
- 收集、处理用户交互事件,如果事件触发了数据变化,将该变化投射到 UI 组件上。
- 重复上述1~3步骤。
UI State 的流转
将 UI 层数据化,就得到 UI State
。
定义 UI State
用户在界面上看到的 UI 由两部分组成,分别是 UI 元素(UI Elements) 和 UI 状态(UI State) ,以界面上展示中的一个 CheckBox
为例,它本身是一个 UI 元素,而根据数据源所保存的数据不同,可以将自身展示为 Checked
和 Unchecked
两种状态,如下图。
请注意,这里 UI State 中的数据含义并不是对 UI 元素的描述,例如"用户是否勾选了通知 CheckBox
",而是在业务逻辑上的含义,即"用户开启通知开关。这样设计的目的在于隐藏 UI 层的具体实现,只对抽象的业务逻辑进行流转,而非具体的 UI 组件状态。"
在前文的信息流场景里,可以根据交互稿设计出如下的 UI State,注意这里的 State 存在两层结构。
kotlin
data class NewsUiState( // ===> 新闻列表 State,来源是数据仓库
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(), // ===> 下一级 State
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState( // 子 State
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
UI State 的不可变性
注意到上文的 NewsUiState
类使用了 data class
,意味着这是一个 不可变 的对象。这种不可变性在 UI 层的设计中是至关重要的,UI State
是且仅是 数据源在 UI 层的投射,这种数据的变更来源只有一个,就是原始数据源(单一可信数据源原则)。不可变数据的设计,其优点在于防止多方修改数据造成的冲突,UI 层只负责监听新数据并并渲染。
UI State 的命名建议
根据页面、组件的实际功能进行命名,格式为:
功能
+ UiState
例如,信息流页面的 UI State 可以命名为 NewsUiState
,其中一条信息则命名为 NewsItemUiState
。
使用单向数据流(Unidirectional Data Flow)管理 UI State
单项数据流解决了 UI 层数据源统一的问题,所有数据都只有一个来源就是 数据仓库(Repository) 。然而,从数据仓库发射出来的原始数据,并不一定可以直接应用于 UI 层的显示,举个例子,如果有一个数据仓库叫做 UserRepository
,它保存了该用户的所有个性化设置。对一个新闻类 APP 而言,在"信息流列表"页,UI 层需要知道该用户 订阅了哪些类目主题 ,而在"用户信息"页,UI 层则需要知道 用户的邮箱、头像、会员状态等信息 。不同页面所关心的具体数据是不一样的,这意味着,尽管这些数据来自于同一个数据仓库,UI 层必须要对此进行转换、合并、取舍,最终才能得到一个适配于当前页面的 UI State
。
使用 UDF
可以带来如下好处。
- 数据一致性: UI 拥有单一真实来源。
- 可测试性: 状态源是独立的,因此可以独立于 UI 进行测试。
- 可维护性: 状态的变更遵循明确定义的路径方向,其中变更既是用户事件的结果,也是其所提取数据源的结果。
状态持有者 State holders
处理这部分逻辑的角色叫做 状态持有者(State holders) ,它们的职责范围可大可小,大到一整个页面的状态维护,小到一个组件(如 CheckBox
)的状态生成,都可以为其定义 State holder
。
Google 官方提供了 ViewModel
,作为 State holder
的具体实现。它的生命周期独立于 UI 组件,可以在页面旋转等事件引起的 UI 销毁重建过程中,临时地保持数据一致性。
下图描绘了 ViewModel
所处的层级,以及其中的数据流(虚线箭头)、控制流(实线箭头)。
对图中各个角色的职责,简单概括下。
ViewModel
持有并维护UI State
,其数据来源是底层的数据仓库
。UI
层负责将用户交互事件告知ViewModel
。ViewModel
的业务逻辑代码处理用户交互事件,如果有状态更新,则将该状态更新通知数据仓库。- 数据仓库在接收到更新请求后,把新生成的不可变数据发射给
ViewModel
。 -ViewModel
通知UI
对新生成的数据进行渲染。
回到文章开头的例子,当用户点击"收藏"来对文章进行保存时,触发 UI State 更新链路如下图。
UI State 的生产
前文当中讲到,UI State 具有单向、不可变的特性,同时,在页面生命周期里面,可能存在多个状态,它们是以序列的形式逐个生成的。因此,需要这样的机制,能够按顺序发射状态,且在 Android 组件销毁重建后,获取到上一个缓存到的状态。
Android SDK 提供了 LiveData
和 StateFlow
两种技术,实现上述需求。
kotlin
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState()) // ===> 可变数据,对外隐藏
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow() // ===> 建立通道,转换为不可变 State,后续可变数据发生变化后,会自动将其转换并发射
...
}
结合 viewModelScope
,利用协程,实现拉取远端数据,并将新数据组装成 NewsUiState
,发射出去的逻辑。
kotlin
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) // ===> 会自动关联到 uiState,并将新 UiState 发射出去
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
关于发射 UI State 的思考
- 将所有相关的业务逻辑进行整合后,汇总成为一个 UI State 进行发射
ViewModel 有责任 汇总多条数据源,并且在处理完业务逻辑后,把它们组装成一个 UI State 通知 UI。反过来,一份 UI 只应该关注一种 UI State 数据流,如果发现它关注了两种数据流,那就尝试在 ViewModel 层把这两种流进行整合。
例如,只有当用户 登录&&是付费会员 时,才允许他进行收藏文章操作:
kotlin
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
)
val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium // ===> 同时取决于2个因素
- 接上一条建议,对于同一个页面上不相干的两个 UI 组件,分别发射它们的 State
乍一看这条建议与第一条似乎有矛盾,实则不然。第一条建议是为了解决一个组件关联到多个不同数据源的问题。而第二条建议则是用来避免一个数据源关联到多个 UI 组件。这样做是出于两方面的考虑:
- 可维护性,业务逻辑上彼此独立,便于把 UI 组件复用到新页面。
- 性能,若使用单一状态流,容易因为发射频率不同而引起页面全量频繁刷新。
UI State 的消费
在消费 UI State 时,应当使用流式计算中的终结操作符,对于 LiveData
是 observe()
,对于 Flow
则是 collect()
。
处理此类问题时,一定要关注的一点是 UI 组件的生命周期,在其销毁时应当解绑一切数据监听,否则极易造成内存泄漏。 请使用 LifecycleOwner
(对于 LiveData
)和 repeatOnLifecycle
来实现上述功能。

上图:应用在后台时,让 View 继续接收 State 是危险的,此时应当暂停监听。
kotlin
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
}
}
}
}
}
UiState 典型场景:展示加载中 Loading 状态
kotlin
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) {
// ===> 将进度条可见性与 isFetchingArticles 相关联
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged() // ===> distinctUntilChanged 用于过滤掉连续重复的元素
.collect { progressBar.isVisible = it }
}
}
}
}
UiState 典型场景:展示加载加载异常信息
跟上一个场景很像,只不过在 Boolean 参数的基础上,增加了一系列 String 类型的消息,用来通知用户引起异常的原因。
kotlin
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List<Message> = listOf(), // ===> 异常信息列表
...
)
线程与并发
ViewModel 提供的一切接口都应该是 主线程安全的,切换和管理线程是 ViewModel 自身的职责,应当认为外部调用都发生在 UI 线程。
导航、跳转
此类事件同样遵循单向数据流原则,由 Navigation 组件负责监听事件,执行跳转。
分页
分页是比较特殊的场景,因为它的 ViewModel 发射的 不是"不可变" 数据,而是 长度会增长的列表 。Jetpack 提供了 Paging
库,它与 Flow
结合后,可以生成一种特殊的状态流。有兴趣的同学可以继续研究 Paging 库的应用场景和使用方法。