从互联网加载和显示图像

1. 准备工作

简介

在之前的 Codelab 中,您学习了如何使用数据仓库模式从 Web 服务获取数据,并将响应解析为 Kotlin 对象。在本 Codelab 中,您将在此基础上学习如何从 Web URL 加载和显示照片。您还将回顾如何构建 LazyVerticalGrid 并使用它在概览页面上显示图像网格。

前提条件

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

您将学习的内容

  • 如何使用 Coil 库从 Web URL 加载和显示图像。
  • 如何使用 LazyVerticalGrid 显示图像网格。
  • 如何在图像下载和显示过程中处理潜在错误。

您将构建的内容

  • 修改 Mars Photos 应用,从火星数据中获取图片 URL,并使用 Coil 加载和显示该图片。
  • 向应用添加加载动画和错误图标。
  • 向应用添加状态和错误处理功能。

您需要准备的

  • 一台安装有现代 Web 浏览器的计算机,例如最新版本的 Chrome
  • 包含 REST Web 服务的 Mars Photos 应用的入门代码

2. 应用概览

在本 Codelab 中,您将继续使用之前 Codelab 中的 Mars Photos 应用。Mars Photos 应用连接到 Web 服务,以检索和显示使用 Gson 检索的 Kotlin 对象数量。这些 Kotlin 对象包含美国国家航空航天局 (NASA) 火星探测器捕获的火星表面真实照片的 URL。

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. 显示下载的图像

从 Web URL 显示照片听起来很简单,但要使其正常工作,需要大量工程实践。图像必须经过下载、内部存储(缓存)以及从压缩格式解码为 Android 可以使用的图像。您可以将图像缓存到内存缓存、基于存储的缓存或两者兼而有之。所有这些都必须在低优先级的后台线程中进行,以便界面保持响应。此外,为了获得最佳网络和 CPU 性能,您可能需要一次性获取并解码多个图像。

幸运的是,您可以使用一个由社区开发的库,名为 Coil,用于下载、缓冲、解码和缓存图像。如果不使用 Coil,您需要做的工作会多得多。

Coil 主要需要两项内容

  • 您想要加载和显示图像的 URL。
  • 一个 AsyncImage 可组合项,用于实际显示该图像。

在此任务中,您将学习如何使用 Coil 显示来自火星 Web 服务的一张图像。您将显示 Web 服务返回的照片列表中的第一张火星照片。下图显示了之前和之后的屏幕截图

a59e55909b6e9213.png 1b670f284109bbf5.png

添加 Coil 依赖项

  1. 打开来自 添加数据仓库和手动 DI 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 以使用新依赖项重新构建项目。

显示图像 URL

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

  1. ui/screens/MarsViewModel.kt 文件中,在 getMarsPhotos() 方法内,在 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 中的第一个图像 URL。
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 可组合项现在显示第一张火星照片的 URL。下一部分介绍如何使应用显示此 URL 中的图像。

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 值(即图像 URL,为 "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()
    )
}

在前面的代码中,您使用图像 URL (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. 火星照片没有填满整个屏幕。为了填满屏幕上的可用空间,请在 HomeScreen.ktAsyncImage 中,将 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 列表项。您使用了该火星照片数据中的图像 URL 来填充一个 AsyncImage。但是,目标是让您的应用显示图像网格。在此任务中,您将使用带有 Grid 布局管理器的 LazyVerticalGrid 来显示图像网格。

懒惰网格 (Lazy grids)

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

项目键 (Item keys)

当用户滚动网格(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 表达式。调用 MarsPhotoCard,并传入 photo
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.dp 阴影的 Card。将 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() 的 modifier。
@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()。使用空的图像 URL 模拟数据。
@Preview(showBackground = true)
@Composable
fun PhotosGridScreenPreview() {
   MarsPhotosTheme {
       val mockData = List(10) { MarsPhoto("$it", "") }
       PhotosGridScreen(mockData)
   }
}

由于模拟数据包含空 URL,您将在照片网格预览中看到加载图像。

Preview of the photo grid screen preview with loading image

  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 开发者文档

其他