以前有看到过这样的一些文章,如果让你重新设计一个app,你会用什么架构,用哪些三方库,怎么去封装一个模块方便他人使用,自从Android出了Jetpack之后,这些问题似乎有了更清晰的答案。
以上这些考虑的一些依据:
所有框架或者库的使用,都应该减少模版代码的产生,甚至做到0模版代码,毫无疑问,Kotlin语言会成为原生开发的首选,通过内置的空安全、标准函数库、扩展属性、方便的自定义DSL、内置支持的属性代理和类代理、方法内联、data class等。
除了语言层面,通过使用Android API中自带的一些功能类(比如内置支持的Lifecycle),加上Jetpack架构组件的使用(比如LiveData和ViewModel),可以将与程序业务逻辑无关的控制逻辑交由系统内部处理,减少自己处理内存泄漏和空指针的代码。
Jetpack中的很多其他组件,比如视图绑定库ViewBinding、数据库Room、后台任务处理WorkManager等等都使得我们可以减少模版代码,同时提供内置的安全机制,使得我们的代码更健壮
代码简洁的同时,也要做到易读性,这肯定需要团队成员对相关新技术都要有所了解。
同时完整的测试用例必不可少,这可以大大减少自己调试的时间,减少代码变动带来的安全隐患,同时利用Android Studio自带的Lint代码检测以及自定义的Lint检测规则,也可以进一步提高代码的可维护性。
这一点主要是想说明协程的使用,除了大家都知道的可以减少Callback的嵌套,以及使用看似同步的代码来写出异步的逻辑之外,主线程安全性也是一个很重要的方面。通过将耗时操作改造成挂起函数suspend function,并由函数的编写者使用Dispatcher来指定该函数使用到的线程,那么调用方就无需考虑是否调用该函数会影响主线程安全的问题。
下面开始本文的重点,如何使用协程结合一些功能库来简化代码,提高代码的简洁性(自己写的代码减少了)与稳定性(通过各功能库的内置逻辑保证稳定性,而非自己用代码控制)。
文中涉及到的内容完整的项目代码链接:
git clone https://github.com/googlecodelabs/kotlin-coroutines.git
位于coroutines-codelab 路径下的finished_code
关于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 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),
在上面的代码中,虽然已经精简不少,但是如果有多个请求逻辑,那么都需要写一套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
WorkManager是Android Jetpack的一部分,使用 WorkManager API 可以轻松地调度即使在应用退出或设备重启时仍应运行的可延迟异步任务。
主要功能:
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)。
上面我们写的代码都没有关于协程取消的逻辑,但是这也是代码健壮性必不可少的一部分。虽然大多数情况下,我们可以借助于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.")
}
比如我们一个网络请求,规定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
协程毕竟对于我们来说还是一个新鲜事物,难免会出错,所以针对我们写的协程代码,多写单元测试必不可少,kotlinx-coroutines-test库可以帮助我们测试协程代码,虽然其还处于测试阶段,但是大家还是可以学习一下。
鉴于篇幅有限,这里暂时贴出官方的测试用例编写说明:
Testing coroutines through behavior
Testing coroutines directly
参考:
Using Kotlin Coroutines in your Android App
Advanced Coroutines with Kotlin Flow and LiveData