UICollectionView’da görsel yüklemenin doğru yaklaşımı

Yusuf Kamil Ak
Sahibinden Technology
5 min readJan 8, 2021

iOS geliştirme yaparken sıklıkla kullanılan UITableView ve UICollectionView bileşenlerinde çok sayıda görseli aynı anda yüklemeniz gereken durumlarla karşılaşmış olmalısınız. Basit gibi görünse de birçok geliştiricinin bu konuda çeşitli sıkıntılar yaşadığını gözlemleyerek kısaca bahsetmek istedik.

Diyelim ki, bir UICollectionView’iniz var ve Instagram profil ekranı gibi bir ekran geliştiriyorsunuz. Bu durumda çok sayıda görsel arasında dolaşabilmek için kullanıcılarınızın sayfayı kaydırması (scroll etmesi) gerekecek.

Akla gelen ilk çözüm

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell

metotu içerisinde görsel (image) url’ini kullanarak görsele ait Data’yı çekmek olacaktır. Her ne kadar yanlış olmasa da yalnızca bununla yetinmek çeşitli sorunları ortaya çıkaracaktır.

Problem ne?

Kullanıcı ekranı yeterince hızlı kaydırmaya başladığında görsellerin birbirine girmeye başladığını, bazı görsellerin bir anda değiştiğini hatta aynı görselden birkaç farklı hücrede bulunduğunu görmeye başlayacaktır.

Hatalı UICollectionView görsel yüklemesi

Bu problemi çözerek daha stabil ve kullanıcı dostu bir deneyim yaratmadan önce kısaca Concurrent Queue’lardan bahsedelim.

Global Central Dispatch (GCD) ve Concurrent Queue

GCD bu konunun ötesinde her iOS geliştirici tarafından öğrenilmesi gereken bir konu. Kısaca, thread yönetimini ve eşzamanlı operasyonların yönetilmesini sağlayan düşük-seviye (low-level) bir API. Uygulamaların responsive ve verimli çalışmasını sağlamak adına oldukça önemli. Bu yazımızda Concurrent Queue’dan kısaca bahsedeceğiz.

Her şeyden önce, URL’den görsel yüklemek gibi işlemler asenkron çağrılardır. Kullanıcının arayüzle olan iletişiminde aksaklığa yol açmamak için bu tür işlemler Main thread yerine farklı thread’lerde yapılmalıdır. Bu durumda concurrent queue kullanmamız gerekmektedir. Bizim senaryomuzda bu amaç için Global Queue’yu kullanabilirsiniz.

private func downloadWithGlobalQueue(at indexPath: IndexPath) {
DispatchQueue.global(qos: .utility).async { [weak self] in
// Görsel yükleme işlemi
}
}

İçine aldığı parametre `qos: Quality of Service``i ifade eder ve 6 çeşidi vardır:

  • .userInteractive: Kullanıcının direkt arayüzden etkileşime girdiği, öncelik sırası en yüksek servis türüdür. Her ne kadar performans açısından sürekli kullanımı cazip gelse de batarya ömrü ve OS optimizasyonları açısından yalnızca gerektiğinde kullanılmalıdır.
  • .userInitiated: Kullanıcının başlattığı fakat ne zaman biteceği belirli olmayan görevler için kullanılır. Birkaç saniye içinde tamamlanması öngörülen işler için kullanılmalıdır.
  • .utility: Genelde ilerleme göstergesi (progress indicator) içeren uzun soluklu çağrılar ve hesaplamalar için kullanılır. Bizim örneğimizdeki görsel yüklemelerde olduğu gibi.
  • .background: Arkaplanda çalışması gereken ve süre gibi bir kısıtlaması olmayan işlemler için kullanılır. Veritabanı bakımı, uzak sunucuların eşlenmesi vb. örnekler verilebilir.
  • .unspecified ve .default: Pratikte kullanılmaması gereken eski API’leri de kapsayabilmek için eklenmiş türlerdir.

Her ne kadar bunları bilmek sağlıklı uygulamalar geliştirebilmek adına çok önemli olsa da her zaman kendi Dispatch Queue’muzu yaratıp kullanmak zorunda değiliz. Birçok standard iOS kütüphanesi bunu bizim için yapıyor. Bunlardan biri de: URLSession

// DownloadableImageView.swift
private func downloadWithUrlSession(url: String) {
URLSession.shared.dataTask(with: url) {
data, response, error in
guard let data = data,
let image = UIImage(data: data) else {
return
}
DispatchQueue.main.async {
self.image = image
}
}.resume()
}

URLSession veriyi indirme işini hallettiği için yeni bir queue yaratmamıza gerek kalmıyor. Burada dikkat edilmesi gereken en önemli nokta iOS’te tüm UI değişikliklerinin mutlaka main thread’de yapılması gerektiği bilgisidir.

Problemin çözümü ne?

Öncelikle UITableView ve UICollectionView bileşenlerinde hücrelerin dequeue edilerek (yeniden sıraya dizilerek) kullanıldığını bilmemiz gerekir. Ekranda aynı anda maksimum 12 hücre görünüyorsa kaydırmaya başladığımızda yeni beliren hücreler aslında kaybolan hücrelerin ta kendisidir. iOS bunu kaynak verimliliğini sağlamak adına yapmaktadır. Burada bize düşen görev ise bunu göz önünde bulundurarak bu hücreleri doğru veri ile eşleştirebilmektir.

Görsel yüklemek asenkron bir çağrı olduğu için veri elimize ulaştığında gösterilecek olduğu hücre çoktan kaybolmuş ve yerini başka bir hücreye bırakmış olabilir. Bu durumda esasen farklı bir görseli görmeyi beklediğimiz hücrede eski hücreye ait bir görsel görebiliriz.

Bunu çözmenin en basit yolu, yukarıdaki metotun içerisine dataTask çağrısının hemen öncesine şu satırı eklemektir.

cell.imageView.image = nil
URLSession.shared.dataTask(with: url) { ... }

Böylece hücre yeniden kullanıldığında önce içerisindeki image’ı sileceği için karışıklığı önleyecektir.

Peki şimdi, problem ne?

Her ne kadar imageView.image = nil görsellerin birbirine girmesi problemini çözmüş olsa da çok da iyi olmayan bir internet bağlantısında ekranı kaydırmayı denediğimizde her defasında ekrana yeni giren hücrelerdeki görsellerin görünmediğini, yerine boş bir alanın göründüğünü ve görselin hücre ekrana girdikten sonra belirdiğini görebilirsiniz. Elbette Instagram'daki deneyimimiz bundan çok farklı. Hem yapılan network çağrılarının yükünü azaltmak hem de daha akıcı bir deneyim sağlamak için farklı bir çözüm uygulamalıyız.

Nasıl daha akıcı bir deneyim sağlarız?

Öncelikle bir UIImageView subclass’ı yaratmamız gerekiyor. Bunu, objenin içerisinde görsel linkini tutabilmek için yapıyoruz. Böylece görsel yükleme metotumuzun içerisinde asenkron çağrıdan cevap aldığımızda doğru görseli doğru UIImageView a verdiğimize emin olacağız.

import UIKit
// 1
let imageCache = NSCache<AnyObject, AnyObject>()
class DownloadableImageView: UIImageView {

var urlString: String?
// 2
var dataTask: URLSessionDataTask?

func downloadWithUrlSession(at cell: UICollectionViewCell, urlStr: String) {
urlString = urlStr

guard let url = URL(string: urlStr) else { return }
// 3
image = nil

if let imageFromCache = imageCache.object(forKey: urlStr as AnyObject) as? UIImage {
self.image = imageFromCache
return
}

self.dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in

guard let self = self,
let data = data,
let image = UIImage(data: data) else {
return
}

DispatchQueue.main.async {
if self.urlString == urlStr { // 4
self.image = image
}

imageCache.setObject(image, forKey: urlStr as AnyObject)
}
}

dataTask?.resume()
}
// 5
func cancelLoadingImage() {
dataTask?.cancel()
dataTask = nil
}
}

Şimdi yukarıdaki koddaki maddelerde neler yaptığımıza bakalım:

1 — Bir NSCache objesi yaratıyoruz. Bunu global bir yerde yapmak yerine Singleton bir objenin içerisinde yapmamız da mümkün.

2 — Bu değişkeni metotun içerisindeki urlStr değeriyle kıyaslamak için kullanıyoruz. dataTask değişkenini de task'i iptal edebilmek için tutuyoruz.

3 — Cache’de bu urlStr anahtar kelimesiyle herhangi bir görsel olup olmadığını kontrol ediyoruz. Eğer varsa o görseli kullanıp metottan ayrılıyoruz.

4 — Bu kontrolü sağlamazsak imageView.image = nil yaparak çözdüğümüz problemle tekrar karşılaşmaya başlarız.

5 — Cell yeniden kullanıldığında bu metotu dışarıdan çağırıyor ve işlemi iptal ediyoruz.

ViewController’daki cellForItemAtIndexPath metotumuz da son haliyle şu şekilde görünmeli.

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CellIdentifier", for: indexPath) as! CollectionItemCell
let image = "<https://picsum.photos/seed/\\>(indexPath.item + 1)/200/300" // Lorem Picsum. Sizin görsel url'iniz farklı olacaktır.
// 1
cell.cellReused = {
cell.imageView.cancelLoadingImage()
}
// 2
cell.imageView.downloadWithUrlSession(urlStr: image)
return cell
}

1 — Bu closure (kod bloğu) CollectionItemCell dosyasında prepareForReuse() metotunun içerisinde çağırılıyor. Böylece ekrandan çıkan hücreler için network operasyonlarını iptal edebiliyoruz.

2 — Burada download işlemini gerçekleştiriyoruz. imageView private olarak tanımlanarak hücredeki başka bir metot üzerinden de erişilebilir.

Sonuç

Aşağıdaki videoda da gördüğünüz gibi artık görsellerimiz birbirine girmiyor, her ekran kaydırıldığında yeniden yüklenmiyor.

Projenin son halini aşağıda bulabilirsiniz.

Bir sonraki yazımızda görüşmek üzere.

UICollectionView’da görsel yüklemenin son hali

Projenin son haline aşağıdaki repository’den ulaşabilirsiniz:

https://github.com/yusufkamilak/image-loading-with-cache-tutorial

--

--