Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(39)-Room與Flow簡介

使用 Room library 即可輕鬆在 Android app 中使用 database。Room 也稱為 ORM (Object Relational Mapping) library,顧名思義,就是將 relational database 中的 table 對應至可在 Kotlin 程式碼中使用的 objects。在本課程中,您只需要關注讀取 data。使用預先填入的 database,您就能載入公車抵達時間 table 中的 data,並在 RecyclerView 中呈現這些 data。

在課程中,您將瞭解使用 Room 的基礎知識,包括 database classDAO實體(entities)view models。此外,課程中也會介紹 ListAdapter class,讓您透過另一種方式在 RecyclerView 中呈現 data;以及 Flow,這是一種類似於 LiveData 的 Kotlin 語言功能,可使 UI 針對 database 變更做出 response

學習目標

  • 將 database table 以 Kotlin objects (實體(entities)) 表示。
  • 定義要用於在 app 中使用 Room 的 database class,並從檔案預先填入 database。
  • 定義 DAO class,並使用 SQL 查詢Kotlin 程式碼存取 database。
  • 定義 view models,以允許 UI 與 DAO 互動
  • 瞭解如何在 recycler view 中使用 ListAdapter
  • 瞭解 Kotlin Flow 的基本概念,以及學習如何實際運用,讓 UI 對基礎 data 的變更做出 response

開始操作

在本程式碼研究室中,您要使用的 app 稱為 Bus Schedule。此 app 會顯示公車站(bus stops) list,以及從最早到最晚的抵達時間(arrival times)

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

bus stop data 來自 app 預先封裝(prepackaged)的 database。不過,在目前的狀態下,app 首次執行時不會顯示任何內容。您的工作是整合 Room,讓 app 顯示預先填入(prepopulated)arrival times database


新增 Room dependency

和任何其他 library 一樣,您必須先新增必要的 dependency,才能在 Bus Schedule app 中使用 Room。這只需要兩個小幅變更,每個 Gradle 檔案中各一個。

  1. 在 project-level 的 build.gradle 檔案中,請在 ext 區塊中定義 room_version
1
2
3
4
5
ext {
kotlin_version = "1.6.20"
nav_version = "2.4.1"
room_version = '2.4.2'
}
  1. 在 app-level 的 build.gradle 檔案中,請於 dependency list 的結尾新增下列 dependency。
1
2
3
4
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. 請同步(sync)處理變更並建構 project,確認是否已正確新增 dependency。

sync 後會產生以下 error:

重要:sync 後,會產生 Could not find method kapt() for arguments error。參考 issue,需要在 app-level build.gradle 檔案的 plugins 中新增 id 'kotlin-kapt'

1
2
3
4
plugins {
...
id 'kotlin-kapt'
}

最後重新 sync 即可解決。

在接下來的幾個頁面中,我們會介紹將 Room 整合至 app 所需的 components

  • models
  • DAO
  • view models
  • database class

建立實體(entity)

在先前的程式碼研究室中瞭解 relational databases 後,您便知道系統如何將 data 整理成含有多欄(columns)的 table 其中每欄(column)都代表特定 data type 的特定屬性。如同 Kotlin 中的 class 為每個 object 都提供一個 template,database 中的 table 也會為其中每個項目(item)每列(row)各提供一個 template。因此,Kotlin class 可用於代表 database 中的每個 table,這點並不讓人意外。

使用 Room 時,每個 table 都以一個 class 表示。在 Room 等 ORM (Object Relational Mapping) library 中,通常稱為 model classes實體(entities)

Bus Schedule app 的 database 只包含「schedule」這一個 table,當中包含 bus arrival 的部分基本資訊。

  • id:提供 unique ID 做為主鍵的 integer
  • stop_name:string
  • arrival_time:integer

請注意, database 中使用的 SQL type 實際上對於 IntINTEGER,對於 StringTEXT。不過,使用 Room 時,您應該只在定義 model classes 時考慮 Kotlin type。系統會自動將 model class 的 data type 對應至 database 使用的 data type

如果 project 含有多個檔案,建議您將檔案整理成不同的 packages,以便為各個 class 提供更好的存取權控管(access control),並更輕鬆地找出相關 class。

如要為 schedule table 建立實體(entity),請在 com.example.busschedule package 中加入名為 database 的新 package。在這個 package 中,為實體(entity)新增名為 schedule 的新 package。接著,在 database.schedule package 中建立名為 Schedule.kt 的新檔案,並定義名為 Schedule 的 data class。

1
2
data class Schedule(
)

