Android Kotlin 基础知识:07.2 将 DiffUtil 和数据绑定与 RecyclerView 结合使用

此 Codelab 是“Android Kotlin 基础知识”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘此课程的价值。“Android Kotlin 基础知识”Codelab 着陆页列出了所有课程 Codelab。

简介

在上一个 Codelab 中,您更新了 TrackMySleepQuality 应用,以在 RecyclerView 中显示与睡眠质量相关的数据。您在构建首个 RecyclerView 时学到的方法足以处理大多数 RecyclerViews,用于显示源数据变化不大的简单列表。不过,有很多方法可以让 RecyclerView 更高效地处理大型列表。这些方法可使您的代码更易于维护和扩展,以处理复杂的列表和网格。

在此 Codelab 中,您将在上一个 Codelab 的睡眠跟踪器应用的基础上进行进一步构建。您将学习一种更有效的方法来更新睡眠数据列表。您还将学习如何将数据绑定与 RecyclerView 结合使用。如果您没有上一个 Codelab 的应用,可以下载此 Codelab 的起始代码。

您应当已掌握的内容

  • 使用 activity、fragment 和 view 构建基本界面。
  • 在 fragment 之间导航,并使用 safeArgs 在 fragment 之间传递数据。
  • 视图模型、视图模型工厂、转换以及 LiveData 及其观察者。
  • 如何创建 Room 数据库,创建 DAO 和定义实体。
  • 如何将协程用于数据库任务和其他长时间运行的任务。
  • 如何使用 Adapter、ViewHolder 和项布局实现基本 RecyclerView。

学习内容

  • 如何使用 DiffUtil, 这个实用程序来计算两个列表之间的差异,以便高效地更新 RecyclerView 显示的列表。
  • 如何将数据绑定与 RecyclerView 结合使用。
  • 如何使用绑定适配器来转换数据。

实践内容

  • 在本系列上一个 Codelab 的 TrackMySleepQuality 应用的基础上进行进一步构建。
  • 更新 SleepNightAdapter,以便使用 DiffUtil 高效地更新列表。
  • 使用绑定适配器转换数据,从而为 RecyclerView 实现数据绑定。

此睡眠跟踪器应用有两个屏幕,分别以 fragment 表示,如下图所示。

左侧所示的第一个屏幕包含用于开始和停止睡眠质量跟踪的按钮。这个屏幕会显示用户的一些睡眠数据。Clear 按钮用于永久删除应用针对用户收集的所有数据。右侧所示的第二个屏幕用于选择睡眠质量评分。

此应用的架构采用一个界面控制器、ViewModel 和 LiveData,以及一个用于保留睡眠数据的 Room 数据库。

49f975f1e5fe689.png

睡眠数据显示在 RecyclerView 中。在此 Codelab 中,您将为 RecyclerView 构建 DiffUtil 和数据绑定部分。完成此 Codelab 后,您的应用看起来会和之前完全一样,但会变得更高效,而且更易于扩展和维护。

您可以继续使用上一个 Codelab 的睡眠跟踪器应用,也可以从 GitHub 下载 RecyclerViewDiffUtilDataBinding-Starter 应用。

  1. 视需要从 GitHub 下载 RecyclerViewDiffUtilDataBinding-Starter 应用,并在 Android Studio 中打开项目。
  2. 运行应用。
  3. 打开 SleepNightAdapter.kt 文件。
  4. 检查代码,熟悉应用的结构。参阅下图,简要回顾如何将 RecyclerView 与适配器模式结合使用,以向用户显示睡眠数据。

6c32c918f96efb46.png

  • 应用根据用户输入创建 SleepNight 对象列表。每个 SleepNight 对象表示一个夜晚以及用户该晚睡眠的时长和质量。
  • SleepNightAdapter 会将 SleepNight 对象的列表调整为 RecyclerView 可以使用和显示的内容。
  • SleepNightAdapter 适配器会生成 ViewHolders,其中包含 RecyclerView 用于显示数据的视图、数据和元数据信息。
  • RecyclerView 使用 SleepNightAdapter 来确定要显示的项数 (getItemCount())。RecyclerView 使用 onCreateViewHolder() 和 onBindViewHolder() 获取与要显示的数据绑定的 ViewHolder。

