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.rhkb.cn/news/108753.html

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

相关文章

【jvm】双亲委派机制

目录 一、说明二、工作原理三、优势四、图示 一、说明 1.java虚拟机对class文件采用的是按需加载的方式&#xff0c;当需要使用该类时才会将它的class文件加载到内存生成class对象 2.加载某个类的class文件时&#xff0c;java虚拟机采用双亲委派模式&#xff0c;即把请求交给由…

Spring Authorization Server入门 (十六) Spring Cloud Gateway对接认证服务

前言 之前虽然单独讲过Security Client和Resource Server的对接&#xff0c;但是都是基于Spring webmvc的&#xff0c;Gateway这种非阻塞式的网关是基于webflux的&#xff0c;对于集成Security相关内容略有不同&#xff0c;且涉及到代理其它微服务&#xff0c;所以会稍微比较麻…

怎么把pdf转换成jpg格式?

怎么把pdf转换成jpg格式&#xff1f;在我们日常的办公过程中&#xff0c;PDF文件是一个经常被使用来传输文件的格式。它能够确保我们的文件内容不会混乱&#xff0c;并以更加完美的方式呈现出来。然而&#xff0c;PDF文件也存在一些缺陷。例如&#xff0c;它无法直接编辑&#…

React+Typescript从请求数据到列表渲染

我们在项目src目录下创建一个目录 叫 pages 在里面创建一个组件叫 list.tsx 这里 我启动了自己的java项目 创建接口 你们就也需要弄几个自己的接口做测试 然后 list.tsx 编写代码如下 import * as React from "react";export default class hello extends React.C…

【seaweedfs】3、f4: Facebook’s Warm BLOB Storage System 分布式对象存储的冷热数据

论文地址 Facebook的照片、视频和其他需要可靠存储和快速访问的二进制大型对象(BLOB)的语料库非常庞大&#xff0c;而且还在继续增长。随着BLOB占用空间的增加&#xff0c;将它们存储在我们传统的存储系统-- Haystack 中变得越来越低效。为了提高我们的存储效率(以Blob的有效复…

基于安卓的考研助手系统app 微信小程序

&#xff0c;设计并开发实用、方便的应用程序具有重要的意义和良好的市场前景。HBuilder技术作为当前最流行的操作平台&#xff0c;自然也存在着大量的应用服务需求。 本课题研究的是基于HBuilder技术平台的安卓的考研助手APP&#xff0c;开发这款安卓的考研助手APP主要是为了…

计算机系统真题

计算机系统真题 考点计算机系统存储体系磁盘调度算法 考点 计算机系统 PC找到指令&#xff0c;存储到IR中 根据ID分析指令的操作&#xff0c;并执行指令,AR访问操作数 A pc存指令的地址 内存按照字节编址&#xff1a; 在统一单位&#xff0c;转换一下&#xff1a; 3x2的平方 …

Unix时间戳

江科大学习记录 Unix时间戳 Unix 时间戳&#xff08;Unix Timestamp&#xff09;定义为从UTC/GMT的1970年1月1日0时0分0秒开始所经过的秒数&#xff0c;不考虑闰秒时间戳存储在一个秒计数器中&#xff0c;秒计数器为32位/64位的整型变量世界上所有时区的秒计数器相同&#xf…

HTML番外篇(五)-移动端适配

一、媒体查询 1.认识媒体查询 媒体查询是一种提供给开发者针对不同设备需求进行定制化开发的一个接口。 你可以根据设备的类型&#xff08;比如屏幕设备、打印机设备&#xff09;或者特定的特性(比如屏幕的宽度)来修改你的页面。 媒体查询的使用方式主要有三种&#xff1a;…

算法笔记:球树

1 KD树的问题 算法笔记&#xff1a;KD树_UQI-LIUWJ的博客-CSDN博客 在kd树中&#xff0c;导致性能下降的最核心因素是因为kd-tree中被分割的子空间是一个个的超方体&#xff0c;而求最近邻时使用的是欧式距离&#xff08;超球&#xff09;。超方体与超球体相交的可能性是极高…

【VsCode】SSH远程连接Linux服务器开发,搭配cpolar内网穿透实现公网访问(1)

