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 時圖片將立即顯示,不用再重新抓圖。