Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(35)-載入並顯示Internet上的images

了解如何使用 Coil library 從 URL 載入並顯示網路上的圖片。

學習目標

  • 如何使用 Coil library 從 URL 載入及顯示 image
  • 如何使用 RecyclerViewgrid adapter 顯示格狀(grid) images
  • 如何處理 images 下載及顯示時出現的潛在 errors

建構項目

  • 修改 MarsPhotos app 以取得 Mars data 中的 image URL,並使用 Coil 載入並顯示該 image
  • 在 app 中加入 loading animationerror icon
  • 使用 RecyclerView 顯示格狀(grid) Mars images。
  • RecyclerView 加入狀態和錯誤處理機制(status and error handling)

App overview

在本程式碼研究室中,您將繼續使用之前程式碼研究室中稱為 MarsPhotos app。MarsPhotos app 會連線至 web service,以擷取並顯示使用 Retrofit 獲取的 Kotlin objects 數量。這些 Kotlin objects 包含 NASA 的火星探測器擷取的火星表面真實相片的 URL。

您在本程式碼研究室中建構的 app 版本會填入 overview 頁面,此頁面以 images grid(格狀) 模式顯示 Mars photos。這些 images 來自您的 app 從 Mars web service 擷取的 data。您的 app 會使用 Coil library 來載入並顯示 images,也會使用 RecyclerView 來建立 images 的 grid layout。此外,app 式還會妥善處理網路錯誤(network errors)


顯示 internet image

想要顯示某個 URL 的相片,聽起來可能很簡單,但其中需要經過很多程序才能順利完成。image 必須經過下載(downloaded)內部儲存(internally stored)對壓縮格式進行解碼(decoded from its compressed format),才能供 Android 使用。image 應快取(cached)至記憶體快取(in-memory cache)儲存空間快取(storage-based cache),或同時存放在這兩個地方。系統只會在低優先順序的 background threads 中採取這些動作,以確保 UI 的靈敏度。此外,為獲得最佳 network 和 CPU 效能,建議您一次擷取多張圖片並解碼(decode)。

您可以使用社群開發的資料庫 Coil下載(download)緩衝處理(buffer)解碼(decode)快取(cache)您的 images。如果不使用 Coil,工作將會更多。

Coil 基本上需要下列兩項:

  • 要載入並顯示的 image URL
  • 用來顯示該 image 的 ImageView object

在這項工作中,您會瞭解如何使用 Coil 顯示來自 Mars web service 的單張圖片。您會顯示 web service 傳回的 photos list 中第一張 Mars photo。以下是前後對照的螢幕截圖:

新增 Coil dependency

  1. 開啟先前程式碼研究室中的 MarsPhotos solution app。
  2. 執行 app 以查看其用途。(其中顯示擷取的 Mars photos 總數)。
  3. 開啟「build.gradle (Module: app)」。
  4. dependencies 區段,為 Coil library 新增此行內容:
1
2
// Coil
implementation "io.coil-kt:coil:1.1.1"

Coil library 於 mavenCentral() repository 託管(hosted)並提供。在 build.gradle (Project: MarsPhotos) 的頂部 repositories 區塊中,新增 mavenCentral()

1
2
3
4
repositories {
google()
mavenCentral()
}
  1. 按一下「Sync Now」,使用新 dependency rebuild project。

更新 ViewModel

在這個步驟中,您需要將 LiveData 屬性新增至 OverviewViewModel class,以儲存收到的 Kotlin object MarsPhoto

  1. 開啟 overview/OverviewViewModel.kt。在 _status 屬性宣告的正下方,新增 type 為 MutableLiveData 的可變動屬性 _photos,其可儲存單個 MarsPhoto object。
1
private val _photos = MutableLiveData<MarsPhoto>()
  • 依要求 import com.example.android.marsphotos.network.MarsPhoto
  1. _photos 宣告的正下方,新增 type 為 LiveData<MarsPhoto> 的 public backing field photos
1
val photos: LiveData<MarsPhoto> = _photos
  1. getMarsPhotos() 方法的 try{} 區塊中,找到以下一行內容,可用於將從 web service 擷取的 data 設為 listResult
1
2
3
4
try {
val listResult = MarsApi.retrofitService.getPhotos()
...
}
  1. 將擷取到的第一張 Mars photo 指派給新的變數 _photos。將 listResult 變更為 _photos.value。在 index 0 處指派第一個 photos url。這將引發 error,您要在稍後修正它。
1
2
3
4
try {
_photos.value = MarsApi.retrofitService.getPhotos()[0]
...
}
  1. 在下一行中,將 status.value 更新為下列內容。請使用新屬性 (非 listResult 中) 的 data。顯示 photos List 中的第一個 image URL。
1
2
3
4
5
try {
...
_status.value = " First Mars image URL : ${_photos.value!!.imgSrcUrl}"

}
  1. 現在,完整的 try{} 區塊大致如下:
1
2
3
4
try {
_photos.value = MarsApi.retrofitService.getPhotos()[0]
_status.value = " First Mars image URL : ${_photos.value!!.imgSrcUrl}"
}
  1. 執行 app。現在,TextView 會顯示第一張 Mars photo 的 URL。目前,您已設定好該 URL 的 ViewModelLiveData

使用 Binding Adapters

Binding Adapters 是帶註解的方法,用於view自訂屬性建立自訂 setter

通常您會使用以下程式碼在 XML 中設定屬性:android:text="Sample Text"。Android 系統會自動尋找由 setText(String: text) 方法 setter、name 與 text 屬性相同的 setter 屬性setText(String: text) 方法是一種 setter 方法,適用於 Android 架構所提供之部分 view。您可使用 binding adapters 自訂類似行為;也可以提供由 Data binding library 呼叫的自訂屬性和自訂邏輯。

範例:
若想執行更為複雜的操作,而不只是在 Image view 上呼叫 setter,請設定 drawable image。請考慮從 internet 載入 UI thread (main thread) 的 images。首先,選擇自訂屬性用於將 image 指派給 ImageView。在以下範例中為 imageUrl

1
2
3
4
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:imageUrl="@{product.imageUrl}"/>

如果您未新增任何程式碼,系統將在 ImageView 中尋找 setImageUrl(String) 方法;如果找不到該方法,將會引發 error,因為這個自定屬性並非由架構提供。您必須建立實作方式,並將 app:imageUrl 屬性設為 ImageView。請使用 Binding adapters (註解方法) 進行這項作業。

Binding Adapter 範例:

1
2
3
4
5
6
7
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
// Load the image in the background using Coil.
}
}
}
  • @BindingAdapter 註解會將屬性名稱做為參數
  • bindImage 方法中,第一個方法參數是目標 View 的 type,第二個則是要設為屬性的 value
  • 在方法中,Coil library 會載入 UI thread 的 image 並將其設為 ImageView
建立 binding adapter 及使用 Coil
  1. com.example.android.marsphotos.overview 中,建立名稱為 BindingAdapters 的 Kotlin 檔案。這個檔案會保留您在 app 中使用的 binding adapters。
  1. BindingAdapters.kt 中,建立 bindImage() 函式做為頂層函式 (不在類別中),並使用 ImageViewString 做為參數。
1
2
3
fun bindImage(imgView: ImageView, imgUrl: String?) {

}
  • 依要求 import android.widget.ImageView
  1. 使用 @BindingAdapter 為函式加上註解。@BindingAdapter 註解會通知 data binding,在 View item 擁有 imageUrl 屬性時執行此 binding adapter。
1
2
3
4
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {

}
  • 依要求 import androidx.databinding.BindingAdapter
let scope function

let 是 Kotlin 的範圍函式(scope function)之一,可讓您objectcontext 內執行程式碼區塊(code block)。Kotlin 中有五個 scope function,詳情請參閱 documentation

使用方式:

  • let 的用途是根據呼叫鏈(call chains)的結果叫用一或多個函式
  • let 函式和安全呼叫運算子 (?) 的用途是對 object 執行空值安全運算(null safe operation)。在這種情況下,只有在 object 不是空值時,系統才會執行 let 程式碼區塊(code block)
  1. bindImage() 函式中,使用安全呼叫運算子 (?.) 將 let{} 區塊新增至 imgUrl 引數(argument)。
1
2
imgUrl?.let {
}

筆記:

  • 傳遞出去的是 參數(parameter)
  • 可以被傳進來的是 引數(argument)

load(n)n 是參數
fun load(number: Int)number 是引數

  1. let{} 區塊內,新增以下一行內容來使用 toUri() 方法將 URL string 轉換為 Uri object。若要使用 HTTPS 配置,請將 buildUpon.scheme("https") 附加至 toUri builder。接著呼叫 build() 來建構(build) object。
1
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build() 
  • 依要求 import androidx.core.net.toUri
  1. let{} 區塊內,宣告 imgUri 之後,使用 Coil 的 load(){}imgUri object 的 image 載入 imgView
1
imgView.load(imgUri)
  • 依要求 import coil.load
  1. 完整方法大致如下:
1
2
3
4
5
6
7
8
9
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
// 使用 toUri() 將 URL string 轉換為 Uri object (將 buildUpon.scheme("https") 附加至 toUri builder)
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
// 使用 Coil 的 load(){} 將 imgUri object 的 image 載入 imgView
imgView.load(imgUri)
}
}