notifyDataSetChanged() 方法效率低下

为了告知 RecyclerView 列表中的某个项已更改且需要更新,当前代码会在 SleepNightAdapter 中调用 notifyDataSetChanged(),如下所示。

var data =  listOf<SleepNight>()
   set(value) {
       field = value
       notifyDataSetChanged()
   }

但是,notifyDataSetChanged() 会告知 RecyclerView 整个列表可能无效。因此,RecyclerView 会重新绑定并重新绘制列表中的每个项,包括屏幕上看不到的项。这是一项既繁重又不必要的工作。对于较大或复杂的列表,这个过程可能需要较长时间,以至于在用户滚动浏览列表时,屏幕会闪烁或卡顿。

要解决此问题,您可以确切地告诉 RecyclerView 发生了什么更改。然后,RecyclerView 便可仅更新屏幕上已经发生更改的视图。

RecyclerView 拥有一个用于更新单个元素的功能丰富的 API。您可以使用 notifyItemChanged() 告知 RecyclerView 某个项发生了更改,并且您可以对添加、移除或移动的项使用类似的函数。您可以全部手动完成,但这样任务就会很繁重,并且可能需要使用大量代码。

幸运的是,我们有一个更好的办法。

DiffUtil 很高效并可为您完成繁重工作

RecyclerView 有一个名为 DiffUtil 的类,用于计算两个列表之间的差异。DiffUtil 会接受一个旧列表和一个新列表,并确定二者有何不同。它会查找已添加、移除或更改的项。然后,它会使用一种算法(名为 Eugene W. Myers 差分算法),来确定要生成新列表,需要对旧列表做出的最小更改量。

在 DiffUtil 确定了更改内容后,RecyclerView 可以根据这些信息仅更新已更改、添加、移除或移动的项,这比重做整个列表要高效得多。

在此任务中,您需要升级 SleepNightAdapter 以使用 DiffUtil 优化 RecyclerView,以处理数据更改。

第 1 步:实现 SleepNightDiffCallback

为了使用 DiffUtil 类的功能,请扩展 DiffUtil.ItemCallback。

  1. 打开 SleepNightAdapter.kt。
  2. 在 SleepNightAdapter 的完整类定义下方,创建一个名为 SleepNightDiffCallback 的新顶级类,用于扩展 DiffUtil.ItemCallback。以通用参数的形式传递 SleepNight。
class SleepNightDiffCallback : DiffUtil.ItemCallback<SleepNight>() {
}
  1. 将光标放在 SleepNightDiffCallback 类名称上。
  2. 按 Alt+Enter(在 Mac 上,按 Option+Enter)并选择 Implement Members。
  3. 在打开的对话框中,按住 Shift 键并点击鼠标左键以选择 areItemsTheSame() 和 areContentsTheSame() 方法,然后点击 OK。

此操作会针对这两个方法在 SleepNightDiffCallback 中生成桩,如下所示。DiffUtil 使用这两种方法来确定列表和项的具体更改。

    override fun areItemsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }

    override fun areContentsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
  1. 在 areItemsTheSame() 中,将 TODO 替换为用于测试两个传入 SleepNight 项 oldItem 和 newItem 是否相同的代码。如果这两个项具有相同的 nightId,则表明它们是相同的,因此返回 true。否则返回 false。DiffUtil 使用此测试来帮助发现是否已添加、移除或移动某个项。
override fun areItemsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
   return oldItem.nightId == newItem.nightId
}
  1. 在 areContentsTheSame() 中,检查 oldItem 和 newItem 是否包含相同的数据;即判断它们是否相等。由于 SleepNight 是一个数据类,此相等性检查将检查所有字段。Data 类会自动为您定义 equals 和一些其他方法。如果 oldItem 和 newItem 之间存在差异,此代码会告知 DiffUtil 相应项已更新。
override fun areContentsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
   return oldItem == newItem
}

通常使用 RecyclerView 来显示会发生变化的列表。RecyclerView 提供适配器类 ListAdapter,可帮助您构建由列表支持的 RecyclerView 适配器。

ListAdapter 会为您跟踪列表,并在列表更新时通知适配器。

第 1 步:更改适配器以扩展 ListAdapter

  1. 在 SleepNightAdapter.kt 文件中,更改 SleepNightAdapter 的类签名以扩展 ListAdapter。
  2. 如果出现提示,请导入 androidx.recyclerview.widget.ListAdapter。
  3. 将 SleepNight 作为第一个参数添加到 ListAdapter 中 SleepNightAdapter.ViewHolder 之前。
  4. 将 SleepNightDiffCallback() 作为参数添加到构造函数中。ListAdapter 将利用此参数确定列表中的更改内容。完成后的 SleepNightAdapter 类签名应如下所示。
class SleepNightAdapter : ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {
  1. 在 SleepNightAdapter 类中,删除 data 字段,包括 setter。您已不再需要它,因为 ListAdapter 会为您跟踪列表。
  2. 删除 getItemCount() 的替换方法,因为 ListAdapter 为您实现了此方法。
  3. 如需消除 onBindViewHolder() 中的错误,请更改 item 变量。调用 ListAdapter 提供的 getItem(position) 方法,而不要使用 data 来获取 item。
val item = getItem(position)

第 2 步:使用 submitList() 及时更新列表

在有已更改的列表时,您的代码需要告知 ListAdapter。ListAdapter 提供了一个名为 submitList() 的方法,用于告知 ListAdapter 列表有新版本。调用此方法时,ListAdapter 会将新列表与旧列表进行差异比较,并检测已添加、移除、移动或更改的项。然后,ListAdapter 会更新 RecyclerView 所显示的项。

  1. 打开 SleepTrackerFragment.kt。
  2. 在 sleepTrackerViewModel 内的观察器上,在 onCreateView() 中找到引用您已删除的 data 变量的错误。
  3. 将 adapter.data = it 替换为对 adapter.submitList(it) 的调用。更新后的代码如下所示。
sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
   it?.let {
       adapter.submitList(it)
   }
})
  1. 运行您的应用。您可能需要导入 findNavController。您可能会注意到,您的应用运行速度变快了,然而如果列表太小,这个变化可能不明显。

在此任务中,您需要使用与之前 Codelab 相同的方法来设置数据绑定,并消除对 findViewById() 的调用。

第 1 步:向布局文件添加数据绑定

  1. 在 Code 标签页中打开 list_item_sleep_night.xml 布局文件。
  2. 将光标放在 ConstraintLayout 标签上,然后按 Alt+Enter(在 Mac 上,按 Option+Enter)。系统随即会打开 intent 菜单(“quick fix”菜单)。
  3. 选择 Convert to data binding layout。这会将布局封装到 <layout> 中,并在其中添加 <data> 标签。
  4. 根据需要滚动回顶部,并在 <data> 标签内声明一个名为 sleep 的变量。
  5. 将其 type 设为 SleepNight 的完全限定名称 com.example.android.trackmysleepquality.database.SleepNight。完成后的 <data> 标签应如下所示。
   <data>
        <variable
            name="sleep"
            type="com.example.android.trackmysleepquality.database.SleepNight"/>
    </data>
  1. 如需强制创建 Binding 对象,请依次选择 Build > Clean Project,然后依次选择 Build > Rebuild Project。(如果仍然存在问题,请依次选择 File > Invalidate Caches / Restart。)ListItemSleepNightBinding 绑定对象以及相关代码会添加到项目生成的文件中。

第 2 步:使用数据绑定膨胀项布局

  1. 打开 SleepNightAdapter.kt。
  2. 在 companion object 中,找到 from(parent: ViewGroup) 函数。
  3. 删除 view 变量的声明。

