Android Kotlin开发之 协程

Android 上的 Kotlin 协程

协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。协程是在版本 1.3 中添加到 Kotlin 的,它基于来自其他语言的既定概念。

在 Android 上,协程有助于管理长时间运行的任务,如果管理不当,这些任务可能会阻塞主线程并导致应用无响应。使用协程的专业开发者中有超过 50% 的人反映使用协程提高了工作效率。本主题介绍如何使用 Kotlin 协程解决以下问题,从而让您能够编写出更清晰、更简洁的应用代码。

功能

协程是我们在 Android 上进行异步编程的推荐解决方案。值得关注的功能包括:

  • 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。
  • 内存泄露更少:使用结构化并发机制在一个作用域内执行多个操作。
  • 内置取消支持取消功能会自动通过正在运行的协程层次结构传播。
  • Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。

示例概览

根据应用架构指南,本主题中的示例会发出网络请求并将结果返回到主线程,然后应用可以在主线程上向用户显示结果。

具体而言,ViewModel 架构组件会在主线程上调用代码库层,以触发网络请求。本指南介绍了多种使用协程确保主线程畅通的解决方案。

ViewModel 包含一组可直接与协程配合使用的 KTX 扩展。这些扩展是 lifecycle-viewmodel-ktx,在本指南中有用到。

依赖项信息

如需在 Android 项目中使用协程,请将以下依赖项添加到应用的 build.gradle 文件中:

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

在后台线程中执行

如果在主线程上发出网络请求,则主线程会处于等待或阻塞状态,直到收到响应。由于线程处于阻塞状态,因此操作系统无法调用 onDraw(),这会导致应用冻结,并有可能导致弹出“应用无响应”(ANR) 对话框。为了提供更好的用户体验,我们在后台线程上执行此操作。

首先,我们来了解一下 Repository 类,看看它是如何发出网络请求的:

sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

class LoginRepository(private val responseParser: LoginResponseParser) {
    private const val loginUrl = "https://example.com/login"

    // Function that makes the network request, blocking the current thread
    fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {
        val url = URL(loginUrl)
        (url.openConnection() as? HttpURLConnection)?.run {
            requestMethod = "POST"
            setRequestProperty("Content-Type", "application/json; utf-8")
            setRequestProperty("Accept", "application/json")
            doOutput = true
            outputStream.write(jsonBody.toByteArray())
            return Result.Success(responseParser.parse(inputStream))
        }
        return Result.Error(Exception("Cannot open HttpURLConnection"))
    }
}

makeLoginRequest 是同步的,并且会阻塞发起调用的线程。为了对网络请求的响应建模,我们创建了自己的 Result 类。

ViewModel 会在用户点击(例如,点击按钮)时触发网络请求:

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        val jsonBody = "{ username: \"$username\", token: \"$token\"}"
        loginRepository.makeLoginRequest(jsonBody)
    }
}

使用上述代码,LoginViewModel 会在网络请求发出时阻塞界面线程。如需将执行操作移出主线程,最简单的方法是创建一个新的协程,然后在 I/O 线程上执行网络请求:

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}

下面我们仔细分析一下 login 函数中的协程代码:

  • viewModelScope 是预定义的 CoroutineScope,包含在 ViewModel KTX 扩展中。请注意,所有协程都必须在一个作用域内运行。一个 CoroutineScope 管理一个或多个相关的协程。
  • launch 是一个函数,用于创建协程并将其函数主体的执行分派给相应的调度程序。
  • Dispatchers.IO 指示此协程应在为 I/O 操作预留的线程上执行。

login 函数按以下方式执行:

  • 应用从主线程上的 View 层调用 login 函数。
  • launch 会创建一个新的协程,并且网络请求在为 I/O 操作预留的线程上独立发出。
  • 在该协程运行时,login 函数会继续执行,并可能在网络请求完成前返回。请注意,为简单起见,我们暂时忽略掉网络响应。

