ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【Android】Android Studio 中的协程

【Android】Android Studio 中的协程

Android Studio 中的协程

  • 一. 概述
  • 二. 实现比赛进度
    • 2.1. 实现run方法
    • 2.2. LaunchedEffect 方法
    • 2.3. 结构化并发
    • 2.4. 协程作用域
    • 2.5 rememberCoroutineScope
  • 三. 单元测试
    • 3.1. 导入单元测试库
    • 3.2. 编写测试代码

一. 概述

我们这里使用一个模拟选手赛跑的demo来学洗Android Studio中的协程。

点按 Start 按钮,开始比赛。比赛过程中,Start 按钮上的文字将变为 Pause。

您可以随时使用此按钮暂停或继续比赛。

比赛开始后,您可以通过状态指示器查看每位选手的进度。
StatusIndicator 可组合函数会显示每位选手的进度状态。该函数使用 LinearProgressIndicator 可组合项来显示进度条。您将使用协程来更新进度值。

二. 实现比赛进度

2.1. 实现run方法

/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */packagecom.example.racetracker.uiimportandroid.util.Logimportandroidx.compose.runtime.getValueimportandroidx.compose.runtime.mutableStateOfimportandroidx.compose.runtime.setValueimportkotlinx.coroutines.CancellationExceptionimportkotlinx.coroutines.delayimportkotlin.math.log/** * This class represents a state holder for race participant. */classRaceParticipant(valname:String,valmaxProgress:Int=100,valprogressDelayMillis:Long=500L,privatevalprogressIncrement:Int=1,privatevalinitialProgress:Int=0){init{require(maxProgress>0){"maxProgress=$maxProgress; must be > 0"}require(progressIncrement>0){"progressIncrement=$progressIncrement; must be > 0"}}/** * Indicates the race participant's current progress */varcurrentProgressbymutableStateOf(initialProgress)privateset/** * Regardless of the value of [initialProgress] the reset function will reset the * [currentProgress] to 0 */funreset(){currentProgress=0}suspendfunrun(){try{// 为了模拟比赛,添加一个while循环,在没有完成比赛前一直循环while(currentProgress<maxProgress){// 使用挂起函数,模拟比赛中的不同进度间隔。 延迟 函数执行会挂起,不会阻止执行delay(progressDelayMillis)currentProgress+=progressIncrement;}}catch(e:CancellationException){Log.e("RaceParticipant","$name:${e.message}")throwe}}}/** * The Linear progress indicator expects progress value in the range of 0-1. This property * calculate the progress factor to satisfy the indicator requirements. */valRaceParticipant.progressFactor:Floatget()=currentProgress/maxProgress.toFloat()

在您刚刚添加完的代码时,会在 Android Studio 中调用 delay() 函数的代码左侧看到一个图标,如以下屏幕截图所

此图标表示挂起点,相应函数可能会挂起,稍后再恢复运行。

在协程等待延迟时间完成的过程中,主线程不会阻塞,如下图所示:

2.2. LaunchedEffect 方法

当用户按Start按钮时, 你需要对每个选手实例调用run()挂起函数,从而开始比赛。因此你需要启动一个协程来调用run()函数。

启动协程触发比赛时, 您需要针对两位参赛者确保以下方面:

  • 当用户点击Start按钮时(即启动协程之时),他们都会立刻开始跑
  • 当用户点击Pause或Reset按钮时(即取消携程之时),他们分别暂停或停止奔跑
  • 当用户关闭应用时,系统会妥善管理取消操作,即取消所有协程并将其绑定到一个生命周期。

在之前你可能了解到您只能通过另外一个挂起函数调用挂起函数。如需从可组合箱内安全地调用挂起函数,您需要使用LaunchedEffect()可组合项。只要还在组合中,LaunchedEffect()可组合项就会运行提供的挂起函数。您可以使用LaunchedEffect()可组合函数实现下述所有目标:

  • 借助LaunchedEffect()可组合项,您可以安全从可组合项调用挂起函数
  • 当LaunchedEffect()函数进入组合时,它会启动一个协程,并将代码块作为参数传递。只要还在组合中,它就会运行提供的挂起函数。当用户点Start按钮时,LaunchedEffect()会进入组合并启动一个协程来说更新进度。
  • 当LaunchedEffect()退出组合时,携程会被取消。在应用中,如果用户点击了Pause/Reset按钮,系统会从组合中移除LaunchedEffect(),并取消底层协程。

对于这个例子, 您无需明确提供调度程序,因为LaunchedEffect()会负责。

@ComposablefunRaceTrackerApp(){...varraceInProgressbyremember{mutableStateOf(false)}LaunchedEffect(playerOne,playerTwo){playerOne.run()playerTwo.run()}RaceTrackerScreen(...)}

当你运行项目点击Start发现,选手2是在选手1完成比赛后,才开始跑的,很显然这并不公平, 我们应该并发启动。

方法参数解释:

2.3. 结构化并发

使用协程写代码的方式称为结构化并发。这种编程方式可以提高代码的可读性,并缩短开发时间。结构化并发的思路是,协程具有层次结构,任务可以启动子任务,子任务可以再启动子任务。这种层次结构的单位称为协程作用域。协程作用域应始终与生命周期相关联。

协程 API 在设计上遵循这种结构化并发机制。您不能通过未标记为“挂起”的函数调用挂起函数。此限制可确保您从协程构建器中(例如 launch)调用挂起函数。这些构建器反过来又会与CoroutineScope相关联。

为了让两位参赛者同时开跑, 你需要启动两个单独的协程,并将run()函数的每次调用都移动到这些携程中。使用launch构建器封装对playerOne.run()的调用。

LaunchedEffect(playerOne,playerTwo){launch{playerOne.run()}launch{playerTwo.run()}raceInProgress=false}LaunchedEffect(可变变量){// 执行逻辑}// 注意: 当变量变化时, 会重新执行LaunchedEffect的逻辑。如果只想要执行一次, 参数可以是不可变的或者直接写成Uint。

你运行项目后,点击Start,虽然您认为比赛会开始,,但是按钮的文字立即意外的变回了Start。但是我们的实际要求应该是等两位选手都跑完了才将Pause按钮的文字重置为Start。现在之所以有这个问题是,因为你启动两个协程后, 就立即更新了比赛的标记, 实际上是等两个协程完成后再更新标记。

  • launch构建函数会启动一个协程来执行playerOne.run(),然后立即返回,以执行代码块中下一行
  • 执行playerOne.run()函数的第二个launch构建函数也会发生相同的执行流程。
  • 第二个launch构建器返回后,raceInProgress标记会立即更新。

2.4. 协程作用域

coroutineScope挂起函数会创建一个CoroutineScope,并通过当前作用域调用指定的挂起代码块。该作用域会继承LaunchedEffect()作用域中的coroutineContext

当指定代码块及其所有子协程完成后,作用域会立即返回。对于这个应用,它会在两位参赛者对象都完成run()函数的执行后立即返回。 修改代码如下:

@ComposablefunRaceTrackerApp(){valplayerOne=remember{RaceParticipant(name="Player 1",progressIncrement=1)}valplayerTwo=remember{RaceParticipant(name="Player 2",progressIncrement=2)}varraceInProgressbyremember{mutableStateOf(false)}if(raceInProgress){// 增加LaunchedEffect可组合选项LaunchedEffect(playerOne,playerTwo){// coroutineScope 挂起函数会创建一个 CoroutineScope,// 并通过当前作用域调用指定的挂起代码块。该作用域会继承 LaunchedEffect() 作用域中的 coroutineContext。// 当指定的代码块及其所有子携程完成后,作用域会立刻返回。对于RaceTracker应用,它会在coroutineScope{launch{playerOne.run()}launch{playerTwo.run()}}raceInProgress=false}}// ..... 其他实现}

其流程如下:

  • 当LaunchedEffect()代码块执行时,控制权转移到coroutineScope{..}代码块。
  • coroutineScope代码块以并发方式启动两个协程,然后等待它们完成执行。
    执行完成后,raceInProgress标志会更新。

coroutineScope代码块只会在代码块内的所有代码均完成执行后才会返回并继续执行。对于代码块之外的代码,是否存在并发仅仅是一个实现细节。这种编码方式为并发编程提供了一种结构化的方法,被称为结构并发化。

若要查看协程在用户点按 Reset 按钮时如何被取消,请按以下步骤操作

suspendfunrun(){try{while(currentProgress<maxProgress){// 使用挂起函数,模拟比赛中的不同进度间隔。 延迟 函数执行会挂起,不会阻止执行delay(progressDelayMillis)currentProgress+=progressIncrement;}}catch(e:CancellationException){Log.e("RaceParticipant","$name:${e.message}")throwe}}

当比赛开始一段时间后,点击Reset按钮, 会抛出如下错误。

2.5 rememberCoroutineScope

一个获取“作用域对象”的 Composable 函数。这个对象本身并不执行任何代码,只是为你提供了一个可以在 Composable 函数体之外(如 onClick 回调)启动协程的“容器”。它的关键价值在于生命周期管理:这个 scope 与它在组合中的调用点绑定,Composable 离开组合,scope 就被取消。

如果需要再用户交互(按钮点击)时启动协程,就可以使用rememberCoroutineScope

在 Compose 的按钮点击(或其他 UI 事件回调)中,标准且安全的模式是:

valscope=rememberCoroutineScope()// 获取与UI生命周期绑定的ScopeButton(onClick={scope.launch{// 在事件回调中启动协程// 执行你的挂起操作,例如显示Snackbar、发起网络请求等}})

三. 单元测试

3.1. 导入单元测试库

需要特别留意使用协程的单元测试代码,因为其执行可能是异步的,并且可能发生在多个线程中。

如需在测试中调用挂起函数,您必须位于协程中。由于JUnit测试函数本身并不是挂起函数,因此您需要使用runTest协程构建器。此构建器是kotlinx-coroutines-test库的一部分,旨在用来执行测试。构建器会在新协程中执行测试主体。

注意:协程不仅可以直接在测试主体中启动,还可以使用 runTest 通过测试中使用的对象启动。

由于runTest是kotlinx-coroutines-test库的一部分,因此您需要添加其依赖项。

plugins{...}android{...}dependencies{...testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")}


然后点击顶部通知栏的,Sync Now, 这将完成导入和构建。

3.2. 编写测试代码

  1. 找到位于测试源代码集下的RaceParticipantTest.kt文件。
  2. 在raceParticipant定义之后创建一个raceParticipant_RaceStarted_ProgressUpdated()函数,并为其添加@Test注解,以定义测试。由于测试代码块需要放在runTest构建器中,因此请使用表达式语法将runTest()代码块作为测试结果返回。

/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */packagecom.example.racetrackerimportcom.example.racetracker.ui.RaceParticipantimportkotlinx.coroutines.ExperimentalCoroutinesApiimportkotlinx.coroutines.launchimportkotlinx.coroutines.test.advanceTimeByimportkotlinx.coroutines.test.runCurrentimportkotlinx.coroutines.test.runTestimportorg.junit.Assert.assertEqualsimportorg.junit.TestclassRaceParticipantTest{privatevalraceParticipant=RaceParticipant(name="Test",maxProgress=100,progressDelayMillis=500L,initialProgress=0,progressIncrement=1)@OptIn(ExperimentalCoroutinesApi::class)@TestfunraceParticipant_RaceStarted_ProgressUpdated()=runTest{// 添加只读的expectedProgress变量,并将其设置为1valexpectedProgress=1// 使用launch构建启动新携程并调用raceParticipant.run() 函数,以模拟比赛开始的场景launch{raceParticipant.run()}// raceParticipant.progressDelayMillis 属性的值决定了多长时间后比赛进度才会更新// 为了在progressDelayMillis时间过后测试进度,你需要再测试中添加某种形式的延迟// 使用advanceTimeBy()辅助函数将时间提前raceParticipant.progressDelayMillis的值。// advanceTimeBy() 函数有助于缩短测试执行时间advanceTimeBy(raceParticipant.progressDelayMillis)// 由于 advanceTimeBy() 不会运行原定在给定持续时间运行的任务,// 因此您需要调用 runCurrent() 函数。此函数会在当前时间执行所有待处理的任务runCurrent()// 添加一个对 assertEquals() 函数的调用,以检查 raceParticipant.currentProgress 属性的值// 是否与 expectedProgress 变量的值匹配,以确保进度正确更新。assertEquals(expectedProgress,raceParticipant.currentProgress)}@OptIn(ExperimentalCoroutinesApi::class)@TestfunraceParticipant_RaceFinished_ProgressUpdated()=runTest{launch{raceParticipant.run()}advanceTimeBy(raceParticipant.maxProgress*raceParticipant.progressDelayMillis)runCurrent()assertEquals(100,raceParticipant.currentProgress)}}
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进