1. 事前準備
本程式碼研究室會說明資料層的相關知識,以及如何將其融入整體應用程式架構中。
圖 1. 這張圖表顯示資料層是網域層和 UI 層的依賴層。
您將建構工作管理應用程式的資料層。您會建立本機資料庫和網路服務的資料來源,以及用於公開、更新和同步處理資料的存放區。
必要條件
- 此為中階程式碼研究室,因此您應具備建構 Android 應用程式的基本知識 (請參閱下列新手學習資源)。
- 具備 Kotlin 的使用經驗,包括 lambda、協同程式和資料流。如要瞭解如何在 Android 應用程式中編寫 Kotlin 程式碼,請參閱「Android Kotlin 基本概念」課程的單元 1。
- 具備 Hilt (依附元件插入) 和 Room (資料庫儲存空間) 程式庫的基本知識。
- 具備使用 Jetpack Compose 的經驗。「Android 基本概念:使用 Compose」課程的單元 1 到單元 3 是學習 Compose 知識的絕佳管道。
- 選擇性:讀過架構總覽和「資料層」指南。
- 選擇性:完成 Room 程式碼研究室。
課程內容
在本程式碼研究室中,您可以瞭解如何執行以下操作:
- 以有效且可擴充的資料管理方式建立存放區、資料來源和資料模型。
- 向其他架構層公開資料。
- 處理非同步資料更新作業,以及複雜或長時間執行的工作。
- 在多個資料來源間同步處理資料。
- 建立測試,以便驗證存放區和資料來源的行為。
建構項目
您將建構一個工作管理應用程式,可用於新增工作,並將工作標示為已完成。
請放心,您不必從頭開始編寫應用程式,而是會在已具備 UI 層的應用程式上作業。此應用程式中的 UI 層包含使用 ViewModel 實作的畫面,以及畫面層級狀態容器。
在本程式碼研究室中,您還會新增資料層,然後將其連接至現有的 UI 層,使應用程式能完全正常運作。
圖 2. 工作清單畫面的螢幕截圖。 | 圖 3. 工作詳細資料畫面的螢幕截圖。 |
2. 做好準備
- 下載程式碼:
https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip
- 或者,您也可以複製 GitHub 存放區的程式碼:
git clone https://github.com/android/architecture-samples.git git checkout data-codelab-start
- 開啟 Android Studio 並載入
architecture-samples
專案。
資料夾結構
- 在「Android」檢視畫面中開啟「Project Explorer」。
java/com.example.android.architecture.blueprints.todoapp
資料夾下有多個資料夾。
圖 4. 這張螢幕截圖顯示 Android Studio 中「Android」檢視畫面的「Project Explorer」視窗。
<root>
包含應用程式層級類別,例如導覽、主要活動和應用程式類別。addedittask
包含 UI 功能,可讓使用者新增及編輯工作。data
包含資料層。您主要是在這個資料夾中作業。di
包含用於插入依附元件的 Hilt 模組。tasks
包含 UI 功能,可讓使用者查看及更新工作清單。util
包含公用程式類別。
此外還有兩個測試資料夾,資料夾名稱結尾會以帶有括號的文字標示。
androidTest
採用與<root>
相同的結構,但包含「檢測設備測試」。test
採用與<root>
相同的結構,但包含「本機測試」。
執行專案
- 按一下頂端工具列中的綠色播放圖示。
圖 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
是「外部」資料模型的示例。它會由資料層對外公開,可供其他層存取。稍後,您將定義只能在資料層內使用的「內部」資料模型。
建議您為每種商業模式的表現法定義資料模型。這個應用程式中有三種資料模型。
模型名稱 | 是由資料層使用的外部還是內部模型? | 代表 | 相關聯的資料來源 |
| 外部 | 可在應用程式中任意位置使用的工作,這類工作只會儲存在記憶體中,或是在儲存應用程式狀態時保存 | 不適用 |
| 內部 | 儲存在本機資料庫中的工作 |
|
| 內部 | 已從網路伺服器擷取的工作 |
|
資料來源
資料來源是一種類別,負責讀取資料並將其寫入「單一來源」(例如資料庫或網路服務)。
這個應用程式有兩個資料來源:
TaskDao
是本機資料來源,會讀取資料並將其寫入資料庫。NetworkTaskDataSource
是網路資料來源,會將讀取資料並將其寫入網路伺服器。
存放區
存放區應管理單一資料模型。在這個應用程式中,您會建立管理 Task
模型的存放區。存放區可用來:
- 公開
Task
模型清單。 - 提供建立及更新
Task
模型的方法。 - 執行商業邏輯,例如為每個工作建立專屬 ID。
- 將資料來源中的內部資料模型合併或對應至
Task
模型。 - 同步處理資料來源。
準備編寫程式碼!
- 切換至「Android」檢視畫面,然後展開
com.example.android.architecture.blueprints.todoapp.data
套件:
圖 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
模型。
- 開啟
ToDoDatabase.kt
,將BlankEntity
變更為LocalTask
。 - 移除
BlankEntity
和任何多餘的import
陳述式。 - 新增方法,傳回名為
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
的行為是否符合預期。
測試並非應用程式的組成部分,因此應放在其他資料夾中。我們有兩個測試資料夾,在套件名稱結尾由帶有括號的文字表示:
圖 10. 顯示 Project Explorer 中「test」和「androidTest」資料夾的螢幕截圖。
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 | 工作資料流中的第一個項目與插入的工作相符 |
- 請先建立一個失敗的測試。這麼做會驗證測試是否確實執行,以及測試的物件及其依附元件是否正確。
@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)
}
- 在測試旁的空白邊中,按一下「Play」按鈕即可執行測試。
圖 11. 這張螢幕截圖顯示在程式碼編輯器的空白邊中,有與測試對應的「Play」按鈕。
測試結果視窗中應該會顯示測試失敗,以及「expected:<0> but was:<1>
」訊息。這在預料之內,因為資料庫內的工作數量是一,而不是零。
圖 12. 顯示測試失敗的螢幕截圖。
- 移除現有的
assertEquals
陳述式。 - 新增程式碼,測試該資料來源是否只提供一個工作,且該工作與插入的工作相同。
assertEquals
的參數順序應一律為「預期值」優先,「實際值」在後**。**
assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
- 再次執行測試。測試結果視窗中應該會顯示測試已通過。
圖 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
}
}
以下是 LocalTask
和 NetworkTask
的差異:
- 工作說明的名稱是
shortDescription
,而不是description
。 isCompleted
欄位會以status
列舉的方式表示,包含兩個可能的值:ACTIVE
和COMPLETE
。- 後者包含額外的
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
這個物件會模擬與伺服器的互動,包括每次在系統呼叫 loadTasks
或 saveTasks
時延遲兩秒。這可以代表網路或伺服器的回應延遲時間。
這個物件也包含一些測試資料,您稍後可以用這些資料,驗證工作是否能從網路成功載入。
7. 建立工作存放區
在這一步中,我們要把兩個資料來源整合起來。
圖 14. 顯示 DefaultTaskRepository
依附元件的圖表。
這兩個資料來源分別用於本機資料 (TaskDao
) 和網路資料 (TaskNetworkDataSource
)。每種來源都允許讀取和寫入資料,且擁有自己的工作表示法 (分別為 LocalTask
和 NetworkTask
)。
現在,我們要建立使用這些資料來源的存放區,並提供一個 API,讓其他架構層可以存取這個工作資料。
公開資料
- 在
data
套件中開啟DefaultTaskRepository.kt
,然後建立名為DefaultTaskRepository
的類別,該類別會將TaskDao
和TaskNetworkDataSource
做為依附元件。
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
) {
}
請使用資料流公開資料。這麼做可讓呼叫端瞭解資料隨時間變化的情形。
- 新增名為
observeAll
的方法,該方法會使用Flow
傳回Task
模型的資料流。
fun observeAll() : Flow<List<Task>> {
// TODO add code to retrieve Tasks
}
存放區應公開來自單一可靠資料來源的資料。也就是說,資料應只來自一個資料來源,可以是記憶體內快取、遠端伺服器,或如本例中使用的本機資料庫。
使用 TaskDao.observeAll
即可存取本機資料庫中的工作,輕鬆傳回資料流。不過,這是 LocalTask
模型的資料流,其中 LocalTask
是不應向其他架構層公開的內部模型。
您需將 LocalTask
轉換為 Task
。後者為外部模型,會形成資料層 API 的一環。
將內部模型對應至外部模型
如要執行這項轉換作業,您需將 LocalTask
中的欄位對應至 Task
的欄位。
- 在
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
即可。
- 在
observeAll
中使用新建立的toExternal
函式:
fun observeAll(): Flow<List<Task>> {
return localDataSource.observeAll().map { tasks ->
tasks.toExternal()
}
}
每當本機資料庫中的工作資料發生變更,新的 LocalTask
模型清單都會傳送至資料流。接著,每個 LocalTask
都會對應到 Task
。
太好了!現在,其他層可使用 observeAll
從本機資料庫取得所有 Task
模型,並在這些 Task
模型變更時收到通知。
更新資料
如果無法建立及更新工作,待辦事項應用程式就會大打折扣。因此您現在要新增方法來加入這些功能。
用於建立、更新或刪除資料的方法為一次性作業,應使用 suspend
函式來實作。
- 新增名為
create
的方法,該方法使用title
和description
做為參數,並傳回新建立工作的 ID。
suspend fun create(title: String, description: String): String {
}
請注意,為禁止其他層建立 Task
,資料層 API 僅提供接受個別參數 (而非 Task
) 的 create
方法。這個方法會封裝以下項目:
- 建立專屬工作 ID 的商業邏輯。
- 工作初次建立後儲存的位置。
- 新增方法來建立工作 ID
// This method might be computationally expensive
private fun createTaskId() : String {
return UUID.randomUUID().toString()
}
- 使用新增的
createTaskId
方法建立工作 ID
suspend fun create(title: String, description: String): String {
val taskId = createTaskId()
}
不要封鎖主執行緒
等一下!如果建立工作 ID 的運算成本高昂,該怎麼辦?或許這項操作會使用密碼學來建立 ID 的雜湊鍵,因此需要幾秒鐘才能完成。如果在主執行緒上呼叫,可能就會導致 UI 卡頓。
資料層會負責「確保長時間執行或複雜的工作不會封鎖主執行緒」。
如要修正這個問題,請指定用於執行這些指令的協同程式調度器。
- 首先,請將
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,
)
- 現在,請將對
UUID.randomUUID().toString()
的呼叫放入withContext
區塊中。
val taskId = withContext(dispatcher) {
createTaskId()
}
如要進一步瞭解資料層中的執行緒,請按這裡。
建立及儲存工作
- 現在您已擁有工作 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
。
- 將下列擴充功能函式新增至
LocalTask
的結尾,這是與您先前建立的LocalTask.toExternal
反向對應的函式。
fun Task.toLocal() = LocalTask(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
- 請在
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)
}
您現在可以透過一些實用方法來建立及完成工作。
同步處理資料
在這個應用程式中,網路資料來源是做為線上備份之用。每次資料寫入本機時,系統都會一併更新備份;而當使用者每次要求重新整理時,則會從網路載入資料。
以下圖表是每個作業類型的行為概述。
作業類型 | 存放區方法 | 步驟 | 資料遷移 |
載入 |
| 從本機資料庫載入資料 | 圖 15. 這張圖表顯示從本機資料來源到工作存放區的資料流程。 |
儲存 |
| 1. 將資料寫入本機 database2。請將所有資料複製到網路,並覆寫全部內容 | 圖 16. 這張圖表顯示從工作存放區到本機資料來源,再到網路資料來源的資料流程。 |
重新整理 |
| 1. 從 network2 載入資料,接著複製到本機資料庫,覆寫所有資料 | 圖 17. 這張圖表顯示從網路資料來源到本機資料來源,再到工作存放區的資料流程。 |
儲存及重新整理網路資料
您的存放區已從本機資料來源載入工作了。如要完成同步處理演算法,您需建立方法,以便儲存及重新整理網路資料來源的資料。
- 首先,在
NetworkTask.kt
中建立LocalTask
與NetworkTask
之間的正向與反向對應函式。將函式放在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)
在這裡,您可以看到每個資料來源使用獨立模型的優勢:將一個資料類型對應至另一個類型時,可以封裝為獨立的函式。
- 在
DefaultTaskRepository
結尾新增refresh
方法。
suspend fun refresh() {
val networkTasks = networkDataSource.loadTasks()
localDataSource.deleteAll()
val localTasks = withContext(dispatcher) {
networkTasks.toLocal()
}
localDataSource.upsertAll(networkTasks.toLocal())
}
這會將所有「本機」工作替換為來自「網路」的工作。withContext
適用於執行大量的 toLocal
作業,因為工作的數量不明,且每個對應作業可能會產生高昂的運算成本。
- 將
saveTasksToNetwork
方法新增至DefaultTaskRepository
的結尾。
private suspend fun saveTasksToNetwork() {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
這會將所有「網路」工作替換為來自「本機」資料來源的工作。
- 現在更新現有的方法,以便更新工作
create
和complete
,進而在本機資料變更時將資料儲存到網路上。
suspend fun create(title: String, description: String): String {
...
saveTasksToNetwork()
return taskId
}
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
saveTasksToNetwork()
}
避免呼叫端等候
如果執行這段程式碼,您會看到 saveTasksToNetwork
阻塞。這表示 create
和 complete
的呼叫端不得不等待資料儲存至網路,才能確認作業已完成。這在模擬的網路資料來源中,只會延遲兩秒,但在實際應用程式中,所需時間可能較長,如果沒有網路連線甚至會無法運作。
這會造成不必要的限制,而且可能導致使用者體驗不佳,沒有人想要等待工作建立,特別是在忙碌的時候!
更理想的做法是使用不同的協同程式範圍,藉此將資料儲存至網路。這麼做可讓作業在背景完成,不必讓呼叫端等待結果。
- 將協同程式範圍做為參數新增至
DefaultTaskRepository
。
class DefaultTaskRepository @Inject constructor(
// ...other parameters...
@ApplicationScope private val scope: CoroutineScope,
)
Hilt 修飾符 @ApplicationScope
(在 di/CoroutinesModule.kt
中定義) 的用途,是插入遵循應用程式生命週期的範圍。
- 使用
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
) 例項化。首先需要建立這些依附元件。
- 在「Project Explorer」視窗中展開
(test)
資料夾,然後展開source.local
資料夾並開啟FakeTaskDao.kt.
。
圖 18. 這張螢幕截圖顯示「Project」資料夾結構中的 FakeTaskDao.kt
。
- 新增下列內容:
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
(讓假物件和實際物件實作一個通用介面),但在本程式碼研究室中,可以直接使用這項依附元件就好。
- 在
DefaultTaskRepositoryTest
中新增以下內容。
一項規則,用於設定要在所有測試中使用的主要調度器。 |
部分測試資料。 |
本機和網路資料來源的測試依附元件。 |
要測試的主體: |
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 | 工作資料流是使用 |
Then | 工作資料流中的第一個項目,與本機資料來源中工作的外部表示法相符 |
- 建立名為
observeAll_exposesLocalData
的測試,並在其中加入以下內容:
@Test
fun observeAll_exposesLocalData() = runTest {
val tasks = taskRepository.observeAll().first()
assertEquals(localTasks.toExternal(), tasks)
}
使用 first
函式從工作資料流取得第一個項目。
測試資料更新
接下來請編寫測試,驗證工作已建立並儲存至網路資料來源。
Given | 空白資料庫 |
When | 透過呼叫 |
Then | 這項工作會同時在本機資料來源和網路資料來源中建立 |
- 建立名為
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 | 透過呼叫 |
Then | 本機資料和網路資料也會更新 |
- 建立名為
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 | 已呼叫 |
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
開始。這是用來在應用程式中顯示第一個畫面的檢視模型,也就是目前所有進行中工作的清單。
- 開啟這個類別,並將
DefaultTaskRepository
新增為建構函式參數。
@HiltViewModel
class TasksViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
- 使用存放區將
tasksStream
變數初始化。
private val tasksStream = taskRepository.observeAll()
檢視模型現在可以存取存放區提供的所有工作,且每當有資料變更,檢視模型就會收到新的工作清單,這只需要一行程式碼就能做到!
- 剩下的工作是將使用者動作連結至存放區中的相應方法,請找出
complete
方法,並將其更新為以下內容:
fun complete(task: Task, completed: Boolean) {
viewModelScope.launch {
if (completed) {
taskRepository.complete(task.id)
showSnackbarMessage(R.string.task_marked_complete)
} else {
...
}
}
}
- 使用
refresh
執行相同操作。
fun refresh() {
_isLoading.value = true
viewModelScope.launch {
taskRepository.refresh()
_isLoading.value = false
}
}
為新增工作的畫面更新檢視畫面模型
- 開啟
AddEditTaskViewModel
並將DefaultTaskRepository
新增為建構函式參數,與上一個步驟的做法相同。
class AddEditTaskViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
)
- 將
create
方法更新為以下內容:
private fun createNewTask() = viewModelScope.launch {
taskRepository.create(uiState.value.title, uiState.value.description)
_uiState.update {
it.copy(isTaskSaved = true)
}
}
執行應用程式
- 終於來到期待已久的時刻,可以執行應用程式了!畫面上應該會顯示「You have no tasks!」。
圖 19. 應用程式的工作畫面螢幕截圖,顯示沒有工作時的畫面。
- 輕觸右上角的三點圖示,然後按一下「Refresh」。
圖 20. 顯示動作選單的應用程式工作畫面螢幕截圖。
您應該會看到載入旋轉圖示持續顯示兩秒,接著應該會顯示先前新增的測試工作。
圖 21. 應用程式的工作畫面螢幕截圖,顯示兩項工作。
- 現在,輕觸右下角的加號來新增工作。請填寫標題和說明欄位。
圖 22. 應用程式新增工作畫面的螢幕截圖。
- 輕觸右下角的勾號按鈕,即可儲存工作。
圖 23. 應用程式的工作畫面螢幕截圖,顯示已新增的工作。
- 勾選工作旁的核取方塊,即可將工作標示為完成。
圖 24. 應用程式的工作畫面螢幕截圖,顯示已完成的工作。
10. 恭喜!
您已成功為應用程式建立資料層。
資料層是應用程式架構的重要部分,也是其他層的建構基礎,若能妥善建立資料層,可讓應用程式依照使用者和您的業務需求進行調整。
您學到的內容
- 資料層在 Android 應用程式架構中的角色。
- 如何建立資料來源和模型。
- 存放區的角色、存放區如何公開資料,以及存放區如何提供一次性的方法來更新資料。
- 如何變更協同程式調度器,以及這項操作的重要性。
- 使用多個資料來源同步處理資料。
- 如何為常用資料層類別建立單元和檢測設備測試。
進階挑戰
如想嘗試其他挑戰,請實作下列功能:
- 在工作標示為完成後重新啟動工作。
- 輕觸工作以編輯標題和說明。
進階挑戰不提供任何操作說明,一切由您自行建構!如果遇到困難,請查看 main
分支版本的完整功能應用程式。
git checkout main
後續步驟
如要進一步瞭解資料層,請參閱官方說明文件以及離線優先應用程式指南。您也可以參閱「UI 層」和「網域層」,瞭解其他架構層。
如需較為複雜的實際範例,請參閱 Now in Android 應用程式。