更新 layout 和 fragments

在上一節中,您使用了 Coil image library 載入 image。接著使用新屬性更新 ImageView,以顯示單張 image,即可在螢幕上顯示 image。

稍後,在程式碼研究室中,您將使用 res/layout/grid_view_item.xml 做為 RecyclerView 中每個 grid item 的 layout resource 檔案。在這項工作中,您將暫時使用這個檔案,透過在前一項工作中擷取到的 image URL 來顯示 image。目前,您將使用這個 layout 檔案取代 fragment_overview.xml

注意:在這項工作中,您將使用 grid_view_item.xml layout 來暫時顯示單張 image。藉此避免建立及刪除臨時 layout 檔案。產生的 binding class 名稱為 GridViewItemBinding,因為 class 的命名依據是 layout 檔案名稱,即 grid_view_item.xml。即使 binding class 名稱為 GridViewItemBinding,但在這項工作中的 RecyclerView 內,您將無法使用它來顯示 grid images。這屬於後續工作。

  1. 開啟 res/layout/grid_view_item.xml
  2. <ImageView> element 上方,為 data binding 新增 <data> element,並 bind 至 OverviewViewModel class:
1
2
3
4
5
<data>
<variable
name="viewModel"
type="com.example.android.marsphotos.overview.OverviewViewModel" />
</data>
  1. ImageView element 中新增 app:imageUrl 屬性,即可使用新的 image 載入 binding adapter。請注意,photos 包含從 server 擷取的 list MarsPhotos。將第一個 item 的 URL 指派給 imageUrl 屬性。
1
2
3
4
5
<ImageView
android:id="@+id/mars_image"
...
app:imageUrl="@{viewModel.photos.imgSrcUrl}"
... />
  1. 開啟 overview/OverviewFragment.kt。在 onCreateView() 方法中,註解加載(inflates) FragmentOverviewBinding class 並將其指派給 binding variable 的行。移除此行會發生錯誤。請放心,這是暫時現象,稍後就會修正。
1
//val binding = FragmentOverviewBinding.inflate(inflater)
  1. 請改用 grid_view_item.xml 取代 fragment_overview.xml。在這種情況下,請改為新增以下一行內容,以加載(inflate) GridViewItemBinding class。
1
val binding = GridViewItemBinding.inflate(inflater)
  • 視需要 import com.example.android.marsphotos.databinding.GridViewItemBinding

注意:這項變更可能會導致 Android Studio 發生 data-binding errors。clean 後再 rebuild project 即可解決這些錯誤。依序選取「Build」>「Clean Project」>「Build」>「Rebuild Project」

執行 app。現在螢幕上會顯示單張 Mars image。

新增 loading 和 error images

使用 Coil,您可以在載入 image 時顯示 placeholder image,並在載入失敗(例如 image 遺失或損壞)時顯示 error image,從而改善使用者體驗。在此步驟中,您需將該功能新增至 binding adapter

  1. 開啟 res/drawable/ic_broken_image.xml,然後按一下右側的「Design」分頁標籤。如果出現 error image,表示您使用的是內建 icon library 中的 broken-image icon。此 vector drawable 使用 android:tint 屬性,將 icon 變為灰色。
  1. 開啟 res/drawable/loading_animation.xml。這個 drawable 是 animation,可圍繞中心點旋轉(rotate) image drawable loading_img.xml (預覽畫面不會顯示 animation)。
  1. 返回 BindingAdapters.kt 檔案。在 bindImage() 方法中,更新對 imgView.load(imgUri) 的呼叫即可新增後置(trailing) lambda,如下所示:此程式碼會設定載入時使用的 placeholder loading image (loading_animation drawable)。此程式碼也會設定 image 載入失敗時要使用的 error image (broken_image drawable)
1
2
3
4
5
imgView.load(imgUri) {
// 使用 Coil 的 placeholder() 和 error() 設定 image 載入時和載入失敗時要使用的 drawable
placeholder(R.drawable.loading_animation)
error(R.drawable.ic_broken_image)
}
  1. 現在,完整的 bindImage() 方法大致如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
// 使用 toUri() 將 URL string 轉換為 Uri object
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
// 使用 Coil 的 load(){} 將 imgUri object 的 image 載入 imgView
imgView.load(imgUri) {
// 使用 Coil 的 placeholder() 和 error() 設定 image 載入時和載入失敗時要使用的 drawable
placeholder(R.drawable.loading_animation)
error(R.drawable.ic_broken_image)
}
}
}
  1. 執行 app。系統可能會在 Coil 下載並顯示屬性 image 時,暫時顯示 loading image,具體情況視網路連線速度而定。但即使您關閉網路,此刻仍不會顯示 broken-image icon。此問題會在程式碼研究室的最後一項工作中加以修正。
  1. 還原您在 overview/OverviewFragment.kt 中做出的臨時變更。在方法 onCreateview() 中,取消註解加載(inflates) FragmentOverviewBinding 的行。刪除加載(inflates) GridViewIteMBinding 的行或設為註解。
