Android Room:資料庫存取

Photo by Sidekix Media on Unsplash
Photo by Sidekix Media on Unsplash
本文章將介紹如何在 Android 中使用 Room 來存取資料庫。

本文章將介紹如何在 Android 中使用 Room 來存取資料庫。

完整程式碼可以在 下載。

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,它的 typecreatedAt 都是 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。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Hans-Jurgen Mager on Unsplash
Read More

Kotlin Coroutine 教學

Kotlin 的 coroutine 是用來取代 thread。它不會阻塞 thread,而且還可以被取消。Coroutine core 會幫你管理 thread 的數量,讓你不需要自行管理,這也可以避免不小心建立過多的 thread。
Read More