使用 Room 2.x 將資料儲存在本機資料庫

如果應用程式可以處理大量結構化資料,將可以在本機保存資料。最常見的用途是快取相關資料片段,這樣即使裝置無法存取網路,您仍可在離線時瀏覽這類內容。

Room 永久保存程式庫透過 SQLite 提供抽象層,可讓您順暢存取資料庫,同時充分發揮 SQLite 的效用。

設定 Room 2.x

如要在應用程式中使用 Room 2.x,請在應用程式的 build.gradle 檔案中新增下列依附元件:

dependencies {
    val room_version = "2.6.1"

    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")

    // To use Kotlin Symbol Processing (KSP)
    // ksp("androidx.room:room-compiler:$room_version")

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$room_version")

    // optional - RxJava2 support for Room
    implementation("androidx.room:room-rxjava2:$room_version")

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation("androidx.room:room-guava:$room_version")

    // optional - Test helpers
    testImplementation("androidx.room:room-testing:$room_version")
}

主要元件

Room 有三個主要元件:

  • 資料庫類別,用於保存資料庫並做為應用程式保留資料基礎連線的主要存取點。
  • 資料實體,代表應用程式資料庫中的資料表。
  • 資料存取物件 (DAO),提供應用程式可用於查詢、更新、插入及刪除資料庫中資料的方法。

圖 1 說明 Room 不同元件之間的關係。

圖 1. Room 程式庫架構圖。

實作範例

// Entity
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

// DAO
@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

// Usage
val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

使用實體定義資料

每個 Room 實體都代表資料庫中的一個資料表。您可以將每個實體定義為已加上 @Entity 註解的類別。

@Entity(tableName = "users")
data class User (
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Ignore val picture: Bitmap? = null
)
  • 自訂資料表和資料欄名稱:根據預設,Room 會使用類別名稱做為資料表名稱,並使用屬性名稱做為資料欄名稱。如要自訂這些項目,請使用 @Entity 中的 tableName 屬性和 @ColumnInfo(name = "...") 註解。
  • 主鍵:如要定義主鍵,請使用 @PrimaryKey。如果是複合鍵,請使用 @EntityprimaryKeys 屬性:@Entity(primaryKeys = ["firstName", "lastName"])
  • 忽略欄位:如要避免欄位持續存在,請使用 @Ignore

類型轉換器

有時您需要在單一資料欄中儲存自訂型別,例如 Date。提供 @TypeConverter 方法,將自訂型別轉換為 Room 可保存的型別,反之亦然。

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? = date?.time
}

// Register in your Database class
@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

使用 DAO 存取資料

DAO 會定義資料庫互動的方法。使用 @Dao 為介面或抽象類別加上註解。

便利方法

@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUsers(vararg users: User)

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • 插入:插入方法可以傳回代表插入資料列 ID 的 Long,或是包含所有插入資料列 ID 的 List<Long>
  • 更新或刪除:更新或刪除方法可以傳回 Int,代表受影響的資料列數量。

查詢方式

使用 @Query 為方法加上註解,即可編寫 SQL 陳述式。Room 會在編譯期間驗證查詢。

@Dao
interface UserDao {
    // Simple query
    @Query("SELECT * FROM user")
    fun loadAllUsers(): Array<User>

    // Return a subset of columns using a POJO or tuple
    @Query("SELECT first_name, last_name FROM user")
    fun loadFullName(): List<NameTuple>

    // Pass parameters
    @Query("SELECT * FROM user WHERE age > :minAge")
    fun loadAllUsersOlderThan(minAge: Int): Array<User>

    // Collection of parameters
    @Query("SELECT * FROM user WHERE region IN (:regions)")
    fun loadUsersFromRegions(regions: List<String>): List<User>

    // Join tables
    @Query("SELECT * FROM book INNER JOIN user ON user.id = book.user_id WHERE user.name = :userName")
    fun findBooksBorrowedByName(userName: String): List<Book>
}

多重對應傳回類型

在 Room 2.4 以上版本中,查詢方法可使用 Map 型別直接傳回多重對應:

@Query("SELECT * FROM user JOIN book ON user.id = book.user_id")
fun loadUserAndBookNames(): Map<User, List<Book>>

非同步 DAO 查詢

為避免 UI 凍結,資料庫查詢無法在主執行緒上執行。使用下列其中一種整合功能,以非同步方式執行查詢:

Kotlin 協同程式和 Flow

需要 room-ktx 依附元件。

@Dao
interface UserDao {
    // One-shot async query
    @Insert
    suspend fun insertUsers(vararg users: User)

    // Observable query using Flow
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flow<User>
}

Java 搭配 RxJava

