使用 Room 保存資料

1. 事前準備

大多數品質達到實際工作環境的應用程式都需要保存資料,例如可能會儲存歌曲播放清單、待辦事項清單、支出和收入記錄、星座目錄或個人資料記錄。針對這類用途,您可以使用資料庫儲存這些持續性資料。

Room 是 Android Jetpack 中的持續性程式庫。Room 是 SQLite 資料庫頂端的抽象層。SQLite 使用專門的語言 (SQL) 執行資料庫作業。Room 不直接使用 SQLite,因此簡化了設定資料庫、調整其他設定以及與應用程式互動的過程。此外,Room 也提供 SQLite 陳述式的編譯時間檢查。

「抽象層」是一組隱藏基礎實作/複雜度的函式,並且為現有功能組合 (例如這裡提到的 SQLite) 提供介面。

下圖說明了做為資料來源的 Room 如何配合本課程推薦的整體架構。

資料層包含存放區和資料來源

必要條件

  • 能夠使用 Jetpack Compose 為 Android 應用程式建構基本的使用者介面 (UI)。
  • 能夠使用 TextIconIconButtonLazyColumn 等可組合項。
  • 能夠使用 NavHost 可組合項,定義應用程式中的路徑和畫面。
  • 能夠使用 NavHostController 瀏覽不同畫面。
  • 熟悉 Android 架構元件 ViewModel,而且能夠使用 ViewModelProvider.Factory 將 ViewModel 例項化。
  • 熟悉有關並行的基礎知識。
  • 能夠使用協同程式處理長時間執行的工作。
  • 具備 SQLite 資料庫和 SQL 語言的基本知識。

課程內容

  • 如何使用 Room 程式庫建立 SQLite 資料庫並與之互動。
  • 如何建立實體、資料存取物件 (DAO) 和資料庫類別。
  • 如何使用 DAO 將 Kotlin 函式對應至 SQL 查詢。

建構項目

  • 您要建構一個商品目錄應用程式,用於將庫存商品儲存至 SQLite 資料庫。

軟硬體需求

  • 商品目錄應用程式的範例程式碼
  • 搭載 Android Studio 的電腦
  • 搭載 API 級別 26 以上版本的裝置或模擬器

2. 應用程式總覽

在本程式碼研究室中,您將使用商品目錄應用程式的範例程式碼,並透過 Room 程式庫將資料庫層加入該應用程式。最終版本的應用程式會顯示商品目錄資料庫的商品清單。使用者可以選擇在商品目錄資料庫中新增商品、更新現有商品及刪除商品。您要在本程式碼研究室中將商品資料儲存到 Room 資料庫,並在下一個程式碼研究室中完成應用程式的其他功能。

手機螢幕顯示庫存商品

手機螢幕顯示「Add Item」畫面。

手機螢幕顯示已填入商品詳細資料。

3. 範例應用程式總覽

下載本程式碼研究室的範例程式碼

如要開始使用,請先下載範例程式碼:

或者,您也可以複製 GitHub 存放區的程式碼:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout starter

您可以瀏覽 Inventory app GitHub 存放區中的程式碼。

範例程式碼總覽

  1. 在 Android Studio 中開啟含有範例程式碼的專案。
  2. 在 Android 裝置或模擬器上執行應用程式。請確保模擬器或已連結的裝置搭載 API 級別 26 以上版本。資料庫檢查器可在搭載 API 級別 26 以上的模擬器/裝置上運作。
  1. 請注意,應用程式不會顯示商品目錄資料。
  2. 輕觸懸浮動作按鈕 (FAB),這可讓您新增商品到資料庫。

應用程式將顯示新的畫面,您可以在該畫面中輸入新商品的詳細資料。

手機螢幕顯示沒有庫存商品

手機螢幕顯示「Add Item」畫面。

範例程式碼相關問題

  1. 在「Add Item」畫面中,輸入商品的名稱、價格和數量等詳細資料。
  2. 輕觸「Save」。「Add Item」畫面不會關閉,但您可以使用返回鍵,回到上一個頁面。這個應用程式尚未實作儲存功能,因此不會儲存商品詳細資料。

請注意,此應用程式並不完整,且未實作「Save」按鈕功能。

手機螢幕顯示已填入商品詳細資料。

在這個程式碼研究室中,您要新增使用 Room 的程式碼,將商品目錄詳細資料儲存到 SQLite 資料庫。此外,也將使用 Room 持續性程式庫與 SQLite 資料庫互動。

