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 和视图构建基本界面。
  • 在 fragment 之间导航,并使用 safeArgs 在 fragment 之间传递数据。
  • 查看模型、查看模型工厂、转换以及 LiveData 及其观察者。
  • 如何创建 Room 数据库、创建 DAO 以及定义实体。
  • 如何将协程用于数据库任务和其他长时间运行的任务。
  • 如何实现具有 AdapterViewHolder 和项目布局的基本 RecyclerView

学习内容

  • 如何使用 DiffUtil 高效地更新 RecyclerView 显示的列表。
  • 如何将数据绑定与 RecyclerView 搭配使用?
  • 如何使用绑定适配器转换数据。

您将执行的操作

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

睡眠跟踪器应用有两个屏幕(由 fragment 表示),如下图所示。

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

该应用的架构是使用界面控制器 ViewModelLiveData 以及一个 Room 数据库来存储持久性数据。

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

您可以继续使用上一个 Codelab 中的 SleepTracker 应用,也可以从 GitHub 下载 RecyclerViewDiffUtilDataBinding-Starter 应用

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

  • 应用根据用户输入创建 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),然后选择实现成员
  3. 在打开的对话框中,点击鼠标左键以选择 areItemsTheSame()areContentsTheSame() 方法,然后点击确定

    此操作会为 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 替换为用于测试两个传入 SleepNightoldItemnewItem 是否相同的代码。如果这两个项具有相同的 nightId,则表明它们是相同的,因此返回 true。否则返回 falseDiffUtil 使用此测试来帮助发现内容是添加、移除还是移动的。
override fun areItemsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
   return oldItem.nightId == newItem.nightId
}
  1. areContentsTheSame() 中,检查 oldItemnewItem 是否包含相同的数据;即判断它们是否相等。由于 SleepNight 是一个数据类,此相等性检查将检查所有字段。Data 类会自动为您定义 equals 和一些其他方法。如果 oldItemnewItem 之间存在差异,此代码会告知 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() 及时更新列表

在有已更改的列表时,您的代码需要告知 ListAdapterListAdapter 提供了一个名为 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. 运行应用。如果应用的运行规模较小,运行速度会更快,可能也不会太明显。

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

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

  1. Text 标签页中打开 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. ViewHolder 类中,找到 from() 方法。
  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. 在函数末尾,返回 binding,而不是返回 view
return ViewHolder(binding)
  1. 要消除该错误,请将光标置于 binding 一词上。按 Alt+Enter(在 Mac 上,按 Option+Enter)打开 intent 菜单。
  1. 选择将参数 'itemView' 的主要构造函数类型更改为 'ViewHolder' to 'ListItemSleepNightBinding'。这将更新 ViewHolder 类的参数类型。

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

    ViewHolder 类定义中,右键点击出现的某个 itemView,然后选择 Refactor > Rename。将名称更改为 binding
  2. 为构造函数参数 binding 添加 val 前缀,使其成为属性。
  3. 在对父类 RecyclerView.ViewHolder 的调用中,将参数从 binding 更改为 binding.root。您需要传递 Viewbinding.root 是项目布局中的根 ConstraintLayout
  4. 完成后的类声明应如以下代码所示。
class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root){

您还会看到对 findViewById() 的调用出错,接下来您会解决此问题。

第 3 步:替换 findViewById()

您现在可以更新 sleepLengthqualityqualityImage 属性,以使用 binding 对象代替 findViewById()

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

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

  1. 右键点击 sleepLengthqualityqualityImage 属性名称。选择 Refactor > Inline,或按 Control+Command+N(在 Mac 上,按 Option+Command+N)。
  2. 运行您的应用。(如果项目存在错误,您可能需要清理重新构建。)

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

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

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

第 1 步:创建绑定适配器

请注意,您必须在此步骤中导入多个类,而且系统不会逐个调用该类。

  1. 打开 SleepNightAdapater.kt
  2. ViewHolder 类中,找到 bind() 方法并注意该方法的用途。您需要获取用于计算 binding.sleepLengthbinding.qualitybinding.qualityImage 的值的代码,并在适配器中改用该代码。(目前不要更改代码,您需要在后续步骤中移动代码。)
  3. sleeptracker 软件包中,创建并打开名为 BindingUtils.kt 的文件。
  4. TextView 上声明一个名为 setSleepDurationFormatted 的扩展函数,并传入 SleepNight。此函数将是计算睡眠持续时间和设置睡眠时间的格式的适配器。
fun TextView.setSleepDurationFormatted(item: SleepNight) {}
  1. setSleepDurationFormatted 的正文中,将数据绑定到视图,如在 ViewHolder.bind() 中一样。调用 convertDurationToFormatted(),然后将 TextViewtext 设置为格式化文本。(由于这是 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
   })
}

第 2 步:更新 SleepNightAdapter

  1. 打开 SleepNightAdapter.kt
  2. 删除 bind() 方法中的所有内容,因为您现在可以使用数据绑定和新的适配器来为您执行这项操作。
fun bind(item: SleepNight) {
}
  1. bind() 中,将休眠分配给 item,因为您需要向绑定对象提供有关新的 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_lengthquality_string 文本视图执行相同的操作。每当引用 sleepDurationFormattedsleepQualityString 时,适配器都会调整来自 SleepNight 的数据。
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 只包含一个视图。

开始学习下一课:7.3: GridLayout with RecyclerView