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

简介

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

前提条件

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

学习内容

  • 如何使用 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 会显示第一张火星照片的网址。到目前为止,您已为该网址设置 ViewModel 和 LiveData。

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() 函数,该函数将 ImageView 和 String 作为参数。
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 中添加了一个名为 _photos 的 LiveData 对象,用于保存一个 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}"
}

网格布局

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

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,将 width 和 height 属性设置为 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 将布局中显示的项数设置为 16。itemCount 属性指定了布局编辑器应该在 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(刷新程序)

ListAdapter 是 RecyclerView.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.ListAdapter 和 com.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")
}

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

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

如果收到请求,则导入 androidx.recyclerview.widget.RecyclerView 和 com.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() 方法中,移除 TODO。DiffUtil 将调用此方法来确定两个对象是否代表相同的列表项。DiffUtil 使用此方法来确定新的 MarsPhoto 对象是否与旧的 MarsPhoto 对象相同。每个列表项(MarsPhoto 对象)的 ID 都是唯一的。比较 oldItem 和 newItem 的 ID,然后返回结果。
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.id == newItem.id
}
  1. 在 areContentsTheSame() 中,移除 TODO。DiffUtil 会在需要检查两个列表项的数据是否相同时调用此方法。MarsPhoto 中的重要数据是图片网址。比较 oldItem 和 newItem 的网址,然后返回结果。
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() 方法,该方法会获取 RecyclerView 和 MarsPhoto 对象列表作为参数。使用带有 listData 属性的 @BindingAdapter 为该方法添加注解。
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
    data: List<MarsPhoto>?) {
}

如果收到请求,则导入 androidx.recyclerview.widget.RecyclerView 和 com.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 中的类

如需表示应用中的这三种状态,您将使用 enum。enum 是枚举的缩写,意思是集合中所有项的有序列表。每个 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. 滚动到 _status 和 status 属性的定义,将类型从 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() 的新绑定适配器,该适配器获取 ImageView 和 MarsApiStatus 值作为参数。使用 @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}" />

上面的 ImageView 与 RecyclerView 的限制相同。不过,宽度和高度使用 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 注解进行注解的扩展方法。
  • 如需显示图片网格,请使用包含 GridLayoutManager 的 RecyclerView。
  • 如需在属性发生更改时更新属性列表,请在 RecyclerView 和布局之间使用绑定适配器。

Android 开发者文档:

其他: