加载和显示来自互联网的图片

简介

在上一个 Codelab 中,您学习了如何从网络服务中获取数据,以及如何将响应解析为 Kotlin 对象。在本 Codelab 中,您将利用这些知识从一个网址加载和显示照片。此外,您还将回顾如何构建 RecyclerView 以及用它在概览页面上显示图片网格。

前提条件

  • 如何创建和使用 fragment。
  • 如何从 REST 网络服务中检索 JSON,并使用 RetrofitMoshi 库将该数据解析为 Kotlin 对象。
  • 如何使用 RecyclerView 构建网格布局。
  • AdapterViewHolderDiffUtil 如何工作。

学习内容

  • 如何使用 Coil 库从一个网址加载和显示图片。
  • 如何使用 RecyclerView 和网格适配器显示图片网格。
  • 如何处理图片下载和显示时的潜在错误。

构建内容

  • 修改 MarsPhotos 应用,从火星数据中获取图片网址,并使用 Coil 加载和显示该图片。
  • 将加载动画和错误图标添加到应用中。
  • 使用 RecyclerView 显示火星图片网格。
  • 将状态和错误处理添加到 RecyclerView

所需条件

  • 一台安装了新版网络浏览器(如最新版 Chrome)的计算机。
  • 计算机可以访问互联网。

在此 Codelab 中,您可以继续使用上一个 Codelab 中名为 MarsPhotos 的应用。MarsPhotos 应用会连接到网络服务,以检索并显示使用 Retrofit 检索到的 Kotlin 对象数量。这些 Kotlin 对象包含了 NASA 火星探测器拍摄的火星表面真实照片的网址。

您将在此 Codelab 中构建的应用版本会填充概览页面,该页面将以图片网格形式显示火星照片。这些图片是您的应用从 Mars 网络服务检索到的数据的一部分。您的应用将使用 Coil 库加载和显示图片,并使用 RecyclerView 为图片创建网格布局。您的应用还将妥善处理网络错误。

1b33675b009bee15.png

从一个网址显示照片可能听起来非常简单,但实际上却需要完成大量工程才能正常运行。图片必须下载、在内部存储并从其压缩格式解码为 Android 可使用的图片。应将图片缓存到内存缓存、基于存储空间的缓存或这两种缓存中。所有操作都必须在低优先级的后台线程中进行,以便界面保持快速响应。另外,为获得最佳网络和 CPU 性能,可能需要同时获取和解码多张图片。

幸好,您可以使用名为 Coil 的社区开发的库下载、缓冲、解码以及缓存您的图片。如果不使用 Coil,您会需要执行更多操作。

Coil 基本上需要有以下两项:

  • 需要加载和显示的图片的网址。
  • 用于实际显示该图片的 ImageView 对象。

在此任务中,您将学习如何使用 Coil 显示火星网络服务中的单张图片。您可以在网络服务返回的照片列表中显示第一张火星照片的图片。下面是操作之前和之后的屏幕截图:

添加 Coil 依赖项

  1. 打开上一个 Codelab 中的 MarsPhotos 解决方案应用。
  2. 运行该应用即可查看其功能。(显示检索到的火星照片的总数)。
  3. 打开 build.gradle (Module: app)
  4. dependencies 部分中,为 Coil 库添加下面这行代码:
    // Coil
    implementation "io.coil-kt:coil:1.1.1"

Ccee 文档页面查看并更新该库的最新版本。

  1. Coil 库托管在 mavenCentral() 代码库中,并可以通过该代码库使用。在 build.gradle (Project: MarsPhotos) 中,在顶部的 repositories 代码块中添加 mavenCentral()
repositories {
   google()
   jcenter()
   mavenCentral()
}
  1. 请点击 Sync Now,以使用新的依赖项重建项目。

更新 ViewModel

在此步骤中,您将向 OverviewViewModel 类添加 LiveData 属性,以存储接收到的 Kotlin 对象,即 MarsPhoto。

  1. 打开 overview/OverviewViewModel.kt。在 _status 属性声明的下面,添加一个名为 _photos 的新可变属性,其类型为 MutableLiveData,可存储单个 MarsPhoto 对象。
private val _photos = MutableLiveData<MarsPhoto>()

根据需要导入 com.example.android.marsphotos.network.MarsPhoto

  1. _photos 声明的下面,添加一个名为 photos 的公开后备字段 LiveData<MarsPhoto>
val photos: LiveData<MarsPhoto> = _photos
  1. getMarsPhotos() 方法中的 try{} 代码块内,找到用于将从网络服务中检索到的数据设置为 listResult. 的代码行
try {
   val listResult = MarsApi.retrofitService.getPhotos()
   ...
}
  1. 将检索到的第一张火星照片分配给新变量 _photos。将 listResult 更改为 _photos.value。在索引 0 处指定前几张照片的网址。这会抛出一个错误,您稍后将进行修复。
try {
   _photos.value = MarsApi.retrofitService.getPhotos()[0]
   ...
}
  1. 在下一行代码中,将 status.value 更新为以下内容。使用新属性中的数据,而非 listResult。显示照片列表中的第一张图片的网址。.
try {
   ...
   _status.value = "   First Mars image URL : ${_photos.value!!.imgSrcUrl}"

}
  1. 完整的 try{} 代码块现在应如下所示:
try {
   _photos.value = MarsApi.retrofitService.getPhotos()[0]
   _status.value = "   First Mars image URL : ${_photos.value!!.imgSrcUrl}"
}
  1. 运行应用。现在,TextView 会显示第一张火星照片的网址。到目前为止,您已为该网址设置 ViewModelLiveData

b8ac93805b69b03a.png

使用绑定适配器

绑定适配器是用于为视图的自定义属性创建自定义 setter 的注解方法。

通常,当使用 android:text="Sample Text" 代码在 XML 中设置属性时,Android 系统会自动查找名称与 text 属性相同的 setter,后者由 setText(String: text) 方法设置。setText(String: text) 方法是 Android 框架提供的某些视图的 setter 方法。可以使用绑定适配器自定义类似行为;您可以提供一个自定义数据和自定义逻辑,该逻辑将由数据绑定库调用。

示例:

执行比只是对图片视图调用 setter 方法更为复杂的操作,设置一张可绘制的图片。考虑从互联网加载界面线程(主线程)中的图片。首先,选择一个自定义属性以将图片分配给 ImageView。在以下示例中,为 imageUrl

<ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:imageUrl="@{product.imageUrl}"/>

如果您未添加任何其他代码,系统会在 ImageView 中查找 setImageUrl(String) 方法,但不会找到该方法,并且会抛出错误,因为这是框架未提供的自定义属性。您必须创建一个实现方法并将 app:imageUrl 属性设置为 ImageView。您将使用绑定适配器(注解方法)来完成此操作。

绑定适配器示例

@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        // Load the image in the background using Coil.
        }
    }
}

@BindingAdapter 注解会将属性名称作为其参数。

bindImage 方法中,第一个方法参数是目标视图类型,第二个参数是要设置为属性的值。

在该方法内,Coil 库从界面线程中加载图片,并将其设置为 ImageView

创建绑定适配器并使用 Coil

  1. com.example.android.marsphotos 软件包中,创建一个名为 BindingAdapters 的 Kotlin 文件。此文件将保留您在整个应用中使用的绑定适配器。

a04afbd6ae8ccfcd.png

  1. BindingAdapters.kt 中,创建一个 bindImage() 函数,该函数将 ImageViewString 作为参数。
fun bindImage(imgView: ImageView, imgUrl: String?) {

}

在收到请求时,导入 android.widget.ImageView

  1. 使用 @BindingAdapter 为该函数添加注解。@BindingAdapter 注解用于指示数据绑定,在视图项目具有 imageUrl 属性时执行此绑定适配器。
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {

}

在收到请求时,导入 androidx.databinding.BindingAdapter

let 作用域函数

let 是 Kotlin 的 Scope 函数之一,让您能够在对象的上下文中执行代码块。Kotlin 中有五个作用域函数,请参阅文档以了解详情。

用法:

let 用于对调用链结果调用一个或多个函数。

let 函数和安全调用运算符 (?.) 用于对对象执行 null 安全操作。在这种情况下,仅当对象不为 null 时,系统才会执行 let 代码块。

  1. bindImage() 函数中,使用安全调用运算符向 imageURL 参数添加 let{} 代码块。
imgUrl?.let {
}
  1. let{} 代码块内,添加以下代码行,以使用 toUri() 方法将网址字符串转换为 Uri 对象。如需使用 HTTPS 架构,请将 buildUpon.scheme("https") 附加到 toUri 构建器。调用 build() 以构建对象。
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()

在收到请求时,导入 androidx.core.net.toUri

  1. let{} 代码块中,在 imgUri 声明之后,使用 Coil 中的 load(){}imgUri 对象中的图片加载到 imgView
imgView.load(imgUri) {
}

在收到请求时,导入 coil.load

  1. 您的完整方法应如下所示:
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        imgView.load(imgUri)
    }
}

更新布局和 fragment

在上一部分中,您使用了 Coil 图片库来加载图片。如需在屏幕上显示图片,下一步是使用新属性更新 ImageView,以显示单张图片。

稍后在 Codelab 中,您将使用 res/layout/grid_view_item.xml 作为 RecyclerView 中的每个网格项的布局资源文件。在此任务中,您将暂时使用此文件,用您在上一个任务中检索到的图片网址来显示图片。您暂时使用的是此布局文件,而不是 fragment_overview.xml

  1. 打开 res/layout/grid_view_item.xml
  2. <ImageView> 元素上方,为数据绑定添加 <data> 元素,并绑定到 OverviewViewModel 类:
<data>
   <variable
       name="viewModel"
       type="com.example.android.marsphotos.overview.OverviewViewModel" />
</data>
  1. app:imageUrl 属性添加到 ImageView 元素中,以使用新的图片加载绑定适配器。请注意,photos 包含从服务器检索到的列表 MarsPhotos。为 imageUrl 属性分配第一个条目网址。
    <ImageView
        android:id="@+id/mars_image"
        ...
        app:imageUrl="@{viewModel.photos.imgSrcUrl}"
        ... />
  1. 打开 overview/OverviewFragment.kt。在 onCreateView() 方法中,注解掉扩充 FragmentOverviewBinding 类的代码行并将它分配给绑定变量。您将看到由于删除此代码行而出现的错误。这只是临时错误,您稍后将对这些错误进行修复。
//val binding = FragmentOverviewBinding.inflate(inflater)
  1. 请使用 grid_view_item.xml,而不是 fragment_overview.xml.。添加以下代码行,以扩充 GridViewItemBinding 类。
val binding = GridViewItemBinding.inflate(inflater)

如果收到请求,则导入 com.example.android.marsphotos. databinding.GridViewItemBinding

  1. 请运行应用。现在,您应该会看到一张火星图片。

e59b6e849e63ae2b.png

添加加载和错误图片

使用 Coil 时,系统会在加载图片时显示占位符图片,并在加载失败时显示错误消息(例如,图片丢失或损坏),从而可以改善用户体验。在此步骤中,您需要将该功能添加到绑定适配器中。

  1. 打开 res/drawable/ic_broken_image.xml,然后点击右侧的 Design 标签页。对于错误图片,您将使用内置图标库中提供的损坏图片图标。此矢量可绘制对象使用 android:tint 属性将图标设为灰色。

467c213c859e1904.png

  1. 打开 res/drawable/loading_animation.xml。该可绘制对象是围绕中心点旋转图片可绘制对象 loading_img.xml 的动画。(您在预览中看不到这段动画。)

6c1f87d1c932c762.png

  1. 返回 BindingAdapters.kt 文件。在 bindImage() 方法中,更新 imgView.load(imgUri) 的调用以添加尾部 lambda,如下所示:此代码可设置加载时要使用的占位符加载图片(loading_animation 可绘制对象)。此代码还可设置图片加载失败时要使用的图片(broken_image 可绘制对象)。
imgView.load(imgUri) {
   placeholder(R.drawable.loading_animation)
   error(R.drawable.ic_broken_image)
}
  1. 完整的 bindImage() 方法现在看上去会像下面这样:
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        imgView.load(imgUri) {
            placeholder(R.drawable.loading_animation)
            error(R.drawable.ic_broken_image)
        }
    }
}
  1. 请运行应用。根据网络连接速度,您可能会短暂地看到加载图片显示为 Glide 下载内容,并显示属性图片。但是您不会看到损坏图片图标,即使您关闭网络也是如此 - 您将在 Codelab 的最后一个任务中修复该问题。

80553d5e5c7641de.gif

  1. 还原您在 overview/OverviewFragment.kt 中进行的临时更改。在方法 onCreateview() 中,取消备注扩充 FragmentOverviewBinding 的代码行。删除或注解掉扩充 GridViewIteMBinding 的代码行。
val binding = FragmentOverviewBinding.inflate(inflater)
 // val binding = GridViewItemBinding.inflate(inflater)

您的应用现在从互联网加载了一张火星照片。您使用前 MarsPhoto 个列表项的数据在 ViewModel 中创建了一个 LiveData 属性,并使用该火星照片数据中的图片网址填充了 ImageView。但是,应用的目标是显示图片网格,因此,在此任务中,您将使用带网格布局管理器的 RecyclerView 来显示图片网格。

更新 ViewModel

在上一个任务中,您在 OverviewViewModel 中添加了一个名为 _photosLiveData 对象,用于保存一个 MarsPhoto 对象,即来自网络服务的响应列表中的第一个对象。在这一步中,您将更改此 LiveData 以保存 MarsPhoto 对象的完整列表。

  1. 打开 overview/OverviewViewModel.kt
  2. _photos 类型更改为 MarsPhoto 对象的列表。
private val _photos = MutableLiveData<List<MarsPhoto>>()
  1. 将后备属性 photos 类型也替换为 List<MarsPhoto> 类型:
 val photos: LiveData<List<MarsPhoto>> = _photos
  1. 向下滚动到 getMarsPhotos() 方法中的 try {} 代码块。MarsApi.retrofitService.getPhotos()

返回一个 MarsPhoto 对象列表,您可以只将该列表分配给 _photos.value

_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
  1. 完整的 try/catch 代码块现在应如下所示:
try {
    _photos.value = MarsApi.retrofitService.getPhotos()
    _status.value = "Success: Mars properties retrieved"
} catch (e: Exception) {
    _status.value = "Failure: ${e.message}"
}

网格布局

RecyclerViewGridLayoutManager 将数据布局为可滚动的网格,如下所示。

fcf0fc4b78f8650.png

从设计角度来看,网格布局最适合可以表示为图标或图片的列表,例如火星照片浏览应用中的列表。

网格布局如何布局列表项

网格布局将以行和列的网格安排列表项。假定您采用的是垂直滚动方式,默认情况下,一行中的每个列表项会占据一个“span”。一个列表项可以占据多个 span。在下面的案例中,一个 span 相当于列宽为 3。

在下面的两个示例中,每一行由三个 span 组成。默认情况下,GridLayoutManager 会在一个 span 中布局每个列表项,直到达到您指定的 span 计数为止。达到 span 计数时,它会换行至下一行。

添加 Recyclerview

在此步骤中,您将更改应用布局,使用带有网格布局的 Recycler 视图,而非单个图片视图。

  1. 打开 layout/gridview_item.xml。移除 viewModel 数据变量。
  2. <data> 标签内,添加类型为 MarsPhoto 的以下 photo 变量。
<data>
   <variable
       name="photo"
       type="com.example.android.marsphotos.network.MarsPhoto" />
</data>
  1. <ImageView> 中,更改 app:imageUrl 属性以引用 MarsPhoto 对象中的图片网址。这些更改将撤消您在上一个任务中所做的临时更改。
app:imageUrl="@{photo.imgSrcUrl}"
  1. 打开 layout/fragment_overview.xml。删除整个 <TextView> 元素。
  2. 改为添加以下 <RecyclerView> 元素。将 ID 设置为 photos_grid,将 widthheight 属性设置为 0dp,因此它会填充父级 ConstraintLayout。您将使用网格布局,因此请将 layoutManager 属性设置为 androidx.recyclerview.widget.GridLayoutManager。将 spanCount 设置为 2,您将得到两列。
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/photos_grid"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layoutManager=
       "androidx.recyclerview.widget.GridLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:spanCount="2" />
  1. 如需预览上述代码在 Design 视图中的显示效果,请使用 tools:itemCount 将布局中显示的项数设置为 16itemCount 属性指定了布局编辑器应该在 Preview 窗口中呈现的项数。使用 tools:listitem 将列表项的布局设置为 grid_view_item
<androidx.recyclerview.widget.RecyclerView
            ...
            tools:itemCount="16"
            tools:listitem="@layout/grid_view_item" />
  1. 切换到 Design 视图,您应该会看到类似如下屏幕截图的预览。这看起来不是火星照片,但是将显示您的 Recycler 视图网格布局看起来会是什么样。预览为 recyclerview 的每个网格项使用内边距和 grid_view_item 布局。

20742824367c3952.png

  1. 根据 Material Design 准则,列表顶部、底部和侧边之间应留出 8dp 的空间,而列表项之间应留出 4dp 的空间。您可以通过结合使用 fragment_overview.xml 布局和 gridview_item.xml 布局中的内边距来实现此目的。

a3561fa85fea7a8f.png

  1. 打开 layout/gridview_item.xml。请注意 padding 属性,列表项的外侧和内容之间已有 2dp 的内边距。这样的话,我们在列表项内容之间就会有 4dp 的空间,而外边缘会有 2dp 的空间,这意味着我们需要对外边缘再添加 6dp 内边距,这样才符合设计准则。
  2. 返回 layout/fragment_overview.xml。为 RecyclerView 添加 6dp 的内边距,这样一来,作为准则,外侧将有 8dp 空间,内侧将有 4dp 空间。
<androidx.recyclerview.widget.RecyclerView
            ...
            android:padding="6dp"
            ...  />
  1. 完整的 <RecyclerView> 元素应如下所示。
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/photos_grid"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:padding="6dp"
    app:layoutManager=
        "androidx.recyclerview.widget.GridLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:spanCount="2"
    tools:itemCount="16"
    tools:listitem="@layout/grid_view_item"  />

添加照片网格适配器

现在,fragment_overview 布局具有包含网格布局的 RecyclerView。在此步骤中,您需要通过 RecyclerView 适配器将从网络服务器检索的数据绑定到 RecyclerView

ListAdapter(刷新程序)

ListAdapterRecyclerView.Adapter 类的子类,用于在 RecyclerView 中显示列表数据,包括后台线程上列表之间的计算差异。

在此应用中,您将在 ListAdapter. 中使用 DiffUtil 实现。使用 DiffUtil 的优势在于,每次添加、移除或更改 RecyclerView 中的某个列表项时,不会刷新整个列表。系统只会刷新已更改的列表项。

ListAdapter 添加到您的应用。

  1. overview 软件包中,创建一个名为 PhotoGridAdapter.kt 的 Kotlin 类。
  2. 使用如下所示的构造函数参数扩展 ListAdapter 中的 PhotoGridAdapter 类。PhotoGridAdapter 类扩展了 ListAdapter,其构造函数需要列表项类型、视图容器以及 DiffUtil.ItemCallback 实现。
class PhotoGridAdapter : ListAdapter<MarsPhoto,
        PhotoGridAdapter.MarsPhotoViewHolder>(DiffCallback) {
}

如果收到请求,则导入 androidx.recyclerview.widget.ListAdaptercom.example.android.marsphoto.network.MarsPhoto 类。在下面的步骤中,您将实现该构造函数的其他缺失实现,这些实现会产生错误。

  1. 要解决上述错误,您需要在此步骤中添加所需的方法,并在此任务的稍后过程中实现。点击 PhotoGridAdapter 类,点击红色灯泡,然后从下拉菜单中选择实现成员。在弹出式窗口中,选择 ListAdapter 方法,即 onCreateViewHolder()onBindViewHolder()。Android Studio 仍会显示错误,您将在此任务结束时修复这些错误。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridAdapter.MarsPhotoViewHolder {
   TODO("Not yet implemented")
}

override fun onBindViewHolder(holder: PhotoGridAdapter.MarsPhotoViewHolder, position: Int) {
   TODO("Not yet implemented")
}

要实现 onCreateViewHolderonBindViewHolder 方法,需要添加 MarsPhotoViewHolder,您将在下一步中添加该代码。

  1. PhotoGridAdapter 中,为 MarsPhotoViewHolder 添加内部类定义,该定义可扩展 RecyclerView.ViewHolder。您需要使用 GridViewItemBinding 变量将 MarsPhoto 绑定到布局,以便将变量传递到 MarsPhotoViewHolder。基础 ViewHolder 类需要其构造函数中的一个视图,您需要向其传递绑定根视图。
class MarsPhotoViewHolder(private var binding:
                   GridViewItemBinding):
       RecyclerView.ViewHolder(binding.root) {
}

如果收到请求,则导入 androidx.recyclerview.widget.RecyclerViewcom.example.android.marsrealestate.databinding.GridViewItemBinding

  1. MarsPhotoViewHolder 中,创建一个 bind() 方法,该方法获取 MarsPhoto 对象作为参数,并将 binding.property 设置为该对象。设置属性后,请调用 executePendingBindings(),这会导致更新立即执行。
fun bind(MarsPhoto: MarsPhoto) {
   binding.photo = MarsPhoto
   binding.executePendingBindings()
}
  1. 依然在 onCreateViewHolder()PhotoGridAdapter 类中,移除 TODO 并添加如下所示的代码行。onCreateViewHolder() 方法需要返回新的 MarsPhotoViewHolder,方法是扩充 GridViewItemBinding 并使用父级 ViewGroup 上下文中的 LayoutInflater
   return MarsPhotoViewHolder(GridViewItemBinding.inflate(
      LayoutInflater.from(parent.context)))

如果收到请求,则导入 android.view.LayoutInflater

  1. onBindViewHolder() 方法中,移除 TODO 并添加如下所示的代码行。在这里,您将调用 getItem() 以获取与当前 RecyclerView 位置关联的 MarsPhoto 对象,然后将该属性传递给 MarsPhotoViewHolder 中的 bind() 方法。
val marsPhoto = getItem(position)
holder.bind(marsPhoto)
  1. PhotoGridAdapter 中,为 DiffCallback 添加伴生对象定义,如下所示。
    DiffCallback 对象使用您想要比较的泛型对象类型 MarsPhoto 来扩展 DiffUtil.ItemCallback。您将在此实现中比较两个火星照片对象。
companion object DiffCallback : DiffUtil.ItemCallback<MarsPhoto>() {
}

在收到请求时,导入 androidx.recyclerview.widget.DiffUtil

  1. 按住红色灯泡,为 DiffCallback 对象实现比较条件方法,即 areItemsTheSame()areContentsTheSame()
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   TODO("Not yet implemented")
}

override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   TODO("Not yet implemented") }
  1. areItemsTheSame() 方法中,移除 TODODiffUtil 将调用此方法来确定两个对象是否代表相同的列表项。DiffUtil 使用此方法来确定新的 MarsPhoto 对象是否与旧的 MarsPhoto 对象相同。每个列表项(MarsPhoto 对象)的 ID 都是唯一的。比较 oldItemnewItem 的 ID,然后返回结果。
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.id == newItem.id
}
  1. areContentsTheSame() 中,移除 TODODiffUtil 会在需要检查两个列表项的数据是否相同时调用此方法。MarsPhoto 中的重要数据是图片网址。比较 oldItemnewItem 的网址,然后返回结果。
override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.imgSrcUrl == newItem.imgSrcUrl
}

请确保您能够编译和运行应用,不会出现任何错误,但模拟器会显示空白屏幕。您已经准备好了 Recycler 视图,但没有向它传递任何数据。您将在下一步中实现此操作。

添加绑定适配器并连接各部分

在此步骤中,您将使用 BindingAdapter 通过 MarsPhoto 对象列表初始化 PhotoGridAdapter。使用 BindingAdapter 设置 RecyclerView 数据会导致数据绑定自动观察 MarsPhoto 对象列表的 LiveData。然后,当 MarsPhoto 列表发生更改时,系统会自动调用绑定适配器。

  1. 打开 BindingAdapters.kt
  2. 在文件末尾添加 bindRecyclerView() 方法,该方法会获取 RecyclerViewMarsPhoto 对象列表作为参数。使用带有 listData 属性的 @BindingAdapter 为该方法添加注解。
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
    data: List<MarsPhoto>?) {
}

如果收到请求,则导入 androidx.recyclerview.widget.RecyclerViewcom.example.android.marsphotos.network.MarsPhoto

  1. bindRecyclerView() 函数中,将 recyclerView.adapter 类型转换为 PhotoGridAdapter 并将其分配给新的 val 属性 adapter.
val adapter = recyclerView.adapter as PhotoGridAdapter
  1. bindRecyclerView() 函数末尾,使用火星照片列表数据调用 adapter.submitList()。此代码会在有新列表可供使用时告知 RecyclerView
adapter.submitList(data)

如果收到请求,则导入 com.example.android.marsrealestate.overview.PhotoGridAdapter

  1. 完整的 bindRecyclerView 绑定适配器应如下所示:
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
                    data: List<MarsPhoto>?) {
   val adapter = recyclerView.adapter as PhotoGridAdapter
   adapter.submitList(data)

}
  1. 如需将所有内容关联在一起,请打开 res/layout/fragment_overview.xml。向 RecyclerView 元素中添加 app:listData 属性,并使用数据绑定将该属性设置为 viewmodel.photos。这和在上一个任务中针对 ImageView 执行的操作类似。
app:listData="@{viewModel.photos}"
  1. 打开 overview/OverviewFragment.kt。在 onCreateView() 中的 return 语句前面,将 binding.photosGrid 中的 RecyclerView 适配器初始化为新的 PhotoGridAdapter 对象。
binding.photosGrid.adapter = PhotoGridAdapter()
  1. 请运行应用。您应该会看到一个包含滚动的火星照片的网格。在您滚动查看新图片时,这个网格看起来有点奇怪。在您滚动时,RecyclerView 的顶部和底部始终会显示内边距,因此它看起来并不像是在操作栏下方滚动的列表。

5d03641aa1589842.png

  1. 要修复此错误,需要告知 RecyclerView 不要使用 android:clipToPadding 属性将内部内容裁剪到内边距。这样会使它在内边距区域绘制滚动视图。返回 layout/fragment_overview.xml。为 RecyclerView 添加 android:clipToPadding 属性,并将该属性设置为 false
<androidx.recyclerview.widget.RecyclerView
            ...
            android:clipToPadding="false"
            ...  />
  1. 运行您的应用。请注意,应用会像预期的那样,在显示图片本身之前先显示加载进度图标。这是您传递给 Coil 图片库的占位符加载图片。

3128b84aa22ef97e.png

  1. 在应用运行时,开启飞行模式。在模拟器中滚动图片。尚未加载的图片显示为损坏图片图标。这是您传递给 Coil 图片库,以便在出现任何网络错误或无法获取图片时显示的图片可绘制对象。

28d2cbba564f35ff.png

恭喜,您即将大功告成!在下一个也就是最后一个任务中,您将通过向应用添加更多错误处理属性来进一步改善用户体验。

MarsPhotos 应用会在无法获取图片时显示损坏图片图标。但在没有网络连接时,应用会显示空白屏幕。您将在下一步中对空白屏幕进行验证。

  1. 在设备或模拟器上,开启飞行模式。从 Android Studio 运行应用。请注意空白屏幕。

492011786c2dd7f7.png

这样的用户体验并不是很好。在此任务中,您将添加一个基础错误处理属性,以便用户可以清楚地了解所发生的情况。如果互联网不可用,应用将显示连接错误图标,而在应用获取 MarsPhoto 列表时,应用将显示加载动画。

向 ViewModel 添加状态

在此任务中,您将在 OverviewViewModel 中创建一个属性来表示网络请求的状态。需要考虑以下三种状态:正在加载、成功和失败。等待数据时,出现加载状态。在我们成功从网络服务中检索数据时,出现成功状态。失败状态则表示任何网络或连接错误。

枚举 Kotlin 中的类

如需表示应用中的这三种状态,您将使用 enumenum 是枚举的缩写,意思是集合中所有项的有序列表。每个 enum 常量都是 enum 类的对象。

在 Kotlin 中,enum 是一种数据类型,可以保留一组常量。可以通过在类定义前添加关键字 enum 来定义这些常量,如下所示。枚举常量用逗号分隔开。

定义:

enum class Direction {
    NORTH, SOUTH, WEST, EAST
}

用法:

var direction = Direction.NORTH;

如上所示,可以使用类名称后跟点 (.) 运算符以及常量名称来引用 enum 对象。

使用 ViewModel 中的状态值添加枚举类定义。

  1. 打开 overview/OverviewViewModel.kt。在文件的顶部(导入后,在类定义前),添加表示所有可用状态的 enum
enum class MarsApiStatus { LOADING, ERROR, DONE }
  1. 滚动到 _statusstatus 属性的定义,将类型从 String 更改为您在上一步中定义的枚举类 MarsApiStatus. MarsApiStatus
private val _status = MutableLiveData<MarsApiStatus>()

val status: LiveData<MarsApiStatus> = _status
  1. getMarsPhotos() 方法中,将 "Success: ..." 字符串更改为 MarsApiStatus.DONE 状态,将 "Failure..." 字符串更改为 MarsApiStatus.ERROR
try {
    _photos.value = MarsApi.retrofitService.getPhotos()
    _status.value = MarsApiStatus.DONE
} catch (e: Exception)
     _status.value = MarsApiStatus.ERROR
}
  1. 将状态设置为 try {} 代码块上方的 MarsApiStatus.LOADING。这是协程运行以及等待数据期间显示的初始状态。完整的 viewModelScope.launch{} 代码块现在应如下所示:
viewModelScope.launch {
            _status.value = MarsApiStatus.LOADING
            try {
                _photos.value = MarsApi.retrofitService.getPhotos()
                _status.value = MarsApiStatus.DONE
            } catch (e: Exception) {
                _status.value = MarsApiStatus.ERROR
            }
        }
  1. catch {} 代码块中的错误状态后,将 _photos 设置为空列表。这会清除 Recycler 视图。
} catch (e: Exception) {
   _status.value = MarsApiStatus.ERROR
   _photos.value = listOf()
}
  1. 完整的 getMarsPhotos() 方法应如下所示:
private fun getMarsPhotos() {
   viewModelScope.launch {
        _status.value = MarsApiStatus.LOADING
        try {
           _photos.value = MarsApi.retrofitService.getPhotos()
           _status.value = MarsApiStatus.DONE
        } catch (e: Exception) {
           _status.value = MarsApiStatus.ERROR
           _photos.value = listOf()
        }
    }
}

您定义了状态的枚举状态,设置了协程开始运行时的加载状态,设置了应用结束从网络服务器检索数据时的完成状态,并设置了出现异常时显示的错误。在下一个任务中,您将使用绑定适配器显示相应的图标。

为状态 ImageView 添加绑定适配器

您已使用一组 enum 状态在 OverviewViewModel 中设置了 MarsApiStatus。在此步骤中,您将使该状态显示在应用中。您可以为 ImageView 使用绑定适配器,以显示加载和错误状态的图标。当应用处于加载状态或错误状态时,ImageView 应可见。应用完成加载后,ImageView 不得处于不可见状态。

  1. 打开 BindingAdapters.kt,滚动到文件末尾以添加其他适配器。添加一个名为 bindStatus() 的新绑定适配器,该适配器获取 ImageViewMarsApiStatus 值作为参数。使用 @BindingAdapter 为该方法添加注解,并传入自定义属性 marsApiStatus 作为参数。
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
          status: MarsApiStatus?) {
}

如果收到请求,则导入 com.example.android.marsrealestate.overview.MarsApiStatus

  1. bindStatus() 方法中添加一个 when {} 代码块,以在不同状态之间切换。
when (status) {

}
  1. when {} 中,为加载状态 (MarsApiStatus.LOADING) 添加一个用例。对于此状态,请将 ImageView 设为可见,然后为其分配加载动画。这与您在上一个任务中用于 Coil 的动画可绘制对象相同。
when (status) {
   MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
   }
}

如果收到请求,则导入 android.view.View

  1. 为错误状态添加一个用例,MarsApiStatus.ERROR。与针对 LOADING 状态的操作类似,将状态 ImageView 设置为可见,并使用连接错误可绘制对象。
MarsApiStatus.ERROR -> {
   statusImageView.visibility = View.VISIBLE
   statusImageView.setImageResource(R.drawable.ic_connection_error)
}
  1. 为完成状态添加一个用例,MarsApiStatus.DONE。在这里,假设您获得成功响应,因此将状态 ImageView 的可见性设置为 View.GONE 可隐藏该状态。
MarsApiStatus.DONE -> {
   statusImageView.visibility = View.GONE
}

您已为状态图片视图设置绑定适配器,在下一步中,您将添加使用新绑定适配器的图片视图。

添加状态 ImageView

在此步骤中,您将在 fragment_overview.xml 中添加图片视图,该视图会显示您之前定义的状态。

  1. 打开 res/layout/fragment_overview.xml。在 ConstraintLayout 中的 RecyclerView 元素下,添加如下所示的 ImageView
<ImageView
   android:id="@+id/status_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:marsApiStatus="@{viewModel.status}" />

上面的 ImageViewRecyclerView 的限制相同。不过,宽度和高度使用 wrap_content 使图片居中,而不是拉伸图片填满视图。另请注意,app:marsApiStatus 属性被设为 viewModel.status,此属性会在 ViewModel 中的状态属性发生更改时调用 BindingAdapter

  1. 如需测试上述代码,请在模拟器或设备中开启飞行模式来模拟网络连接错误。编译并运行应用,并注意显示的错误图片:

a91ddb1c89f2efec.png

  1. 点按“返回”按钮以关闭应用,然后关闭飞行模式。使用“最近”屏幕返回应用。根据网络连接速度,如果应用在开始加载图片之前查询网络服务,您可能会看到一个非常简单的旋转图标。

恭喜您完成此 Codelab 的学习并构建了 MarsPhotos 应用!现在您可以向亲朋好友展示您的应用,其中包含了许多真实的火星照片。

此 Codelab 的解决方案位于如下所示的项目中。请使用 main 分支拉取或下载该代码。

如需获取此 Codelab 的代码并在 Android Studio 中打开它,请执行以下操作。

获取代码

  1. 点击提供的网址。此时会在浏览器中打开项目的 GitHub 页面。
  2. 在项目的 GitHub 页面上,点击 Code 按钮,以打开一个对话框。

Eme2bJP46u-pMpnXVfm-bS2N2dlyq6c0jn1DtQYqBaml7TUhzXDWpYoDI0lGKi4xndE_uJw8sKfwfOZ1fC503xCVZrbh10JKJ4iEHdLDwFfdvnOheNxkokITW1LW6UZTncVJJUZ5Fw

  1. 在对话框中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(可能在 Downloads 文件夹中)。
  3. 双击 ZIP 文件进行解压缩。系统将创建一个包含项目文件的新文件夹。

在 Android Studio 中打开项目

  1. 启动 Android Studio。
  2. Welcome to Android Studio 窗口中,点击 Open an existing Android Studio project

Tdjf5eS2nCikM9KdHgFaZNSbIUCzKXP6WfEaKVE2Oz1XIGZhgTJYlaNtXTHPFU1xC9pPiaD-XOPdIxVxwZAK8onA7eJyCXz2Km24B_8rpEVI_Po5qlcMNN8s4Tkt6kHEXdLQTDW7mg

注意:如果 Android Studio 已经打开,请依次选择 File > New > Import Project 菜单选项。

PaMkVnfCxQqSNB1LxPpC6C6cuVCAc8jWNZCqy5tDVA6IO3NE2fqrfJ6p6ggGpk7jd27ybXaWU7rGNOFi6CvtMyHtWdhNzdAHmndzvEdwshF_SG24Le01z7925JsFa47qa-Q19t3RxQ

  1. Import Project 对话框中,转到解压缩的项目文件夹所在的位置(可能在 Downloads 文件夹中)。
  2. 双击该项目文件夹。
  3. 等待 Android Studio 打开项目。
  4. 点击 Run 按钮 j7ptomO2PEQNe8jFt4nKCOw_Oc_Aucgf4l_La8fGLCMLy0t9RN9SkmBFGOFjkEzlX4ce2w2NWq4J30sDaxEe4MaSNuJPpMgHxnsRYoBtIV3-GUpYYcIvRJ2HrqR27XGuTS4F7lKCzg 以构建并运行应用。请确保该应用可以正常使用。
  5. Project 工具窗口中浏览项目文件,了解应用的实现方式。
  • Cily 库简化了应用中的图片下载、缓冲、解码和缓存等图片管理过程。
  • 绑定适配器是位于视图与视图的绑定数据之间的扩展方法。绑定适配器可以在数据发生更改时提供自定义行为,例如,调用 Coil 将来自网址的图片加载到 ImageView 中。
  • 绑定适配器是使用 @BindingAdapter 注解进行注解的扩展方法。
  • 如需显示图片网格,请使用包含 GridLayoutManagerRecyclerView
  • 如需在属性发生更改时更新属性列表,请在 RecyclerView 和布局之间使用绑定适配器。

Android 开发者文档:

其他: