Android Room Database: Kiến Thức Cần Biêt

Room Database là thư viện của Google hỗ trợ việc quản lý dữ liệu thông qua cơ sở dữ liệu (database) cho Android. Room Database được xây dựng trên nền tảng của SQLite chính vì vậy mọi tính năng của SQLite đều có thể được sử dụng trên Room Database một cách thuận tiện.

Trong Room Database có 3 định nghĩa quan trọng và cơ bản nhất: Entity, DAO, và Database

Ngoài ra chúng ta cũng có các định nghĩa khác để nâng cấp hiểu biết, ứng dụng của Room Db: TypeConverter, Migrate Database, Entity Relationship

Hỏi: Tại sao mình chỉ chọn Entity, DAODatabase là cơ bản mà không chọn Migrate Database, Entity Relationship hay TypeConverter?

Trả lời: Vì bạn không thể sử dụng Room Database nếu không đụng đến 3 định nghĩa cơ bản. Còn TypeConverter, Migrate Database hay Entity Relationship có thể skip, cheat bằng cách này hoặc cách khác.

Entity

Room Entity là các class đại diện cho 1 Table trong cơ sở dữ liệu.

Ví dụ ứng dụng TODO của bạn thiết kế ra 1 Table là Note có các cột và kiểu dữ liệu lần lượt là

ColumnData Type
idInteger
titleText
contentText
created_atDate
updated_atDate
Bảng mô phỏng dữ liệu của NOTE trong ứng dụng TODO – Khóa học Android Mastery

Để biểu diễn cấu trúc trên trong Room Entity ta cần đoạn code sau

@Entity(tableName = "notes")
data class Note(
		@PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    var id: Int = 0,
    @ColumnInfo(name = "title")
    var title: String,
    @ColumnInfo(name = "content")
    var content: String,
    @ColumnInfo(name = "created_at")
    var created_at: Long = System.currentTimeMillis(),
    @ColumnInfo(name = "updated_at")
    var updated_at: Long = System.currentTimeMillis()
)

DAO

DAO là interface nơi khai báo các câu query cần thiết để lấy dữ liệu của Entity từ Database. Trong cùng 1 DAO có thể làm data source cho một hoặc nhiều Entity khác nhau.

// Demo DAO sử dụng RxJava để query dữ liệu
@Dao
interface NotesDao {

    // insert notes
    @Insert(onConflict = OnConflictStrategy.REPLACE, entity = Note::class)
    fun insertNote(note: Note): Single<Long>

    // update notes
    @Update(onConflict = OnConflictStrategy.REPLACE, entity = Note::class)
    fun updateNotes(notes: Note): Single<Int>

    // get all notes from db
    @Query("SELECT * FROM notes ORDER BY date_updated DESC")
    fun getNotes(): Single<List<Note>>

    @Query("SELECT * FROM notes WHERE updated_at < :start ORDER BY updated_at DESC LIMIT :limit")
    fun getNotesInEarlier(start: Long, limit: Int): Single<List<Note>>

    // delete notes by id
    @Query("DELETE FROM notes where id=:id")
    fun deleteNote(id: Int): Single<Int>

    @Query("SELECT * FROM notes where id=:id")
    fun getNote(id: Int): Single<Note>
}
// Demo DAO sử dụng Kotlin suspend function
@Dao
interface NotesDao {

    // insert notes
    @Insert(onConflict = OnConflictStrategy.REPLACE, entity = Note::class)
    suspend fun insertNote(note: Note): Long

    // update notes
    @Update(onConflict = OnConflictStrategy.REPLACE, entity = Note::class)
    suspend fun updateNotes(notes: Note): Int

    // get all notes from db
    @Query("SELECT * FROM notes ORDER BY date_updated DESC")
    suspend fun getNotes(): List<Note>

    @Query("SELECT * FROM notes WHERE updated_at < :start ORDER BY updated_at DESC LIMIT :limit")
    suspend fun getNotesInEarlier(start: Long, limit: Int): List<Note>

    // delete notes by id
    @Query("DELETE FROM notes where id=:id")
    suspend fun deleteNote(id: Int): Int

    @Query("SELECT * FROM notes where id=:id")
    suspend fun getNote(id: Int): Note
}

Chúng ta còn có thể sử dụng Flow để lấy dữ liệu từ Database. Cách này sẽ được hướng dẫn cụ thể trong những bài viết kỹ thuật cao hơn bạn nhé!

DAO không chỉ hỗ trợ viết query trên 1 Entity, trong các dự án phức tạp bạn hoàn toàn có thể sử dụng các câu lệnh như JOIN, GROUP BY trên nhiều Table khác nhau.

Chi tiết về việc thiết kế Entity cũng như DAO cho dự án phức tạp cần JOIN, GROUP BY nhiều Entity trong 1 câu query bạn có thể tham khảo khóa học Lập trình Android của Danh nhé.

Database

Khi đã có thiết kế của Entity, và DAO. Database chính là class bạn cần tạo để làm nguồn gốc lưu trữ, truy xuất dữ liệu trên Android app của mình.

Hãy lưu ý rằng Database là đại diện cho 1 database connection. Nên thông thường chúng ta sẽ chỉ có 1 Database Instance trong 1 ứng dụng mà thôi.

const val VERSION = 1
@Database(
    entities = [Note::class],
    version = VERSION,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun getNoteDao(): NoteDAO
    companion object {
        @Volatile
        private var instance: AppDatabase? = null
        private val LOCK = Any()

        // Check for DB instance if not null then get or insert or else create new DB Instance
        operator fun invoke(context: Context) = instance ?: synchronized(LOCK) {
            instance ?: createDatabase(context).also { instance = it }
        }

        // create db instance
        private fun createDatabase(context: Context) = Room.databaseBuilder(
            context.applicationContext,
            AppDatabase::class.java,
            "app_db.db"
        ).build()
    }
}

