架构层
每一层均基于较低的层逐级构建,并通过组合功能来创建更高级别的组件。每一层都是基于较低层的公共 API 构建的,用于验证模块边界,还支持根据需要替换任何层。
Material: 此模块为 Compose 界面提供了 Material Design 系统的实现,同时提供了一个主题系统以及若干样式化组件、涟漪效果指示元素和图标。在应用中使用 Material Design 时,不妨基于此层进行构建。
基础: 此模块为 Compose 界面提供了与设计系统无关的构建块,例如 Row 和 Column、LazyColumn、特定手势的识别等。可以考虑基于基础层构建自己的设计系统。
界面: 界面层由多个模块(ui-text、ui-graphics 和 ui-tooling 等)组成。这些模块实现了界面工具包的基本组件,例如 LayoutNode、Modifier、输入处理程序、自定义布局和绘图。如果只需要用到界面工具包的基本概念,则可以考虑基于此层进行构建。
运行时此模块提供了 Compose 运行时的基本组件,例如 remember、mutableStateOf、@Composable 注释和 SideEffect。如果只需要 Compose 的树管理功能,而不需要其界面,则可以考虑直接基于此层进行构建。
Jetpack Compose 的一个指导原则是提供可以组合在一起的重点突出的小块功能片段,而不是几个单体式组件。这种方法有许多优点。
控制:更高级别的组件往往能完成更多操作,但拥有的直接控制权较少。如果需要更多控制权,可以使用较低级别的组件。使用较低级别的 API 的过程更为复杂,但可提供更多的控制权。
自定义:通过将较小的构建块组合成更高级别的组件,可大幅降低按需自定义组件的难度。如果希望在组件的参数之外进行自定义,则可以“降级”并复刻某个组件。
选择合适的抽象化级别:Compose 以构建可重复使用的分层组件作为理念,这意味着不应该始终以构建较低级别的构建块为目标。许多较高级别的组件不仅能够提供更多功能,而且通常还会融入最佳实践,例如支持无障碍功能等。一般来讲,最好基于能提供所需功能的最高级别的组件进行构建,以便从其包含的最佳实践中受益。
在 Compose 中,界面是不可变的,在绘制后无法进行更新。可以控制的是界面的状态。每当界面的状态发生变化时,Compose 都会重新创建界面树中已更改的部分。可组合项可以接受状态并公开事件,例如 TextField 接受值并公开请求回调处理程序更改值的回调 onValueChange。
由于可组合项接受状态并公开事件,因此单向数据流模式非常适合 Jetpack Compose。
采用 Jetpack Compose 不会影响应用的其他层(数据层和业务层)。
单向数据流
单向数据流 (UDF) 是一种设计模式,在该模式下状态向下流动,事件向上流动。通过采用单向数据流,可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分分离开来。
使用单向数据流的应用的界面更新循环如下所示:
事件:界面的某一部分生成一个事件,并将其向上传递,例如将按钮点击传递给 ViewModel 进行处理;或者从应用的其他层传递事件,如指示用户会话已过期。
更新状态:事件处理脚本可能会更改状态。
显示状态:状态容器向下传递状态,界面显示此状态。
使用 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 中来保留状态,使其在配置更改后仍保持不变。
可组合项的生命周期
一个组合将描述应用的界面,并通过运行可组合项来生成。组合是描述界面的可组合项的树结构。
当 Jetpack Compose 首次运行可组合项时,在初始组合期间,它将跟踪为了描述组合中的界面而调用的可组合项。然后,当应用的状态发生变化时,Jetpack Compose 会安排重组。重组是指 Jetpack Compose 重新执行可能因状态更改而更改的可组合项,然后更新组合以反映所有更改。
组合只能通过初始组合生成且只能通过重组进行更新。重组是修改组合的唯一方式。
可组合项的生命周期通过以下事件定义:进入组合,执行 0 次或多次重组,然后退出组合。
重组通常由对 State<T> 对象的更改触发。Compose 会跟踪这些操作,并运行组合中读取该特定 State<T> 的所有可组合项以及这些操作调用的无法跳过的所有可组合项。
如果某一可组合项多次被调用,在组合中将放置多个实例。每次调用在组合中都有自己的生命周期。
组合中可组合项的实例由其调用点进行标识。Compose 编译器将每个调用点都视为不同的调用点。从多个调用站点调用可组合项会在组合中创建多个可组合项实例。
调用点是调用可组合项的源代码位置。这会影响其在组合中的位置,因此会影响界面树。
在重组期间,可组合项调用的可组合项与上个组合期间调用的可组合项不同,Compose 将确定调用或未调用的可组合项,对于在两次组合中均调用的可组合项,如果其输入未更改,Compose 将避免重组这些可组合项。
保留身份对于将附带效应与可组合项相关联十分重要,这样它们才能成功完成,而不是每次重组时都重新启动。
多次调用同一可组合项也会多次将其添加到组合中。如果从同一个调用点多次调用某个可组合项,Compose 就无法唯一标识对该可组合项的每次调用,因此除了调用点之外,还会使用执行顺序来区分实例。这种行为有时是必需的,但在某些情况下会导致发生意外行为。
使用 key 可组合项帮助 Compose 识别组合中的可组合项实例。当从同一个调用点调用多个可组合项,且这些可组合项包含附带效应或内部状态时,这一点非常重要。
在重组期间,如果某些符合条件的可组合函数的输入未从先前组合中发生变化,则可以完全跳过它们的执行。
可组合函数可以跳过,除非:
该函数具有非 Unit 返回值类型
该函数带有 @NonRestartableComposable 或 @NonSkippableComposable 注解。
必需参数属于非稳定类型
有一个实验性编译器模式: 强跳过,该模式放宽了最后一个要求。
为了让某个类型被视为稳定类型,它必须遵循以下协定:
对于相同的两个实例,其 equals 的结果将始终相同。
如果类型的某个公共属性发生变化,组合将收到通知。
所有公共属性类型也都是稳定。
此协定中有一些重要的通用类型,即使没有使用 @Stable 注解将其明确标记为稳定,Compose 编译器也会将其视为稳定类型:
所有基元值类型:Boolean、Int、Long、Float、Char 等。
字符串
所有函数类型 (lambda)
所有这些类型都可以遵循稳定协定,因为它们是不可变的。由于不可变类型绝不会发生变化,它们就永远不必通知组合更改方面的信息,因此遵循该协定就容易得多。
Compose 的 MutableState 类型是一种众所周知稳定但可变的类型。如果 MutableState 中存储了值,状态对象整体会被视为稳定对象,因为 State 的 .value 属性如有任何更改,Compose 就会收到通知。
当作为参数传递到可组合项的所有类型都很稳定时,系统会根据可组合项在界面树中的位置来比较参数值,以确保相等性。如果所有值自上次调用后未发生变化,则会跳过重组。
Compose 仅在可以证明稳定的情况下才会认为类型是稳定的。例如,接口通常被视为不稳定类型,并且具有可变公共属性的类型(实现可能不可变)的类型也被视为不稳定类型。
如果 Compose 无法推断某个类型的稳定性,请为该类型添加 @Stable 注解,让 Compose 优先选择智能重组。
定义可组合项参数
在定义可组合项的状态参数时,应牢记以下问题:
可组合项的可重用性或灵活性如何?
状态参数如何影响此可组合项的性能?
为了促进分离和重复使用,每个可组合项都应包含尽可能少的信息。例如,构建可组合项以保存新闻报道的标题时,最好仅传递需要显示的信息,而不是整篇新闻报道
有时,使用独立参数还能提高性能,例如,如果 News 包含的不仅仅是 title 和 subtitle 的信息,每当有 News 的新实例传入 Header(news) 时,即使 title 和 subtitle 没有变化,可组合项也将重组。
请仔细考虑传入的参数数量。如果一个函数拥有过多参数,会降低该函数的工效,因此在这种情况下,建议将这些参数分到一个类下。
Compose 中的事件
应用的每项输入都应表示为事件:点按、文本更改,甚至计时器或其他更新。当这些事件会更改界面的状态时,应由 ViewModel 来处理它们并更新界面状态。
界面层绝不应更改事件处理脚本之外的状态,因为这样做可能会导致应用出现不一致和 bug。
最好为状态和事件处理脚本 lambda 传递不可变值。此方法具有以下优势:
提升可重用性。
确保界面不会直接更改状态的值。
避免并发问题,确保不会从其他线程修改状态。
通常情况下,还可以降低代码的复杂性。
例如,接受 String 和 lambda 作为参数的可组合项可以从许多上下文中调用,并且可重用性较高。假设应用中的顶部应用栏始终显示文本并包含返回按钮。可以定义一个更通用的 MyAppTopBar 可组合项,该可组合项用于接收文本和返回按钮句柄作为参数
ViewModel、状态和事件
借助 ViewModel 和 mutableStateOf,如果出现以下任一情况,还可以在应用中引入单向数据流:
界面的状态通过 StateFlow 或 LiveData 等可观察的状态容器公开。
ViewModel 处理来自应用界面或其他层的事件,并根据事件更新状态容器。
例如,在实现登录屏幕时,点按登录按钮应该会使应用显示一个进度旋转图标和网络调用。如果登录成功,应用会转到其他屏幕;如果发生错误,应用会显示信息提示控件。以下是如何为屏幕状态和事件建模的方法:
该屏幕有四种状态:
退出登录:当用户尚未登录时。
进行中:当应用目前正在尝试通过执行网络调用来让用户登录时。
错误:登录时出现错误。
登录成功:用户登录后。
可以将这些状态建模为密封类。ViewModel 以 State 的形式公开状态,设置初始状态,并根据需要更新状态。ViewModel 还会通过公开 onSignIn() 方法来处理登录事件。
除了 mutableStateOf API 之外,Compose 还提供 LiveData、Flow 和 Observable 的扩展,用于注册为监听器,并将值表示为状态。
Jetpack Compose 的阶段
与大多数其他界面工具包一样,Compose 会通过几个不同的“阶段”来渲染帧。
Compose 有 3 个主要阶段:
组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。
这些阶段通常会以相同的顺序执行,让数据能够沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流)。BoxWithConstraints 以及 LazyColumn 和 LazyRow 是值得注意的特例,其子级的组合取决于父级的布局阶段。
可以放心地假设每个帧都会以虚拟方式经历这 3 个阶段,但为了保障性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。Compose 只会执行更新界面所需的最低限度的工作。之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取。
组合:在组合阶段,Compose 运行时会执行可组合函数并输出表示界面的树形结构。此界面树由多个布局节点组成,其中包含后续阶段所需的所有信息。
布局: 在布局阶段,Compose 使用在组合阶段生成的界面树作为输入。布局节点的集合包含确定每个节点在 2D 空间中的大小和位置所需的所有信息。
在布局阶段,系统使用以下三步算法遍历布局树:
测量子节点:节点会测量其子节点(如果存在)。
确定自己的大小:节点根据这些测量结果决定自己的大小。
放置子节点:每个子节点均相对于节点自身的位置进行放置。
在此阶段结束时,每个布局节点都会:
已指定的 width 和 height
应绘制它的 x、y 坐标
Compose 运行时只需遍历界面树一次即可测量和放置所有节点,从而提高性能。当树中的节点数量增加时,遍历该树所花的时间将以线性方式增加。相反,如果多次访问每个节点,遍历时间会呈指数级增加。
绘制: 在绘制阶段,系统会再次从上到下遍历树,并且每个节点会依次在屏幕上绘制自身。
状态
应用中的状态是指可以随时间变化的任何值。这是一个非常宽泛的定义,从 Room 数据库到类的变量,全部涵盖在内。
所有 Android 应用都会向用户显示状态。下面是 Android 应用中的一些状态示例:
在无法建立网络连接时显示的信息提示控件。
博文和相关评论。
在用户点击按钮时播放的涟漪效果。
用户可以在图片上绘制的贴纸。
状态和组合
由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合项。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。因此,TextField 不会像在基于 XML 的命令式视图中那样自动更新。可组合项必须明确获知新状态,才能相应地进行更新。
可组合项中的状态
可组合函数可以使用 remember API 将对象存储在内存中。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。remember 既可用于存储可变对象,又可用于存储不可变对象。当名为remember的可组合项从组合中移除后,它会忘记该对象。
mutableStateOf 会创建可观察的 MutableState<T>,后者是与 Compose 运行时集成的可观察类型。
对 value 所做的任何更改都会安排重组读取 value 的所有可组合函数。
在可组合项中声明 MutableState 对象的方法有三种:
val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }
这些声明是等效的,以语法糖的形式针对状态的不同用法提供。选择的声明应该能够在可组合项中生成可读性最高的代码。
by 委托语法需要以下导入:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
可以将记住的值用作其他可组合项的参数,甚至用作语句中的逻辑来更改要显示的可组合项。
其他受支持的状态类型
Compose 不要求使用 MutableState<T> 来保存状态;它支持其他可观察类型。在读取 Compose 中的其他可观察类型之前,必须将其转换为 State<T>,以便可组合项在状态发生变化时自动重组。
Compose 附带一些可以根据 Android 应用中使用的常见可观察类型创建 State<T> 的函数。在使用这些集成之前,请先添加适当的工件,如下所述:
Flow:collectAsStateWithLifecycle()
collectAsStateWithLifecycle() 以生命周期感知型方式从 Flow 收集值,让应用能够节省应用资源。它表示 Compose State 最新发出的值。建议使用此 API 作为在 Android 应用中收集数据流的方法。
build.gradle 文件中需要以下依赖项
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
collectAsState 与 collectAsStateWithLifecycle 类似,因为它也会从 Flow 收集值并将其转换为 Compose State。
请为平台通用代码使用 collectAsState,而不要使用仅适用于 Android 的 collectAsStateWithLifecycle。
collectAsState 可在 compose-runtime 中使用,因此不需要其他依赖项。
observeAsState() 会开始观察此 LiveData,并通过 State 表示其值。
build.gradle 文件中需要以下依赖项:
implementation("androidx.compose.runtime:runtime-livedata:1.6.8")
Compose 通过读取 State 对象自动重组。如果在 Compose 中使用 LiveData 等其他可观察类型,则应在读取该类型前,先将其转换为 State。请务必在可组合项中转换类型,并且使用 LiveData.observeAsState() 等可组合扩展函数。
可使用的集成不限于上述几种。可以为 Jetpack Compose 构建扩展函数,以便其读取其他可观察类型。如果应用使用的是自定义可观察类,请使用 produceState API 对其进行转换,以生成 State<T>。
有状态与无状态
使用 remember 存储对象的可组合项会创建内部状态,使该可组合项有状态。HelloContent 就是一个有状态可组合项的示例,因为它会在内部保持和修改自己的 name 状态。在调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试。
无状态可组合项是指不保持任何状态的可组合项。实现无状态的一种简单方法是使用状态提升。
在开发可重复使用的可组合项时,通常想要同时提供同一可组合项的有状态和无状态版本。有状态版本对于不关心状态的调用方来说很方便,而无状态版本对于需要控制或提升状态的调用方来说是必要的。
状态提升
Compose 中的状态提升,是一种将状态移至可组合项的调用方,使可组合项变成无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:
value: T:要显示的当前值
onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值
不过,并不局限于 onValueChange。如果更具体的事件适合可组合项,应使用 lambda 定义事件。
以这种方式提升的状态具有一些重要的属性:
单一可信来源:通过移动状态,而不是复制状态,可确保只有一个可信来源。这有助于避免 bug。
封装:只有有状态可组合项能够修改其状态。完全是在内部操作。
可共享:可与多个可组合项共享提升的状态。如果想在另一个可组合项中读取 name,可以通过变量提升来做到这一点。
可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件。
分离:无状态可组合项的状态可以存储在任何位置。例如,现在可以将 name 移入 ViewModel。
状态下降、事件上升的这种模式称为“单向数据流”。通过遵循单向数据流,可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分解耦。
提升状态时,有三条规则可帮助弄清楚状态应去向何处:
状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项。
状态应至少提升到它可以发生变化(写入)的最高级别。
如果两种状态发生变化以响应相同的事件,它们应一起提升。
可以将状态提升到高于这些规则要求的级别,但欠提升状态会使遵循单向数据流变得困难或不可能。
提升状态的场景
在 Compose 应用中,提升界面状态的场景取决于这是界面逻辑的需要还是业务逻辑的需要。
应将界面状态提升到读取和写入状态的所有可组合项之间的最低共同祖先实体。应使状态尽可能靠近其使用位置。通过状态所有者,向使用者公开不可变状态和事件,以修改状态。
最低共同祖先实体也可以在组合之外。例如,因涉及业务逻辑而在 ViewModel 中提升状态时。
界面状态和界面逻辑的类型
界面状态
界面状态是描述界面的属性。界面状态有两种类型:
屏幕界面状态是需要在屏幕上显示的内容。例如,NewsUiState 类可以包含呈现界面所需的新闻报道和其他信息。由于该状态包含应用数据,因此通常会与层次结构中的其他层相关联。
界面元素状态是指界面元素的固有属性,这些属性会影响界面元素的呈现方式。界面元素可能处于显示或隐藏状态,并且可能具有特定的字体、字号或颜色。在 Android View 中,View 会自行管理此状态(因为它本身是有状态的),并公开用于修改或查询其状态的方法。例如,TextView 类的 get 和 set 方法用于显示该类的文本。在 Jetpack Compose 中,状态在可组合项之外,甚至可以将状态从可组合项附近提升到执行调用的可组合函数或状态容器中。例如,Scaffold 可组合项的 ScaffoldState。
逻辑
应用中的逻辑可以是业务逻辑或界面逻辑:
业务逻辑决定着应用数据的产品要求的实现。例如,在新闻阅读器应用中,当用户点按相应按钮时,就会为报道添加书签。这种用于将书签保存到文件或数据库的逻辑通常放置在网域层或数据层中。状态容器通常通过调用这类层公开的方法,将此逻辑委托给相应的层。
界面逻辑决定着如何在屏幕上显示界面状态。例如,在用户选择了某个类别时获取正确的搜索栏提示、滚动至列表中的特定项,或者在用户点击某按钮时便进入特定屏幕的导航逻辑。
界面逻辑
当界面逻辑需要读取或写入状态时,应根据界面的生命周期,将状态的作用域限定为界面。为了实现这一点,应在可组合函数中以正确的级别提升状态。或者,也可以在普通状态容器类中执行此操作,其作用域也限定为界面生命周期。
以可组合项作为状态所有者
如果状态和逻辑比较简单,在可组合项中使用界面逻辑和界面元素状态是一种不错的方法。可以根据需要将状态保留在可组合项内部或进行提升。
不需要状态提升: 并不总是需要提升状态。当其他可组合项不需要控制状态时,可以将状态保留在可组合项内部。
在可组合项中提升: 如果需要与其他可组合项共用界面元素状态,并在不同位置将界面逻辑应用到状态,则可在界面层次结构中提升状态所在的层次。这样做会使可组合项的可重用性更高,并且更易于测试。
以普通状态容器类作为状态所有者
当可组合项包含涉及界面元素的一个或多个状态字段的复杂界面逻辑时,应将这种责任委托给状态容器,例如普通状态容器类。这样做更易于单独对可组合项的逻辑进行测试,还可以降低复杂性。该方法支持关注点分离原则:可组合项负责发出界面元素,而状态容器包含界面逻辑和界面元素的状态。
普通状态容器类为可组合函数的调用方提供了一些便捷的函数,这样他们就无需自行编写此逻辑。
这些普通类在组合中创建并记住。它们遵循可组合项的生命周期,因此可以采用 Compose 库提供的类型,例如 rememberNavController() 或 rememberLazyListState()。
这种类型的一个示例是 Compose 中实现的 LazyListState 普通状态容器类,用于控制 LazyColumn 或 LazyRow 的界面复杂性。
LazyListState 封装用于存储此界面元素的 scrollPosition 的 LazyColumn 的状态。它还公开了修改滚动位置的方法,例如滚动到给定项。
ViewModel 作为状态所有者
AAC ViewModels 在 Android 开发中的优势使其适用于提供对业务逻辑的访问权限以及准备要在屏幕上呈现的应用数据。
在 ViewModel 中提升界面状态时,会将其移出到组合之外。
ViewModel 不会作为组合的一部分进行存储。它们由框架提供,其作用域限定为 ViewModelStoreOwner,可以是 activity、fragment、导航图或导航图的目的地。如需详细了解 ViewModel 作用域,请参阅相关文档。
然后,ViewModel 是界面状态的可信来源和最低共同祖先实体。
对于某些 Compose 界面元素状态,升级到 ViewModel 可能需要特别注意。例如,Compose 界面元素的某些状态容器公开了修改状态的方法。其中一些可能是触发动画的挂起函数。如果从作用域未限定于组合的 CoroutineScope 调用这些挂起函数,则可能会抛出异常。
假设应用抽屉的内容是动态的,需要数据层关闭后从数据层中进行提取和刷新。应将抽屉状态提升到 ViewModel,以便从状态所有者调用此元素的界面和业务逻辑。
不过,从 Compose 界面使用 viewModelScope 调用 DrawerState 的 close() 方法会导致运行时类型 IllegalStateException 的异常,并显示消息:“a MonotonicFrameClock is not available in this CoroutineContext”。
如需修复此问题,请使用作用域限定为组合的 CoroutineScope。这会在 CoroutineContext 中提供 MonotonicFrameClock,这是挂起函数正常运行所必需的。
在 Compose 中保存界面状态
根据要将状态提升到什么位置和所需的逻辑,可以使用不同的 API 来存储和恢复界面状态。为了最有效地实现这一目的,每个应用都组合使用 API。
任何 Android 应用都可能会因重新创建 activity 或进程而丢失界面状态。此类状态丢失可能是因以下事件造成的:
配置更改。除非手动处理配置变更,否则系统会销毁并重新创建相应 activity。
系统发起的进程终止。应用位于后台,而设备释放了其他进程要使用的资源(如内存)。
在发生这些事件后保留状态对于提供良好的用户体验至关重要。选择要保留哪种状态取决于应用的唯一用户体验流程。根据最佳实践,至少应保留用户输入和导航相关状态。这方面的例子包括:列表的滚动位置、用户想详细了解的项目的 ID、正在进行的用户偏好设置选择或文本字段中的输入。
在 Compose 中恢复状态
rememberSaveable API 的行为与 remember 类似,因为它会在重组时保留状态,还会在使用保存的实例状态机制重新创建 activity 或进程后保留状态。例如,当屏幕旋转时,就会发生这种情况。如果 activity 被用户完全关闭,rememberSaveable将不会保留状态。例如,如果用户从“最近使用的应用”屏幕向上滑动当前 activity,应用不会保留状态。
存储状态的方式
添加到 Bundle 的所有数据类型都会自动保存。如果要保存无法添加到 Bundle 的内容,有以下几种选择:
Parcelize: 最简单的解决方案是向对象添加 @Parcelize 注解。对象将变为可打包状态并且可以捆绑。
MapSaver: 如果某种原因导致 @Parcelize 不合适,可以使用 mapSaver 定义自己的规则,规定如何将对象转换为系统可保存到 Bundle 的一组值。
ListSaver: 为了避免需要为映射定义键,也可以使用 listSaver 并将其索引用作键
Compose 中的状态容器
可以通过可组合函数本身管理简单的状态提升。但是,如果要跟踪的状态数增加,或者可组合函数中出现要执行的逻辑,最好将逻辑和状态事务委派给其他类(状态容器)。
在键发生变化时重新触发 remember 计算
remember API 经常与 MutableState 结合使用:var name by remember { mutableStateOf("") }
因此,使用 remember 函数可使 MutableState 值在重组后继续有效。
通常,remember 接受 calculation lambda 参数。remember 会在首次运行时调用 calculation lambda 并存储其结果。在重组期间,remember 会返回上次存储的值。
除了缓存状态之外,还可以使用 remember 将初始化或计算成本高昂的对象或操作结果存储在组合中。因此,可能不会在每次重组时都重复进行这种计算。
remember 会存储该值,直到退出组合。不过,有一种方法可以让缓存值失效。由于 remember API 也接受 key 或 keys 参数,因此,如果其中有任何键发生变化,那么下次函数重组时,remember 就会让缓存失效并再次对 lambda 块进行计算。这种机制可控制组合中对象的生命周期。在输入发生变化之前(而不是在记住的值退出组合之前),计算会一直有效。
Compose 会使用该类的 equals 实现来确定键是否已发生变化,并使存储的值无效。
重组后使用键存储状态
rememberSaveable API 是 remember 的封装容器,可在 Bundle 中存储数据。此 API 不仅能让状态在重组后保留下来,还能让状态在重新创建 activity 和系统发起的进程终止后继续留存。rememberSaveable 接收 input 参数的目的与 remember 接收 keys 的目的相同。只要输入发生更改,缓存就会失效。下次函数重组时,rememberSaveable 会对 lambda 块重新执行计算。
状态读取
Compose 会自动跟踪在系统读取该值时正在执行的操作。通过这项跟踪,Compose 能够在状态值更改时重新执行读取程序,基于此实现了对状态的观察。
状态通常是使用 mutableStateOf() 创建,然后通过以下两种方式之一进行访问:直接访问 value 属性,或使用 Kotlin 属性委托。
属性委托在后台使用“getter”和“setter”函数来访问和更新状态的 value。只有将相应属性作为值引用时,系统才会调用这些 getter 和 setter 函数(而不会在创建属性时调用),因此上述两种方法是等效的。
每个可以在读取状态发生更改时重新执行的代码块都是一个重启作用域。在不同阶段内,Compose 会跟踪状态值的更改和重启作用域。
分阶段状态读取
如上所述,Compose 有 3 个主要阶段,并且 Compose 会跟踪在每个阶段中读取到的状态。这样一来,Compose 只需向需要对界面的每个受影响的元素执行工作的特定阶段发送通知即可。
创建和存储状态实例的位置与阶段几乎没有什么关系,关键在于读取状态值的时间和位置。
第 1 阶段:组合
@Composable 函数或 lambda 代码块中的状态读取会影响组合阶段,并且可能会影响后续阶段。当状态值发生更改时,Recomposer 会安排重新运行所有要读取相应状态值的可组合函数。请注意,如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。
根据组合结果,Compose 界面会运行布局和绘制阶段。如果内容保持不变,并且大小和布局也未更改,界面可能会跳过这些阶段。
第 2 阶段:布局
布局阶段包含两个步骤:测量和放置。测量步骤会运行传递给 Layout 可组合项的测量 lambda、LayoutModifier 接口的 MeasureScope.measure 方法,等等。放置步骤会运行 layout 函数的放置位置块、Modifier.offset { … } 的 lambda 块,等等。
每个步骤的状态读取都会影响布局阶段,并且可能会影响绘制阶段。当状态值发生更改时,Compose 界面会安排布局阶段。如果大小或位置发生更改,界面还会运行绘制阶段。
更确切地说,测量步骤和放置步骤分别具有单独的重启作用域,这意味着,放置步骤中的状态读取不会在此之前重新调用测量步骤。不过,这两个步骤通常是交织在一起的,因此在放置步骤中读取的状态可能会影响属于测量步骤的其他重启作用域。
第 3 阶段:绘制
绘制代码期间的状态读取会影响绘制阶段。常见示例包括 Canvas()、Modifier.drawBehind 和 Modifier.drawWithContent。当状态值发生更改时,Compose 界面只会运行绘制阶段。
由于 Compose 会执行局部状态读取跟踪,因此可以在适当阶段读取每个状态,从而尽可能降低需要执行的工作量。
Compose 中的附带效应
附带效应是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和属性(例如不可预测的重组、以不同顺序执行可组合项的重组或可以舍弃的重组),可组合项在理想情况下应该是无附带效应的。
不过,有时附带效应是必要的,例如,触发一次性事件(例如显示信息提示控件),或在满足特定状态条件时进入另一个屏幕。这些操作应从能感知可组合项生命周期的受控环境中调用。
由于效应会在 Compose 中带来各种可能性,所以很容易过度使用。确保在其中完成的工作与界面相关,并且不会破坏单向数据流
自适应界面本质上是异步的,而 Jetpack Compose 会在 API 级别引入协程而非使用回调来解决此问题。
LaunchedEffect:在可组合项的作用域内运行挂起函数
如需在可组合项的生命周期内执行工作并能够调用挂起函数,请使用 LaunchedEffect 可组合项。当 LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。如果使用不同的键重组 LaunchedEffect(请参阅下方的重启效应部分),系统将取消现有协程,并在新的协程中启动新的挂起函数。
rememberCoroutineScope:获取组合感知作用域,以在可组合项外启动协程
由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。为了在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,请使用 rememberCoroutineScope。 此外,如果需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。
rememberCoroutineScope 是一个可组合函数,会返回一个 CoroutineScope,该 CoroutineScope 绑定到调用它的组合点。调用退出组合后,作用域将取消。
rememberUpdatedState:在效应中引用值,在值发生更改时不应重启
当其中一个键参数发生变化时,LaunchedEffect 会重启。不过,在某些情况下,可能希望在效应中捕获某个值,但如果该值发生变化,不希望效应重启。为此,需要使用 rememberUpdatedState 来创建对可捕获和更新的该值的引用。这种方法对于包含长期操作的效应十分有用,因为重新创建和重启这些操作可能代价高昂或令人望而却步。
例如,假设应用的 LandingScreen 在一段时间后消失。即使 LandingScreen 已重组,等待一段时间并发出时间已过通知的效应也不应该重启。
DisposableEffect:需要清理的特效
对于需要在键发生变化或可组合项退出组合后进行清理的附带效应,请使用 DisposableEffect。如果 DisposableEffect 键发生变化,可组合项需要处理(执行清理操作)其当前效应,并通过再次调用效应进行重置。
例如,可能需要使用 LifecycleObserver,根据 Lifecycle 事件发送分析事件。如需在 Compose 中监听这些事件,请根据需要使用 DisposableEffect 注册和取消注册观察器。
在 onDispose 中放置空块并不是最佳做法。
SideEffect:将 Compose 状态发布到非 Compose 代码
如需与并非由 Compose 管理的对象共享 Compose 状态,请使用 SideEffect 可组合项。使用 SideEffect 可保证效应在每次成功重组后执行。另一方面,在保证成功重组前执行效果是错误的,如果直接在可组合项中写入效果,就会出现这种情况。
例如,分析库可能允许通过将自定义元数据(在此示例中为“用户属性”)附加到所有后续分析事件,来细分用户群体。如需将当前用户的用户类型传递给分析库,请使用 SideEffect 更新其值。
produceState:将非 Compose 状态转换为 Compose 状态
produceState 会启动一个协程,该协程将作用域限定为可将值推送到返回的 State 的组合。使用此协程将非 Compose 状态转换为 Compose 状态,例如将外部订阅驱动的状态(如 Flow、LiveData 或 RxJava)引入组合。
该制作工具在 produceState 进入组合时启动,在其退出组合时取消。返回的 State 冲突;设置相同的值不会触发重组。
即使 produceState 创建了一个协程,它也可用于观察非挂起的数据源。如需移除对该数据源的订阅,请使用 awaitDispose 函数。
derivedStateOf:将一个或多个状态对象转换为另一种状态
在 Compose 中,每次观察到的状态对象或可组合输入出现变化时都会发生重组。状态对象或输入的变化频率可能高于界面实际需要的更新频率,从而导致不必要的重组。
当可组合项输入的变化频率超过需要的重组频率时,就应该使用 derivedStateOf 函数。这种情况通常是指,某些内容(例如滚动位置)频繁变化,但可组合项只有在超过某个阈值时才需要对其做出响应。derivedStateOf 会创建一个新的 Compose 状态对象,可以观察到该对象只会按照需要进行更新。这样,它的作用就与 Kotlin Flow distinctUntilChanged() 运算符类似。
snapshotFlow:将 Compose 的 State 转换为 Flow
使用 snapshotFlow 将 State<T> 对象转换为冷 Flow。snapshotFlow 会在收集到块时运行该块,并发出从块中读取的 State 对象的结果。当在 snapshotFlow 块中读取的 State 对象之一发生变化时,如果新值与之前发出的值不相等,Flow 会向其收集器发出新值(此行为类似于 Flow.distinctUntilChanged 的行为)。
重启效应
Compose 中有一些效应(如 LaunchedEffect、produceState 或 DisposableEffect)会采用可变数量的参数和键来取消运行效应,并使用新的键启动一个新的效应。
这些 API 的典型形式是:EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
由于此行为的细微差别,如果用于重启效应的参数不是适当的参数,可能会出现问题:
如果重启效应次数不够,可能会导致应用出现错误。
如果重启效应次数过多,效率可能不高。
一般来说,效应代码块中使用的可变和不可变变量应作为参数添加到效应可组合项中。除此之外,还可以添加更多参数,以便强制重启效应。如果更改变量不应导致效应重启,则应将该变量封装在 rememberUpdatedState 中。如果由于变量封装在一个不含键的 remember 中使之没有发生变化,则无需将变量作为键传递给效应。
可以使用 true 等常量作为效应键,使其遵循调用点的生命周期。它实际上具有有效的用例。但在这样做之前,请审慎考虑,并确保确实需要这么做。
使用 CompositionLocal 将数据的作用域限定在局部
CompositionLocal 是通过组合隐式向下传递数据的工具。
通常情况下,在 Compose 中,数据以参数形式向下流经整个界面树传递给每个可组合函数。这会使可组合项的依赖项变为显式依赖项。但是,对于广泛使用的常用数据(如颜色或类型样式),这可能会很麻烦。为了支持无需将颜色作为显式参数依赖项传递给大多数可组合项,Compose 提供了 CompositionLocal,可创建以树为作用域的具名对象,这可以用作让数据流经界面树的一种隐式方式。
CompositionLocal 元素通常在界面树的某个节点以值的形式提供。该值可供其可组合项的后代使用,而无需在可组合函数中将 CompositionLocal 声明为参数。
CompositionLocal 是 Material 主题在后台使用的内容。 MaterialTheme 是一个提供三个 CompositionLocal 实例(颜色、排版和形状)的对象,可以在组合的任何部分检索它们。具体来说,这些是可以通过 MaterialTheme colors、shapes 和 typography 属性访问的 LocalColors、LocalShapes 和 LocalTypography 属性。
CompositionLocal 实例的作用域限定为组合的一部分,因此可以在结构树的不同级别提供不同的值。CompositionLocal 的 current 值对应于该组合部分中的某个祖先提供的最接近的值。
如需为 CompositionLocal 提供新值,请使用 CompositionLocalProvider 及其 provides infix 函数,该函数将 CompositionLocal 键与 value 相关联。在访问 CompositionLocal 的 current 属性时,CompositionLocalProvider 的 content lambda 将获取提供的值。提供新值后,Compose 会重组读取 CompositionLocal 的组合部分。
例如,LocalContentAlpha CompositionLocal 包含用于文本和图标的首选内容 Alpha 值,以强调或弱化界面的不同部分。
使用 CompositionLocal 的另一个关键信号是该参数为横切参数且中间层的实现不应知道该参数的存在,因为让这些中间层知道会限制可组合项的功用。例如,对 Android 权限的查询是由 CompositionLocal 在后台提供的。媒体选择工具可组合项可以添加新功能,即访问设备上受权限保护的内容而无需更改其 API,并且需要媒体选择工具的调用方知道从环境中使用的这一新增的上下文。
但是,CompositionLocal 并非始终是最好的解决方案。不建议过度使用 CompositionLocal,因为它存在一些缺点:
CompositionLocal 使得可组合项的行为更难推断。在创建隐式依赖项时,使用这些依赖项的可组合项的调用方需要确保为每个 CompositionLocal 提供一个值。
此外,该依赖项可能没有明确的可信来源,因为它可能会在组合中的任何部分发生改变。因此,在出现问题时调试应用可能更具有挑战性因为需要向上查看组合,了解提供 current 值的位置。Android Studio 中的“Find usages”或 Compose 布局检查器等工具提供了足够的信息来缓解这个问题。
决定是否使用 CompositionLocal
CompositionLocal 应具有合适的默认值。如果没有默认值,必须保证开发者极其难陷入不提供 CompositionLocal 值的状况。如果创建测试或预览使用该 CompositionLocal 的可组合项时始终需要显式提供默认值,那么不提供默认值可能会导致问题并带来糟糕的体验。
有些概念并非以树或子层次结构为作用域,请避免对这些概念使用 CompositionLocal。建议使用 CompositionLocal 的情况为:其可能会被任何(而非少数几个)后代使用。
一种错误做法的示例是创建包含特定屏幕的 ViewModel 的 CompositionLocal,以便该屏幕中的所有可组合项都可以获取对 ViewModel 的引用来执行某些逻辑。这是一种错误做法,因为并非特定界面树下的所有可组合项都需要知道 ViewModel。最佳做法是遵循ni的模式,只向可组合项传递所需信息。这样做会使可组合项的可重用性更高,并且更易于测试。
创建 CompositionLocal
有两个 API 可用于创建 CompositionLocal:
compositionLocalOf:在重组期间更改提供的值只会使读取其 current 值的内容无效。
staticCompositionLocalOf:与 compositionLocalOf 不同,Compose 不会跟踪 staticCompositionLocalOf 的读取。更改该值会导致提供 CompositionLocal 的整个 content lambda 被重组,而不仅仅是在组合中读取 current 值的位置。
如果为 CompositionLocal 提供的值发生更改的可能性微乎其微或永远不会更改,使用 staticCompositionLocalOf 可提高性能。
为 CompositionLocal 提供值
CompositionLocalProvider 可组合项可将值绑定到给定层次结构的 CompositionLocal 实例。如需为 CompositionLocal 提供新值,请使用 provides infix 函数,该函数将 CompositionLocal 键与 value 相关联
使用 CompositionLocal
CompositionLocal.current 返回由最接近的 CompositionLocalProvider提供的值
需考虑的替代方案
对于某些用例,CompositionLocal 可能是一种过度的解决方案。如果用例不符合决定是否使用 CompositionLocal 中指定的条件,其他解决方案可能更适合
传递显式参数
显式使用可组合项的依赖项是一种很好的习惯。建议仅传递所需可组合项。为了鼓励分离和重用可组合项,每个可组合项包含的信息应该尽可能少。
控制反转
另一种避免将不必要的依赖项传递给可组合项的方法是采用控制反转方式。不是由后代接受依赖项来执行某些逻辑,而是由父级接受依赖项来执行某些逻辑。
将子级与其直接祖先实体分离。祖先实体可组合项往往越来越复杂,这样就可以使更低级别的可组合项更灵活。
同样,可以用相同的方式使用 @Composable 内容 lambda,以获得相同的优势