0%

Android Jetpack Compose 笔记

本文还在持续更新中,目前仅为随笔状态,内容顺序尚未整理。仅作为初学速查阶段。

官方常用网站及工具:

术语翻译

  • compose:组合
  • composition:组合
  • composable:可组合、可组合项
  • recompose:重组
  • recomposition:重组
  • state holders:状态容器

关键术语

  • 组合:对 Jetpack Compose 在执行可组合项时所构建界面的描述。
  • 初始组合:通过首次运行可组合项创建组合。
  • 重组:在数据发生变化时重新运行可组合项以更新组合。
  • 状态容器:用于管理可组合项的逻辑和状态。在某些资源中,状态容器也被称为提升的状态对象 (hoisted state objects)。

Compose 的渲染阶段(Compose Phases)

Compose 有 3 个主要阶段

  1. 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  2. 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
  3. 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

Compose Phases

这些阶段通常会以相同的顺序执行,让数据能够沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流)。BoxWithConstraints 以及 LazyColumnLazyRow 是值得注意的特例,其子级的组合取决于父级的布局阶段。

您可以放心地假设每个帧都会以虚拟方式经历这 3 个阶段,但为了保障性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。Compose 只会执行更新界面所需的最低限度的工作。之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取。

Compose 编程思想

附带效应(side-effect)

https://www.jianshu.com/p/f8f99ee501c3

编者注:感觉还是翻译成“副作用”更直观些,也理好理解,因为就像它的字面意思一样。

Compose 中的附带效应是指在可组合函数作用域之外发生的应用状态的变化。

另一种解释可能更好理解:

附带效应(side-effect),指的是如果一个操作、函数或表达式在其内部与外界进行了互动(最典型的情况,就是修改了外部环境的变量值),产生运算以外的其他结果,则该操作、函数或表达式具有副作用。相对的纯函数就是没有副作用的函数。

由于可组合函数的特性(可以按任何顺序执行、可以并行运行,重组时会跳过尽可能多的内容等),可组合函数在理想状态下是没有附带效应的。换句话说在理想状态下不能在可组合函数作用域外改变应用的状态,每个可组合函数都要保持独立。但有些情况下附带效应是必须需要的,这时就需要使用 Compose 提供的附带效应API。参见下文中的“常用附带效应(side-effect) API”。

为什么要使用附带效应

某些情况下,我们需要在可组合函数作用域外更新应用状态。例如,当用户点按一个按钮时打开一个新屏幕,或者在应用未连接到互联网时显示一条消息。

例如:在可组合函数中执行一次网络请求,根据请求结果改变应用状态,这时就需要用到 LaunchedEffect。还比如想要在可组合函数的某一个控件的 onClick 事件中执行挂起函数,就需要用到 rememberCoroutineScope 用来创建一个绑定了可组合函数生命周期的协程,在协程中执行挂起函数。

再比如,以下操作全部都是危险的附带效应:

  • 写入共享对象的属性
  • 更新 ViewModel 中的可观察项
  • 更新共享偏好设置

Compose 中的事件

“状态”是指可以随时间变化的任何值,例如,聊天应用最新收到的消息。但是,是什么原因导致状态更新呢?在 Android 应用中,状态会根据事件进行更新。

事件是从应用外部或内部生成的输入,例如:

  • 用户与界面互动,例如按下按钮。
  • 其他因素,例如传感器发送新值或网络响应。

应用的状态说明了要在界面中显示的内容,而事件则是一种机制,可在状态发生变化时导致界面发生变化。

关键提示:通常的描述为“是”某状态,“发生”某事件。

事件用于通知程序发生了某事。所有 Android 应用都有核心界面更新循环,如下所示:

UI Update Loop

  • 事件:由用户或程序的其他部分生成。
  • 更新状态:事件处理脚本会更改界面所使用的状态。
  • 显示状态:界面会更新以显示新状态。

Compose 中的状态管理主要是了解状态和事件之间的交互方式。

状态驱动型界面

Compose State

关键提示:如果界面是相对用户而言的,那么界面状态就是相对应用而言的。这就像同一枚硬币的两面,界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。

管理状态

应用中的状态是指可以随时间变化的任何值。

状态和组合

由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合项。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。

可组合项中的状态

可组合函数可以使用 remember API 将对象存储在内存中。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。remember 既可用于存储可变对象,又可用于存储不可变对象。

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

remember API 接受 keykeys 参数,因此,如果其中有任何键发生变化,那么下次函数重组时,remember 就会让缓存失效并再次对 lambda 块进行计算。这种机制可让您控制组合中对象的生命周期。在输入发生变化之前(而不是在记住的值退出组合之前),计算会一直有效。

Compose 会使用该类的 equals 实现来确定键是否已发生变化,并使存储的值无效。

mutableStateOf 会创建可观察的 MutableState,后者是与 Compose 运行时集成的可观察类型。

1
2
3
interface MutableState<T> : State<T> {
override var value: T
}

如果 value 有任何变化,系统就会为用于读取 value 的所有可组合函数安排重组。对于 ExpandingCard,每当 expanded 发生变化时,都会导致 ExpandingCard 重组。

remember API 经常与 MutableState 结合使用。

在可组合项中声明 MutableState 对象的方法有三种:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

这些声明是等效的,以语法糖的形式针对状态的不同用法提供,在编写项目时,可自行选择。其中的 by 委托语法需要以下导入:

1
2
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

虽然 remember 可帮助您在重组后保持状态,但不会帮助您在配置更改(如屏幕旋转等)后保持状态。为此,您必须使用 rememberSaveablerememberSaveable 会自动保存可保存在 Bundle 中的任何值。对于其他值,您可以将其传入自定义 Saver 对象。

注意:在 Compose 中将可变对象(如 ArrayList<T>mutableListOf())用作状态会导致用户在您的应用中看到不正确或过时的数据。不可观察的可变对象(如 ArrayList 或可变数据类)不能由 Compose 观察,且在发生变化后不会触发重组。建议您使用可观察的数据存储器(如 State<List<T>>)和不可变的 listOf(),而不是使用不可观察的可变对象。

其它可支持的状态类型

Jetpack Compose 不要求您使用 MutableState<T> 来保存状态。Jetpack Compose 支持其它可观察类型。在 Jetpack Compose 中读取其它可观察类型之前,您必须将其转换为 State<T>,以便组合函数在状态更改时可以自动重组。

Compose 提供了一些根据 Android 应用程序中常用的可观察类型创建 State<T> 的函数,仅需要添加相应的依赖即可。

collectAsStateWithLifecycle() 会以生命周期感知型方式从 Flow 收集值,以便应用能够保存不需要的应用资源。它通过 Compose State 表示最新发出的值。请将此 API 作为在 Android 应用中收集数据流的推荐方法。

注意:您可以阅读这篇文章,详细了解如何使用 collectAsStateWithLifecycle() API 在 Android 中安全地收集数据流。

要点:Compose 通过读取 State 对象自动重组。如果您在 Compose 中使用 LiveData 等其他可观察类型,则应在读取该类型前,先将其转换为 State。请务必在可组合项中转换类型,并且使用 LiveData<T>.observeAsState() 等可组合扩展函数。

注意:可使用的集成方法不限于上述几种。您可以为 Jetpack Compose 构建扩展函数,以便其读取其他可观察类型。如果您的应用使用的是自定义可观察类,请使用 produceState API 对其进行转换,以生成 State<T>

如需查看具体操作方法的示例,请参阅内置函数的实现:collectAsStateWithLifecycle。任何允许 Jetpack Compose 订阅每项更改的对象都可以转换为 State<T> 并由可组合项读取。

有状态与无状态

使用 remember 存储对象的可组合项会创建内部状态,使该可组合项变得有状态。若可组合项是有状态的,那么它会在内部保持和修改自己的状态。调用方不需要自行控制及管理该可组合项的状态,因此这让“有状态”可组合项显得非常的有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试。

无状态可组合项是指不保持任何状态的可组合项。实现无状态的一种简单方法是使用状态提升

在开发可重复使用的可组合项时,您通常想要同时提供同一可组合项的有状态和无状态版本。有状态版本对于不关心状态的调用方来说很方便,而无状态版本对于需要控制或提升状态的调用方来说是必要的。

当所有状态都可以从可组合函数中提取出来时,生成的可组合函数称为无状态函数。

无状态可组合项是指不具有任何状态的可组合项,这意味着它不会存储、定义或修改新状态。

有状态可组合项是一种具有可以随时间变化的状态的可组合项。

在实际应用中,让可组合项 100% 完全无状态可能很难实现,具体取决于可组合项的职责。在设计可组合项时,您应该让可组合项拥有尽可能少的状态,并能够在必要时通过在可组合项的 API 中公开状态来提升状态。

状态提升

不保存任何状态的可组合项称为无状态可组合项。如需创建无状态可组合项,一种简单的方法是使用状态提升。

Compose 中的状态提升是一种将状态移至可组合项的调用方以使可组合项无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

其中,此值表示任何可修改的状态。

状态下降、事件上升的这种模式称为单向数据流 (UDF),而状态提升就是我们在 Compose 中实现此架构的方式。如需了解相关详情,请参阅 Compose 架构文档

以这种方式提升的状态具有一些重要的属性:

  • 单一可信来源:通过移动状态,而不是复制状态,我们可确保只有一个可信来源。这有助于避免 bug。
  • 可共享:可与多个可组合项共享提升的状态。
  • 可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件。
  • 分离:无状态可组合函数的状态可以存储在任何位置。例如,存储在 ViewModel 中。

要点:提升状态时,有三条规则可帮助您弄清楚状态应去向何处:

  1. 状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项
  2. 状态应至少提升到它可以发生变化(写入)的最高级别
  3. 如果两种状态发生变化以响应相同的事件,它们应提升到同一级别

您可以将状态提升到高于这些规则要求的级别,但如果未将状态提升到足够高的级别,则遵循单向数据流会变得困难或不可能。

状态提升的一些好处
  1. 无状态可组合项现在已可重复使用。
  2. 有状态可组合函数可以为多个可组合函数提供相同的状态。

要点:设计可组合项的最佳实践是仅向它们传递所需要的参数。

Jetpack Compose 中的高级状态和附带效应

常用附带效应(side-effect) API

https://juejin.cn/post/7221341862115426359

https://www.jianshu.com/p/f8f99ee501c3

LaunchedEffect

LaunchedEffect: run suspend functions in the scope of a composable

该 API 只能在 @Composable 函数中使用。

这篇讲解 LaunchedEffect 的文章非常好,强调建议大家一定要看一下。

PS: 这篇文章尚示看,之后再看一下,应该非常好。

  • LaunchedEffect is executed once when entered inside the composition. And it is canceled when leaving the composition.
  • LaunchedEffect cancels/re-launch when Keys state changes
  • LaunchedEffect must have at least one key
  • LaunchedEffect Scope’s Dispatcher is Main.

如需从可组合项内安全调用挂起函数,请使用 LaunchedEffect 可组合项。当 LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。如果使用不同的键重组 LaunchedEffect(请参阅下方的重启效应部分),系统将取消现有协程,并在新的协程中启动新的挂起函数。

注意:为创建与调用点的生命周期相匹配的附带效应,并在组合函数的生命周期内仅触发一次附带效应,可以将常量(如 Unittrue)作为LaunchedEffect 函数的参数。例如:LaunchedEffect(true)

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
private fun DoughnutDetailsScreen() {
var doughnutCount by remember { mutableIntStateOf(1) }
LogContext.log.e(TAG, "doughnutCount: $doughnutCount")

LaunchedEffect(key1 = 10086){
delay(100) // Simulate an async operation
LogContext.log.e(TAG, "Called API")
}

Row {
Text(text = doughnutCount.toString())
Button(onClick = { doughnutCount += 1 }) { Text(text = "+") }
}
}

当首次进入组合时,控制台输出结果如下:

1
2
doughnutCount: 1
Called API

当反复点击 Button 时,控制台输出结果如下:

1
2
3
doughnutCount: 2
doughnutCount: 3
doughnutCount: 4

可以看到,反复点击 Button 时, Called API 仅输出了一次。

rememberCoroutineScope

rememberCoroutineScope:obtain a composition-aware scope to launch a coroutine outside a composable

PS: 这篇文章尚示看,之后再看一下,应该非常好。

注意:无法在可组合函数内调用 scope.launch 函数。

rememberCoroutineScope 用于在可组合函数创建协程,并在可组合函数启动协程。

rememberCoroutineScope 返回的 coroutineScope 会和其调用点的生命周期保持一致,当调用点所在的 Composition 退出时,该 coroutineScope 会被取消。由于该函数是一个可组合函数,因此只能在可组合中使用。

由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。为了在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,请使用 rememberCoroutineScope。 此外,如果您需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。

rememberUpdatedState

rememberUpdatedState: reference a value in an effect that shouldn’t restart if the value changes (在效应中引用某个值,当值改变时,该效应不应重启)

这篇讲解 rememberUpdatedState 的文章非常好,强烈建议大家一定要看一下。

作用:rememberUpdatedState 是给某个参数创建一个引用,并保证其值被使用时是最新值。

使用场景:当我们需要在长时间运行的附带效应中(如 LaunchedEffectDisposableEffect),使用最新的参数值,但是又不想重新启动附带效应,因为该附带效应中可能包含了重量级的操作,重新启动会浪费资源,此时就需要用到 rememberUpdatedState

rememberUpdatedStateCompose 中用于处理状态更新的函数,它用于捕获参数在组合函数重组时是否发生了变化。例如在使用 LaunchedEffectDisposableEffect 时,可以避免不必要的重新启动操作。

在组合期间通过运算得到的参数或值,被长时间运行的 lambda 或对象表达式引用时,应使用 rememberUpdatedState,这在使用 LaunchedEffect 时可能很常见。通常会在 LaunchedEffect 内部,使用 rememberUpdatedState 封装后的值。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun LandingScreen(onTimeout: () -> Unit) {

// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)

// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}

/* Landing screen content */
}

注意:前面已经讲到了,若想在组合函数的生命周期内仅触发一次附带效应,可以将常量(如 Unittrue)作为附带效应函数的参数。如本例中使用的:LaunchedEffect(true)

为了确保 onTimeout lambda 始终包含重组 LandingScreen 时使用的最新值,onTimeout 需使用 rememberUpdatedState 函数封装。附带效应中应使用 rememberUpdatedState 封装后的状态。例如本例中, LaunchedEffect 内应该使用 currentOnTimeout

最后,我们再来看一下刚才推荐的文章中的两个示例,让我们彻底了解 rememberUpdatedState 的作用。

本示例很简单,页面上有两个不同颜色的按钮,用户随意点击按钮后,最后在控制台输出用户最后点击的按钮的颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Composable
fun TwoButtonScreen() {
var buttonColour by remember { mutableStateOf("Unknown") }
Column {
Button(
onClick = {
buttonColour = "Red"
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red
)
) {
Text("Red Button")
}
Spacer(Modifier.height(24.dp))
Button(
onClick = {
buttonColour = "Black"
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Black
)
) {
Text("Black Button")
}
Timer(buttonColour = buttonColour)
}
}

@Composable
fun Timer(buttonColour: String) {
val timerDuration = 5000L
Text(text = "Composing timer with colour : $buttonColour")
LaunchedEffect(key1 = Unit, block = {
startTimer(timerDuration) {
LogContext.log.e(TAG, "Timer ended")
LogContext.log.e(TAG, "Last pressed button color was $buttonColour")
}
})
}

suspend fun startTimer(time: Long, onTimerEnd: () -> Unit) {
delay(timeMillis = time)
onTimerEnd()
}

程序运行结果如下:

remember

可以看到,虽然我们已经反复点击了两个按钮,也 rememberbuttonColour,但是 Timer 结束后,控制台输出的结果却显示 buttonColourUnknown。这是为什么呢?

仔细看代码你会发现,我们使用了 LaunchedEffect,并且使用的 keyUnit。根据前文 LaunchedEffect 讲解的知识,我们知道,该情况表示 LaunchedEffect 在组合函数的生命周期内仅触发一次附带效应。因此我们就能理解了,当组合开始后,buttonColour 的初始值是 Unknown,因此 LaunchedEffect 块执行时,取得的 buttonColour 值就是 Unknown。虽然之后我们改变了 buttonColour 的值,可组合项也进行了重组,但是 LaunchedEffect 并不会被重新执行(因为 keyUnit)。所以这种情况下,在 LaunchedEffect 块内我们永远无法取得更新后的 buttonColour 值。

为了解决上面的问题,我们需要引入一种全新的附带效应,这也正是 rememberUpdatedState 产生的原因。

接下来我们修改下 Timer 函数的代码,使用 rememberUpdatedState 来封装 buttonColour

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun Timer(buttonColour: String) {
val timerDuration = 5000L
Text(text = "Composing timer with colour : $buttonColour")
// 请注意这里新追加的 rememberUpdatedState
val buttonColorUpdated by rememberUpdatedState(newValue = buttonColour)
LaunchedEffect(key1 = Unit, block = {
startTimer(timerDuration) {
LogContext.log.e(TAG, "Timer ended")
LogContext.log.e(TAG, "[1] Last pressed button color was $buttonColour")
LogContext.log.e(TAG, "[2] Last pressed button color was $buttonColorUpdated")
}
})
}

再让我们看一下运行结果:

rememberUpdatedState

这回和我们期待的结果是一致的了。

与修改前的 Timer 函数作对比,我们仅追加了一行主要代码,那就是使用了 rememberUpdatedState 来封装 buttonColour。当 buttonColour 更新后,使用 rememberUpdatedState 封装后的 buttonColorUpdated 可以让我们获取到 buttonColour 的最新值。

现在你应该理解 rememberUpdatedState 的作用及用法了吧。

最后我们再重温一下 rememberUpdatedState 的作用:

当我们需要在长时间运行的附带效应中(如 LaunchedEffectDisposableEffect),使用最新的参数值,但是又不想重新启动附带效应,因为该附带效应中可能包含了重量级的操作,重新启动会浪费资源,此时就需要用到 rememberUpdatedState

DisposableEffect

DisposableEffect: effects that require cleanup

For side effects that need to be cleaned up after the keys change or if the composable leaves the Composition, use DisposableEffect. If the DisposableEffect keys change, the composable needs to dispose (do the cleanup for) its current effect, and reset by calling the effect again.

通常用在处理事件订阅、与生命周期相关的操作、或清理资源的情况。例如,需要资源释放或者解除注册事件的时候。如 EventBus 或者 LifecycleOwner 的订阅或取消订阅等。

LaunchedEffect 相似,key 值变化时(若有多个 key,则任一 key 发现变化时),DisposableEffect 执行过程如下:

  • 先执行 DisposableEffectonDispose
  • 再执行 DisposEffect 代码块

注意DisposableEffect 必须在其代码块中添加 onDispose 子句作为最终语句。否则,IDE 将显示构建时错误。

注意:在 onDispose 中放置空块并不是最佳做法。如果出现这种情况,表明该效应可能并不适用您的使用场景,也许 SideEffect 更合适,或者尝试使用其它附带效应 API。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// Safely update the current lambdas when a new one is provided
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)

// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}

// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)

// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}

/* Home screen content */
}
SideEffect

SideEffect: publish Compose state to non-compose code

如果需要与非 Compose 管理的对象共享 Compose 状态,请使用 SideEffect 可组合项。 SideEffect 中的代码每次重组时都会执行。

SideEffect 有如下特点:

  • 在组合成功完成后才会执行。

  • 每次 Compose 函数重组时都会执行。

  • SideEffectblock 中不能运行 suspend 函数。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun MemberCenterContent(modifier: Modifier = Modifier) {
var count by remember { mutableIntStateOf(0) }
LogContext.log.e(TAG, "1 Count updated: $count")

SideEffect {
// 执行一些与界面无关的操作,例如打印日志、发送网络请求等
LogContext.log.e(TAG, "3 Count updated: $count")
}
LogContext.log.e(TAG, "2 Count updated: $count")

Column(modifier = Modifier.fillMaxSize()) {
Text("Current Count: $count")
Button(onClick = {
count++
LogContext.log.e(TAG, "4 Count updated: $count")
}) {
Text("Increment Count")
}
}
}

首次进行组合时,控制台输出结果如下:

1
2
3
1 Count updated: 0
2 Count updated: 0
3 Count updated: 0

点击 Button 后,控制台输出结果如下:

1
2
3
4
4 Count updated: 1
1 Count updated: 1
2 Count updated: 1
3 Count updated: 1
produceState

produceState: convert non-Compose state into Compose state

produceState launches a coroutine scoped to the Composition that can push values into a returned State. 和 SideEffect 相反,使用此协程可以将非 Compose 状态转换为 Compose 状态,例如将外部订阅驱动的状态(如 FlowLiveDataRxJava)引入组合。

有时候我们会使用协程来获取数据,例如 Flow,但是该数据是在 Composition 中使用。为了使用这些数据,我们需要使用 produceState 来将 non-Compose state 来转变为 Compose state

produceState 特点如下:

  • produceState 进入 Composition 时,获取数据的任务被启动,当其离开 Composition 时,该任务被取消。

  • 尽管 produceState 创建了一个协程,它也可以用于获取 non-suspending 数据源。To remove the subscription to that source, use the awaitDispose function.

  • The returned State conflates; setting the same value won’t trigger a recomposition.

使用示例:

The following example shows how to use produceState to load an image from the network. The loadNetworkImage composable function returns a State that can be used in other composables.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository
): State<Result<Image>> {

// Creates a State<T> with Result.Loading as initial value
// If either `url` or `imageRepository` changes, the running producer
// will cancel and will be re-launched with the new inputs.
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

// In a coroutine, can make suspend calls
val image = imageRepository.load(url)

// Update State with either an Error or Success result.
// This will trigger a recomposition where this State is read
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}

注意:您应采用常规 Kotlin 函数命名方式命名含返回值类型的可组合项,以小写字母开头。

要点produceState 在后台充分利用其他效应!它使用 remember { mutableStateOf(initialValue) } 保留 result 变量,并在 LaunchedEffect 中触发 producer 块。每当 producer 块中的 value 更新时,result 状态都会更新为新值。

您可以基于现有 API 轻松创建自己的效应。

derivedStateOf

derivedStateOf:convert one or multiple state objects into another state

这篇讲解 derivedStateOf 的文章非常好,强烈建议大家一定要看一下。

derivedStateOf:如果某个状态是从其他状态对象计算或派生得出的,请使用 derivedStateOf。使用此函数可确保仅当计算中使用的状态之一发生变化时才会进行计算。

当你的状态或键的变化频率比你更新 UI 的频率高时,你应该考虑使用 derivedStateOf。因为当 Compose 状态变化的时候,会导致重组,这可能会影响性能。

derivedStateOf 类似于 Kotlin Flows 或其它类似响应式框架中的 distinctUntilChangedderivedStateOf 允许你创建一个新的状态对象,仅在你需要的程度上进行变化。

注意:derivedStateOf 只能在读取 Compose 状态对象时进行更新。在 derivedStateOf 内部读取的任何其它变量都会在创建衍生状态时捕获该变量的初始值。如果您需要在计算中使用这些变量,则可以将它们作为键提供给 remember 函数。

我们可以通过一个例子来更好的理解上面这段话。

如下示例的功能是根据滚动状态,来设置按钮的启用状态。其中 threshold 参数是一个普通变量,而非 Compose 状态变量:

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun ScrollToTopButton(lazyListState: LazyListState, threshold: Int) {
// There is a bug here
val isEnabled by remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > threshold }
}

Button(onClick = { }, enabled = isEnabled) {
Text("Scroll to top")
}
}

上面这个示例,初看可能不觉得有什么问题,因为程序运行可以正常运行,并且也没有性能问题。我们可以通过一张表格来看一下程序运行情况:

scrollPosition threshold isEnable (scrollPosition > threshold)
0 0 false
1 0 true
2 0 true

但如果 ScrollToTopButton 重组时,threshold 的值发生了变化,其运行结果如下表:

scrollPosition threshold isEnable (scrollPosition > threshold)
2 5 true
3 5 true
4 5 true

问题出现了,虽然新的 thresholdscrollPosition 大,但是返回结果却是 true。这是为什么呢?此时请回看一下刚才提到的“注意”内容:

derivedStateOf 内部读取的任何其它变量都会在创建衍生状态时捕获该变量的初始值。

问题就出在 threshold 的值一旦被初始化后,在 derivedStateOf 内部就不会被更新。因此才会出现上面的问题。要想修改这个问题也很简单,为 derivedStateOf 外层的 remember 追加一个 key,其值为 threshold。修改后的代码如下:

1
2
3
val isEnabled by remember(threshold) {
derivedStateOf { lazyListState.firstVisibleItemIndex > threshold }
}

实际开发中,需要使用 derivedStateOf 的情况可能会很少。但是一旦你找到了一个适合的情况,它可以在最小化重组方面发挥极其有效的作用。

常见的使用场景:

  • 列表的滚动是否超过阈值 (scrollPosition > 0)
  • 列表中的项目数量是否超过阈值 (items > 0)
  • 表单验证 (username.isValid())

注意:时刻牢记,若要使用 derivedStateOf 的话,那么输入参数和输出结果之间需要存在一定的变化量差异。只有这样,才会让 derivedStateOf 的使用变得有意义。

如果输入参数与输出结果之间不存在变化量的差异或差异很小,那么直接使用普通的 remember 可能更合适。

假如,有如下场景:我们需要在一个函数中进行一些计算(有可能该计算的代价会很大),我们希望每当该函数的输出发生变化时,我们的 UI 都会重组。那么该情况,更适合使用带有 keyremember。因为我们的 UI 需要在 key 变化的同时进行更新。换句话说,我们的输入和输出是相同的。例如:

1
2
3
val output = remember(input) {
expensiveCalculation(input)
}

总之,derivedStateOf 是在您的状态或键的更新频率高于您希望更新 UI 的情况下使用的。如果输入与输出之间的差异不大,您就不需要使用它。

derivedStateOf 使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {

val todoTasks = remember { mutableStateListOf<String>() }

// Calculate high priority tasks only when the todoTasks or highPriorityKeywords
// change, not on every recomposition
val highPriorityTasks by remember(highPriorityKeywords) {
derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
}

Box(Modifier.fillMaxSize()) {
LazyColumn {
items(highPriorityTasks) { /* ... */ }
items(todoTasks) { /* ... */ }
}
/* Rest of the UI where users can add elements to the list */
}
}

在以上代码中,derivedStateOf 保证每当 todoTasks 发生变化时,系统都会执行 highPriorityTasks 计算,并相应地更新界面。如果 highPriorityKeywords 发生变化,系统将执行 remember 代码块,并且会创建新的派生状态对象并记住该对象,以代替旧的对象。由于执行过滤以计算 highPriorityTasks 的成本很高,因此应仅在任何列表发生更改时才执行,而不是在每次重组时都执行。

此外,更新 derivedStateOf 生成的状态不会导致可组合项在声明它的位置重组,Compose 仅会对返回状态为已读的可组合项(在本例中,指 LazyColumn 中的可组合项)进行重组。

该代码还假设 highPriorityKeywords 的变化频率显著低于 todoTasks。否则,该代码会使用 remember(todoTasks, highPriorityKeywords) 而不是 derivedStateOf

snapshotFlow

snapshotFlow: convert Compose’s State into Flows

snapshotFlow:使用 snapshotFlow 可将 State<T> 对象转换为 冷 Flow,可以方便的使用 Flow 运算符强大的功能。

snapshotFlow 会在 collect 时运行其代码块,并发出从块中读取的 State 对象的结果。当在 snapshotFlow 块中读取的 State 对象之一发生变化时,如果新值与之前发出的值不相等(此行为类似于 Flow.distinctUntilChanged 的行为),Flow 会向其收集器发出新值。

使用示例:

示例场景如下:当用户滚动列表时,若首个列表项滚出屏幕,则期望的操作仅执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val listState = rememberLazyListState()

LazyColumn(state = listState) {
// ...
}

LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
小结
  • LaunchedEffect:可以在组合项内安全调用挂起函数。该效应进入组合时仅被调用一次key 不变的情况),退出组合时取消协程。
  • rememberCoroutineScope:在可组合函数创建协程,并在可组合函数启动协程。
  • rememberUpdatedState:用于长时间运行的附带效应中(如 LaunchedEffectDisposableEffect),希望使用最新的参数值,但又不想重启 LaunchedEffect,避免由于重量级操作等导致浪费资源。
  • DisposableEffect:
  • SideEffect:
  • produceState:
  • derivedStateOf:
  • snapshotFlow:

单向数据流(UDF) (Unidirectional data flow)

Unidirectional data flow

Elements of the UI state production pipeline

The UI state and the logic that produces it defines the UI layer.

UI state

UI state is the property that describes the UI. There are two types of UI state:

  • Screen UI state is what you need to display on the screen.
  • UI element state refers to properties intrinsic to UI elements that influence how they are rendered.

Logic

UI state is not a static property, as application data and user events cause UI state to change over time. Logic determines the specifics of the change, including what parts of the UI state have changed, why it’s changed, and when it should change.

Logic as the producer of UI state

Logic in an application can be either business logic or UI logic:

  • Business logic is the implementation of product requirements for app data.
  • UI logic is related to how to display UI state on the screen.

Android lifecycle and the types of UI state and logic

The UI layer has two parts: one dependent and the other independent of the UI lifecycle. This separation determines the data sources available to each part, and therefore requires different types of UI state and logic.

  • UI lifecycle independent: This part of the UI layer deals with the data producing layers of the app (data or domain layers) and is defined by business logic. Lifecycle, configuration changes, and Activity recreation in the UI may affect if the UI state production pipeline is active, but do not affect the validity of the data produced.
  • UI lifecycle dependent: This part of the UI layer deals with UI logic, and is directly influenced by lifecycle or configuration changes. These changes directly affect the validity of the sources of data read within it, and as a result its state can only change when its lifecycle is active. Examples of this include runtime permissions and getting configuration dependent resources like localized strings.

The above can be summarized with the table below:

UI Lifecycle independent UI Lifecycle dependent
Business logic UI Logic
Screen UI state

The UI state production pipeline

  • UI state produced and managed by the UI itself.
  • UI logic → UI.
  • Business logic → UI.
  • Business logic → UI logic → UI.

分别举例说明:

  • UI state produced and managed by the UI itself. For example, a simple, reusable basic counter:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Composable
    fun Counter() {
    // The UI state is managed by the UI itself
    var count by remember { mutableStateOf(0) }
    Row {
    Button(onClick = { ++count }) {
    Text(text = "Increment")
    }
    Button(onClick = { --count }) {
    Text(text = "Decrement")
    }
    }
    }
  • UI logic → UI. For example, showing or hiding a button that allows a user to jump to the top of a list.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Composable
    fun ContactsList(contacts: List<Contact>) {
    val listState = rememberLazyListState()
    val isAtTopOfList by remember {
    derivedStateOf {
    listState.firstVisibleItemIndex < 3
    }
    }

    // Create the LazyColumn with the lazyListState
    ...

    // Show or hide the button (UI logic) based on the list scroll position
    AnimatedVisibility(visible = !isAtTopOfList) {
    ScrollToTopButton()
    }
    }
  • Business logic → UI. A UI element displaying the current user’s photo on the screen.

    1
    2
    3
    4
    5
    6
    7
    8
    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
    // Read screen UI state from the business logic state holder
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // Call on the UserAvatar Composable to display the photo
    UserAvatar(picture = uiState.profilePicture)
    }
  • Business logic → UI logic → UI. A UI element that scrolls to display the right information on the screen for a given UI state.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
    // Read screen UI state from the business logic state holder
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val contacts = uiState.contacts
    val deepLinkedContact = uiState.deepLinkedContact

    val listState = rememberLazyListState()

    // Create the LazyColumn with the lazyListState
    ...

    // Perform UI logic that depends on information from business logic
    if (deepLinkedContact != null && contacts.isNotEmpty()) {
    LaunchedEffect(listState, deepLinkedContact, contacts) {
    val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
    if (deepLinkedContactIndex >= 0) {
    // Scroll to deep linked item
    listState.animateScrollToItem(deepLinkedContactIndex)
    }
    }
    }
    }

Business logicUI logic 都是应用于 UI state production pipeline 的。

business logic must always be applied before UI logic.

logic-hierarchy

State holders(状态容器) and their responsibilities

状态容器的责任是存储状态,以便应用读取状态。 在需要逻辑时,它会充当中介,并提供对托管所需逻辑的数据源的访问权限。这样,状态容器就会将逻辑委托给相应的数据源。

这会带来以下好处:

  • 简单的界面:界面仅绑定了其状态。
  • 可维护性:可以对状态容器中定义的逻辑进行迭代,而无需更改界面本身。
  • 可测试性:界面及其状态生成逻辑可独立进行测试。
  • 可读性:代码读者可以清楚地看出界面呈现代码与界面状态生成代码之间的差异。

无论大小或作用域如何,每个界面元素都与其对应的状态容器具有 1 对 1 关系。此外,状态容器必须能够接受和处理任何可能导致界面状态发生变化的用户操作,并且必须生成随后的状态变化。

注意:状态容器并非绝对必要。简单的界面可能会托管内嵌到其呈现代码中的逻辑。

状态容器的类型

界面层中有两种类型的状态容器,它们根据自身与界面生命周期的关系而定义:

  • The business logic state holder.
  • The UI logic state holder.
Business logic and its state holder

业务逻辑状态容器会处理用户事件,并将数据从 data layerdomain layers 转换为 Screen UI state。在考虑 Android lifecycle 和 app configuration 更改时,为了提供最佳用户体验,利用业务逻辑的状态容器应具有以下属性:

  • 生成界面状态
  • 在 activity 重新创建后保留下来
  • 具有长期存在的状态
  • 对界面来说独一无二,且不可重复使用

注意:业务逻辑状态容器通常使用 ViewModel 实例来实现,因为 ViewModel 实例支持上述很多功能,尤其是在 Activity 重新创建后仍然有效。

注意:您只能将 ViewModel 用于目标位置级界面,而不应将其用于界面中可重复使用的部分,例如搜索栏或条状标签组。在这些情况下,更适合使用普通类。

警告:请勿将 ViewModel 实例向下传递到其他可组合函数。 这样做会导致可组合函数与 ViewModel 类型形成耦合,从而降低其可重用性,而且会更难以测试和预览。此外,没有明确的单一数据源 (SSOT - single source of truth) 可以管理 ViewModel 实例。向下传递 ViewModel 可允许多个可组合项调用 ViewModel 函数并修改其状态,从而导致更难以调试 bug。作为替代方案,请遵循 UDF 最佳实践并仅向下传递必要的状态。同样,请向上传递传播事件,直到它们到达 ViewModel 的可组合 SSOT。这是负责处理事件并调用相应 ViewModel 方法的 SSOT

UI logic and its state holder

界面逻辑是对界面本身提供的数据执行操作的逻辑。它可能依赖于界面元素的状态或界面数据源(如权限 API 或 Resources)。利用界面逻辑的状态容器通常具有以下属性:

  • Produces UI state and manages UI elements state.

  • Activity 重新创建后不再有效

  • 可引用 UI 作用域的数据源

  • 可在多个不同的 UI 中重复使用

界面逻辑状态容器通常使用普通类实现。这是因为界面本身负责创建界面逻辑状态容器,而界面逻辑状态容器与界面本身具有相同的生命周期。例如,在 Jetpack Compose 中,状态容器是组合的一部分,并遵循组合的生命周期。

注意:当界面逻辑足够复杂,可以移出界面时,会使用普通类状态容器。否则,界面逻辑可以在界面中以内嵌方式实现。

注意:建议为可重用的界面部分(如搜索栏或条状标签组)使用普通状态容器类。在这种情况下,您不应使用 ViewModel,因为 ViewModel 最适合用于管理导航目的地的状态和对业务逻辑的访问权限。

为状态容器选择 ViewModel 和普通类

注意:大多数应用会选择执行内嵌在界面本身中的界面逻辑,而这些逻辑原本可以放在普通类状态容器中。这适用于简单的情况,但在其他情况下,您可以通过将逻辑拉取到普通类状态容器中来提高可读性。

In summary, the diagram below shows the position of state holders in the UI State production pipeline:

stateholder-hierarchy

您应根据离使用界面状态的位置最近的状态容器生成界面状态。一个非正式的原则是,在尽可能低的位置存储状态,同时保留适当的所有权。

状态容器可组合 (State holders are compoundable)

状态容器可以依赖于另一个状态容器,前提是依赖项的生命周期与状态容器相同或更短。示例如下:

  • 界面逻辑状态容器可以依赖于另一个界面逻辑状态容器。
  • 屏幕级状态容器可以依赖于界面逻辑状态容器。

注意:鉴于屏幕级状态容器管理部分或整个屏幕的业务逻辑复杂性,因此让屏幕级状态容器依赖于另一个屏幕级状态容器的做法并不合理。如果您遇到这种情况,请重新考虑相关屏幕和状态容器,确定您是否真的需要这样做。

界面状态生成

是指以下过程:应用访问数据层、应用业务规则(如果需要),以及公开要从界面取用的界面状态。

Jetpack Compose Navigation

https://juejin.cn/post/7135253864411824165