要删除的代码:

val view = layoutInflater
       .inflate(R.layout.list_item_sleep_night, parent, false)
  1. 在 view 变量所在的位置,定义一个名为 binding 的新变量,以膨胀 ListItemSleepNightBinding 绑定对象,如下所示。根据需要导入绑定对象。
val binding =
ListItemSleepNightBinding.inflate(layoutInflater, parent, false)
  1. 在函数结尾,不要返回 view,而应返回 binding。
return ViewHolder(binding)
  1. 要消除 binding 上的错误,请将光标放在 binding 一词上。按 Alt+Enter(在 Mac 上,按 Option+Enter)打开 intent 菜单。
  2. 选择 Change parameter ‘itemView' type of primary constructor of class ‘ViewHolder' to ‘ListItemSleepNightBinding'。这将更新 ViewHolder 类的参数类型。

d7213f958ae695b5.png

  1. 向上滚动到 ViewHolder 的类定义,以查看签名中的更改。您会看到 itemView 的错误,因为您在 from() 方法中将 itemView 更改为了 binding。

在 ViewHolder 类定义中,右键点击 itemView 的一个发生实例,然后依次选择 Refactor > Rename。将名称更改为 binding。

  1. 为构造函数参数 binding 添加 val 前缀,使其成为属性。
  2. 在对父类 RecyclerView.ViewHolder 的调用中,将参数从 binding 更改为 binding.root。您需要传递 View,并且将 binding.root 作为项布局中的根 ConstraintLayout。
  3. 完成后的类声明应如以下代码所示。
class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root){

您还会看到对 findViewById(). 的调用的错误。您将在下一部分中修复这些错误。

第 3 步:替换 findViewById()

您现在可以更新 sleepLength、quality 和 qualityImage 属性,以使用 binding 对象代替 findViewById()。

  1. 将 sleepLength、qualityString 和 qualityImage 的初始化更改为使用 binding 对象的视图,如下所示。此后,您的代码应该不会再显示任何错误。
val sleepLength: TextView = binding.sleepLength
val quality: TextView = binding.qualityString
val qualityImage: ImageView = binding.qualityImage

绑定对象就位后,您根本不需要定义 sleepLength、quality 和 qualityImage 属性。DataBinding 将缓存查询,因此无需声明这些属性。

  1. 右键点击 sleepLength、quality 和 qualityImage 属性名称。对于每个属性,依次选择 Refactor > Inline,或按 Ctrl+Alt+N(在 Mac 上按 Option+Command+N)。b136364471dd01fb.png
  2. 运行您的应用。(如果项目出现错误,您可能需要清理并重建项目。)

在此任务中,您需要升级应用,将数据绑定与绑定适配器结合使用,在视图中设置数据。

在上一个 Codelab 中,您使用了 Transformations 类来获取 LiveData 并生成要在文本视图中显示的格式化字符串。但是,如果您需要绑定不同类型或复杂类型的数据,您可以提供绑定适配器来帮助数据绑定功能使用这些类型。绑定适配器会获取您的数据,并将其调整为可供数据绑定功能用于绑定视图(例如文本或图片)的内容。

您需要实现三个绑定适配器,一个用于高质量图片,另外两个分别用于一个文本字段。总而言之,如需声明绑定适配器,您需要定义一种获取项和视图的方法,并用 @BindingAdapter 进行注解。在该方法的正文中,您可以实现转换。在 Kotlin 中,您可以在接收数据的视图类上将绑定适配器编写为扩展函数。

第 1 步:创建绑定适配器

请注意,您必须在此步骤中导入很多类。