必須提供「room-rxjava2」或「room-rxjava3」。

@Dao
interface UserDao {
    @Insert
    fun insertUsers(users: List<User>): Completable

    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flowable<User>
}

Java 搭配 LiveData 與 Guava

需要 ListenableFutureroom-guava

@Dao
interface UserDao {
    // LiveData for observable queries
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): LiveData<User>

    // Guava ListenableFuture for one-shot queries
    @Insert
    fun insertUsers(users: List<User>): ListenableFuture<Integer>
}

在 Room 2.x 中定義關係

如要避免在 UI 執行緒上延遲載入,您無法在實體之間使用直接物件參照。請改用中繼資料類別和 @Relation 定義關係。

一對一

每位使用者只有一個媒體庫。

@Entity
data class User(@PrimaryKey val userId: Long, val name: String)

@Entity
data class Library(@PrimaryKey val libraryId: Long, val userOwnerId: Long)

// Intermediate class
data class UserAndLibrary(
    @Embedded val user: User,
    @Relation(
         parentColumn = "userId",
         entityColumn = "userOwnerId"
    )
    val library: Library
)

// DAO Query
@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

一對多

每位使用者可以建立多個播放清單。

@Entity
data class Playlist(@PrimaryKey val playlistId: Long, val userCreatorId: Long)

data class UserWithPlaylists(
    @Embedded val user: User,
    @Relation(
          parentColumn = "userId",
          entityColumn = "userCreatorId"
    )
    val playlists: List<Playlist>
)

多對多

播放清單可包含許多歌曲,而每首歌曲也可能位於不同播放清單中。需要聯結資料表。

@Entity
data class Song(@PrimaryKey val songId: Long, val songName: String)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(val playlistId: Long, val songId: Long)

data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)

巢狀關係

查詢使用者、使用者的播放清單,以及這些播放清單中的所有歌曲。

data class UserWithPlaylistsAndSongs(
      @Embedded val user: User,
      @Relation(
          entity = Playlist::class,
          parentColumn = "userId",
          entityColumn = "userCreatorId"
      )
      val playlists: List<PlaylistWithSongs> // Nesting PlaylistWithSongs
  )

資料庫管理

本節涵蓋管理 Room 資料庫的各個層面,包括資料庫檢視區塊、預先填入資料和資料庫遷移。

資料庫檢視畫面

將複雜查詢封裝到加上 @DatabaseView 註解的類別中。

@DatabaseView("SELECT user.id, user.name, department.name AS departmentName FROM user INNER JOIN department ON user.departmentId = department.id")
data class UserDetail(val id: Long, val name: String, val departmentName: String)

// Register in Database class
@Database(entities = [User::class], views = [UserDetail::class], version = 1)
abstract class AppDatabase : RoomDatabase() { ... }

預先填入資料庫

在初始化時,從資產檔案或檔案系統填入資料庫。

Room.databaseBuilder(appContext, AppDatabase::class.java, "Sample.db")
    .createFromAsset("database/myapp.db")
    .build()

遷移

變更結構定義時,請增加資料庫版本,並定義 Migration 物件。

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
  }
}

Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database-name")
  .addMigrations(MIGRATION_1_2)
  .build()
  • 自動遷移:如果您使用 Room 2.4.0 以上版本,可以透過 @AutoMigration 自動遷移基本結構定義變更。這項操作需要將資料庫設定中的 exportSchema 設為 true@Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)])
  • 破壞性回溯:如果缺乏遷移路徑導致遺失資料,請在建構資料庫時呼叫 .fallbackToDestructiveMigration

測試遷移

如要驗證遷移作業,請使用 room-testing 構件中的 MigrationTestHelper。如要支援這項功能,請務必在 build.gradle 設定中匯出結構定義。

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    fun migrate1To2() {
        var db = helper.createDatabase("test-db", 1).apply {
            execSQL("INSERT INTO User VALUES (1, 'John')")
            close()
        }
        db = helper.runMigrationsAndValidate("test-db", 2, true, MIGRATION_1_2)
        // Verify data was migrated correctly
    }
}

從 SQLite 遷移至 Room

如要將應用程式從 SQLite 遷移至 Room,請完成下列步驟:

  1. 更新依附元件,加入 Room。
  2. 使用 @Entity@PrimaryKey@ColumnInfo 為模型類別加上註解
  3. 建立 DAO,取代輔助查詢方法。
  4. 建立 RoomDatabase 類別,參照您的實體和 DAO。 依累加原則設定版本號碼。
  5. 定義空白遷移路徑,因為結構定義不會變更,只有框架會變更: kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. 更新例項建立作業,以便透過遷移路徑使用 Room.databaseBuilder