Android Room Local Database (本地資料庫)

在建構現代化的 Android 應用程式時,結構化本地儲存 (Ordered Local Storage) 是提供離線功能、快取資料以及提升使用者體驗的關鍵。SQLite 雖然是 Android 內建的資料庫解決方案,但直接撰寫 SQL 語法不僅繁瑣,且缺乏編譯時期的安全性檢查。

Room 是 Google 官方提供的 ORM (Object Relational Mapping) 函式庫,它在 SQLite 之上建構了一個抽象層,讓開發者能以 Kotlin 物件的開發思維來操作資料庫,並在編譯階段自動檢查 SQL 語法。

Room 三大核心組件

  1. Entity (實體):定義資料表 (Table) 的結構,每個類別代表一個 Table,每個實例代表一筆 Record。
  2. DAO (Data Access Object):負責提供讀取與操作資料的方法,如查詢、新增、刪除。
  3. Database:資料庫持有者,作為管理中心連接 Entity 與 DAO 並建立資料庫實例。

基礎實作範例

1. 定義 Entity

我們使用 @Entity 標記類別,並指定主鍵 (Primary Key)。

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey(autoGenerate = true) 
    val id: Int = 0,
    
    @ColumnInfo(name = "full_name") 
    val name: String,
    
    val age: Int,
    
    // 記錄這筆資料的建立日期
    val createdAt: Long = System.currentTimeMillis() 
)

2. 定義 DAO

DAO 可以定義成 Interface。Room 支持回傳 Flow,這意味著當資料庫內容變動時,UI 會自動接收到最新資料。

@Dao
interface UserDao {
    // 取得所有使用者,回傳 Flow 以監聽即時更新
    @Query("SELECT * FROM users ORDER BY full_name ASC")
    fun getAllUsers(): Flow<List<UserEntity>>

    // 根據 ID 查詢,使用 suspend 進行單次異步查詢
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Int): UserEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: UserEntity)

    @Delete
    suspend fun deleteUser(user: UserEntity)
}

3. 定義 Database

// 指定包含的 Entity、版本號以及是否需要自動遷移
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

TypeConverters 處理複雜資料

Room 預設僅支援基礎型別。如果你需要儲存 Date 或自定義的 Object,則需要轉換器。

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

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

// 在 Database 類別上加上標註
@Database(...)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

進階資料表關聯 (Relationships)

Room 不允許實體之間的直接引用(避免遞迴查詢問題),而是透過 @Relation 註解來連結。

一對一 (One-to-One)

假設一個「使用者」擁有一個「個人檔案」。

data class UserAndLibrary(
    @Embedded val user: UserEntity,
    @Relation(
        parentColumn = "id", // User 的 PK
        entityColumn = "ownerId" // Profile 的 FK
    )
    val profile: ProfileEntity
)

一對多 (One-to-Many)

一個「使用者」擁有多個「播放清單」。

data class UserWithPlaylists(
    @Embedded val user: UserEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "userOwnerId"
    )
    val playlists: List<PlaylistEntity>
)

多對多 (Many-to-Many)

「歌曲」與「播放清單」的關係,通常需要一個中間表 (Cross-reference table)。

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

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

資料庫遷移 (Migrations)

當你修改 Entity(例如新增欄位)時,必須升級資料庫版本。

自動遷移 (Auto Migration - 推薦)

適用於簡單的新增欄位。

@Database(
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ],
    ...
)

手動遷移 (Manual Migration)

適用於複雜變更(如 Table 重新命名、資料型態轉換)。

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 使用原生 SQL 執行結構變更
        database.execSQL("ALTER TABLE users ADD COLUMN email TEXT DEFAULT '' NOT NULL")
    }
}

與 Hilt 整合 (Dependency Injection)

在專案中使用 Hilt 注入資料庫與 DAO 是目前 Android 開發的最佳實踐。

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "my_app.db"
        )
        // 將手動遷移加入建置流程
        .addMigrations(MIGRATION_1_2) 
        .build()
    }

    @Provides
    fun provideUserDao(database: AppDatabase): UserDao {
        return database.userDao()
    }
}

資料庫測試 (Testing Room)

測試資料庫時,為了避免污染真實數據,我們通常使用 In-memory database,這會在進程結束後自動清除。

@RunWith(AndroidJUnit4::class)
class UserDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: UserDao

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        // 建立內存資料庫
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
        dao = db.userDao()
    }

    @After
    fun closeDb() {
        db.close()
    }

    @Test
    fun writeUserAndReadInList() = runTest {
        val user = UserEntity(id = 1, name = "Mike", age = 25)
        dao.insertUser(user)
        val result = dao.getUserById(1)
        assertEquals(result?.name, "Mike")
    }
}

透過 Room,我們可以大幅減少處理資料持久化時的程式碼量,並透過強大的編譯檢查與關聯支援,打造更穩健的 Android 應用程式。