前言
最近新項目開始,老總發(fā)話說我們要用新技術(shù)践惑,不能再使用老的架構(gòu)和技術(shù)了。迫于無奈嘶卧,開始Google推薦的新架構(gòu)學習尔觉,基于單一數(shù)據(jù)源和單項數(shù)據(jù)流驅(qū)動的MVVM架構(gòu)。在學習的過程中芥吟,又系統(tǒng)的了解了一遍Android協(xié)程的使用侦铜。有了一些新的感悟,就記錄在此了钟鸵。 本文基本轉(zhuǎn)載自Android官方文檔钉稍,加了少許個人見解。大佬們看到請輕噴棺耍。
一贡未、協(xié)程的誕生
眾所周知,Android為了主線程安全蒙袍,是不能在主線程上去執(zhí)行任何耗時操作的俊卤。開發(fā)者在進行耗時操作時,需要自己啟動子線程后放在子線程中運行害幅,過程中會產(chǎn)生大量的線程管理代碼消恍。協(xié)程的誕生就是為了優(yōu)化這一操作,協(xié)程是一種并發(fā)的設(shè)計模式以现,可以在Android平臺上使用它來簡化需要異步執(zhí)行的代碼狠怨。
協(xié)程的特點
協(xié)程是Google推薦的在 Android 上進行異步編程的推薦解決方案。它具有一下特點:
- 輕量:您可以在單個線程上運行多個協(xié)程邑遏,因為協(xié)程支持掛起佣赖,不會使正在運行協(xié)程的線程阻塞。掛起比阻塞節(jié)省內(nèi)存无宿,且支持多個并行操作茵汰。
- 內(nèi)存泄漏更少:使用結(jié)構(gòu)化并發(fā)機制在一個作用域內(nèi)執(zhí)行多項操作。
- 內(nèi)置取消支持:取消功能會自動通過正在運行的協(xié)程層次結(jié)構(gòu)傳播孽鸡。
- Jetpack 集成:許多 Jetpack 庫都包含提供全面協(xié)程支持的擴展蹂午。某些庫還提供自己的協(xié)程作用域,可供您用于結(jié)構(gòu)化并發(fā)彬碱。
二豆胸、協(xié)程的使用
在后臺線程中執(zhí)行
如果在主線程上發(fā)出網(wǎng)絡請求,則主線程會處于等待或阻塞狀態(tài)巷疼,直到收到響應晚胡。由于線程處于阻塞狀態(tài),因此操作系統(tǒng)無法調(diào)用 onDraw(),這會導致應用凍結(jié)估盘,并有可能導致彈出“應用無響應”(ANR) 對話框瓷患。為了解決這個問題,通常開發(fā)中我們會在后臺線程上執(zhí)行網(wǎng)絡請求等耗時操作遣妥。
下面我們以一個簡單的登錄請求為例擅编,看一下協(xié)程操作的使用方法。
首先箫踩,我們先看一下在Google推薦的架構(gòu)中爱态,Repository 類是如何發(fā)出請求的:
//網(wǎng)絡請求響應結(jié)果實體封裝類
sealed class Result<out R> {
//帶范型的返回數(shù)據(jù)類
data class Success<out T>(val data: T) : Result<T>()
//網(wǎng)絡請求錯誤結(jié)果數(shù)據(jù)類
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
//具體的網(wǎng)絡請求函數(shù),會阻塞當前線程境钟,直到結(jié)果返回锦担。
fun makeLoginRequest(
jsonBody: String
): Result<LoginResponse> {
val url = URL(loginUrl)
(url.openConnection() as? HttpURLConnection)?.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"))
}
}
上面的代碼中為了對網(wǎng)絡請求的響應數(shù)據(jù)做處理,我們創(chuàng)建了自己的 Result 類慨削。其中在 makeLoginRequest 是同步執(zhí)行函數(shù)洞渔,會阻塞發(fā)起調(diào)用的線程。
ViewModel 會在用戶與界面發(fā)生交互(例如理盆,點擊登錄按鈕)時觸發(fā)網(wǎng)絡請求:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun login(username: String, token: String) {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
loginRepository.makeLoginRequest(jsonBody)
}
}
如果我們直接使用上述代碼痘煤,LoginViewModel 就會在網(wǎng)絡請求發(fā)出時阻塞界面線程。如需將執(zhí)行操作移出主線程猿规,我們以往的方式是啟動一個新的線程去執(zhí)行:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun login(username: String, token: String) {
//創(chuàng)建線程并啟動衷快,執(zhí)行登錄請求。
Thread{
Runnable {
loginRepository.makeLoginRequest(jsonBody)
}
}.start()
}
}
上面這樣的做法姨俩,會讓我們在每次執(zhí)行登網(wǎng)絡請求時都創(chuàng)建一個線程蘸拔,并且在請求完成后需要使用回調(diào)和handler把請求結(jié)果重新傳遞給主線程處理。而協(xié)程的出翔讓我們有個更簡單的方法环葵,就是創(chuàng)建一個新的協(xié)程调窍,然后在 I/O 線程上執(zhí)行網(wǎng)絡請求:
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
//創(chuàng)建一個新的協(xié)程,使其移出UI線程執(zhí)行张遭, Dispatchers.IO: I/O 操作預留的線程
viewModelScope.launch(Dispatchers.IO) {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
//執(zhí)行網(wǎng)絡請求操作邓萨,該請求會在I/O 操作預留的線程上執(zhí)行
loginRepository.makeLoginRequest(jsonBody)
}
}
}
下面我們仔細分析一下 login 函數(shù)中的協(xié)程代碼:
- viewModelScope 是預定義的 CoroutineScope,包含在 ViewModel KTX 擴展中菊卷。請注意缔恳,所有協(xié)程都必須在一個作用域內(nèi)運行。一個 CoroutineScope 管理一個或多個相關(guān)的協(xié)程洁闰。
- launch 是一個函數(shù)歉甚,用于創(chuàng)建協(xié)程并將其函數(shù)主體的執(zhí)行分派給相應的調(diào)度程序,(Dispatchers.IO) 為可選參數(shù)扑眉。
- Dispatchers.IO 指示此協(xié)程應在為 I/O 操作預留的線程上執(zhí)行纸泄。
login 函數(shù)按以下方式執(zhí)行:
- 應用從主線程上的 View 層調(diào)用 login 函數(shù)(點擊登錄按鈕)赖钞。
- launch 會創(chuàng)建一個新的協(xié)程,并且網(wǎng)絡請求在為 I/O 操作預留的線程上獨立發(fā)出聘裁。
- 在該協(xié)程運行時雪营,login 函數(shù)會繼續(xù)執(zhí)行,并可能在網(wǎng)絡請求完成前返回(請求不會阻塞主線程后續(xù)操作)咧虎。
由于此協(xié)程通過 viewModelScope 啟動卓缰,因此在 ViewModel 的作用域內(nèi)執(zhí)行。如果 ViewModel 因用戶離開屏幕而被銷毀砰诵,則 viewModelScope 會自動取消,且所有運行的協(xié)程也會被取消捌显。
前面的示例存在的兩個問題是茁彭,一是調(diào)用 makeLoginRequest 的任何項都需要記得將執(zhí)行操作顯式移出主線程,即在 launch 函數(shù)后傳入 (Dispatchers.IO) 參數(shù)扶歪。二是沒有對登錄請求的結(jié)果做處理理肺。下面我們來看看如何修改 Repository 以解決這一問題。
使用協(xié)程確保主線程安全
如果函數(shù)不會在主線程上阻止界面更新善镰,我們即將其視為是主線程安全的妹萨。makeLoginRequest 函數(shù)不是主線程安全的,因為從主線程調(diào)用 makeLoginRequest 確實會阻塞界面炫欺。在上面的代碼示例中乎完,我們可以在 ViewModel 中啟動協(xié)程,并分配對應的調(diào)度程序品洛,但是這種做法需要我們每次在調(diào)用 makeLoginRequest 時都要去尾貨調(diào)度程序树姨。為了解決該問題我們可以使用協(xié)程庫中的 withContext() 函數(shù)將協(xié)程的執(zhí)行操作移至其他線程:
class LoginRepository(...) {
private const val loginUrl = "https://example.com/login"
//suspend 關(guān)鍵字表示改方法會阻塞線程,Kotlin 利用此關(guān)鍵字強制從協(xié)程內(nèi)調(diào)用函數(shù)桥状。
suspend fun makeLoginRequest(
jsonBody: String
): Result<LoginResponse> {
// Move the execution of the coroutine to the I/O dispatcher
//表示協(xié)程的后續(xù)執(zhí)行會被放在IO線程中
return withContext(Dispatchers.IO) {
val url = URL(loginUrl)
(url.openConnection() as? HttpURLConnection)?.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) 將協(xié)程的執(zhí)行操作移至一個 I/O 線程帽揪,這樣一來,我們的調(diào)用函數(shù)便是主線程安全的辅斟,并且支持根據(jù)需要更新界面转晰。
makeLoginRequest 還會用 suspend 關(guān)鍵字進行標記。Kotlin 利用此關(guān)鍵字強制從協(xié)程內(nèi)調(diào)用函數(shù)士飒。
接下來我們在 ViewModel 中查邢,由于 makeLoginRequest 將執(zhí)行操作移出主線程,login 函數(shù)中的協(xié)程現(xiàn)在可以在主線程中執(zhí)行:
class LoginViewModel(
private val loginRepository: LoginRepository
): ViewModel() {
fun login(username: String, token: String) {
// Create a new coroutine on the UI thread
//直接在UI主線程中啟動一個協(xié)程
viewModelScope.launch {
val jsonBody = "{ username: \"$username\", token: \"$token\"}"
// Make the network call and suspend execution until it finishes
//執(zhí)行網(wǎng)絡操作变汪,并且等待被suspend標記的函數(shù)執(zhí)行完成侠坎。
//該等待并不會阻塞主線程,因為被suspend標記的函數(shù)會被分配到IO線程執(zhí)行
val result = loginRepository.makeLoginRequest(jsonBody)
// Display result of the network request to the user
//當收到請求結(jié)果后裙盾,向用戶現(xiàn)實請求結(jié)果实胸,并更行對應界面
when (result) {
is Result.Success<LoginResponse> -> // 登錄成功他嫡,跳轉(zhuǎn)主頁。庐完。钢属。
else -> // 登錄失敗,提示用戶錯誤信息
}
}
}
}
請注意门躯,此處仍需要協(xié)程淆党,因為 makeLoginRequest 是一個 suspend 函數(shù),而所有 suspend 函數(shù)都必須在協(xié)程中執(zhí)行讶凉。
此代碼與前面的 login 示例的不同之處體現(xiàn)在以下幾個方面:
- launch 不接受 (Dispatchers.IO) 參數(shù)染乌。默認從 viewModelScope 啟動的所有協(xié)程都會在主線程中運行。
- 系統(tǒng)現(xiàn)在會處理網(wǎng)絡請求的結(jié)果懂讯,以顯示成功或失敗界面荷憋。
login 函數(shù)現(xiàn)在按以下方式執(zhí)行:
- 應用從主線程上的 View 層調(diào)用 login() 函數(shù)。
- launch 在主線程上創(chuàng)建新協(xié)程褐望,然后協(xié)程開始執(zhí)行勒庄。
- 在協(xié)程內(nèi),調(diào)用 loginRepository.makeLoginRequest() 現(xiàn)在會掛起協(xié)程的進一步執(zhí)行操作瘫里,直至 makeLoginRequest() 中的 withContext 塊結(jié)束運行实蔽。
- withContext 塊結(jié)束運行后,login() 中的協(xié)程在主線程上恢復執(zhí)行操作谨读,并返回網(wǎng)絡請求的結(jié)果局装。
- 收到結(jié)果后,處理對應的結(jié)果并更行UI
處理異常
在進行網(wǎng)絡請求或者耗時操作時漆腌,經(jīng)常會拋出異常贼邓。為了處理 Repository 可能出現(xiàn)的異常,我們可以使用 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> -> // 登錄成功闷尿,跳轉(zhuǎn)主頁塑径。。填具。
else -> // 登錄失敗统舀,提示用戶錯誤信息
}
}
}
}
在上面代碼示例中,makeLoginRequest() 調(diào)用拋出的任何意外異常都會處理為界面錯誤劳景。
總結(jié)
這樣我們就使用協(xié)程完整實現(xiàn)了一個登錄請求的操作誉简,在此過程中,我們只需要在 loginRepository 中使用 withContext 函數(shù)聲明調(diào)度程序盟广,就可以避免耗時操作阻塞主線程的問題闷串,并且不需要開發(fā)者自己去管理對應的線程。并且因為有 viewModelScope 的存在筋量,使得我們也不需要去特意處理頁面銷毀后的請求取消問題烹吵。優(yōu)化性能的同時碉熄,又大大減少了我們的代碼量。是一種優(yōu)秀的異步代碼處理模式肋拔。