Android MVI架构之UI开发指南

Android MVI架构之UI开发指南

在整个应用程序架构中,UI层并不是唯一的层级。除了UI层之外,您还可以找到数据层,有时还有领域层。根据Android架构文档:

  • UI层在屏幕上显示数据。
  • 数据层暴露应用程序数据,并包含大部分业务逻辑。
  • 领域层是一个可选的层,旨在简化和重用UI层的潜在业务逻辑复杂性。不多也不少。

注意:业务逻辑赋予应用程序价值。它是实现产品需求的方式,决定了应用程序如何获取、存储和修改数据。

典型的Android应用程序架构中的层级结构:UI层、数据层和领域层

UI层中的实体

UI层包括三个具有明确定义责任的独立实体。这种区分有助于关注点分离,增强可测试性,并促进可重用性。

  • UI或UI元素,在屏幕上呈现数据。
  • UI状态描述要在屏幕上呈现的数据。如果UI代表用户所见的内容,那么UI状态就是应用程序指定用户应该看到的内容。
  • 引入了一个可选的状态持有者,以简化UI,管理部分逻辑,保存其UI状态,并将其暴露给UI。当UI内部状态和逻辑的复杂性增加,导致更难以理解时,就会使用状态持有者。

UI层中的实体:UI、UI状态和状态持有者

单向数据流

然而,应用程序并不显示静态信息。用户经常与其进行交互,执行可能修改应用程序状态的操作。用户事件通常由状态持有者处理,并在处理后可能导致UI状态的变化。在这种情况下,UI状态不是静态的。状态持有者将公开一个UI状态的流,其发射的数据会立即反映在UI上。文档中也将此概念称为单向数据流(Unidirectional Data Flow,UDF)。

https://developer.android.com/topic/architecture#unidirectional-data-flow

在单向数据流中,事件从UI流向状态持有者,状态从状态持有者流向UI

UI

文档和本博文中的指导适用于View系统和Jetpack Compose。无论您选择哪种UI工具包,UI在UI层中的角色保持独立。

在考虑UI层时,开发人员往往将UI层仅仅看作屏幕级别的一部分 - 即在可用显示区域的大部分地方显示应用程序数据的UI树的那一部分。通常,开发人员使用androidx.ViewModel作为状态持有者的实现细节。

然而,就像为处理不同类型的数据(例如PaymentsRepositoryUserRepository等)创建“多个”数据层一样,您可以灵活地在需要的UI树或UI层次结构的任何位置引入UI层实体。这个决策的粒度取决于您的UI的复杂性。

您可以在UI树的任何部分引入UI层中的不同实体。例如,在屏幕、导航栏或仅仅一个按钮中。

正如我们将在状态持有者部分看到的那样,您可以在UI树的任何位置引入状态持有者以简化UI。实际上,在某些情况下,这是推荐的。

UI状态

UI状态描述了要在屏幕上显示的信息。在本节中,我们将看到如何建模、生成和观察UI状态。

UI状态的类型*
通常需要特殊处理的一种UI状态子类型是屏幕UI状态。这通常来自于数据层公开的应用程序状态。之所以特别提到它,是因为它包含了大部分在屏幕上显示的信息,这与用户通常最感兴趣的内容相符。
作为一种特殊类型的UI状态,屏幕UI状态通常包含数据层公开的应用程序数据

作为稍后即将介绍的内容的预览,重要的是要注意,屏幕UI状态应该在配置更改时持久化或缓存。

如何生成UI状态

生成UI状态是状态持有者处理某些输入的输出。这些输入可以是:1)事件,2)本地状态变化的源,或者3)外部状态变化的源。

UI状态是状态持有者处理某些输入后的输出

在不同情况下应该使用哪些API?

  • UI状态应该作为可观察的数据持有类公开(例如StateFlowCompose State<T>LiveData)。这种类型确保UI始终具有要在屏幕上呈现的UI状态。
  • 输入可以采用各种形式,主要是作为数据流或一次性API。
    让我们来看几个例子!

使用本地状态变化源生成UI状态

想象一下,我们正在一个屏幕上,允许用户掷两个骰子。除了显示骰子的值之外,我们还想跟踪用户掷骰子的次数。我们的UI状态可能如下所示:

data class DiceRollUiState(val firstDiceValue: Int? = nullval secondDiceValue: Int? = nullval numberOfRolls: Int = 0
)

掷骰子的业务逻辑是通过一次性调用Random API 实现的。

firstDiceValue = Random.nextInt(1..6),
secondDiceValue = Random.nextInt(1..6),
numberOfRolls = currentUiState.numberOfRolls + 1

那么,我们如何在状态持有者中保存这个UI状态呢?创建一个可观察的数据持有类!在这个例子中,我们使用MutableStateFlow API来实现这一点。为了避免直接依赖于Random API,这可能会影响可重用性和可测试性,我们引入了一个更通用的RandomProvider接口,其实现是Random API。

class DiceRollStateHolder(private val randomProvider: RandomProvider
) {private val _uiState = MutableStateFlow(DiceRollUiState())val uiState: StateFlow<DiceRollUiState> = _uiState.asStateFlow()fun rollDice() {_uiState.update { currentState ->currentState.copy(firstDiceValue = randomProvider.nextInt(1..6),secondDiceValue = randomProvider.nextInt(1..6),numberOfRolls = currentState.numberOfRolls + 1)}}
}

生成这个UI状态的业务逻辑是在状态持有者内部实现的。为了防止暴露可变版本的可观察状态持有者,从而允许直接修改UI状态并违反单一真相来源原则,我们将UI状态作为StateFlow公开。uiState是我们可变状态的只读版本,我们使用.asStateFlow操作符进行转换。

注意:除了MutableStateFlow,我们还可以使用Compose State<T>或LiveData来建模我们的UI状态。有关在这种上下文中使用Compose State<T>的模式和最佳实践,请参阅状态生成文档。

使用外部状态变化源生成UI状态

应用程序数据以数据流的形式来自层次结构的其他层。为了将这些数据适应UI状态,我们必须将其转换为可观察的数据持有类型。在下面的示例中,我们通过在屏幕上显示用户的姓名来向用户打招呼。

class DiceRollViewModel(userRepository: UserRepository
) : ViewModel() {val userUiState: StateFlow<String> =userRepository.userStream.map { user -> user.name }.stateIn(scope = viewModelScope,started = SharingStarted.WhileSubscribed(5_000),initialValue = "")
}

状态持有者作为依赖项获取数据层的实例(例如UserRepository)。然后,它将userStream: Flow映射为我们感兴趣的特定信息,这在本例中是用户的姓名。由于map操作符返回一个Flow,我们使用.stateIn操作符将Flow转换为StateFlow,即可观察的数据持有类型。

在处理来自层次结构其他层的Flows和/或组合多个数据流时,.stateIn是状态持有者中常用的操作符。它的方法定义包含以下内容:

  • scope:定义结果StateFlow的生命周期。
  • started:确定启动和停止共享的策略。在代码片段中,我们使用WhileSubscribed(5_000)来停止从上游流(例如来自UserRepository的流)收集数据,当特定时间内没有收集器/观察者时,例如5秒。通过这种方式,如果UI对用户不可见超过5秒,我们可以取消那些数据层流的收集,并节省资源以保护设备的健康。
  • initialValue:指定StateFlow的初始值。如前所述,使用可观察的状态持有类型可以确保UI始终有UI状态可以在屏幕上呈现,而该参数在实现此目标方面起着关键作用。

生成UI状态的总结

让我们根据输入类型和源API的类型总结要公开的类型:

  1. 如果您使用的是一次性API或本地业务逻辑,请使用MutableStateFlowCompose MutableState<T>在状态持有者中存储状态。然后,将其公开为StateFlowCompose State<T>
  2. 当源类型是作为Flow提供的外部流时,您应该公开一个StateFlow
  3. 如果您同时处理两种类型的输入,例如至少有一个外部流,请组合所有输入,并将UI状态公开为StateFlow

Summary of the UI state exposed type given the source type and API

如何建模 UI 状态

UI 状态描述了特定时间点的用户界面。UI 是 UI 状态的可视表示。我们之前在上面的 DiceRollUiState 代码片段中定义了一个数据类作为 UI 状态。这里是它的定义:

data class DiceRollUiState(val firstDiceValue: Int? = null,val secondDiceValue: Int? = null,val numberOfRolls: Int = 0
)

UI 状态中的字段非常重要的是不可变的(即 val),以确保时间和一致性保证。通常,UI 状态字段具有合理的默认值以便于创建和复制。然而,并非所有的 UI 状态都像前面那个例子那样简单明了。

让我们考虑另一种情况,即当用户登录后才能掷骰子的场景。当用户进入界面时,我们会检查用户状态并做出决定。以下是这种情况下可能的 UI 状态:

sealed interface DiceRollUiState {data object Loading : DiceRollUiStatedata class DiceRoll(val username: String,val numberOfRolls: Int,val firstDiceValue: Int? = null,val secondDiceValue: Int? = null) : DiceRollUiStatedata object LogUserIn : DiceRollUiState}

UI 状态可以是加载状态(Loading),表示用户需要登录状态(LogUserIn),或者在屏幕上显示带有用户名的骰子掷出值(DiceRoll)。

何时使用数据类、密封接口/类或两者结合使用?

当屏幕可能处于多个互斥状态时,请使用密封接口/类。
当其中的数据可能发生变化时,请使用数据类。这在采用离线优先方法的屏幕中特别有用,因为屏幕可能同时显示加载指示、数据和错误消息。

如何建模复杂的 UI 状态

在处理复杂的屏幕时,您需要确保不会创建 UI 不一致性。作为练习,让我们尝试对 Jetnews 的主屏幕进行建模,Jetnews 是一个 Compose 示例应用程序的主屏幕。

https://github.com/android/compose-samples/tree/main/JetNews
Home screen of Jetnews displayed on a tablet

屏幕的主要内容显示了一系列文章以及一个打开的文章详情部分,用户可以在其中阅读文章。作为建模整个 UI 屏幕的初步步骤,我们可以定义以下 UI 状态:

private data class HomeViewModelState(val postsFeed: PostsFeed? = null,val selectedPostId: String? = null,val isArticleOpen: Boolean = false,val favorites: Set<String> = emptySet(),val isLoading: Boolean = false,val errorMessages: List<ErrorMessage> = emptyList(),val searchInput: String = ""
)

然而,这里存在一个问题。你能发现它吗?由于默认值的存在,我们可能会创建出不一致的 UI 状态!我们可能会有一个具有 selectedPostId 但没有 postsFeed 的 UI 状态。这是不现实的,不应该发生。为了解决这个问题,我们需要引入更强类型的状态来防止出现这些问题。考虑到我们的业务需求允许在屏幕上显示帖子或者什么都不显示,我们可以在该状态之上引入一个密封接口:

sealed interface HomeUiState {val isLoading: Booleanval errorMessages: List<ErrorMessage>val searchInput: Stringdata class NoPosts(override val isLoading: Boolean = false,override val errorMessages: List<ErrorMessage> = emptyList(),override val searchInput: String = "") : HomeUiStatedata class HasPosts(val postsFeed: PostsFeed,val selectedPost: Post,val isArticleOpen: Boolean,val favorites: Set<String>,override val isLoading: Boolean = false,override val errorMessages: List<ErrorMessage> = emptyList(),override val searchInput: String = "") : HomeUiState
}

现在,我们的 UI 可能会显示 HasPostsNoPosts。在 HasPosts 变体中,不可能有一个 selectedPost 而没有现有的 postsFeed。问题解决了!虽然我们对 UI 状态的初始近似可能对于私下建模整个 UI 状态仍然有用,但这种类型永远不会公开。最终,您将把该状态映射到 HomeUiState

private data class HomeViewModelState(...) {fun toUiState(): HomeUiState =if (postsFeed == null) {HomeUiState.NoPosts(...)} else {HomeUiState.HasPosts(...)}
}

公开单个与多个 UI 状态流

关于状态持有者是否应该公开单个或多个数据流,我们经常进行讨论。

到目前为止,我们一直建议如果字段之间存在依赖关系,那么应该公开单个 UI 状态流。另一方面,如果这些字段彼此独立,不会导致 UI 不一致性,那么可以公开多个流是可以接受的。

有人可能会争辩说,如果它们完全独立,那意味着它们影响 UI 的不同部分,并且每个部分都可以拥有自己的状态持有者。当然,我同意。但如果你不想创建多个状态持有者,并从更高级别的状态持有者中公开多个 UI 状态,那也是可以接受的。

如何消费 UI 状态

理想情况下,应该以生命周期感知的方式从 UI 中消费 UI 状态。也就是说,只有当 UI 在屏幕上可见时才进行消费。在 Android 生命周期中,这是当生命周期处于 STARTEDSTOPPED 状态之间时。有不同的 API 可以方便地实现这一点。

对于 Android Views,您可以使用位于 androidx.lifecycle.lifecycle-runtime-ktx 组件中的 repeatOnLifecycleflowWithLifecycle API。以下是使用 repeatOnLifecycle 的示例:

class SomeActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {// ...lifecycleScope.launch {repeatOnLifecycle(Lifecycle.State.STARTED) {viewModel.uiState.collect {// 新的 UI 状态!更新 UI}}}}
}