1
2
val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)

使用 RecyclerView 顯示格狀的 images

現在,您的 app 會從 internet 載入 Mars photo。透過使用第一個 MarsPhoto list item 的 data,您已ViewModel 中建立 LiveData 屬性,並使用 Mars photo data 中的 image URL 填入 ImageView。但我們的目標是讓您的 app 顯示格狀的 images,因此在這項工作中,您將使用 RecyclerView 搭配 Grid layout manager 來顯示格狀 images

更新 view model

在先前的工作中,您在 OverviewViewModel 新增了名為 _photosLiveData object,該 object 存放了一個 MarsPhoto object,即 web service 提供的 response list 中的第一個 object。在此步驟中,您必須變更這個 LiveData,使其存放 MarsPhoto object 的完整 list

  1. 開啟 overview/OverviewViewModel.kt
  2. _photos type 變更為 MarsPhoto object 的 list
1
private val _photos = MutableLiveData<List<MarsPhoto>>()
  1. 同時將 backing 屬性 photos type 變更為 List<MarsPhoto> type:
1
val photos: LiveData<List<MarsPhoto>> = _photos
  1. 向下捲動至 getMarsPhotos() 方法中的 try {} 區塊。MarsApi.retrofitService.getPhotos()
    會傳回 MarsPhoto objects 的 list,您可以將它指派給 _photos.value
1
2
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
  1. 現在整個 try/catch 區塊如下所示:
1
2
3
4
5
6
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
} catch (e: Exception) {
_status.value = "Failure: ${e.message}"
}

Grid Layout

RecyclerViewGridLayoutManager 會將 data 設為 scrollable(可滑動的) grid,如下所示。

從設計的角度來看,Grid Layout 最適合用於可透過 icons 或 images 表示的 lists,例如 Mars photo 瀏覽 app 中的 list。

Grid layout 的 items 排列方式

Grid layout 透過由列(rows)欄(columns)組成的格線排列 items。假設您預設為垂直捲動(vertical scrolling),則每一列(rows)中的每個 item 都會占用一個 span。一個 item 可以占用多個 span。在下列範例中,一個 span 等同於一欄的寬度 3。

在以下兩個範例中,每列(rows)由三個 spans 組成。根據預設,GridLayoutManager將每個 item 放置在一個 span 中,直到達到您指定的 span count。達到 span count 時,系統會換行到下一行。

新增 Recycler view

在這個步驟中,您需要變更 app 的 layout,改用採用 grid layout 的 recycler view,而非 single image view。

  1. 開啟 layout/grid_view_item.xml。請移除 viewModel data variable。
  2. <data> 標記中,新增下列 MarsPhoto type 的 photo variable
1
2
3
4
5
<data>
<variable
name="photo"
type="com.example.android.marsphotos.network.MarsPhoto" />
</data>
  1. <ImageView> 中,將 app:imageUrl 屬性變更為引用 MarsPhoto object 中的 image URL。這些變更會復原您在上一個工作中做出的臨時變更。
1
app:imageUrl="@{photo.imgSrcUrl}"
  1. 開啟 layout/fragment_overview.xml。刪除整個 <TextView> element。
  2. 請改為新增下列 <RecyclerView> element。將 ID 設為 photos_grid,將 widthheight 屬性設為 0dp,方便填充父項(parent) ConstraintLayout。您要使用的是 Grid layout,因此請layoutManager 屬性設為 androidx.recyclerview.widget.GridLayoutManagerspanCount 設為 2,即可擁有兩欄。
1
2
3
4
5
6
7
8
9
10
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photos_grid"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2" />
  1. 如要預覽上述程式碼在「Design」view 中的外觀,請使用 tools:itemCount 將 layout 中顯示的 items 數量設為 16itemCount 屬性可指定 layout editor 在「Preview」視窗中應顯示的 items 數量。使用 tools:listitem 將 list items 的 layout 設為 grid_view_item
1
2
3
4
<androidx.recyclerview.widget.RecyclerView
...
tools:itemCount="16"
tools:listitem="@layout/grid_view_item" />
  1. 切換為「Design」view 後,系統應會顯示類似下方螢幕截圖的預覽畫面。這個預覽畫面看起來不像 Mars photos,但您可以從中查看 recyclerview grid layout 的外觀。對於 recyclerview 中每個 grid item,其預覽皆會顯示 padding 和 grid_view_item layout。
  1. 根據 Material Design guidelines,list 頂端(top)、底部(bottom)和側邊(sides)應保留 8dp 的空間,而 items 之間應保留 4dp 的空間。搭配運用 fragment_overview.xml layout 和 grid_view_item.xml layout 中的 padding 可實現上述目標。
  1. 開啟 layout/grid_view_item.xml。請注意,已設定 padding 屬性在 item 外部和內容(content)之間留有 2dp 的 padding。如此可在 item content 之間留出 4dp 的空間,而在外邊留出 2dp 的空間。也就是說,為了符合設計規範,外部需要留出額外 6dp 的 padding。

  2. 返回 layout/fragment_overview.xml。為 RecyclerView 新增 6dp 的 padding,這樣外部就有 8dp,內部則有 4dp,符合規範。

1
2
3
4
<androidx.recyclerview.widget.RecyclerView
...
android:padding="6dp"
... />
  1. 完整的 <RecyclerView> element 應如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photos_grid"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="6dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2"
tools:itemCount="16"
tools:listitem="@layout/grid_view_item" />

新增 photo grid adapter

現在,RecyclerViewfragment_overview.xml layout 中採用 grid layout。在這個步驟中,您可以透過 RecyclerView adapter,將從 web server 擷取的 data bind 至 RecyclerView

ListAdapter(Refresher)

ListAdapterRecyclerView.Adapter class 的子類別(subclass),用來在 RecyclerView顯示 List data,包括在 background thread 上計算 Lists 之間的差異。

在此 app 中,您將ListAdapter 中使用 DiffUtil 實作。使用 DiffUtil 的優勢在於,每當新增(added)、移除(removed)或變更(changed) RecyclerView 中的某些 item 時,系統不會 refreshed 整個 list。系統只會 refreshed 已變更的 items

ListAdapter 新增至您的 app。

  1. overview package 中,建立名為 PhotoGridAdapter 的 Kotlin class。
  2. 使用如下所示的建構函式(constructor)參數,從 ListAdapter 繼承(extends) PhotoGridAdapter class。PhotoGridAdapter class 會繼承(extends) ListAdapter,其 constructor 需要 list item typeview holder實作 DiffUtil.ItemCallback
1
2
3
class PhotoGridAdapter : ListAdapter<MarsPhoto,
PhotoGridAdapter.MarsPhotoViewHolder>(DiffCallback) {
}
  • 視需要 import androidx.recyclerview.widget.ListAdaptercom.example.android.marsphoto.network.MarsPhoto 類別。在後續步驟中,您將導入此 constructor 缺少的其他實作,這些實作會產生錯誤。
  1. 如要解決上述錯誤,請在這個步驟中新增必要的方法,並於這項工作的後續部分實作這些方法。依序按一下 PhotoGridAdapter class、紅色燈泡,然後從下拉式選單中選取「Implement members」。在彈出式視窗中,選取 ListAdapter 方法,也就是 onCreateViewHolder()onBindViewHolder()。Android Studio 仍會顯示錯誤,您將於這項工作的結尾修正這些錯誤。
1
2
3
4
5
6
7
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridAdapter.MarsPhotoViewHolder {
TODO("Not yet implemented")
}

override fun onBindViewHolder(holder: PhotoGridAdapter.MarsPhotoViewHolder, position: Int) {
TODO("Not yet implemented")
}
  • 實作 onCreateViewHolderonBindViewHolder 方法時需要 MarsPhotoViewHolder,這個項目會在下一步中新增。
  1. PhotoGridAdapter 中,新增 MarsPhotoViewHolder 的內部類別(inner class)定義,該定義可繼承(extends) RecyclerView.ViewHolder。您需要使用 GridViewItemBinding 變數將 MarsPhoto binding 至 layout,請將該變數傳送至 MarsPhotoViewHolder。基本 ViewHolder class 需要在其 constructor 中設定 view,而您要將其傳送至 binding 的 root view
1
2
3
4
class MarsPhotoViewHolder(private var binding:
GridViewItemBinding):
RecyclerView.ViewHolder(binding.root) {
}
  • 視需要 import androidx.recyclerview.widget.RecyclerViewcom.example.android.marsrealestate.databinding.GridViewItemBinding
  1. MarsPhotoViewHolder 中建立 bind() 方法,MarsPhoto object 做為引數(argument),並binding.property 設為該 object。設定屬性後,請呼叫 executePendingBindings(),以立即執行更新作業。
1
2
3
4
fun bind(MarsPhoto: MarsPhoto) {
binding.photo = MarsPhoto
binding.executePendingBindings()
}
  1. onCreateViewHolder()PhotoGridAdapter class 中,移除 TODO 並新增下列一行內容。onCreateViewHolder() 方法需要傳回新的 MarsPhotoViewHolder加載 GridViewItemBinding使用父項(parent) ViewGroup context 中的 LayoutInflater 即可建立。