如 SQL 基礎課程所述,table 應有一個主鍵(primary key),用來識別每個 rows。您要新增至 Schedule class 的第一個屬性是 integer,代表專屬 ID(uniquely identify)。新增一個新屬性,並為其標示 @PrimaryKey 註解。此項操作會指示 Room 在插入新 row 時,將這個屬性視為主鍵(primary key)

1
@PrimaryKey val id: Int

bus stop 的名稱 新增一欄(column)。該 column 的 type 應為 String。如果是新 column,則需要新增 @ColumnInfo 註解來指定該 column 的 name。通常,與 Kotlin 屬性使用的 lowerCamelCase 不同,SQL column name 的字詞底線分隔。我們也不允許這個 column 中的 value 為空值(null),請使用 @NonNull 註解加以標示。

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

注意:SQL 中,根據預設,column 的 value 可以是空值(null),但您可以視需要另外將 value 標示為非空值(non null)。這不同於 Kotlin 的運作方式,在 Kotlin 中,根據預設,value 不可為空值(null)

Arrival times 會在 database 中以 integers 表示。這是 Unix timestamp,可轉換成可用日期(date)。雖然不同版本的 SQL 都提供了轉換日期(dates)的方式,但您仍可按照自己的需要使用 Kotlin date formatting functions。將下列 @NonNull column 新增至 model class

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

最後,為了讓 Room 辨識出這個 class 可用於定義 database table,您需要為 class 本身新增一個註解。在 class name 之前的行中新增 @Entity

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

現在,schedule 實體(entity)的 class 應如下所示。

1
2
3
4
5
6
@Entity
data class Schedule(
@PrimaryKey val id: Int,
@NonNull @ColumnInfo(name = "stop_name") val stopName: String,
@NonNull @ColumnInfo(name = "arrival_time") val arrivalTime: Int
)

定義 DAO

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

  1. 請為 Schedule entity 新增 DAO class。在 database.schedule package 中,建立名為 ScheduleDao.kt 的新檔案,並定義名為 ScheduleDao 的 interface。與 Schedule class 相似,您必須加上註解 @Dao,才能在 Room 中使用 interface。
1
2
3
@Dao
interface ScheduleDao {
}

注意:雖然 DAO 是首字母縮寫,但 Kotlin 程式碼的命名慣例只會將首字母縮寫中的第一個字母大寫,因此請使用 ScheduleDao 名稱,而非 ScheduleDAO 名稱。

    • App 有兩個畫面,每個畫面都需要不同的查詢。第一個畫面會arrival time 遞增排序所有 bus stops。在這種情況下,查詢只需要取得所有 columns,並加入適當的 ORDER BY 子句。查詢以傳遞至 @Query 註解的 string 形式指定。
    • 定義 function getAll(),該 function 會 return Schedule list,其中包括 @Query 註解,如下所示。
1
2
@Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
fun getAll(): List<Schedule>
    • 對於第二個查詢,同樣建議您,從 schedule table 中選取所有 columns。不過請注意,您只需符合所選(selected) stop name 的結果,因此請新增 WHERE 子句。如要引用查詢的 Kotlin value,請在查詢前加上冒號 : (例如來自 function 參數的 :stopName)。和以前一樣,結果會arrival time 遞增排序
    • 定義 getByStopName() function ,該 function 採用名為 stopName 的參數 String,並 return Schedule object 的 List,包含 @Query 註解,如下所示。
1
2
@Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
fun getByStopName(stopName: String): List<Schedule>

定義 ViewModel

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

1
2
3
4
5
6
7
8
9
10
11
12
13
@Dao
interface ScheduleDao {

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

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

@Query(...)
getForScreenThree()

}

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

如果使用 view model,您可以利用 ViewModel class。ViewModel class 用於儲存與 app UI 相關的 data,並且具有生命週期感知特性,對於 lifecycle events 的 response 與 activity 或 fragments 極為相似。如果畫面旋轉等 lifecycle events 會導致 activity 或 fragments 被刪除並重新建立,則不需要重新建立相關聯的 ViewModel。您無法直接存取 DAO class,因此最好使用 ViewModel 子類別,loading data 工作與 activity 或 fragments 區分開

注意:Bus Schedule 是一個相對簡單的 app ,只包含兩個畫面且內容大致相同。基於教學目的,我們將建立單個 view model class,可供兩個畫面使用,但在大型 app 中,則可以針對各個 fragments 使用單獨的 view model。

  1. 如要建立 view model class,請在名為 viewmodels 的新 package 中建立名為 BusScheduleViewModel.kt 的新檔案。定義 view model 的 class。該 class 應採用 ScheduleDao type 的單一參數。
1
class BusScheduleViewModel(private val scheduleDao: ScheduleDao): ViewModel() {
  1. 由於這個 view model 會同時用於兩個畫面,因此您需要新增 method 來取得完整 schedule依照 stop name 進行篩選的 schedule。做法是呼叫 ScheduleDao 的對應 method
1
2
3
fun fullSchedule(): List<Schedule> = scheduleDao.getAll()

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

雖然已經完成 view model 的定義,但您不能直接將 BusScheduleViewModel 實例化(instantiate),並預期一切運作正常。由於 ViewModel class BusScheduleViewModel 必須有生命週期感知特性,因此應以可回應 lifecycle eventsobject 進行實例化(instantiate)

如果直接在其中一個 fragments 中執行實例化(instantiate),則 fragments object 必須處理一切 (包括所有記憶體管理工作),而這超出 app 程式碼的工作範圍。您可以改為建立名為 factory 的 class,該 class 會替您view model object 實例化(instantiate)

  1. 如要建立 factory,請在 view model class 下方建立繼承自 ViewModelProvider.Factory 的新 class BusScheduleViewModelFactory
1
2
3
4
class BusScheduleViewModelFactory(
private val scheduleDao: ScheduleDao
) : ViewModelProvider.Factory {
}
  1. 您只需要一個樣板程式碼,就能正確對 view model 執行實例化(instantiate)。您不必直接初始化 class,而是可以覆寫名為 create() 的 method,該 method 會 return BusScheduleViewModelFactory 以及一些錯誤檢查(error checking)。在 BusScheduleViewModelFactory class 中實作 create(),如下所示。
1
2
3
4
5
6
7
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")
}
  • 在 class 上 control + o,即可選擇要覆寫的 method

現在您可以使用 BusScheduleViewModelFactory.create()BusScheduleViewModelFactory object 執行實例化(instantiate),這樣一來,您的 view model 便可具有生命週期感知特性,而無需 fragments 直接處理這項工作。


建立 database class 及 pre-populate database

目前您已定義 models、DAO 及 view model,以供 fragments 存取 DAO,但您還需要指示 Room 如何處理所有這些 classes。此時 AppDatabase class 便可派上用場。一款使用 Room 的 Android app (例如您自己的 app),可分類 RoomDatabase class ,並具有幾項主要工作。在您的 app 中,AppDatabase 需要:

  1. 指定 database 中定義的 entities
  2. 提供對各個 DAO classsingle instance 的存取。
  3. 執行任何其他設定,例如預先填入資料庫(pre-populate database)

您可能想知道為什麼 Room 無法找到所有 entities 和 DAO object,很有可能是您的 app 擁有多個 database,或是存在許多場景(scenarios),在這些場景下,library 無法假設您或開發人員的意圖(intent)。透過 AppDatabase class,您可完全控管 models、DAO class,以及您想執行的任何 database 設定

  1. 如要新增 AppDatabase class,請在 database package 中,建立名為 AppDatabase.kt 的新檔案,並定義繼承自 RoomDatabase 的新 abstract class AppDatabase
1
2
abstract class AppDatabase: RoomDatabase() {
}
  1. database class 可方便其他 class 存取 DAO class。新增一個 abstract function,該 function 會 return ScheduleDao
1
abstract fun scheduleDao(): ScheduleDao
  1. 使用 AppDatabase class時,建議您確保畫面上只有一個 database instance,以避免出現競爭狀況或其他潛在問題。instance 儲存在 companion object 中,您也必須備妥一個 method,該方法不是 return 現有 instance,就是首次建立 database。必須在 companion object 中定義此項。請在 scheduleDao() function 正下方新增下列 companion object
1
2
companion object {
}

筆記:companion object 只會存在一個 instance,也稱為 Singleton Pattern,目的為保證一個 class 只會產生一個 object,而且要提供存取該 object 的 method

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

1
2
@Volatile
private var INSTANCE: AppDatabase? = null

INSTANCE 屬性下方,定義一個 function 以 return AppDatabase instance:

1
2
3
4
5
6
7
8
9
10
11
12
13
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() 的實作中,如果已有 instance,可以使用 Elvis 運算子 return database 的現有 instance如果尚未有 instance首次建立 database。在這個 app 中,由於系統已預先填入 data,您也可呼叫 createFromAsset() 來載入現有 data。您可以在 project 的 assets.database package 中找到 bus_schedule.db 檔案。

  1. database class 就像 model class 和 DAO 一樣,需要註解來提供特定資訊。所有 entities type (使用 ClassName::class 存取 type 本身) 都會列在 array 中。database 也會提供 version number,請將這個 value 設為 1。請按照下列方式新增 @Database 註解。