角色 说明
NavController 导航的全局管理者,维护着导航的静态和动态信息,静态信息指 NavGraph,动态信息即导航过长中产生的回退栈 NavBackStacks
NavHost 定义导航的入口,同时也是承载导航页面的容器,负责显示导航图的当前目的地
NavGraph 定义导航时,需要收集各个节点的导航信息,并统一将各个可组合目的地添加到导航图中
NavDestination 导航中的各个节点,携带了 route,arguments 等信息
Navigator 导航的具体执行者,NavController 基于导航图获取目标节点,并通过 Navigator 执行跳转

组成

Navigation 的 3 个主要部分是 NavControllerNavGraphNavHost

NavController:是 Navigation 组件的中心 API。此 API 是有状态的,它可跟踪返回堆栈可组合条目、使堆栈向前移动、支持对返回堆栈执行操作,以及在不同目的地状态之间导航。

您可以通过在可组合项中使用 rememberNavController() 方法来创建 NavController

1
val navController = rememberNavController()

一定要把 NavController 放置在可组合项层次结构的顶层(通常位于 App 可组合项中),也就是整个应用的根可组合项和入口点。之后,所有需要引用 NavController 的可组合项都可以访问它。这么做符合状态提升的原则,并可确保 NavController 是在可组合屏幕之间导航和维护返回堆栈的主要可信来源。

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
// ...
) {
// ...
}
}

注意:不要将 NavController 作为参数传递给其它可组合项。正确的做法时暴露一个回调函数。

每个 NavController 都必须与一个 NavHost 可组合项相关联。NavHost 充当容器,负责显示导航图的当前目的地。NavHostNavController 与导航图相关联,后者用于指定您应能够在其间进行导航的可组合项目的地。当您在可组合项之间进行导航时,NavHost 的内容会自动进行重组。导航图中的每个可组合项目的地都与一个 Route (路线) 相关联。

此外,NavHost 还将 NavController 与导航图 (NavGraph) 相关联,后者用于标出能够在其间进行导航的可组合目的地。它实际上是一系列可提取的目的地。

关键术语Route (路线) 是一个 String,用于定义指向可组合项的路径。您可以将其视为指向特定目的地的隐式深层链接。每个目的地都必须有一条唯一的路线。

创建方法:

1
2
3
4
5
6
NavHost(navController = navController, startDestination = "profile") {
// builder parameter will be defined here as the graph
composable("profile") { Profile(/*...*/) }
composable("friendslist") { FriendsList(/*...*/) }
/*...*/
}

注意:Navigation 组件要求您遵循导航原则并使用固定的起始目的地。您不应为 startDestination 路线使用可组合项值。

NavHost 需要一个 startDestination 路线才能知道在应用启动时显示哪个目的地。

如需导航到导航图中的可组合项目的地,您必须使用 navigate 方法。

1
navController.navigate("friendslist")

默认情况下,navigate 会将您的新目的地添加到返回堆栈中。您可以通过向我们的 navigate() 调用附加其他导航选项来修改 navigate 的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Pop everything up to the "home" destination off the back stack before
// navigating to the "friendslist" destination
navController.navigate("friendslist") {
popUpTo("home")
}

// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friendslist" destination
navController.navigate("friendslist") {
popUpTo("home") { inclusive = true }
}

// Navigate to the "search” destination only if we’re not already on
// the "search" destination, avoiding multiple copies on the top of the
// back stack
navController.navigate("search") {
launchSingleTop = true
}

注意: 您只应在回调中调用 navigate(),而不要在可组合项内调用它,这样可以避免每次重组时都调用 navigate()

若希望从其它可组合函数中触发导航,迁移至其它页面时,不要通过传递 NavController 对象,并调用 navigate() 的方法来实现页面迁移。根据**单一可信来源**原则,我们应该暴露一个回调事件,在回调中调用 navigate()

Warning: Don’t pass your NavController to your composables. Expose an event as described above.

定义导航时,需要收集各个节点的导航信息,并统一将各个可组合目的地添加到导航图中。

传递参数

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...

NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
...
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments,
deepLinks = SingleAccount.deepLinks
) { navBackStackEntry ->
val accountType =
navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
SingleAccountScreen(accountType)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
object SingleAccount : RallyDestination {
// Added for simplicity, this icon will not in fact be used, as SingleAccount isn't
// part of the RallyTabRow selection
override val icon = Icons.Filled.Money
override val route = "single_account"
const val accountTypeArg = "account_type"
val routeWithArgs = "$route/{$accountTypeArg}"
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
val deepLinks = listOf(
navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}" }
)
}
1
2
3
4
5
6
@Composable
fun SingleAccountScreen(
accountType: String? = UserData.accounts.first().name
) {
...
}
1
2
3
4
5
6
7
8
9
10
11
import androidx.navigation.NavHostController
// ...
OverviewScreen(
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)

private fun NavHostController.navigateToSingleAccount(accountType: String) {
this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
深层链接

深层链接可以将特定网址、操作和/或 MIME 类型与可组合项关联起来。在 Android 中,深层链接是指将用户直接转到应用内特定目的地的链接。Navigation Compose 支持隐式深层链接。调用隐式深层链接(例如,当用户点击某个链接)时,Android 可以将应用打开到相应的目的地。

使用 adb 测试深层链接

1
adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW

Jetpack Compose 中的常见性能问题

https://youtu.be/EOQB8PTLkpY

Jetpack Compose 最佳实践

坚持原创及高品质技术分享,您的支持将鼓励我继续创作!