1
2
return MarsPhotoViewHolder(GridViewItemBinding.inflate(
LayoutInflater.from(parent.context)))
  • 視需要 import android.view.LayoutInflater
  1. onBindViewHolder() 方法中,移除 TODO 並新增下列幾行內容。您可以在這裡呼叫 getItem()取得與目前 RecyclerView 位置相關的 MarsPhoto object,然後將該屬性傳遞至 MarsPhotoViewHolder 中的 bind() 方法
1
2
val marsPhoto = getItem(position)
holder.bind(marsPhoto)
  1. PhotoGridAdapter 中,為 DiffCallback 新增 companion object 定義,如下所示。
    DiffCallback object 繼承 DiffUtil.ItemCallback 為要比較的 generic type:MarsPhoto。您需要在這項實作中比較兩張 Mars photo objects
1
2
companion object DiffCallback : DiffUtil.ItemCallback<MarsPhoto>() {
}
  • 依要求 import androidx.recyclerview.widget.DiffUtil
  1. 按下紅色燈泡,為 DiffCallback object (areItemsTheSame()areContentsTheSame()) 實作 comparator 方法。
1
2
3
4
5
6
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
TODO("Not yet implemented")
}

override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
TODO("Not yet implemented") }
  1. areItemsTheSame() 方法中,移除 TODO。DiffUtil 會呼叫此方法來判定兩個 objects 是否代表同一個 itemDiffUtil 會使用此方法來判斷新的 MarsPhoto object 是否和舊的 MarsPhoto object 相同。每個 item (MarsPhoto object) 的 ID 皆不得重複比較 oldItemnewItem 的 ID,然後 return 結果。
1
2
3
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
return oldItem.id == newItem.id
}
  1. areContentsTheSame() 中,移除 TODO。如果 DiffUtil檢查兩個 items 是否擁有相同的 data,便會呼叫此方法。MarsPhoto 中的重要 data 是 image URL。比較 oldItemnewItem 的 URLs,然後 return 結果。
1
2
3
override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
return oldItem.imgSrcUrl == newItem.imgSrcUrl
}
  • 請確認您可以正常編譯並執行 app,但模擬器顯示空白畫面。recyclerview 已準備就緒,但尚未收到任何 data,您將在接下來的步驟中實作這部分內容。

新增 binding adapter 並 connect 各個部分

在這個步驟中,您將使用 BindingAdapter初始化包含 MarsPhoto objects listPhotoGridAdapter。若使用 BindingAdapter 設定 RecyclerView data,data binding 將自動監控 LiveDataMarsPhoto objects list。當 MarsPhoto list 有所變更時,系統會自動呼叫 binding adapter

  1. 開啟 BindingAdapters.kt
  2. 在檔案結尾,新增 bindRecyclerView() 方法,以 RecyclerViewMarsPhoto objects list 做為引數。請使用帶有 listData 屬性的 @BindingAdapter 為該方法加上註解。
1
2
3
4
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
data: List<MarsPhoto>?) {
}
  • 視需要 import androidx.recyclerview.widget.RecyclerViewcom.example.android.marsphotos.network.MarsPhoto
  1. bindRecyclerView() 函式中,將 recyclerView.adapter 做為 PhotoGridAdapter 並指派給新的 val 屬性 adapter
1
val adapter = recyclerView.adapter as PhotoGridAdapter
  1. bindRecyclerView() 函式結尾,呼叫 adapter.submitList() 可查看 Mars photos list data。出現新的 list 時,這個屬性會通知 RecyclerView
1
adapter.submitList(data)
  • 視需要 import com.example.android.marsrealestate.overview.PhotoGridAdapter
  1. 完整的 bindRecyclerView binding adapter 應如下所示:
1
2
3
4
5
6
7
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
data: List<MarsPhoto>?) {
val adapter = recyclerView.adapter as PhotoGridAdapter
adapter.submitList(data)

}
  1. 如要連結所有 items,請開啟 res/layout/fragment_overview.xml。接著app:listData 屬性新增至 RecyclerView element,然後使用 data binding 將屬性設為 viewmodel.photos。這類似於您在先前的工作中為 ImageView 完成的工作。
1
app:listData="@{viewModel.photos}"
  1. 開啟 overview/OverviewFragment.kt。在 onCreateView() 中,於 return 陳述式之前,將 binding.photosGrid 中的 RecyclerView adapter 初始化為新的 PhotoGridAdapter object
