建構資料層

1. 事前準備

本程式碼研究室會說明資料層的相關知識,以及如何將其融入整體應用程式架構中。

資料層是位於網域層和 UI 層下方的底層。

圖 1. 這張圖表顯示資料層是網域層和 UI 層的依賴層。

您將建構工作管理應用程式的資料層。您會建立本機資料庫和網路服務的資料來源,以及用於公開、更新和同步處理資料的存放區。

必要條件

課程內容

在本程式碼研究室中,您可以瞭解如何執行以下操作:

  • 以有效且可擴充的資料管理方式建立存放區、資料來源和資料模型。
  • 向其他架構層公開資料。
  • 處理非同步資料更新作業,以及複雜或長時間執行的工作。
  • 在多個資料來源間同步處理資料。
  • 建立測試,以便驗證存放區和資料來源的行為。

建構項目

您將建構一個工作管理應用程式,可用於新增工作,並將工作標示為已完成。

請放心,您不必從頭開始編寫應用程式,而是會在已具備 UI 層的應用程式上作業。此應用程式中的 UI 層包含使用 ViewModel 實作的畫面,以及畫面層級狀態容器。

在本程式碼研究室中,您還會新增資料層,然後將其連接至現有的 UI 層,使應用程式能完全正常運作。

工作清單畫面。

工作詳細資料畫面。

圖 2. 工作清單畫面的螢幕截圖。

圖 3. 工作詳細資料畫面的螢幕截圖。

2. 做好準備

  1. 下載程式碼:

https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip

  1. 或者,您也可以複製 GitHub 存放區的程式碼:
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. 開啟 Android Studio 並載入 architecture-samples 專案。

資料夾結構

  • 在「Android」檢視畫面中開啟「Project Explorer」。

java/com.example.android.architecture.blueprints.todoapp 資料夾下有多個資料夾。

Android Studio 中「Android」檢視畫面的「Project Explorer」視窗。

圖 4. 這張螢幕截圖顯示 Android Studio 中「Android」檢視畫面的「Project Explorer」視窗。

  • <root> 包含應用程式層級類別,例如導覽、主要活動和應用程式類別。
  • addedittask 包含 UI 功能,可讓使用者新增及編輯工作。
  • data 包含資料層。您主要是在這個資料夾中作業。
  • di 包含用於插入依附元件的 Hilt 模組。
  • tasks 包含 UI 功能,可讓使用者查看及更新工作清單。
  • util 包含公用程式類別。

此外還有兩個測試資料夾,資料夾名稱結尾會以帶有括號的文字標示。

  • androidTest 採用與 <root> 相同的結構,但包含「檢測設備測試」
  • test 採用與 <root> 相同的結構,但包含「本機測試」

執行專案

  • 按一下頂端工具列中的綠色播放圖示。

Android Studio 執行設定、目標裝置,以及執行按鈕。

圖 5. 顯示 Android Studio 執行設定、目標裝置和執行按鈕的螢幕截圖。

您應該會看到「工作清單」畫面,包含不會消失的載入旋轉圖示。

應用程式處於起始狀態,含有不會消失的載入旋轉圖示。

圖 6. 應用程式處於起始狀態的螢幕截圖,含有不會消失的載入旋轉圖示。

完成本程式碼研究室後,此畫面會逐項列出工作。

您可以找出 data-codelab-final 分支,查看本程式碼研究室中的最終程式碼。

git checkout data-codelab-final

請記得先儲存變更!

3. 瞭解資料層

在本程式碼研究室中,您會建構應用程式的資料層。

顧名思義,資料層就是管理應用程式資料的架構層,當中也包含商業邏輯,這是可決定如何建立、儲存及變更應用程式資料的實際商業規則。透過這種「關注點分離」原則,資料層可重複使用,因而能夠顯示在多個畫面上、在應用程式的不同部分之間共用資訊,並在 UI 以外重現商業邏輯,以便進行單元測試。

構成資料層的重要元件類型為資料模型、資料來源和存放區。

資料層中的元件類型,包括資料模型、資料來源和存放區之間的依附元件。

圖 7. 這張圖表顯示資料層中的元件類型,包括資料模型、資料來源和存放區之間的依附元件。

資料模型

應用程式資料通常會以資料模型的形式呈現。這是資料在記憶體內的表示法。

由於此應用程式是工作管理應用程式,您需要適用於「工作」的資料模型。以下是 Task 類別:

data class Task(
    val id: String
    val title: String = "",
    val description: String = "",
    val isCompleted: Boolean = false,
) { ... }

