从互联网加载和显示图像

1. 开始之前

简介

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

先决条件

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

您将学习什么

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

您将构建什么

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

您需要什么

  • 一台装有现代网络浏览器的计算机,例如最新版本的Chrome
  • 带有REST网络服务的火星照片应用程序的启动代码

2. 应用程序概述

在本Codelab中,您将继续使用之前Codelab中的火星照片应用程序。火星照片应用程序连接到网络服务,以检索和显示使用Gson检索到的Kotlin对象的数量。这些Kotlin对象包含从NASA的火星探测器拍摄的火星表面真实照片的URL。

a59e55909b6e9213.png

您在本Codelab中构建的应用程序版本以图像网格的形式显示火星照片。这些图像是您的应用程序从网络服务检索的数据的一部分。您的应用程序使用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

您可以在火星照片GitHub存储库中浏览代码。

3. 显示下载的图像

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

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

Coil基本上需要两样东西

  • 您要加载和显示的图像的URL。
  • 一个AsyncImage组合函数来实际显示该图像。

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

a59e55909b6e9213.png 1b670f284109bbf5.png

添加Coil依赖项

  1. 打开火星照片解决方案应用程序,该应用程序来自添加资源库和手动DICodelab。
  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块内,找到将从网络服务检索到的数据设置为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")分配给model。以下示例代码显示了如何将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并单击右侧的设计拆分选项卡。对于错误图像,请使用内置图标库中提供的损坏图像图标。此矢量可绘制对象使用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。但是,目标是让您的应用显示一个图像网格。在此任务中,您将使用带有网格布局管理器的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 允许您指定项目的宽度,然后网格将尽可能多地容纳列。计算列数后,网格将任何剩余宽度平均分配到各列。这种自适应的大小调整方式对于在不同屏幕尺寸上显示项目集特别有用。

在这个代码实验室中,为了显示火星照片,您将使用带有 GridCells.AdaptiveLazyVerticalGrid 可组合项,每列宽度设置为 150.dp

项目键

当用户滚动网格(LazyRow 内的 LazyColumn)时,列表项位置会发生变化。但是,由于方向更改或添加或删除项目,用户可能会丢失行内的滚动位置。项目键可帮助您根据键维护滚动位置。

通过提供键,您可以帮助 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 可组合项中,添加一个具有 8.dp 高度的 Card,围绕 AsyncImage。将 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 图像库的图像 drawable,以防出现任何网络错误或无法获取图像。

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

其他