載入並顯示網際網路上的圖片

1. 事前準備

簡介

在先前的程式碼研究室中,您已瞭解如何使用存放區模式從 Web 服務取得資料,並將回應剖析為 Kotlin 物件。在本程式碼研究室中,您可以在該知識的基礎上,瞭解如何從網址載入並顯示相片。您也可以回顧如何建構 LazyVerticalGrid,並在總覽頁面用以顯示圖片格線。

必要條件

  • 瞭解如何從 REST Web 服務擷取 JSON,以及如何使用 RetrofitGson 程式庫將資料剖析為 Kotlin 物件
  • 瞭解 REST Web 服務
  • 熟悉 Android 架構元件,例如資料層和存放區
  • 瞭解依附元件插入程序
  • 瞭解 ViewModelViewModelProvider.Factory
  • 瞭解應用程式的協同程式實作
  • 瞭解存放區模式

課程內容

  • 如何使用 Coil 程式庫從網址載入及顯示圖片。
  • 如何使用 LazyVerticalGrid 顯示圖片格線。
  • 如何處理圖片下載及顯示時出現的潛在錯誤。

建構項目

  • 修改 Mars Photos 應用程式以便取得火星資料中的圖片網址,並使用 Coil 載入及顯示該圖片。
  • 在應用程式中新增載入動畫和錯誤圖示。
  • 為應用程式加入狀態和錯誤處理機制。

軟硬體需求

  • 電腦需搭載新版網路瀏覽器,例如最新版 Chrome
  • 包含 REST Web 服務的 Mars Photos 應用程式範例程式碼

2. 應用程式總覽

在本程式碼研究室中,您將繼續使用先前程式碼研究室的 Mars Photos 應用程式。Mars Photos 應用程式會連上 Web 服務,擷取並顯示使用 Gson 獲取的 Kotlin 物件數量。這些 Kotlin 物件包含網址,可導向 NASA 火星探測車拍攝的真實火星表面相片。

a59e55909b6e9213.png

您在本程式碼研究室中建構的應用程式版本,是以圖片格線模式顯示火星相片。這些圖片取自應用程式從 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 使用。您可以將圖片快取到記憶體內快取/儲存空間快取,或同時存放在這兩個地方。系統只會在低優先順序的背景執行緒中採取這些動作,讓 UI 保持回應。此外,為獲得最佳網路和 CPU 效能,建議您一次擷取多張圖片並解碼。

好消息是,您可以使用由社群開發的資料庫 Coil 下載、緩衝處理、解碼及快取圖片。如果不使用 Coil,工作將會更多。

Coil 基本上需要下列兩項:

  • 要載入並顯示的圖片網址。
  • 實際顯示該圖片的 AsyncImage 可組合函式。

在這項工作中,您會瞭解如何使用 Coil 顯示來自「火星」Web 服務的單張圖片。Web 服務會傳回一個相片清單,您將顯示清單上的第一張火星相片。下圖是前後對照的螢幕截圖:

a59e55909b6e9213.png 1b670f284109bbf5.png

新增 Coil 依附元件

  1. 開啟「新增存放區和手動 DI」程式碼研究室中的 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. 火星相片並未填滿整個畫面。如要填滿畫面上的可用空間,請在 AsyncImage 內的 HomeScreen.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,然後按一下右側的「Design」或「Split」分頁標籤。如果出現錯誤圖片,請使用內建圖示庫中的毀損圖片圖示。此向量可繪項目使用 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 清單項目。您已使用火星相片資料中的圖片網址填入 AsyncImage。然而,我們的目標是讓應用程式顯示圖片格線。在這項工作中,您會使用 LazyVerticalGrid 搭配格線版面配置管理工具來顯示圖片格線。

Lazy 格線

LazyVerticalGridLazyHorizontalGrid 可組合函式支援在格線中顯示項目。Lazy 垂直格線會在橫跨多欄的垂直捲動式容器中顯示項目,而 Lazy 水平格線在水平軸上也會如此。

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() 的新可組合函式,該函式將 MarsPhotomodifier 清單做為引數。
@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.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 狀態 (將假相片清單傳遞至相片參數) 與 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. 結語

恭喜您完成本程式碼研究室,並建構了 Mars Photos 應用程式!現在就與親朋好友分享真實的火星相片,展現一下您的酷炫應用程式吧。

記得使用 #AndroidBasics,在社群媒體分享您的作品!

9. 瞭解詳情

Android 開發人員說明文件:

其他: