从互联网加载和显示图片

1. 准备工作

简介

在之前的 Codelab 中,您已学过如何使用仓库模式从 Web 服务中获取数据,以及如何将响应解析为 Kotlin 对象。在此 Codelab 中,您将利用这些知识学习如何从网址中加载和显示照片。此外,您还将回顾如何构建 LazyVerticalGrid 以及用它在概览页面上显示图片网格。

前提条件

  • 了解如何从 REST Web 服务中检索 JSON,并使用 Retrofit 库和 Gson 库将相应数据解析为 Kotlin 对象
  • 了解 REST Web 服务
  • 熟悉 Android 架构组件,例如数据层和仓库
  • 了解依赖项注入
  • 了解 ViewModelViewModelProvider.Factory
  • 了解应用的协程实现
  • 了解仓库模式

学习内容

  • 如何使用 Coil 库从网址中加载和显示图片。
  • 如何使用 LazyVerticalGrid 显示图片网格。
  • 如何处理下载和显示图片时的潜在错误。

构建内容

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

所需条件

  • 一台安装了新版网络浏览器(如最新版 Chrome)的计算机
  • 包含 REST 网络服务的 Mars Photos 应用的起始代码

2. 应用概览

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

a59e55909b6e9213.png

您在此 Codelab 中构建的应用版本将以图片网格的形式显示火星照片。这些图片是您的应用从 Web 服务中检索到的数据的一部分。您的应用将使用 Coil 库来加载和显示图片,并使用 LazyVerticalGrid 为图片创建网格布局。您的应用还将通过显示错误消息来合理处理网络连接错误。

68f4ff12cc1e2d81.png

获取起始代码

首先,请下载起始代码:

或者,您也可以克隆代码的 GitHub 代码库:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout coil-starter

您可以在 Mars Photos GitHub 代码库中浏览该代码。

3. 显示下载的图片

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

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

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

  • 需要加载和显示的图片的网址。
  • 用于实际显示该图片的 AsyncImage 可组合项。

在此任务中,您将学习如何使用 Coil 来显示火星 Web 服务中的单张图片。您将在 Web 服务返回的照片列表中显示第一张火星照片的图片。以下是相应图片显示前后的对照屏幕截图:

a59e55909b6e9213.png 1b670f284109bbf5.png

添加 Coil 依赖项

  1. 打开添加仓库和手动依赖项注入 Codelab 中的 Mars Photos 解决方案应用。
  2. 运行应用,确认其中显示了检索到的火星照片的数量。
  3. 打开 build.gradle.kts (Module :app)
  4. dependencies 部分中,为 Coil 库添加下面这行代码:
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")

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

  1. 请点击 Sync Now,以使用新的依赖项重建项目。

显示图片网址

在此步骤中,您将检索并显示第一张火星照片的网址。

  1. ui/screens/MarsViewModel.ktgetMarsPhotos() 方法的 try 代码块内,找到用于将从 Web 服务中检索到的数据设置为 listResult 的代码行。
// No need to copy, code is already present
try {
   val listResult = marsPhotosRepository.getMarsPhotos()
   //...
}
  1. 更新此行,方法是将 listResult 更改为 result,并将检索到的第一张火星照片分配给新变量 result。在索引 0 处分配第一个照片对象。
try {
   val result = marsPhotosRepository.getMarsPhotos()[0]
   //...
}
  1. 在下一行代码中,将传递给 MarsUiState.Success() 函数调用的形参更新为以下代码中的字符串。使用新属性中的数据,而非 listResult。显示照片 result 中的第一张图片的网址。
try {
   ...
   MarsUiState.Success("First Mars image URL: ${result.imgSrc}")
}

现在,完整的 try 代码块应如下所示:

marsUiState = try {
   val result = marsPhotosRepository.getMarsPhotos()[0]
   MarsUiState.Success(
       "   First Mars image URL : ${result.imgSrc}"
   )
}
  1. 运行应用。现在,Text 可组合项会显示第一张火星照片的网址。下一部分将介绍如何让应用显示此网址中的图片。

b5daaa892fe8dad7.png

添加 AsyncImage 可组合项

在这一步,您将添加一个 AsyncImage 可组合函数来加载并显示单张火星照片。AsyncImage 是一个用于异步执行图片请求并呈现结果的可组合项。

// Example code, no need to copy over
AsyncImage(
    model = "https://android.com/sample_image.jpg",
    contentDescription = null
)

model 实参可以是 ImageRequest.data 值,也可以是 ImageRequest 本身。在前面的示例中,您分配了 ImageRequest.data 值,即图片网址 "https://android.com/sample_image.jpg"。以下示例代码展示了如何将 ImageRequest 分配给 model

