Combine ile ViewModel ve ViewController arasında haberleşme

Photo by Chang Duong on Unsplash

Combine ile ViewModel ve ViewController arasında haberleşme

Projenizde MVVM mimarisini kullanarak ilerlemek istiyorsanız ve UIKit kullanıyorsanız bunu yapabilmeniz için birden fazla yol olduğunu farketmişsinizdir. Benim son zamanlarda en çok tercih ettiğim yol Protocol-Delegate pattern başta olmak üzere Closure'lar ve hatta NotificationCenter kullanılarak da haberleşme sağlanabilir. Aynı zamanda çok az sayılabilecek boilerplate kod kullanarak Boxing adı verilen yöntemle de istenilen hedefe ulaşılınabilir. Bugün bütün bunlara bir alternatif olarak Combine'ı kullanıyor olacağız.

Adımları tek tek takip edebilmek için mimariyi zaten implemente ettiğim şu projenin "starter" branch'ini kullanabilirsiniz. Ya da bitmiş haline diğer branch'ten ulaşabilirsiniz.

Combine, 2019 yılındaki WWDC etkinliğinde duyurulan, SwiftUI'ın asıl sihrine sahip olmasını sağlayan ve biraz gölgede kalmış bir teknoloji olarak kabaca yorumlanabilir. Fonksiyonel programlama tercih eden geliştiriciler ya da takımlar için Apple tarafından birinci parti bir çözüm olmakla birlikte iOS 13 ve üzeri sürümleri desteklemekte. Apple, çoktan kendi frameworklerine Combine'ı implemente etmiş ve kullanmayı da teşvik ediyor. Mesela URLSession, CoreData, Timer ya da NotificationCenter ile birlikte Combine'ı kullanmak mümkün. Kısaca bir yerden bir yere veri akışı bir haberleşme sağlanmak isteniyorsa Combine bunu yapmanın en modern yolu denilebilir.

Temel olarak üç ayrı kısımdan oluşuyor; Publisher, Operator ve Subscriber. Aslında bu üç kısım da adı üstünde işlerden sorumlu. Publisher ulaştırmak istediğiniz veriyi yayımlamanıza, operator yayımlanan bilginin filtrelenmesi ve düzenlenmesine ve subscriber ise yayımlanmış bilgiye erişim sağlamaya yarıyor.

Combine'ın prensiplerini birkaç cümlede anlamak, bu konuya çok yeni kişiler için zor olabilir. Ben bu tarz yeni bir kavramı iyi bir şekilde anlamak için en azından bir kitap okunmasını tavsiye ediyorum. Fakat bu makale sonunda bazı temelleri nasıl kullanabileceğinizi de anlamış olacağınızı düşünüyorum.

Simulator Screen Recording - iPhone 13 - 2022-05-30 at 16.13.05.gif

Oluşturduğum örnek uygulama içerisinde en sık karşılaşılan senaryoyla birlikte en sık kullanılan arayüz elemanlarını kullanmaya öncelik verdim. Bu uygulama içerisinde herhangi restful bir veri kaynağından çektiğimiz verileri ilk önce view model içerisinde toplayacağız daha sonra ise bunları view controller ile paylaşacağız. Kulağa basit geliyor olabilir ama iyi kavranması gereken birkaç adımdan oluşan bir süreç bu. Aynı zamanda yükleniyor durumunu da yönetiyor olacağız ve son olarak her bir liste elemanının da view modellleriyle paylaştığımız verilere yine view controller içerisinden ulaşmaya çalışacağız.

İlk adımda ListViewModel içerisinde tanımlı boş bir liste bulunmakta. Bu sınıfın içerisinden proje içerisindeki NetworkManager yapısını da kullanarak veri kaynağına bir istek gönderiyor olacağız ve gelen cevaba göre aksiyon alıyor olacağız. Bunu yapabilmek için Combine'ın bize sunduğu publisher yapılarından bazılarını kullanıyor olacağız dolayısıyla ilk önce Combine'ı import ederek başlayabiliriz.

Eğer Combine'ı ya da bunun gibi yüksek seviye Apple framework'lerini import ettiyseniz, Foundation'ı import ettiğiniz satırı silebilirsiniz. Çünkü daha yüksek seviye frameworkler zaten Foundation'ı kendi içerisinde dahil etmiş vaziyetteler.

let commentListPublisher = PassthroughSubject<Void, Never>()
private(set) var comments: [Comment] = []

Yukarıdaki iki satırı sınıf içerisine dahil ederek başlayalım. Tercih ettiğim rest api'da yorum listesini çektiğim için comments adlı değişken içerisinde gelen veriyi tutacağız. Hemen üstünde ise publisher tanımlaması yapıldı. Bu publisher yardımıyla veri bize ulaştığı anda view controller'a haber verebileceğiz.

