前言
最近新项目开始,老总发话说我们要用新技术,不能再使用老的架构和技术了。迫于无奈,开始Google推荐的新架构学习,基于单一数据源和单项数据流驱动的MVVM架构。在学习的过程中,又系统的了解了一遍Android协程的使用。有了一些新的感悟,就记录在此了。 本文基本转载自Android官方文档,加了少许个人见解。大佬们看到请轻喷。
一、协程的诞生
众所周知,Android为了主线程安全,是不能在主线程上去执行任何耗时操作的。开发者在进行耗时操作时,需要自己启动子线程后放在子线程中运行,过程中会产生大量的线程管理代码。协程的诞生就是为了优化这一操作,协程是一种并发的设计模式,可以在Android平台上使用它来简化需要异步执行的代码。
协程的特点
协程是Google推荐的在 Android 上进行异步编程的推荐解决方案。它具有一下特点:
- 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。
- 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。
- 内置取消支持:取消功能会自动通过正在运行的协程层次结构传播。
- Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。
二、协程的使用
在后台线程中执行
如果在主线程上发出网络请求,则主线程会处于等待或阻塞状态,直到收到响应。由于线程处于阻塞状态,因此操作系统无法调用 onDraw(),这会导致应用冻结,并有可能导致弹出“应用无响应”(ANR) 对话框。为了解决这个问题,通常开发中我们会在后台线程上执行网络请求等耗时操作。
下面我们以一个简单的登录请求为例,看一下协程操作的使用方法。
首先,我们先看一下在Google推荐的架构中,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() asHttpURLConnection)?.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"))
}
}
上面的代码中为了对网络请求的响应数据做处理,我们创建了自己的 Result 类。其中在 makeLoginRequest 是同步执行函数,会阻塞发起调用的线程。
ViewModel 会在用户与界面发生交互(例如,点击登录按钮)时触发网络请求:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun login(username: String, token: String) {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
loginRepository.makeLoginRequest(jsonBody)
}
}
如果我们直接使用上述代码,LoginViewModel 就会在网络请求发出时阻塞界面线程。如需将执行操作移出主线程,我们以往的方式是启动一个新的线程去执行:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun login(username: String, token: String) {
//创建线程并启动,执行登录请求。
Thread{
Runnable {
loginRepository.makeLoginRequest(jsonBody)
}
}.start()
}
}
上面这样的做法,会让我们在每次执行登网络请求时都创建一个线程,并且在请求完成后需要使用回调和handler把请求结果重新传递给主线程处理。而协程的出翔让我们有个更简单的方法,就是创建一个新的协程,然后在 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
//创建一个新的协程,使其移出UI线程执行, Dispatchers.IO: I/O 操作预留的线程
viewModelScope.launch(Dispatchers.IO) {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
//执行网络请求操作,该请求会在I/O 操作预留的线程上执行
loginRepository.makeLoginRequest(jsonBody)
}
}
}
下面我们仔细分析一下 login 函数中的协程代码:
- viewModelScope 是预定义的 CoroutineScope,包含在 ViewModel KTX 扩展中(详见此处)。请注意,所有协程都必须在一个作用域内运行。一个 CoroutineScope 管理一个或多个相关的协程。
- launch 是一个函数,用于创建协程并将其函数主体的执行分派给相应的调度程序,(Dispatchers.IO) 为可选参数。
- Dispatchers.IO 指示此协程应在为 I/O 操作预留的线程上执行。
login 函数按以下方式执行:
- 应用从主线程上的 View 层调用 login 函数(点击登录按钮)。
- launch 会创建一个新的协程,并且网络请求在为 I/O 操作预留的线程上独立发出。
- 在该协程运行时,login 函数会继续执行,并可能在网络请求完成前返回(请求不会阻塞主线程后续操作)。
由于此协程通过 viewModelScope 启动,因此在 ViewModel 的作用域内执行。如果 ViewModel 因用户离开屏幕而被销毁,则 viewModelScope 会自动取消,且所有运行的协程也会被取消。
前面的示例存在的两个问题是,一是调用 makeLoginRequest 的任何项都需要记得将执行操作显式移出主线程,即在 launch 函数后传入 (Dispatchers.IO) 参数。二是没有对登录请求的结果做处理。下面我们来看看如何修改 Repository 以解决这一问题。
使用协程确保主线程安全
如果函数不会在主线程上阻止界面更新,我们即将其视为是主线程安全的。makeLoginRequest 函数不是主线程安全的,因为从主线程调用 makeLoginRequest 确实会阻塞界面。在上面的代码示例中,我们可以在 ViewModel 中启动协程,并分配对应的调度程序,但是这种做法需要我们每次在调用 makeLoginRequest 时都要去尾货调度程序。为了解决该问题我们可以使用协程库中的 withContext() 函数将协程的执行操作移至其他线程:
class LoginRepository(...) {
private const val loginUrl = "https://example.com/login"
//suspend 关键字表示改方法会阻塞线程,Kotlin 利用此关键字强制从协程内调用函数。
suspend fun makeLoginRequest(
jsonBody: String
): Result<LoginResponse> {
// Move the execution of the coroutine to the I/O dispatcher
//表示协程的后续执行会被放在IO线程中
return withContext(Dispatchers.IO) {
val url = URL(loginUrl)
(url.openConnection() asHttpURLConnection)?.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"))
}
}
}
withContext(Dispatchers.IO) 将协程的执行操作移至一个 I/O 线程,这样一来,我们的调用函数便是主线程安全的,并且支持根据需要更新界面。
makeLoginRequest 还会用 suspend 关键字进行标记。Kotlin 利用此关键字强制从协程内调用函数。
接下来我们在 ViewModel 中,由于 makeLoginRequest 将执行操作移出主线程,login 函数中的协程现在可以在主线程中执行:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun login(username: String, token: String) {
// Create a new coroutine on the UI thread
//直接在UI主线程中启动一个协程
viewModelScope.launch {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
// Make the network call and suspend execution until it finishes
//执行网络操作,并且等待被suspend标记的函数执行完成。
//该等待并不会阻塞主线程,因为被suspend标记的函数会被分配到IO线程执行
val result = loginRepository.makeLoginRequest(jsonBody)
// Display result of the network request to the user
//当收到请求结果后,向用户现实请求结果,并更行对应界面
when (result) {
is Result.Success<LoginResponse> -> // 登录成功,跳转主页。。。
else -> // 登录失败,提示用户错误信息
}
}
}
}
请注意,此处仍需要协程,因为 makeLoginRequest 是一个 suspend 函数,而所有 suspend 函数都必须在协程中执行。
此代码与前面的 login 示例的不同之处体现在以下几个方面:
- launch 不接受 (Dispatchers.IO) 参数。默认从 viewModelScope 启动的所有协程都会在主线程中运行。
- 系统现在会处理网络请求的结果,以显示成功或失败界面。
login 函数现在按以下方式执行:
- 应用从主线程上的 View 层调用 login() 函数。
- launch 在主线程上创建新协程,然后协程开始执行。
- 在协程内,调用 loginRepository.makeLoginRequest() 现在会挂起协程的进一步执行操作,直至 makeLoginRequest() 中的 withContext 块结束运行。
- withContext 块结束运行后,login() 中的协程在主线程上恢复执行操作,并返回网络请求的结果。
- 收到结果后,处理对应的结果并更行UI
处理异常
在进行网络请求或者耗时操作时,经常会抛出异常。为了处理 Repository 可能出现的异常,我们可以使用 try-catch 块捕捉并处理对应异常:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun makeLoginRequest(username: String, token: String) {
viewModelScope.launch {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
//使用try-catch捕捉异常
val result = try {
loginRepository.makeLoginRequest(jsonBody)
} catch(e: Exception) {
Result.Error(Exception("Network request failed"))
}
when (result) {
is Result.Success<LoginResponse> -> // 登录成功,跳转主页。。。
else -> // 登录失败,提示用户错误信息
}
}
}
}
在上面代码示例中,makeLoginRequest() 调用抛出的任何意外异常都会处理为界面错误。
总结
这样我们就使用协程完整实现了一个登录请求的操作,在此过程中,我们只需要在 loginRepository 中使用 withContext 函数声明调度程序,就可以避免耗时操作阻塞主线程的问题,并且不需要开发者自己去管理对应的线程。并且因为有 viewModelScope 的存在,使得我们也不需要去特意处理页面销毁后的请求取消问题。优化性能的同时,又大大减少了我们的代码量。是一种优秀的异步代码处理模式。