repeatOnLifecycle 协程块内,您从 UI 状态中进行收集。repeatOnLifecycle 会自动创建一个新的协程,在生命周期达到该状态时执行该块,并在生命周期低于该状态时取消正在运行该块的协程。

在 Compose 中,可以使用 collectAsStateWithLifecycle API,该 API 在内部使用 repeatOnLifecycle API。它位于 androidx.lifecycle.lifecycle-runtime-compose 组件中。此 API 根据给定的生命周期 State 收集底层 flow,并将 flow 的最新值表示为 Compose State<T>。这允许可组合函数在发出新元素时进行重新组合。

@Composable
fun SomeScreen(modifier: Modifier = Modifier,viewModel: SomeViewModel = viewModel()
) {val uiState: SomeUiState by viewModel.uiState.collectAsStateWithLifecycle()// 根据 uiState 发射 UI。SomeScreen 将在 `viewModel.uiState` 发出新值时重新组合。
}

现在您已经阅读了 UI 层速成课程, 应该对该层中存在的不同实体有了一个大致的理解,以及如何有效地考虑 UI 和 UI 状态。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/238459.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

自动评估作业,支持订正最终得分、查看关联代码|ModelWhale 版本更新

冬至时节&#xff0c;2023 已进入尾声&#xff0c;ModelWhale 于今日迎来新一轮的版本更新&#xff0c;与大家一起静候新年的到来。 本次更新中&#xff0c;ModelWhale 主要进行了以下功能迭代&#xff1a; 自动评估作业 新增 提交代码&#xff08;团队版✓ &#xff09;新增…

ARM GIC(四) gicv3架构基础

GICv3架构是GICv2架构的升级版&#xff0c;增加了很多东西。变化在于以下&#xff1a; 使用属性层次&#xff08;affinity hierarchies&#xff09;&#xff0c;来对core进行标识&#xff0c;使gic支持更多的core 将cpu interface独立出来&#xff0c;用户可以将其设计在core…

Vue CLI 设置 publicPath:打包后的应用可部署在任意路径

前言 领导要重新部署多个应用环境&#xff0c;且不受路径层级影响。 于是找到了 Vue CLI 配置 publicpath 配置说明 下图所示&#xff1a; / &#xff1a;默认值&#xff0c;应用部署在根路径上&#xff1b;./&#xff1a;注意前面加了一个点&#xff0c;应用可部署在任意路…

算法基础之扩展欧几里得算法

扩展欧几里得算法 核心思想&#xff1a;裴蜀定理 : 欧几里得算法: 辗转相除法求最大公约数 传入参数(int a,int b,int &x,int &y) 递归(int b,int a%b,int y,int x) xy换位置 方便计算(推公式) #include<iostream>#include<algorithm>using namespac…

yolo-nas无人机高空红外热数据小目标检测(教程+代码)

前言 YOLO-NAS是目前最新的YOLO目标检测模型。从一开始&#xff0c;它就在准确性方面击败了所有其他 YOLO 模型。与之前的 YOLO 模型相比&#xff0c;预训练的 YOLO-NAS 模型能够以更高的准确度检测更多目标。但是我们如何在自定义数据集上训练 YOLO NAS&#xff1f; 这将是我…

效果图云渲染是什么意思?如何渲染出照片级别的效果图?

​在当前的建筑规划、室内装修以及电影视效制作等行业内&#xff0c;制作高质量的效果图起着至关重要的作用&#xff0c;因为它能够给予观众或客户极为逼真和吸引人的视觉体验。在此篇文章中&#xff0c;我们将深入了解什么是云端效果图渲染&#xff0c;并探讨如何运用Renderbu…

MySQL 分表真的能提高查询效率?

背景 首先我们以InnoDB引擎&#xff0c;BTree 3层为例。我们需要先了解几个知识点&#xff1a;页的概念、InnoDB数据的读取方式、什么是树搜索&#xff1f;、一次查询花费的I/O次数&#xff0c;跨页查询。 页的概念 索引树的页&#xff08;page&#xff09;是指存储索引数据…

7-1 建立二叉搜索树并查找父结点(PTA - 数据结构)

按输入顺序建立二叉搜索树&#xff0c;并搜索某一结点&#xff0c;输出其父结点。 输入格式: 输入有三行&#xff1a; 第一行是n值&#xff0c;表示有n个结点&#xff1b; 第二行有n个整数&#xff0c;分别代表n个结点的数据值&#xff1b; 第三行是x&#xff0c;表示要搜索值…

Kylin基础知识点解析与应用探索

目录 学习目标&#xff1a; 学习内容&#xff1a; 学习时间&#xff1a; 学习产出&#xff1a; Kylin简介 什么是Kylin Kylin的历史和发展 Kylin在大数据领域的地位和作用 Kylin架构 Kylin的组成部分和模块 Kylin的工作原理和流程 Kylin与其他大数据组件的关系和集成 Kylin功能…

链接未来:深入理解链表数据结构(二.c语言实现带头双向循环链表)

上篇文章简述讲解了链表的基本概念并且实现了无头单向不循环链表&#xff1a;链接未来&#xff1a;深入理解链表数据结构&#xff08;一.c语言实现无头单向非循环链表&#xff09;-CSDN博客 那今天接着给大家带来带头双向循环链表的实现&#xff1a; 文章目录 一.项目文件规划…

Java商城免 费 搭 建:VR全景到SAAS,各种模式一网打尽!

一、技术选型 java开发语言&#xff1a;java是一种跨平台的编程语言&#xff0c;适用于大型企业级应用开发。使用java开发直播商城可以保证系统的稳定性和可扩展性。 spring boot框架&#xff1a;spring boot是一个快速构建spring应用的框架&#xff0c;简化了开发过程&#xf…

人工智能对网络安全的影响

技术的快速发展带来了不断增长的威胁环境&#xff0c;网络犯罪分子和恶意行为者利用我们互联世界中的漏洞。在这个数字时代&#xff0c;数据泄露和网络攻击呈上升趋势&#xff0c;仅靠传统的安全措施已经不够了。人工智能 &#xff08;AI&#xff09; 的进步彻底改变了网络安全…

LeetCode 21 合并两个有序链表

题目描述 合并两个有序链表 将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 示例 1&#xff1a; 输入&#xff1a;l1 [1,2,4], l2 [1,3,4] 输出&#xff1a;[1,1,2,3,4,4]示例 2&#xff1a; 输入&#xff1a;l1 [],…

由浅入深走进Python异步编程【多进程】(含代码实例讲解 || multiprocessing、异步进程池、进程通信)

写在前面 从底层到第三方库&#xff0c;全面讲解python的异步编程。这节讲述的是python的多线程实现&#xff0c;纯干货&#xff0c;无概念&#xff0c;代码实例讲解。 本系列有6章左右&#xff0c;点击头像或者专栏查看更多内容&#xff0c;陆续更新&#xff0c;欢迎关注。 …

高镍正极材料湿法回收除硼树脂

#高镍正极材料湿法回收除硼树脂 锂离子电池高镍正极材料具备高能量密度&#xff0c;能够满足现有电动交通工具对于高续航里程的要求&#xff0c;是现在市场中最受青睐的正极材料之一&#xff0c;然而&#xff0c;组分中高镍含量给材料带来高容量的同时也使材料稳定性变差&#…

大模型ChatGLM下载、安装与使用

在人工智能领域&#xff0c;清华技术成果转化的公司智谱AI启动了支持中英双语的对话机器人ChatGLM内测。ChatGLM是一个初具问答和对话功能的千亿中英语言模型&#xff0c; 并针对中文进行了优化&#xff0c;现已开启邀请制内测&#xff0c;后续还会逐步扩大内测范围。 ChatGLM…

【数据结构和算法】最大连续1的个数 III

其他系列文章导航 Java基础合集数据结构与算法合集 设计模式合集 多线程合集 分布式合集 ES合集 文章目录 其他系列文章导航 文章目录 前言 一、题目描述 二、题解 2.1 方法一&#xff1a;滑动窗口 2.2 滑动窗口解题模板 三、代码 3.1 方法一&#xff1a;滑动窗口 四、…

头歌—衍生密码体制

# 第1关&#xff1a;Rabin密码体制 题目描述 任务描述 Rabin密码体制是RSA密码体制的一种。 本关任务&#xff1a;使用Rabin密码体制对给定的明文进行加密。 相关知识 为了完成本关任务&#xff0c;你需要掌握&#xff1a;Rabin密码体制。 Rabin密码体制 在本关中&#x…

rk3588多模型检测部署quickrun

quickrun 是一款rk3588 rknn多模型高效高并发部署软件 软件框架 采用session思想&#xff0c;可以定义多个session满足不同模型的义务需求。比如充电桩检测&#xff0c;垃圾分类&#xff0c;悬崖检测&#xff0c;模型共用一个摄像头&#xff0c;采用yolov5的模型。 采用消息…