Hemen aşağıda ise yükleniyor durumunu yönetebilmek için ayrı bir değişken ve publisher tanımlaması yapıldığını görebilirsiniz. Yine bunları da sınıf içerisine dahil edelim.

let loadingStatePublisher = PassthroughSubject<Bool, Never>()
private(set) var isLoading: Bool = false

Yukarıdaki tanımlamalardan da anlayacağınız gibi yaymak istediğiniz ve üstünde yapılan değişiklikleri takip edebilmek istediğiniz herhangi bir veri için ayrıca bir publisher'a ihtiyacınız olacak. Bunu tek satırda kelimenin tam anlamıyla daha elegant yapmanın ayrıca bir yolu var biraz sonra onu da öğreneceksiniz.

Şimdi geldi sıra bu değişkenleri veriyle buluşturmaya ve publisher'lar yardımıyla bu verileri paylaşmaya. Bunun için fetch adında bir fonksiyon yazalım. Bu fonksiyon içerisinde gerekli adımları gerçekleştirelim.

func fetch() {
    isLoading = true
    loadingStatePublisher.send(true)
    let randomInt = Int.random(in: 0..<100)
    NetworkManager.getComments(by: randomInt) { [weak self] result in
        guard let self = self else { return }
        switch result {
        case .success(let comments):
            self.comments = comments
            self.commentListPublisher.send()
            self.isLoading = false
            self.loadingStatePublisher.send(false)
        case .failure(let error):
            print(error.localizedDescription)
            self.isLoading = false
            self.loadingStatePublisher.send(false)
        }
    }
}

Fonksiyon içerisinde ilk yapılan işlem, yükleniyor durumunu pozitif hale getirmek ve publisher'la da bu durum güncellemesini view controller'a göndermek. Daha sonra bu fonksiyon her çağrıldığında rastgele bir sayı hesaplayıp istek gönderirken bu hesaplanan rastgele sayıya göre sonuçlar bekliyor olacağız. Hemen sonrasında NetworkManager singleton yapısını kulanıyoruz ve gelen başarılı ve başarısız sonuçlara göre eylem alıyoruz.

Başarısız olunan istek sonucunda gelen hata mesajını konsola yazmaktan başka bir şekilde kullanmak istemedim. Eğer hata durumda arayüzde kullanıcıya göstermek istediğiniz herhangi bir şey olursa yukarıdaki adımları tekrar edip bir değişken ve bir publisher tanımlaması yapabilir ve bu durumu da yönetebilirsiniz.

Bu bütün işlemleri daha temiz yapabilmemizin bir yolu olduğunu söylemiştim. Published adı verilen Combine ile hayatımıza girmiş bir property wrapper, yukarıdaki işlemleri biraz daha az kodla yapmamıza olanak sağlayacak. Bu yapının kullanılabilmesi için, bulunduğu sınıfın ObservableObject'i miras alması yeterli olacaktır.

@Published private(set) var comments: [Comment] = []
@Published private(set) var isLoading: Bool = false

Yukarıdaki satırlardan da göreceğiniz gibi değişken tanımlamalarının başına bu özel ismi getirmeniz bu değişkenlere publisher olabilme yetisini veriyor. Böylece tek taşla iki kuş vurmuş oluyorsunuz. Ekstradan da bu değişkenlerde yapılan herhangi bir değişiklği ekstradan bildirmeniz gerekmiyor. Hemen aşağıdaki satırları incelerseniz eğer daha önce tanımladığımız fonksiyonun çok daha temiz bir hale geldiğini göreceksiniz.

func fetch() {
    isLoading = true
    let randomInt = Int.random(in: 0..<100)
    NetworkManager.getComments(by: randomInt) { [weak self] result in
        guard let self = self else { return }
        switch result {
        case .success(let comments):
            self.comments = comments
            self.isLoading = false
        case .failure(let error):
            print(error.localizedDescription)
            self.isLoading = false
        }
    }
}

Sıra geldi view controller içerisinden view model tarafından gönderilen değişiklere karşı aksiyon almaya. ListViewController dosyası açıldığında temel arayüz elemanlarıyla ve view model ile konfigürasyonu yapıldığı görülecektir. İlk önce arayüz üzerinde sağ üst köşede bulunan butona tıklandığında view model içerisinde yazdığımız fonksiyonu çağırmak isteyeceğiz.

// MARK: - Actions
extension ListViewController {

    @objc func didTapRefresh(_ button: UIBarButtonItem) {
        viewModel.fetch()
    }
}

Bunu aradan çıkardıktan sonra aslında tek yönlü bir iletişim ağı kurmuş olduk. Yani butona basıldığında view model'in bundan haberi olacak ve görevini gerçekleştirecek fakat bu görev sonucunda gelen dış kaynaktan çektiği verileri veya bunların yükleniyor olma durumu gibi bilgileri view controller'a ulaştıramayacak. Bunu yapabilmesi için view controller içerisindeki bir fonksiyon içerisinde, view model içerisindeki publisher'ları, view controller'a bağlamamız gerekiyor.