文章目录 前言1、安装OpenSSH2、vscode配置ssh3. 局域网测试连接远程服务器4. 公网远程连接4.1 ubuntu安装cpolar内网穿透4.2 创建隧道映射4.3 测试公网远程连接 5. 配置固定TCP端口地址5.1 保留一个固定TCP端口地址5.2 配置固定TCP端口地址5.3 测试固定公网地址远程 前言 远程…

SpringBootWeb案例 Part3

目录 1. 新增员工 1.1 需求 1.2 接口文档 1.3 思路分析 PostMapping RequestBody //把前端传递的JSON数据填充到实体类中 1.4 功能开发 1.5 功能测试 1.6 前后端联调 2. 文件上传 2.1 文件上传简介 Spring中提供了一个API&#xff1a;MultipartFile&#xff0c;使…

爬虫逆向实战(二十一)-- 某某点集登录与获取数据

登录 一、数据接口分析 主页地址&#xff1a;某某点集 1、抓包 通过抓包可以发现登录接口是phonePwdLogin 2、判断是否有加密参数 请求参数是否加密&#xff1f; 通过查看“载荷”模块可以发现有pwd和sig两个加密参数 请求头是否加密&#xff1f; 无响应是否加密&#x…

144. 二叉树的前序遍历-C++

题目来源&#xff1a;力扣 示例 1&#xff1a; 输入&#xff1a;root [1,null,2,3] 输出&#xff1a;[1,2,3]示例 2&#xff1a; 输入&#xff1a;root [] 输出&#xff1a;[] 代码实现&#xff1a; class Solution { public:vector<int> preorderTraversal(TreeNo…

深入理解linux内核--程序的执行

可执行文件 在第一章中我们把进程定义为“执行上下文”。这就意味着进行特定的计算需要收集必要的信息&#xff0c;包括所访问的页&#xff0c;打开的文件&#xff0c;硬件寄存器的内容等等。 可执行文件是一个普通文件&#xff0c;它描述了如何初始化一个新的执行上下文&…

飞腾FT-2000/4、D2000 log报错指导(1)

在爱好者群中遇见了很多的固件问题,这里总结记录了大家的交流内容和调试心得。主要是飞腾桌面CPU FT-2000/4 D2000相关的,包含uboot和UEFI。希望对大家调试有所帮助。 这个专题会持续更新,凑够一些就发。 1 UEFI启动时报错: ASSERT_EFI_ERROR (Status = Not Found) ASS…

ubuntu20.04 直接安装vpp23.06 测试双 VPP Tunnel Ike2

环境信息&#xff1a;VMware Workstation 17 Pro ubuntu20.04 (清华源) ubuntu 源点进去选&#xff1a;ubuntu-22.04.3-desktop-amd64.iso 如果之前装过VPP&#xff0c;用以下命令确定是否卸载干净&#xff1a; dpkg -l | grep vpp dpkg -l | grep DPDK 卸载&#xff1a; …

【springboot】WebScoket双向通信:

文章目录 一、介绍&#xff1a;二、案例&#xff1a;三、使用&#xff1a;【1】导入WebSocket的maven坐标【2】导入WebSocket服务端组件WebSocketServer&#xff0c;用于和客户端通信【3】导入配置类WebSocketConfiguration&#xff0c;注册WebSocket的服务端组件【4】导入定时…

DevOps系列文章 之 Python基础

Python语法结构 语句块缩进 1.python代码块通过缩进对齐表达代码逻辑而不是使用大括号 2.缩进表达一个语句属于哪个代码块 3.缩进风格 &#xff1a; 建议使用四个空格 如果是Linux系统的话&#xff0c;可以这样做&#xff0c;实现自动缩进 &#xff1a; vim ~/.vimrc set ai…

中国芯,寻找新赛道迫在眉睫

北京华兴万邦管理咨询有限公司 商瑞 陈皓 近期国内半导体行业的热点可以用两个“有点多”来描述&#xff0c;一个是中国芯群体中上市公司股价闪崩的有点多&#xff0c;另一个是行业和企业的活动有点多。前者说明了许多国内芯片设计企业&#xff08;fabless商业模式&#xff09;…