TypeConverter

Trong Entity Note chúng ta có 2 Column thời gian

@ColumnInfo(name = "created_at")
var created_at: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at")
var updated_at: Long = System.currentTimeMillis()

Về cơ bản biểu diễn thời gian trong database dùng 1 số Long là hợp lý. Tuy nhiên, khi đưa lên application sử dụng ta cần 1 bước Convert sang kiểu Date để thuận tiện hợp. Room Database hiểu được nhu cầu này nên đã tạo ra TypeConverter

Cách sử dụng TypeConverter : Tạo một class đặt tên là DateConverter

class DateConverter {
    @TypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }
    @TypeConverter
    fun dateToTimestamp(date: Date?): Long? {
        return date?.time
    }
}

Cập nhật lại class AppDatabase

@TypeConverters(value = [DateConverter::class])
abstract class AppDatabase : RoomDatabase()

Cập nhật lại Column trong Entity Note

@ColumnInfo(name = "created_at")
var created_at: Date = Calendar.getInstance().time,
@ColumnInfo(name = "updated_at")
var updated_at: Date = Calendar.getInstance().time

Tương tự nếu chúng ta có nhiều Converter thì sẽ tạo và add trên annotation của AppDatabase

Relationships

Room Database hỗ trợ việc query trên các bảng có Relationships với nhau rất mạnh. Hãy cùng tham khảo ví dụ sau.

Bài toán: Ứng dụng TODO của bạn có chức năng đọc và ghi các ghi chú (Note) của User. Ứng dụng có các Label để đại diện cho topic mà Note đang hướng tới.

Trong thực tế mỗi Note có thể liên quan đến nhiều Label, và ngược lại mỗi Label cũng có thể chứa nhiều Note bên trong nó.

Chúng ta có cách thiết kế sau.

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    val id: Int = 0,
    @ColumnInfo(name = "title")
    val title: String,
    @ColumnInfo(name = "content")
    val content: String,
		@ColumnInfo(name = "created_at")
		var created_at: Date = Calendar.getInstance().time,
		@ColumnInfo(name = "updated_at")
		var updated_at: Date = Calendar.getInstance().time
)

@Entity(
    tableName = "labels",
    indices = [androidx.room.Index(value = ["name"], unique = true)]
)
data class Label(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    val id: Int = 0,
    @ColumnInfo(name = "name")
    val name: String
)

@Entity(tableName = "note_labels",
    indices = [androidx.room.Index(value = ["note_id", "label_id"], unique = true)])
data class NoteLabel(
    @PrimaryKey(autoGenerate = true)
    val id: Int  = 0,
    @ColumnInfo(name = "note_id")
    val noteId: Int,
    @ColumnInfo(name = "label_id")
    val labelId: Int
)

Hỏi:

  • Tại sao phải tách làm 3 bảng? Chúng ta có đang làm phức tạp hóa vấn đề không?
  • @PrimaryKey là gì?
  • indicies là gì?

Tiếp theo, sau khi đã có các lớp Entity đại diện cho dữ liệu. Ta tiến hành viết query trong DAO

@Query("SELECT * FROM labels " +
        "JOIN note_labels ON labels.id = note_labels.label_id " +
        "JOIN notes ON note_labels.note_id = notes.id")
suspend fun getAllLabelsWithNotes(): Map<Label, List<Note>>

@Query("SELECT * FROM notes " +
        "JOIN note_labels ON notes.id = note_labels.note_id " +
        "JOIN labels ON note_labels.label_id = labels.id")
suspend fun getAllNotesWithLabels(): Map<Note, List<Label>>

Hỏi:

  • JOIN là gì?
  • Tại sao lại trả về kiểu Map
  • Nếu không dùng JOIN thì dùng cách nào?

Migrate Database

Trong quá trình phát triển phần mềm, cơ sở dữ liệu sẽ phải thay đổi liên tục để đồng bộ với các yêu cầu hệ thống cũng như tính năng.

Để hỗ trợ được các user đang sử dụng Android của bạn khi nâng cấp lên phiên bản mới vẫn có thể giữ được các dữ liệu trong database hiện tại cũng họ, đồng thời database đó cũng tương thích với logic của app ở phiên bản mới ta cần 1 bước gọi là Migrate Database

Trong Migrate Database bạn sẽ viết các SQL script với mục đích cập nhật, thay đổi csdl từ phiên bản cũ sang phiên bản mới.

Một số case study của Migrate Database

  • Thêm / Xóa một hoặc nhiều Column vào 1 Table (Thêm variable cho Entity)
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE entity ADD COLUMN new_column TEXT NOT NULL DEFAULT ''")
    }
}
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE entity DROP COLUMN old_column")
    }
}
  • Thay đổi tên của 1 Column (Rename của variable trong Entity nhưng vẫn giữ giá trị cũ)
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE entity RENAME COLUMN old_column to new_column")
    }
}
  • Thêm / Xóa/ Rename 1 Table trong Database
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
              "CREATE TABLE new_entity (id INTEGER PRIMARY KEY NOT NULL, title TEXT, description TEXT, date INTEGER)"
          )
    }
}
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Drop the table
        database.execSQL("DROP TABLE old_table")
    }
}
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // Rename the table
        database.execSQL("ALTER TABLE old_table RENAME TO new_table")
    }
}