跳至主要內容

译文 | Jetpack Compose 生命周期

guodongAndroid大约 19 分钟

提示

本文是《Lifecycle of Composables in Jetpack Compose》的译文,仅供个人学习使用,不得且不可用于商业用途,若是因用于商业用途而产生的一切法律责任与译者无关。

译者已获得原组织/原作者翻译授权,版权归原组织/原作者所有。@kodeco


原文信息:

发布日期:2022年07月11日

原文作者:Lena Stepanovaopen in new window

原文地址open in new window

原文资源open in new window


Jetpack Compose 官网资源:

可组合项的生命周期 | Jetpack Compose | Android Developers (google.cn)open in new window

前言

响应式编程是 Jetpack Compose 的基础。它允许你通过声明式的方式构建 UI。你不再需要使用 getter 和 setter 来更改视图以响应基础数据。相反,这些变化是随着 重组 而自然发生的。

在本教程中,你将了解:

  • 关于可组合函数的 生命周期
  • 从一个可组合项更新另一个可组合项。
  • 在 Logcat 中观察日志输出。
  • 如何使用重组来构建 响应式可组合项

提示

注意:重组与 Jetpack Compose 中的状态息息相关。最好在熟悉该概念后阅读本教程。如果你对 状态 还不熟悉,可以参考 译文 | Jetpack Compose 状态管理

启航

你可以在本文顶部原文信息中访问 原文地址 来获取本教程所需要的资源,也可以通过访问 原文资源 来获取。

使用 Android Studio 2021.1.1 或者更新的版本打开下载资源中的 starter 项目。

浏览项目结构

不同于传统的 Android 项目,在 QuizMe App 中,你会发现没有 layout 资源目录。main 目录也不一样 —— ui 目录有一个 screens 的包,其中包含 MainScreenQuizScreenResultScreen。你将在这些文件中全部使用 Kotlin 声明 UI,而不再使用 XML。

ui 目录下你还能看到一个 theme 的包,其中包含与 UI 项目外观相关的所有内容。

打开 MainActivity.kt。注意:MainActivity 并不像往常那样继承自 AppCompatActivity,而是继承自 ComponentActivity。这是由于 App 模块中的 build.gradle 文件内容发生了变更。当你创建一个 Empty Compose Activity 的新项目时,Android Studio 将为你添加必要的依赖。

但是,如果你在已有项目中添加 Jetpack Compose。首先,你需要在 App 级别的 build.gradle 文件中对其进行配置以启用 Jetpack Compose,然后添加相关依赖项。

确认问题

现在,你已经对本项目有了基本的认识,编译并运行它。你将会看到一个测验表单。

quiz form

准备好接受挑战了么?尝试回答测验表单中的问题。

不幸的是,当你在输入框中输入时,什么也不会发生。这是因为你的 UI 尚未响应输入。

打开 QuizScreen.kt 并查看 QuizInput()

@Composable
fun QuizInput(question: String) {
    Log.d(MAIN, "QuizInput $question")
    val input = ""
    TextField(
        value = input,
        onValueChange = { },
        modifier = Modifier
            .fillMaxWidth()
            .padding(top = 16.dp),
        placeholder = {
            Text(text = question)
        }
    )
}

这个可组合函数是负责更新答案的输入框。它持有一个 input 变量 —— 是不可变的 String 类型。目前,TextField() 无法响应状态来更新它的值。还有,onValueChange 回调也是空的实现。

是时候更改这段代码以便你的答案可以在文本框中显示。但首先,你需要了解 可组合项的生命周期

可组合项的生命周期

作为 Android 生态系统的一部分,每一个可组合项都有它自己的生命周期。幸运的是,与其他组件相比,它更简单。每个可组合项都会经历生命周期的三个阶段:

  • 进入组合
  • 重组
  • 离开组合
composable-lifecycle

可组合项可以根据需要进行多次重组以渲染最新的 UI。

触发重组

Quiz 项目中,你在 MainActivitysetContent() 中声明组合。