程式碼逐步操作說明

您下載的範例程式碼包含已為您預先設計的畫面版面配置。在本課程中,您將著重於實作資料庫邏輯。下一節提供了一些檔案的簡要逐步操作說明,協助您快速上手。

ui/home/HomeScreen.kt

這個檔案是主畫面,也是應用程式中的第一個畫面,當中包含多個可組合項,用於顯示商品目錄清單。此外,此畫面也會顯示 FAB +,用途是將商品新增至清單。在本課程中,您之後會顯示清單內的商品。

手機螢幕顯示庫存商品

ui/item/ItemEntryScreen.kt

這個畫面與 ItemEditScreen.kt 類似,兩者都含有商品詳細資料的文字欄位。只要在主畫面中輕觸懸浮動作按鈕 (FAB),就會顯示這個畫面。ItemEntryViewModel.kt 是此畫面的對應 ViewModel

手機螢幕顯示已填入商品詳細資料。

ui/navigation/InventoryNavGraph.kt

此檔案是整個應用程式的導覽圖。

4. Room 的主要元件

Kotlin 可讓您透過資料類別輕鬆處理資料。雖然使用資料類別處理記憶體內的資料很方便,但就保存資料而言,您必須將這些資料轉換為與資料庫儲存空間相容的格式。如要執行這項操作,您需要使用「資料表」儲存資料,並利用「查詢」存取及修改資料。

以下三個 Room 元件可讓這些工作流程順暢運作。

  • Room 實體代表應用程式資料庫中的資料表,可用於更新資料表中資料列儲存的資料,也可用來建立新的資料列來插入內容。
  • Room DAO 為應用程式提供各種方法,用於在資料庫中擷取、更新、插入及刪除資料。
  • Room 資料庫類別為應用程式提供與該資料庫相關聯的 DAO 例項。

在本程式碼研究室中,您稍後將實作這些元件,並進一步瞭解相關資訊。下方圖表展示了 Room 的各元件如何協同運作,以便與資料庫互動。

b95ee603605526c1.png

新增 Room 依附元件

在這項工作中,您要將必要的 Room 元件程式庫加入 Gradle 檔案。

  1. 開啟模組層級 Gradle 檔案 build.gradle.kts (Module: InventoryApp.app)
  2. dependencies 區塊中,加入以下程式碼顯示的 Room 程式庫依附元件。
//Room
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}")
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}")
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}")

KSP 是簡單易用但功能強大的 API,可用於剖析 Kotlin 註解。

5. 建立商品實體

實體類別定義了資料表,此類別的每個例項都代表資料庫資料表中的一個資料列。實體類別含有對應項目,可用來向 Room 指示該類別打算如何呈現資料庫中的資訊並與之互動。在應用程式中,這個實體會存放有關庫存商品的資訊,例如商品的名稱、價格和供應數量。

ee0ef2847ddcbe91.png

@Entity 註解會將類別標示為資料庫實體類別。應用程式會為每個實體類別建立資料庫資料表,用來存放商品。除非另行註明,否則實體的各欄位均表示資料庫中的一個資料欄 (詳情請參閱「實體」說明文件)。資料庫中儲存的所有實體例項都必須包含主鍵。主鍵專門用來識別資料庫資料表中的每筆記錄/每個項目。主鍵由應用程式指派後便無法修改;只要實體物件仍保存在資料庫中,就會以主鍵表示。

在這項工作中,您要建立實體類別,並定義多個欄位儲存每項商品的以下商品目錄資訊:Int 用於保存主鍵,String 用來保存商品名稱,double 用於保存商品價格,而 Int 則用來保存庫存數量。

  1. 在 Android Studio 中開啟範例程式碼。
  2. 開啟 com.example.inventory 基本套件下的 data 套件。
  3. data 套件中開啟 Item Kotlin 類別,這代表應用程式中的資料庫實體。
// No need to copy over, this is part of the starter code
class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)

資料類別

資料類別主要用於保存 Kotlin 中的資料。這些類別是以關鍵字 data 定義。Kotlin 資料類別物件可帶來一些額外優勢,舉例來說,編譯器會自動產生用來比較、顯示及複製的公用程式,例如 toString()copy()equals()

範例:

// Example data class with 2 properties.
data class User(val firstName: String, val lastName: String){
}

為了讓產生的程式碼保持一致並有效運作,資料類別必須符合下列規定:

  • 主要建構函式至少必須含有一個參數。
  • 所有主要建構函式參數都必須是 valvar
  • 資料類別不得為 abstractopensealed

如要進一步瞭解資料類別,請參閱「資料類別」說明文件。

  1. Item 類別的定義前方加上 data 關鍵字,將該類別轉換為資料類別。
data class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)
  1. Item 類別宣告上方,為資料類別加上 @Entity 註解。接著使用 tableName 引數,將 items 設為 SQLite 資料表名稱。
import androidx.room.Entity

@Entity(tableName = "items")
data class Item(
   ...
)
  1. id 屬性加上 @PrimaryKey 註解,讓 id 成為主鍵。主鍵是用來識別 Item 資料表中每筆記錄/每個項目的專屬 ID
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey
    val id: Int,
    ...
)
  1. id 指派預設值 0id 需要這個值才能自動產生 id 值。
  2. 將參數 autoGenerate 設為 true,讓 Room 為每個實體產生逐漸遞增的 ID。這種做法可保證每項商品的 ID 都不重複。
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    // ...
)

您將在下一項工作中定義用於存取資料庫的介面。

6. 建立商品 DAO

資料存取物件 (DAO) 是一種模式,可透過提供抽象介面,從應用程式的其餘部分中區隔出持久層。這種隔離機制符合先前程式碼研究室提及的單一責任原則

DAO 的功能是隱藏在基礎持久層中執行資料庫作業所涉及的所有複雜度,與應用程式的其餘部分做出區隔。如此一來,您就能單獨變更資料層,不會影響使用該資料的程式碼。

6d40734740c0cffc.png

在這項工作中,您要為 Room 定義 DAO。DAO 是 Room 的主要元件,負責定義存取資料庫的介面。

您建立的 DAO 屬於自訂介面,可提供便利的方法,用於在資料庫中查詢/擷取、插入、刪除及更新資料。Room 會在編譯期間產生這個類別的實作內容。

Room 程式庫提供便利的 @Insert@Delete@Update 等註解,用來定義各種方法,可執行簡易的插入、刪除與更新作業,而且完全不必編寫 SQL 陳述式。

如需定義更複雜的插入、刪除與更新作業,或是需要查詢資料庫中的資料,請改用 @Query 註解。

另一個好處是,當您在 Android Studio 中編寫查詢時,編譯器會檢查 SQL 查詢是否有語法錯誤。

針對商品目錄應用程式,您需要能夠執行以下操作:

  • 插入或新增商品。
  • 更新現有商品的名稱、價格和數量。
  • 根據商品主鍵 id 取得特定商品。
  • 取得所有商品以便展示。
  • 刪除資料庫中的項目。

286d6a1799c173c9.png

如要在應用程式中實作商品 DAO,請完成下列步驟:

  1. data 套件中建立 Kotlin 介面 ItemDao.kt

在名稱欄位中填入 ItemDao

  1. ItemDao 介面加上 @Dao 註解。
import androidx.room.Dao

@Dao
interface ItemDao {
}
  1. 在介面主體中新增 @Insert 註解。
  2. @Insert 下方新增 insert() 函式,這個函式會採用 Entity 類別 item 的例項做為引數。
  3. 使用 suspend 關鍵字標示函式,讓該函式在另一個執行緒中執行。

資料庫作業的執行時間可能相當長,因此需要在另一個執行緒中執行。Room 不允許在主執行緒中存取資料庫。

import androidx.room.Insert

@Insert
suspend fun insert(item: Item)

將商品插入資料庫時,可能會發生衝突。舉例來說,程式碼中的多個位置嘗試以不同且互相衝突的值 (例如相同的主鍵) 更新實體。實體是資料庫中的一個資料列。在商品目錄應用程式中,我們只會從「Add Item」畫面插入實體,因此預期不會發生任何衝突,且可將衝突策略設為「Ignore」。

  1. 新增引數 onConflict,並為其指派 OnConflictStrategy.IGNORE 這個值。

引數 onConflict 會指示 Room 在發生衝突時應如何處理,而 OnConflictStrategy.IGNORE 策略則會忽略新商品。

如要進一步瞭解可用的衝突策略,請參閱 OnConflictStrategy 說明文件。

import androidx.room.OnConflictStrategy

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(item: Item)

現在 Room 會產生所有必要程式碼,將 item 插入資料庫。如果您呼叫標有 Room 備註的任何 DAO 函式,Room 會在資料庫中執行對應的 SQL 查詢。舉例來說,當您呼叫 Kotlin 程式碼中的上述 insert() 方法時,Room 就會執行 SQL 查詢,將實體插入資料庫。

  1. 新增含有 @Update 註解的函式,這個函式會採用 Item 做為參數。

更新與傳入的實體含有相同主鍵。您可以更新實體的部分或所有其他屬性。

  1. insert() 方法類似,使用 suspend 關鍵字標示這個函式。
import androidx.room.Update

@Update
suspend fun update(item: Item)

新增另一個含有 @Delete 註解的函式刪除商品,然後將其設為暫停函式。

import androidx.room.Delete

@Delete
suspend fun delete(item: Item)

剩餘功能沒有便利的註解,因此您必須使用 @Query 註解並提供 SQLite 查詢。

  1. 編寫 SQLite 查詢,根據指定的 id 從商品資料表中擷取特定商品。以下程式碼提供的範例查詢會選取 items 內的所有資料欄,其中 id 會與特定的值相符,而且 id 均不重複。

範例:

// Example, no need to copy over
SELECT * from items WHERE id = 1
  1. 新增 @Query 註解。
  2. 使用上一個步驟的 SQLite 查詢做為 @Query 註解的字串參數。
  3. String 參數加入 @Query,這是一項 SQLite 查詢,用於從商品資料表中擷取商品。

此查詢現在會指示要選取 items 內的所有資料欄,其中 id 會與 :id 引數相符。請注意,:id 在查詢中使用冒號標記法參照函式內的引數。

@Query("SELECT * from items WHERE id = :id")
  1. @Query 註解後方新增 getItem() 函式,這個函式會採用 Int 引數並傳回 Flow<Item>
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Query("SELECT * from items WHERE id = :id")
fun getItem(id: Int): Flow<Item>

建議在持久層中使用 Flow。如果以 Flow 做為傳回類型,每當資料庫中的資料有所變更時,您都會收到通知。Room 將隨時為您更新這個 Flow,因此您只需明確取得資料一次。這項設定有助於更新您在下一個程式碼研究室中實作的商品目錄清單。根據 Flow 傳回類型,Room 也會在背景執行緒中執行查詢。您不需將其明確設為 suspend 函式,也不必在協同程式範圍內呼叫。

  1. getAllItems() 函式加上 @Query 註解。
  2. 讓 SQLite 查詢傳回 item 資料表中的所有資料欄,以遞增順序排序。
  3. getAllItems()Item 實體清單做為 Flow 傳回。Room 將隨時為您更新這個 Flow,因此您只需明確取得資料一次。
@Query("SELECT * from items ORDER BY name ASC")
fun getAllItems(): Flow<List<Item>>

ItemDao 已完成

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

    @Query("SELECT * from items WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from items ORDER BY name ASC")
    fun getAllItems(): Flow<List<Item>>
}
  1. 雖然您看不到明顯的變更,但請妥善建構應用程式,確保沒有任何錯誤。

7. 建立資料庫例項

在這項工作中,您要建立 RoomDatabase,這個資料庫使用先前工作中建立的 Entity 和 DAO。資料庫類別定義了實體和 DAO 的清單。

Database 類別為應用程式提供您定義的 DAO 例項。因此,應用程式可以使用 DAO 擷取資料庫中的資料,做為關聯資料實體物件的例項。另外,應用程式也可利用定義的資料實體,更新對應資料表中的資料列,或是建立新的資料列來插入內容。

您需要建立抽象的 RoomDatabase 類別,並加上 @Database 註解。此類別包含一個方法,可在沒有資料庫時傳回 RoomDatabase 的現有例項。

以下是取得 RoomDatabase 執行個體的一般程序:

  • 建立一個擴充 RoomDatabasepublic abstract 類別。您定義的新抽象類別會成為資料庫容器。Room 會為您建立實作內容,因此您定義的類別屬於抽象性質。
  • 為該類別加上 @Database 註解。在引數中,列出資料庫的實體並設定版本號碼。
  • 定義一個傳回 ItemDao 例項的抽象方法或屬性,Room 會為您產生實作內容。
  • 整個應用程式只需要一個 RoomDatabase 執行個體,因此請將 RoomDatabase 設為單例模式。
  • 僅在您的 (item_database) 資料庫不存在的情況下,使用 RoomRoom.databaseBuilder 建立資料庫。否則,請傳回現有資料庫。

