JetPack Compose系列之架构与分层

472 阅读12分钟

compose-roadmap.svg

前言

如何快速高效的掌握一门学问,建议先阅读下这篇文章关于学习的一些看法

码字不易,记得关注+点赞+收藏

该系列的其他文章:JetPack Compose系列之总览

概述

在 Compose 中,界面是不可变的,在绘制后无法进行更新。但可以控制的是界面的状态。每当界面的状态发生变化时,Compose 都会重新创建界面树中已更改的部分。可组合项可以接受状态并公开事件,例如 TextField 接受值并公开请求回调处理程序更改值的回调 onValueChange

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

由于可组合项接受状态并公开事件,因此单向数据流模式非常适合 Jetpack Compose。本篇文章将重点介绍如何在 Compose 中实现单向数据流模式,如何实现事件和状态容器,以及如何在 Compose 中使用 ViewModel。

注意:采用 Jetpack Compose 不会影响应用的其他层(数据层和业务层)。详细可查看应用架构指南

单向数据流

单向数据流 (UDF) 是一种设计模式,在该模式下状态向下流动事件向上流动。通过采用单向数据流,可在界面中显示状态的可组合项与应用中存储和更改状态的部分分离开来。

使用单向数据流的应用的界面更新循环如下所示:

  • 事件:界面的某一部分生成一个事件,并将其向上传递,例如将按钮点击传递给 ViewModel 进行处理;或者从应用的其他层传递事件,如指示用户会话已过期。
  • 更新状态:事件处理脚本可能会更改状态。
  • 显示状态:状态容器向下传递状态,界面显示此状态。

图 1. 单向数据流。

使用 Jetpack Compose 时遵循此模式可带来下面几项优势:

  • 可测试性:将状态与显示状态的界面分离开来,更方便单独对二者进行测试。
  • 状态封装:因为状态只能在一个位置进行更新,并且可组合项的状态只有一个可信来源,所以不太可能由于状态不一致而出现 bug。
  • 界面一致性:通过使用可观察的状态容器,例如 StateFlow 或 LiveData,所有状态更新都会立即反映在界面中。

Jetpack Compose 中的单向数据流

可组合项基于状态和事件进行工作。例如,只有在更新其 value 参数并公开 onValueChange 回调(这是一个请求将值更改为新值的事件)时,TextField 才会更新。Compose 将 State 对象定义为值容器,而对状态值的更改会触发重组。可将状态保存在 remember { mutableStateOf(value) } 或 rememberSaveable { mutableStateOf(value) 中,具体取决于需要记住值的时长。

TextField 可组合项的值的类型为 String,因此该值可以来自任意位置,包括来自硬编码值、ViewModel 或从父级可组合项传入。不必将它保存在 State 对象中,但在调用 onValueChange 时需要更新该值。

要点mutableStateOf(value) 会创建一个 MutableState,后者是 Compose 中的可观察类型。如果其值有任何更改,系统会安排重组读取此值的所有可组合函数。

remember 会将对象存储在组合中,当调用 remember 的可组合项从组合中移除后,它会忘记该对象。

rememberSaveable 通过将状态保存在 Bundle 中来保留状态,使其在配置更改后仍保持不变。

定义可组合项参数

在定义可组合项的状态参数时,应牢记以下问题:

  • 可组合项的可重用性或灵活性如何?
  • 状态参数如何影响此可组合项的性能?

为了促进分离和重复使用,每个可组合项都应包含尽可能少的信息。例如,构建可组合项以保存新闻报道的标题时,最好仅传递需要显示的信息,而不是整篇新闻报道:

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}
ArchitectureSnippets.kt

有时,使用独立参数还能提高性能,例如,如果 News 包含的不仅仅是 title 和 subtitle 的信息,每当有 News 的新实例传入 Header(news) 时,即使 title 和 subtitle 没有变化,可组合项也将重组。

请仔细考虑传入的参数数量。如果一个函数拥有过多参数,会降低该函数的工效,因此在这种情况下,建议将这些参数分到一个类下。

Compose 中的事件

应用的每项输入都应表示为事件:点按、文本更改,甚至计时器或其他更新。当这些事件会更改界面的状态时,应由 ViewModel 来处理这些事件并更新界面状态。

