Kotlin 基礎 | 爲何要這樣用協程?

看上去連續的一段代碼,執行起來卻走走停停,不一樣的子代碼段還可能執行在不一樣的線程上。協程就是用這種方式來實現異步android

異步

最開始,在沒有協程和各類異步工具時,只能這樣實現異步:web

// 構建主線程 Handler
val mainHandler = Handler(Looper.getMainLooper()) // 啓動新線程 val handlerThread = HandlerThread("user") handlerThread.start() // 構建新線程 Handler val handler = Handler(handlerThread.looper) // 把"拉取用戶信息"經過 Handler 發送到新線程執行 handler.post(object : Runnable {  override fun run() {  val user = fetchUser() //執行在新線程  // 把用戶信息經過 Handler 發送到主線程執行  mainHandler.post(object : Runnable {  override fun run() {  tvName.text = user.name //執行在主線程  }  })  } }) Log.v("test", "after post") // 會馬上打印(主線程不被阻塞)  fun fetchUser(): User {  Thread.sleep(1000) //模擬網絡請求  return User("taylor", 20, 0) } 複製代碼

這段代碼從網絡獲取用戶數據並顯示在控件上。數據庫

代碼的不一樣部分會執行在不一樣線程上:拉取用戶信息的耗時操做會在handlerThread線程中執行,而界面顯示邏輯在主線程。編程

這兩個線程間步調不一樣(異步),即互不等待對方執行完畢再執行本身的後續代碼(不阻塞)。它們經過進程間互發消息實現了異步。緩存

這樣寫的缺點是在同一層次中暴露太多細節!構建並啓動線程的細節、線程切換的細節、線程通訊的細節、網絡請求的細節。這些本該被隱藏的細節通通在業務層被鋪開。網絡

若改用RxJava就能夠屏蔽這些細節:多線程

userApi.fetchUser()
 .observeOn(AndroidSchedulers.mainThread())  .subscribeOn(Schedulers.io())  .subscribe(  { user ->  tvName.text = user.name  },  { error ->  Log.e("error","no user")  }  ) 複製代碼

RxJava 幫咱們切換到 IO 線程作網路請求,再切換回主線程展現界面。線程間通訊方式也從發消息變爲回調。代碼可讀性瞬間提高。架構

若需求改爲「獲取用戶信息後再根據用戶 ID 獲取其消費流水」,就得使用flatMap()將兩個請求串聯起來,此時不可避免地出現嵌套回調,代碼可讀性降低。併發

協程

若用協程,就能夠像寫同步代碼同樣寫異步代碼:異步

launch()

class TestActivity : AppCompatActivity() {
 override fun onCreate(savedInstanceState: Bundle?) {  super.onCreate(savedInstanceState)  setContentView(R.layout.text)   // 啓動頂層協程  GlobalScope.launch {  // 拉取用戶信息(掛起點)  val user = fetchUser()  // 拉取用戶帳單(掛起點)  val bills = fetchBill(user)  // 展現用戶帳單(UI操做)  showBill(bills)  }  Log.v("test", "after launch") // 馬上打印(主線程不被阻塞)  }   // 掛起方法  suspend fun fetchUser(): User {  delay(1000) // 模擬網絡請求  return User("taylor", 20, 0)  }   // 掛起方法  suspend fun fetchBill(user: User): List<Bill> {  delay(2000) // 模擬網絡請求  return mutableListOf(Bill("Tmall", 10), Bill("JD", 20))  } } 複製代碼

GlobalScope.launch()啓動了一個協程,主線程不會被阻塞(「after launch」會當即打印)。其中GlobalScopeCoroutineScope的一個實現。

CoroutineScope稱爲 協程領域,它是協程中最頂層的概念,全部的協程都直接或間接的依附於它 ,它用於描述協程的歸屬,定義以下:

// 協程領域
public interface CoroutineScope {  // 協程上下文  public val coroutineContext: CoroutineContext }  // 協程領域的靜態實現:頂層領域 public object GlobalScope : CoroutineScope {  // 空上下文  override val coroutineContext: CoroutineContext  get() = EmptyCoroutineContext } 複製代碼

協程領域 持有 CoroutineContext

CoroutineContext稱爲 協程上下文 ,它是「和協程執行相關的一系列元素的集合」,其中最重要的兩個是CoroutineDispatcher(描述協程代碼分發到哪一個線程執行)和Job(表明着協程自己)。

協程領域 有一個靜態實現GlobalScope,它用於建立頂層協程,即其生命週期同 App 一致。

協程的啓動方法被定義成CoroutineScope的擴展方法:

/**  * 啓動一個新協程,它的執行不會阻塞當前線程。默認狀況下,協程會被當即執行。  *  * @param context 在原有協程上下文基礎上附加的上下文  * @param start 協程啓動選項  * @param block 協程體,它會在協程上下文指定的線程中執行  **/ public fun CoroutineScope.launch(  context: CoroutineContext = EmptyCoroutineContext,// 默認爲空上下文  start: CoroutineStart = CoroutineStart.DEFAULT, // 默認啓動選項  block: suspend CoroutineScope.() -> Unit ): Job {  ... } 複製代碼

啓動協程時,必須提供參數block(協程體),即在協程中執行的代碼段。

Demo 在協程體中前後調用了兩個帶suspend的方法。

suspend方法稱爲 掛起方法。掛起的對象是其所在協程,即協程體的執行被暫停。被暫停的執行點稱爲 掛起點,執行掛起點以後的代碼稱爲 恢復

Demo 中有兩個掛起點:在用戶信息不返回以前,拉取帳單就不會被執行,在拉取帳單不返回以前,就不會把數據填充到列表中。

withContext()

執行下 Demo,看看效果:

android.view.ViewRootImpl$CalledFromWrongThreadException: 
 Only the original thread that created a view hierarchy can touch its views. 複製代碼

崩潰緣由是「展現帳單邏輯被執行在非UI線程」。GlobalScope.launch()將協程體調度到新線程執行,執行完耗時操做後,UI 展現時還須要調度回主線程:

GlobalScope.launch {
 val user = fetchUser()  val bills = fetchBill(user)  withContext(Dispatchers.Main) {  showBill(bills)  } } 複製代碼

withContext()是一個頂層掛起方法:

public suspend fun <T> withContext(  context: CoroutineContext,// 指定 block 被調度到哪一個線程執行  block: suspend CoroutineScope.() -> // 被調度執行的代碼段 ): T = suspendCoroutineUninterceptedOrReturn sc@ { uCont ->  ... } 複製代碼

它用於在協程中切換上下文(切換協程體執行的線程)。withContext()會掛起當前協程(它是一個掛起點),直到block執行完,協程纔會在本身原先的線程上恢復執行後續代碼。

async()

上面的例子是兩個串行請求,若是換成「等待兩個並行請求的結果」,能夠這樣寫:

GlobalScope.launch {
 val a = async { fetchA() }  val b = async { fetchB() }  a.await() // 掛起點  b.await() // 掛起點  Log.v("test","result=${a+b}")// 當兩個網絡請求都返回後纔會打印 }  suspend fun fetchA(): String {  ...// 網絡請求 }  suspend fun fetchB(): String {  ...// 網絡請求 } 複製代碼

在頂層協程中又調用async()啓動了2個子協程:

// 啓動協程,並返回協程執行結果
public fun <T> CoroutineScope.async(  context: CoroutineContext = EmptyCoroutineContext,  start: CoroutineStart = CoroutineStart.DEFAULT,  block: suspend CoroutineScope.() -> T ): Deferred<T> {  ... } 複製代碼

aync()也是CoroutineScope的擴展方法,和launch()惟一的不一樣是它引入了泛型,用於描述協程體執行的結果,並將其包裝成一個Deferred做爲返回值:

public interface Deferred<out T> : Job {
 // 掛起方法: 等待值的計算,但不會阻塞當前線程,計算完成後恢復當前協程執行  public suspend fun await(): T } 複製代碼

調用async()啓動子協程不會掛起外層協程,而是當即返回一個Deferred對象,直到調用Deferred.await(),協程的執行纔會被掛起。當協程在多個Deferred對象上被掛起時,只有當它們都恢復後,協程才繼續執行。這樣就實現了「等待多個並行的異步結果」。

coroutineScope()

若是多個並行的異步操做沒有返回值,如何等待它們都執行完畢?

GlobalScope.launch {
 // 掛起外層協程  coroutineScope { // 和外層協程體執行在同一個線程中  launch { updateCache() }  launch { insertDb() }  }  Log.v("test", "after coroutineScope()") // 被coroutineScope阻塞,等其執行完畢纔打印 }  suspend fun updateCache() {  ...// 更新內存緩存 }  suspend fun insertDb() {  ...// 插入數據庫 } 複製代碼

coroutineScope()建立了一個協程並阻塞當前協程,在其中調用launch()建立了2個子協程,只有當2個子協程都執行完畢後纔會打印 log。

coroutineScope()聲明以下:

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R {
 ... } 複製代碼

coroutineScope()有以下特色:

  1. 返回計算結果
  2. 阻塞當前協程
  3. 執行在和父協程相同的線程中
  4. 它等待全部子協程執行完畢

coroutineScope() 是 withContext() 的一種狀況,當給withContext()傳入當前協程上下文時,它和 coroutineScope() 如出一轍。它也會返回計算結果,也會阻塞當前線程,也會等待全部子協程執行完畢。

換句話說,coroutineScope() 是 不進行線程調度的 withContext()

GlobalScope的罪過

雖然上面這些代碼都是正確的,但它們不應出如今真實項目中。

由於它們都使用GlobalScope.launch()來啓動協程。這樣作會讓管理協程變得困難:

GlobalScope.launch()構建的協程是獨立的,它不隸屬於任何CoroutineScope。並且是靜態的,因此生命週期和 APP 一致。

一旦被建立則要等到 APP 關閉時纔會釋放線程資源。若在短生命週期的業務界面使用,需純手動管理生命週期,不能享受structured-concurrency

structured-concurrency 是一種併發編程範式,它是管理多線程併發執行生命週期的一種方式,它要求「執行單元」的孵化要有結構性,即新建的「執行單元」必須依附於一個更大的「執行單元」。這樣就便於管理(同步)全部執行單元的生命週期。

Kotlin 協程實現了該範式,具體表現爲:

  1. 新建協程必須隸屬於一個 CoroutineScope,新協程的 Job也就成爲 CoroutineScope的子 Job
  2. Job被結束時,全部子 Job立馬被結束(即便還未執行完)。
  3. Job會等待全部子協程都結束了才結束本身。
  4. Job拋出異常時,會通知父 Job,父 Job將其餘全部子 Job都結束。

先看一個手動管理協程生命週期的例子:若是一個 Activity 全部的協程都經過GlobalScope.launch()啓動,那在 Activity 退出時,該如何取消這些協程?

辦法仍是有的,只要在每次啓動協程時保存其Job的引用,而後在Activity.onDestroy()時遍歷全部Job並逐個取消:

class TestActivity : AppCompatActivity(){
 // 持有該界面中全部啓動協程的引用  private var jobs = mutableListOf<Job>()  override fun onCreate(savedInstanceState: Bundle?) {  super.onCreate(savedInstanceState)  // 啓動頂層協程並保存其引用  GlobalScope.launch {  ...  }.also { jobs.add(it) }  }   override fun onMessageReceive(msg: Message) {  // 啓動頂層協程並保存其引用  GlobalScope.launch {  ...  }.also { jobs.add(it) }  }   override fun onDestroy() {  super.onDestroy()  // 將全部協程都取消以釋放資源  jobs.forEach { it.cancel() }  } } 複製代碼

每個GlobalScope.launch()都是獨立的,且它不隸屬於任何一個CoroutineScope。爲了管理它們就必須持有每一個啓動協程的引用,並逐個手動釋放資源。

若使用structured-concurrency範式就可讓管理變簡單:

class TestActivity : AppCompatActivity(), CoroutineScope by MainScope() {{
 override fun onCreate(savedInstanceState: Bundle?) {  super.onCreate(savedInstanceState)  launch { ... }  }   override fun onMessageReceive(msg: Message) {  launch { ... }  }   override fun onDestroy() {  super.onDestroy()  cancel()  } } 複製代碼

Activity 實現了CoroutineScope接口並將其委託給MainScope():

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
複製代碼

MainScope()是一個頂層方法,它新建了一個ContextScope實例,併爲其指定上下文,其中一個是Dispatchers.Main,它是系統預約義的主線程調度器,這意味着,MainScope中啓動的協程體都會被調度到主線程執行。

launch()cancel()都是 CoroutineScope的擴展方法,而 Activity 實現了該接口並委託給MainScope。因此 Demo 中經過launch()啓動的協程都隸屬於MainScopeonDestroy中調用的cancel()取消了MainScopeJob,它的全部子Job也一同被取消。

Activity 被建立的時CoroutineScope同時被實例化,在 Activity 被銷燬時,全部的協程也被銷燬,實現了協程和生命週期對象綁定。 不再用擔憂後臺任務完成後更新界面時,因 Activity 已銷燬報空指針了。

協程能夠和任何具備生命週期的對象綁定,好比 View,只有當 View 依附於界面時其對應的協程任務纔有意義,因此當它與界面解綁時應該取消協程:

// 爲 Job 擴展方法
fun Job.autoDispose(view: View) {  // 判斷傳入 View 是否依附於界面  val isAttached = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && view.isAttachedToWindow || view.windowToken != null  // 若是 View 已脫離界面則直接取消對應協程  if (!isAttached) {  cancel()  }   // 構建 View 和界面綁定關係監聽器  val listener = object : View.OnAttachStateChangeListener {  // 當 View 和界面解綁時,取消協程  override fun onViewDetachedFromWindow(v: View?) {  cancel()  v?.removeOnAttachStateChangeListener(this)  }   override fun onViewAttachedToWindow(v: View?) = Unit  }   // 爲 View 設置監聽器  view.addOnAttachStateChangeListener(listener)  // 當協程執行完畢時,移除監聽器  invokeOnCompletion {  view.removeOnAttachStateChangeListener(listener)  } } 複製代碼

而後就能夠像這樣使用:

launch {
 // 加載圖片 }.autoDispose(imageView) 複製代碼

GlobalScope沒法和任何生命週期對象綁定(除 App 生命週期),除了這個缺點外,還有一個:

coroutineScope {
 GlobalScope.launch {  queryA()  }  GlobalScope.launch {  queryB()  } } 複製代碼

queryB()拋出異常時,queryA()不會被取消。由於它們是經過GlobalScope.launch()啓動的,它們是獨立的,不隸屬於外層coroutineScope

但若換成下面這種方式,queryA()就會被取消:

coroutineScope {
 launch {  queryA()  }  launch {  queryB()  } } 複製代碼

由於這裏的launch()都是外層coroutineScope對象上的調用,因此它們都隸屬於該對象。當子協程拋出異常時,父協程會受到通知並取消掉全部其餘子協程。

viewModelScope

上一節的代碼雖然是正確的,但依然不應出如今真實項目中。由於 Activity 屬於View層 ,只應該包含和 UI 相關的代碼,啓動協程執行異步操做這樣的細節不應在這層暴露。(架構相關的詳細討論能夠點擊我是怎麼把業務代碼越寫越複雜的 | MVP - MVVM - Clean Architecture

真實項目中,協程更有可能在ViewModel層出現。只要引入 ViewModel Kotlin 版本的包就能夠輕鬆地在ViewModel訪問到CoroutineScope

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-alpha03"
複製代碼
class MainViewModel : ViewModel() {
 fun fetchBean() {  // 系統爲 ViewModel 預約義的 CoroutineScope  viewModelScope.launch {  ...  }  } } 複製代碼

viewModelScope被定義成ViewModel的擴展屬性,這種擴展手法頗爲巧妙,限於篇幅緣由,準備單獨寫一篇詳細分析。

疑惑

這篇僅粗略地介紹了協程相關的概念、協程的使用方式,及注意事項。依然留了不少疑惑,好比:

  1. 爲啥要設定 CoroutineScope這個角色?啓動協程爲啥要定義成 CoroutineScope的擴展函數?
  2. CoroutineContext的內部結構是怎麼樣的?爲啥要這樣設計?
  3. 協程是如何將協程體中的代碼調度到不一樣線程執行的?
  4. 協程是如何在掛起點恢復執行的?

下一篇將更加深刻閱讀源碼,解答這些疑問。

推薦閱讀

  1. Kotlin基礎 | 白話文轉文言文般的Kotlin常識

  2. Kotlin基礎 | 望文生義的Kotlin集合操做

  3. Kotlin實戰 | 用實戰代碼更深刻地理解預約義擴展函數

  4. Kotlin實戰 | 使用DSL構建結構化API去掉冗餘的接口方法

  5. Kotlin基礎 | 抽象屬性的應用場景

  6. Kotlin進階 | 動畫代碼太醜,用DSL動畫庫拯救,像說話同樣寫代碼喲!

  7. Kotlin基礎 | 用約定簡化相親

  8. Kotlin基礎 | 2 = 12 ?泛型、類委託、重載運算符綜合應用

  9. Kotlin實戰 | 語法糖,總有一顆甜到你(持續更新)

  10. Kotlin 實戰 | 幹掉 findViewById 和 Activity 中的業務邏輯

  11. Kotlin基礎 | 爲何要這樣用協程?

本文使用 mdnice 排版

相關文章
相關標籤/搜索