建立資料庫

  1. data 套件中建立 Kotlin 類別 InventoryDatabase.kt
  2. InventoryDatabase.kt 檔案中,將 InventoryDatabase 類別設為擴充 RoomDatabaseabstract 類別。
  3. 為該類別加上 @Database 註解。請忽略缺少參數的錯誤,您將在下一個步驟中做出修正。
import androidx.room.Database
import androidx.room.RoomDatabase

@Database
abstract class InventoryDatabase : RoomDatabase() {}

@Database 註解需要多個引數,以便 Room 能夠建構資料庫。

  1. Item 指定為包含 entities 清單的唯一類別。
  2. version 設為 1每次變更資料庫資料表的結構定義時,都必須增加版本號碼。
  3. exportSchema 設為 false,即可不保留結構定義版本記錄的備份。
@Database(entities = [Item::class], version = 1, exportSchema = false)
  1. 在類別主體中,宣告一個傳回 ItemDao 的抽象函式,讓資料庫得知該 DAO。
abstract fun itemDao(): ItemDao
  1. 在抽象函式下方定義 companion object,這個物件使用類別名稱做為限定詞,並可讓您存取建立或取得資料庫的方法。
 companion object {}
  1. companion 物件中,為資料庫宣告可為空值的私人變數 Instance,並將其初始化為 null

如有已建立的資料庫,Instance 變數會保留對該資料庫的參照。這有助於維護在指定時間開啟的資料庫單一例項,該例項是建立及維護成本很高的資源。

  1. Instance 加上 @Volatile 註解。

系統一律不會快取易失變數的值,所有讀取與寫入作業都會在主記憶體中完成。這些功能有助確保 Instance 的值隨時處於最新狀態,而且對於所有執行緒均保持一致。也就是說,任一執行緒對 Instance 所做的變更會立即向所有其他執行緒顯示。

@Volatile
private var Instance: InventoryDatabase? = null
  1. Instance 下方的 companion 物件內,使用資料庫建構工具所需的 Context 參數定義 getDatabase() 方法。
  2. 傳回 InventoryDatabase 類型。由於 getDatabase() 尚未傳回任何內容,畫面上會顯示錯誤訊息。
import android.content.Context

fun getDatabase(context: Context): InventoryDatabase {}

多個執行緒可能會同時要求一個資料庫例項,因而產生兩個資料庫,而非單一資料庫。這個問題稱為競爭狀況。如果將用來取得資料庫的程式碼納入 synchronized 區塊,就表示一次只有一個執行緒能夠進入此程式碼區塊,這可確保資料庫僅初始化一次。

  1. getDatabase() 中傳回 Instance 變數;如果 Instance 為空值,則在 synchronized{} 區塊內將其初始化。請使用 elvis 運算子 (?:) 執行此作業。
  2. 傳入 this,這代表伴生物件。您將在後續步驟中修正錯誤。
return Instance ?: synchronized(this) { }
  1. 在 synchronized 區塊中,使用資料庫建構工具取得資料庫。請繼續忽略錯誤,您將在後續步驟中做出修正。
import androidx.room.Room

Room.databaseBuilder()
  1. synchronized 區塊中,使用資料庫建構工具取得資料庫。將應用程式結構定義、資料庫類別和資料庫名稱 item_database 傳入 Room.databaseBuilder()
Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")

Android Studio 會產生類型不符的錯誤。如要移除這項錯誤,您必須在下列步驟中新增 build()

  1. 將必要的遷移策略新增至建構工具。請使用 .fallbackToDestructiveMigration()
.fallbackToDestructiveMigration()
  1. 如要建立資料庫例項,請呼叫 .build()。這項呼叫會移除 Android Studio 錯誤。
.build()
  1. build() 後方,新增 also 區塊並指派 Instance = it,保留對最近所建立資料庫例項的參照。
.also { Instance = it }
  1. synchronized 區塊的結尾,傳回 instance。最終的程式碼如下所示:
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
* Database class with a singleton Instance object.
*/
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase : RoomDatabase() {

    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var Instance: InventoryDatabase? = null

        fun getDatabase(context: Context): InventoryDatabase {
            // if the Instance is not null, return it, otherwise create a new database instance.
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .build()
                    .also { Instance = it }
            }
        }
    }
}
  1. 建構程式碼,確保沒有錯誤。

8. 實作存放區

在這項工作中,您要實作 ItemsRepository 介面和 OfflineItemsRepository 類別,提供資料庫中的 getinsertdeleteupdate 實體。

  1. 開啟 data 套件下的 ItemsRepository.kt 檔案。
  2. 將下列函式加入介面,這些函式會對應至 DAO 實作內容。