启动 QuizMe 后,你的可组合项会 进入 MainScreen() 中声明的组合,然后调用 QuizScreen() 及其所有可组合项。但是,之后没有可组合项发生重组。QuizInput() 永远不会进入生命周期的下一个阶段,因为你的 UI 仍然是静态的。

你需要向可组合函数发出指令以重组并显示更新后的 UI。

状态在声明式 UI 中发挥着重要作用。当初始组合中涉及的任何状态发生变化时,就会发生重组。将不可变的 String 类型变量更改为 MutableState 类型变量,以使 QuizInput() 成为有状态的可组合项。

首先,找到 input 变量,然后使用 remember() 创建 MutableState,而不是空文本:

var input by remember { mutableStateOf("") }

提示

译者注:确保导入 androidx.compose.runtime.getValueandroidx.compose.runtime.setValue

现在,input 是一个持有 String 值的状态变量。你将在 TextField() 中使用它的值。在这里,你使用 remember() 函数帮助可组合项在重组期间保持最新的状态。

然后,在下面第三行,更新 onValueChange 回调:

onValueChange = { value -> input = value  }

当文本框里的内容变化时,onValueChange() 回调将会被触发。它将设置 MutableState 的值为新输入的值,因此,Jetpack Compose 会响应这个状态变更并重组 TextFeild()

重新编译并运行。现在尝试输入问题的答案。当你在文本框中输入新字母时,将会发生重组,你就可以看到最新的输入内容了。

ezgif

太棒了!

但是,如果你想触发另一个可组合项的重组以响应 QuizInput() 中的更改,该怎么办?例如,当用户选择复选框时,在屏幕中添加另一个 TextField()。可以在下一章节中找到答案。

定义重组源

如果你想触发一个可组合项的重组来影响另一个可组合项,可以使用 状态提升 并将控制权转移至前者(译者注:第一个发生重组的可组合项)。

例如:查看 QuizScreen.kt 中的 CheckBox()

@Composable
fun CheckBox(
   // 1
   isChecked: Boolean,
   onChange: (Boolean) -> Unit
) {
   . . .
   Checkbox( 
       // 2
       checked = isChecked, 
       onCheckedChange = onChange
   )
   . . .
}

在上述代码中:

  1. 函数接受一个 Boolean 类型的检查状态和一个 onChange lambda 作为参数。这种方式非常有用,因为你可以多次重复使用可组合项,并且不会有任何副作用。此外,你还为可组合函数增加了灵活性,因为它现在可以从任何地方接收状态。
  2. 你将 Boolean 类型的检查状态和 lambda 分配给 CheckBox 组件。现在,如果用户改变了 checked 的状态,组件会通过 onChange lambda 做出响应,将 isChecked 的当前状态提升到 QuizScreen() 中。

QuizScreen() 中查看以下代码:

var checked by remember { mutableStateOf(false) }
val onCheckedChange: (Boolean) -> Unit = { value -> checked = value }

这意味着 QuizScreen()checked 状态值的实际持有者。它控制着该值,并且在收到 onCheckedChange 回调时可以更新该值。

因此,你可以使用 QuizScreen() 中的 checked 状态去触发其他可组合项的重组。

接下来,如果当复选框处于未选中状态时,你想禁用表单提交。更改 SubmitButton() 的函数签名,如下所示:

@Composable
fun SubmitButton(isChecked: Boolean, text: String, onClick: () -> Unit)

在上述代码中,你向提交按钮的可组合函数提供 check 状态值。

然后,在 SubmitButton() 中的 ExtendedFloatingActionButton() 中添加一个参数:

backgroundColor = if (isChecked) MaterialTheme.colors.secondary else Color.Gray

通过上述代码,你可以根据选中的值来更改提交按钮的背景颜色。

同时,在 QuizScreen() 中,将 checked 状态传递给 SubmitButton()

SubmitButton(checked, stringResource(id = R.string.try_me)) {  }

编译并运行,然后尝试使用复选框。

ezgif

当用户选中或取消选中复选框时,提交按钮会更新背景色。哇哦!你触发了提交按钮的重组以响应复选框的状态更改。

观察日志和条件

打开 Logcat 并选择 com.yourcompany.android.quizme,然后在日志输出中观察重组:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: Checked state false
D/MainLog: Button recomposed

如你所见,在你点击复选框时发生了多次重组。

现在,添加以下逻辑:当复选框未选中时不显示提交按钮。在 QuizScreen() 中,为 SubmitButton() 添加以下条件:

if (checked) {
   SubmitButton(checked, stringResource(id = R.string.try_me)) { }
}

现在,如果复选框未选中,Compose 将不会重组提交按钮。

编译并运行,然后多次点击复选框,注意:提交按钮只会在复选框选中时显示。

Screenshot_20220303-175235-243x500

日志输出也与之前的不同:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: Checked state false
D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: Checked state false

重组遵循了条件语句,就像 Kotlin 中的所有其他代码一样。当选中状态为 false 时,Jetpack Compose 无需重新组合 SubmitButton()

跳过重组

Jetpack Compose 非常智能,可以在非必要时选择 跳过重组

if 语句内且 SubmitButton() 上方,给 questions 变量赋一个新值:

questions = listOf(
  "What's the best programming language?",
  "What's the best OS?",
  "The answer to Life, The Universe and Everything?"
)

你在问题列表中又添加了一个问题。这将导致用户在第一次选中复选框时会动态的添加新的输入框。

编译并运行,然后选中复选框,你将看到新的输入框出现。

ezgif

检查日志输出,你将看到以下日志:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: QuizInput The answer to Life, The Universe and Everything?

注意:日志中仅包含新添加的 QuizInput 字段名。这是因为 Jetpack Compose 认为列表中的前两项没有发生变化,所以不需要重组它们。

当你选中该复选框时,它会向 QuizScreen() 发送一个事件,该事件将选中状态设置为新值。反过来讲,Jetpack Compose 知道要重组哪些可组合项。在日志输出中,你可以看到每次用户选中或取消选中复选框时,CheckBox 都会被重组。但是,提交按钮的重组仅发生在 checked 的状态为 true 时。QuizInput() 根本不受用户与复选框交互的影响。

发生这种情况是因为 Jetpack Compose 会尽可能的跳过重组以保持优化。另一个示例是条件语句定义何时不显示可组合项(如 SubmitButton()),或者状态不影响可组合项或状态未发生更改(如 QuizInput())。

智能重组

Jetpack Compose 如何知道状态是否发生变化?它仅仅使用 equals() 来比较可变状态的新值和旧值。所以,任何不可变的类型都不会触发重组。

还记得在教程开始时尝试将 QuizInput() 与不可变类型 String 的值一起使用吗?Jetpack Compose 不会重组任何内容,因为它的值无法更改。

任何不可变类型都是 稳定的(译者注:扩展一下,或许这里可以简单理解 Compose 中 @Stable 注解的作用),不会触发重组。这就是为什么需要将 remember()mutableStateOf() 结合使用。Jetpack Compose 观察可变状态的变更,因此它知道何时开始重组以及应该重组哪些可组合函数。

修改之前的代码块,如下所示:

questions = listOf(
   "The answer to Life, The Universe and Everything?",
   "What's the best programming language?",
   "What's the best OS?"
) 

上述代码对列表中的问题进行了重新排序。

重新编译并运行,然后选中复选框。

Screenshot_20220303-183126-243x500

咋一看,还是和之前一样。但在日志输出中,你将看到 Compose 重新组合了所有的(三个)输入框:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: QuizInput The answer to Life, The Universe and Everything?
D/MainLog: QuizInput What's the best programming language?
D/MainLog: QuizInput What's the best OS?

一旦 Jetpack Compose 开始将新列表与旧列表进行比较,它就会发现新列表中的第一个子项与旧列表中的第一个子项不相等。因此,它无法重用旧的 QuizInputs(),而是必须要重组它们。但是,如果有旧的可组合项,如何确保 Jetpack Compose 会使用旧的可组合项呢?

修改 QuizInputFields(),将 QuizInput() 包装进 key() 中:

key(question){
    QuizInput(question = question)
}

为了确保 Jetpack Compose 重用旧的可组合项,你可以使用 key()。提供的 question 值是 QuizInput 特定实例的标识符。

重新编译并运行,然后选中复选框,UI 界面将和之前一样。

Screenshot_20220303-183126-243x500

重新检查日志输出。这次,看起来就像你将新问题添加到列表底部时一样 —— 仅重新组合了新的 QuizInput()

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: QuizInput The answer to Life, The Universe and Everything?

尽管列表顺序发生了变化,但 Jetpack Compose 重用了两个初始问题输入框(旧可组合项),因为这次它可以识别出它们。当你需要渲染较长的列表或列表顺序至关重要时,这是一个比较重要的概念。

与 ViewModel 交互

你现在要做的是验证用户输入并让校验他们输入的是否正确。查看项目中的 repositorybusiness 文件夹。

为了提出问题并验证答案,你将在 QuestionsRepository 中模拟后端。然后你将通过 QuizViewModel 来连接 UI 和数据源。

从 ViewModel 中传递数据给可组合项

QuizViewModel 中添加以下两个函数来获取问题:

fun fetchQuestions(): List<String> {
   return repository.getQuestions()
}

fun fetchExtendedQuestions(): List<String>  { 
   return repository.getExtendedQuestions()
}

上述两个函数将调用 repository 中的函数来获取之前介绍过的基本问题和扩展问题列表。

QuizScreen.kt 中找到 QuizScreen(),然后使用 quizViewModel 中函数调用来替换硬编码的 listOf 问题列表:

var questions by remember {
   mutableStateOf(quizViewModel.fetchQuestions())
}

同样的,在 SubmitButton() 上方,通过以下代码替换 listOf() 来修改扩展列表:

questions = quizViewModel.fetchExtendedQuestions()

编译并运行。你不应该看到到任何变化,因为重组仍然像之前一样工作。只是数据源发生了变化。

Screenshot_20220303-183126-243x500

恭喜!你已经将 QuizViewModel 与可组合函数连接起来了。

使用 LiveData 和 SharedFlow 做出响应

当 Jetpack Compose UI 与 ViewModel 通信时,你有多种选择可以对更改做出响应。

打开 QuizViewModel.kt 并查看以下变量:

// 1
private val _state = MutableLiveData<ScreenState>(ScreenState.Quiz)
val state: LiveData<ScreenState> = _state

// 2
private val _event = MutableSharedFlow<Event>()
val event: SharedFlow<Event> = _event

在上述代码中:

  1. state 持有屏幕的状态并通知 Jetpack Compose 何时从 QuizScreen() 切换到 ResultScreen()
  2. event 通过显示对话框或加载指示器来帮助你与用户交互。

这些变量保存 ScreenStateEvent 的实例,它们是 QuizViewModel 底部的密封类。

回到 QuizScreen.kt 文件。在 QuizScreen() 中的 onAnswerChanged 变量上方添加一个 Map 类型的变量 answers

val answers = remember { mutableMapOf<String, String>() }

使用 remember() 来确保当 QuizScreen() 重组时之前回答的问题答案不会丢失。

然后,完善 onAnswerChanged 的 lambda:

answers[question] = answer

使用 questionanswer 变量的值来填充 answers Map 集合。

接下来,为 QuizInput() 函数增加一个新的参数:

fun QuizInput(question: String, onAnswerChanged: (String, String) -> Unit)

然后,一旦输入框的值发生变更就会调用 onAnswerChanged。修改 onValueChange lambda 的代码,如下所示:

run {
    input = value
    onAnswerChanged(question, input)
}

// 译者注:这里可以不用 `run`