1
binding.photosGrid.adapter = PhotoGridAdapter()
  1. 執行 app。畫面上應該會顯示可滑動的(scrolling)格狀 Mars images。滑動畫面即可查看新 images,但看起來有點奇怪。當您滑動畫面時,邊框間距會維持在 RecyclerView 的頂端和底部,所以 list 看起來並不會在動作列下方捲動。
  1. 如要修正這個問題,您需要指示 RecyclerView 不要使用 android:clipToPadding 屬性將 inner contents clip 至 padding。這樣,它便會在 padding 區域中繪製 scrolling view。返回 layout/fragment_overview.xml。為 RecyclerView 新增 android:clipToPadding 屬性,並將其設為 false
1
2
3
4
<androidx.recyclerview.widget.RecyclerView
...
android:clipToPadding="false"
... />
  1. 執行 app。請注意,app 在正常顯示 image 前還會顯示 loading-progress icon。這是您傳送給 Coil image library 的 placeholder loading image
  1. 執行 app 期間,請開啟飛航模式。在模擬器中滑動 image。尚未載入的 image 會顯示為 broken-image icons。這是您傳遞至 Coil library的 image drawable,用於在系統出現網路錯誤或無法擷取 image 時顯示。

恭喜您,就快完成了!在接下來的最後一項工作中,您需要在 app 中新增更多錯誤處理機制(error handling),進一步改善使用者體驗。


在 RecyclerView 中新增 error handling

MarsPhotos app 會將無法擷取的圖片顯示為 broken-image icon。但是如果沒有網路連線,app 會顯示空白畫面。您將在接下來的步驟中驗證空白畫面。

開啟裝置或模擬器上的飛航模式。透過 Android Studio 執行應用程式。請注意空白畫面。

這無法提供良好的使用者體驗。在這項工作中,您需要新增基本的錯誤處理機制(error handling),讓使用者更好地瞭解發生的情況。如果未連線到網際網路,app 將顯示 connection-error icon;而在擷取 MarsPhoto list 時,app 會顯示 loading animation。

在 ViewModel 中新增 status

在這項工作中,您會在 OverviewViewModel 中建立屬性來代表 web request 的狀態(status)。需要考慮的 status 有三種:載入(loading)成功(success)失敗(failure)

  • 等待資料時,畫面會顯示 loading status
  • 系統成功從 web service 擷取資料時會顯示 success status
  • 出現網路或連線錯誤時會顯示 failure status
Kotlin 中的 Enum Classes

如要在 app 中表示這三個 status,請使用 enumenum列舉(enumeration)的縮寫,表示集合(collection)中所有 items 已排序的 list。每個 enum 常數都是 enum class 的一個 object

在 Kotlin 中,enum 這種 data type 可容納一組常數(constants)。其定義方式是在 class 定義前方加上 keyword enum,如下所示。列舉常數(Enum constants)會以半形逗號分隔。

定義:

1
2
3
enum class Direction {
NORTH, SOUTH, WEST, EAST
}

使用方式:

1
var direction : Direction = Direction.NORTH 
  • 如上所示,您可以使用 class 名稱後跟點 (.) 運算子和常數名稱來引用 enum object。

注意:您可使用 class 定義中的建構函式(constructor)參數,以自訂 value 初始化列舉常數(Enum constants)。此內容目前不在這個程式碼研究室的範圍內,更多資訊請參閱 Kotlin 說明文件

在 Viewmodel 中新增帶有 status values 的 enum class 定義
  1. 開啟 overview/OverviewViewModel.kt。然後在檔案頂端 (imports之後、class 定義之前) 新增 enum,代表所有可用狀態(available statuses):
1
enum class MarsApiStatus { LOADING, ERROR, DONE }
  1. 捲動至 _statusstatus 屬性的定義後,將 type 從 String 變更為 MarsApiStatus. MarsApiStatus。是您在上一步定義的 enum class。
1
2
3
private val _status = MutableLiveData<MarsApiStatus>()

val status: LiveData<MarsApiStatus> = _status
  1. getMarsPhotos() 方法中,將 “Success: …” 字串變更為 MarsApiStatus.DONE status,並將 “Failure…” 字串變更為 MarsApiStatus.ERROR
1
2
3
4
5
6
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = MarsApiStatus.DONE
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
}
  1. try {} 區塊上方將 status 設為 MarsApiStatus.LOADING。這是協程(coroutine)執行期間以及您在等待資料時初始 status。現在,完整的 viewModelScope.launch {} 區塊如下所示:
1
2
3
4
5
6
7
8
9
viewModelScope.launch {
_status.value = MarsApiStatus.LOADING
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = MarsApiStatus.DONE
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
}
}
  1. catch {} 區塊中的 error state 之後,將 _photos 設為 empty list。這項操作可清除 Recycler view。
