zl程序教程

您现在的位置是:首页 >  移动开发

当前栏目

Android 官方现代应用架构解读

2023-09-11 14:18:54 时间

作者: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 ElementsUI EventsUI State 三者之间应该如何通讯?
  • 什么是 UiState 以及如何定义 UiState
  • 如何在 UI elements 使用 UiState
  • 如何处理 UI Events

UI ElementsUI EventsUI State 三者之间应该如何通讯?

在讲述着三者关系之前,还是要回顾下在没有架构指南的情况下的编码习惯。通常并不会有明确的职责区分,所有的代码逻辑都是写在 Activity 或 Fragment 之中的,这其中就包括对用户操作的的响应、数据的产生及转换。这就是原本负责绘制的 Activity 或 Fragment 负责了其职责之外的事情。除此之外,主要有:

  • 我们无法完全掌控 Activity 和 Fragment 的行为逻辑,Android 系统会根据当前系统的运行状态对其进行回收。
  • 业务逻辑耦合在一起,职责不清晰,增加代码的复杂度不利于维护迭代。
  • 业务逻辑依赖 Android 相关类,不利于进行单元测试。

所以就需要根据其负责的事情对其进行职责拆分,这也是定义UI ElementsUI EventsUI 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 的时候需要我们使用 lifecycleScoperepeatOnLifecycle ****来处理这些任务,如下:

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 来全面了解所有内容。

看大家的反馈及个人时间,会补充剩余的内容解读。