  1. 打开 SleepNightAdapter.kt。
  2. 在 ViewHolder 类中,找到 bind() 方法并注意该方法的用途。您需要获取用于计算 binding.sleepLength、binding.quality 和 binding.qualityImage 的值的代码,并在适配器中改用该代码。(目前不要更改代码,您需要在后续步骤中移动代码。)
  3. 在 sleeptracker 软件包中,创建一个名为 BindingUtils.kt 的新文件并打开此文件。
  4. 删除 BindingUtils 类中的所有内容,因为您接下来要创建静态函数。
class BindingUtils {}
  1. 在 TextView 上声明一个名为 setSleepDurationFormatted 的扩展函数,并传入 SleepNight。此函数将作为适配器,用于计算和格式化睡眠时长。
fun TextView.setSleepDurationFormatted(item: SleepNight) {}
  1. 在 setSleepDurationFormatted 的正文中,将数据绑定到视图,如在 ViewHolder.bind() 中一样。调用 convertDurationToFormatted(),然后将 TextView 的 text 设置为格式化文本。(由于这是 TextView 上的扩展函数,您可以直接访问 text 属性。)
text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, context.resources)
  1. 如需向数据绑定功能告知此绑定适配器,请使用 @BindingAdapter 为该函数添加注解。
  2. 此函数是用于 sleepDurationFormatted 属性的适配器,因此请将 sleepDurationFormatted 作为参数传递给 @BindingAdapter。
@BindingAdapter("sleepDurationFormatted")
  1. 第二个适配器根据 SleepNight 对象中的值设置睡眠质量。在 TextView 上再创建一个名为 setSleepQualityString() 的扩展函数,并传入 SleepNight。
  2. 在正文中,将数据绑定到视图,如在 ViewHolder.bind() 中一样。调用 convertNumericQualityToString 并设置 text。
  3. 使用 @BindingAdapter("sleepQualityString") 为该函数添加注解。
@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight) {
   text = convertNumericQualityToString(item.sleepQuality, context.resources)
}
  1. 我们需要第三个绑定适配器,用于在图片视图上设置图片。在 ImageView 上创建第三个扩展函数,调用 setSleepImage,并使用 ViewHolder.bind() 中的代码,如下所示。
@BindingAdapter("sleepImage")
fun ImageView.setSleepImage(item: SleepNight) {
   setImageResource(when (item.sleepQuality) {
       0 -> R.drawable.ic_sleep_0
       1 -> R.drawable.ic_sleep_1
       2 -> R.drawable.ic_sleep_2
       3 -> R.drawable.ic_sleep_3
       4 -> R.drawable.ic_sleep_4
       5 -> R.drawable.ic_sleep_5
       else -> R.drawable.ic_sleep_active
   })
}

您可能需要导入 convertDurationToFormatted 和 convertNumericQualityToString。

第 2 步:更新 SleepNightAdapter

  1. 打开 SleepNightAdapter.kt。
  2. 删除 bind() 方法中的所有内容,因为您现在可以使用数据绑定和新的适配器来为您执行这项操作。
fun bind(item: SleepNight) {
}
  1. 在 bind() 中,为 item 分配 sleep,因为您需要告知绑定对象您的新 SleepNight。
binding.sleep = item
  1. 在该行的下方,添加 binding.executePendingBindings()。此调用是一种优化,用于要求数据绑定功能立即执行任何待处理的绑定。当您在 RecyclerView 中使用绑定适配器时,最好调用 executePendingBindings(),因为它可以略微加快调整视图大小的过程。
 binding.executePendingBindings()

第 3 步:向 XML 布局添加绑定

  1. 打开 list_item_sleep_night.xml。
  2. 在 ImageView 中,添加与设置图片的绑定适配器同名的 app 属性。传入 sleep 变量,如下所示。

此属性通过适配器建立视图与绑定对象之间的连接。每当引用 sleepImage 时,适配器都会调整 SleepNight 中的数据。

app:sleepImage="@{sleep}"
  1. 现在,为 sleep_length 和 quality_string 文本视图添加类似的应用属性。每当引用 sleepDurationFormatted 或 sleepQualityString 时,适配器都会调整来自 SleepNight 中的数据。请务必将每个属性分别放入其各自的 TextView. 中
