Kotlin协程和在Android中的使用总结(四 协程和Retrofit、Room、WorkManager结合使用)

在这里插入图片描述

0 设计一个全新Android app架构的思考

以前有看到过这样的一些文章,如果让你重新设计一个app,你会用什么架构,用哪些三方库,怎么去封装一个模块方便他人使用,自从Android出了Jetpack之后,这些问题似乎有了更清晰的答案。

以上这些考虑的一些依据:

  • 简洁和健壮

所有框架或者库的使用,都应该减少模版代码的产生,甚至做到0模版代码,毫无疑问,Kotlin语言会成为原生开发的首选,通过内置的空安全、标准函数库、扩展属性、方便的自定义DSL、内置支持的属性代理和类代理、方法内联、data class等。

除了语言层面,通过使用Android API中自带的一些功能类(比如内置支持的Lifecycle),加上Jetpack架构组件的使用(比如LiveData和ViewModel),可以将与程序业务逻辑无关的控制逻辑交由系统内部处理,减少自己处理内存泄漏和空指针的代码。

Jetpack中的很多其他组件,比如视图绑定库ViewBinding、数据库Room、后台任务处理WorkManager等等都使得我们可以减少模版代码,同时提供内置的安全机制,使得我们的代码更健壮

  • 易读和可维护

代码简洁的同时,也要做到易读性,这肯定需要团队成员对相关新技术都要有所了解。
同时完整的测试用例必不可少,这可以大大减少自己调试的时间,减少代码变动带来的安全隐患,同时利用Android Studio自带的Lint代码检测以及自定义的Lint检测规则,也可以进一步提高代码的可维护性。

  • 主线程安全性 mian-safe

这一点主要是想说明协程的使用,除了大家都知道的可以减少Callback的嵌套,以及使用看似同步的代码来写出异步的逻辑之外,主线程安全性也是一个很重要的方面。通过将耗时操作改造成挂起函数suspend function,并由函数的编写者使用Dispatcher来指定该函数使用到的线程,那么调用方就无需考虑是否调用该函数会影响主线程安全的问题。

下面开始本文的重点,如何使用协程结合一些功能库来简化代码,提高代码的简洁性(自己写的代码减少了)与稳定性(通过各功能库的内置逻辑保证稳定性,而非自己用代码控制)。

文中涉及到的内容完整的项目代码链接:
git clone https://github.com/googlecodelabs/kotlin-coroutines.git
位于coroutines-codelab 路径下的finished_code

1 Coroutines in Room & Retrofit

关于Room,不理解的可以先学习一下。这里直接举例。

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

通过在函数前面加了suspend关键字,Room就会提供主线程安全性,即main-safe,会自动在一个后台线程上执行它,当然这时候也只能在一个协程里调用该函数了。

没了,协程在Room里的使用就是这么简单。

关于Retrofit,大家应该都很熟悉了,直接举例。

// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}

除了在接口中对函数添加一个suspend关键字之外,对于函数的返回值形式,也有原来的Call包装的结果改成直接的结果类型,就如上面返回了String,当然也可以是你自定义的Json数据类。

改造前的代码可能像下面这样:

suspend fun refreshTitle() {
   // interact with *blocking* network and IO calls from a coroutine
   withContext(Dispatchers.IO) {
       val result = try {
           // Make network request using a blocking call
           network.fetchNextTitle().execute()
       } catch (cause: Throwable) {
           // If the network throws an exception, inform the caller
           throw TitleRefreshError("Unable to refresh title", cause)
       }
      
       if (result.isSuccessful) {
           // Save it to database
           titleDao.insertTitle(Title(result.body()!!))
       } else {
           // If it's not successful, inform the callback of the error
           throw TitleRefreshError("Unable to refresh title", null)
       }
   }
}

改造后的代码像下面这样:

//TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

改造起来还是相当简单的,这里Room会使用设置的query和transaction Executor来执行协程体,Retrofit将在后台线程创建一个新的Call对象,并在其上调用队列以异步发送请求,在结果返回时恢复协程的执行。