1
2
@Database(entities = arrayOf(Schedule::class), version = 1)
abstract class AppDatabase: RoomDatabase() {

注意:每次變更 database 結構時,version number 就會遞增。app 會檢查這個版本與 database 中的版本,判斷是否應執行遷移(migration)以及遷移(migration)方式。

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

  1. com.example.busschedule package 中,新增名為 BusScheduleApplication.kt 的 class,並繼承 Application class。
1
2
class BusScheduleApplication : Application() {
}
  1. 新增 database 屬性(type 為 AppDatabase)。這個屬性應延遲處理,並 return 在 AppDatabase class 上呼叫 getDatabase() 的結果
1
2
class BusScheduleApplication : Application() {
val database: AppDatabase by lazy { AppDatabase.getDatabase(this) }
  1. 最後,為了確保使用 BusScheduleApplication class (而非預設的 base class Application),您必須稍微變更 manifest。在 AndroidMainifest.xml 中,將 android:name 屬性設為 com.example.busschedule.BusScheduleApplication
1
2
3
<application
android:name="com.example.busschedule.BusScheduleApplication"
...

這時就可以設定 app 的 model 了。您可以開始透過 UI 使用 Room 的 data。在接下來幾頁中,您需要為 app 的 RecyclerView 建立 ListAdapter,藉此顯示 bus schedule data,並以動態(dynamically)方式回應 data 變更


建立 ListAdapter

現在,我們需要完成一些艱鉅的工作,並將 model 彙整到 view 中。以往使用 RecyclerView 時,您需要使用 RecyclerViewAdapter 可顯示靜態(static) data list。儘管特別適用於 Bus Schedule 等 app,但使用 database 時,往往遇到的都是即時(real time)處理 data 的變更。即使只有一個 item 的內容有變更,系統仍會重新整理整個 recycler view。但對大多數使用持續性的 app 來說,這種做法並不夠。

動態變更 list 的替代方案稱為 ListAdapterListAdapter 使用 AsyncListDiffer判斷舊 data list新 data list 之間的差異。之後,系統再根據這兩份 list 之間的差異來更新 recycler view。因此,處理頻繁更新的 data 時,recycler view 的執行效能會較高,就像在 database app 中經常遇到的情況一樣。

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

    • 建立新 class BusStopAdapter.kt。該 class 會繼承一個 generic ListAdapter,其中會列出 UI 的 Schedule object listBusStopViewHolder class
    • 對於 BusStopViewHolder,您也會傳遞即將定義的 DiffCallback type
    • BusStopAdapter class 本身也會採用參數 onItemClicked()。系統會在第一個畫面選取 item 時,使用此功能來處理 navigation,但在第二個畫面中,您只需傳遞 empty function 即可。
1
2
class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
}
  • import androidx.recyclerview.widget.ListAdapter
  1. 與 recycler view adapter 類似,您需要具備 view holder,才能在程式碼中存取透過 layout 檔案建立的 view。接著只要建立 BusStopViewHolder class 並實作 bind() function,即可stopNameTextView 的 text 設為 stop name,並arrivalTimeTextView 的 text 設為格式化日期(formatted date)
1
2
3
4
5
6
7
8
9
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(),加載 layout,並onClickListener() 設定為針對目前位置(position)的 item 呼叫 onItemClicked()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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(),同時在指定位置(position) bind view
1
2
3
override fun onBindViewHolder(holder: BusStopViewHolder, position: Int) {
holder.bind(getItem(position))
}
  1. 記得您為 ListAdapter 指定的 DiffCallback class 嗎?這只是一個 object,能夠協助 ListAdapter更新 list 時確定新舊 list 中哪些 item 不同。為此,有以下兩種 method:
  • areItemsTheSame() 透過僅檢查 ID 來確認 object (database 的 row) 是否相同
  • areContentsTheSame()檢查所有屬性 (不只是 ID) 是否相同。這些方法可讓 ListAdapter 判斷 insertedupdateddeleted 哪些 item,以便更新相應 UI。

新增 companion object,然後實作 DiffCallback,如下所示。

1
2
3
4
5
6
7
8
9
10
11
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
}
}
}

這就是設定 adapter 的全部內容。您將在 app 的兩個畫面中使用。

  1. 首先,您需要在 FullScheduleFragment.kt取得 view model 的引用
1
2
3
4
5
private val viewModel: BusScheduleViewModel by activityViewModels {
BusScheduleViewModelFactory(
(activity?.application as BusScheduleApplication).database.scheduleDao()
)
}
  1. 接著在 onViewCreated() 中,加入以下程式碼,以設定 recycler view,並指派其 layout manager
