从互联网加载和显示图像

1. 开始之前

简介

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

先决条件

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

您将学到什么

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

您将构建什么

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

您需要什么

  • 一台装有现代 Web 浏览器(例如最新版本的 Chrome)的计算机
  • 火星照片应用的起始代码,包含 REST Web 服务

2. 应用概述

在本 Codelab 中,您将继续使用之前 Codelab 中的火星照片应用。火星照片应用连接到 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 可以使用的图像。您可以将图像缓存到内存缓存、基于存储的缓存或两者兼而有之。所有这些都必须在低优先级后台线程中发生,以便 UI 保持响应。此外,为了获得最佳网络和 CPU 性能,您可能希望一次获取和解码多个图像。

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

Coil 主要需要两样东西

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

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

a59e55909b6e9213.png 1b670f284109bbf5.png

添加 Coil 依赖项

  1. 打开来自 添加存储库和手动 DI Codelab 的 火星照片解决方案 应用。
  2. 运行应用以确认它显示了检索到的火星照片数量。
  3. 打开build.gradle.kts (Module :app)
  4. dependencies 部分,为 Coil 库添加此行
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")

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

  1. 单击立即同步以使用新依赖项重建项目。

显示图像 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.kt 中的 AsyncImage 中,将 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 并单击右侧的设计拆分选项卡。对于错误图像,请使用内置图标库中提供的损坏图像图标。此矢量可绘制对象使用 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 正在下载并显示属性图像。但是,即使您关闭网络,您也不会看到损坏的图像图标,您将在代码实验室的最后一个任务中修复此问题。

d684b0e096e57643.gif

4. 使用 LazyVerticalGrid 显示图像网格

您的应用程序现在从互联网加载火星照片,即第一个 MarsPhoto 列表项。您已使用该火星照片数据中的图像 URL 来填充 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 允许您为项目指定宽度,然后网格将尽可能多地适应列。计算列数后,网格会在列之间平均分配任何剩余的宽度。这种自适应的大小调整方式对于在不同屏幕尺寸上显示项目集特别有用。

在此代码实验室中,要显示火星照片,您将使用 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 表达式。调用 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() 的修饰符。
@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. 获取解决方案代码

要下载已完成代码实验室的代码,您可以使用此 git 命令

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

或者,您可以将存储库下载为 zip 文件,解压缩它,然后在 Android Studio 中打开它。

如果您想查看此代码实验室的解决方案代码,请在 GitHub 上查看。

8. 结论

恭喜您完成此代码实验室并构建了火星照片应用程序!现在是时候向您的家人和朋友展示您使用真实火星图片制作的应用程序了。

不要忘记在社交媒体上使用 #AndroidBasics 分享您的作品!

9. 了解更多

Android 开发人员文档

其他