本文章將介紹如何在 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。