app:sleepDurationFormatted="@{sleep}"
app:sleepQualityString="@{sleep}"
  1. 运行您的应用,其运行情况与之前完全一样。绑定适配器负责处理随着数据变化而格式化和更新视图的所有工作,从而简化 ViewHolder 并为代码提供比之前更好的结构。

您针对最后几个练习显示的列表是相同的。这是有意设计的,目的是向您表明 Adapter 接口让您可以许多不同的方式设计代码架构。代码越复杂,合理设计代码架构就越重要。在正式版应用中,这些模式和其他模式均可与 RecyclerView 结合使用。这些模式都是有效的,而且分别都有各自的优势。您应选择哪一个模式取决于您要构建什么应用。

祝贺您!至此,您已掌握 Android 中的 RecyclerView 的知识。

Android Studio 项目:RecyclerViewDiffUtilDataBinding。

DiffUtil

  • RecyclerView 有一个名为 DiffUtil 的类,用于计算两个列表之间的差异。
  • DiffUtil 有一个名为 ItemCallBack 的类,可以扩展此类以确定两个列表之间的差异。
  • 在 ItemCallback 类中,您必须替换 areItemsTheSame() 和 areContentsTheSame() 方法。

ListAdapter

  • 如需免费获取部分列表管理功能,您可以使用 ListAdapter 类,而不是 RecyclerView.Adapter。不过,如果您使用 ListAdapter,则必须为其他布局编写您自己的适配器,所以此 Codelab 向您介绍了具体应如何操作。
  • 如需在 Android Studio 中打开 intent 菜单,请将光标放在任意代码项上,然后按 Alt+Enter(在 Mac 上,按 Option+Enter)。该菜单对于重构代码以及为实现各种方法创建桩特别有用。该菜单与上下文相关,因此您需要准确放置光标才能获得正确的菜单。

数据绑定

  • 在项布局中使用数据绑定将数据绑定到视图。

绑定适配器

  • 您之前使用了 Transformations 来根据数据创建字符串。如果您需要绑定不同类型或复杂类型的数据,请提供绑定适配器来帮助数据绑定功能使用这些类型。
  • 如需声明绑定适配器,请定义一个接受项和视图的方法,并为该方法添加 @BindingAdapter 注解。在 Kotlin 中,您可以在 View 上将绑定适配器编写为扩展函数。传入适配器调整的属性的名称。例如:
@BindingAdapter("sleepDurationFormatted")
  • 在 XML 布局中,设置与绑定适配器同名的 app 属性。传入包含数据的变量。例如:
.app:sleepDurationFormatted="@{sleep}"

Udacity 课程:

Android 开发者文档:

其他资源:

此部分列出了在由教师指导的课程教学中,学生完成此 Codelab 后可能需要做的家庭作业。教师自行决定是否执行以下措施:

  • 根据需要布置作业。
  • 告知学生如何提交家庭作业。
  • 给家庭作业评分。

教师可以酌情采用这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。

如果您是在自学此 Codelab,可自由使用这些家庭作业来检测您的知识掌握情况。

回答以下问题

问题 1

要使用 DiffUtil,必须执行以下哪些操作?请选择所有适用的选项。

▢ 扩展 ItemCallBack 类。

▢ 替换 areItemsTheSame()。

▢ 替换 areContentsTheSame()。

▢ 使用数据绑定跟踪各个项之间的差异。

问题 2

以下关于绑定适配器的表述中,哪些是正确的?

▢ 绑定适配器是使用 @BindingAdapter 注解的函数。

▢ 使用绑定适配器让您可以将数据格式化与 ViewHolder 分开。

▢ 如果您想使用绑定适配器,则必须使用 RecyclerViewAdapter。

▢ 当需要转换复杂数据时,绑定适配器是一个很好的解决方案。

问题 3

在什么情况下应考虑使用 Transformations 而不使用绑定适配器?请选择所有适用的选项。

▢ 您的数据很简单。

▢ 您要设置字符串格式。

▢ 您的列表很长。

▢ 您的 ViewHolder 仅包含一个视图。

开始学习下一课:GridLayout 和 RecyclerView