Lưu dữ liệu trong cơ sở dữ liệu cục bộ bằng Room 2.x

Nếu ứng dụng của bạn xử lý lượng dữ liệu có cấu trúc với lượng không nhỏ, thì bạn có thể được hưởng nhiều lợi ích khi lưu dữ liệu đó trên máy. Trường hợp sử dụng phổ biến nhất là lưu các phần dữ liệu có liên quan vào bộ nhớ đệm. Bằng cách này, khi thiết bị không thể truy cập mạng, bạn vẫn duyệt qua được nội dung đó.

Thư viện lưu trữ dữ liệu Room cung cấp một lớp trừu tượng qua SQLite để cho phép bạn truy cập vào cơ sở dữ liệu một cách dễ dàng, đồng thời khai thác toàn bộ sức mạnh của SQLite.

Thiết lập Room 2.x

Để sử dụng Room 2.x trong ứng dụng, hãy thêm các phần phụ thuộc sau vào tệp build.gradle của ứng dụng:

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")
}

Thành phần chính

Room có 3 thành phần chính:

  • Lớp cơ sở dữ liệu lưu giữ cơ sở dữ liệu và đóng vai trò là điểm truy cập chính cho đường kết nối cơ bản đến dữ liệu cố định của ứng dụng.
  • Thực thể dữ liệu đại diện cho các bảng trong cơ sở dữ liệu của ứng dụng.
  • Đối tượng truy cập dữ liệu (DAO) cung cấp các phương thức mà ứng dụng của bạn có thể dùng để truy vấn, cập nhật, chèn và xoá dữ liệu trong cơ sở dữ liệu.

Hình 1 minh hoạ mối quan hệ giữa các thành phần của Room.

Hình 1. Sơ đồ cấu trúc thư viện Room.

Triển khai mẫu

// 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()

Xác định dữ liệu bằng thực thể

Mỗi thực thể Room đại diện cho một bảng trong cơ sở dữ liệu. Bạn xác định từng thực thể dưới dạng một lớp được chú thích bằng @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
)
  • Tên bảng và tên cột tuỳ chỉnh: Theo mặc định, Room sử dụng tên lớp làm tên bảng và tên thuộc tính làm tên cột. Để tuỳ chỉnh các thành phần này, hãy dùng thuộc tính tableName trong @Entity và chú thích @ColumnInfo(name = "...").
  • Khoá chính: Để xác định khoá chính, hãy sử dụng @PrimaryKey. Đối với các khoá kết hợp, hãy dùng thuộc tính primaryKeys của @Entity: @Entity(primaryKeys = ["firstName", "lastName"]).
  • Bỏ qua các trường: Để ngăn các trường được duy trì, hãy sử dụng @Ignore.

Trình chuyển đổi loại

Đôi khi, bạn cần lưu trữ các loại tuỳ chỉnh, chẳng hạn như Date, trong một cột duy nhất. Cung cấp các phương thức @TypeConverter để chuyển đổi các loại tuỳ chỉnh thành và từ các loại mà Room có thể duy trì.

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() { ... }

Truy cập dữ liệu bằng DAO

DAO xác định các phương thức tương tác với cơ sở dữ liệu. Chú giải giao diện hoặc lớp trừu tượng bằng @Dao.

Phương thức thuận tiện

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

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • Chèn: Phương thức chèn có thể trả về một Long đại diện cho mã nhận dạng hàng được chèn hoặc một List<Long> chứa mã nhận dạng của tất cả các hàng được chèn.
  • Cập nhật hoặc Xoá: Phương thức cập nhật hoặc xoá có thể trả về một Int đại diện cho số lượng hàng bị ảnh hưởng.

Phương thức truy vấn

Chú giải các phương thức bằng @Query để viết câu lệnh SQL. Room xác thực các truy vấn vào thời gian biên dịch.

@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>
}

Kiểu dữ liệu trả về nhiều bản đồ

Trong Room 2.4 trở lên, các phương thức truy vấn có thể trực tiếp trả về một đa ánh xạ bằng cách sử dụng kiểu Map:

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

Truy vấn DAO không đồng bộ

Để tránh tình trạng giao diện người dùng bị treo, các truy vấn cơ sở dữ liệu không thể chạy trên luồng chính. Thực hiện các truy vấn không đồng bộ bằng một trong các chế độ tích hợp sau:

Coroutine và Flow trong Kotlin

Yêu cầu phần phụ thuộc 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 với RxJava

Bạn phải sử dụng room-rxjava2 hoặc 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 với LiveData và Guava

Yêu cầu phải có room-guava cho ListenableFuture.

@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>
}

Xác định mối quan hệ trong Room 2.x

Để ngăn tải từng phần trên luồng giao diện người dùng, bạn không thể sử dụng các tham chiếu đối tượng trực tiếp giữa các thực thể. Thay vào đó, hãy xác định các mối quan hệ bằng cách sử dụng các lớp dữ liệu trung gian có @Relation.

Một với một

Mỗi người dùng chỉ có một thư viện.

@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>

Một với nhiều

Mỗi người dùng có thể có nhiều danh sách phát.

@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>
)

Nhiều-nhiều

Danh sách phát có thể có nhiều bài hát và bài hát có thể nằm trong nhiều danh sách phát. Cần có một bảng kết hợp.

@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>
)

Mối quan hệ lồng ghép

Truy vấn người dùng, danh sách phát của họ và tất cả bài hát trong những danh sách phát đó.

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

Quản lý cơ sở dữ liệu

Phần này đề cập đến nhiều khía cạnh của việc quản lý cơ sở dữ liệu Room, bao gồm cả chế độ xem cơ sở dữ liệu, dữ liệu điền sẵn và hoạt động di chuyển cơ sở dữ liệu.

Chế độ xem cơ sở dữ liệu

Đóng gói một truy vấn phức tạp vào một lớp được chú thích bằng @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() { ... }

Điền sẵn cơ sở dữ liệu

Điền dữ liệu vào cơ sở dữ liệu tại thời điểm khởi tạo từ một tệp tài sản hoặc hệ thống tệp.

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

Di chuyển

Khi bạn thay đổi giản đồ, hãy tăng phiên bản cơ sở dữ liệu và xác định một đối tượng 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()
  • Di chuyển tự động: Nếu sử dụng Room 2.4.0 trở lên, bạn có thể dùng @AutoMigration để tự động di chuyển các thay đổi cơ bản về giản đồ. Bạn cần đặt exportSchema thành true trong cấu hình cơ sở dữ liệu: @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]).
  • Dự phòng huỷ dữ liệu: Nếu bạn chấp nhận mất dữ liệu khi thiếu đường dẫn di chuyển, hãy gọi .fallbackToDestructiveMigration khi tạo cơ sở dữ liệu.

Kiểm thử các quá trình di chuyển

Để xác minh quá trình di chuyển, hãy sử dụng MigrationTestHelper từ cấu phần phần mềm room-testing. Để hỗ trợ việc này, hãy đảm bảo rằng bạn xuất các giản đồ trong cấu hình 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
    }
}

Di chuyển từ SQLite sang Room

Để di chuyển ứng dụng của bạn từ SQLite sang Room, hãy hoàn tất các bước sau:

  1. Cập nhật các phần phụ thuộc để thêm Room.
  2. Chú giải các lớp mô hình bằng @Entity, @PrimaryKey@ColumnInfo.
  3. Tạo DAO để thay thế các phương thức truy vấn trợ giúp.
  4. Tạo một lớp RoomDatabase tham chiếu đến các thực thể và DAO của bạn. Tăng số phiên bản.
  5. Xác định một đường dẫn di chuyển trống vì giản đồ không thay đổi, chỉ có khung thay đổi: kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. Cập nhật quá trình khởi tạo để sử dụng Room.databaseBuilder theo đường dẫn di chuyển.