由于此协程通过 viewModelScope 启动,因此在 ViewModel 的作用域内执行。如果 ViewModel 因用户离开屏幕而被销毁,则 viewModelScope 会自动取消,且所有运行的协程也会被取消。

前面的示例存在的一个问题是,调用 makeLoginRequest 的任何项都需要记得将执行操作显式移出主线程。下面我们来看看如何修改 Repository 以解决这一问题。

使用协程确保主线程安全

如果函数不会在主线程上阻止界面更新,我们即将其视为是主线程安全的。makeLoginRequest 函数不是主线程安全的,因为从主线程调用 makeLoginRequest 确实会阻塞界面。可以使用协程库中的 withContext() 函数将协程的执行操作移至其他线程:

class LoginRepository(...) {
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {

        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) {
            // Blocking network request code
        }
    }
}

withContext(Dispatchers.IO) 将协程的执行操作移至一个 I/O 线程,这样一来,我们的调用函数便是主线程安全的,并且支持根据需要更新界面。

makeLoginRequest 还会用 suspend 关键字进行标记。Kotlin 利用此关键字强制从协程内调用函数。

注意:为更轻松地进行测试,我们建议将 Dispatchers 注入 Repository 层。如需了解详情,请参阅在 Android 上测试协程

在以下示例中,协程是在 LoginViewModel 中创建的。由于 makeLoginRequest 将执行操作移出主线程,login 函数中的协程现在可以在主线程中执行:

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {

        // Create a new coroutine on the UI thread
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"

            // Make the network call and suspend execution until it finishes
            val result = loginRepository.makeLoginRequest(jsonBody)

            // Display result of the network request to the user
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

请注意,此处仍需要协程,因为 makeLoginRequest 是一个 suspend 函数,而所有 suspend 函数都必须在协程中执行。

此代码与前面的 login 示例的不同之处体现在以下几个方面:

  • launch 不接受 Dispatchers.IO 参数。如果您未将 Dispatcher 传递至 launch,则从 viewModelScope 启动的所有协程都会在主线程中运行。
  • 系统现在会处理网络请求的结果,以显示成功或失败界面。

login 函数现在按以下方式执行:

  • 应用从主线程上的 View 层调用 login() 函数。
  • launch 创建一个新的协程,以在主线程上发出网络请求,然后该协程开始执行。
  • 在协程内,调用 loginRepository.makeLoginRequest() 现在会挂起协程的进一步执行操作,直至 makeLoginRequest() 中的 withContext 块结束运行。
  • withContext 块结束运行后,login() 中的协程在主线程上恢复执行操作,并返回网络请求的结果。

注意:如需与 ViewModel 层中的 View 通信,请按照应用架构指南中的建议,使用 LiveData。遵循此模式时,ViewModel 中的代码会在主线程上执行,因此您可以直接调用 MutableLiveDatasetValue() 函数。

处理异常

为了处理 Repository 层可能抛出的异常,请使用 Kotlin 对异常的内置支持。在以下示例中,我们使用的是 try-catch 块:

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun makeLoginRequest(username: String, token: String) {
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            val result = try {
                loginRepository.makeLoginRequest(jsonBody)
            } catch(e: Exception) {
                Result.Error(Exception("Network request failed"))
            }
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

在此示例中,makeLoginRequest() 调用抛出的任何意外异常都会处理为界面错误。

利用 Kotlin 协程提升应用性能

借助 Kotlin 协程,您可以编写整洁、简化的异步代码,以在管理长时间运行的任务(例如网络调用或磁盘操作)时使应用保持敏捷的响应。

本主题详细介绍 Android 上的协程。如果您不熟悉协程,请务必先阅读 Android 上的 Kotlin 协程,然后再阅读本主题。

管理长时间运行的任务

协程在常规函数的基础上添加了两项操作,用于处理长时间运行的任务。在 invoke(或 call)和 return 之外,协程添加了 suspendresume

  • suspend 用于暂停执行当前协程,并保存所有局部变量。
  • resume 用于让已暂停的协程从暂停处继续执行。

如需调用 suspend 函数,只能从其他 suspend 函数进行调用,或通过使用协程构建器(例如 launch)来启动新的协程。

以下示例展示了一项任务(假设这是一项长时间运行的任务)的简单协程实现:

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

在上面的示例中,get() 仍在主线程上运行,但它会在启动网络请求之前暂停协程。当网络请求完成时,get 会恢复已暂停的协程,而不是使用回调通知主线程。

Kotlin 使用堆栈帧管理要运行哪个函数以及所有局部变量。暂停协程时,系统会复制并保存当前的堆栈帧以供稍后使用。恢复时,会将堆栈帧从其保存位置复制回来,然后函数再次开始运行。即使代码可能看起来像普通的顺序阻塞请求,协程也能确保网络请求避免阻塞主线程。

使用协程确保主线程安全

Kotlin 协程使用调度程序确定哪些线程用于执行协程。要在主线程之外运行代码,可以让 Kotlin 协程在 Default 或 IO 调度程序上执行工作。在 Kotlin 中,所有协程都必须在调度程序中运行,即使它们在主线程上运行也是如此。协程可以自行暂停,而调度程序负责将其恢复。

Kotlin 提供了三个调度程序,可用于指定应在何处运行协程:

  • Dispatchers.Main - 使用此调度程序可在 Android 主线程上运行协程。此调度程序只能用于与界面交互和执行快速工作。示例包括调用 suspend 函数、运行 Android 界面框架操作,以及更新 LiveData 对象。
  • Dispatchers.IO - 此调度程序经过了专门优化,适合在主线程之外执行磁盘或网络 I/O。示例包括使用 Room 组件、从文件中读取数据或向文件中写入数据,以及运行任何网络操作。
  • Dispatchers.Default - 此调度程序经过了专门优化,适合在主线程之外执行占用大量 CPU 资源的工作。用例示例包括对列表排序和解析 JSON。

接着前面的示例来讲,您可以使用调度程序重新定义 get 函数。在 get 的主体内,调用 withContext(Dispatchers.IO) 来创建一个在 IO 线程池中运行的块。您放在该块内的任何代码都始终通过 IO 调度程序执行。由于 withContext 本身就是一个暂停函数,因此函数 get 也是一个暂停函数。

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

借助协程,您可以通过精细控制来调度线程。由于 withContext() 可让您在不引入回调的情况下控制任何代码行的线程池,因此您可以将其应用于非常小的函数,例如从数据库中读取数据或执行网络请求。一种不错的做法是使用 withContext() 来确保每个函数都是主线程安全的,这意味着,您可以从主线程调用每个函数。这样,调用方就从不需要考虑应该使用哪个线程来执行函数了。

在前面的示例中,fetchDocs() 在主线程上执行;不过,它可以安全地调用 get,这样会在后台执行网络请求。由于协程支持 suspendresume,因此 withContext 块完成后,主线程上的协程会立即根据 get 结果恢复。

重要提示:使用 suspend 不会让 Kotlin 在后台线程上运行函数。suspend 函数在主线程上运行是一种正常的现象。在主线程上启动协程的情况也很常见。当您需要确保主线程安全时(例如,从磁盘上读取数据或向磁盘中写入数据、执行网络操作或运行占用大量 CPU 资源的操作时),应始终在 suspend 函数内使用 withContext()

withContext() 的效用

与基于回调的等效实现相比,withContext() 不会增加额外的开销。此外,在某些情况下,还可以优化 withContext() 调用,使其超越基于回调的等效实现。例如,如果某个函数对一个网络进行十次调用,您可以使用外部 withContext() 让 Kotlin 只切换一次线程。这样,即使网络库多次使用 withContext(),它也会留在同一调度程序上,并避免切换线程。此外,Kotlin 还优化了 Dispatchers.DefaultDispatchers.IO 之间的切换,以尽可能避免线程切换。

重要提示:利用一个使用线程池的调度程序(例如 Dispatchers.IODispatchers.Default)不能保证块在同一线程上从上到下执行。在某些情况下,Kotlin 协程在 suspendresume 后可能会将执行工作移交给另一个线程。这意味着,对于整个 withContext() 块,线程局部变量可能并不指向同一个值。

启动协程

您可以通过以下两种方式来启动协程:

  • launch 可启动新协程而不将结果返回给调用方。任何被视为“一劳永逸”的工作都可以使用 launch 来启动。
  • async会启动一个新的协程,并允许您使用一个名为 await 的暂停函数返回结果。

通常,您应使用 launch 从常规函数启动新协程,因为常规函数无法调用 await。只有在另一个协程内或在暂停函数内且在执行并行分解时,才使用 async

警告launchasync 以不同的方式处理异常。由于 async 希望在某一时刻对 await 进行最终调用,因此它持有异常并将其作为 await 调用的一部分重新抛出。这意味着,如果您使用 await 从常规函数启动新协程,则能以静默方式丢弃异常。这些丢弃的异常不会出现在崩溃指标中,也不会在 logcat 中注明。如需了解详情,请参阅协程中的取消和异常

并行分解

suspend 函数启动的所有协程都必须在该函数返回结果时停止,因此您可能需要保证这些协程在返回结果之前完成。借助 Kotlin 中的结构化并发机制,您可以定义用于启动一个或多个协程的 coroutineScope。然后,您可以使用 await()(针对单个协程)或 awaitAll()(针对多个协程)保证这些协程在从函数返回结果之前完成。

例如,假设我们定义一个用于异步获取两个文档的 coroutineScope。通过对每个延迟引用调用 await(),我们可以保证这两项 async 操作在返回值之前完成:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

您还可以对集合使用 awaitAll(),如以下示例所示:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

虽然 fetchTwoDocs() 使用 async 启动新协程,但该函数使用 awaitAll() 等待启动的协程完成后才会返回结果。不过请注意,即使我们没有调用 awaitAll()coroutineScope 构建器也会等到所有新协程都完成后才恢复名为 fetchTwoDocs 的协程。

此外,coroutineScope 会捕获协程抛出的所有异常,并将其传送回调用方。

如需详细了解并行分解,请参阅编写暂停函数

协程概念

CoroutineScope

CoroutineScope 会跟踪它使用 launchasync 创建的所有协程。您可以随时调用 scope.cancel() 以取消正在进行的工作(即正在运行的协程)。在 Android 中,某些 KTX 库为某些生命周期类提供自己的 CoroutineScope。例如,ViewModelviewModelScopeLifecyclelifecycleScope。不过,与调度程序不同,CoroutineScope 不运行协程。

注意:如需详细了解 viewModelScope,请参阅 Android 中的简易协程:viewModelScope

viewModelScope 也可用于 Android 上采用协程的后台线程中的示例。但是,如果您需要创建自己的 CoroutineScope 以控制协程在应用的特定层中的生命周期,则可以创建一个如下所示的 CoroutineScope:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

已取消的作用域无法再创建协程。因此,仅当控制其生命周期的类被销毁时,才应调用 scope.cancel()。使用 viewModelScope 时,ViewModel 类会在 ViewModel 的 onCleared() 方法中自动为您取消作用域。

作业

Job 是协程的句柄。使用 launchasync 创建的每个协程都会返回一个 Job 实例,该实例唯一标识协程并管理其生命周期。您还可以将 Job 传递给 CoroutineScope 以进一步管理其生命周期,如以下示例所示:

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext 使用以下元素集定义协程的行为:

对于在作用域内创建的新协程,系统会为新协程分配一个新的 Job 实例,而从包含作用域继承其他 CoroutineContext 元素。可以通过向 launchasync 函数传递新的 CoroutineContext 替换继承的元素。请注意,将 Job 传递给 launchasync 不会产生任何效果,因为系统始终会像新协程分配新的 Job 实例。

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + "BackgroundCoroutine") {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

猜你喜欢

转载自blog.csdn.net/MYBOYER/article/details/109469277