要修复之前代码中出现的错误,找到 QuizInputFields() 并向 QuizInput() 传入 onAnswerChanged 回调:

QuizInput(question = question, onAnswerChanged = onAnswerChanged)

最后,你需要在用户提交测验后校验答案。在 QuizScreen() 中,使用以下代码完成 SubmitButton() 中的回调:

quizViewModel.verifyAnswers(answers)

现在你已经将 UI 与 QuizViewModelQuestionsRepository 连接起来,剩下要做的就是如何对变化做出响应。

打开 QuizViewModel.kt 文件并查看 verifyAnswers()

fun verifyAnswers(answers: MutableMap<String, String>) {
    viewModelScope.launch {

        // 1
        _event.emit(Event.Loading)
        delay(1000)

        // 2
        val result = repository.verifyAnswers(answers)
        when (result) {

            // 3
            is Event.Error -> _event.emit(result)
            else -> _state.value = ScreenState.Success
        }
    }
}

在上述函数中有几个重要的点:

  1. verifyAnswers() 被调用时,**SharedFlow ** 会发送一个 Loading 的事件。
  2. 一秒后,调用 repositoryverifyAnswers() 来校验答案。
  3. 如果答案不正确,将发送 Error 的事件,否则,将通过 LiveData 设置 stateScreenState.Success

如你所见,事件和状态应该通过你之前定义的 LiveDataSharedFlowViewModel 传递到可组合项。

现在要做的是 MainScreen() 将如何对状态和事件的变化做出响应。

打开 MainScreen.kt,然后使用下面的代码替换 MainScreen()

// 1
val state by quizViewModel.state.observeAsState()
val event by quizViewModel.event.collectAsState(null)

// 2
when (state) {
    is ScreenState.Quiz -> {
        QuizScreen(
            contentPadding = contentPadding,
            quizViewModel = quizViewModel
        )
        // 3
        when (val e = event) {
            is Event.Error -> {
                ErrorDialog(message = e.message)
            }
            is Event.Loading -> {
                LoadingIndicator()
            }
            else -> {}
        }
    }
    is ScreenState.Success -> {
        ResultScreen(
            contentPadding = contentPadding,
            quizViewModel = quizViewModel
        )
    }
    else -> {}
}

记得导入相关依赖。在上述代码中:

  1. 使用哪个可观察类与 ViewModel 交互取决于你的选择。但请记住差异:使用 observeAsState() 来观察 LiveData,使用 collectAsState() 来观察SharedFlow。你还需要设置 SharedFlow 的初始状态。这就是为什么你将 null 传递给 collectAsState()
  2. 在这里,Jetpack Compose 根据状态值重组 UI。
  3. 在此用例中,你只需要在 QuizScreen() 中处理事件,并根据它发出的错误或加载事件做出相应的响应。在这里,你将 event 分配给局部变量,以便其能智能转换为 Event 实例并访问其 message 属性。

编译并运行,然后点击 Try me,你将看到一个错误对话框。

Screenshot_20220304-170921-243x500

尝试回答问题。如果答案不正确,你会看到一个错误对话框,其中包含相应的提示消息。如果你都回答正确了,你将跳转到成功界面。

Screenshot_20220304-170842-243x500

太棒了,App 就快完成了!

打开 ResultScreen.kt 文件,并在 ResultScreen() 中的 Congrats() 下面添加一个按钮:

SubmitButton (true, stringResource(id = R.string.start_again)) {
  quizViewModel.startAgain()
}

上述代码允许你重新开始测验。

编译并运行,当你正确回答所有问题后,你将跳转到成功界面,在成功界面有个重新开始测验的按钮。

Screenshot_20220409-144219-1-243x500.jpg

挑战:为问题添加 LiveData

现在你已经知道如何从 ViewModel 观察变量,你可以通过向 QuizViewModel 添加另一个可观察变量来使程序更加优雅:

private val _questions = MutableLiveData<List<String>>()
val questions: LiveData<List<String>> = _questions

