1. 准备工作
简介
在之前的 Codelab 中,您学习了如何使用数据仓库模式从 Web 服务获取数据,并将响应解析为 Kotlin 对象。在本 Codelab 中,您将在此基础上学习如何从 Web URL 加载和显示照片。您还将回顾如何构建 LazyVerticalGrid
并使用它在概览页面上显示图像网格。
前提条件
- 了解如何从 REST Web 服务检索 JSON,并使用 Retrofit 和 Gson 库将这些数据解析为 Kotlin 对象
- 了解 REST Web 服务
- 熟悉 Android 架构组件,例如数据层和数据仓库
- 了解依赖注入
- 了解
ViewModel
和ViewModelProvider.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。
在本 Codelab 中构建的应用版本以图像网格的形式显示火星照片。这些图像是您的应用从 Web 服务检索的数据的一部分。您的应用使用 Coil 库来加载和显示图像,并使用 LazyVerticalGrid
为图像创建网格布局。您的应用还将通过显示错误消息来优雅地处理网络错误。
获取入门代码
首先,请下载入门代码
或者,您可以克隆该代码的 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 服务返回的照片列表中的第一张火星照片。下图显示了之前和之后的屏幕截图
添加 Coil 依赖项
- 打开来自 添加数据仓库和手动 DI Codelab 的 Mars Photos 解决方案应用。
- 运行应用,确认它显示检索到的火星照片数量。
- 打开 build.gradle.kts (Module :app)。
- 在
dependencies
部分,为 Coil 库添加以下行
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")
从 Coil 文档页面查看并更新库的最新版本。
- 点击 Sync Now 以使用新依赖项重新构建项目。
显示图像 URL
在此步骤中,您将检索并显示第一张火星照片的 URL。
- 在
ui/screens/MarsViewModel.kt
文件中,在getMarsPhotos()
方法内,在try
代码块内,找到将从 Web 服务检索的数据设置为listResult
的行。
// No need to copy, code is already present
try {
val listResult = marsPhotosRepository.getMarsPhotos()
//...
}
- 更新此行,将
listResult
更改为result
,并将检索到的第一张火星照片分配给新变量result
。分配索引0
处的第一个照片对象。
try {
val result = marsPhotosRepository.getMarsPhotos()[0]
//...
}
- 在下一行,将传递给
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}"
)
}
- 运行应用。
Text
可组合项现在显示第一张火星照片的 URL。下一部分介绍如何使应用显示此 URL 中的图像。
添加 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
可组合项添加到您的代码中,以显示检索到的第一张火星照片。
- 在
ui/screens/HomeScreen.kt
文件中,添加一个名为MarsPhotoCard()
的新可组合函数,该函数接受MarsPhoto
和Modifier
作为参数。
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
}
- 在
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
为无障碍阅读器设置文本。
- 将
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()
)
}
- 更新
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())
}
}
- 在
MarsViewModel.kt
文件中,更新MarsUiState
接口以接受MarsPhoto
对象而不是String
。
sealed interface MarsUiState {
data class Success(val photos: MarsPhoto) : MarsUiState
//...
}
- 更新
getMarsPhotos()
函数,将第一张火星照片对象传递给MarsUiState.Success()
。删除result
变量。
marsUiState = try {
MarsUiState.Success(marsPhotosRepository.getMarsPhotos()[0])
}
- 运行应用,确认它显示单张火星图像。
- 火星照片没有填满整个屏幕。为了填满屏幕上的可用空间,请在
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,
)
}
- 运行应用,确认图像水平和垂直方向都填满了屏幕。
添加加载和错误图像
您可以通过在加载图像时显示占位符图像来改善应用中的用户体验。如果由于图像文件丢失或损坏等问题导致加载失败,您还可以显示错误图像。在本节中,您将使用 AsyncImage
添加错误和占位符图像。
- 打开
res/drawable/ic_broken_image.xml
,然后点击右侧的 Design 或 Split 标签。对于错误图像,使用内置图标库中提供的损坏图像图标。此矢量可绘制项使用android:tint
属性将图标着色为灰色。
- 打开
res/drawable/loading_img.xml
。此可绘制项是一个动画,它绕中心点旋转图像可绘制项loading_img.xml
。(您在预览中看不到动画。)
- 返回
HomeScreen.kt
文件。在MarsPhotoCard
可组合项中,更新对AsyncImage()
的调用,添加error
和placeholder
属性,如以下代码所示
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
)
}
- 运行应用。根据您的网络连接速度,当 Coil 下载并显示属性图像时,您可能会短暂看到加载图像。但即使关闭网络,您也不会看到损坏的图像图标——您将在 Codelab 的最后一个任务中修复此问题。
4. 使用 LazyVerticalGrid 显示图像网格
您的应用现在从互联网加载了一张火星照片,即第一个 MarsPhoto
列表项。您使用了该火星照片数据中的图像 URL 来填充一个 AsyncImage
。但是,目标是让您的应用显示图像网格。在此任务中,您将使用带有 Grid 布局管理器的 LazyVerticalGrid
来显示图像网格。
懒惰网格 (Lazy grids)
LazyVerticalGrid 和 LazyHorizontalGrid 可组合项支持在网格中显示项目。垂直懒惰网格在可垂直滚动的容器中显示其项目,跨越多列,而水平懒惰网格在水平轴上具有相同的行为。
从设计角度来看,网格布局最适合以图标或图像形式显示火星照片。
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
添加一个可组合项,用于在垂直网格中显示火星照片列表。
- 在
HomeScreen.kt
文件中,创建一个名为PhotosGridScreen()
的新可组合函数,该函数接受一个MarsPhoto
列表和一个modifier
作为参数。
@Composable
fun PhotosGridScreen(
photos: List<MarsPhoto>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
}
- 在
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,
) {
}
}
- 要添加项目列表,请在
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 }) {
}
}
}
- 要添加单个列表项显示的内容,请定义
items
lambda 表达式。调用MarsPhotoCard
,并传入photo
。
items(items = photos, key = { photo -> photo.id }) {
photo -> MarsPhotoCard(photo)
}
- 更新
HomeScreen
可组合项,以便在请求成功完成时显示PhotosGridScreen
可组合项,而不是MarsPhotoCard
可组合项。
when (marsUiState) {
// ...
is MarsUiState.Success -> PhotosGridScreen(marsUiState.photos, modifier)
// ...
}
- 在
MarsViewModel.kt
文件中,更新MarsUiState
接口以接受MarsPhoto
对象列表,而不是单个MarsPhoto
。PhotosGridScreen
可组合项接受MarsPhoto
对象列表。
sealed interface MarsUiState {
data class Success(val photos: List<MarsPhoto>) : MarsUiState
//...
}
- 在
MarsViewModel.kt
文件中,更新getMarsPhotos()
函数,将火星照片对象列表传递给MarsUiState.Success()
。
marsUiState = try {
MarsUiState.Success(marsPhotosRepository.getMarsPhotos())
}
- 运行应用。
注意每张照片周围都没有内边距,并且不同照片的宽高比不同。您可以添加一个 Card
可组合项来解决这些问题。
添加卡片可组合项
- 在
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()
)
}
}
- 要修复宽高比,请在
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)
)
}
}
}
- 更新结果屏幕预览,以预览
PhotosGridScreen()
。使用空的图像 URL 模拟数据。
@Preview(showBackground = true) @Composable fun PhotosGridScreenPreview() { MarsPhotosTheme { val mockData = List(10) { MarsPhoto("$it", "") } PhotosGridScreen(mockData) } }
由于模拟数据包含空 URL,您将在照片网格预览中看到加载图像。
- 运行应用。
- 当应用正在运行时,开启飞行模式。
- 在模拟器中滚动图像。尚未加载的图像会显示为损坏图像图标。这是您传递给 Coil 图像库的可绘制图像,以便在发生任何网络错误或图像无法获取时显示。
干得好!您通过在模拟器或设备中开启飞行模式来模拟了网络连接错误。
5. 添加重试操作
在本节中,您将添加一个重试操作按钮,并在点击按钮时检索照片。
- 向错误屏幕添加一个按钮。在
HomeScreen.kt
文件中,更新ErrorScreen()
可组合项以包含retryAction
lambda 参数和一个按钮。
@Composable
fun ErrorScreen(retryAction: () -> Unit, modifier: Modifier = Modifier) {
Column(
// ...
) {
Image(
// ...
)
Text(//...)
Button(onClick = retryAction) {
Text(stringResource(R.string.retry))
}
}
}
查看预览
- 更新
HomeScreen()
可组合项以传入重试 lambda。
@Composable
fun HomeScreen(
marsUiState: MarsUiState, retryAction: () -> Unit, modifier: Modifier = Modifier
) {
when (marsUiState) {
//...
is MarsUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
}
}
- 在
ui/theme/MarsPhotosApp.kt
文件中,更新HomeScreen()
函数调用,将retryAction
lambda 参数设置为marsViewModel::getMarsPhotos
。这将从服务器检索火星照片。
HomeScreen(
marsUiState = marsViewModel.marsUiState,
retryAction = marsViewModel::getMarsPhotos
)
6. 更新 ViewModel 测试
MarsUiState
和 MarsViewModel
现在容纳照片列表而不是单张照片。在当前状态下,MarsViewModelTest
期望 MarsUiState.Success
数据类包含一个字符串属性。因此,测试无法编译。您需要更新 marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
测试,以断言 MarsViewModel.marsUiState
等于包含照片列表的 Success
状态。
- 打开
rules/MarsViewModelTest.kt
文件。 - 在
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 开发者文档
其他