import kotlinx.coroutines.flow.Flow

/**
* Repository that provides insert, update, delete, and retrieve of [Item] from a given data source.
*/
interface ItemsRepository {
    /**
     * Retrieve all the items from the given data source.
     */
    fun getAllItemsStream(): Flow<List<Item>>

    /**
     * Retrieve an item from the given data source that matches with the [id].
     */
    fun getItemStream(id: Int): Flow<Item?>

    /**
     * Insert item in the data source
     */
    suspend fun insertItem(item: Item)

    /**
     * Delete item from the data source
     */
    suspend fun deleteItem(item: Item)

    /**
     * Update item in the data source
     */
    suspend fun updateItem(item: Item)
}
  1. 開啟 data 套件下的 OfflineItemsRepository.kt 檔案。
  2. 傳入 ItemDao 類型的建構函式參數。
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository
  1. OfflineItemsRepository 類別中,覆寫 ItemsRepository 介面內定義的函式,並呼叫 ItemDao 中的對應函式。
import kotlinx.coroutines.flow.Flow

class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
    override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()

    override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)

    override suspend fun insertItem(item: Item) = itemDao.insert(item)

    override suspend fun deleteItem(item: Item) = itemDao.delete(item)

    override suspend fun updateItem(item: Item) = itemDao.update(item)
}

實作 AppContainer 類別

在這項工作中,您要將資料庫例項化,並將 DAO 例項傳入 OfflineItemsRepository 類別。

  1. 開啟 data 套件下的 AppContainer.kt 檔案。
  2. ItemDao() 例項傳入 OfflineItemsRepository 建構函式。
  3. InventoryDatabase 類別呼叫 getDatabase() 傳入結構定義,將資料庫例項化,然後呼叫 .itemDao() 建立 Dao 的例項。
override val itemsRepository: ItemsRepository by lazy {
    OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
}

您現在已有可搭配 Room 使用的所有建構區塊。此程式碼可以編譯並執行,但您無法判斷程式碼是否確實正常運作。因此,這正是測試資料庫的好時機。如要完成測試,您需要 ViewModel 才能向資料庫下達指示。

9. 新增儲存功能

目前您已建立一個資料庫,而範例程式碼原本就包含 UI 類別。如要儲存應用程式的暫時性資料及存取資料庫,您必須更新 ViewModelViewModel 會透過 DAO 與資料庫互動,並提供資料給 UI。請注意,所有資料庫作業都需要在主要 UI 執行緒以外執行,使用協同程式和 viewModelScope 即可完成這項操作。

UI 狀態類別的逐步操作說明

開啟 ui/item/ItemEntryViewModel.kt 檔案。ItemUiState 資料類別代表商品的 UI 狀態;ItemDetails 資料類別則代表單一商品。

範例程式碼提供三種擴充功能函式:

  • ItemDetails.toItem() 擴充功能函式可將 ItemUiState UI 狀態物件轉換為 Item 實體類型。
  • Item.toItemUiState() 擴充功能函式可將 Item Room 實體物件轉換為 ItemUiState UI 狀態類型。
  • Item.toItemDetails() 擴充功能函式可將 Item Room 實體物件轉換為 ItemDetails
// No need to copy, this is part of starter code
/**
* Represents Ui State for an Item.
*/
data class ItemUiState(
    val itemDetails: ItemDetails = ItemDetails(),
    val isEntryValid: Boolean = false
)

data class ItemDetails(
    val id: Int = 0,
    val name: String = "",
    val price: String = "",
    val quantity: String = "",
)

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

fun Item.formatedPrice(): String {
    return NumberFormat.getCurrencyInstance().format(price)
}

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

您可以在檢視區塊模型中使用上述類別,讀取及更新 UI。

更新 ItemEntry ViewModel

在這項工作中,您要將存放區傳入 ItemEntryViewModel.kt 檔案,並將在「Add Item」畫面上輸入的商品詳細資料儲存到資料庫。

  1. 請注意 ItemEntryViewModel 類別中的 validateInput() 私人函式。
// No need to copy over, this is part of starter code
private fun validateInput(uiState: ItemDetails = itemUiState.itemDetails): Boolean {
    return with(uiState) {
        name.isNotBlank() && price.isNotBlank() && quantity.isNotBlank()
    }
}

上述函式會檢查 namepricequantity 是否為空白。在新增或更新資料庫中的實體前,請使用這個函式驗證使用者輸入內容。

  1. 開啟 ItemEntryViewModel 類別,然後新增 ItemsRepository 類型的 private 預設建構函式參數。
import com.example.inventory.data.ItemsRepository

class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
}
  1. ui/AppViewModelProvider.kt 中更新商品項目檢視區塊模型的 initializer,並傳入存放區例項做為參數。
object AppViewModelProvider {
    val Factory = viewModelFactory {
        // Other Initializers
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(inventoryApplication().container.itemsRepository)
        }
        //...
    }
}
  1. 前往 ItemEntryViewModel.kt 檔案,然後在 ItemEntryViewModel 類別的結尾新增名為 saveItem() 的暫停函式,將商品插入 Room 資料庫。這個函式會以非阻塞的方式添加資料到資料庫。
suspend fun saveItem() {
}
  1. 在函式中檢查 itemUiState 是否有效,並將其轉換為 Item 類型,讓 Room 能夠解讀資料。
  2. itemsRepository 呼叫 insertItem(),然後傳入資料。UI 會呼叫此函式,將商品詳細資料加入資料庫。
suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}

您現在已新增所有必要的函式,可將實體加入資料庫。在下一項工作中,您將更新 UI,以便使用上述函式。

ItemEntryBody() 可組合項的逐步操作說明

  1. ui/item/ItemEntryScreen.kt 檔案中,範例程式碼包含已為您完成部分實作的 ItemEntryBody() 可組合項。請在 ItemEntryScreen() 函式呼叫中查看 ItemEntryBody() 可組合項。
// No need to copy over, part of the starter code
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = Modifier
        .padding(innerPadding)
        .verticalScroll(rememberScrollState())
        .fillMaxWidth()
)
  1. 請注意,目前傳遞的 UI 狀態和 updateUiState lambda 是做為函式參數。您可以查看函式定義,瞭解 UI 狀態的更新方式。
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    itemUiState: ItemUiState,
    onItemValueChange: (ItemUiState) -> Unit,
    onSaveClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             onValueChange = onItemValueChange,
             modifier = Modifier.fillMaxWidth()
         )
        Button(
             onClick = onSaveClick,
             enabled = itemUiState.isEntryValid,
             shape = MaterialTheme.shapes.small,
             modifier = Modifier.fillMaxWidth()
         ) {
             Text(text = stringResource(R.string.save_action))
         }
    }
}

您要在這個可組合函式中顯示 ItemInputForm 和「Save」按鈕,並在 ItemInputForm() 可組合項中顯示三個文字欄位。只有在文字欄位內已輸入文字時,系統才會啟用「Save」按鈕。如果所有文字欄位內的文字皆有效 (並非空白),「isEntryValid值即為 true。

手機螢幕顯示已填入部分商品詳細資料,以及停用的「Save」按鈕