1
2
3
4
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
_photos.value = listOf()
}
  1. 完整的 getMarsPhotos() 方法應如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
private fun getMarsPhotos() {
viewModelScope.launch {
_status.value = MarsApiStatus.LOADING
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = MarsApiStatus.DONE
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
_photos.value = listOf()
}
}
}

您已將 status 定義為 enum states,並完成了以下設定:

  1. coroutine 開始時顯示 loading
  2. 在 app 從 web server 擷取好資料時顯示 done
  3. 出現 exception 時顯示 error

在下一項工作中,您將使用 binding adapter 顯示對應的 icons。

為 ImageView status 新增 binding adapter

您已使用一組 enum status 在 OverviewViewModel 中設定了 MarsApiStatus。在這個步驟中,您需要在 app 中顯示它。您可以對 ImageView 使用 binding adapter,以顯示 loading 和 error status 的 icon。如果 app 處於 loading state 或 error state,則應顯示(visible) ImageView。app loading 完畢後,應不再顯示(invisible) ImageView

  1. 請開啟 BindingAdapters.kt,捲動至檔案結尾,即可新增其他 adaptor。新增名為 bindStatus() 的新 binding adapter,ImageViewMarsApiStatus 值做為引數。使用 @BindingAdapter 為方法加上注釋,傳遞自訂屬性 marsApiStatus 做為參數
1
2
3
4
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
status: MarsApiStatus?) {
}
  • 視需要 import com.example.android.marsrealestate.overview.MarsApiStatus
  1. bindStatus() 方法中新增 when {} 區塊,即可在不同 status 之間切換。
1
2
3
when (status) {

}
  1. when {} 中,新增 loading state 的 case (MarsApiStatus.LOADING)。對於這個 state,請將 ImageView 設為 visible,並指派 loading animation。這個 animation drawable 與先前工作中 Coil 採用的 drawable 相同。
1
2
3
4
5
6
when (status) {
MarsApiStatus.LOADING -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.loading_animation)
}
}
  • 視需要 import android.view.View
  1. 新增 error state 的 case,即 MarsApiStatus.ERROR。如同 LOADING state,請將 state ImageView 設為 visible,並使用 connection-error drawable。
1
2
3
4
MarsApiStatus.ERROR -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.ic_connection_error)
}
  1. 新增 done state 的 case,即 MarsApiStatus.DONE。您收到了正確的 response,請將 state ImageView 的 visibility 設定為 View.GONE 以隱藏該 status。
1
2
3
MarsApiStatus.DONE -> {
statusImageView.visibility = View.GONE
}

您已為 status image view 設定了 binding adapter,在下一個步驟中,您需要新增 image view ,該 view 使用新的 binding adapter。

新增 status ImageView

在這個步驟中,您將在 fragment_overview.xml 中新增 Image view,以顯示您先前定義的 status。

  1. 開啟 res/layout/fragment_overview.xml。在 ConstraintLayoutRecyclerView element 下方,新增下方顯示的 ImageView
1
2
3
4
5
6
7
8
9
<ImageView
android:id="@+id/status_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:marsApiStatus="@{viewModel.status}" />

上述 ImageViewRecyclerView 具有相同的 constraints。不過,所設 width 和 height 會使用 wrap_content 將圖片置中,而不是延展圖片來填滿 view。另請注意,app:marsApiStatus 屬性已設為 viewModel.status,並在 ViewModel 中的 status 屬性變更時呼叫 BindingAdapter

  1. 如要測試上述程式碼,請在模擬器或裝置上開啟飛航模式,模擬網路連線錯誤。接著編譯並執行 app,請注意,畫面上會顯示 error image:
  1. 輕觸返回按鈕即可關閉 app,並關閉飛航模式。使用最近畫面可返回 app。當 app 查詢 web service 時,圖片開始 load 之前,畫面上可能會極短暫地顯示 loading 旋轉 icon,具體視您的網路連線速度而定。

恭喜您完成本程式碼研究室,並建構了 MarsPhotos app!現在就與親朋好友分享真實的火星圖片,炫耀一下您的 app 吧。


總結

  • Coil library 可簡化在 app 中管理 image 的程序,例如download, buffer, decode, 和 cache image。
  • Binding adapters 是一種 extension methods,顯示在 view 與 view 綁定的 data 之間。data 變更時,binding adapters 會提供自訂行為,例如呼叫 Coil 將 URL 中的 image 載入 ImageView
  • Binding adapters 是 extension methods,附有 @BindingAdapter 註解。
  • 如要顯示格狀排列的 image,請將 RecyclerViewGridLayoutManager 搭配使用。
  • 如要在屬性變更時更新屬性 list,請使用 RecyclerView 和 layout 之間的 binding adapter。