iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 17
0
自我挑戰組

Android Architecture 及 Unit Test系列 第 17

[Day 17] Domain layer:UseCase

  • 分享至 

  • xImage
  •  

今天我想來完成主要的 UseCase ,並順便跟 ViewModel 串接起來。

UseCase

自從 Bob 大叔提出 Clean Architecture 後到了最近, Android 也為了可以更加容易處理 Domain layer 的部分而開始向大家提出 UseCase 的做法,與其類似的名稱還有 Interactor 等等,但都是描述同一件事,即旨在:

處理實體之間的資料流,並指揮實體達到使用案例 (UseCase) 的目標或是功能。

他可以當成是平常所稱呼的 『邏輯層』,但我們會把邏輯在拆分成一個個的使用案例 (UseCase) 並加以實現。

下面我們先來完成一個較簡單的 UseCase。

第一個主畫面:工作項目

Todo App 的主頁大致上有許多比較重要的功能,但是如果依照與實體資料交流的角度來看,我們可以歸納出以下四個使用案例:

  • GetTasksUseCase:獲得工作項目的資料,可以依據條件獲得不同類型的工作項目
  • ClearCompletedTasksUseCase:清除已完成的工作事項
  • CompleteTaskUseCase:將某個工作事項的狀態切換成已完成
  • ActivateTaskUseCase:將某個工作事項的狀態切換成未完成(可執行)

先從較簡單的 CompleteTaskUseCase 開始吧,那就直接上程式碼:

class CompleteTaskUseCase @Inject constructor(
    private val tasksRepository: TasksRepository
) {
    suspend operator fun invoke(task: Task) {
        tasksRepository.completeTask(task) 
    }
}

class TasksViewModel @Inject constructor(
    private val completeTaskUseCase: CompleteTaskUseCase
) : ViewModel() {

    ......
    
    fun completeTask(task: Task, completed: Boolean) = viewModelScope.launch {
        if (completed) {
            completeTaskUseCase(task)
            showSnackbarMessage("Task Marked Complete")
        }
    }
}

完成!這時可能有些人認為這樣寫有什麼優點,直接在 completeTask() 方法內呼叫 tasksRepository.completeTask(task) ,不是更加簡單嗎?

其實沒有錯,但現在讓我們先來看看這樣寫之後會有什麼影響。

從 ViewModel 的角度來看,如果想要完成 “將某個工作事項的狀態切換成已完成” 這個功能的話,他只需要呼叫 completeTask() 並把實際功能交給 UseCase 完成,自己則只需要專注在切換畫面的狀態等工作就好。這麼寫個人覺得有幾個好處:

  1. ViewModel 跟資料層解耦,現在 ViewModel 不會跟我們的資料層 (Repository) 有任何關連了。
  2. 更好的職責分離,我們建立這幾個 UseCase 後,其實已經側面闡述了這個 ViewModel 所擁有的職責。
  3. 更容易的測試,如今我們可以針對 UseCase 做單元測試,他不會被其他部件及邏輯影響。

透過 UseCase ,我們可以將資料流的處理,或是某些邏輯的實現從 ViewModel 裡拆出去。

接下來看看 UseCase 處理較複雜情況時的寫法,但在這之前,我想先來為 UseCase 設計一個架構,讓他可以一次只針對一件事情處理資料。

abstract class UseCase<in P, R>(private val ioDispatcher: CoroutineDispatcher) {

    suspend operator fun invoke(parameters: P): Result<R> {
        return withContext(ioDispatcher) {
            return@withContext executeNow(parameters)
        }
    }

    /**
     * 同步執行程式,回傳 [Result]
     */
    suspend fun executeNow(parameters: P): Result<R> {
        return try {
            execute(parameters)
        } catch (e: Exception) {
            Result.Error(e)
        }
    }

    /**
     * override 此方法並把邏輯寫在這裡
     */
    @Throws(RuntimeException::class)
    protected abstract suspend fun execute(parameters: P): Result<R>
}

suspend operator fun <R> UseCase<Unit, R>.invoke(): Result<R> = this(Unit)

再來改造一下 CompleteTaskUseCase

class CompleteTaskUseCase @Inject constructor(
    private val tasksRepository: ITasksRepository,
    ioDispatcher: CoroutineDispatcher
) : UseCase<CompleteTaskUseCase.Params, Unit>(ioDispatcher) {
    override suspend fun execute(parameters: CompleteTaskUseCase.Params): Result<Unit> {
        wrapEspressoIdlingResource {
            return Success(tasksRepository.completeTask(parameters.task))
        }
    }

    data class Params(val task: Task)
}

......

fun completeTask(task: Task, completed: Boolean) = viewModelScope.launch {
    if (completed) {
        completeTaskUseCase(CompleteTaskUseCase.Params(task))
        showSnackbarMessage("Task Marked Complete")
    }
}

我設計了一個簡單的父類 UseCase 來限制其子類,透過限制回傳的資料型態來達到限制其處理的邏輯範圍。

有了 UseCase 後,就可以來看看比較複雜的 GetTasksUseCase 了。

GetTasksUseCase

我們來完成以下功能:

獲得工作項目的資料,可以依據條件獲得不同類型的工作項目
可以根據 filter ALL_TASKSACTIVE_TASKSCOMPLETED_TASKS ,獲得全部、未完成、已完成的工作事項

先看看不使用 UseCase 的寫法:

class TasksViewModel @Inject constructor(
    private val tasksRepository: TasksRepository
) : ViewModel() {

    ......
    
    fun loadTasks(forceUpdate: Boolean) {
    
        // 這是一個 LiveData ,描述 loading 狀態
        _dataLoading.value = true

        viewModelScope.launch {
                val tasksResult = tasksRepository.getTasks(forceUpdate)

                if (tasksResult is Success) {
                    val tasks = tasksResult.data

                    val tasksToShow = ArrayList<Task>()
                    // 將取得 Task 的方式用三種 Type 約束
                    for (task in tasks) {
                        when (_currentFiltering) {
                            TasksFilterType.ALL_TASKS -> tasksToShow.add(task)
                            TasksFilterType.ACTIVE_TASKS -> if (task.isActive) {
                                tasksToShow.add(task)
                            }
                            TasksFilterType.COMPLETED_TASKS -> if (task.isCompleted) {
                                tasksToShow.add(task)
                            }
                        }
                    }
                    // 資料獲取成功,將顯示 error 的 LiveData 狀態切成 false
                    isDataLoadingError.value = false
                    // 將保存資料的 LiveData 發射出去
                    _items.value = ArrayList(tasksToShow)
                } else {
                    isDataLoadingError.value = false
                    _items.value = emptyList()
                    // 顯示讀去資料錯誤的 Snackbar
                    showSnackbarMessage(R.string.loading_tasks_error)
                }

                _dataLoading.value = false
            }
    }
}

事實上 ViewModel 並不需要知道 Task 資料是如何獲取並加以處理的,對他而言他只需要 loadData 並顯示相應的畫面跟資料就好了,所以可以對這裡再加以改造:

class GetTasksUseCase @Inject constructor(
    private val tasksRepository: ITasksRepository,
    ioDispatcher: CoroutineDispatcher
) : UseCase<GetTasksUseCase.Params, List<Task>>(ioDispatcher) {
    override suspend fun execute(parameters: GetTasksUseCase.Params): Result<List<Task>> {
        val tasksResult = tasksRepository.getTasks(parameters.forceUpdate)
            
        // Filter tasks
        if (tasksResult is Success && parameters.currentFiltering != ALL_TASKS) {
            val tasks = tasksResult.data

            val tasksToShow = mutableListOf<Task>()
                
            for (task in tasks) {
                when (parameters.currentFiltering) {
                    ACTIVE_TASKS -> if (task.isActive) {
                        tasksToShow.add(task)
                    }
                    COMPLETED_TASKS -> if (task.isCompleted) {
                        tasksToShow.add(task)
                    }
                    else -> NotImplementedError()
                }
            }
            return Success(tasksToShow)
        }
        return tasksResult
    }

    data class Params(
        val forceUpdate: Boolean,
        val currentFiltering: TasksFilterType = ALL_TASKS
    )
}

class TasksViewModel @Inject constructor(
    private val getTasksUseCase: GetTasksUseCase,
    private val completeTaskUseCase: CompleteTaskUseCase
) : ViewModel() {

    ......
    
    fun loadTasks(forceUpdate: Boolean) {
        _dataLoading.value = true

        viewModelScope.launch {
            val tasksResult =
                getTasksUseCase(GetTasksUseCase.Params(forceUpdate, _currentFiltering))
            if (tasksResult is Success) {
                isDataLoadingError.value = false
                Timber.e("loadTasks: ${tasksResult.data}")
                _items.value = tasksResult.data
            } else {
                isDataLoadingError.value = false
                _items.value = emptyList()
                showSnackbarMessage(R.string.loading_tasks_error)
            }

            _dataLoading.value = false
        }
    }
}

現在 ViewModel 如一開始想要的只需要委託 UseCase 處理資料,自己再顯示對應畫面跟資料就好了,UseCase 也幫助我們順利讓 ViewModel 與資料層解偶,並讓實際的邏輯更加單純。

今天大致示範了如何整理 Domain layer 並利用 UseCase 加以實現,明天就可以利用這些資料完成 Data Bindind 。


上一篇
[Day 16] Dagger 2:Part 4 Refactor
下一篇
[Day 18] DataBinding
系列文
Android Architecture 及 Unit Test30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言