這個模型的重點在於「不可變更」。其他層無法變更工作屬性;如要對工作進行變更,則必須使用資料層。

內部和外部資料模型

Task 是「外部」資料模型的示例。它會由資料層對外公開,可供其他層存取。稍後,您將定義只能在資料層內使用的「內部」資料模型。

建議您為每種商業模式的表現法定義資料模型。這個應用程式中有三種資料模型。

模型名稱

是由資料層使用的外部還是內部模型?

代表

相關聯的資料來源

Task

外部

可在應用程式中任意位置使用的工作,這類工作只會儲存在記憶體中,或是在儲存應用程式狀態時保存

不適用

LocalTask

內部

儲存在本機資料庫中的工作

TaskDao

NetworkTask

內部

已從網路伺服器擷取的工作

NetworkTaskDataSource

資料來源

資料來源是一種類別,負責讀取資料並將其寫入「單一來源」(例如資料庫或網路服務)。

這個應用程式有兩個資料來源:

  • TaskDao 是本機資料來源,會讀取資料並將其寫入資料庫。
  • NetworkTaskDataSource 是網路資料來源,會將讀取資料並將其寫入網路伺服器。

存放區

存放區應管理單一資料模型。在這個應用程式中,您會建立管理 Task 模型的存放區。存放區可用來:

  • 公開 Task 模型清單。
  • 提供建立及更新 Task 模型的方法。
  • 執行商業邏輯,例如為每個工作建立專屬 ID。
  • 將資料來源中的內部資料模型合併或對應至 Task 模型。
  • 同步處理資料來源。

準備編寫程式碼!

  • 切換至「Android」檢視畫面,然後展開 com.example.android.architecture.blueprints.todoapp.data 套件:

顯示資料夾和檔案的「Project Explorer」視窗。

圖 8. 顯示資料夾和檔案的「Project Explorer」視窗。

Task 類別已事先建立完畢,以利應用程式的其餘部分進行編譯。如此一來,您就能將實作項目新增至提供的空白 .kt 檔案,從頭開始建立大部分的資料層類別。

4. 在本機儲存資料

在這個步驟中,您將為 Room 資料庫建立「資料來源」和「資料模型」,此資料庫會將工作儲存在裝置本機中。

工作存放區、模型、資料來源與資料庫之間的關係。

圖 9. 這張圖表顯示工作存放區、模型、資料來源與資料庫之間的關係。

建立資料模型

如要將資料儲存在 Room 資料庫,您需建立資料庫實體。

  • data/source/local 中開啟檔案 LocalTask.kt,然後加入下列程式碼:
@Entity(
    tableName = "task"
)
data class LocalTask(
    @PrimaryKey val id: String,
    var title: String,
    var description: String,
    var isCompleted: Boolean,
)

LocalTask 類別代表 Room 資料庫中,task 資料表內儲存的資料。此類別與 Room 緊密耦合,不應該用於 DataStore 等其他資料來源。

類別名稱中的 Local 前置字串表示這項資料儲存在本機中。該字串也用於區分此類別與 Task 資料模型,後者是由應用程式中的其他層公開。換句話說,LocalTask 屬於資料層「內部」,而 Task 屬於資料層「外部」

建立資料來源

有了資料模型後,接下來請建立資料來源來建立、讀取、更新及刪除 (CRUD) LocalTask 模型。由於您使用 Room,因此可以使用資料存取物件 (@Dao 註解) 做為本機資料來源。

  • 在名為 TaskDao.kt 的檔案中建立新的 Kotlin「介面」
@Dao
interface TaskDao {

    @Query("SELECT * FROM task")
    fun observeAll(): Flow<List<LocalTask>>

    @Upsert
    suspend fun upsert(task: LocalTask)

    @Upsert
    suspend fun upsertAll(tasks: List<LocalTask>)

    @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
    suspend fun updateCompleted(taskId: String, completed: Boolean)

    @Query("DELETE FROM task")
    suspend fun deleteAll()
}

用於「讀取資料」的方法開頭會加上 observe 前置字串。這些是會傳回 Flow 的非暫停函式。每次基礎資料變更時,系統都會將一個新項目傳送至資料流。不論是 Room 程式庫,還是其他資料儲存空間程式庫,都有這項實用功能,可讓您監聽資料變更,不必輪詢資料庫來尋找新資料。

用於「寫入資料」的方法為暫停函式,因為它們會執行 I/O 作業。

更新資料庫結構定義

接下來,您需要更新資料庫,以便儲存 LocalTask 模型。

  1. 開啟 ToDoDatabase.kt,將 BlankEntity 變更為 LocalTask
  2. 移除 BlankEntity 和任何多餘的 import 陳述式。
  3. 新增方法,傳回名為 taskDao 的 DAO。

更新後的類別應如下所示:

@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {

    abstract fun taskDao(): TaskDao
}

更新 Hilt 設定

此專案會使用 Hilt 插入依附元件。Hilt 需要瞭解如何建立 TaskDao,以便將其插入會用到它的類別。

  • 開啟 di/DataModules.kt,並將以下方法新增至 DatabaseModule
    @Provides
    fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()

至此,您已完成讀取工作並將其寫入本機資料庫的所有必要操作。

5. 測試本機資料來源

在上個步驟中,您編寫了許多程式碼,但該如何確認程式碼是否正常運作?由於在 TaskDao 中進行 SQL 查詢很容易出錯,因此請建立測試,驗證 TaskDao 的行為是否符合預期。

測試並非應用程式的組成部分,因此應放在其他資料夾中。我們有兩個測試資料夾,在套件名稱結尾由帶有括號的文字表示:

Project Explorer 中的「test」和「androidTest」資料夾。

圖 10. 顯示 Project Explorer 中「test」和「androidTest」資料夾的螢幕截圖。

  • androidTest 包含在 Android 模擬器或裝置上執行的測試,稱為「檢測設備測試
  • test 包含在主體機器上執行的測試,也稱為「本機測試

TaskDao 需要用到 Room 資料庫 (只能在 Android 裝置上建立),因此如要對其進行測試,您需建立檢測設備測試。

建立測試類別

  • 展開 androidTest 資料夾,然後開啟 TaskDaoTest.kt,並在其中建立名為 TaskDaoTest 的空白類別。
class TaskDaoTest {

}

新增測試資料庫

  • 新增 ToDoDatabase,並在每次測試前將其初始化。
    private lateinit var database: ToDoDatabase

    @Before
    fun initDb() {
        database = Room.inMemoryDatabaseBuilder(
            getApplicationContext(),
            ToDoDatabase::class.java
        ).allowMainThreadQueries().build()
    }

這項操作會在每次測試前建立記憶體內資料庫。記憶體內資料庫的速度比磁碟式資料庫還要快,所以很適合用於自動化測試,因為這類測試的資料保留時間不需超過測試時間。

新增測試

新增測試,驗證是否可插入 LocalTask,以及能否使用 TaskDao 讀取相同的 LocalTask

本程式碼研究室中的所有測試均符合「Given-When-Then結構:

Given

空白資料庫

When

已插入工作,且您開始觀察工作資料流

Then

工作資料流中的第一個項目與插入的工作相符

  1. 請先建立一個失敗的測試。這麼做會驗證測試是否確實執行,以及測試的物件及其依附元件是否正確。
@Test
fun insertTaskAndGetTasks() = runTest {

    val task = LocalTask(
        title = "title",
        description = "description",
        id = "id",
        isCompleted = false,
    )
    database.taskDao().upsert(task)

    val tasks = database.taskDao().observeAll().first()

    assertEquals(0, tasks.size)
}
  1. 在測試旁的空白邊中,按一下「Play」按鈕即可執行測試。

在程式碼編輯器空白邊中,測試的「Play」按鈕。

圖 11. 這張螢幕截圖顯示在程式碼編輯器的空白邊中,有與測試對應的「Play」按鈕。

測試結果視窗中應該會顯示測試失敗,以及「expected:<0> but was:<1>」訊息。這在預料之內,因為資料庫內的工作數量是一,而不是零。

失敗的測試。

圖 12. 顯示測試失敗的螢幕截圖。

  1. 移除現有的 assertEquals 陳述式。
  2. 新增程式碼,測試該資料來源是否只提供一個工作,且該工作與插入的工作相同。

assertEquals 的參數順序應一律為「預期值」優先,「實際值」在後**。**

assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
  1. 再次執行測試。測試結果視窗中應該會顯示測試已通過。

通過的測試。

圖 13. 通過測試的螢幕截圖。

6. 建立網路資料來源

可將工作儲存在裝置本機固然很好,但如果也想在網路服務中儲存及載入這些工作,該怎麼辦?或許您的 Android 應用程式只是使用者將工作新增至待辦事項清單的途徑之一,網站或電腦應用程式也可用來管理工作。又或許,您只想提供線上資料備份,讓使用者在變更裝置後也能還原應用程式資料。

在上述情況下,您通常會有網路式服務,所有用戶端 (包括您的 Android 應用程式) 都能利用該服務載入及儲存資料。

在下一個步驟中,您會建立資料來源,用來與網路服務通訊。在本程式碼研究室中,這是一項模擬服務,雖然不會連線至即時網路服務,但可讓您瞭解如何在實際應用程式中實作這項功能。

關於網路服務

在此範例中,網路 API 非常簡單。它只會執行兩項作業:

  • 儲存所有工作,覆寫任何先前寫入的資料。
  • 載入所有工作,列出目前儲存在網路服務中的所有工作。

建立網路資料模型

從網路 API 取得資料時,資料的表示方式往往與本機的資料不同。工作的網路表示法可能包含額外欄位,也可能使用不同的類型或欄位名稱來表示相同的值。

為了因應這些差異,請建立網路專屬的資料模型。

  • 開啟在 data/source/network 中找到的檔案 NetworkTask.kt,然後新增下列程式碼來代表欄位:
data class NetworkTask(
    val id: String,
    val title: String,
    val shortDescription: String,
    val priority: Int? = null,
    val status: TaskStatus = TaskStatus.ACTIVE
) {
    enum class TaskStatus {
        ACTIVE,
        COMPLETE
    }
}

以下是 LocalTaskNetworkTask 的差異:

  • 工作說明的名稱是 shortDescription,而不是 description
  • isCompleted 欄位會以 status 列舉的方式表示,包含兩個可能的值:ACTIVECOMPLETE
  • 後者包含額外的 priority 欄位,以整數表示。

建立網路資料來源

  • 開啟 TaskNetworkDataSource.kt,然後建立名為 TaskNetworkDataSource 的類別,其中含有下列內容:
class TaskNetworkDataSource @Inject constructor() {

    // A mutex is used to ensure that reads and writes are thread-safe.
    private val accessMutex = Mutex()
    private var tasks = listOf(
        NetworkTask(
            id = "PISA",
            title = "Build tower in Pisa",
            shortDescription = "Ground looks good, no foundation work required."
        ),
        NetworkTask(
            id = "TACOMA",
            title = "Finish bridge in Tacoma",
            shortDescription = "Found awesome girders at half the cost!"
        )
    )

    suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        return tasks
    }

    suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        tasks = newTasks
    }
}

private const val SERVICE_LATENCY_IN_MILLIS = 2000L

這個物件會模擬與伺服器的互動,包括每次在系統呼叫 loadTaskssaveTasks 時延遲兩秒。這可以代表網路或伺服器的回應延遲時間。

這個物件也包含一些測試資料,您稍後可以用這些資料,驗證工作是否能從網路成功載入。

如果實際伺服器 API 使用 HTTP,建議您使用 KtorRetrofit 等程式庫來建構網路資料來源。

7. 建立工作存放區

在這一步中,我們要把兩個資料來源整合起來。

DefaultTaskRepository 的依附元件。

圖 14. 顯示 DefaultTaskRepository 依附元件的圖表。

這兩個資料來源分別用於本機資料 (TaskDao) 和網路資料 (TaskNetworkDataSource)。每種來源都允許讀取和寫入資料,且擁有自己的工作表示法 (分別為 LocalTaskNetworkTask)。

現在,我們要建立使用這些資料來源的存放區,並提供一個 API,讓其他架構層可以存取這個工作資料。

公開資料

  1. data 套件中開啟 DefaultTaskRepository.kt,然後建立名為 DefaultTaskRepository 的類別,該類別會將 TaskDaoTaskNetworkDataSource 做為依附元件。
class DefaultTaskRepository @Inject constructor(
    private val localDataSource: TaskDao,
    private val networkDataSource: TaskNetworkDataSource,
) {

}

請使用資料流公開資料。這麼做可讓呼叫端瞭解資料隨時間變化的情形。

  1. 新增名為 observeAll 的方法,該方法會使用 Flow 傳回 Task 模型的資料流。
fun observeAll() : Flow<List<Task>> {
    // TODO add code to retrieve Tasks
}

存放區應公開來自單一可靠資料來源的資料。也就是說,資料應只來自一個資料來源,可以是記憶體內快取、遠端伺服器,或如本例中使用的本機資料庫。

使用 TaskDao.observeAll 即可存取本機資料庫中的工作,輕鬆傳回資料流。不過,這是 LocalTask 模型的資料流,其中 LocalTask 是不應向其他架構層公開的內部模型。

您需將 LocalTask 轉換為 Task。後者為外部模型,會形成資料層 API 的一環。

將內部模型對應至外部模型

如要執行這項轉換作業,您需將 LocalTask 中的欄位對應至 Task 的欄位。

  1. LocalTask 中為這項操作建立擴充功能函式
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)

// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }

現在,不管您何時需要將 LocalTask 轉換為 Task,只要呼叫 toExternal 即可。

  1. observeAll 中使用新建立的 toExternal 函式:
fun observeAll(): Flow<List<Task>> {
    return localDataSource.observeAll().map { tasks ->
        tasks.toExternal()
    }
}

每當本機資料庫中的工作資料發生變更,新的 LocalTask 模型清單都會傳送至資料流。接著,每個 LocalTask 都會對應到 Task

太好了!現在,其他層可使用 observeAll 從本機資料庫取得所有 Task 模型,並在這些 Task 模型變更時收到通知。

更新資料

如果無法建立及更新工作,待辦事項應用程式就會大打折扣。因此您現在要新增方法來加入這些功能。

用於建立、更新或刪除資料的方法為一次性作業,應使用 suspend 函式來實作。

  1. 新增名為 create 的方法,該方法使用 titledescription 做為參數,並傳回新建立工作的 ID。
suspend fun create(title: String, description: String): String {
}

請注意,為禁止其他層建立 Task,資料層 API 僅提供接受個別參數 (而非 Task) 的 create 方法。這個方法會封裝以下項目:

  • 建立專屬工作 ID 的商業邏輯。
  • 工作初次建立後儲存的位置。
  1. 新增方法來建立工作 ID
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. 使用新增的 createTaskId 方法建立工作 ID
suspend fun create(title: String, description: String): String {
    val taskId = createTaskId()
}

不要封鎖主執行緒

等一下!如果建立工作 ID 的運算成本高昂,該怎麼辦?或許這項操作會使用密碼學來建立 ID 的雜湊鍵,因此需要幾秒鐘才能完成。如果在主執行緒上呼叫,可能就會導致 UI 卡頓。

資料層會負責「確保長時間執行或複雜的工作不會封鎖主執行緒」

如要修正這個問題,請指定用於執行這些指令的協同程式調度器。

  1. 首先,請將 CoroutineDispatcher 新增為 DefaultTaskRepository 的依附元件。使用先前建立的 @DefaultDispatcher 限定詞 (在 di/CoroutinesModule.kt 中定義),指示 Hilt 使用 Dispatchers.Default 插入這個依附元件。之所以指定 Default 調度器,是因為它已針對大量耗用 CPU 的工作完成最佳化調整。如想進一步瞭解協同程式調度器,請參閱這篇文章
class DefaultTaskRepository @Inject constructor(
   private val localDataSource: TaskDao,
   private val networkDataSource: TaskNetworkDataSource,
   @DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
  1. 現在,請將對 UUID.randomUUID().toString() 的呼叫放入 withContext 區塊中。
val taskId = withContext(dispatcher) {
    createTaskId()
}

如要進一步瞭解資料層中的執行緒,請按這裡

建立及儲存工作

  1. 現在您已擁有工作 ID,可以利用它和提供的參數建立新的 Task
suspend fun create(title: String, description: String): String {
    val taskId = withContext(dispatcher) {
        createTaskId()
    }
    val task = Task(
        title = title,
        description = description,
        id = taskId,
    )
}

在將工作插入本機資料來源之前,您需要將工作對應至 LocalTask

  1. 將下列擴充功能函式新增至 LocalTask 的結尾,這是與您先前建立的 LocalTask.toExternal 反向對應的函式。
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. 請在 create 中使用此函式,將工作插入本機資料來源,然後傳回 taskId
suspend fun create(title: String, description: String): Task {
    ...
    localDataSource.upsert(task.toLocal())
    return taskId
}

完成工作

  • 建立其他方法 complete,將 Task 標示為完成。
suspend fun complete(taskId: String) {
    localDataSource.updateCompleted(taskId, true)
}

您現在可以透過一些實用方法來建立及完成工作。

同步處理資料

在這個應用程式中,網路資料來源是做為線上備份之用。每次資料寫入本機時,系統都會一併更新備份;而當使用者每次要求重新整理時,則會從網路載入資料。

以下圖表是每個作業類型的行為概述。

作業類型

存放區方法

步驟

資料遷移

載入

observeAll

從本機資料庫載入資料

從本機資料來源到工作存放區的資料流程。圖 15. 這張圖表顯示從本機資料來源到工作存放區的資料流程。

儲存

createcomplete

1. 將資料寫入本機 database2。請將所有資料複製到網路,並覆寫全部內容

從工作存放區到本機資料來源,再到網路資料來源的資料流程。圖 16. 這張圖表顯示從工作存放區到本機資料來源,再到網路資料來源的資料流程。

重新整理

refresh

1. 從 network2 載入資料,接著複製到本機資料庫,覆寫所有資料

從網路資料來源到本機資料來源,再到工作存放區的資料流程。圖 17. 這張圖表顯示從網路資料來源到本機資料來源,再到工作存放區的資料流程。

儲存及重新整理網路資料

您的存放區已從本機資料來源載入工作了。如要完成同步處理演算法,您需建立方法,以便儲存及重新整理網路資料來源的資料。

  1. 首先,在 NetworkTask.kt 中建立 LocalTaskNetworkTask 之間的正向與反向對應函式。將函式放在 LocalTask.kt 中也有相同效果。
fun NetworkTask.toLocal() = LocalTask(
    id = id,
    title = title,
    description = shortDescription,
    isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)

fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)

