collection view & table view 的網路圖片顯示問題

開發 iOS App 時,我們時常在 collection view & table view 的 cell 顯示網路抓取的圖片,然而撰寫這部分程式時卻有一些要注意的小地方,接下來就讓我們以抓取 Placeholder.com 的彩色圖片為例說明吧。

設計 storyboard 的畫面,以 collection view 呈現彩色圖片

在 storyboard 加入 collection view controller,依下圖設計顯示的畫面。

完成以下設定。

  • 將 controller 類別設為繼承 UICollectionViewController 的 PhotoCollectionViewController。
  • 將 cell 類別設為繼承 UICollectionViewCell 的 PhotoCollectionViewCell。
  • 將 cell id 設為 PhotoCollectionViewCell。
  • 從 collection view 設定 cell size 180 * 180,Estimate Size 設為 None。

定義資料的型別 Photo

url 儲存圖片網址,rgb 儲存顏色的十六進位字串,比方 FFA3FF。

struct Photo {
let rgb: String
let url: URL
}

定義 cell 的型別 PhotoCollectionViewCell

在 PhotoCollectionViewCell 裡宣告儲存資料的 property photo,方便之後 controller 呼叫 cell 的 function update 設定 cell。

class PhotoCollectionViewCell: UICollectionViewCell {

var photo: Photo!
@IBOutlet weak var label: UILabel!
@IBOutlet weak var imageView: UIImageView!

func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data,
let image = UIImage(data: data) {
completion(image)
} else {
completion(nil)
}
}.resume()
}


func update() {
label.text = photo.rgb
fetchImage(url: photo.url) { image in
guard let image else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
}
}
}

定義 controller 的型別 PhotoCollectionViewController

從 Placeholder.com 抓圖時圖片的網址有固定的格式,我們可指定圖片的大小跟顏色,比方 https://via.placeholder.com/200/FF0000 將抓取寬高 200 pixel 的紅色圖片。

因此我們以亂數產生 1000 個顏色的字串,生成 1000 個 Photo 存在 array 裡,然後讓 collection view 顯示這 1000 張圖片。

class PhotoCollectionViewController: UICollectionViewController {
let photos = (1...1000).map { _ -> Photo in
let rgb = (1...3).reduce("") { result, _ in
result.appending(String(Int.random(in: 0...255), radix: 16, uppercase: true))
}
let url = URL(string: "https://via.placeholder.com/200/\(rgb)")!
return Photo(rgb: rgb, url: url)
}

override func viewDidLoad() {
super.viewDidLoad()
}

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return photos.count
}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(PhotoCollectionViewCell.self)", for: indexPath) as! PhotoCollectionViewCell

cell.photo = photos[indexPath.item]
cell.update()

return cell
}
}

問題

當我們滑動畫面時,從以下兩張 App 畫面可發現一些問題。左邊的畫面跟右邊的畫面大概相隔 2 秒,左邊是圖片還沒抓完,右邊是圖片已抓完的畫面。

  • 當圖片還沒抓到時,cell 將顯示空白區塊。
  • 由於 cell 重覆利用的特性,使用者將看到 cell 先顯示之前 image view 的圖片,然後才更新成正確的圖片。

解法: 抓取圖片前將 image view 設為預設圖片

當圖片還沒抓到時先顯示預設圖片,當 cell 重覆利用時,它也會先顯示預設圖片,不會顯示之前的圖片。

func update() {
label.text = photo.rgb
imageView.image = UIImage(systemName: "questionmark.circle")
fetchImage(url: photo.url) { (image) in
guard let image else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
}
}

ps: 我們也可以在 prepareForReuse 裡設定預設圖片,關於防止重覆利用的 cell 顯示之前的照片,有興趣的朋友可進一步參考以下連結。

問題: cell 顯示錯誤的圖片

以上的寫法已可解決大部分的問題,不過有時我們卻會遇到 cell 顯示錯誤圖片的問題。

為了容易重現這個問題,我們故意將 array 裡第一張圖的網址改成尺寸超大的可愛貓咪圖,讓它抓取的時間比較久。(ps: 圖片網址若失效,測試時請改成其它圖片網址)

class PhotoCollectionViewController: UICollectionViewController {

let photos = (1...1000).map { number in
let rgb = (1...3).reduce("") { result, _ in
result.appending(String(Int.random(in: 0...255), radix: 16, uppercase: true))
}
let url = URL(string: "https://via.placeholder.com/200/\(rgb)")!
if number == 1 {
return Photo(rgb: "xxx", url: URL(string: "https://unsplash.com/photos/9SWHIgu8A8k/download?force=true")!)
} else {
return Photo(rgb: rgb, url: url)
}
}

如下圖所示,可愛貓咪應該出現在第一個 cell,但牠卻出現在別的 cell 了。怎麼會這樣呢 ?

當我們滑動表格後,顯示的第 n 個 cell 其實是當初的第一個 cell(因為 cell 的重覆利用)。由於第 n 個 cell 對應的圖較小,所以它的圖將先抓到,cell 上的圖片先被設為某個顏色的圖片。接著當尺寸超大的貓咪圖抓到時,它將第 n 個 cell (原本是第一個 cell)的圖換成貓咪圖,因此造成 cell 顯示了錯誤的圖片。

解法

以下我們介紹 5 種解法。

  • 解法 1 : 圖片抓到時,檢查 cell 應該顯示的圖是否還是當初抓的圖,比方比對資料的 id 或圖片的 URL。
  • 解法 2: 在 cell 裡儲存抓圖的 task,在 prepareForReuse 取消抓圖的task
  • 解法 3: 在 cell 裡儲存抓圖的 task,當 cell 從畫面消失,從 collection view 或 table view 移除時取消抓圖的task (定義 fucntion didEndDisplaying cell )
  • 解法 4: 圖片抓到時,檢查 cell 的 indexPath 是否已經改變。(ps: 此方法在 iOS 15 測試正常,印象中在舊版有問題)
  • 解法 5: 使用套件。

解法 1 : 圖片抓到時,檢查 cell 應該顯示的圖是否還是當初抓的圖,比方比對資料的 id 或圖片的 URL

為了避免 cell 顯示錯誤的圖片,抓到圖後我們應檢查 cell 是否已變了心,變成別的位置的 cell。在剛剛的例子,cell 顯示的圖片跟 Photo 的 rgb 有關,因此我們可用 rgb 判斷。在抓圖前先將 photo.rgb 存在常數 rgb 裡,抓到圖後再判斷 rgb & self.photo.rgb 是否相等若 cell 已經因為重覆利用變成別的位置的 cell,它儲存的 photo 資料也會不一樣,因此我們可用 photo.rgb 跟當初的 rgb 比較。

PhotoCollectionViewCell

func update() {
let rgb = photo.rgb
label.text = rgb
imageView.image = UIImage(systemName: "questionmark.circle")
fetchImage(url: photo.url) { image in
guard let image else { return }

DispatchQueue.main.async {
if rgb == self.photo.rgb {
self.imageView.image = image
}
}
}
}

此方法的重點在於檢查 cell 對應的資料是否還是當初抓圖時的資料,因此判斷的方法將因資料而異,比方資料的 id,名字或圖片網址都可以當成判斷的欄位。

解法 2: 在 cell 裡儲存抓圖的 task,在 prepareForReuse 取消抓圖的task

抓圖時我們將產生的 task 存起來,當 cell 因為重覆利用準備變成另一個位置的 cell 時,將觸發 function prepareForReuse,我們在 prepareForReuse 呼叫 task?.cancel(),取消抓圖的任務,因為此時 cell 變成了新的位置,要顯示的是別筆資料的圖片,不是之前抓的圖片。

class PhotoCollectionViewCell: UICollectionViewCell {

var photo: Photo!
@IBOutlet weak var label: UILabel!
@IBOutlet weak var imageView: UIImageView!
var task: URLSessionDataTask?

func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void) {
task = URLSession.shared.dataTask(with: url) { data, response, error in
if let data,
let image = UIImage(data: data) {
completion(image)

} else {
completion(nil)
}
self.task = nil
}
task?.resume()
}

override func prepareForReuse() {
super.prepareForReuse()
task?.cancel()
task = nil
}

解法 3: 在 cell 裡儲存抓圖的 task,當 cell 從畫面消失,從 collection view 或 table view 移除時取消抓圖的task (定義 fucntion didEndDisplaying cell )

抓圖時我們將產生的 task 存起來。

class PhotoCollectionViewCell: UICollectionViewCell {

var photo: Photo!
@IBOutlet weak var label: UILabel!
@IBOutlet weak var imageView: UIImageView!
var task: URLSessionDataTask?

func fetchImage(url: URL, completion: @escaping (UIImage?) -> Void) {
task = URLSession.shared.dataTask(with: url) { data, response, error in
if let data,
let image = UIImage(data: data) {
completion(image)

} else {
completion(nil)
}
self.task = nil
}
task?.resume()
}

當 cell 從畫面消失,從 collection view 或 table view 移除時取消抓圖的task。(ps: 若是 table view,可定義 function tableView(_:didEndDisplaying:forRowAt:) )

class PhotoCollectionViewController: UICollectionViewController {

override func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
let cell = cell as? PhotoCollectionViewCell
cell?.task?.cancel()
cell?.task = nil
}

解法 4: 圖片抓到時,檢查 cell 的 indexPath 是否已經改變

圖片抓到時,檢查 cell 的 indexPath 是否已經改變。若是 cell 的 indexPath 已經改變,表示之前抓的圖不該在 cell 顯示。(ps: 此方法在 iOS 15 測試正常,印象中在舊版有問題)

class PhotoCollectionViewController: UICollectionViewController {

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(PhotoCollectionViewCell.self)", for: indexPath) as! PhotoCollectionViewCell
let photo = photos[indexPath.item]
cell.label.text = photo.rgb
cell.imageView.image = UIImage(systemName: "questionmark.circle")
cell.fetchImage(url: photo.url) { image in
DispatchQueue.main.async {
guard let image,
indexPath == collectionView.indexPath(for: cell) else {
return cell
}
cell.imageView.image = image
}
}
return cell
}

解法 5: 使用套件 Kingfisher

網路上有很多第三方抓圖的套件,所以想偷懶的朋友也可以直接使用套件,省去花時間處理網路圖片的顯示問題,比方
Kingfisher 就是不錯的選擇。

仔細研究 Kingfisher 的程式後,可發現它在抓到圖時檢查 image view 是否應該顯示抓到的圖,有興趣的朋友可研究 ImageView+Kingfisher.swift 133 行的 guard issuedIdentifier == self.taskIdentifier else

利用 NSCache 暫存網路圖片

若想讓圖片顯示的效果更好,也可利用 NSCache 暫存網路圖片,這樣抓過的圖片將可暫存在 App 裡,滑到已抓過圖片的 cell 時圖片將立即顯示,不用再重新抓圖。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com