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 和定义实体。
  • 如何将协程用于数据库任务和其他长时间运行的任务。
  • 如何使用 AdapterViewHolder 和项布局实现基本 RecyclerView

学习内容

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

实践内容

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

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

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

此应用的架构采用一个界面控制器、ViewModelLiveData,以及一个用于保留睡眠数据的 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 替换为用于测试两个传入 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 作为第一个参数添加到 ListAdapterSleepNightAdapter.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. 运行您的应用。您可能需要导入 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()

您现在可以更新 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,或按 Ctrl+Alt+N(在 Mac 上按 Option+Command+N)。b136364471dd01fb.png
  2. 运行您的应用。(如果项目出现错误,您可能需要清理重建项目。)

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

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

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

第 1 步:创建绑定适配器

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

  1. 打开 SleepNightAdapter.kt
  2. ViewHolder 类中,找到 bind() 方法并注意该方法的用途。您需要获取用于计算 binding.sleepLengthbinding.qualitybinding.qualityImage 的值的代码,并在适配器中改用该代码。(目前不要更改代码,您需要在后续步骤中移动代码。)
  3. sleeptracker 软件包中,创建一个名为 BindingUtils.kt 的新文件并打开此文件。
  4. 删除 BindingUtils 类中的所有内容,因为您接下来要创建静态函数。
class BindingUtils {}
  1. 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
   })
}

您可能需要导入 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_lengthquality_string 文本视图添加类似的应用属性。每当引用 sleepDurationFormattedsleepQualityString 时,适配器都会调整来自 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