今天會來研究看看在這個專案裡使用 Coroutines 時可能遇到的情境,因為可能涉及到的很多內容及架構都是在後面才會提到,所以現在先專注在 Coroutines 即可。
現在有一個情境
一個 filter View,上面有三個按鈕,點擊可分別顯示所有工作事項、未完成的工作事項及已完成的工作事項。
這是十分常見的情境,由於現在還沒有考慮打 API 的狀況,我們就只需要考慮 從 Local DB 讀取資料 的情境就好。
我們在專案裡使用的 local db 是 Room ,但是因為才剛開始所以就先做一個測試用的假資料好了
enum class TaskType {
All, Activated, Completed
}
class TasksRepository {
suspend fun getTasksFromRoom(): List<String> {
return fakeGetTasks()
}
suspend fun getActivatedTasksFromRoom(): List<String> {
return fakeGetActivatedTasks()
}
suspend fun getCompletedTasksFromRoom(): List<String> {
return fakeGetCompletedTasks()
}
......
private suspend fun fakeGetTasks(): List<String> {
// 假裝是讀取資料所消耗的 IO 時間
delay(1500L)
return mutableListOf("Task1", "Tasks2", "Tasks3")
}
private suspend fun fakeGetActivatedTasks(): List<String> {
// 假裝是讀取資料所消耗的 IO 時間
delay(1000L)
return mutableListOf("Task1")
}
private suspend fun fakeGetCompletedTasks(): List<String> {
// 假裝是讀取資料所消耗的 IO 時間
delay(500L)
return mutableListOf("Tasks2", "Tasks3")
}
}
接著是在 ViewModel 獲得資料:
class TaskViewModel(private val repository: TasksRepository) {
suspend fun getTasks(type: TaskType = TaskType.All): List<String> {
return withContext(Dispatchers.IO) {
when (type) {
TaskType.Activated -> repository.getActivatedTasksFromRoom()
TaskType.Completed -> repository.getCompletedTasksFromRoom()
else -> repository.getTasksFromRoom()
}
}
}
}
最後在 Activity 調用:
class MainActivity : AppCompatActivity() {
private val repository by lazy { TasksRepository() }
private val viewModel by lazy { TaskViewModel(repository) }
val scope = MainScope()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnAll.setOnClickListener {
getTasksList(TaskType.All)
}
btnActivated.setOnClickListener {
getTasksList(TaskType.Activated)
}
btnCompleted.setOnClickListener {
getTasksList(TaskType.Completed)
}
}
private fun getTasksList(type: TaskType) {
scope.launch {
val tasks = viewModel.getTasks(type)
Toast.makeText(
this@MainActivity,
tasks.toString(),
Toast.LENGTH_SHORT
).show()
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
如此就完成了一個簡單的 Coroutines 小程式!
接下來我們再來探討一些比較複雜的狀況。
可能會有一種狀況:
User 不小心手滑多點了好幾下,會發生什麼事?
以 Case 1 的情況來說,就會重複請求資料!
這個情境乍看之下似乎不影響效果,但是如果有一種情況:
User 反覆在 All, Activated, Completed 按鈕之間瘋狂點擊
此時 QA 就回報,有時候顯示的資料與點擊的類型不一樣。
這是為什麼呢?理論上顯示的資料應該是用戶最後一次選擇的類型啊?
因為用戶瘋狂點擊按鈕時,同時開啟了多個協程讀取資料,由於協程不保證消費順序,因此可能以任意一個順序結束!
這是一個典型的 concurrency bug,一不小心就會掉入陷阱了。
既然原因是同時讀取資料,所以一次只讓讀取資料做一次的話就能解決這個問題了。
比較簡單的方式就是在讀取資料的時候 禁用按鈕 ,這非常容易做到,而且大部分情況下都可以接受這種做法。
其實有很多方式可以達到這個目的,我想討論的是如果不想要禁用按鈕,是否有其他辦法?這邊我提出兩種做法:
- 取消之前的請求,只接受後來的請求
- 使用之前的請求,拒絕後來的請求
在不細究 spec 的情況下兩者都是可行的,下面再來說說具體的作法:
如果用戶點擊新的按鈕,其實就是在告訴程式他們只想要最新的結果,也意味著可以終止前面的請求,我們可以這樣修改程式:
class TasksRepository {
var controllCoroutines = ControlledCoroutinesExample<List<String>>()
suspend fun getTasksFromRoom(): List<String> {
return controllCoroutines.cancelPreviousThenRun {
fakeGetTasks()
}
}
suspend fun getActivatedTasksFromRoom(): List<String> {
return controllCoroutines.cancelPreviousThenRun {
fakeGetActivatedTasks()
}
}
suspend fun getCompletedTasksFromRoom(): List<String> {
return controllCoroutines.cancelPreviousThenRun {
fakeGetCompletedTasks()
}
}
......
}
class ControlledCoroutinesExample<T> {
private var cachedTasks: Deferred<T>? = null
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
// 如果當前有正在執行的 cachedTasks,可以直接取消並改成執行最新的請求
cachedTasks?.cancelAndJoin()
return coroutineScope {
// 建立一個 async 並且 suspend
val newTask = async {
block()
}
// newTask 執行完畢時清除舊的 cachedTasks 任務
newTask.invokeOnCompletion {
cachedTasks = null
}
// newTask 完成後交給 cachedTasks
cachedTasks = newTask
// newTask 恢復狀態並開始執行
newTask.await()
}
}
}
其實就是以最開始的請求為準,這種做法比較適用在需要打 API 的時候,因為這樣可以節省一些網路資源,一樣我們來看看不打算採用禁用按鈕的做法時的方案:
class TasksRepository {
var controllCoroutines = ControlledCoroutinesExample<List<String>>()
suspend fun getTasksFromRoom(): List<String> {
return controllCoroutines.joinPreviousOrRun {
fakeGetTasks()
}
}
suspend fun getActivatedTasksFromRoom(): List<String> {
return controllCoroutines.joinPreviousOrRun {
fakeGetActivatedTasks()
}
}
suspend fun getCompletedTasksFromRoom(): List<String> {
return controllCoroutines.joinPreviousOrRun {
fakeGetCompletedTasks()
}
}
......
}
class ControlledCoroutinesExample<T> {
private var cachedTasks: Deferred<T>? = null
......
suspend fun joinPreviousOrRun(block: suspend () -> T): T {
// 如果當前有正在執行的 cachedTasks ,直接返回
activeTask?.let {
return it.await()
}
// 否則建立一個新的 async
return coroutineScope {
val newTask = async {
block()
}
newTask.invokeOnCompletion {
activeTask = null
}
activeTask = newTask
newTask.await()
}
}
}
這是一個 pseudo code ,只是想表達大致上的意思。
兩個做法的大致思路都是準備一個 cachedTasks
保存正在執行的工作,每次有新的請求時再來檢查。
Kotlin Coroutines 的介紹大概到這裡,其實還有很多無法帶到,我也只是介紹我在這個專案會使用到的東西,有興趣可以自己到官網看看。