同时由于Room和Retrofit提供了主线程安全性main-safe,所以我们在调用的时候,也不用使用withContext(Dispatcher.IO)

2 在高阶函数中使用协程

在上面的代码中,虽然已经精简不少,但是如果有多个请求逻辑,那么都需要写一套try-catch和状态初始化和异常赋值逻辑,这些也是模版代码,比如在ViewModel中调用上面简化后的代码,如下:

// MainViewModel.kt

fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           // 假设_spinner.value的赋值和其他异常逻辑都是通用的
           // 那么下面这行代码才是唯一需要关注的,
           repository.refreshTitle() 
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

这时候我们可以使用函数式编程的样式来编写一个高阶函数,将通用的业务处理逻辑封装起来,如下:

private fun launchDataLoad(block: suspend () -> Unit): Job {
   return viewModelScope.launch {
       try {
           _spinner.value = true
           block()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

那么最终我们在ViewModel中调用时,就只剩下关键的一行代码了:

// MainViewModel.kt

fun refreshTitle() {
   launchDataLoad {
       repository.refreshTitle()
   }
}

其实跟我们使用函数式编程编写其他高阶函数一样,这里只是对参数加了一个suspend关键字修饰而已。也就是suspend lambda可以调用suspend函数,协程构建器launch和runBlocking就是这么实现的。

// suspend lambda

block: suspend () -> Unit

3 将协程和WorkManager结合使用

WorkManagerAndroid Jetpack的一部分,使用 WorkManager API 可以轻松地调度即使在应用退出或设备重启时仍应运行的可延迟异步任务。

主要功能:

  • 最高向后兼容到 API 14
    • 在运行 API 23 及以上级别的设备上使用 JobScheduler
    • 在运行 API 14-22 的设备上结合使用 BroadcastReceiver 和 AlarmManager
  • 添加网络可用性或充电状态等工作约束
  • 调度一次性或周期性异步任务
  • 监控和管理计划任务
  • 将任务链接起来
  • 确保任务执行,即使应用或设备重启也同样执行任务
  • 遵循低电耗模式等省电功能

WorkManager 旨在用于可延迟运行(即不需要立即运行)并且在应用退出或设备重启时必须能够可靠运行的任务。例如:

  • 向后端服务发送日志或分析数据
  • 定期将应用数据与服务器同步

WorkManager 不适用于应用进程结束时能够安全终止的运行中后台工作,也不适用于需要立即执行的任务。

这里直接以使用CoroutineWorker为例,自定义一个类RefreshMainDataWork继承自CoroutineWorker,复写dowork方法,如下:

override suspend fun doWork(): Result {
   val database = getDatabase(applicationContext)
   val repository = TitleRepository(network, database.titleDao)

   return try {
       repository.refreshTitle()
       Result.success()
   } catch (error: TitleRefreshError) {
       Result.failure()
   }
}

注意*CoroutineWorker.doWork()*是一个挂起函数,不同于普通的Worker类使用的配置线程池,其使用coroutineContext中的dispatcher来控制线程调度(默认是Dispatchers.Default)。

4 关于协程取消和超时的处理

上面我们写的代码都没有关于协程取消的逻辑,但是这也是代码健壮性必不可少的一部分。虽然大多数情况下,我们可以借助于Android提供的viewModelScope和lifecycleScope在页面生命周期结束时取消内部的协程,但是仍有一些情况需要我们自己去处理取消和超时逻辑。

这部分可以参考kotlin官网的介绍: Cancellation and Timeouts

使用cancel和join方法或者cancelAndJoin方法,我们可以取消一个Job,如下所示:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        repeat(1000) { i ->
            println("job: I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // 在外部协程体中延迟1300毫秒,上面的job会先执行
    println("main: I'm tired of waiting!")
    job.cancel() // 取消当前的job
    job.join() // 等待直到这个job完成后结束 
    println("main: Now I can quit.")    
}

打印的log为:

job: I’m sleeping 0 …
job: I’m sleeping 1 …
job: I’m sleeping 2 …
main: I’m tired of waiting!
main: Now I can quit.

我们看一下Job的cancel和join方法源码:

abstract fun cancel(
    cause: CancellationException? = null
): Unit (source

abstract suspend fun join(): Unit (source)

在取消时,可以提供一个可选的cause参数,用于指定错误消息或提供有关取消原因的其他详细信息,以进行调试。
至于这个join挂起函数以及cancelAndJoin函数会等待所有的协程体执行完成,包括try-finally块中的逻辑。

一个协程的Job在调用cancel方法后,只是将其状态标记为取消状态,其内部的逻辑仍然会继续执行,这应该不是我们期望的结果,比如如下代码:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) { // computation loop, just wastes CPU
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")    
}

打印的log为:

job: I’m sleeping 0 …
job: I’m sleeping 1 …
job: I’m sleeping 2 …
main: I’m tired of waiting!
job: I’m sleeping 3 …
job: I’m sleeping 4 …
main: Now I can quit.

解决上述问题的方法有两个,第一个是定期调用检查取消的挂起函数。为此,有一个yield函数是一个不错的选择。另一个是明确检查取消状态。让我们尝试后一种方法:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (isActive) { // 通过使用CoroutineScope的扩展属性isActive来使得该计算循环可取消
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("job: I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")    
}

打印的log为:

job: I’m sleeping 0 …
job: I’m sleeping 1 …
job: I’m sleeping 2 …
main: I’m tired of waiting!
main: Now I can quit.

在try-finally块中再次使用挂起函数或抛出Cancel异常,因为此时的协程体已经被取消了。虽然常用的资源释放和关闭操作都是非阻塞式的,并且不会再引入挂起函数的调用,但是在极端情况下通过使用withContext(NonCancellable),可以使得已取消的协程被再次挂起,然后可以继续调用挂起函数:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1000) { i ->
                println("job: I'm sleeping $i ...")
                delay(500L)
            }
        } finally {
            withContext(NonCancellable) {
                println("job: I'm running finally")
                delay(1000L)
                println("job: And I've just delayed for 1 sec because I'm non-cancellable")
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")    
}

Timeout超时控制

比如我们一个网络请求,规定15秒为超时,超时后需要展示超时的UI,那么这里就可以使用withTimeout函数,如下:

import kotlinx.coroutines.*

fun main() = runBlocking {
    withTimeout(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
}

打印的log如下:

I’m sleeping 0 …
I’m sleeping 1 …
I’m sleeping 2 …
Exception in thread “main” kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

然后我们可以使用*try {…} catch (e: TimeoutCancellationException) {…}*来处理超时的逻辑。
通过使用withTimeoutOrNull会在超时后返回null,借助该特性,也可以用来处理超时逻辑。

fun main() = runBlocking {
    val result = withTimeoutOrNull(1300L) {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
        "Done" // will get cancelled before it produces this result
    }
    println("Result is $result")
}

打印的log如下:

I’m sleeping 0 …
I’m sleeping 1 …
I’m sleeping 2 …
Result is null

5 关于测试用例的编写

协程毕竟对于我们来说还是一个新鲜事物,难免会出错,所以针对我们写的协程代码,多写单元测试必不可少,kotlinx-coroutines-test库可以帮助我们测试协程代码,虽然其还处于测试阶段,但是大家还是可以学习一下。
鉴于篇幅有限,这里暂时贴出官方的测试用例编写说明:
Testing coroutines through behavior
Testing coroutines directly

参考:
Using Kotlin Coroutines in your Android App
Advanced Coroutines with Kotlin Flow and LiveData

发布了82 篇原创文章 · 获赞 86 · 访问量 11万+

猜你喜欢

转载自blog.csdn.net/unicorn97/article/details/105170501