fun LocalTask.toNetwork() = NetworkTask(
    id = id,
    title = title,
    shortDescription = description,
    status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)

fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)

在這裡,您可以看到每個資料來源使用獨立模型的優勢:將一個資料類型對應至另一個類型時,可以封裝為獨立的函式。

  1. DefaultTaskRepository 結尾新增 refresh 方法。
suspend fun refresh() {
    val networkTasks = networkDataSource.loadTasks()
    localDataSource.deleteAll()
    val localTasks = withContext(dispatcher) {
        networkTasks.toLocal()
    }
    localDataSource.upsertAll(networkTasks.toLocal())
}

這會將所有「本機」工作替換為來自「網路」的工作。withContext 適用於執行大量的 toLocal 作業,因為工作的數量不明,且每個對應作業可能會產生高昂的運算成本。

  1. saveTasksToNetwork 方法新增至 DefaultTaskRepository 的結尾。
private suspend fun saveTasksToNetwork() {
    val localTasks = localDataSource.observeAll().first()
    val networkTasks = withContext(dispatcher) {
        localTasks.toNetwork()
    }
    networkDataSource.saveTasks(networkTasks)
}

這會將所有「網路」工作替換為來自「本機」資料來源的工作。

  1. 現在更新現有的方法,以便更新工作 createcomplete,進而在本機資料變更時將資料儲存到網路上。
    suspend fun create(title: String, description: String): String {
        ...
        saveTasksToNetwork()
        return taskId
    }

     suspend fun complete(taskId: String) {
        localDataSource.updateCompleted(taskId, true)
        saveTasksToNetwork()
    }

避免呼叫端等候

如果執行這段程式碼,您會看到 saveTasksToNetwork 阻塞。這表示 createcomplete 的呼叫端不得不等待資料儲存至網路,才能確認作業已完成。這在模擬的網路資料來源中,只會延遲兩秒,但在實際應用程式中,所需時間可能較長,如果沒有網路連線甚至會無法運作。

這會造成不必要的限制,而且可能導致使用者體驗不佳,沒有人想要等待工作建立,特別是在忙碌的時候!

更理想的做法是使用不同的協同程式範圍,藉此將資料儲存至網路。這麼做可讓作業在背景完成,不必讓呼叫端等待結果。

  1. 將協同程式範圍做為參數新增至 DefaultTaskRepository
class DefaultTaskRepository @Inject constructor(
    // ...other parameters...
    @ApplicationScope private val scope: CoroutineScope,
)

Hilt 修飾符 @ApplicationScope (在 di/CoroutinesModule.kt 中定義) 的用途,是插入遵循應用程式生命週期的範圍。

  1. 使用 scope.launch 納入 saveTasksToNetwork 內的程式碼。
    private fun saveTasksToNetwork() {
        scope.launch {
            val localTasks = localDataSource.observeAll().first()
            val networkTasks = withContext(dispatcher) {
                localTasks.toNetwork()
            }
            networkDataSource.saveTasks(networkTasks)
        }
    }

現在 saveTasksToNetwork 會立即回傳,且工作會在背景中儲存至網路。

8. 測試工作存放區

太好了,資料層中新增了好多功能,現在可以為 DefaultTaskRepository 建立單元測試,確認一切運作正常了!

您需使用本機和網路資料來源的測試依附元件,將受測試的主體 (DefaultTaskRepository) 例項化。首先需要建立這些依附元件。

  1. 在「Project Explorer」視窗中展開 (test) 資料夾,然後展開 source.local 資料夾並開啟 FakeTaskDao.kt.

「Project」資料夾結構中的 FakeTaskDao.kt 檔案。

圖 18. 這張螢幕截圖顯示「Project」資料夾結構中的 FakeTaskDao.kt

  1. 新增下列內容:
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {

    private val _tasks = initialTasks.toMutableList()
    private val tasksStream = MutableStateFlow(_tasks.toList())

    override fun observeAll(): Flow<List<LocalTask>> = tasksStream

    override suspend fun upsert(task: LocalTask) {
        _tasks.removeIf { it.id == task.id }
        _tasks.add(task)
        tasksStream.emit(_tasks)
    }

    override suspend fun upsertAll(tasks: List<LocalTask>) {
        val newTaskIds = tasks.map { it.id }
        _tasks.removeIf { newTaskIds.contains(it.id) }
        _tasks.addAll(tasks)
    }

    override suspend fun updateCompleted(taskId: String, completed: Boolean) {
        _tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
        tasksStream.emit(_tasks)
    }

    override suspend fun deleteAll() {
        _tasks.clear()
        tasksStream.emit(_tasks)
    }
}

在實際應用程式中,您還需建立假的依附元件來取代 TaskNetworkDataSource (讓假物件和實際物件實作一個通用介面),但在本程式碼研究室中,可以直接使用這項依附元件就好。

  1. DefaultTaskRepositoryTest 中新增以下內容。

一項規則,用於設定要在所有測試中使用的主要調度器。

部分測試資料。

本機和網路資料來源的測試依附元件。

要測試的主體:DefaultTaskRepository

class DefaultTaskRepositoryTest {

    private var testDispatcher = UnconfinedTestDispatcher()
    private var testScope = TestScope(testDispatcher)

    private val localTasks = listOf(
        LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
        LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
    )

    private val localDataSource = FakeTaskDao(localTasks)
    private val networkDataSource = TaskNetworkDataSource()
    private val taskRepository = DefaultTaskRepository(
        localDataSource = localDataSource,
        networkDataSource = networkDataSource,
        dispatcher = testDispatcher,
        scope = testScope
    )
}

太好了!您現在可以開始編寫單元測試了。應測試的主要面向有三:讀取、寫入與資料同步。

測試已公開的資料

以下說明如何測試存放區是否正確公開資料。此測試會採用「Given-When-Then」結構。例如:

Given

本機資料來源中有一些現有工作

When

工作資料流是使用 observeAll 從存放區取得

Then

工作資料流中的第一個項目,與本機資料來源中工作的外部表示法相符

  • 建立名為 observeAll_exposesLocalData 的測試,並在其中加入以下內容:
@Test
fun observeAll_exposesLocalData() = runTest {
    val tasks = taskRepository.observeAll().first()
    assertEquals(localTasks.toExternal(), tasks)
}

使用 first 函式從工作資料流取得第一個項目。

測試資料更新

接下來請編寫測試,驗證工作已建立並儲存至網路資料來源。

Given

空白資料庫

When

透過呼叫 create 建立工作

Then

這項工作會同時在本機資料來源和網路資料來源中建立

  1. 建立名為 onTaskCreation_localAndNetworkAreUpdated 的測試。
@Test
    fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
        val newTaskId = taskRepository.create(
            localTasks[0].title,
            localTasks[0].description
        )

        val localTasks = localDataSource.observeAll().first()
        assertEquals(true, localTasks.map { it.id }.contains(newTaskId))

        val networkTasks = networkDataSource.loadTasks()
        assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
    }

接著,請確認工作完成後,是否已正確寫入本機資料來源,並儲存至網路資料來源。

Given

本機資料來源含有工作

When

透過呼叫 complete 來完成工作

Then

本機資料和網路資料也會更新

  1. 建立名為 onTaskCompletion_localAndNetworkAreUpdated 的測試。
    @Test
    fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
        taskRepository.complete("1")

        val localTasks = localDataSource.observeAll().first()
        val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
        assertEquals(true, isLocalTaskComplete)

        val networkTasks = networkDataSource.loadTasks()
        val isNetworkTaskComplete =
            networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
        assertEquals(true, isNetworkTaskComplete)
    }

測試資料重新整理作業

最後,測試重新整理作業是否成功。

Given

網路資料來源包含資料

When

已呼叫 refresh

Then

本機資料會與網路資料相同

  • 建立名為 onRefresh_localIsEqualToNetwork 的測試
@Test
    fun onRefresh_localIsEqualToNetwork() = runTest {
        val networkTasks = listOf(
            NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
            NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
        )
        networkDataSource.saveTasks(networkTasks)

        taskRepository.refresh()

        assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
    }

大功告成!執行測試,這些測試應該都會通過。

9. 更新 UI 層

現在您已瞭解資料層能順利運作,接著要將其連接至 UI 層。

為工作清單畫面更新檢視畫面模型

先從 TasksViewModel 開始。這是用來在應用程式中顯示第一個畫面的檢視模型,也就是目前所有進行中工作的清單。

  1. 開啟這個類別,並將 DefaultTaskRepository 新增為建構函式參數。
@HiltViewModel
class TasksViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
  1. 使用存放區將 tasksStream 變數初始化。
private val tasksStream = taskRepository.observeAll()

檢視模型現在可以存取存放區提供的所有工作,且每當有資料變更,檢視模型就會收到新的工作清單,這只需要一行程式碼就能做到!

  1. 剩下的工作是將使用者動作連結至存放區中的相應方法,請找出 complete 方法,並將其更新為以下內容:
fun complete(task: Task, completed: Boolean) {
    viewModelScope.launch {
        if (completed) {
            taskRepository.complete(task.id)
            showSnackbarMessage(R.string.task_marked_complete)
        } else {
            ...
       }
    }
}
  1. 使用 refresh 執行相同操作。
    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            taskRepository.refresh()
            _isLoading.value = false
        }
    }

為新增工作的畫面更新檢視畫面模型

  1. 開啟 AddEditTaskViewModel 並將 DefaultTaskRepository 新增為建構函式參數,與上一個步驟的做法相同。
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. create 方法更新為以下內容:
    private fun createNewTask() = viewModelScope.launch {
        taskRepository.create(uiState.value.title, uiState.value.description)
        _uiState.update {
            it.copy(isTaskSaved = true)
        }
    }

執行應用程式

  1. 終於來到期待已久的時刻,可以執行應用程式了!畫面上應該會顯示「You have no tasks!」

沒有工作時顯示的應用程式工作畫面。

圖 19. 應用程式的工作畫面螢幕截圖,顯示沒有工作時的畫面。

  1. 輕觸右上角的三點圖示,然後按一下「Refresh」

顯示動作選單的應用程式工作畫面。

圖 20. 顯示動作選單的應用程式工作畫面螢幕截圖。

您應該會看到載入旋轉圖示持續顯示兩秒,接著應該會顯示先前新增的測試工作。

應用程式的工作畫面,顯示兩項工作。

圖 21. 應用程式的工作畫面螢幕截圖,顯示兩項工作。

  1. 現在,輕觸右下角的加號來新增工作。請填寫標題和說明欄位。

應用程式的新增工作畫面。

圖 22. 應用程式新增工作畫面的螢幕截圖。

  1. 輕觸右下角的勾號按鈕,即可儲存工作。

新增工作後的應用程式工作畫面。

圖 23. 應用程式的工作畫面螢幕截圖,顯示已新增的工作。

  1. 勾選工作旁的核取方塊,即可將工作標示為完成。

應用程式的工作畫面,顯示已完成的工作。

圖 24. 應用程式的工作畫面螢幕截圖,顯示已完成的工作。

10. 恭喜!

您已成功為應用程式建立資料層。

資料層是應用程式架構的重要部分,也是其他層的建構基礎,若能妥善建立資料層,可讓應用程式依照使用者和您的業務需求進行調整。

您學到的內容

  • 資料層在 Android 應用程式架構中的角色。
  • 如何建立資料來源和模型。
  • 存放區的角色、存放區如何公開資料,以及存放區如何提供一次性的方法來更新資料。
  • 如何變更協同程式調度器,以及這項操作的重要性。
  • 使用多個資料來源同步處理資料。
  • 如何為常用資料層類別建立單元和檢測設備測試。

進階挑戰

如想嘗試其他挑戰,請實作下列功能:

  • 在工作標示為完成後重新啟動工作。
  • 輕觸工作以編輯標題和說明。

進階挑戰不提供任何操作說明,一切由您自行建構!如果遇到困難,請查看 main 分支版本的完整功能應用程式。

git checkout main

後續步驟

如要進一步瞭解資料層,請參閱官方說明文件以及離線優先應用程式指南。您也可以參閱「UI 層」和「網域層」,瞭解其他架構層。

如需較為複雜的實際範例,請參閱 Now in Android 應用程式