Jetpack Compose UI架构

Jetpack Compose UI架构

引言

Jetpack Compose是我职业生涯中最激动人心的事。它改变了我工作和问题思考的方式,引入了易用且灵活的工具,几乎可轻松实现各种功能。

早期在生产项目中尝试了Jetpack Compose后,我迅速着迷。尽管我已有使用Compose创建UI的经验,但对新的Jetpack Compose驱动特性的组织和架构引发了许多反复。

本文目标是分享这些经验,提出可扩展、易用、易操作的架构,并接受反馈以进一步改进。

免责声明:本文仅涉及UI部分,其余应用构建遵循经典Clean Architecture方法。假设您熟悉Jetpack Compose,不深入讨论UI实现细节。

示例

为了提供具体示例,让我介绍一下本文将要介绍的示范项目。我们将要构建的应用程序允许用户在不同的地标之间进行切换并导航到它们。以下是基本流程的描述:

  • 用户可以滑动浏览地点卡片,查看有关地点的不同信息,如地点图片、名称和评分。
  • 用户可以将地点标记/取消标记为收藏。
  • 用户可以从其位置导航并规划前往所选地点的路线。为此,我们需要用户的位置权限。
  • 如果出现错误,我们希望显示一个消息提示。
  • 权限只有在用户选择规划路线时才会询问。如果用户拒绝权限,我们会导航到另一个屏幕(位置理由屏幕)。
  • 我们还希望跟踪用户与分析服务的交互。

基础知识

我对 Jetpack Compose 的最初记忆是这个方程式:UI = f(state)。这意味着 UI 是应用于某个状态的函数的结果。让我们简要回顾一下 Compose 和响应式 UI 的重要方面,特别是关于状态处理的内容:状态提升和单向数据流。

状态提升
状态提升是一种在软件开发中常用的技术,尤其在 UI 编程中,它将组件管理和操作状态的责任移至更高级的组件或更集中的位置。状态提升的目的是改善代码组织、可重用性和可维护性。你可以在这里了解更多关于状态提升的内容。

单向数据流
单向数据流(UDF)是一种设计模式,其中状态向下流动,事件向上流动。遵循单向数据流,你可以将在 UI 中显示状态的可组合项与存储和更改状态的应用程序部分解耦。

要点是,我们希望我们的 UI 组件消耗状态并发出事件。如果让我们的组件处理源自外部的事件,将打破这一规则,引入多个真相来源。重要的是,我们引入的任何“事件”都应该基于状态。

入门

首先,让我们介绍核心组件,这些是我们架构的基础。

State

我们从最明显的开始,即状态。状态可以是根据你的用例而定的任何内容。它可以是一个数据类,包含UI可能需要的所有属性,或者是一个封装接口,代表所有可能的情景。无论哪种情况,状态是你的组件或整个屏幕UI的“静态”表示,便于轻松操作。
根据我们的要求,我们有一个地点列表和一个可选的错误,所以我们的状态可能是这样的:

data class PlacesState(val places: List<Place> = emptyList(),val error: String? = null
)

Screen

屏幕是我们方程中的 f 函数。为了遵循状态提升模式,我们需要使该组件无状态并将用户交互暴露为回调。这将使我们的屏幕具有可测试性、预览性和可重用性!
我们已经有了状态,基于我们的需求,我们只需要处理两个用户交互。所以这就是我们的屏幕可能的样子。我们还包括了可能需要的其他组合状态,所以它们被提升到了屏幕外部。

@Composable
fun PlacesScreen(state: PlacesState,pagerState: PagerState,onFavoritesButtonClick: (Place) -> Unit,onNavigateToPlaceButtonClick: (Place) -> Unit
) {Scaffold {PlacesPager(pagerState = pagerState,state = state,onFavoritesButtonClick = onFavoritesButtonClick,onNavigateToPlaceButtonClick = onNavigateToPlaceButtonClick)}
}
@Composable
fun PlacesRoute(navController: NavController,viewModel: PlacesViewModel = hiltViewModel(),
) {// ... state collectionLaunchedEffect(state.error) {state.error?.let {context.showError()viewModel.dismissError()}}PlacesScreen(state = uiState,onFavoritesButtonClick = //..onNavigateToPlaceClick = {when {permissionState.isGranted -> {analyitcs.track("StartRoutePlanner")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})
}

Route

路由(Route)是整个流程的入口。

@Composable
fun PlacesRoute(navController: NavController,viewModel: PlacesViewModel = hiltViewModel(),
) {// ... state collectionLaunchedEffect(state.error) {state.error?.let {context.showError()viewModel.dismissError()}}PlacesScreen(state = uiState,onFavoritesButtonClick = //..onNavigateToPlaceClick = {when {permissionState.isGranted -> {analyitcs.track("StartRoutePlanner")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})
}

这是PlacesRoute函数的简化版本,但已经相当庞大。随着每个新的用户交互和基于状态的效果,这个函数的大小将会增长,使其变得更难理解和维护。另一个问题是回调函数。随着每个新的用户交互,我们将不得不在PlacesScreen的声明中添加另一个回调,这也可能会变得相当大。

另外,让我们考虑一下测试。我们可以轻松测试屏幕和ViewModel,但是Route呢?它有很多内容,不是每样东西都可以轻松模拟。首先,它与屏幕耦合在一起,所以如果没有引用它,我们将无法适当地进行单元测试。将其他组件替换为存根将需要我们将所有内容移到Route的声明中。

进行改变

让我们尝试解决我们迄今为止已经确定的这些问题

Action

在看到这些回调时,我脑海中首先想到的是如何将它们进行分组。而我当时所做的第一件事情是这样的:

sealed interface PlacesAction {data class NavigateToButtonClicked(val place: Place) : ParcelActiondata class FavoritesButtonClicked(val place: Place) : ParcelAction
}

虽然这使我们能够将我们的操作分组到一个明确定义的结构中,但也带来了不同的问题。

在屏幕级别上,我们将不得不实例化这些类并调用我们的onAction回调。如果你熟悉重组(Re-composition)的工作原理,当涉及到lambda表达式时,你可能还会有冲动将其包裹在remember中,以避免不必要的UI重新渲染。

@Composable
fun PlacesScreen(state: PlacesState,onAction: (PlacesAction) -> Unit
) {PlacesPager(onFavoritesButtonClicked = { onAction(PlacesAction.FavoritesButtonClicked(it))})
}

另一方面,Route还引入了另一件我不太喜欢的事情——可能是巨大的when语句。

PlacesScreen(state = uiState,onAction = { when(it) {FavoritesButtonClick = //..NavigateToPlaceClicked = {when {permissionState.isGranted -> {analyitcs.track("StartRoutePlanner")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})

所有这些都使我找到了一个更好的解决方案,那就是一个简单的数据类。

data class ParcelActions(val onFavoritesClicked: (Place) -> Unit = {},val onNavigateToButtonClicked: (Place) -> Unit = {},
)

这使我们能够在与屏幕相关的操作中引入相同的分组水平和便利性,以及一种更简单的方式将这些操作传递给相关组件。

@Composable
fun PlacesScreen(state: PlacesState,actions: PlacesActions
) {PlacesPager(onFavoritesButtonClicked = actions.onFavoritesButtonClicked,onNavigateToPlaceButtonClicked = actions.onNavigateToPlaceButtonClicked)
}

现在,在Route方面,我们还可以避免使用when语句,并引入以下实用程序,以便在每次重组时不会重新创建Actions类,使Route更加简洁。

@Composable
fun PlacesRoute(viewModel: PlacesViewModel,navController: NavController,
) {val uiState by viewModel.stateFlow.collectAsState()val actions = rememberPlacesActions(navController)LaunchedEffect(state.error) {state.error?.let {context.showError()viewModel.dismissError()}}PlacesScreen(state = uiState,actions = actions)}@Composable
fun rememberPlacesActions(navController: NavController,analytics: Analytics = LocalAnalytics.current,permissionState: PermissionState = rememberPermissionState(),
) : PlacesActions {return remember(permissionState, navController, analytics) {PlacesActsions(onNavigateToPlaceClick = {when {permissionState.isGranted -> {analyitcs.track("RoutePlannerClicked")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})}   
}

虽然PlacesRoute现在更加直观,但我们所做的只是将其所有的Actions逻辑移到另一个函数中,这既没有提高可读性,也没有提高可扩展性。此外,我们的第二个问题仍然存在——基于状态的效果。我们的UI逻辑现在也分散开来,引入了不一致性,并且我们并没有使其变得更具可测试性。现在是我们引入最后一个组件的时候了。

Coordinator

协调器的核心作用,正如你可能从其名称中猜到的,是协调不同的操作处理程序和状态提供者。协调器观察和响应状态变化,并处理用户操作。你可以将其视为我们流程的Compose状态。在我们简化的示例中,协调器的样子如下。
需要注意的是,由于我们的协调器现在不在可组合范围内,我们可以以更直接的方式处理一切,无需LaunchedEffect,就像我们通常在ViewModel中所做的那样,只不过这里的业务逻辑是UI逻辑。

class PlacesCoordinator(val viewModel: PlacesViewModel,val navController: NavController,val context: Context,private val permissionState: PermissionState,private val scope: CoroutineScope
) {val stateFlow = viewModel.stateFlowinit {// now we can observe our state and react to itstateFlow.errorFlow.onEach { error ->context.toast(error.message)viewModel.dismissError()}.launchIn(scope)}// and handle actionsfun navigateToRoutePlanner() {when {permissionState.isGranted -> {viewModel.trackRoutePlannerEvent()navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {viewModel.trackRationaleEvent()navController.navigate("LocationRationale")}else -> permissionState.launchPermissionRequest()}}}

我们的Action将修改成如下

@Composable
fun rememberPlacesActions(coordinator: PlacesCoordinator
) : PlacesActions {return remember(coordinator: PlacesCoordinator) {PlacesActsions(onFavoritesButtonClicked = coordinator.viewModel::toggleFavorites,onNavigateToPlaceButtonClicked = coordinator::navigateToRoutePlanner)
}

我们的Route修改如下

@Composable
fun PlacesRoute(coordinator: PlacesCoordinator = rememberPlacesCoordinator()
) {val uiState by coordinator.stateFlow.collectAsState()val actions = rememberPlacesActions(coordinator)PlacesScreen(state = uiState,actions = actions)}

在我们的示例中,PlacesCoordinator 现在负责在我们的功能流中发生的UI逻辑。由于它了解不同的状态,我们可以轻松地对状态变化做出反应,并为每个用户交互构建条件逻辑。如果交互很直接,我们可以轻松地将其委托给相关的组件,比如 ViewModel。
通过拥有协调器,我们还可以控制向屏幕公开哪些状态。如果我们有多个 ViewModel 或者 ViewModel 状态对于我们正在处理的屏幕来说过于庞大,我们可以将这些状态组合起来或者公开部分状态。

val screenStateFlow = viewModel.stateFlow.map { PartialScreenState() }// orval screenStateFlow = combine(vm1.stateFlow, vm2.stateFlow) { ScreenStateFlow() }

另一个好处是,整个流程的UI逻辑现在与Route解耦,这意味着我们可以将我们的Coordinator作为另一个Route的一部分使用,而无需复制重要内容并保持屏幕部分无状态。

@Composable
fun TwoPanePlacesRoute(detailsCoordinator: PlacesDetailsCoordinator,placesCoordinator: PlacesCoordinator
) {TwoPane(first = {PlacesScreen(state = placesCoordinator.state,actions = rememberPlacesActions(placesCoordinator))},second = {PlaceDetailsScreen(state = detailsCoordinator. state,actions = rememberDetailsActions(detailsCoordinator))})
}

最后,现在我们可以通过测试实现它的组件来测试我们的UI逻辑。让我们看看如何通过使用我们的“当权限被拒绝时导航到理由屏幕”来测试我们的Coordinator。
这部分假设您对如何测试Composable组件有一些了解。

fun test_NavigateToRatinoleIfPermissionWasDeniedBefore() {composeRule.setContent {// 1ComposableUnderTest(coordinator = rememberPlacesCoordinator(navController = testNavController,viewModel = NearbyPlacesViewModel()))}// 2composeRule.onNode(hasText("Navigate")).performClick()// 3Assert.assertEquals("locationRationale",navController.currentBackStackEntry?.destination?.route)
}

让我们快速浏览一下这个测试:

  1. 首先,我们发出了作为测试对象的Composable UI。这个UI的结构很简单,直接调用了我们的Coordinator。
 @Composable
private fun ComposableUnderTest(coordinator: NearbyPlacesCoordinator) {NavHost(navController = coordinator.navController,startDestination = "home") {composable("home") {Button(onClick = { coordinator.navigateToPlace(Place.Mock) }) {Text(text = "Navigate")                }}composable("locationRationale") {Text(text = "No permission")}}
}
  1. 其次,我们以编程方式点击“导航”按钮,触发操作并让Coordinator处理它。
  2. 最后,我们通过检查NavHostController中的当前目标是否与我们预期的目标一致来验证我们的假设是否有效,我们的实现是否正常工作。

总结一下我们进行的重构和取得的成就:

  1. 我们的Screen仍然完全是无状态的。它仅依赖于作为函数参数传递的内容。所有用户交互都通过Actions暴露给其他组件处理。
  2. Route现在在导航图中充当简单的入口点。它收集状态,在重新组合过程中记住我们的操作。
  3. Coordinator现在正在做大部分繁重的工作:响应状态变化并将用户交互委派给其他相关组件。它完全与Screen和Route解耦,可以在另一个路由中重用并单独测试。
    以下图表展示了我们现在拥有的数据流程。

问答时间

每个Compose屏幕都需要一个协调器吗?
简短的回答是:这取决于情况!对于一个非常简单的流程,比如一个带有两个操作的对话框,可能有点过于复杂。你可能会完全取消操作数据类,将这些操作放在路由中处理。对于一个随着时间复杂度增加的屏幕,我认为从一开始就值得投资,或者在看到路由增长时开始进行重构。

LaunchedEffect 是否已被“弃用”?
当然没有!同样,一个没有协调器的简单屏幕可以使用LaunchedEffect来对状态变化做出反应,这是完全可以的。当UI逻辑存在于屏幕层并在屏幕层中终止时,您仍然可以在屏幕中使用LaunchedEffect,例如动画。

路由没有做太多事情
是的,在我们的示例中,路由在责任方面相当轻量级。但将其作为导航入口意味着更多。许多不是基于状态的效果都属于路由的处理范畴。例如,我们可以使用SideEffect来调整颜色,或者放置BackHandler来拦截返回按钮的按下,这在屏幕内并不总是合适。

协调器会像路由一样随着时间而增长吗?
很可能是的。这可能是它正在做太多事情的迹象,其中一些事情可以提取到另一个具有状态的组件中,甚至是另一个协调器中。就像您从屏幕中提取不同的UI组件来封装一些UI一样,您可以构建其他组件或协调器来封装UI逻辑。

资源

Jetpack Compose UI Architecture IDE Plugin:https://plugins.jetbrains.com/plugin/19034-jetpack-compose-ui-architecture-templates
compose ui架构文档:https://levinzonr.github.io/compose-ui-arch-docs/

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

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

相关文章

tomcat 启动 java war 包

要启动一个Java WAR包的Tomcat服务器&#xff0c;您可以按照以下步骤操作&#xff1a; 确保您已经安装了Java Development Kit (JDK)。您可以在Oracle官方网站上下载并安装适合您操作系统的JDK版本。 下载和安装Tomcat服务器。您可以访问Apache Tomcat官方网站&#xff08;htt…

小项目01:尿试纸条没有放到位问题检测

目录 一、完整代码1.1 返回值含义1.2 main1.3 bvdetector.h1.4 bvdetector.cpp 二、图例1. 原图scrImg2.灰度图img_gray3.二值图binImg4.反二值图invImg 一、完整代码 1.1 返回值含义 0:表示正常&#xff08;有角度&#xff09;&#xff1b;1&#xff1a;表示没有图片&#x…

C语言实现:从RSA PEM文件中提取私钥n/e/d/p/q/dp/dq/qp因子

我们知道使用openssl命令行openssl rsa -in test_priv.pem -text 即可实现从私钥PEM文件中提取私钥因子&#xff1a;n/e/d/p/q/dp/dq/qp. 那么如何用C语言实现呢&#xff1f;如何在代码中实现呢&#xff1f; #include <stdio.h> #include <stdlib.h> #include &l…

el-button实现按钮,鼠标移入显示,移出隐藏

2023.8.18今天我学习了 如何实现鼠标移入显示按钮&#xff0c;鼠标移出隐藏按钮。 效果如图&#xff1a; 鼠标移入时&#xff1a; 鼠标移出时&#xff1a; mouseover //鼠标移入事件 mouseleave //鼠标移出事件 原本我是想直接在el-button写入这两个方法&#xff0c;但是elem…

sqlsugar 使用TNS连接oracle

在使用SqlSugar连接Oracle数据库时&#xff0c;可以通过TNS来实现连接。以下是一个示例代码&#xff0c;展示了如何使用TNS连接Oracle数据库&#xff1a; 首先&#xff0c;确保您已经安装了Oracle客户端&#xff0c;并正确配置了TNS的相关信息。 // 引入SqlSugar命名空间 usi…

Nuxt3环境变量配置

Nuxt3 正式发布还不到半年&#xff0c;在投入生产环境使用后&#xff0c;遇到了不少问题&#xff0c;很难找到合适的解决方案&#xff0c;其中环境变量配置就是其中一个&#xff0c;之前一直未能解决&#xff0c;最近要上持续集成&#xff0c;无法绕过这个问题&#xff0c;所以…

工厂方法模式介绍

韩敬海 设计模式&#xff08;Java版&#xff09; &#xff08;一&#xff09;定义 定义一个创建对象的接口&#xff0c;让子类决定实例化哪个类。工厂方法使一个类的实例化延迟到其子类。 工厂方法涉及的角色有&#xff1a; 1 .抽象工厂角色&#xff1a;工厂方法模式的核心&am…

clion软件ide的安装和环境配置@ubuntu

1.官网&#xff1a; Download CLion 2.安装Clion 直接在官网下载并安装即可&#xff0c;过程很简单 https://www.jetbrains.com/clion/ https://www.jetbrains.com/clion/download/#sectionlinux 3.激活码 4.配置Clion 安装gcc、g、make Ubuntu中用到的编译工具是gcc©…

Redis 列表 | Navicat

在最近的博客 文章 中&#xff0c;我们已经了解了 Redis 的六种数据类型。其中&#xff0c;Redis 列表&#xff08;List&#xff09;包含一组字符串&#xff0c;他们按照被添加的顺序进行排序。本文将就列表数据类型进行展开介绍&#xff0c;并且重点介绍一些主要的命令来管理它…

RPC和HTTP协议

RPC 全称&#xff08;Remote Procedure Call&#xff09;&#xff0c;它是一种针对跨进程或者跨网络节点的应用之间的远程过程调用协议。 它的核心目标是&#xff0c;让开发人员在进行远程方法调用的时候&#xff0c;就像调用本地方法一样&#xff0c;不需要额外为了完成这个交…

HAProxy 高级功能与配置

HAProxy 高级功能与配置 配置和验证的环境看这篇文章:HAProxy 各种调度算法介绍 一.基于 cookie 的会话保持 使用cookie关键字来配置后端服务器基于 cookie 的会话持久连接。 配置格式 cookie <name> [ rewrite | insert | prefix ] [ indirect ] [ nocache ][ post…

探究代理服务器在网络安全与爬虫中的双重作用

在如今高度互联的世界中&#xff0c;代理服务器已经成为网络安全和爬虫开发的关键工具。本文将深入探讨Socks5代理、IP代理、网络安全、爬虫、HTTP等关键词&#xff0c;以揭示代理服务器在这两个领域中的双重作用&#xff0c;以及如何充分利用这些技术来保障安全和获取数据。 …

制作 Mikrotik CHR AWS AMI 镜像

文章目录 制作 Mikrotik RouterOS CHR AWS AMI 镜像前言前期准备配置 Access Key安装配置 AWS CLI创建 S3 bucket上传 Mikrotik CHR 镜像trust-policy配置role-policy 配置创建 AMI导入镜像查看导入进度导入进度查看注册镜像参考:制作 Mikrotik RouterOS CHR AWS AMI 镜像 前言…

MyBatis动态SQL、模糊查询与结果映射

目录 前言 一、MyBatis动态SQL 1.动态SQL是什么 2.动态SQL的作用 3.常用动态SQL元素 1. where if 元素 2. set if 元素 3. choose when otherwise 元素 4. 自定义 trim 元素 <1>. 自定义 trim 元素改写上面的 where if 语句 <2>. 自定义 trim 元素改…

将AI融入CG特效工作流;对谈Dify创始人张路宇;关于Llama 2的一切资源;普林斯顿LLM高阶课程;LLM当前的10大挑战 | ShowMeAI日报

&#x1f440;日报&周刊合集 | &#x1f3a1;生产力工具与行业应用大全 | &#x1f9e1; 点赞关注评论拜托啦&#xff01; &#x1f916; 将AI融入CG特效工作流&#xff0c;体验极致的效率提升 BV1pP411r7HY 这是 B站UP主 特效小哥studio 和 拓星研究所 联合投稿的一个AI特…

SpringBoot 调用外部接口

SpringBoot 调用外部接口 一、第一种方式(HttpClient等) 使用插件方式&#xff0c;比如自带的HttpClient&#xff0c;或者OkHttp&#xff0c;甚至是原生的HttpURLConnection 等等&#xff0c;这里以HttpClient为例。 1、封装工具类 简单封装的get请求 /*** 发送get请求:带请求…

纹波和噪声测试知识

随着开关频率和开关速度不断的提升&#xff0c;在使用开关型的DC/DC电源的时候&#xff0c;要特别关注输入输出电源的纹波。但是测量DC/DC电源的纹波和噪声没有一个行业标准。不同厂家的测试环境以及测试标准都不太一样&#xff0c;导致很多人很迷惑。这篇文章提供了一个简单可…

go rpc

运用go标准库写一个rpc例子 服务端 package mainimport ("fmt""net""net/rpc" )//对象 type Hello struct { } //对象方法 func (h *Hello) HelloWorld(name string, resp *string) error {*resp name "你好"return nil }func mai…

赋能道路交通场景,九州未来助力建成广西交科车路协同智能网联示范园区

智慧高速车路协同需要更加稳固的技术基座 智慧高速是中国高速公路建设的热点之一&#xff0c;车路协同又是未来智慧高速建设的核心内容。高速公路运行环境相对简单、主体权责清晰、路侧机电设施齐全&#xff0c;具备开展车路协同创新示范的良好条件。 广西交科集团&#xff0…

编程锦囊妙计——快速创建本地Mock服务

点击上方&#x1f446;蓝色“Agilean”&#xff0c;发现更多精彩。 前情提要 在本系列上一篇文章《全文干货&#xff1a;打破前后端数据传递鸿沟&#xff0c;高效联调秘笈》中我们分享了使用Zod这一运行时类型校验库来对后端服务响应结果进行验证达到增加项目质量的方式。 这次…