iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 5
0
自我挑戰組

Android Architecture 及 Unit Test系列 第 5

[Day 5] Kotlin Coroutines:Part 3 Real Work

  • 分享至 

  • xImage
  •  

今天會來研究看看在這個專案裡使用 Coroutines 時可能遇到的情境,因為可能涉及到的很多內容及架構都是在後面才會提到,所以現在先專注在 Coroutines 即可。

Case 1:讀取工作事項

現在有一個情境

一個 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 小程式!

接下來我們再來探討一些比較複雜的狀況。

Case 2:並發模式

可能會有一種狀況:

User 不小心手滑多點了好幾下,會發生什麼事?

Case 1 的情況來說,就會重複請求資料!

這個情境乍看之下似乎不影響效果,但是如果有一種情況:

User 反覆在 All, Activated, Completed 按鈕之間瘋狂點擊

此時 QA 就回報,有時候顯示的資料與點擊的類型不一樣。

這是為什麼呢?理論上顯示的資料應該是用戶最後一次選擇的類型啊?

因為用戶瘋狂點擊按鈕時,同時開啟了多個協程讀取資料,由於協程不保證消費順序,因此可能以任意一個順序結束!

這是一個典型的 concurrency bug,一不小心就會掉入陷阱了。

既然原因是同時讀取資料,所以一次只讓讀取資料做一次的話就能解決這個問題了。

比較簡單的方式就是在讀取資料的時候 禁用按鈕 ,這非常容易做到,而且大部分情況下都可以接受這種做法。

其實有很多方式可以達到這個目的,我想討論的是如果不想要禁用按鈕,是否有其他辦法?這邊我提出兩種做法:

  1. 取消之前的請求,只接受後來的請求
  2. 使用之前的請求,拒絕後來的請求

在不細究 spec 的情況下兩者都是可行的,下面再來說說具體的作法:

1. 取消之前的請求,只接受後來的請求

如果用戶點擊新的按鈕,其實就是在告訴程式他們只想要最新的結果,也意味著可以終止前面的請求,我們可以這樣修改程式:

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()
        }
    }
}

2. 使用之前的請求,拒絕後來的請求

其實就是以最開始的請求為準,這種做法比較適用在需要打 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 的介紹大概到這裡,其實還有很多無法帶到,我也只是介紹我在這個專案會使用到的東西,有興趣可以自己到官網看看。


上一篇
[Day 4] Kotlin Coroutines:Part 2 Scope、Suspend & Dispatcher
下一篇
[Day 6] Android Architecture Components:ViewModel
系列文
Android Architecture 及 Unit Test30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言