【现代 Android APP 架构】02. UI 层的职责与具体实现

人员数量和时间的互换仅仅适用于以下情况:某个任务可以分解完全给参与人员,并且他们之间不需要相互的交流。 这在收割小麦或采摘棉花的工作中是可行的,而在系统编程中近乎不可能。------《人月神话》

UI 层整体架构

首先要明确一个概念,尽管这个概念听起来可能有一些抽象 ------

一切皆是数据 / Everything is data

是的,我们在手机上看到的一切内容,都可以认为是数据,甚至就连人类大脑的认知、神经元突触也可以理解成电信号这一数据的传输和保存。

UI 层本质上也是一种数据 ,它是数字信息在人类视觉层面的呈现。围绕着数据,UI 层的职责有两点:

  • 把数据展现为用户可以看到并理解的视图内容。
  • 将用户的操作映射为数据变更,并将其传输给数据仓库。

然而,UI 层使用的数据可能只是 数据仓库(Repository) 传输数据的子集,或者它需要将 两个不同来源的数据融合后 进行显示,这部分逻辑应当写在 UI 层的 ViewModels 当中。

考虑以下这个场景,一个资讯阅读类 APP,类似 NowInAndroid,具备以下功能:

  • 查看在线的新闻列表。
  • 按照不同分类进行浏览。
  • 登录并且收藏文章。
  • 对于会员,提供高级功能,例如浏览付费文章等。

本文剩下的部分将基于这个典型案例,提供 UI 层的架构设计建议。在这样一个经典的场景下,围绕着"呈现数据"、"传递指令"的目标,UI 层主要有以下职责:

  1. 消费数据层传来的原始数据,并将其转换为 UI 层所需要的数据格式。
  2. 将 UI 层所需要的数据格式渲染为用户能够看到的 UI 组件。
  3. 收集、处理用户交互事件,如果事件触发了数据变化,将该变化投射到 UI 组件上。
  4. 重复上述1~3步骤。

UI State 的流转

将 UI 层数据化,就得到 UI State

定义 UI State

用户在界面上看到的 UI 由两部分组成,分别是 UI 元素(UI Elements)UI 状态(UI State) ,以界面上展示中的一个 CheckBox 为例,它本身是一个 UI 元素,而根据数据源所保存的数据不同,可以将自身展示为 CheckedUnchecked 两种状态,如下图。

请注意,这里 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 提供了 LiveDataStateFlow 两种技术,实现上述需求。

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 时,应当使用流式计算中的终结操作符,对于 LiveDataobserve(),对于 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 库的应用场景和使用方法。

参考资料

相关推荐
前端太佬40 分钟前
从零到一实现扫码登录:一个前端菜鸟的踩坑实录
前端·javascript·架构
louisgeek1 小时前
Android NSD 网络服务发现
android
karatttt1 小时前
用go从零构建写一个RPC(仿gRPC,tRPC)--- 版本1
后端·qt·rpc·架构·golang
张可2 小时前
历时两年半开发,Fread 项目现在决定开源,基于 Kotlin Multiplatform 和 Compose Multiplatform 实现
android·前端·kotlin
余辉zmh2 小时前
【Linux系统篇】:信号的生命周期---从触发到保存与捕捉的底层逻辑
android·java·linux
异常君3 小时前
Nginx 架构深度剖析:多进程单线程模型与异步事件驱动
后端·nginx·架构
雨白3 小时前
PointerInputModifierNode的功能介绍和原理简析
android jetpack
孤鸿玉3 小时前
[Flutter小试牛刀] 低配版signals,添加多层监听链
android·前端·响应式设计
雨和卡布奇诺3 小时前
LiveData源码浅析
android
淡蓝色_justin3 小时前
Hilt-plus 简介
android·android jetpack