界面层绝不应更改事件处理脚本之外的状态,因为这样做可能会导致应用出现不一致和 bug。

最好为状态和事件处理脚本 lambda 传递不可变值。此方法具有以下优势:

  • 提升可重用性。
  • 确保界面不会直接更改状态的值。
  • 避免并发问题,因为可确保不会从其他线程修改状态。
  • 通常情况下,还可以降低代码的复杂性。

例如,接受 String 和 lambda 作为参数的可组合项可以从许多上下文中调用,并且可重用性较高。假设应用中的顶部应用栏始终显示文本并包含返回按钮。可以定义一个更通用的 MyAppTopAppBar 可组合项,该可组合项用于接收文本和返回按钮句柄作为参数:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                    Icons.Filled.ArrowBack,
                    contentDescription = localizedString
                )
            }
        },
    )
}

ViewModel、状态和事件:示例

借助 ViewModel 和 mutableStateOf,如果出现以下任一情况,还可以在应用中引入单向数据流:

  • 界面的状态通过 StateFlow 或 LiveData 等可观察的状态容器公开。
  • ViewModel 处理来自应用界面或其他层的事件,并根据事件更新状态容器。

例如,在实现登录屏幕时,点按登录按钮应该会使应用显示一个进度旋转图标和网络调用。**如果登录成功,应用会转到其他屏幕;如果发生错误,应用会显示信息提示控件。以下是如何为屏幕状态和事件建模的方法:

该屏幕有四种状态:

  • 退出登录:当用户尚未登录时。
  • 进行中:当应用目前正在尝试通过执行网络调用来让用户登录时。
  • 错误:登录时出现错误。
  • 登录成功:用户登录后。

可以将这些状态建模为密封类。ViewModel 将状态公开为 State,设置初始状态并根据需要更新状态。ViewModel 还会通过公开 onSignIn() 方法来处理登录事件。

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState
}

除了 mutableStateOf API 之外,Compose 还提供 LiveDataFlow 和 Observable 的扩展,用于注册为监听器,并将值表示为状态。

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(UiState.SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
}

架构分层

Jetpack Compose 不是一个单体式项目;它由一些模块构建而成,这些模块组合在一起,构成了一个完整的堆栈。通过了解组成 Jetpack Compose 的不同模块,可以:

  • 使用适当的抽象级别来构建应用或库
  • 了解何时可以“降级”到较低级别,以获取更多的控制权或更高的自定义程度
  • 尽可能减少依赖项

Jetpack Compose 的主要层包括:

image.png

图 1.  Jetpack Compose 的主要层。

每一层均基于较低的层逐级构建,并通过组合功能来创建更高级别的组件。每一层都是基于较低层的公共 API 构建的,用于验证模块边界,还支持根据需要替换任何层。让我们自下而上地分析这些层。

  • 运行时

    此模块提供了 Compose 运行时的基本组件,例如 remembermutableStateOf@Composable 注释和 SideEffect。如果只需要 Compose 的树管理功能,而不需要其界面,则可以考虑直接基于此层进行构建。

  • 界面

    界面层由多个模块(ui-textui-graphics 和 ui-tooling 等)组成。这些模块实现了界面工具包的基本组件,例如 LayoutNodeModifier、输入处理程序、自定义布局和绘图。如果只需要用到界面工具包的基本概念,则可以考虑基于此层进行构建。

  • 基础

    此模块为 Compose 界面提供了与设计系统无关的构建块,例如 Row 和 ColumnLazyColumn、特定手势的识别等。可以考虑基于基础层构建自己的设计系统。

  • Material

    此模块为 Compose 界面提供了 Material Design 系统的实现,同时提供了一个主题系统以及若干样式化组件、涟漪效果指示元素和图标。在应用中使用 Material Design 时,不妨基于此层进行构建。

设计原则

Jetpack Compose 的一个指导原则是提供可以组合在一起的重点突出的小块功能片段,而不是几个单体式组件。这种方法有许多优点。

控制

更高级别的组件往往能完成更多操作,但拥有的直接控制权较少。如果需要更多控制权,可以通过“降级”使用较低级别的组件。

例如,如果想为某个组件的颜色添加动画效果,可以使用 animateColorAsState API:

    val color = animateColorAsState(if (condition) Color.Green else Color.Red)

