Android Coroutine开发实践

背景

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

val user = fetchUserData()
textView.text = user.name
复制代码

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

workerThread{
    val user = fetchUserData()
    textView.text = user.name
}
复制代码

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

fetchUserData { user ->  //callback
    textView.text = user.name
}
复制代码

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

val subscription = fetchUserData { user ->
    textView.text = user.name
}

fun onDestroy(){
    subscription.cancel()
}
复制代码

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

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依赖

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界面上

        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来实现

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方法

class UploadNoteWorker(...): CoroutineWorker(...){
    override suspend fun doWork():Result{
    }
}
复制代码

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

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会使得代码变得简洁,如下代码所示

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。

//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构建器

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方法可以通过类型推导出发射的数据类型,所以上面的代码可以变成这样

val user = liveData{
    emit(database.load(userId))
}
复制代码

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

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

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就会自动更新新的数据

@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

viewModelScope.launch {
    while(true) {
        delay(1_000)
        writeFile()
    }
}
复制代码

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

lifecycleScope

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

activity.lifecycleScope.launch {}
fragment.lifecycleScope.launch {}
fragment.viewLifecycleOwner.launch {}
复制代码

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

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

mainHandler.postDelayed(Runnable {
    showFullHint()
    mainHandler.postDelayed(Runnable{
        showSmallHint()
    })
}
复制代码

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

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状态之后才会操作。

lifecycleScope.launchWhenStarted{
    val note = userViewModel.loadNote()
    fragmentManager.beginTransaction()...commit()
}
复制代码

Coroutine单元测试

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

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

class Repository {
    val liveData = liveData {
        emit(1)
        delay(1_000)
        emit(2)
    }
}
复制代码

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

val testDispatcher = TestCoroutineDispatcher()
val testScope = TestCoroutineScope(testDispatcher)

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

@After
fun tearDown() {
    Dispatchers.resetMain()
    testScope.cleanupTestCoroutines()
}
复制代码

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

@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的数据改变。

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

更多精彩...

分类:
Android
标签: