簡介 Room 與 Flow

1. 事前準備

先前的程式碼研究室中,您已瞭解關聯資料庫的基本概念,並學到如何使用 SQL 指令 (SELECT、INSERT、UPDATE 和 DELETE) 讀取及寫入資料。學習使用關聯資料庫是整個程式設計流程中必備的一大技能。瞭解關聯資料庫的運作方式,對於確保 Android 應用程式的資料持續性而言,也是十分重要的一環,本課程稍後將為您說明實作方式。

使用名為 Room 的程式庫即可輕鬆在 Android 應用程式中使用資料庫。Room 也稱為 ORM (物件關聯對應) 程式庫,顧名思義,就是將關聯資料庫中的資料表對應至可在 Kotlin 程式碼中使用的物件。在本課程中,您只需要關注讀取資料。使用預先填入的資料庫,您就能載入公車抵達時間資料表中的資料,並在 RecyclerView 中呈現這些資料。

70c597851eba9518.png

在課程中,您將瞭解使用 Room 的基礎知識,包括資料庫類別、DAO、實體和檢視畫面模型。此外,課程中也會介紹 ListAdapter 類別,讓您透過另一種方式在 RecyclerView 中呈現資料;以及 Flow,這是一種類似於 LiveData 的 Kotlin 語言功能,可使使用者介面針對資料庫變更做出回應。

必要條件

  • 熟悉物件導向的程式設計,以及如何在 Kotlin 中使用類別、物件和繼承機制。
  • SQL 基礎知識程式碼研究室中有關關聯資料庫和 SQL 的基本知識。
  • 使用過 Kotlin 協同程式。

課程內容

完成本課程後,您應能夠:

  • 將資料庫資料表以 Kotlin 物件 (實體) 表示。
  • 定義要用於在應用程式中使用 Room 的資料庫類別,並從檔案預先填入資料庫。
  • 定義 DAO 類別,並使用 SQL 查詢從 Kotlin 程式碼存取資料庫。
  • 定義檢視畫面模型,以允許使用者介面與 DAO 互動。
  • 瞭解如何在回收器檢視畫面中使用 ListAdapter。
  • 瞭解 Kotlin Flow 的基本概念,以及學習如何實際運用,讓使用者介面對基礎資料的變更做出回應。

建構項目

  • 使用 Room 讀取預填資料庫的資料,再透過簡易的公車時刻表應用程式,以回收器檢視畫面展示。

2. 立即開始

在本程式碼研究室中,您要使用的應用程式稱為 Bus Schedule。此應用程式會顯示公車站清單,以及從最早到最晚的抵達時間。

70c597851eba9518.png

輕觸第一個畫面中的任意一列可開啟新畫面,只顯示所選公車站的公車即將抵達時間。

f477c0942746e584.png

公車站資料來自應用程式預先封裝的資料庫。不過,在目前的狀態下,應用程式首次執行時不會顯示任何內容。您的工作是整合 Room,讓應用程式顯示預先填入的抵達時間資料庫。

  1. 前往專案指定的 GitHub 存放區頁面。
  2. 驗證分支版本名稱與程式碼研究室中指定的分支版本名稱相符。例如,在下方螢幕截圖中,分支版本名稱為「main」。

1e4c0d2c081a8fd2.png

  1. 在 GitHub 的專案頁面中,按一下「Code」按鈕,系統會隨即顯示彈出式視窗。

1debcf330fd04c7b.png

  1. 在彈出式視窗中,按一下「Download ZIP」按鈕,將專案儲存至電腦。等待下載作業完成。
  2. 在電腦上尋找該檔案 (可能位於「下載」資料夾中)。
  3. 按兩下 ZIP 檔案,將檔案解壓縮。這項操作會建立含有專案檔案的新資料夾。

在 Android Studio 中開啟專案

  1. 啟動 Android Studio。
  2. 在「Welcome to Android Studio」視窗中,按一下「Open」

d8e9dbdeafe9038a.png

注意:如果 Android Studio 已開啟,請改為依序選取「File」>「Open」選單選項。

8d1fda7396afe8e5.png

  1. 在檔案瀏覽器中,前往已解壓縮的專案資料夾所在的位置 (可能位於「Downloads」資料夾中)。
  2. 按兩下該專案資料夾。
  3. 等待 Android Studio 開啟專案。
  4. 按一下「Run」按鈕 8de56cba7583251f.png 即可建構並執行應用程式,請確認應用程式的建構作業符合預期。

3. 新增 Room 依附元件

和任何其他程式庫一樣,您必須先新增必要的依附元件,才能在 Bus Schedule 應用程式中使用 Room。這只需要兩個小幅變更,每個 Gradle 檔案中各一個。

  1. 在專案層級的 build.gradle 檔案中,請在 ext 區塊中定義 room_version
ext {
   kotlin_version = "1.6.20"
   nav_version = "2.4.1"
   room_version = '2.4.2'
}
  1. 在應用程式層級的 build.gradle 檔案中,請於依附元件清單的結尾新增下列依附元件。
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
  1. 請同步處理變更並建構專案,確認是否已正確新增依附元件。

在接下來的幾個頁面中,我們會介紹將 Room 整合至應用程式所需的元件:模型、DAO、檢視畫面模型,以及資料庫類別。

4. 建立實體

在先前的程式碼研究室中瞭解關聯資料庫後,您便知道系統如何將資料整理成含有多欄的資料表,其中每欄都代表特定資料類型的特定屬性。如同 Kotlin 中的類別為每個物件都提供一個範本,資料庫中的資料表也會為其中每個項目或每列各提供一個範本。因此,Kotlin 類別可用於代表資料庫中的每個資料表,這點並不讓人意外。

使用 Room 時,每個資料表都以一個類別表示。在 Room 等 ORM (物件關聯性對應) 程式庫中,通常稱為「模型類別」「實體」

Bus Schedule 應用程式的資料庫只包含「schedule」這一個資料表,當中包含公車抵達的部分基本資訊。

  • id:提供專屬 ID 做為主鍵的整數
  • stop_name:字串
  • arrival_time:整數

請注意,資料庫中使用的 SQL 類型實際上對於 IntINTEGER,對於 StringTEXT。不過,使用 Room 時,您應該只在定義模型類別時考慮 Kotlin 類型。系統會自動將模型類別的資料類型對應至資料庫使用的資料類型。

如果專案含有多個檔案,建議您將檔案整理成不同的檔案包,以便為各個類別提供更好的存取權控管,並更輕鬆地找出相關類別。如要為「schedule」資料表建立實體,請在 com.example.busschedule 檔案包中加入名為 database 的新檔案包。在這個檔案包中,為實體新增名為 schedule 的新檔案包。接著,在 database.schedule 檔案包中建立名為 Schedule.kt 的新檔案,並定義名為 Schedule 的資料類別。

data class Schedule(
)

如 SQL 基本概念課程所述,資料表應有一個主鍵,用來識別每個資料列。您要新增至 Schedule 類別的第一個屬性是整數,代表專屬 ID。新增一個新屬性,並為其標示 @PrimaryKey 註解。此項操作會指示 Room 在插入新列時,將這個屬性視為主鍵。

@PrimaryKey val id: Int

為公車站的名稱新增一欄。該欄的類型應為 String。如果是新欄,則需要新增 @ColumnInfo 註解來指定該欄的名稱。通常,與 Kotlin 屬性使用的 lowerCamelCase 不同,SQL 資料欄名稱的字詞以底線分隔。我們也不允許這個資料欄中的值為空值,請使用 @NonNull 註解加以標示。

@NonNull @ColumnInfo(name = "stop_name") val stopName: String,

抵達時間會在資料庫中以整數表示。這是 Unix 時間戳記,可轉換成可用日期。雖然不同版本的 SQL 都提供了轉換日期的方式,但您仍可按照自己的需要使用 Kotlin 日期格式設定函式。將下列 @NonNull 欄新增至模型類別。

@NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int

最後,為了讓 Room 辨識出這個類別可用於定義資料庫資料表,您需要為類別本身新增一個註解。在類別名稱之前的單獨行中新增 @Entity

根據預設,Room 會使用類別名稱做為資料庫資料表的名稱。因此,目前類別定義的資料表名稱就是 Schedule。另外,您也可以選擇指定 @Entity(tableName="schedule"),但由於 Room 查詢不區分大小寫,因此可以不使用明確定義小寫的資料表名稱。

現在,時間表實體的類別應如下所示。

@Entity
data class Schedule(
   @PrimaryKey val id: Int,
   @NonNull @ColumnInfo(name = "stop_name") val stopName: String,
   @NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int
)

5. 定義 DAO

需要新增以整合 Room 的下一個類別是 DAO。DAO 代表資料存取物件,是一種可供存取資料的 Kotlin 類別。具體來說,DAO 包含用來讀取及操控資料的函式。在 DAO 上呼叫函式相當於在資料庫上執行 SQL 指令。事實上,DAO 函式就像您在此應用程式中定義的函式一樣,通常會指定 SQL 指令,以便您準確指定希望該函式執行的操作。定義 DAO 時,在先前程式碼研究室中瞭解的 SQL 知識可派上用場。

  1. 請為時間表實體新增 DAO 類別。在 database.schedule 檔案包中,建立名為 ScheduleDao.kt 的新檔案,並定義名為 ScheduleDao 的介面。與 Schedule 類別相似,您必須加上註解 (這次是 @Dao),才能在 Room 中使用介面。
@Dao
interface ScheduleDao {
}
  1. 應用程式有兩個畫面,每個畫面都需要不同的查詢。第一個畫面會依抵達時間遞增排序所有公車站。在這種情況下,查詢只需要取得所有資料欄,並加入適當的 ORDER BY 子句。查詢以傳遞至 @Query 註解的字串形式指定。定義函式 getAll(),該函式會傳回 Schedule 的清單,其中包括 @Query 註解,如下所示。
@Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
fun getAll(): List<Schedule>
  1. 對於第二個查詢,同樣建議您從時間表資料表中選取所有資料欄。不過請注意,您只需符合所選停靠站名稱的結果,因此請新增 WHERE 子句。如要參照查詢的 Kotlin 值,請在查詢前加上冒號 (:) (例如來自函式參數的 :stopName)。和以前一樣,結果會依抵達時間遞增排序。定義 getByStopName() 函式,該函式採用名為 stopName 的參數 String,並傳回 Schedule 物件的 List,包含 @Query 註解,如下所示。
@Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
fun getByStopName(stopName: String): List<Schedule>

6. 定義 ViewModel

現在,DAO 已設定完畢,從技術面來說,您已備齊所有必要項目,必須開始透過片段存取資料庫。然而,雖然這在理論上可行,但通常不被視為最佳做法。這是因為在較複雜的應用程式中,可能有多個畫面只會存取資料的特定部分。雖然 ScheduleDao 相對簡單,但在面對兩個或更多不同的畫面時,很容易會失控。舉例來說,DAO 看起來大致如下:

@Dao
interface ScheduleDao {

    @Query(...)
    getForScreenOne() ...

    @Query(...)
    getForScreenTwo() ...

    @Query(...)
    getForScreenThree()

}

雖然畫面 1 的程式碼可以存取 getForScreenOne(),但該程式碼不適合存取其他方法。最佳做法是將顯示在檢視畫面的 DAO 部分分隔成獨立類別,稱為「檢視畫面模型」。這是行動應用程式中常見的架構模式。使用檢視畫面模型有助於更清晰地區分應用程式使用者介面及其資料模型的程式碼。也有助於單獨測試程式碼的各個部分,隨著您繼續 Android 開發之旅,之後便會深入探索這個主題。

ee2524be13171538.png

如果使用檢視畫面模型,您可以利用 ViewModel 類別。ViewModel 類別用於儲存與應用程式使用者介面相關的資料,並且具有生命週期感知特性,對於生命週期事件的回應與活動或片段極為相似。如果畫面旋轉等生命週期事件會導致活動或片段被刪除並重新建立,則不需要重新建立相關聯的 ViewModel。您無法直接存取 DAO 類別,因此最好使用 ViewModel 子類別,將載入資料工作與活動或片段區分開。

  1. 如要建立檢視畫面模型類別,請在名為 viewmodels 的新檔案包中建立名為 BusScheduleViewModel.kt 的新檔案。定義檢視畫面模型的類別。該類別應採用 ScheduleDao 類型的單一參數。
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
  1. 由於這個檢視畫面模型會同時用於兩個畫面,因此您需要新增方法來取得完整時間表,以及依照停靠站名稱進行篩選的時間表。做法是呼叫 ScheduleDao 的對應方法。
fun fullSchedule(): List<Schedule> = scheduleDao.getAll()

fun scheduleForStopName(name: String): List<Schedule> = scheduleDao.getByStopName(name)

雖然已經完成檢視畫面模型的定義,但您不能直接將 BusScheduleViewModel 例項化,並預期一切運作正常。由於 ViewModel 類別 BusScheduleViewModel 必須有生命週期感知特性,因此應以可回應生命週期事件的物件進行例項化。如果直接在其中一個片段中執行例項化,則片段物件必須處理一切 (包括所有記憶體管理工作),而這超出應用程式程式碼的工作範圍。您可以改為建立名為「工廠」的類別,該類別會替您將檢視畫面模型物件例項化。

  1. 如要建立工廠,請在檢視畫面模型類別下方建立繼承自 ViewModelProvider.Factory 的新類別 BusScheduleViewModelFactory
class BusScheduleViewModelFactory(
   private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
}
  1. 您只需要一個樣板程式碼,就能正確對檢視畫面模型執行個體化。您不必直接初始化類別,而是可以覆寫名為 create() 的方法,該方法會傳回 BusScheduleViewModelFactory 以及一些錯誤檢查。在 BusScheduleViewModelFactory 類別中實作 create(),如下所示。
override fun <T : ViewModel> create(modelClass: Class<T>): T {
       if (modelClass.isAssignableFrom(BusScheduleViewModel::class.java)) {
           @Suppress("UNCHECKED_CAST")
           return BusScheduleViewModel(scheduleDao) as T
       }
       throw IllegalArgumentException("Unknown ViewModel class")
   }

現在您可以使用 BusScheduleViewModelFactory.create()BusScheduleViewModelFactory 物件執行個體化,這樣一來,您的檢視畫面模型便可具有生命週期感知特性,而無需片段直接處理這項工作。

7. 建立資料庫類別及預先填入資料庫

目前您已定義模型、DAO 及檢視畫面模型,以供片段存取 DAO,但您還需要指示 Room 如何處理所有這些類別。此時 AppDatabase 類別便可派上用場。一款使用 Room 的 Android 應用程式 (例如您自己的應用程式),可分類 RoomDatabase 類別,並具有幾項主要工作。在您的應用程式中,AppDatabase 需要:

  1. 指定資料庫中定義的實體。
  2. 提供對各個 DAO 類別的單個執行個體的存取。
  3. 執行任何其他設定,例如預先填入資料庫。

您可能想知道為什麼 Room 無法找到所有實體和 DAO 物件,很有可能是您的應用程式擁有多個資料庫,或是存在許多場景,在這些場景下,程式庫無法假設您或開發人員的意圖。透過 AppDatabase 類別,您可完全控管模型、DAO 類別,以及您想執行的任何資料庫設定。

  1. 如要新增 AppDatabase 類別,請在資料庫檔案包中,建立名為 AppDatabase.kt 的新檔案,並定義繼承自 RoomDatabase 的新抽象類別 AppDatabase
abstract class AppDatabase: RoomDatabase() {
}
  1. 資料庫類別可方便其他類別存取 DAO 類別。新增一個抽象函式,該函式會傳回 ScheduleDao
abstract fun scheduleDao(): ScheduleDao
  1. 使用 AppDatabase 類別時,建議您確保畫面上只有一個資料庫執行個體,以避免出現競爭狀況或其他潛在問題。執行個體儲存在隨附物件中,您也必須備妥一個方法,該方法不是傳回現有執行個體,就是首次建立資料庫。必須在隨附物件中定義此項。請在 scheduleDao() 函式正下方新增下列 companion object
companion object {
}

companion object 中,新增類型為 AppDatabase 的屬性 INSTANCE。這個值最初設為 null,因此類型標有 ? 標記。此外還標有 @Volatile 標記。本課程將詳細說明如何使用揮發性屬性。不過,還是建議您將其用於 AppDatabase 執行個體,以免發生潛在錯誤。

@Volatile
private var INSTANCE: AppDatabase? = null

INSTANCE 屬性下方,定義一個函式以傳回 AppDatabase 執行個體:

fun getDatabase(context: Context): AppDatabase {
    return INSTANCE ?: synchronized(this) {
        val instance = Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app_database")
            .createFromAsset("database/bus_schedule.db")
            .build()
        INSTANCE = instance

        instance
    }
}

getDatabase() 的實作中,您可以使用 Elvis 運算子傳回資料庫的現有執行個體 (如果已有執行個體),或視需求首次建立資料庫。在這個應用程式中,由於系統已預先填入資料,您也可呼叫 createFromAsset() 來載入現有資料。您可以在專案的 assets.database 檔案包中找到 bus_schedule.db 檔案。

  1. 資料庫類別就像模型類別和 DAO 一樣,需要註解來提供特定資訊。所有實體類型 (您使用 ClassName::class 存取類型本身) 都會列在陣列中。資料庫也會提供版本編號,請將這個值設為 1。請按照下列方式新增 @Database 註解。
@Database(entities = arrayOf(Schedule::class), version = 1)

您已建立 AppDatabase 類別,只要再完成一個步驟就能開始使用。您需要提供 Application 類別的自訂子類別,並建立 lazy 屬性來存放 getDatabase() 的結果。

  1. com.example.busschedule 檔案包中,新增名為 BusScheduleApplication.kt 的檔案,並建立繼承自 ApplicationBusScheduleApplication 類別。
class BusScheduleApplication : Application() {
}
  1. 新增 AppDatabase 類型的資料庫屬性。這個屬性應延遲處理,並傳回在 AppDatabase 類別上呼叫 getDatabase() 的結果。
class BusScheduleApplication : Application() {
   val database: AppDatabase by lazy { AppDatabase.getDatabase(this) }
  1. 最後,為了確保使用 BusScheduleApplication 類別 (而非預設的基礎類別 Application),您必須稍微變更資訊清單。在 AndroidMainifest.xml 中,將 android:name 屬性設為 com.example.busschedule.BusScheduleApplication
<application
    android:name="com.example.busschedule.BusScheduleApplication"
    ...

這時就可以設定應用程式的模型了。您可以開始透過使用者介面使用 Room 的資料。在接下來幾頁中,您需要為應用程式的 RecyclerView 建立 ListAdapter,藉此顯示公車時刻表資料,並以動態方式回應資料變更。

8. 建立 ListAdapter

現在,我們需要完成一些艱鉅的工作,並將模型彙整到檢視畫面中。以往使用 RecyclerView 時,您需要使用 RecyclerViewAdapter 可顯示靜態資料清單。儘管特別適用於 Bus Schedule 等應用程式,但使用資料庫時,往往遇到的都是即時處理資料的變更。即使只有一個項目的內容有變更,系統仍會重新整理整個回收器的檢視畫面。但對大多數使用持續性的應用程式來說,這種做法並不夠。

動態變更清單的替代方案稱為 ListAdapterListAdapter 使用 AsyncListDiffer 來判斷舊資料清單和新資料清單之間的差異。之後,系統再根據這兩份清單之間的差異來更新回收器檢視畫面。因此,處理頻繁更新的資料時,回收器檢視畫面的執行效能會較高,就像在資料庫應用程式中經常遇到的情況一樣。

f59cc2fd4d72c551.png

由於這兩個畫面的使用者介面都相同,因此您只需要建立可同時用於這兩個畫面的單個 ListAdapter 即可。

  1. 建立新檔案 BusStopAdapter.ktBusStopAdapter 類別,如圖所示。類別會展開一個通用 ListAdapter,其中會列出使用者介面的 Schedule 物件清單和 BusStopViewHolder 類別。對於 BusStopViewHolder,您也會傳遞即將定義的 DiffCallback 類型。BusStopAdapter 類別本身也會採用參數 onItemClicked()。系統會在第一個畫面選取項目時,使用此功能來處理導覽,但在第二個畫面中,您只需傳遞空白函式即可。
class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
}
  1. 與回收器檢視畫面轉接器類似,您需要具備檢視畫面容器,才能在程式碼中存取透過版面配置檔案建立的檢視畫面。儲存格版面配置已建立。只要建立 BusStopViewHolder 類別 (如圖所示) 並實作 bind() 函式,即可將 stopNameTextView 的文字設為公車站名稱,並將 arrivalTimeTextView 的文字設為格式化日期。
class BusStopViewHolder(private var binding: BusStopItemBinding): RecyclerView.ViewHolder(binding.root) {
    @SuppressLint("SimpleDateFormat")
    fun bind(schedule: Schedule) {
        binding.stopNameTextView.text = schedule.stopName
        binding.arrivalTimeTextView.text = SimpleDateFormat(
            "h:mm a").format(Date(schedule.arrivalTime.toLong() * 1000)
        )
    }
}
  1. 覆寫並實作 onCreateViewHolder(),加載版面配置,並將 onClickListener() 設定為針對目前位置的項目呼叫 onItemClicked()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BusStopViewHolder {
   val viewHolder = BusStopViewHolder(
       BusStopItemBinding.inflate(
           LayoutInflater.from( parent.context),
           parent,
           false
       )
   )
   viewHolder.itemView.setOnClickListener {
       val position = viewHolder.adapterPosition
       onItemClicked(getItem(position))
   }
   return viewHolder
}
  1. 覆寫並實作 onBindViewHolder(),同時在指定位置繫結檢視畫面。
override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
   holder.bind(getItem(position))
}
  1. 記得您為 ListAdapter 指定的 DiffCallback 類別嗎?這只是一個物件,能夠協助 ListAdapter 在更新清單時確定新舊清單中哪些項目不同。為此,有以下兩種方法:areItemsTheSame() 透過僅檢查 ID 來確認物件 (在此案例中,則為資料庫的資料列) 是否相同。areContentsTheSame() 會檢查所有屬性 (不只是 ID) 是否相同。這些方法可讓 ListAdapter 判斷已插入、更新及刪除哪些項目,以便更新相應使用者介面。

新增隨附物件,然後實作 DiffCallback,如下所示。

companion object {
   private val DiffCallback = object : DiffUtil.ItemCallback<Schedule>() {
       override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
           return oldItem.id == newItem.id
       }

       override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
           return oldItem == newItem
       }
   }
}

這就是設定轉接器的全部內容。您將在應用程式的兩個畫面中使用。

  1. 首先,您需要在 FullScheduleFragment.kt 中取得檢視畫面模型的參照。
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. 接著在 onViewCreated() 中,加入以下程式碼,以設定回收器檢視畫面,並指派其版面配置管理工具。
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
  1. 然後指派轉接器屬性。傳入的動作會使用 stopName 瀏覽所選的下一個畫面,以便篩選公車站清單。
val busStopAdapter = BusStopAdapter({
   val action = FullScheduleFragmentDirections.actionFullScheduleFragmentToStopScheduleFragment(
       stopName = it.stopName
   )
   view.findNavController().navigate(action)
})
recyclerView.adapter = busStopAdapter
  1. 最後,如要更新清單檢視畫面,請呼叫 submitList(),然後傳入該檢視畫面模型的公車站清單。
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.fullSchedule())
}
  1. StopScheduleFragment 中執行相同動作。首先,取得檢視畫面模型的參照。
private val viewModel: BusScheduleViewModel by activityViewModels {
   BusScheduleViewModelFactory(
       (activity?.application as BusScheduleApplication).database.scheduleDao()
   )
}
  1. 然後在 onViewCreated() 中設定回收器檢視畫面。這次,您只要使用 {} 傳入空白區塊 (函式) 即可。輕觸這個畫面中的資料列時,最好不會出現任何動作。
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
val busStopAdapter = BusStopAdapter({})
recyclerView.adapter = busStopAdapter
// submitList() is a call that accesses the database. To prevent the
// call from potentially locking the UI, you should use a
// coroutine scope to launch the function. Using GlobalScope is not
// best practice, and in the next step we'll see how to improve this.
GlobalScope.launch(Dispatchers.IO) {
   busStopAdapter.submitList(viewModel.scheduleForStopName(stopName))
}
  1. 轉接器設定完畢之後,Room 便整合到 Bus Schedule 應用程式中。花點時間執行該應用程式,畫面中會顯示抵達時間清單。只要輕觸任一資料列,即可前往詳細資料畫面。

9. 使用 Flow 回應資料變更

雖然已設定清單檢視,如果呼叫了 submitList(),系統就能有效處理資料變更,但您的應用程式目前仍無法處理動態更新。如果想親眼看一看,您可以嘗試開啟資料庫檢查器,並執行下列查詢,以便在時刻表資料表中插入新項目。

INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

不過,請注意,系統不會在模擬器中執行任何作業。使用者會假設資料並未變更。您需要重新執行應用程式,才能看到這些變更。

問題是每個 DAO 函式只傳回 List<Schedule> 一次。即使更新基礎資料,系統也不會呼叫 submitList() 來更新使用者介面,而從使用者的角度來看,就好像什麼也沒發生。

如要修正此問題,您可以利用名為 asynchronous flow 的 Kotlin 功能 (通常簡稱為 Flow),此功能可讓 DAO 持續從資料庫發出資料。如果插入、更新或刪除項目,系統就會將結果傳回片段。使用名為 collect(), 的函式時,您可以使用 Flow 發出的新值呼叫 submitList(),藉此讓 ListAdapter 根據新資料更新使用者介面。

  1. 如要在 Bus Schedule 中使用 Flow,請開啟 ScheduleDao.kt。如要轉換 DAO 函式以傳回 Flow,只要將 getAll() 函式的傳回類型變更為 Flow<List<Schedule>> 即可。
fun getAll(): Flow<List<Schedule>>
  1. 同樣地,請更新 getByStopName() 函式的傳回值。
fun getByStopName(stopName: String): Flow<List<Schedule>>
  1. 此外,也要更新檢視畫面模型中存取 DAO 的函式。將 fullSchedule()scheduleForStopName() 的傳回值更新為 Flow<List<Schedule>>
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {

   fun fullSchedule(): Flow<List<Schedule>> = scheduleDao.getAll()

   fun scheduleForStopName(name: String): Flow<List<Schedule>> = scheduleDao.getByStopName(name)
}
  1. 最後,在 FullScheduleFragment.kt 中,當您根據查詢結果呼叫 collect() 時,系統應更新 busStopAdapter。由於 fullSchedule() 是暫停函式,因此需要從協同程式中呼叫。取代這一行內容。
busStopAdapter.submitList(viewModel.fullSchedule())

這段程式碼會使用 fullSchedule() 傳回的 Flow。

lifecycle.coroutineScope.launch {
   viewModel.fullSchedule().collect() {
       busStopAdapter.submitList(it)
   }
}
  1. StopScheduleFragment 中執行相同作業,但請將 scheduleForStopName() 呼叫改成以下內容。
lifecycle.coroutineScope.launch {
   viewModel.scheduleForStopName(stopName).collect() {
       busStopAdapter.submitList(it)
   }
}
  1. 完成上述變更後,您可以重新執行應用程式,驗證系統現在是否即時處理資料變更。應用程式執行完畢後,請返回資料庫檢查器,並傳送下列查詢,在上午 8 點前插入新的抵達時間。
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

新項目隨即會顯示在清單頂端。

79d6206fc9911fa9.png

以上是 Bus Schedule 應用程式的相關內容。做得好。現在,您在使用 Room 方面應該具備了穩固的基礎。在下一課程中,您將使用新的範例應用程式進一步瞭解 Room,並瞭解如何在裝置上儲存使用者建立的資料。

10. 解決方案程式碼

本程式碼研究室的解決方案程式碼在下方顯示的專案和模組中。

  1. 前往專案指定的 GitHub 存放區頁面。
  2. 驗證分支版本名稱與程式碼研究室中指定的分支版本名稱相符。例如,在下方螢幕截圖中,分支版本名稱為「main」。

1e4c0d2c081a8fd2.png

  1. 在 GitHub 的專案頁面中,按一下「Code」按鈕,系統會隨即顯示彈出式視窗。

1debcf330fd04c7b.png

  1. 在彈出式視窗中,按一下「Download ZIP」按鈕,將專案儲存至電腦。等待下載作業完成。
  2. 在電腦上尋找該檔案 (可能位於「下載」資料夾中)。
  3. 按兩下 ZIP 檔案,將檔案解壓縮。這項操作會建立含有專案檔案的新資料夾。

在 Android Studio 中開啟專案

  1. 啟動 Android Studio。
  2. 在「Welcome to Android Studio」視窗中,按一下「Open」

d8e9dbdeafe9038a.png

注意:如果 Android Studio 已開啟,請改為依序選取「File」>「Open」選單選項。

8d1fda7396afe8e5.png

  1. 在檔案瀏覽器中,前往已解壓縮的專案資料夾所在的位置 (可能位於「Downloads」資料夾中)。
  2. 按兩下該專案資料夾。
  3. 等待 Android Studio 開啟專案。
  4. 按一下「Run」按鈕 8de56cba7583251f.png 即可建構並執行應用程式,請確認應用程式的建構作業符合預期

11. 恭喜

摘要

  • SQL 資料庫中的資料表會在 Room 中以稱為實體的 Kotlin 類別表示。
  • DAO 會提供對應於與資料庫互動的 SQL 指令的方法。
  • ViewModel 是一種生命週期感知元件,可將您的應用程式資料與檢視畫面分隔。
  • AppDatabase 類別會指示 Room 要使用哪些實體、提供 DAO 的存取權,並在建立資料庫時執行任何設定。
  • ListAdapter 是搭配 RecyclerView 使用的轉接器,非常適合處理動態更新的清單。
  • Flow 是一種 Kotlin 功能,可用於傳回資料串流,可與 Room 搭配使用,確保使用者介面和資料庫保持同步。

瞭解詳情