viewModel.$comments
    .receive(on: DispatchQueue.main)
    .sink { [weak self] _ in
        guard let self = self else { return }
        self.tableView.reloadData()
    }
    .store(in: &cancellables)

viewModel.$isLoading
    .receive(on: DispatchQueue.main)
    .sink { [weak self] isLoading in
        guard let self = self else { return }
        if isLoading {
            self.loadingView.isHidden = false
        } else {
            self.loadingView.isHidden = true
        }
    }
    .store(in: &cancellables)

bindViewModel fonksiyonu içerisinde yukarıdaki satırları eklememiz gerekiyor. Peki burada ne yapıyoruz? Aslında kabaca yaptığımız işlem view model içerisindeki comments publisher'ına ulaşıyoruz ve Combine'a ait olan receive adındaki metot üzerinden aslında bu çağrının main queue içerisinde gerçekleşmesini belirtiyoruz. Bildiğiniz üzere iOS'de arayüz elemanlarındaki değişiklikleri bu thread üzerinden yapılıyor. sink metodu üzerinden güncel değerlere ulaşıp, arayüzde yapılması gereken değişiklikleri bildiriyoruz. Son olarak store metodu üzerinden bir işlem daha yapıyoruz. Bizim subscribe olduğumuz değişiklikleri artık takip etmek istemediğimiz ve bellekten silmek istediğimiz bir senaryo gerçekleşebilir, bu durumda view controller içerisinde bu subscription'ları store etmemiz gerekiyor. Combine geliştiricileri subscriber'lara cancellable adını vermiş biz de buna sadık kalalım.

private var cancellables: Set<AnyCancellable> = []

Bu aşamadan sonra artık refresh butonuna her basıldığında arayüzün kendini yenilediğini görüyor olmalısınız. Her butona basıldığında view model içerisinde rastgele bir sayı hesaplayıp bu sayı yardımıyla yeni bir istek atıyoruz ve karşılığında gelen yorum listesini ilk önce view model içerisinde depoluyoruz daha sonra view controller'a haber veriyoruz ve verileri arayüzümüzde gösteriyoruz. Buna ek olarak, bir özellik daha ekleyelim hem de bu sayede Combine ile farklı ekranlar arasında haberleşmek için izlenmesi gereken adımları tekrar etmiş olalım.

Geliştirmek istenilen özellik, liste içerisinde herhangi bir yoruma basıldığında o yorumu kimin yaptığını basit bir pop-up ya da modal adıyla da kullanılan alert view controller kullanarak göstermek. Bunun için ilk önce Combine'ı, ListItemCellViewModel içerisinde import edelim daha sonra da email bilgisini view controller ile paylaşabilmek için publisher tanımlaması yapalım.

let emailPublisher = PassthroughSubject<String, Never>()

Publisher tanımlama işlemi yukarıda gördüğünüz gibi olmalı. String değer göndereceğimiz için generic yapıdaki bu publisher'ın ilk parametresini String olarak belirtiyoruz, ikinci parametre olarak da Never türünü kullanalım. Arayüzde gerçekleşen işlemler için genelde bu şekilde bir kullanım mevcut olduğunu gözlemledim. Şimdi hemen aşağıda bu listenin herhangi bir elemanına her basıldığında çağırmak istediğimiz fonksiyonu view model içerisine ekleyelim.

func sendEmailAddress() {
    emailPublisher.send(commentEmail)
}

Burada yapmamız gerekenleri bitirmiş bulunmaktayız son olarak ListViewController'a giderek ilk önce bu view model içerisinde tanımladığımız publisher'a subscribe olmak. Bu işlemi cellForRowAt metodu içerisinde yapmayı tercih ettim. Hem cell'e buradan direk bir ulaşımız mevcut hem de cell view model ile populate edildiğine emin olduktan sonra ilk önce view model daha sonra da ona ait publisher'a hatasız bir şekilde ulaşabiliyoruz.

cell.viewModel.emailPublisher.sink { [weak self] email in
    guard let self = self else { return }
    AlertManager.showAlert(with: email, in: self)
}
.store(in: &cancellables)

Yazıyı buraya kadar okuduğunuz için teşekkür ederim ve ayrıca yeni öğrendiğiniz bilgiler için sizi ayrıca tebrik ederim. Anlamadığınız veya kafanıza oturmayan kısımlar için projenin sağını solunu kurcalamanızı tavsiye ederim. Ayrıca bir yorum da bırakırsanız sevinirim, öğrendiklerinizden başkalarının da haberi olması adına makaleyi paylaşmaktan çekinmeyin.