不过,如果需要该组件始终从灰色开始,此 API 就无能为力了。可以改为下拉菜单使用较低级别的 Animatable API:

val color = remember { Animatable(Color.Gray) }
    LaunchedEffect(condition) {
        color.animateTo(if (condition) Color.Green else Color.Red)
    }

较高级别的 animateColorAsState API 本身基于较低级别的 Animatable API 构建而成。使用较低级别的 API 的过程更为复杂,但可提供更多的控制权。请选择最符合需求的抽象化级别。

自定义

通过将较小的构建块组合成更高级别的组件,可大幅降低按需自定义组件的难度。例如,可以考虑使用 Material 层提供的 Button 的实现

@Composable
    fun Button(
        content: @Composable RowScope.() -> Unit
    ) {
        Surface(/* … */) {
            CompositionLocalProvider(/* … */) { // set LocalContentAlpha
                ProvideTextStyle(MaterialTheme.typography.button) {
                    Row(
                        // …
                        content = content
                    )
                }
            }
        }
    }

Button 由 4 个组件组合而成:

  1. Material Surface:用于提供背景、形状和点击处理方式等。
  2. CompositionLocalProvider:用于在启用或停用相应按钮时更改内容的 Alpha 值
  3. ProvideTextStyle:用于设置要使用的默认文本样式
  4. Row:用于为相应按钮的内容提供默认布局政策

为了使结构更加清晰,我们省略了一些参数和注释,但整个组件只有 40 行左右的代码,因为它只是组合了这 4 个组件来实现该按钮。Button 等组件会自行判断它们需要公开哪些参数,同时在实现常见的自定义项和可能使组件更难使用的参数突增之间创造平衡。例如,Material 组件可提供 Material Design 系统中指定的自定义项,这样可以轻松遵循 Material Design 原则。

不过,如果希望在组件的参数之外进行自定义,则可以“降级”某个级别并为组件创建分支。例如,Material Design 指定按钮应具有纯色背景。如果需要渐变背景,Button 参数就不适用了,因为它不支持此选项。在此类情况下,可以将 Material Button 实现作为参考,并构建自己的组件:

@Composable
fun GradientButton(
    // …
    background: List<Color>,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Row(
        modifier = modifier
            .clickable(onClick = {})
            .background(
                Brush.horizontalGradient(background)
            )
    ) {
        CompositionLocalProvider(/* … */) { // set material LocalContentAlpha
            ProvideTextStyle(MaterialTheme.typography.button) {
                content()
            }
        }
    }
}

上述实现继续使用 Material 层中的组件,例如 Material 的当前内容 Alpha 值和当前文本样式的概念。不过,它会将 Material Surface 替换为 Row,并设置其样式以获得理想的外观。

注意:当“降级”到较低层以自定义组件时,请确保不会因忽视无障碍功能支持等原因而使任何功能发生降级。要为哪个组件创建分支,就应以哪个组件作为指导。

如果根本不想使用 Material 概念(例如,在构建自己的定制设计系统),则可以降级为仅使用基础层组件:

@Composable
fun BespokeButton(
    backgroundColor: Color,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Row(
        modifier = modifier
            .clickable(onClick = {})
            .background(backgroundColor)
    ) {
        content()
    }
}

Jetpack Compose 为最高级别的组件保留了最为简洁的名称。例如,androidx.compose.material.Text 基于 androidx.compose.foundation.text.BasicText 构建。这样一来,如果想替换更高级别,则可以为自己的实现提供更易于发现的名称。

注意:为组件创建分支意味着,不会从上游组件的任何未来增补项或 bug 修复中受益。

选择合适的抽象化级别

Compose 以构建可重复使用的分层组件作为理念,这意味着不应该始终以构建较低级别的构建块为目标。许多较高级别的组件不仅能够提供更多功能,而且通常还会融入最佳实践,例如支持无障碍功能等。

例如,如果想为自己的自定义组件添加手势支持,可以使用 Modifier.pointerInput 从头开始构建;但在此之上还有其他更高级别的组件,它们可以提供更好的起点,例如 Modifier.draggableModifier.scrollable 或 Modifier.swipeable

一般来讲,最好基于能提供所需功能的最高级别的组件进行构建,以便从其包含的最佳实践中受益。