前一天我們理解了 ClassroomScheduleRepository.kt如何包裝 DAO,讓 ViewModel 不直接接觸資料庫操作。今天我們要順著資料流繼續往下看核心資料庫的設定— AppDatabase.kt。
專案中的 SF.csv 與 ES.csv,是我們預先整理好的 114 學年度下學期大樓課表原始檔。然而,靜態 CSV 無法直接被 Room 操作,必須在資料庫初始化時解析並轉換為 ClassroomSchedule 實體(Entity)寫入資料庫。今天就來聊聊如何在 AppDatabase 中實現這個預填資料的流程。
一開始我對:「因為原始課表資料無法直接被 Room 操作,必須另外的檔案把這些 CSV 檔案放進去」,這部分的概念上大致理解:CSV 只是文字檔,Room 是 SQLite 的封裝,兩者中間需要一層「解析器(Parser)」把純文字轉換為 ClassroomSchedule 的 Entity 物件。
但當時我對具體運作仍有盲點:
匯入的時間點:
我一開始以為是在 MainActivity 啟動時手動寫程式碼去讀檔。但重讀後發現,專案是利用 RoomDatabase.Callback 將匯入邏輯綁定在資料庫自身的生命週期中。
onCreate 與 onOpen 的觸發機制:
原本以為只要靠 onCreate(資料庫首次建立時)預載一次就好;但回頭看程式碼才發現,我們連 onOpen(每次資料庫被開啟時)都呼叫了 triggerPopulate();配合 DAO 的 REPLACE 策略,等於每次開啟 App 都會重新對齊最新 CSV 資料。
非同步與執行緒安全:
讀取檔案與寫入數十間教室課表屬於 I/O 密集操作,若在主執行緒執行會造成畫面卡頓。重讀後發現專案使用了 CoroutineScope(Dispatchers.IO) 切換至背景執行緒,確保了 UI 的流暢度。
AppDatabase.kt 負責什麼和 Hermes Agent 一起重讀檔案後,我更知道AppDatabase.kt 是 RoomRush 的 Room Database 設定中心;它除了負責定義資料庫、還提供 DAO、建立資料庫單例,更重要的是把 assets 裡的 SF.csv 和 ES.csv 解析成 ClassroomSchedule 後寫入資料庫。
實際剖析程式碼後,重點核心可以分為以下四點:
abstract fun classroomScheduleDao(): ClassroomScheduleDao
ClassroomScheduleDao,後續 Repository 會透過這個 DAO 存取資料。@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"classroom_database"
)
.fallbackToDestructiveMigration()
.addCallback(DatabaseCallback(context))
.build()
INSTANCE = instance
instance
}
}
@Volatile 與 synchronized(this) 確保多執行緒下,整個 App 只建立一個資料庫實例(Singleton),避免重複建立耗費資源。private class DatabaseCallback(private val context: Context) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
triggerPopulate()
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
triggerPopulate()
}
private fun triggerPopulate() {
CoroutineScope(Dispatchers.IO).launch {
val dao = getInstance(context).classroomScheduleDao()
prePopulateDatabase(context, dao)
}
}
}
RoomDatabase.Callback 監聽資料庫生命週期—onCreate(資料庫初次建立)與 onOpen(資料庫每次開啟)。由於讀寫檔案屬於 I/O 密集型工作,特別使用 CoroutineScope(Dispatchers.IO) 切換至背景執行緒,避免主畫面卡住。private suspend fun prePopulateDatabase(context: Context, dao: ClassroomScheduleDao) {
val sfSchedules = parseCsv(context, "SF.csv")
val esSchedules = parseCsv(context, "ES.csv")
if (sfSchedules.isNotEmpty() || esSchedules.isNotEmpty()) {
dao.insertAll(sfSchedules + esSchedules)
}
}
assets 裡的 SF.csv 與 ES.csv,利用 drop(1) 跳過表頭並依照欄位順序解析為 ClassroomSchedule 物件清單。最後呼叫 dao.insertAll() 進行批次寫入(搭配 OnConflictStrategy.REPLACE 確保資料覆蓋為最新狀態)。App 啟動
↓
建立 AppDatabase
↓
取得 ClassroomScheduleDao
↓
讀取 SF.csv / ES.csv
↓
parseCsv()
↓
每一列 CSV → ClassroomSchedule
↓
dao.insertAll()
↓
Room Database
AppDatabase 是 RoomRush 的 Room Database 設定中心。
@Database 指定 ClassroomSchedule 為 Entity,提供 classroomScheduleDao() 讓 Repository 可以取得 DAO,並用 Singleton 確保 App 只建立一個資料庫實例。DatabaseCallback 會觸發 triggerPopulate(),在 Dispatchers.IO 背景執行緒讀取 SF.csv 和 ES.csv。parseCsv() 會略過 CSV 表頭,把每一列依照欄位順序轉成 ClassroomSchedule。dao.insertAll() 批次寫入資料庫。重構的核心,在於將預載改移至 onCreate 防止資料被洗掉;讀檔時可以先抓取第一列欄位名稱,建立索引對照表,讓 CSV 標頭名稱可以動態對齊欄位;並改用 Result 清楚回報錯誤而非默默吞掉。
讀到 AppDatabase 時,我終於把 RoomRush 的資料來源串起來了。
前面看到的 ClassroomSchedule、DAO 和 Repository,都是資料層的一部分;而 AppDatabase 則是把這些組合起來的資料庫中心。它不只建立 Room Database,還會在資料庫建立或開啟時,把 assets 裡的 CSV 讀進來,轉成 ClassroomSchedule 後寫入資料庫。
這個檔案也讓我看到幾個正式 App 需要注意的地方:例如 onOpen 每次開啟資料庫都會預載 CSV,如果管理者曾修改資料,可能被 CSV 覆蓋。另外,CSV 解析非常依賴欄位順序,只要欄位順序改變,資料就可能對錯欄位。最後,解析錯誤時直接回傳空清單雖然可以避免崩潰,但也可能讓錯誤被隱藏。這些都可以列為後續技術債觀察。
用一句話總結AppDatabase:
AppDatabase 建立 Room Database,提供 DAO,並在資料庫建立 / 開啟時把 SF.csv 和 ES.csv 解析成 ClassroomSchedule 寫入資料庫。
當我們把資料庫(Entity、DAO、Database)與畫面邏輯都串起來之後,下一個我們會介紹到:這些物件到底是誰負責「生」出來的?
ViewModel 需要 Repository,Repository 需要 DAO,而 DAO 又必須從 Database 取得;如果每個頁面都自己 new 一次,系統資源很快就會被用光。這時候就是 依賴注入(Dependency Injection) 就派上用場了。
下一篇,我們將解密 EmptyRoomFinderApp.kt 與 AppContainer.kt:看 App 如何打造一個「全域工具箱」,用 Context 依序組裝好資料庫(AppDatabase)與倉庫(ClassroomScheduleRepository),讓所有頁面都能隨取隨用。