// Example code, no need to copy over

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data("https://example.com/image.jpg")
        .crossfade(true)
        .build(),
    placeholder = painterResource(R.drawable.placeholder),
    contentDescription = stringResource(R.string.description),
    contentScale = ContentScale.Crop,
    modifier = Modifier.clip(CircleShape)
)

AsyncImage 支持与标准 Image 可组合项相同的实参。此外,它还支持设置 placeholder/error/fallback 绘制程序和 onLoading/onSuccess/onError 回调。上述示例代码加载了具有圆形剪裁和淡入淡出效果的图片,并设置了占位符。

contentDescription 设置无障碍服务用于描述此图片内容的文本。

AsyncImage 可组合项添加到您的代码中,以显示检索到的第一张火星照片。

  1. ui/screens/HomeScreen.kt 中,添加一个新的名为 MarsPhotoCard() 的可组合函数,该函数接受 MarsPhotoModifier
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
}
  1. MarsPhotoCard() 可组合函数内,添加 AsyncImage() 函数,如下所示:
import coil.compose.AsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.platform.LocalContext

@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(context = LocalContext.current)
            .data(photo.imgSrc)
            .build(),
        contentDescription = stringResource(R.string.mars_photo),
        modifier = Modifier.fillMaxWidth()
    )
}

在前面的代码中,您使用图片网址 (photo.imgSrc) 构建了 ImageRequest,并将其传递给 model 实参。您可以使用 contentDescription 为使用无障碍功能的读者设置文本。

  1. crossfade(true) 添加到 ImageRequest 以在请求成功完成时启用淡入淡出动画。
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(context = LocalContext.current)
            .data(photo.imgSrc)
            .crossfade(true)
            .build(),
        contentDescription = stringResource(R.string.mars_photo),
        modifier = Modifier.fillMaxWidth()
    )
}
  1. 在请求成功完成后,更新 HomeScreen 可组合项以显示 MarsPhotoCard 可组合项,而不是 ResultScreen 可组合项。您将在下一步中修复类型不匹配错误。
@Composable
fun HomeScreen(
    marsUiState: MarsUiState,
    modifier: Modifier = Modifier
) {
    when (marsUiState) {
        is MarsUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
        is MarsUiState.Success -> MarsPhotoCard(photo = marsUiState.photos, modifier = modifier.fillMaxSize())
        else -> ErrorScreen(modifier = modifier.fillMaxSize())
    }
}
  1. MarsViewModel.kt 文件中,更新 MarsUiState 接口以接受 MarsPhoto 对象,而不是 String
sealed interface MarsUiState {
    data class Success(val photos: MarsPhoto) : MarsUiState
    //...
}
  1. 更新 getMarsPhotos() 函数,以将第一张火星照片对象传递给 MarsUiState.Success()。删除 result 变量。
marsUiState = try {
    MarsUiState.Success(marsPhotosRepository.getMarsPhotos()[0])
}
  1. 运行应用并确认它显示一张火星图片。

d4421a2458f38695.png

  1. 火星照片未填满整个屏幕。如需填充屏幕上的可用空间,请在 AsyncImageHomeScreen.kt 中将 contentScale 设置为 ContentScale.Crop
import androidx.compose.ui.layout.ContentScale

@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
   AsyncImage(
       model = ImageRequest.Builder(context = LocalContext.current)
           .data(photo.imgSrc)
           .crossfade(true)
           .build(),
       contentDescription = stringResource(R.string.mars_photo),
       contentScale = ContentScale.Crop,
       modifier = modifier,
   )
}
  1. 运行应用,并确认图片在水平和垂直方向上填满整个屏幕。

1b670f284109bbf5.png

添加加载和错误图片

您可以在加载图片时显示占位图片,从而改善应用中的用户体验。如果加载因任何问题(例如图片文件缺失或损坏)而失败,您也可以显示错误图片。在本部分中,您将使用 AsyncImage 添加错误图片和占位符图片。

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

70e008c63a2a1139.png

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

92a448fa23b6d1df.png

  1. 返回 HomeScreen.kt 文件。在 MarsPhotoCard 可组合项中,更新对 AsyncImage() 的调用以添加 errorplaceholder 属性,如以下代码所示:
import androidx.compose.ui.res.painterResource

@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
    AsyncImage(
        // ...
        error = painterResource(R.drawable.ic_broken_image),
        placeholder = painterResource(R.drawable.loading_img),
        // ...
    )
}

此代码可设置加载时要使用的占位符加载图片(loading_img 可绘制对象)。此代码还可设置图片加载失败时要使用的图片(ic_broken_image 可绘制对象)。

现在,完整的 MarsPhotoCard 可组合项应如以下代码所示:

