0%

Android Coroutine开发实践

Android Coroutine开发实践

背景

在Android应用的开发中,我们最常见到的一个应用场景是请求网络数据接口然后显示在UI界面上,如下面代码所示

1
2
val user = fetchUserData()
textView.text = user.name

但是当你运行上面的代码时就会发发现程序报错,抛出了 NetworkOnMainThreadException 的异常,这是因为Android不允许在主线程(也就是UI线程)中进行网络操作。这时,我们需要加上后台线程去处理网络请求

1
2
3
4
workerThread{
val user = fetchUserData()
textView.text = user.name
}

但是运行后又报错出现了新的异常CalledFromWrongThreadException ,这是因为更新UI组件的内容必须是在主线程去操作,上面的代码中textView.text的更新也是在工作线程,所以出现了错误,而解决这个问题最常规的方式就是将网络请求放在工作线程中执行,当获取到结果后,通过回调监听的方式在主线程更新UI,如下面的代码所示

1
2
3
fetchUserData { user ->  //callback
textView.text = user.name
}

通过callback的方式解决了异步请求及UI更新的问题,但是又会产生新的问题,callback如果不及时释放就会造成内存泄露,从而产生OOM(Out of Memory),所以需要及时释放callback,我们的解决方案是在Activity销毁取消网络请求,释放callback

1
2
3
4
5
6
7
val subscription = fetchUserData { user ->
textView.text = user.name
}

fun onDestroy(){
subscription.cancel()
}

或者通过第三方框架RxJava可以更好的解决这个问题

1
2
3
4
5
6
7
fun fetchUser(): Observable<User> = ...

fetchUser()
.as(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe { user ->
textView.text = user.name
}

所以,通过RxJava或者其他框架可以解决异步请求回调的问题了,那为什么我们还要去使用用coroutine呢?

Why Coroutine?

为什么我们要去学着使用Coroutine? 作为开发者,我们想要在Android中更简单,更直接,更容易实现并发操作,总结一些,就是如下集点要求:简单易上手,良好的扩展性已经非常棒的稳定性。但是不管是RxJava还是JetPack中的LiveData,都有着一定问题,现在我们可以做如下对比:

LiveData:只是一个数据封装的容器,没有完整的并发操作集,没有线程切换,只运行在UI线程

RxJava: 非常强大,有丰富的操作符,也就是因为强大的功能,加重了学习成本,开发者对操作符的理解不够透彻,容易误用操作符。再加上RxJava第第三方框架,Google官方对此投入的支持不是很多

**Coroutine:**刚刚出来没有多久,大多数人觉得还不够稳定,学习曲线比较陡峭,但是得到kotlin语言层面的支持,2019 Google I/O更是提出在JetPack中将coroutine作为Android并发的首要方案支持

综合上述,我们提前学习和了解coroutine是有必要的。

Coroutine基础

快速开始

添加coroutine Gradle依赖
1
2
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2'
使用coroutine

我们通过一个小的网络请求Demo,来演示coroutine的使用。Retrofit是一个REST Api网络请求框架,下面的代码中,我们通过retrofit来获取一个网络接口数据,然后将数据显示在UI界面上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mTvUserName = findViewById(R.id.tv_user_name)
val retrofit = Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
mApiService = retrofit.create(MyRequestService::class.java)

mApiService.getUser().enqueue(object : Callback<UserEntity> {
override fun onFailure(call: Call<UserEntity>, t: Throwable) {
//handle error
}

override fun onResponse(call: Call<UserEntity>, response: Response<UserEntity>) {
//handle result
mTvUserName.text = response.body()?.title
}

})

以往的做法如上面代码所示,我们现在就通过coroutine来实现

1
2
3
4
5
6
7
8
9
10
11
12
GlobalScope.launch(Dispatchers.Main) {
log("launch start")
val user: UserEntity? = getUser()
mTvUserName.text = "用户信息:$user"
}

suspend fun getUser() = withContext(Dispatchers.IO) {
log("getUser method")
val body = mApiService.getUser().execute().body()
log("getUser method result: $body")
body
}

可以通过上面代码看到,使用coroutine方式实现的异步请求跟使用callback实现有很大的不同,代码整体上得到了简化,更直接明了。

coroutine基础
suspend关键字

suspend这个关键字是kotlin专门用来支持coroutine而新增的关键字,这个关键字只能用来修饰方法,并且suspend方法只能被其他suspend方法调用或者在coroutine构建方法中调用。

为了方便,suspend方法后面我们就统一称为挂起函数

我们可以这么理解,每一个独立的挂起函数都可以设置自己的上下文环境,包括线程运行环境等,每一个挂起函数的起始位置我们可以称之为挂起点,挂起函数之间可以相互调用,最后通过kotlin编译器组合起所有的挂起函数,在各个挂起点暂停和恢复挂起函数的运行调用,也就是说coroutine内部的挂起和恢复机制跟原始的callback方案其实是类似的,只是其内部由kotlin编译器帮我们去实现了而不需要我们再去写哪些“冗余代码”。

Dispatchers

coroutine默认提供了三种线程环境,Default,io和Main这三种线程环境可以针对不同的使用场景。

Dispatchers.Default:主要是cpu运算型线程环境,如当存在成千上万的数量运算,文本比较,差分算法等case时,建议使用此线程环境。

Dispatchers.IO:主要是网络或硬盘读写类型的线程环境,如常见的Api网络接口请求,读写手机sdcard的操作都建议使用此线程。

Dispatchers.Main:主线程,在Android中对应的是UI线程,通常情况下,Android组件的更新必须要在此线程环境中运行,同时一个coroutine启动建议放在主线程中。

withContext设置coroutine上下文环境

在网络请求的Coroutine Demo中有几个点需要注意,第一个是我们通过GlobalScope.launch构建方法来初始化启动UI更新coroutine,通过在launch方法中传入Dispatchers.Main参数,将其设置在UI线程中运行;第二个是getUser()这个suspend方法中,通过witchContext构建方法将网络请求coroutine的线程环境切换到io线程。

Coroutine In Android

在AndroidX组件中,大部分组件现在已经对coroutine有非常好的支持,如workmanager,liveData等,包括一些第三方的框架,如Retrofit现在都已经支持coroutine挂起函数。

WorkManager中使用coroutine

WorkManager在work-runtime-ktx:2.0.0版本开始支持coroutine,我们可以像下面示例代码中一样通过继承CoroutineWorker来实现挂起doWork方法

1
2
3
4
class UploadNoteWorker(...): CoroutineWorker(...){
override suspend fun doWork():Result{
}
}

那WorkerManager使用coroutine到底带来了什么优势呢,我们可以看到如下未使用挂起函数的代码示例

1
2
3
4
5
6
7
8
9
10
class UploadNoteWorker(...): Worker(...){
fun doWork():Result{
val newNotes = db.queryNewNotes()
if(!isStopped()) return Result.failure()
noteService.uploadNotes(newNotes)
if(!isStopped()) return Result.failure()
db.markAsSynced(newNotes)
return Result.success()
}
}

在上面的代码中,是一个常见的数据同步的场景,用户将本地的笔记数据同步到云端,其基本流程大致是查询本地数据 -> 上传数据 ->
标记本地已上传的数据。但是在workerManager的工作过程中,有可能用户会主动停止任务或者相关条件不允许(如网络关闭,电量过低等)时取消任务,这就需要在doWork方法中加上是否任务已被取消的判断逻辑,但是这样一来,代码就会变得很不好看。但通过CoroutineWorker会使得代码变得简洁,如下代码所示

1
2
3
4
5
6
7
8
class UploadNoteWorker(...): CoroutineWorker(...){
suspend fun doWork():Result{
val newNotes = db.queryNewNotes()
noteService.uploadNotes(newNotes)
db.markAsSynced(newNotes)
return Result.success()
}
}

使用CoroutineWorker不需要加各种停止的判断条件,当取消worker任务时doWork()挂起函数会逐一的将其内部调用的各个挂起函数都取消,数据库操作使用Room,网络接口请求使用Retrofit。

1
2
3
4
5
6
7
8
9
10
11
//Room数据库操作
class NoteDao{
@Query("SELECT * FROM notes WHERE synced = 0")
suspend fun queryNewNotes(): List<Note>
}

//Retrofit网络请求
interface NoteService {
@Post("/notes/new")
suspend fun uploadNote(@Body note: Note): ResponseBody
}

Room和Retrofit都支持了suspend函数,Room可以保证线程安全,确保query操作在后台线程中执行,而Retrofit同样也能达到这个效果,至此,通过更加简洁的代码保证了并发操作的安全执行以及统一取消。

LiveData和Coroutine

在前面的部分我们提到LiveData是用来更新主线程UI的数据容器,在LiveData: 2.2.0-alpha01之后的版本将提供liveData{}的coroutine构建器

1
2
3
4
5
6
val user: LiveData<User> = liveData {
emit(database.load(userId))
}

@Query("SELECT * FROM User WHERE id = :userId")
suspend fun loadUser(userId : String): User

在上面的代码中liveData{}跟coroutine中的sequence有点类似,在这个liveData的方法块里面可以通过emit()方法发射一个或者多个数据,同事emit方法可以通过类型推导出发射的数据类型,所以上面的代码可以变成这样

1
2
3
val user = liveData{
emit(database.load(userId))
}

我们可以稍微看一下liveData{ }的实现:

1
2
3
4
5
fun <T> liveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
@BuilderInference block: suspend LiveDataScope<T>.() -> Unit
): LiveData<T>

上面的代码中,liveData有三个参数:

context
context参数主要作用是用来做线程环境切换,liveData这个方法块默认是在主线程执行的,通过设置不同的Dispatcher可以将其放到后台线程中执行

timeoutInMs
方法块内方法体执行的超时时间,为什么要设置超时时间呢?这里有一个应用场景,就是旋转屏幕,我们都知道,屏幕旋转时,Activity会先onDestroy然后再onRestart,这里就涉及到liveData{}方法块中数据的释放回收问题,因为当屏幕旋转Activity的生命周期会迅速的onDestroy然后马上onRestart,如果liveData回收然后马上重新创建无意是增加无用的资源消耗,所以这里增加一个超时时间,当activity destroy后过了这个超时时间liveData才会取消coroutine。

block
执行的方法体

emitSource()

liveData{}构建器中,除了emit方法外,还有一个叫emitSource的方法,如下代码中描述的场景,我们从数据库中获取的本地的用户信息,但是需要跟云端的用户信息同步,所以我们从接口中抓取新的用户信息,然后保存到数据库中,再发射到liveData

1
2
3
4
5
6
7
8
class MyRepository {
fun loadUser(userId : String) = liveData {
emit(database.load(userId))
val user = webService.fetch(userId)
database.insert(user)
emit(database.load(userId))
}
}

我们知道Room可以直接返回liveData,所以当query返回的数据就是LiveData时,我们就可以用emitSource,然后emitSource中返回的LiveData就会自动更新新的数据

1
2
3
4
5
6
7
8
9
10
@Query("SELECT * FROM User WHERE id = :userId")
fun loadUser(userId : String) : LiveData<User>

class MyRepository {
fun loadUser(userId : String) = liveData {
emitSource(database.load(userId))
val user = webService.fetch(userId)
database.insert(user)
}
}

ViewModel和Coroutine

Coroutine泄露

就像内存泄露一样,Coroutine也存在泄露的问题,在Android中如果我们通过Coroutine进行网络请求,这时候点击返回Activity销毁,但是Coroutine没有释放,Coroutine又持有了Activity,这样就会造成coroutine的泄露。

为了解决这类问题,coroutine退出了scope的概念,coroutine只能运行在一个scope内,当scope的生命周期结束时,不管scope内部的coroutine是否还在执行,都需要结束,在Scope中的coroutine抛出的异常都可以被scope捕获,通过scope的方式可以避免泄露的问题。

viewModelScope

顾名思义,viewModelScope是跟ViewModel生命周期一样的coroutine scope

1
2
3
4
5
6
viewModelScope.launch {
while(true) {
delay(1_000)
writeFile()
}
}

如上例子所示,我们在viewModelScope中启动了一个coroutine,这个任务是个无线循环,每隔一秒中会去写文件,这是一个相当费资源和耗时的操作,当用户离开当前屏幕时,ViewModel收到onCleared()回调的同时就会取消掉viewModelScope中的所有coroutine。

lifecycleScope

跟viewModelScope一样,lifecycleScope是跟随Lifecycle组件生命周期的coroutine scope。我们都知道android中的Activity和Fragment都有生命周期,lifecycleScope有如下几种启动方式

1
2
3
activity.lifecycleScope.launch {}
fragment.lifecycleScope.launch {}
fragment.viewLifecycleOwner.launch {}

因为fragment的隐藏和显示不一定跟随lifecycle所以增加了一种viewLifecycleOwner的scope用来标示fragment的可见和不可见。

在一些需求开发中,常见的应用场景如下代码所示:

1
2
3
4
5
6
mainHandler.postDelayed(Runnable {
showFullHint()
mainHandler.postDelayed(Runnable{
showSmallHint()
})
}

我们通过UI线程的Handler来做一些延迟UI显示的逻辑,然后还有可能嵌套着使用,这种Runnable内部类的写法就会导致Activity context泄露的问题,而通过coroutine的lifeCycleScope就可以很好的解决这类问题

1
2
3
4
5
6
lifecycleScope.launch {
delay(DELAY_TIME)
showFullHint()
delay(DELAY_TIME)
showSmallHint()
}

通过lifecycleScope方式启动corotuine实现延时显示的需求不仅能使代码看上去更简洁,同时当lifecycle onDestroy时可以使lifecycleScope中启动的coroutine都取消,从而不会引起内存泄露。

lifecycleScope需要比较注意的一点是当Activity configuration改变时生命周期会重走,这时候lifecycleScope中的coroutine也会重新走,所以大部分情况建议使用viewModelScope

launchWhenStarted

当我们操作Fragment,使用commit操作显示fragment时,有时会出现IlleagleStateException的异常信息,这是因为,界面还没有完全显示就进行了fragment的commit操作,通过launchWhenStarted方法可以保证当其方法会在Activity的started或者resumed状态之后才会操作。

1
2
3
4
lifecycleScope.launchWhenStarted{
val note = userViewModel.loadNote()
fragmentManager.beginTransaction()...commit()
}

Coroutine单元测试

关于Coroutine的测试,我们可以使用corotuine新推出的测试库kotlinx-coroutines-test

例如我们现有如下代码通过一个liveData构建起发射数字1然后延迟1秒在发数字2,我们如何去写这个测试代码呢

1
2
3
4
5
6
7
class Repository {
val liveData = liveData {
emit(1)
delay(1_000)
emit(2)
}
}

首先,我们需要定义TestCoroutineDispatcher,这个类用来保证coroutine在测试线程中运行,在就是TestCoroutineScope,用来保证测试方法运行结束后能及时的清理释放couroutine资源。在测试方法运行的开始(setup方法)和结束(tearDown方法)分别初始化测试线程和释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
val testDispatcher = TestCoroutineDispatcher()
val testScope = TestCoroutineScope(testDispatcher)

@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}

@After
fun tearDown() {
Dispatchers.resetMain()
testScope.cleanupTestCoroutines()
}

上面代码中的这种模式几乎是一个固定的样式,熟悉Junit的知道我们可以将这种固定代码定义成一个Test Rule,如下所示

1
2
3
4
5
6
7
8
9
10
11
@get:Rule
val testCoroutineRule = TestCoroutineRule()

@Test
fun testLiveData() = testCoroutineRule.runBlockingTest {
val subject = repository.liveData
subject.observeForTesting {
subject.value shouldEqual 1
advanceTimeBy(1_000)
subject.value shouldEqual 2
}

这个TestCoroutineRule coroutine的测试库中没有提供,我们可以在项目中自行创建一个TestRule。关于observeForTesting方法是LiveData的一个扩展工具方法,主要用来在测试方法中一直监测liveData的数据改变。

1
2
3
4
5
6
7
8
9
fun <T> LiveData<T>.observeForTesting(
block: () -> Unit){
val observer = Observer<T> { Unit }
try {
observeForever(observer)
block()
}finally{
removeObserver(observer)
}

总结

所有内容我们可以通过如下一张表来整理

主题 内容
WorkManager 使用CoroutineWorker
Retrofit 支持suspend fun
Room 支持suspend fun
liveData{} LiveData支持coroutines
viewModelScope Launch in ViewModel
lifecycleScope coroutine运行在UI生命周期内
launchWhenStarted coroutines启动在Fragment所允许的State
kotlinx-coroutines-test Testing coroutines