Room 和 Flow 简介

1. 准备工作

上一个 Codelab 中,您已学过关系型数据库的基础知识,以及如何使用以下 SQL 命令读取和写入数据:SELECT、INSERT、UPDATE 和 DELETE。了解如何使用关系型数据库是一项贯穿您整个编程生涯的基本技能。此外,了解关系型数据库的工作方式对于在 Android 应用中实现数据持久性也必不可少,您将在本课中开始学习如何实现数据持久性。

想要在 Android 应用中使用数据库,一种简单的方式是使用名为 Room 的库。Room 是所谓的 ORM(对象关系映射)库,顾名思义,它将关系型数据库中的表映射到可在 Kotlin 代码中使用的对象。在本课中,我们只准备重点介绍如何读取数据。您将使用预先填充的数据库加载公交到站时间表中的数据,并将其呈现于 RecyclerView 中。

70c597851eba9518.png

在此过程中,您将学习使用 Room 的基础知识,包括数据库类、DAO、实体和视图模型。此外,我们还将向您介绍 ListAdapter 类(这是在 RecyclerView 中呈现数据的另一种方式)和 flow(这是 Kotlin 语言中一项类似于 LiveData 的功能,可让界面对数据库中的更改做出响应)。

前提条件

  • 熟悉面向对象的编程以及如何使用 Kotlin 中的类、对象和继承。
  • 掌握了“SQL 基础知识”Codelab 中介绍的关系型数据库和 SQL 基础知识。
  • 具有使用 Kotlin 协程的经验。

学习内容

学完本课后,您应当能够

  • 以 Kotlin 对象(实体)表示数据库表。
  • 定义要在应用中使用 Room 的数据库类,并从文件预先填充数据库。
  • 定义 DAO 类并使用 SQL 查询通过 Kotlin 代码访问数据库。
  • 定义一个视图模型,使界面能够与 DAO 交互。
  • 了解如何将 ListAdapter 与 recycler 视图配合使用。
  • 掌握 Kotlin flow 的基础知识,并了解如何用其让界面对底层数据的更改做出响应。

构建内容

  • 使用 Room 从预先填充的数据库中读取数据,并将其呈现于简单的公交时刻表应用中的 recycler 视图中。

2. 开始

您在此 Codelab 中要使用的应用名为 Bus Schedule。该应用会按照公交车到站时间早晚列出每个车站名称和到站时间。

70c597851eba9518.png

点按第一个屏幕中的某一行可转到一个新屏幕,其中仅显示所选公交车站接下来的公交到站时间。

f477c0942746e584.png

公交车站的数据来自应用预封装的数据库。不过,在应用的当前状态下,应用首次运行时不会显示任何内容。您的任务是集成 Room,让应用显示预先填充的公交到站时间数据库。

  1. 进入为此项目提供的 GitHub 代码库页面。
  2. 验证分支名称是否与此 Codelab 中指定的分支名称一致。例如,在以下屏幕截图中,分支名称为 main

1e4c0d2c081a8fd2.png

  1. 在项目的 GitHub 页面上,点击 Code 按钮,以打开一个弹出式窗口。

1debcf330fd04c7b.png

  1. 在弹出式窗口中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(可能在 Downloads 文件夹中)。
  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. 创建实体

在上一个 Codelab 中学习关系型数据库时,您已经了解了如何将数据整理为包含多列的表,每列表示一个属于特定数据类型的特定属性。就像 Kotlin 中的类为每个对象提供模板一样,数据库中的表为该表中的每个项(即行)提供模板。那么,可以使用 Kotlin 类来表示数据库中的每个表应该也就不足为奇了。

使用 Room 时,每个表都由一个类表示。在 Room 等 ORM(对象关系映射)库中,这些类通常称为模型类或实体。

Bus Schedule 应用的数据库仅包含一个表,即时刻表,内含一些与公交到站相关的基本信息。

  • id:一个整数,提供用作主键的唯一标识符
  • stop_name:一个字符串
  • arrival_time:一个整数

请注意,数据库中使用的 SQL 类型实际上是 INTEGER(对应于 Int)和 TEXT(对应于 String)。不过,在使用 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 注解,用于为该列指定名称。通常,SQL 列名称使用以下划线分隔的单词,而 Kotlin 属性采用小驼峰命名法。对于此列,我们还希望其值为非 null 值,因此您应该使用 @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 查询不区分大小写,因此您在这里无需显式定义小写的表名称。

Schedule 实体的类现在应如下所示。

@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 时,您从上一个 Codelab 中学到的 SQL 知识将派上用场。

  1. 为 Schedule 实体添加一个 DAO 类。在 database.schedule 软件包中,创建一个名为 ScheduleDao.kt 的新文件并定义一个名为 ScheduleDao 的接口。与 Schedule 类相似,您需要添加注解(这次是添加 @Dao 注解),使该接口可用于 Room。
@Dao
interface ScheduleDao {
}
  1. 应用中有两个屏幕,每个屏幕需要的查询不同。第一个屏幕按公交到站时间依升序显示所有公交车站。在此用例中,查询只需获取所有列并包含适当的 ORDER BY 子句。可将查询指定为传入 @Query 注解中的字符串。定义一个函数 getAll(),用于返回包含 @Query 注解的 Schedule 对象的列表,如下所示。
@Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
fun getAll(): List<Schedule>
  1. 对于第二个查询,您也需要选择 schedule 表中的所有列。不过,您只需要与所选车站名称匹配的结果,因此您需要添加一个 WHERE 子句。您可以在查询前面添加冒号 (:) 来引用查询中的 Kotlin 值(例如,函数参数中的 :stopName)。与之前一样,结果按公交到站时间依升序排序。定义一个 getByStopName() 函数,该函数接受名为 stopNameString 形参、返回 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,从技术角度而言,您已具备从 fragment 访问数据库所需的全部条件。然而,尽管这在理论上可行,但通常并不属于最佳做法。其原因在于,在更复杂的应用中,可能会有多个屏幕,而且这些屏幕只访问数据的特定部分。虽然 ScheduleDao 相对简单,但不难看出在使用两个或更多不同屏幕时,这种情况会如何失控。例如,DAO 可能会如下所示:

@Dao
interface ScheduleDao {

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

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

    @Query(...)
    getForScreenThree()

}

尽管 Screen 1 的代码可以访问 getForScreenOne(),但它没有充分的理由访问其他方法。而最佳做法是将您向视图公开的 DAO 部分拆分为名为“视图模型”的单独的类。这是移动应用中常用的架构模式。使用视图模型有助于在应用界面与其数据模型的代码之间执行明确分隔。它还有助于单独测试代码的每一部分,随着您继续从事 Android 开发,您将进一步探索这一主题。

ee2524be13171538.png

使用视图模型,您就可以利用 ViewModel 类。ViewModel 类用于存储与应用界面相关的数据,并且具有生命周期感知能力,这意味着它能像 activity 或 fragment 一样对生命周期事件做出响应。如果屏幕旋转等生命周期事件导致 activity 或 fragment 被销毁并重新创建,不需要重新创建关联的 ViewModel。通过直接访问 DAO 类不可能做到这一点,因此最佳实践是使用 ViewModel 子类将加载数据的责任从 activity 或 fragment 中分离出来。

  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 要能感知生命周期,因此应该由可响应生命周期事件的对象对其进行实例化。如果在您的某个 fragment 中直接对其进行实例化,那么您的 fragment 对象就不得不处理所有任务(包括所有内存管理任务),而这超出了您的应用代码的应有责任范围。不过,您可以创建一个名为工厂的类来为您实例化视图模型对象。

  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 对象,让您的视图模型可以感知生命周期而不必由 fragment 直接进行处理。

7. 创建数据库类和预先填充数据库

您已定义模型、DAO 和供 fragment 访问 DAO 的视图模型,现在还需要告知 Room 如何处理所有这些类。这正是 AppDatabase 类的用武之地。像您的应用这样使用 Room 的 Android 应用会创建 RoomDatabase 类的子类并承担一些重要责任。在您的应用中,AppDatabase 需要

  1. 指定数据库中定义的实体。
  2. 提供对每个 DAO 类的单个实例的访问。
  3. 执行任何其他设置,例如预先填充数据库。

您可能想知道为什么 Room 不能直接为您找出所有实体和 DAO 对象,但您的应用可能有多个数据库或者存在任意数量的场景,在这些情况下,该库无法假定您作为开发者的意图。借助 AppDatabase 类,您可以完全控制模型、DAO 类以及您希望执行的任何数据库设置。

  1. 如需添加 AppDatabase 类,请在 database 软件包中创建一个名为 AppDatabase.kt 的新文件,并定义一个继承自 RoomDatabase 的新抽象类 AppDatabase
abstract class AppDatabase: RoomDatabase() {
}
  1. 通过该数据库类,其他类可以轻松访问 DAO 类。添加一个返回 ScheduleDao 的抽象函数。
abstract fun scheduleDao(): ScheduleDao
  1. 使用 AppDatabase 类时,您需要确保仅存在一个数据库实例,以防出现竞态条件或其他潜在问题。该实例存储在伴生对象中,并且您还需要用一个方法来返回现有实例或首次创建数据库。此方法在伴生对象中定义。将以下 companion object 添加到 scheduleDao() 函数的正下方。
companion object {
}

companion object 中,添加一个名为 INSTANCE 且类型为 AppDatabase 的属性。此值最初设为 null,因此类型标记有 ?。此值还标记有 @Volatile 注解。虽然有关何时使用 volatile 属性的详细内容对本课而言有些过于深奥,但您需要将其用于 AppDatabase 实例以避免可能出现的 bug。

@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() 以加载现有数据。bus_schedule.db 文件可在项目的 assets.database 软件包中找到。

  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 这样的应用,但是在使用数据库时,实时处理数据更改才是常见的情形。即使只有一项内容发生更改,也会刷新整个 recycler 视图。因此,这种方式对于利用数据持久性的大多数应用来说还不够。

我们可以使用 ListAdapter 来代替动态更改的列表。ListAdapter 使用 AsyncListDiffer 确定旧数据列表与新数据列表之间的差异。然后,系统就会仅基于两个列表之间的差异来更新 recycler 视图。其结果是,recycler 视图在处理频繁更新的数据(数据库应用中往往会使用这种数据)时的性能会得到提高。

f59cc2fd4d72c551.png

由于两个屏幕的界面相同,因此您只需创建可用于两个屏幕的单个 ListAdapter

  1. 创建一个新文件 BusStopAdapter.kt 和一个 BusStopAdapter 类,如下所示。该类将扩展通用 ListAdapter,接受 Schedule 对象列表和用于界面的 BusStopViewHolder 类。对于 BusStopViewHolder,您还要传入一个 DiffCallback 类型,您很快将定义该类型。BusStopAdapter 类本身也会接受一个参数,即 onItemClicked()。此函数将在第一个屏幕上的某个项被选中时用于处理导航。但是,对于第二个屏幕,您只需要传入一个空函数。
class BusStopAdapter(private val onItemClicked: (Schedule) -> Unit) : ListAdapter<Schedule, BusStopAdapter.BusStopViewHolder>(DiffCallback) {
}
  1. 与 recycler 视图适配器类似,您需要一个 ViewHolder,以便在代码中访问通过布局文件创建的视图。单元格的布局已创建。您只需如下所示创建一个 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() 中添加以下代码,以设置 recycler 视图并分配其布局管理器。
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() 中配置 recycler 视图。这一次,您只需要使用 {} 传入一个空块(函数)。实际上,您并不需要在此屏幕上的行被点按时发生任何操作。
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() 被调用时有效处理数据更改,但您的应用尚无法处理动态更新。如果您想自己看一看,请尝试打开 Database Inspector 并运行以下查询,在 schedule 表中插入一个新项。

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

但是您会发现,模拟器中没有任何反应。用户会以为数据没有变化。您需要重新运行应用才能看到更改。

问题在于,系统只会从每个 DAO 函数返回一次 List<Schedule>。即便底层数据已更新,系统也不会调用 submitList() 来更新界面,而从用户的角度来看,就好像数据没有任何变化。

为了解决此问题,您可以利用一项称为异步 flow(通常简称为 flow)的 Kotlin 功能,DAO 可以借助该功能从数据库连续发出数据。如果插入、更新或删除某个项,结果会被发送回 fragment。通过名为 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. 完成上述更改后,您可以重新运行应用以验证数据更改现在是否得到实时处理。应用运行后,返回到 Database Inspector 并发送以下查询,在上午 8:00 之前插入一个新的公交到站时间。
INSERT INTO schedule
VALUES (null, 'Winding Way', 1617202500)

新项应显示于列表顶部。

79d6206fc9911fa9.png

以上就是有关 Busion Schedule 应用的全部内容。坚持学到这里,您的表现太棒了!您现在应该已经为使用 Room 奠定了坚实的基础。在下一个课程中,您将通过新的示例应用深入了解 Room,并学习如何在设备上保存用户创建的数据。

10. 解决方案代码

此 Codelab 的解决方案代码位于下方所示的项目和模块中。

  1. 进入为此项目提供的 GitHub 代码库页面。
  2. 验证分支名称是否与此 Codelab 中指定的分支名称一致。例如,在以下屏幕截图中,分支名称为 main

1e4c0d2c081a8fd2.png

  1. 在项目的 GitHub 页面上,点击 Code 按钮,以打开一个弹出式窗口。

1debcf330fd04c7b.png

  1. 在弹出式窗口中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(可能在 Downloads 文件夹中)。
  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 搭配使用以确保界面和数据库保持同步。

了解详情