@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(context = LocalContext.current)
            .data(photo.imgSrc)
            .crossfade(true)
            .build(),
        error = painterResource(R.drawable.ic_broken_image),
        placeholder = painterResource(R.drawable.loading_img),
        contentDescription = stringResource(R.string.mars_photo),
        contentScale = ContentScale.Crop
    )
}
  1. 请运行应用。根据网络连接速度,您可能会短暂地看到加载图片显示为 Coil 下载内容,并显示属性图片。但是您不会看到损坏图片图标,即使您关闭网络也是如此 - 您将在 Codelab 的最后一个任务中修复该问题。

d684b0e096e57643.gif

4. 使用 LazyVerticalGrid 显示图片网格

您的应用现在从互联网加载了一张火星照片,即第一个 MarsPhoto 列表项。您已使用该火星照片数据中的图片网址填充了 AsyncImage。不过,您的目标是让应用显示图片网格。在此任务中,您将使用带网格布局管理器的 LazyVerticalGrid 来显示图片网格。

延迟网格

LazyVerticalGridLazyHorizontalGrid 可组合项支持在网格中显示列表项。延迟垂直网格会在可垂直滚动容器中跨多个列显示其列表项,而延迟水平网格则会在水平轴上实现相同的行为。

27680e208333ed5.png

从设计角度来看,网格布局最适合以图标或图片的形式显示火星照片。

LazyVerticalGrid 中的 columns 形参和 LazyHorizontalGrid 中的 rows 形参用于控制单元格组成列或行的方式。以下示例代码在网格中显示列表项,并使用 GridCells.Adaptive 将每列设置为至少 128.dp 宽:

// Sample code - No need to copy over

@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(minSize = 150.dp)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}

通过 LazyVerticalGrid,您可以指定列表项的宽度,然后网格将适应尽可能多的列。在计算列数之后,网格会在各列之间平均分配所有剩余的宽度。这种自适应尺寸调整方式非常适合在不同尺寸的屏幕上显示多组列表项。

在此 Codelab 中,如需显示火星照片,请结合使用 LazyVerticalGrid 可组合项与 GridCells.Adaptive,并将每列的宽度设置为 150.dp

项键

当用户滚动网格(LazyColumn 中的 LazyRow)时,列表项的位置会发生变化。不过,由于屏幕方向发生变化或者添加或移除了项,用户可能会丢失该行中的滚动位置。项键可以帮助您根据键来保持滚动位置。

通过提供键,您可以帮助 Compose 正确处理重新排序。例如,如果您的项包含记忆状态,设置键将允许 Compose 在项的位置发生变化时将此状态随该项一起移动。

添加 LazyVerticalGrid

添加一个用于在垂直网格中显示火星照片列表的可组合项。

  1. HomeScreen.kt 文件中,新建一个名为 PhotosGridScreen() 的可组合函数,该函数接受 MarsPhoto 列表和 modifier 作为实参。
@Composable
fun PhotosGridScreen(
    photos: List<MarsPhoto>,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
) {
}
  1. PhotosGridScreen 可组合项内,添加一个具有以下形参的 LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp

@Composable
fun PhotosGridScreen(
    photos: List<MarsPhoto>,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(150.dp),
        modifier = modifier.padding(horizontal = 4.dp),
        contentPadding = contentPadding,
   ) {
     }
}
  1. 如需添加列表项,请在 LazyVerticalGrid lambda 内调用 items() 函数,并传入 MarsPhoto 列表项和 photo.id 项键。
import androidx.compose.foundation.lazy.grid.items

@Composable
fun PhotosGridScreen(
    photos: List<MarsPhoto>,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
) {
   LazyVerticalGrid(
       // ...
   ) {
       items(items = photos, key = { photo -> photo.id }) {
       }
   }
}
  1. 如需添加单个列表项显示的内容,请定义 items lambda 表达式。调用 photo,并传入 MarsPhotoCard
items(items = photos, key = { photo -> photo.id }) {
   photo -> MarsPhotoCard(photo)
}
  1. 在成功完成请求后,更新 HomeScreen 可组合项以显示 PhotosGridScreen 可组合项,而不是 MarsPhotoCard 可组合项。
when (marsUiState) {
       // ...
       is MarsUiState.Success -> PhotosGridScreen(marsUiState.photos, modifier)
       // ...
}
  1. MarsViewModel.kt 文件中,更新 MarsUiState 接口以接受 MarsPhoto 对象列表,而不是单个 MarsPhotoPhotosGridScreen 可组合项接受 MarsPhoto 对象的列表。
sealed interface MarsUiState {
    data class Success(val photos: List<MarsPhoto>) : MarsUiState
    //...
}
  1. MarsViewModel.kt 文件中,更新 getMarsPhotos() 函数以将火星照片对象列表传递给 MarsUiState.Success()
marsUiState = try {
    MarsUiState.Success(marsPhotosRepository.getMarsPhotos())
}
  1. 运行应用。

2eaec198c56b5eed.png

请注意,每张照片都没有内边距,并且不同照片的宽高比也各不相同。您可以添加一个 Card 可组合项来解决这些问题。

添加卡片可组合项

  1. HomeScreen.kt 文件的 MarsPhotoCard 可组合项中,在 AsyncImage 周围添加一个高度为 8.dpCard。将 modifier 实参分配给 Card 可组合项。
import androidx.compose.material.Card
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding

@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {

    Card(
        modifier = modifier,
        elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
    ) {

        AsyncImage(
            model = ImageRequest.Builder(context = LocalContext.current)
                .data(photo.imgSrc)
                .crossfade(true)
                .build(),
            error = painterResource(R.drawable.ic_broken_image),
            placeholder = painterResource(R.drawable.loading_img),
            contentDescription = stringResource(R.string.mars_photo),
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxWidth()
        )
    }
}
  1. 如需修正宽高比,请在 PhotosGridScreen() 中更新 MarsPhotoCard() 的修饰符。
@Composable
fun PhotosGridScreen(photos: List<MarsPhoto>, modifier: Modifier = Modifier) {
   LazyVerticalGrid(
       //...
   ) {
       items(items = photos, key = { photo -> photo.id }) { photo ->
           MarsPhotoCard(
               photo,
               modifier = modifier
                   .padding(4.dp)
                   .fillMaxWidth()
                   .aspectRatio(1.5f)
           )
       }
   }
}
  1. 更新结果屏幕预览以预览 PhotosGridScreen()。使用空白图片网址模拟数据。
@Preview(showBackground = true)
@Composable
fun PhotosGridScreenPreview() {
   MarsPhotosTheme {
       val mockData = List(10) { MarsPhoto("$it", "") }
       PhotosGridScreen(mockData)
   }
}

由于模拟数据包含空网址,因此您会在照片网格预览中看到正在加载图片。

带加载图片的照片网格屏幕预览。

  1. 运行应用。

b56acd074ce0f9c7.png

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

9b72c1d4206c7331.png

太棒了!您已经通过在模拟器或设备中开启飞行模式来模拟了网络连接错误。

5. 添加重试操作

在此部分中,您将添加重试操作按钮,并实现在用户点击该按钮时检索照片。

60cdcd42bc540162.png

  1. 向“错误”页面添加一个按钮。在 HomeScreen.kt 文件中,更新 ErrorScreen() 可组合项以包含一个 retryAction lambda 形参和一个按钮。
@Composable
fun ErrorScreen(retryAction: () -> Unit, modifier: Modifier = Modifier) {
    Column(
        // ...
    ) {
        Image(
            // ...
        )
        Text(//...)
        Button(onClick = retryAction) {
            Text(stringResource(R.string.retry))
        }
    }
}

检查预览

55cf0c45f5be219f.png

  1. 更新 HomeScreen() 可组合项以传入重试 lambda。
@Composable
fun HomeScreen(
   marsUiState: MarsUiState, retryAction: () -> Unit, modifier: Modifier = Modifier
) {
   when (marsUiState) {
       //...

       is MarsUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
   }
}
  1. ui/theme/MarsPhotosApp.kt 文件中,更新 HomeScreen() 函数调用,将 retryAction lambda 形参设置为 marsViewModel::getMarsPhotos。这将实现从服务器检索火星照片。
HomeScreen(
   marsUiState = marsViewModel.marsUiState,
   retryAction = marsViewModel::getMarsPhotos
)

6. 更新 ViewModel 测试

MarsUiStateMarsViewModel 现已可容纳多张照片的列表,而不是单张照片。在当前状态下,MarsViewModelTest 要求 MarsUiState.Success 数据类包含字符串属性。因此,测试无法编译。您需要更新 marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() 测试,以断言 MarsViewModel.marsUiState 等于包含照片列表的 Success 状态。

  1. 打开 rules/MarsViewModelTest.kt 文件。
  2. marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() 测试中,修改 assertEquals() 函数调用,以将 Success 状态(将虚构照片列表传递给 photos 形参)与 marsViewModel.marsUiState 进行比较。
@Test
    fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
        runTest {
            val marsViewModel = MarsViewModel(
                marsPhotosRepository = FakeNetworkMarsPhotosRepository()
            )
            assertEquals(
                MarsUiState.Success(FakeDataSource.photosList),
                marsViewModel.marsUiState
            )
        }

测试现在会成功编译、运行和通过!

7. 获取解决方案代码

如需下载完成后的 Codelab 代码,您可以使用以下 Git 命令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git

或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

如果您想查看此 Codelab 的解决方案代码,请前往 GitHub 查看。

8. 总结

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

别忘了使用 #AndroidBasics 标签在社交媒体上分享您的作品!

9. 了解更多内容

Android 开发者文档:

其他: