筆記: Coroutine簡介(一) 簡單介紹與線程切換

Lin Li
10 min readSep 18, 2023

--

一、簡介

在計算機科學中,有兩種多任務方法用於管理多個並發進程。其中一種是由操作系統控制進程之間的切換。另一種稱為協作式多任務(cooperative multitasking),在這種情況下,進程自行控制其行為。協程(Coroutines)是創建用於協作式多任務的子程序的軟體組件。

協程最早在1958年被用於組合語言。像Python、JavaScript和C#這樣的現代編程語言的開發者多年來一直在使用協程。在Kotlin中,一個協程可以被描述為一系列妥善管理的“子任務”。在某種程度上,協程可以被視為一個輕量級的線程。

你可以在單一線程中執行多個協程。協程也可以在線程之間切換。這意味著一個協程可以從一個線程暫停,然後在另一個線程中恢復。

作為Android開發者,隨著Kotlin 1.3的發布,我們現在擁有一個完全穩定的協程API。所有用RxJava、AsyncTask或其他方法(如executors、HandlerThreads和IntentServices)完成的痛苦的多線程任務,都可以用協程輕鬆且高效地完成。

協程API還允許我們以順序方式編寫異步代碼。因此,它避免了與回調相關的不必要的模板代碼,使我們的代碼更易讀和可維護。

二、在Android中為何需要異步編程( asynchronous programming)?

大多數的智慧型手機刷新頻率至少為60Hz。換句話說,在1秒或1000毫秒內,應用會刷新60次。將1000毫秒除以60。是16.666…

因此,如果我們的應用在這款手機上運行,它必須使用主線程大約每16毫秒重繪一次屏幕。但市場上已經有更好的智能手機,具有更高的刷新率,如90Hz、120Hz。

如果手機的刷新率為90Hz,讓我們將1000毫秒除以90,我們的應用只有11毫秒的時間在Android主線程中執行任務。如果刷新率是120Hz,1000除以120,我們的應用只有8毫秒。

默認情況下,Android主線程有一套固定的職責。它必須始終解析XML,填充視圖組件,並在每次刷新時反復繪製它們。主線程還必須處理用戶交互,如點擊事件。

因此,如果我們在主線程中添加更多任務,如果其執行時間超過兩次刷新之間的這個極短的時間間隔,應用將顯示性能錯誤。螢幕可能會凍結。用戶將在視圖組件中看到不可預測的行為。甚至會導致應用程序無響應(ANR)錯誤。

由於技術進步,這些刷新率每年都在變得越來越高。因此,作為Android開發者,我們應始終嘗試在單獨的線程中異步執行長時間運行的任務。

為了實現這一目標,Kotlin協程是一個非常好的選項。

三、第一個協程

不使用協程

以下程式碼有一個名為DownloadUserData的按鈕,當按下時我們印2萬次log。有另一個按鈕,每按下一次就會在螢幕顯示按下的次數。

我們試著在按下DownloadUserData後,嘗試按下Count按鈕。這時候會發現螢幕被"凍結"了。原因是因為在主線程運行耗時的任務。

class MainActivity : AppCompatActivity() {
private var count = 0
private lateinit var btnDownloadUserData: Button
private lateinit var btnCount: Button
private lateinit var tvCount: TextView


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnDownloadUserData = findViewById(R.id.btnDownloadUserData)
btnCount = findViewById(R.id.btnCount)
tvCount = findViewById(R.id.tvCount)

btnCount.setOnClickListener {
tvCount.text = count++.toString()
}
btnDownloadUserData.setOnClickListener {
downloadUserData()

}
}

private fun downloadUserData() {
for (i in 1..200000) {
Log.i("MyTag", "Downloading user $i in ${Thread.currentThread().name}")
}
}
}

我們可以寫一個簡單的協程,在IO線程中處理耗時的任務。將以上的程式碼中按下Count按鈕的程式碼改成:

        btnDownloadUserData.setOnClickListener {
CoroutineScope(Dispatchers.IO).launch {
downloadUserData()
}
}

四、協程簡介

這裡我只做簡單的介紹,詳細一點的介紹可以參考Andy大大的文章

協程主要由三部分組成:作用域(scope)、調度器(dispatcher)和掛起函數(suspend function)。

  • CoroutineScope:在 Kotlin 協程中,所有的協程都必須在某個作用域(scope)內啟動。通過這個作用域,我們可以方便地管理協程,例如追蹤、取消,或是處理協程中拋出的異常。除了CoroutineScope,還有GlobalScope,但在Android開發中很少使用。 作用域可以接受一個 CoroutineContext 作為參數,你也可以使用 CoroutineContext + Job 的方式來定義作用域。
  • 調度器(Dispatchers):調度器決定協程在哪個線程上運行。在Android開發中,我們主要會使用Dispatchers.MainDispatchers.IO,其他的有Dispatchers.DefaultDispatchers.Unconfined

Main線程又被稱為UI線程,建議是都在Main啟動協程,然後需要時再切換到後台(IO)線程。Dispatcher.Unconfined 是與 GlobalScope 一起使用的調度器。如果我們使用 Dispatchers.Unconfined,協程將在當前線程上運行,但如果它被暫停和恢復,它將在掛起函數正在運行的任何線程上運行。所以不建議在 Android 開發中使用這個調度器。

  • 掛起函數(suspend function):這是協程中的特殊函數,可以暫停協程的執行,而不會阻塞當前線程。當掛起函數完成其工作後,它可以恢復協程的執行。這是協程強大功能的核心所在。

協程有幾種構建器,如launchasyncproducerunBlocking。在 Android 開發中,Launch 和Async 構建器是我們最常用的協程構建器。

  • launch: 這是最簡單的協程構建器,主要用於啟動一個不需要返回結果的協程。它會返回一個 Job 實例,允許您管理協程的生命週期。
  • async: 適用於需要返回值的情況。它返回一個 Deferred 對象,這實際上是 Job 接口的一個擴展。不僅如此,async也能用來並行地處理協程。
  • produce 和 runBlocking: produce 主要用於創建一個可以發出多個元素的協程,返回一個 ReceiveChannel 的實例。runBlocking 在Android開發中則用於測試和一些特殊情況,它會阻塞當前線程直到協程完成。

除了這四種調度器外,協程 API 還方便我們將 executors 轉換為調度器,

以及創建我們自己的自定義調度器。像 Room 和 Retrofit 這樣的庫的創建者,已經使用自己的自定義調度器在單獨的後台線程中執行操作。

當我們使用 Retrofit 和 Room 時,我們可以輕鬆地從主調度器中使用它們,無需編寫程式碼來更改線程。

五、線程的切換

延續之前的程式碼例子,我們這次不印log,而是將結果顯示在螢幕上。但我若是直接這樣寫:

會報CalledFromWrongThreadException。

原因很簡單:UI的改變只能在主線程。因此如果要讓text顯示結果,我們必須將線程切換回主線程,如以下修改:

但這樣改會發現編譯器出現錯誤,原因在於withContext是一個掛起函數,掛起函數只能在掛起函數或協程調用,不能在一般函式中調用,因此需要將這個函式加上suspend修飾詞。

完整的程式碼如下:

class MainActivity : AppCompatActivity() {
private var count = 0
private lateinit var btnDownloadUserData : Button
private lateinit var btnCount : Button
private lateinit var tvCount : TextView
private lateinit var tvUserMessage : TextView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnDownloadUserData = findViewById(R.id.btnDownloadUserData)
btnCount = findViewById(R.id.btnCount)
tvCount = findViewById(R.id.tvCount)
tvUserMessage = findViewById(R.id.tvUserMessage)

btnCount.setOnClickListener {
tvCount.text = count++.toString()
}
btnDownloadUserData.setOnClickListener {
CoroutineScope(Dispatchers.IO).launch {
downloadUserData()
}
}
}

suspend fun downloadUserData() {
for (i in 1..200000) {
withContext(Dispatchers.Main){
tvUserMessage.text = "Downloading user $i in ${Thread.currentThread().name}"

}
}
}
}

下一篇文章會講到Async&Await、結構化併發與非結構化併發,如果可以的話會整理比較常用的scope,例如lifecycle scope。

--

--