1
2
recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
  1. 然後指派 adapter 屬性。傳入的動作(action)會使用 stopName navigate 所選的下一個畫面,以便篩選 bus stops list
1
2
3
4
5
6
7
val busStopAdapter = BusStopAdapter({
val action = FullScheduleFragmentDirections.actionFullScheduleFragmentToStopScheduleFragment(
stopName = it.stopName
)
view.findNavController().navigate(action)
})
recyclerView.adapter = busStopAdapter
  1. 最後,如要更新 list view,請呼叫 submitList(),然後傳入該 view modelbus stops list
1
2
3
4
5
6
7
// 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 中執行相同動作。首先,取得 view model 的引用
1
2
3
4
5
private val viewModel: BusScheduleViewModel by activityViewModels {
BusScheduleViewModelFactory(
(activity?.application as BusScheduleApplication).database.scheduleDao()
)
}
  1. 然後在 onViewCreated()設定 recycler view。這次,您只要使用 {} 傳入 empty block (function) 即可。輕觸這個畫面中的 row 時,最好不會出現任何動作。
1
2
3
4
5
6
7
8
9
10
11
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. Adapter 設定完畢之後,Room 便整合到 Bus Schedule app 中。花點時間執行該 app,畫面中會顯示 arrival times list。只要輕觸任一 row,即可前往 detail 畫面

使用 Flow 回應 data 變更

雖然已設定 list view,如果呼叫了 submitList(),系統就能有效處理 data 變更,但您的 app 目前仍無法處理動態更新(dynamic updates)。如果想親眼看一看,您可以嘗試開啟 App Inspector,並執行下列查詢,以便在 schedule table 中插入新 item。

1
2
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)
  • 請注意,系統不會在模擬器中執行任何作業。使用者會假設 data 並未變更。您需要重新執行 app ,才能看到這些變更

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

如要修正此問題,您可以利用名為 asynchronous flow 的 Kotlin 功能 (通常簡稱為 Flow),此功能可DAO 持續從 database 發出 data。如果 inserted、updated 或 deleted item,系統就會將結果傳回 fragment

使用名為 collect() 的 function 時,您可以使用 Flow 發出的新 value 呼叫 submitList(),藉此ListAdapter 根據新 data 更新 UI

  1. 如要在 Bus Schedule 中使用 Flow,請開啟 ScheduleDao.kt。如要轉換 DAO function 以 return Flow,只要getAll() function 的 return type 變更為 Flow<List<Schedule>> 即可。
1
2
// 用 Flow<> 包起來,使 DAO 持續從 database 發出 data
fun getAll(): Flow<List<Schedule>>
  • import kotlinx.coroutines.flow.Flow
  1. 同樣地,請更新 getByStopName() function 的 return value。
1
2
// 用 Flow<> 包起來,使 DAO 持續從 database 發出 data
fun getByStopName(stopName: String): Flow<List<Schedule>>
  1. 此外,也要更新 view model 中存取 DAO 的 function。將 fullSchedule()scheduleForStopName() 的 return value 更新為 Flow<List<Schedule>>
1
2
3
4
5
6
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()suspend function,因此需要從 coroutine 中呼叫。取代這一行內容。
1
busStopAdapter.submitList(viewModel.fullSchedule())

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

1
2
3
4
5
lifecycle.coroutineScope.launch {
viewModel.fullSchedule().collect() {
busStopAdapter.submitList(it)
}
}
  1. StopScheduleFragment 中執行相同作業,但請將 scheduleForStopName() 呼叫改成以下內容。
1
2
3
4
5
lifecycle.coroutineScope.launch {
viewModel.scheduleForStopName(stopName).collect() {
busStopAdapter.submitList(it)
}
}
  1. 完成上述變更後,您可以重新執行 app,驗證系統現在是否即時(real time)處理 data 變更。App 執行完畢後,請返回 App Inspector,並傳送下列查詢,在 11:00 PM 前插入新的 arrival time
1
2
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

新 item 隨即會顯示在 list 頂端。


總結

  • SQL database 中的 tables 會在 Room以稱為 entities 的 Kotlin classes 表示
  • DAO(Data Access Object) 會提供對應於與 database 互動的 SQL 指令的 methods
  • ViewModel 是一種 lifecycle 感知 component,可將 app dataview 分隔
  • AppDatabase class 會指示 Room 要使用哪些 entities提供 DAO 的存取權,並在建立 database 時執行任何設定
  • ListAdapter搭配 RecyclerView 使用的 adapter,非常適合處理動態更新的 list
  • Flow 是一種 Kotlin 功能,可用於 return data stream,可與 Room 搭配使用,確保 UI 和 database 保持同步(sync)