本文章將介紹如何在 Android 中使用 Room 來存取資料庫。
Table of Contents
Room
Room 是一個 Database Object Mapping 的資料庫存取 library。它將整個存取資料庫的過程變得很簡單,而且它還整合了 Coroutine 的 suspend function。接下來我們將建立一個簡單的 app 來展示如何使用 Room。
首先,建立一個新的專案。然後,在專案中,加入 Room 的 dependecies,如下。
plugins { id 'kotlin-kapt' } dependencies { def room_version = "2.5.0" implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" }
@Entity
每一個有宣告 @Entity 的 class 代表一個 table。以下的程式碼中,我們宣告了 class Employee,並且加上了 @Entity。在 @Entity 中,我們指定 table 的名稱為 employees
。
在這個 table 中,我們宣告了四個 columns。我們可以用 @ColumnInfo 來指定欄位的名稱。如果不指定的話,它預設就會用 field 的名稱。最後,我們用 @PrimaryKey 來指定 primary key。
import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import java.util.* @Entity(tableName = "employees") data class Employee( val name: String, val type: Type, @ColumnInfo(name = "created_at") val createdAt: Date = Date(), @PrimaryKey(autoGenerate = true) var id: Int = 0, ) { enum class Type { FULL_TIME, PART_TIME, } }
@Dao
@Dao 標示一個 interface 或 abstract class 為 Data Access Object (DAO)。Data Access Object 主要是定義存取資料庫的 methods。如以下程式碼中,我們宣告了 interface EmployeeDao。每一個 method 可以宣告為 suspend function,也可以是一般 method。讓我們可以方便地整合到 Coroutine。
CRUD:@Insert、@Update、@Upsert、@Delete、@Query
@Insert 標示一個 method 為 insert method。onConflict 是指定當發生 conflict 時要採取的動作。可以參考 OnConflictStrategy 中所有的動作。
@Update 標示一個 method 為 update method。
@Upsert 標示一個 method 為 insert 或 update method。它會根據 primary key 檢查參數中的 entity 是否已經在資料庫。如果已經存在,它會更新此筆 entity。
@Delete 標示一個 method 為 delete method。
@Query 標示一個 method 為 query method。在 @Query 中,我們必須要撰寫 SQL statement,並且要 bind arguments。在 @Query 中,不是只能有 SELECT,也可以撰寫 DELETE。此外,回傳的值也可以是 Flow。
import androidx.room.* @Dao interface EmployeeDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(employee: Employee) @Update suspend fun update(employee: Employee) @Delete suspend fun delete(employee: Employee) @Query("SELECT * FROM employees") suspend fun findAll(): List<Employee> @Query("SELECT * FROM employees WHERE name = :name") suspend fun findByName(name: String): List<Employee> }
@Transaction
@Transaction 標示一個 method 為 transaction method。在 transaction method 中,所有的操作都在一個 transaction 中執行。
import androidx.room.* @Dao abstract class EmployeeDao { @Delete abstract suspend fun delete(employee: Employee) @Transaction suspend fun delete(list: List<Employee>) { list.forEach { delete(it) } } }
@RawQuery
@RawQuery 標示一個 method 可以執行傳入的 SQL 字串。當我們需要依據不同的情況,來產生出不同的 SQL query 字串時,我們就必須要自己產生 SQL query 字串。然後,再呼叫有標示 @RawQuery 的 method 來執行我們的 SQL query 字串。
import androidx.room.* import androidx.sqlite.db.SimpleSQLiteQuery @Dao abstract class EmployeeDao { @RawQuery abstract suspend fun execSelect(query: SimpleSQLiteQuery): List<Employee> suspend fun findByNameOptional(name: String?): List<Employee> { var sql = "SELECT * FROM employees" name?.let { sql += " WHERE name = $it" } val query = SimpleSQLiteQuery(sql) return execSelect(query) } }
@Database
@Database 標示一個 class 為 RoomDatabase。這個 class 必須為 abstract class,並且繼承 RoomDatabase,如下方的程式碼中的 EmployeeDatabase
。在 @Database 中,用 entities
來指定這個資料庫裡的資料表。
在 EmployeeDatabase
中,我們宣告一個 abstract method 叫 dao()
。我們不需要實作 dao()
,因為它會自動被產生出來。之後,我們會呼叫這個 dao()
來取得 EmployeeDao
,然後再執行 insert()
或 findAll()
等 methods。
然後,我們還要宣告一個 method 叫 getInstance()
來初始化 EmployeeDatabase 物件。當我們呼叫任何和 EmployeeDao 的 method 時,都必須要在非 UI thread 裡進行。不然會有 exception 丟出。但,如果你想要在 UI thread 裡呼叫 EmployeeDao 的 methods 時,那就要在建立 EmployeeDatabase 時,呼叫 .allowMainThreadQueries()
。
此外,我們還宣告了一個叫 getTestingInstance()
的 method。與 getInstance()
不同的是,它會初始化一個 in-memory 的資料庫,而不是一個資料庫檔案。這在 unit testing 的時候相當地方便,因為我們不需要在結束時,還要清理資料庫檔案。
import android.content.Context import androidx.annotation.VisibleForTesting import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters @Database(entities = [Employee::class], version = 1) @TypeConverters(Converters::class) abstract class EmployeeDatabase : RoomDatabase() { abstract fun dao(): EmployeeDao companion object { @Volatile private var INSTANCE: EmployeeDatabase? = null fun getInstance(context: Context, path: String): EmployeeDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, EmployeeDatabase::class.java, path, ) // .allowMainThreadQueries() .build() INSTANCE = instance instance } } @VisibleForTesting @Synchronized fun getTestingInstance(context: Context): EmployeeDatabase { return Room .inMemoryDatabaseBuilder(context.applicationContext, EmployeeDatabase::class.java) .build() } } }
@TypeConverters
在上面的程式碼中,還有一個 annotation 叫 @TypeConverters。當 Room 要將 Employee 寫入資料庫,或是從資料庫中讀取資料並將之轉成 Employee 時,Room 只知道要如何轉換 primitive types。 讓我們先回看一下 class Employee,它的 type
和 createdAt
都是 non-primitive type。所以,我們必須要告訴 Room 如何將這些 non-primitive type 的資料轉換成 primitive type。
@Entity(tableName = "employees") data class Employee( val name: String, val type: Type, @ColumnInfo(name = "created_at") val createdAt: Date = Date(), @PrimaryKey(autoGenerate = true) var id: Int = 0, ) { enum class Type { FULL_TIME, PART_TIME, } }
我們在 class Converters 中宣告了四個 methods。fromDate()
是告訴 Room 如何將 Date 轉換成基本型態。在這裡我們選擇將 Date 轉換成 Long,也就是 Date
的 timestamp。而,toDate()
則是告訴 Room 如何將 Long 轉換成 Date。fromType()
和 toType()
是告訴 Room 如何將 enum Employee.Type 和 String 之間的轉換。
每個 method 都要加上 annotation @TypeConverter。然後在 EmployeeDatabase 那邊,用 @TypeConverters,來指定用 class Converters。
import androidx.room.TypeConverter import java.util.* class Converters { @TypeConverter fun fromDate(value: Date) = value.time @TypeConverter fun toDate(value: Long) = Date(value) @TypeConverter fun fromType(value: Employee.Type) = value.name @TypeConverter fun toType(value: String) = Employee.Type.valueOf(value) }
除錯
當我們在除錯的時候,我們可能會想要知道最終被執行的 SQL statement。這時候,我們可以用 setQueryCallback()
將每次執行的 SQL statement 輸出。
@Database(entities = [Employee::class], version = 1) @TypeConverters(Converters::class) abstract class EmployeeDatabase : RoomDatabase() { abstract fun dao(): EmployeeDao companion object { fun getInstance(context: Context, path: String): EmployeeDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, EmployeeDatabase::class.java, path, ) .setQueryCallback({ sqlQuery, bindArgs -> println("SQL: $sqlQuery; Args: $bindArgs") }, Executors.newSingleThreadExecutor()) .build() INSTANCE = instance instance } } } }
範例
本章實作了一個簡單新增與查詢的表單系統,你可以下載完整的程式碼。以下只有顯示 EmployeeListViewModel
,來展示如何在 ViewModel 中存取資料庫。
import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class EmployeeListViewModel : ViewModel() { val employees: MutableLiveData<List<Employee>> = MutableLiveData() private lateinit var database: EmployeeDatabase fun initDatabase(context: Context) { val path = context.getDatabasePath("employee") database = EmployeeDatabase.getInstance(context, path.absolutePath) } suspend fun addEmployee(name: String, type: Employee.Type) = withContext(Dispatchers.IO) { val employee = Employee(name, type) database.dao().insert(employee) val list = database.dao().findAll() employees.postValue(list) } suspend fun searchByName(name: String) = withContext(Dispatchers.IO) { val list = if (name.isEmpty()) database.dao().findAll() else database.dao().findByName(name) employees.postValue(list) } }
如果你下載此範例並且執行之後,你可以在 Device File Explorer 中找到資料庫檔案。它的路徑會在 /data/data/com.waynestalk.example/databases/,這裡面會有三個檔案,分別為 employee、employee-shm、以及 employee-wal。每一個資料庫,Room 都會產生三個檔案。
結語
Room 把存取資料庫變得相當地容易,相比早期的 ORMLite。而且,它還整合了 suspend function。