手機螢幕顯示已填入商品詳細資料,以及啟用的「Save」按鈕

  1. 查看 ItemInputForm() 可組合函式的實作內容,並留意 onValueChange 函式參數。您要將 itemDetails 值更新為使用者在文字欄位中輸入的值。當「Save」按鈕啟用時,itemUiState.itemDetails 會包含需要儲存的值。
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    //...
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             //...
         )
        //...
    }
}
// No need to copy over, part of the starter code
@Composable
fun ItemInputForm(
    itemDetails: ItemDetails,
    modifier: Modifier = Modifier,
    onValueChange: (ItemUiState) -> Unit = {},
    enabled: Boolean = true
) {
    Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        OutlinedTextField(
            value = itemUiState.name,
            onValueChange = { onValueChange(itemDetails.copy(name = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.price,
            onValueChange = { onValueChange(itemDetails.copy(price = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.quantity,
            onValueChange = { onValueChange(itemDetails.copy(quantity = it)) },
            //...
        )
    }
}

將點擊事件監聽器新增至「Save」按鈕

如要連結所有內容,請將點擊處理常式新增至「Save」按鈕。在點擊處理常式中,您要啟動協同程式並呼叫 saveItem(),將資料儲存到 Room 資料庫。

  1. ItemEntryScreen.ktItemEntryScreen 可組合函式中,使用 rememberCoroutineScope() 可組合函式建立名為 coroutineScopeval
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. 更新 ItemEntryBody() 函式呼叫,並在 onSaveClick lambda 中啟動協同程式。
ItemEntryBody(
   // ...
    onSaveClick = {
        coroutineScope.launch {
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. ItemEntryViewModel.kt 檔案中查看 saveItem() 函式實作內容,檢查 itemUiState 是否有效,並將 itemUiState 轉換為 Item 類型,然後使用 itemsRepository.insertItem() 將其插入資料庫。
// No need to copy over, you have already implemented this as part of the Room implementation

suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}
  1. ItemEntryScreen.ktItemEntryScreen 可組合函式內,找到協同程式並在其中呼叫 viewModel.saveItem(),將商品儲存到資料庫。
ItemEntryBody(
    // ...
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
        }
    },
    //...
)

請注意,您並未在 ItemEntryViewModel.kt 檔案中為 saveItem() 使用 viewModelScope.launch(),但這是呼叫存放區方法時 ItemEntryBody() 所需的函式。暫停函式只能從協同程式或其他暫停函式中呼叫。viewModel.saveItem() 函式是一種暫停函式。

  1. 建構並執行應用程式。
  2. 輕觸「+」FAB。
  3. 在「Add Item」畫面中新增商品詳細資料,然後輕觸「Save」。請注意,輕觸「Save」按鈕不會關閉「Add Item」畫面。

手機螢幕顯示已填入商品詳細資料,以及啟用的「Save」按鈕

  1. onSaveClick lambda 中找到向 viewModel.saveItem() 發出的呼叫,然後在後方新增對 navigateBack() 的呼叫,導覽回上一個畫面。您的 ItemEntryBody() 函式如以下程式碼所示:
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. 再次執行應用程式,然後按照相同步驟輸入及儲存資料。請注意,應用程式現在會切換回「Inventory」畫面。

這項操作會儲存資料,但應用程式不會顯示商品目錄資料。在下一項工作中,您將使用資料庫檢查器瀏覽已儲存的資料。

應用程式畫面顯示空白的商品目錄清單

10. 使用資料庫檢查器瀏覽資料庫內容

透過資料庫檢查器,您可以在應用程式執行期間檢查、查詢及修改應用程式的資料庫。這項功能特別適合用於資料庫偵錯。資料庫檢查器適用於一般 SQLite 和以 SQLite 為基礎的程式庫,例如 Room。資料庫檢查器最適合在搭載 API 級別 26 的模擬器/裝置上運作。

  1. 如果尚未在搭載 API 級別 26 以上版本的模擬器或已連結裝置上執行應用程式,請先執行。
  2. 在 Android Studio 的選單列中,依序選取「View」>「Tool Windows」>「App Inspection」
  3. 選取「Database Inspector」分頁標籤。
  4. 如果尚未在「Database Inspector」窗格的下拉式選單中選取 com.example.inventory,請先選取。「Databases」窗格會顯示商品目錄應用程式中的「item_database」

6876a506d634ca2a.png

  1. 在「Databases」窗格中展開「item_database」的節點,然後選取「items」進行檢查。如果「Databases」窗格沒有任何內容,請使用模擬器,在「Add Item」畫面上將商品加入資料庫。
  2. 勾選資料庫檢查器中的「Live updates」核取方塊,如此一來,當您在模擬器或裝置上與執行中的應用程式互動時,就能自動更新檢查器顯示的資料。

ffd820637ed70b89.png

恭喜!您已建立一個可透過 Room 保存資料的應用程式。在下一個程式碼研究室中,您會添加 lazyColumn 至應用程式來顯示資料庫內的商品,並將刪除及更新實體等新功能加入應用程式。到時見!

11. 取得解決方案程式碼

本程式碼研究室的解決方案程式碼位於 GitHub 存放區中。完成程式碼研究室後,如要下載當中用到的程式碼,請使用以下 Git 指令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout room

另外,您也可以下載存放區為 ZIP 檔案,然後解壓縮並在 Android Studio 中開啟。

如要查看本程式碼研究室的解決方案程式碼,請前往 GitHub 查看。

12. 摘要

  • 將資料表定義為具有 @Entity 註解的資料類別。定義具有 @ColumnInfo 註解的屬性做為資料表中的資料欄。
  • 定義資料存取物件 (DAO) 做為標有 @Dao 註解的介面。DAO 會將 Kotlin 函式對應至資料庫查詢。
  • 使用註解來定義 @Insert@Delete@Update 函式。
  • 針對其他查詢,使用 @Query 註解搭配 SQLite 查詢字串做為參數。
  • 使用資料庫檢查器瀏覽 Android SQLite 資料庫中儲存的資料。

13. 瞭解詳情

Android 開發人員說明文件

網誌文章

影片

其他說明文件和文章