在继续阅读本文之前,请完善项目中的剩余部分,并在准备好后将其与下面参考方案进行比较。

参考方案

修改 QuizViewModel 中的 fetchQuestions()fetchExtendedQuestions()

fun fetchQuestions() {
   _questions.value =  repository.getQuestions()
}
 
fun fetchExtendedQuestions() {
   _questions.value = repository.getExtendedQuestions()
}

注意它们的返回类型和函数体发生了变化。你不再需要返回问题列表,而是使用 LiveData 来存储问题。

现在,在 QuizViewModel 中添加一个 init 代码块:

init {
   fetchQuestions()
}

现在,当 QuizViewModel 初始化时调用 fetchQuestions()。这样,一旦 App 启动,你就可以准备好 LiveData

最后,打开 QuizScreen.kt 并找到 QuizScreen()。如下所示更改 questions 的赋值方式:

val questions by quizViewModel.questions.observeAsState()

通过上述代码,你将 questions 视作 State 来观察。

再往下面几行,SubmitButton 调用处上面,将 QuizInputFields() 调用包装在 let 块中:

questions?.let { QuizInputFields(it, onAnswerChanged) }

这一步是必需的,因为 observeAsState() 返回一个 nullable 的值。

在下面的代码中,删除对 questions 赋值:

quizViewModel.fetchExtendedQuestions()

现在你不再需要为 questions 赋值,因为 fetchExtendedQuestions() 不再返回任何值。

编译并运行。

Screenshot_20220303-183126-243x500

App 运行一切正常。干得好!

观察可组合生命周期

你基本完成了 App。但是现在还有一个问题:通过测验并点击再次开始以重新开始测验,哎呀,测验界面仍然显示三个问题。

如何再次显示只有两个问题的初始列表呢?

当然,你可以简单地在 startAgain() 中刷新问题列表。但是,这一次你将尝试另一种方法。

首先,打开 QuizScreen.kt 文件,并在 QuizScreen() 最下面添加以下代码:

DisposableEffect(quizViewModel) {
   quizViewModel.onAppear()
   onDispose { quizViewModel.onDisappear() }
}

上述代码在 QuizScreen() 生命周期结束时刷新初始问题,然后再从屏幕上消失。

然后,打开 QuizViewModel.kt 文件,在 onDisappear() 函数体最后添加以下代码:

fetchQuestions()

上述代码会带有 副作用DisposableEffect() 会让 QuizViewModelQuizScreen() 进入组合时调用其 onAppear(),并在离开组合时调用其onDisappear()。通过这种方式,可以将 ViewModel 与可组合项的生命周期绑定,以便当你从测验界面跳转到成功界面时 ViewModel 中的问题列表可以刷新。

注意,在此处,你使用 quizViewModel 作为 DisposableEffect()Key,这意味着如果 quizViewModel 发生变化,DisposableEffect() 也会被触发。

许多其他副作用可以帮助你微调各种可组合项的重组。但请记住,你必须谨慎使用副作用,因为即使由于新的状态更改而取消或重新启动重组,它们也会触发。 如果你的任何可组合项依赖于副作用,这可能会导致 UI 不一致。

提示

译者注:显然教程中的处理方式不太可取,或许我们可以在 onAppear() 中刷新问题列表。

编译并运行。如果你通过测验并重新开始测验,屏幕看起来会像以前一样。

Screenshot_20220409-150015-243x500

查看日志输出。如你所见,在测验屏幕出现之前,问题列表已发生重组:

D/MainLog: QuizScreen disappears
D/MainLog: QuizInput What's the best programming language?
D/MainLog: QuizInput What's the best OS?
D/MainLog: Checked state false
D/MainLog: QuizScreen appears

总结

通过本教程,你了解了:

  1. 可组合项的生命周期,
  2. 更多重组相关的内容,
  3. 如何与 ViewModel 交互,
  4. 谨慎使用副作用

最后,与传统 View 相比,Compose 的生命周期如何?

Jetpack Compose Roadmap