Takip et

SwiftUI için Yeniden Kullanılabilir ViewModel Mimarisi: Temiz Kod ve Ölçeklenebilirlik

Modern mobil uygulama geliştirme süreçlerinde, özellikle de SwiftUI gibi deklaratif UI çerçevelerini kullanırken, kodun okunabilirliği, sürdürülebilirliği ve test edilebilirliği kritik önem taşır. Ancak, geliştirme sürecinde hızla büyüyen projelerde, tek bir görünüm (View) dosyasına yığılan iş mantığı (business logic), durum yönetimi (state management) ve veri işleme görevleri, kısa sürede büyük bir karmaşaya yol açabilir. Bu durum, hem yeni özellik eklemeyi zorlaştırır hem de hataların tespit edilmesini ve düzeltilmesini imkansız hale getirebilir. İşte tam da bu noktada, “Yeniden Kullanılabilir ViewModel Mimarisi” devreye girerek, SwiftUI uygulamalarınızı daha organize, esnek ve ölçeklenebilir hale getirmenin anahtarını sunar.

Peki, uygulamalarınızda sıkça karşılaştığınız bu karmaşayı nasıl aşabilir, her bir bileşenin kendi sorumluluklarını üstlendiği, temiz ve modüler bir yapı inşa edebilirsiniz? Cevap, View ve iş mantığını birbirinden net bir şekilde ayıran ViewModel tasarım deseninde yatıyor. Bu makalede, ViewModel mimarisinin temellerinden başlayarak, SwiftUI ile nasıl entegre edileceğini, yeniden kullanılabilirliği artırmak için protokollerin ve jeneriklerin nasıl kullanılacağını, test edilebilirliği maksimuma çıkarmak için bağımlılık enjeksiyonunun (Dependency Injection) gücünü ve daha pek çok ileri düzey konuyu derinlemesine inceleyeceğiz. Amacımız, sıfırdan başlayarak, SwiftUI projelerinizde profesyonel ve sürdürülebilir bir mimari kurmanızı sağlayacak bilgi ve araçları sunmaktır.

Bu yaklaşım, geliştirme ekibinizin verimliliğini artırmanın yanı sıra, uygulama performansını optimize etmenize ve hata ayıklama süreçlerini basitleştirmenize de yardımcı olacaktır. Özellikle büyük ve karmaşık projelerde, iyi tasarlanmış bir ViewModel katmanı, uygulamanızın gelecekteki değişikliklere daha kolay adapte olmasını sağlayarak, uzun vadede size önemli avantajlar sunar. Şimdi, bu güçlü mimarinin detaylarına inerek, SwiftUI geliştirme yolculuğunuzda yeni bir sayfa açmaya hazır mısınız?

ViewModel Nedir ve Neden SwiftUI ile Bu Kadar Önemli Hale Geldi?

ViewModel, genellikle Model-View-ViewModel (MVVM) mimari deseninin temel bir bileşeni olarak karşımıza çıkar. MVVM, kullanıcı arayüzünü (View) iş mantığından (Model) ayırarak, kod tabanını daha düzenli, test edilebilir ve sürdürülebilir hale getirmeyi amaçlar. Peki, ViewModel tam olarak ne iş yapar? Kısaca, ViewModel, View ile Model arasında bir köprü görevi görür. View’in gösterim mantığını (presentation logic) ve durumunu (state) yönetirken, Model’den aldığı verileri View’in kolayca kullanabileceği bir formata dönüştürür ve kullanıcı etkileşimlerini Model’e iletir.

SwiftUI’nin reaktif ve deklaratif doğası, ViewModel desenini daha da cazip hale getirmiştir. SwiftUI ile bir View tanımladığınızda, genellikle bu View’in doğrudan iş mantığıyla ilgilenmesini istemeyiz. View’ler, sadece veriyi görselleştirmek ve kullanıcı etkileşimlerini yakalamakla sorumlu olmalıdır. İş mantığı, ağ istekleri, veritabanı işlemleri, veri formatlama gibi karmaşık görevler ViewModel’in sorumluluğunda olmalıdır. Bu ayrım, View’lerinizi daha basit, daha okunabilir ve daha az hata eğilimli hale getirirken, ViewModel’lerinizi de bağımsız olarak test edilebilir kılar.

SwiftUI’deki @ObservableObject protokolü ve @Published özellik sarmalayıcısı (property wrapper), ViewModel’leri SwiftUI View’leri ile sorunsuz bir şekilde entegre etmemizi sağlar. Bir ViewModel, @ObservableObject protokolüne uyduğunda ve View’in gözlemlemesi gereken özellikleri @Published ile işaretlediğinde, bu özelliklerdeki herhangi bir değişiklik, otomatik olarak ilgili SwiftUI View’lerinin yeniden çizilmesini (re-render) tetikler. Bu reaktif bağlama, manuel olarak UI güncellemeleri yapma ihtiyacını ortadan kaldırır ve geliştirme sürecini önemli ölçüde hızlandırır. Böylece, veri akışı daha öngörülebilir hale gelir ve durum yönetimi karmaşası azalır.

ViewModel kullanımının bir diğer kritik avantajı da test edilebilirliğidir. View’ler genellikle bir işletim sistemi çerçevesine (iOS, macOS vb.) bağlı olduğundan, doğrudan test edilmesi zordur ve genellikle UI testleri gibi daha yavaş ve kırılgan yöntemler gerektirir. Ancak ViewModel’ler, platformdan bağımsız Swift sınıflarıdır. Bu sayede, iş mantığınızı ve durum yönetimini içeren ViewModel’leri, hızlı ve güvenilir bir şekilde birim testleri (unit tests) ile test edebilirsiniz. Bu, kod kalitesini artırır, hataları erken aşamada yakalamanızı sağlar ve uygulama güvenilirliğini önemli ölçüde yükseltir. Sonuç olarak, ViewModel mimarisi, modern SwiftUI uygulamaları geliştiren her geliştiricinin benimsemesi gereken güçlü bir araçtır.

Temel Bir ViewModel Nasıl Oluşturulur? Adım Adım Rehber

ViewModel mimarisini anlamanın en iyi yolu, basit bir örnek üzerinden adım adım uygulamaktır. Bu bölümde, kullanıcıdan bir isim alıp bir karşılama mesajı gösteren temel bir senaryo için nasıl bir ViewModel oluşturacağımızı ve bunu SwiftUI View’i ile nasıl entegre edeceğimizi inceleyeceğiz. Bu örnek, ObservableObject ve @Published kavramlarını pekiştirmenize yardımcı olacaktır.

Adım 1: ViewModel Sınıfını Tanımlamak

İlk olarak, GreetingViewModel adında bir sınıf oluşturacağız. Bu sınıf, kullanıcının adını tutacak, bir karşılama mesajı üretecek ve bu mesajı View’e bildirecektir. ObservableObject protokolünü benimsemesi ve View’in gözlemlemesi gereken özellikleri @Published ile işaretlemesi önemlidir.


import Foundation
import Combine

class GreetingViewModel: ObservableObject {
    @Published var name: String = "" {
        didSet {
            updateGreetingMessage()
        }
    }
    @Published var greetingMessage: String = "Lütfen isminizi girin."

    init() {
        // İsterseniz başlangıçta başka mantıklar ekleyebilirsiniz
    }

    private func updateGreetingMessage() {
        if name.isEmpty {
            greetingMessage = "Lütfen isminizi girin."
        } else {
            greetingMessage = "Merhaba, \(name)!"
        }
    }

    func resetName() {
        name = ""
    }
}
  

Yukarıdaki kod bloğunda, name özelliği @Published ile işaretlenmiştir. Bu, name değeri her değiştiğinde, ViewModel'i gözlemleyen tüm View'lerin otomatik olarak güncelleneceği anlamına gelir. didSet bloğu sayesinde, name değiştiğinde updateGreetingMessage() fonksiyonu çağrılarak greetingMessage da güncellenir. greetingMessage da @Published olduğu için, bu mesaj View'e yansıtılır.

Adım 2: SwiftUI View'i ile ViewModel'i Entegre Etmek

Şimdi, bu ViewModel'i kullanacak bir SwiftUI View oluşturalım. View içinde ViewModel'i başlatmak ve gözlemlemek için @StateObject veya @ObservedObject özellik sarmalayıcılarını kullanırız. Genellikle, bir View'in yaşam döngüsüne bağlı olarak ViewModel'i başlatan ve onun ömrünü yöneten @StateObject tercih edilir.


import SwiftUI

struct GreetingView: View {
    @StateObject var viewModel = GreetingViewModel()

    var body: some View {
        VStack(spacing: 20) {
            TextField("Adınızı girin", text: $viewModel.name)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()

            Text(viewModel.greetingMessage)
                .font(.title)
                .foregroundColor(.blue)

            Button("Adı Temizle") {
                viewModel.resetName()
            }
            .padding()
            .background(Color.red)
            .foregroundColor(.white)
            .cornerRadius(8)
        }
        .padding()
    }
}

struct GreetingView_Previews: PreviewProvider {
    static var previews: some View {
        GreetingView()
    }
}
  

Bu SwiftUI View'inde, @StateObject var viewModel = GreetingViewModel() satırı ile ViewModel'in bir örneğini oluşturup View'in yaşam döngüsüne bağlıyoruz. TextField'ın text parametresini $viewModel.name ile iki yönlü bağlayarak, kullanıcı inputunu doğrudan ViewModel'in name özelliğine iletiyoruz. Text(viewModel.greetingMessage) ise ViewModel'den gelen güncel karşılama mesajını gösteriyor. Button ile de ViewModel'deki resetName() fonksiyonunu çağırarak View'den ViewModel'e bir eylem iletiyoruz.

Uzman İpucu: @StateObject bir View hiyerarşisinde ViewModel'i başlatan View için kullanılır ve ViewModel'in View'in yaşam döngüsü boyunca hayatta kalmasını sağlar. Eğer bir ViewModel'i alt View'lere aktarıyorsanız, @ObservedObject veya environmentObject kullanmayı düşünebilirsiniz. @ObservedObject ana View'den gelen bir referansı izlerken, environmentObject View hiyerarşisinde daha derinlere enjekte etmek için kullanışlıdır.

Bu temel yapı, ViewModel deseninin SwiftUI ile nasıl çalıştığını net bir şekilde ortaya koymaktadır. View, sadece UI elementlerini ve layout'u tanımlarken, tüm iş mantığı ve durum yönetimi ViewModel tarafından halledilmektedir. Bu sayede, View'iniz temiz kalır, ViewModel'iniz bağımsız olarak test edilebilir hale gelir ve uygulamanızın genel mimarisi daha anlaşılır bir yapıya kavuşur. Bu basit örnekten yola çıkarak, daha karmaşık senaryolar için ViewModel'ler oluşturmaya başlayabilir ve uygulamanızın ölçeklenebilirliğini artırabilirsiniz.

Yeniden Kullanılabilirlik Nasıl Sağlanır? Protokoller ve Jenerikler ile Güçlendirme

Uygulamalar büyüdükçe, farklı ekranlar veya bileşenler arasında benzer iş mantıklarıyla karşılaşmak kaçınılmazdır. Örneğin, veri yükleme, form doğrulama, liste filtreleme gibi görevler birçok farklı View için ortak olabilir. Bu tür tekrarlanan mantıkları her seferinde yeniden yazmak yerine, onları yeniden kullanılabilir parçalar halinde tasarlamak, hem kod miktarını azaltır hem de tutarlılığı artırır. İşte tam da bu noktada Swift'in güçlü protokolleri ve jenerikleri devreye girer ve ViewModel mimarinizi daha esnek ve güçlü hale getirir.

Protokoller ile Ortak Arayüzler Tanımlama

Protokoller, belirli bir işlevi yerine getiren sınıfların veya yapının sahip olması gereken metodları ve özellikleri tanımlamamızı sağlar. ViewModel bağlamında, protokoller, farklı ViewModel'lerin paylaşabileceği ortak davranışları veya durumları soyutlamak için idealdir. Örneğin, birçok ViewModel bir API'den veri çekme ve bir yükleme durumu gösterme ihtiyacı duyabilir. Bu ortak davranışı bir protokolle tanımlayabiliriz:


import Foundation
import Combine

protocol LoadableObject: ObservableObject {
    associatedtype Output
    var state: LoadingState { get }
    func load()
}

enum LoadingState {
    case idle
    case loading
    case failed(Error)
    case loaded(Value)
}
  

Bu LoadableObject protokolü, herhangi bir ViewModel'in bir state özelliği (yükleme durumunu belirten) ve bir load() metodu olması gerektiğini belirtir. Böylece, veri yükleyen her ViewModel bu protokole uyarak belirli bir arayüzü sağlamış olur. Bir SwiftUI View'i de bu protokole uyan herhangi bir ViewModel'i kullanarak veri yükleme durumunu dinamik olarak gösterebilir.

Jenerikler ile Farklı Veri Tiplerini Destekleme

Jenerikler, esnek ve yeniden kullanılabilir fonksiyonlar ve tipler yazmamızı sağlayan güçlü bir Swift özelliğidir. Bir ViewModel'i jenerik hale getirerek, farklı Model tipleriyle çalışmasını sağlayabiliriz. Örneğin, yukarıdaki LoadingState enum'ı jenerik bir Value tipi alarak, yüklenecek verinin tipinden bağımsız hale gelmiştir. Şimdi bu protokolü ve enum'ı kullanarak jenerik bir veri yükleme ViewModel'i oluşturalım:


import Foundation
import Combine

class GenericDataLoaderViewModel: ObservableObject, LoadableObject {
    typealias Output = T

    @Published var state: LoadingState = .idle

    private var cancellables = Set()
    private let dataProvider: any DataProvider // Jenerik bir veri sağlayıcı

    init(dataProvider: any DataProvider) {
        self.dataProvider = dataProvider
    }

    func load() {
        guard case .loading = state else { return } // Zaten yükleniyorsa tekrar yükleme
        state = .loading
        dataProvider.fetchData()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.state = .failed(error)
                }
            } receiveValue: { [weak self] data in
                self?.state = .loaded(data)
            }
            .store(in: &cancellables)
    }
}

// DataProvider protokolü, farklı veri kaynakları için soyutlama sağlar
protocol DataProvider {
    func fetchData() -> AnyPublisher
}

// Örnek bir kullanıcı veri sağlayıcısı
class UserAPIDataProvider: DataProvider<[User]> {
    func fetchData() -> AnyPublisher<[User], Error> {
        // API çağrısı simülasyonu
        Just([User(id: 1, name: "Alice"), User(id: 2, name: "Bob")])
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}

struct User: Identifiable, Codable {
    let id: Int
    let name: String
}
  

Bu örnekte, GenericDataLoaderViewModel jenerik bir T tipi alır ve LoadableObject protokolüne uyar. dataProvider bağımlılığı sayesinde, bu ViewModel farklı türde verileri (kullanıcılar, ürünler, makaleler vb.) yüklemek için kullanılabilir, yeter ki ilgili DataProvider protokolüne uyan bir nesne sağlansın. Bu tasarım, aynı yükleme mantığını farklı veri tipleri ve API'ler için yeniden kullanmanızı sağlar.

Vaka Analizi: Farklı API'lerden Veri Çeken Ekranlar

Bir e-ticaret uygulamasında hem ürün listesini hem de blog yazılarını gösteren iki ayrı ekranınız olduğunu varsayalım. Her iki ekran da veriyi bir API'den çekmeli, bir yükleme göstergesi sunmalı, hata durumlarını yönetmeli ve veriler yüklendiğinde bunları göstermelidir. GenericDataLoaderViewModel ve LoadableObject protokolü sayesinde, her ekran için ayrı bir yükleme mantığı yazmak yerine, bu jenerik ViewModel'i kullanabiliriz:

  • ProductListViewModel: GenericDataLoaderViewModel<[Product]>
  • BlogPostsViewModel: GenericDataLoaderViewModel<[BlogPost]>

Her iki ViewModel de aynı temel yükleme işlevselliğini paylaşır, ancak farklı DataProvider implementasyonları sayesinde kendi Model tipleriyle çalışırlar. Bu, kod tekrarını önler, bakımı kolaylaştırır ve yeni bir liste tabanlı ekran eklemeniz gerektiğinde geliştirme sürecini hızlandırır. Protokoller ve jenerikler, ViewModel'lerinizi sadece veri tutucu olmaktan çıkarıp, güçlü, esnek ve gerçekten yeniden kullanılabilir bileşenler haline getirir.

Uzman İpucu: Protokolleri ve jenerikleri bir arada kullanarak, sadece ViewModel'lerinizi değil, aynı zamanda View'lerinizi de daha yeniden kullanılabilir hale getirebilirsiniz. Örneğin, LoadableObject protokolüne uyan herhangi bir ViewModel'i alabilen jenerik bir LoadingView oluşturarak, yükleme durumunu gösteren UI mantığını da tek bir yerde toplayabilirsiniz.

Dependency Injection (Bağımlılık Enjeksiyonu) ile Test Edilebilir ve Esnek ViewModel'ler

Büyük ve karmaşık uygulamalar geliştirirken, kodun parçaları arasındaki bağımlılıklar kaçınılmazdır. Bir ViewModel'in, bir ağ isteği servisine, bir veritabanı yöneticisine veya başka bir yardımcı servise ihtiyacı olabilir. Bu bağımlılıkları doğrudan ViewModel içinde oluşturmak, test edilebilirliği zorlaştırır ve kodun esnekliğini azaltır. İşte bu noktada, Bağımlılık Enjeksiyonu (Dependency Injection - DI) devreye girer. DI, bir nesnenin ihtiyaç duyduğu bağımlılıkları kendisi oluşturmak yerine, dışarıdan sağlanması prensibidir.

Bağımlılık Enjeksiyonu Nedir ve Neden Gereklidir?

Bağımlılık Enjeksiyonu (DI), yazılım tasarım desenlerinden biridir ve bir nesnenin (bağımlı) diğer nesnelerden (bağımlılıklar) sorumlu olmasını sağlamak yerine, bu bağımlılıkların dışarıdan (enjektör tarafından) sağlanmasını ifade eder. ViewModel bağlamında, bu, ViewModel'in kendi servislerini (örneğin, bir API istemcisi) oluşturmak yerine, bu servislerin ViewModel'e dışarıdan iletilmesi anlamına gelir.

DI'nin temel faydaları şunlardır:

  • Test Edilebilirlik: Bağımlılıkları dışarıdan enjekte ederek, testler sırasında gerçek servisler yerine sahte (mock) veya hayali (stub) nesneleri kullanabiliriz. Bu, ViewModel'in izole bir şekilde test edilmesini sağlar.
  • Esneklik ve Modülerlik: Bağımlılıklar soyutlamalar (protokoller) üzerinden sağlandığında, bir bağımlılığın implementasyonunu değiştirmek, ViewModel kodunda herhangi bir değişiklik yapmayı gerektirmez. Bu da uygulamanın daha modüler ve esnek olmasını sağlar.
  • Kod Tekrarının Azalması: Bağımlılıkları tek bir yerden yönetmek, aynı servis nesnesini birçok farklı yere iletme ihtiyacını ortadan kaldırır.
  • Daha Az Sıkı Bağlılık: Nesneler arasındaki sıkı bağlılığı azaltır, bu da kodun bakımını ve evrimini kolaylaştırır.

Farklı Enjeksiyon Yöntemleri

Bağımlılık enjeksiyonunu uygulamanın birkaç yaygın yolu vardır:

  1. Initializer Enjeksiyonu (Constructor Injection): Bağımlılıklar, bir nesne oluşturulduğunda initializer (başlatıcı) aracılığıyla sağlanır. Bu en yaygın ve tercih edilen yöntemdir çünkü bağımlılıkların oluşturulur oluşturulmaz mevcut olmasını garanti eder.
  2. Property Enjeksiyonu (Setter Injection): Bağımlılıklar, nesnenin bir özelliği (property) aracılığıyla enjekte edilir. Genellikle opsiyonel bağımlılıklar veya varsayılan değerleri olan bağımlılıklar için kullanılır.
  3. Method Enjeksiyonu: Bağımlılıklar, bir metoda parametre olarak iletilir. Sadece belirli bir metodun o bağımlılığa ihtiyacı olduğunda kullanışlıdır.

Kod Örneği: UserService Bağımlılığı Olan Bir ViewModel

Bir kullanıcı listesini çeken ve görüntüleyen bir ViewModel düşünelim. Bu ViewModel'in bir UserService'e ihtiyacı var. UserService'i doğrudan ViewModel içinde oluşturmak yerine, initializer aracılığıyla enjekte edeceğiz.


import Foundation
import Combine

// 1. UserService için bir protokol tanımla
protocol UserServiceProtocol {
    func fetchUsers() -> AnyPublisher<[User], Error>
}

// 2. Gerçek UserService implementasyonu
class APIService: UserServiceProtocol {
    func fetchUsers() -> AnyPublisher<[User], Error> {
        // Burada gerçek bir ağ isteği yapılırdı
        return Just([
            User(id: 1, name: "Ahmet"),
            User(id: 2, name: "Ayşe"),
            User(id: 3, name: "Mehmet")
        ])
        .delay(for: .seconds(1), scheduler: DispatchQueue.main) // Ağ gecikmesi simülasyonu
        .setFailureType(to: Error.self)
        .eraseToAnyPublisher()
    }
}

// 3. UserViewModel, UserServiceProtocol bağımlılığını initializer üzerinden alır
class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?

    private var cancellables = Set()
    private let userService: UserServiceProtocol // Bağımlılık

    init(userService: UserServiceProtocol) { // Enjeksiyon burada gerçekleşiyor
        self.userService = userService
    }

    func loadUsers() {
        isLoading = true
        errorMessage = nil

        userService.fetchUsers()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = error.localizedDescription
                }
            } receiveValue: { [weak self] fetchedUsers in
                self?.users = fetchedUsers
            }
            .store(in: &cancellables)
    }
}

struct User: Identifiable, Codable {
    let id: Int
    let name: String
}
  

Bu yapıda, UserListViewModel artık hangi UserService implementasyonunu kullandığını bilmiyor veya umursamıyor. Sadece UserServiceProtocol'e uyan bir nesneye ihtiyacı olduğunu biliyor. Bu sayede, ViewModel'i başlattığınızda gerçek APIService'i veya bir test sırasında sahte bir servisi enjekte edebilirsiniz.

Vaka Analizi: Mock Servislerle ViewModel'i Test Etme

DI'nin en büyük faydalarından biri de test edilebilirliği artırmasıdır. UserListViewModel'i test etmek için, gerçek bir API isteği yapmak yerine, sahte bir UserService oluşturabiliriz:


import XCTest
import Combine
@testable import YourAppName // Uygulamanızın adını buraya yazın

// Sahte UserService implementasyonu
class MockUserService: UserServiceProtocol {
    var shouldReturnError = false
    var mockUsers: [User] = []

    func fetchUsers() -> AnyPublisher<[User], Error> {
        if shouldReturnError {
            return Fail(error: NSError(domain: "MockError", code: 0, userInfo: nil))
                .eraseToAnyPublisher()
        } else {
            return Just(mockUsers)
                .setFailureType(to: Error.self)
                .eraseToAnyPublisher()
        }
    }
}

class UserListViewModelTests: XCTestCase {
    var cancellables: Set!

    override func setUp() {
        super.setUp()
        cancellables = Set()
    }

    override func tearDown() {
        cancellables = nil
        super.tearDown()
    }

    func testLoadUsersSuccessfully() {
        let mockService = MockUserService()
        mockService.mockUsers = [User(id: 1, name: "Test User 1"), User(id: 2, name: "Test User 2")]
        let viewModel = UserListViewModel(userService: mockService)

        let expectation = XCTestExpectation(description: "Users loaded successfully")

        viewModel.$users
            .dropFirst() // Initial empty array
            .sink { users in
                XCTAssertEqual(users.count, 2)
                XCTAssertEqual(users.first?.name, "Test User 1")
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.loadUsers()
        wait(for: [expectation], timeout: 2.0)
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.errorMessage)
    }

    func testLoadUsersFailure() {
        let mockService = MockUserService()
        mockService.shouldReturnError = true
        let viewModel = UserListViewModel(userService: mockService)

        let expectation = XCTestExpectation(description: "Users failed to load")

        viewModel.$errorMessage
            .dropFirst() // Initial nil
            .sink { errorMessage in
                XCTAssertNotNil(errorMessage)
                XCTAssertFalse(errorMessage!.isEmpty)
                expectation.fulfill()
            }
            .store(in: &cancellables)

        viewModel.loadUsers()
        wait(for: [expectation], timeout: 2.0)
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertTrue(viewModel.users.isEmpty)
    }
}
  

Bu testler, ViewModel'in UserService'e olan bağımlılığını enjekte ederek, farklı senaryoları (başarılı yükleme, hata durumu) kolayca test etmemizi sağlar. Gerçek bir ağ bağlantısına veya veritabanına ihtiyaç duymadan ViewModel'in davranışını doğrulayabiliriz. Bu, ViewModel'lerinizi güvenilir, bakımı kolay ve hatalardan arındırılmış hale getirmenin temelidir.

Uzman İpucu: Daha büyük projelerde, bağımlılık enjeksiyonunu yönetmek için Swinject veya Needle gibi bir DI konteyneri kullanmayı düşünebilirsiniz. Bu araçlar, bağımlılık grafiğini oluşturma ve yönetme sürecini otomatikleştirebilir.

İleri Düzey Konular: Combine, Hata Yönetimi ve Kapsülleme

ViewModel mimarisini temel düzeyde anladıktan sonra, uygulamalarınızın daha sağlam, esnek ve hatasız olmasını sağlayacak bazı ileri düzey konulara dalmak faydalı olacaktır. Bu bölümde, Swift'in reaktif programlama çerçevesi Combine'ı ViewModel'lerde daha etkin kullanmayı, güçlü hata yönetimi stratejileri geliştirmeyi ve ViewModel'in iç durumunu doğru bir şekilde kapsüllemeyi ele alacağız.

Combine ile Reaktif Veri Akışları

Combine, Apple'ın reaktif programlama çerçevesidir ve zaman içinde değerleri yayan, işleyen ve alan Publisher/Subscriber modelini kullanarak eş zamansız olayları yönetmeyi sağlar. ViewModel'ler genellikle ağ istekleri, kullanıcı etkileşimleri veya sistem bildirimleri gibi eş zamansız olaylarla ilgilendiği için Combine, bu olayları zarif ve okunabilir bir şekilde ele almak için mükemmel bir araçtır.

Önceki örneklerimizde @Published ve sink operatörünü zaten gördük. Ancak Combine'ın gücü, veri akışlarını birleştirmek, dönüştürmek ve filtrelemek için bir dizi operatör sunmasında yatar. Örneğin, bir metin alanına girilen metnin belirli bir süre sonra (debounce) bir arama isteğini tetiklemesini istiyorsak:


import Foundation
import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText: String = ""
    @Published var searchResults: [String] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?

    private var cancellables = Set()

    init() {
        // searchText'teki değişiklikleri izle
        $searchText
            .dropFirst() // Başlangıçtaki boş değeri atla
            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) // Yarım saniye beklet
            .removeDuplicates() // Ardışık aynı değerleri atla
            .flatMap { [weak self] text -> AnyPublisher<[String], Error> in
                self?.isLoading = true
                self?.errorMessage = nil
                if text.isEmpty {
                    return Just([]).setFailureType(to: Error.self).eraseToAnyPublisher()
                }
                return self?.performSearch(query: text) ?? Empty().eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = error.localizedDescription
                    self?.searchResults = []
                }
            } receiveValue: { [weak self] results in
                self?.searchResults = results
            }
            .store(in: &cancellables)
    }

    private func performSearch(query: String) -> AnyPublisher<[String], Error> {
        // Gerçek bir arama servisi çağrısı burada olurdu
        print("Searching for: \(query)")
        let mockResults = ["Result for \(query) 1", "Result for \(query) 2"]
            .filter { $0.contains(query) } // Basit filtreleme
        return Just(mockResults)
            .delay(for: .seconds(0.3), scheduler: DispatchQueue.main) // API gecikmesi simülasyonu
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}
  

Bu örnekte, debounce ve removeDuplicates gibi operatörler sayesinde, kullanıcı yazmayı bitirene kadar gereksiz API çağrıları yapmaktan kaçınılır. flatMap ise her yeni arama metni için yeni bir publisher (arama isteği) oluşturur ve sonucunu searchResults'e aktarır. Combine, bu tür karmaşık reaktif akışları kolayca yönetmenizi sağlar.

Hata Yönetimi

Sağlam bir uygulama, hata durumlarını etkili bir şekilde yönetebilmelidir. ViewModel'lerde hata yönetimi, View'e kullanıcıya gösterilebilecek anlamlı hata mesajları sağlamakla ilgilidir. Combine ile Publisher'lar bir Failure türü tanımladığı için, hataları yakalamak ve işlemek nispeten kolaydır. Yukarıdaki SearchViewModel örneğinde, errorMessage özelliği aracılığıyla hataları View'e bildiriyoruz.

Daha sofistike bir hata yönetimi için, belirli hata türlerini yakalayıp bunlara göre farklı aksiyonlar alabilirsiniz. Örneğin, bir mapError operatörü kullanarak özel hata tiplerinizi dönüştürebilir veya catch operatörü ile hataları yakalayıp bir yedek Publisher sağlayabilirsiniz.


// Örnek bir custom error
enum AppError: LocalizedError {
    case networkError(Error)
    case invalidData
    case customError(String)

    var errorDescription: String? {
        switch self {
        case .networkError(let error): return "Ağ hatası oluştu: \(error.localizedDescription)"
        case .invalidData: return "Geçersiz veri formatı."
        case .customError(let message): return message
        }
    }
}

// ViewModel içinde hata işleme
// ...
    .mapError { error in
        // Hata türüne göre AppError'a dönüştür
        if let urlError = error as? URLError {
            return AppError.networkError(urlError)
        }
        return AppError.customError("Beklenmeyen bir hata oluştu: \(error.localizedDescription)")
    }
    .catch { error in
        // Hata durumunda bir yedek değer döndür (örneğin boş bir dizi) veya hiç değer döndürme
        Just([]).setFailureType(to: AppError.self)
    }
    .sink { [weak self] completion in
        if case .failure(let error) = completion {
            self?.errorMessage = error.localizedDescription
        }
    } receiveValue: { /* ... */ }
// ...
  

Kapsülleme (Encapsulation)

Kapsülleme, nesneye yönelik programlamanın temel ilkelerinden biridir ve bir nesnenin iç durumunu ve davranışını dış dünyadan gizlemekle ilgilidir. ViewModel'ler için bu, ViewModel'in iç işleyişinin (örneğin, özel Combine cancellable set'leri, yardımcı fonksiyonlar) View veya başka bir dış bileşen tarafından doğrudan erişilemez olmasını sağlamak anlamına gelir.

  • private veya fileprivate Kullanımı: ViewModel içindeki sadece ViewModel'in kullanması gereken özellikleri ve metodları private veya fileprivate olarak işaretlemek, dışarıdan erişimi engeller. Bu, ViewModel'in sorumluluklarını net bir şekilde sınırlar ve istenmeyen yan etkileri önler.
  • @Published ile Sadece Gerekli Durumu Açığa Çıkarma: Yalnızca View'in gözlemlemesi gereken özellikleri @Published olarak işaretleyin. İç mantıkla ilgili geçici durumlar veya yardımcı değişkenler private kalmalıdır.
  • AnyPublisher Kullanımı: Bazı durumlarda, ViewModel'den dışarıya bir Publisher döndürmek isteyebilirsiniz (örneğin, bir eylemin tamamlandığını bildirmek için). Bu durumda, eraseToAnyPublisher() kullanarak Publisher'ın altında yatan somut tipini gizlemek iyi bir uygulamadır. Bu, bağımlılıkları azaltır ve daha esnek bir arayüz sağlar.

class SecureDataViewModel: ObservableObject {
    @Published private(set) var data: String? // Sadece ViewModel değiştirebilir
    @Published var isLoading: Bool = false

    private var internalSecretValue: String = "Gizli Bilgi" // Dışarıdan erişilemez

    func loadData() {
        isLoading = true
        // ... Veri yükleme mantığı
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
            self?.data = "Yüklenen Veri: \(self?.internalSecretValue ?? "")"
            self?.isLoading = false
        }
    }
}
  

Burada, data özelliği dışarıdan sadece okunabilir (private(set)), internalSecretValue ise tamamen gizlidir. Bu tür kapsülleme pratikleri, ViewModel'lerinizi daha güvenli, tutarlı ve bakımı kolay hale getirir. Bu ileri düzey teknikler, uygulamanızın kalitesini ve sürdürülebilirliğini önemli ölçüde artıracaktır.

Gerçek Dünya Senaryosu: Bir E-ticaret Uygulamasında Ürün Detay Ekranı

Şimdiye kadar edindiğimiz bilgileri gerçek bir senaryo üzerinde uygulayalım: Bir e-ticaret uygulamasındaki "Ürün Detay Ekranı". Bu ekran, kullanıcının bir ürünün detaylarını görmesini, sepete eklemesini ve favorilerine kaydetmesini sağlar. Bu işlevsellikler, ViewModel mimarisinin gücünü ve esnekliğini göstermek için harika bir örnektir.

Ürün Detay Ekranının Gereksinimleri ve ViewModel Sorumlulukları

Bir ürün detay ekranı genellikle aşağıdaki işlevleri içerir:

  • Belirli bir ürünün ID'sine göre detaylarını (isim, fiyat, açıklama, resimler) API'den çekme.
  • Ürünün yüklenme durumunu (loading, loaded, error) yönetme.
  • "Sepete Ekle" düğmesine basıldığında ürünü sepete ekleme işlemi.
  • "Favorilere Ekle/Kaldır" düğmesine basıldığında ürünü favoriler listesine ekleme veya çıkarma.
  • Hata durumlarında kullanıcıya uygun mesajlar gösterme.
  • Kullanıcı etkileşimleri sonucunda View'deki durumu güncelleme.

Bu gereksinimler doğrultusunda, ProductDetailViewModel'imiz aşağıdaki sorumlulukları üstlenmelidir:


// Basit Model Tanımları
struct Product: Identifiable, Codable {
    let id: String
    let name: String
    let description: String
    let price: Double
    let imageUrl: String
    var isFavorite: Bool = false
}

struct CartItem: Identifiable, Codable {
    let id = UUID()
    let productId: String
    let quantity: Int
}

enum ProductDetailError: LocalizedError {
    case productNotFound
    case networkError(Error)
    case generalError(String)

    var errorDescription: String? {
        switch self {
        case .productNotFound: return "Ürün bulunamadı."
        case .networkError(let err): return "Ağ hatası: \(err.localizedDescription)"
        case .generalError(let msg): return "Hata: \(msg)"
        }
    }
}

// Servis Protokolleri (Bağımlılık Enjeksiyonu için)
protocol ProductServiceProtocol {
    func fetchProduct(id: String) -> AnyPublisher
}

protocol CartServiceProtocol {
    func addToCart(productId: String, quantity: Int) -> AnyPublisher
}

protocol FavoriteServiceProtocol {
    func toggleFavorite(productId: String) -> AnyPublisher // true: favori oldu, false: favori kaldırıldı
    func isProductFavorite(productId: String) -> AnyPublisher
}

// ViewModel Tanımı
class ProductDetailViewModel: ObservableObject {
    @Published private(set) var product: Product?
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var errorMessage: String?
    @Published private(set) var isAddedToCartSuccessfully: Bool = false
    @Published private(set) var showAddedToCartConfirmation: Bool = false // UI için

    private var productId: String
    private let productService: ProductServiceProtocol
    private let cartService: CartServiceProtocol
    private let favoriteService: FavoriteServiceProtocol
    private var cancellables = Set()

    init(productId: String,
         productService: ProductServiceProtocol,
         cartService: CartServiceProtocol,
         favoriteService: FavoriteServiceProtocol) {
        self.productId = productId
        self.productService = productService
        self.cartService = cartService
        self.favoriteService = favoriteService
        setupCombineSubscriptions()
    }

    private func setupCombineSubscriptions() {
        // Favori durumu değiştiğinde ürünü güncelle
        $product
            .compactMap { $0?.id }
            .flatMap { [unowned self] id in
                self.favoriteService.isProductFavorite(productId: id)
                    .replaceError(with: false) // Hata durumunda varsayılan olarak favori değil say
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isFavorite in
                self?.product?.isFavorite = isFavorite
            }
            .store(in: &cancellables)
    }

    func loadProduct() {
        isLoading = true
        errorMessage = nil
        productService.fetchProduct(id: productId)
            .flatMap { [unowned self] fetchedProduct -> AnyPublisher in
                self.favoriteService.isProductFavorite(productId: fetchedProduct.id)
                    .map { isFavorite in
                        var p = fetchedProduct
                        p.isFavorite = isFavorite
                        return p
                    }
                    .mapError { error -> Error in
                        // Favori servisi hatasını ProductDetailError'a çevir
                        ProductDetailError.networkError(error)
                    }
                    .eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = (error as? ProductDetailError)?.localizedDescription ?? error.localizedDescription
                    self?.product = nil
                }
            } receiveValue: { [weak self] fetchedProduct in
                self?.product = fetchedProduct
            }
            .store(in: &cancellables)
    }

    func addToCart(quantity: Int) {
        guard let product = product else {
            errorMessage = "Ürün bilgisi mevcut değil."
            return
        }
        isAddedToCartSuccessfully = false
        cartService.addToCart(productId: product.id, quantity: quantity)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.errorMessage = (error as? ProductDetailError)?.localizedDescription ?? error.localizedDescription
                } else {
                    self?.isAddedToCartSuccessfully = true
                    self?.showAddedToCartConfirmation = true // UI bildirimi için
                }
            } receiveValue: { _ in }
            .store(in: &cancellables)
    }

    func toggleFavorite() {
        guard let product = product else {
            errorMessage = "Ürün bilgisi mevcut değil."
            return
        }
        favoriteService.toggleFavorite(productId: product.id)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.errorMessage = (error as? ProductDetailError)?.localizedDescription ?? error.localizedDescription
                }
            } receiveValue: { [weak self] isFavorite in
                self?.product?.isFavorite = isFavorite
            }
            .store(in: &cancellables)
    }
}
  

Açıklama:

  • Bağımlılık Enjeksiyonu: ViewModel, ProductServiceProtocol, CartServiceProtocol ve FavoriteServiceProtocol türünden servisleri initializer aracılığıyla alır. Bu, ViewModel'in test edilebilirliğini artırır ve farklı servis implementasyonları kullanılmasına olanak tanır (örneğin, sahte servisler veya farklı API sürümleri).
  • Combine ile Veri Akışı: loadProduct() metodu, productService'den ürün detaylarını çeker ve ardından favoriteService'den ürünün favori olup olmadığını kontrol eder. Bu iki eş zamansız işlemi flatMap kullanarak birleştiririz. Böylece, View'e tek bir ürün objesi yansıtılmadan önce gerekli tüm bilgiler toplanmış olur.
  • Hata Yönetimi: Potansiyel hatalar (ProductDetailError enum'ı ile tanımlanmıştır) Combine zinciri içinde yakalanır ve errorMessage özelliğine atanarak View'e bildirim sağlanır.
  • Durum Yönetimi: isLoading, errorMessage, isAddedToCartSuccessfully gibi @Published özellikler, View'in ViewModel'in durum değişikliklerine reaktif olarak tepki vermesini sağlar (örneğin, yüklenirken bir spinner göstermek, hata mesajı yayınlamak, sepete ekleme onayı göstermek).
  • Kapsülleme: product, isLoading ve errorMessage gibi özellikler private(set) ile işaretlenerek sadece ViewModel'in içinden değiştirilebilir olmaları sağlanır, dışarıdan sadece okunabilirler. Bu, ViewModel'in durumunun kontrollü bir şekilde yönetilmesini sağlar.

Bu ViewModel yapısı, ürün detay ekranı gibi karmaşık bir UI için temiz, test edilebilir ve sürdürülebilir bir çözüm sunar. Her bir sorumluluk açıkça tanımlanmıştır ve ViewModel, View'in sadece görselleştirmeyle ilgilenmesini sağlarken, tüm iş mantığını kendi içinde kapsüller. Bu, özellikle büyük ekiplerde ve zamanla gelişen uygulamalarda kod tabanının yönetilmesini önemli ölçüde kolaylaştırır.

Sonuç: Neden Yeniden Kullanılabilir ViewModel Mimarisi SwiftUI Geliştirmenizi Dönüştürecek?

SwiftUI, deklaratif yapısı ve hızlı geliştirme yetenekleriyle mobil uygulama dünyasında devrim yaratıyor. Ancak bu gücün tam potansiyelini açığa çıkarmak ve karmaşık projeleri yönetilebilir kılmak için sağlam bir mimari temel şarttır. Yeniden Kullanılabilir ViewModel Mimarisi, tam da bu ihtiyaca cevap vererek, SwiftUI geliştirme pratiğinizi kökten dönüştürme potansiyeli taşır.

Bu makalede gördüğümüz gibi, ViewModel'ler View'leri iş mantığından ve durum yönetiminden ayırarak, daha temiz ve okunabilir bir kod tabanı oluşturmanın anahtarını sunar. Bu ayrım, View'lerinizin sadece görsel sunuma odaklanmasını sağlarken, iş mantığınızın bağımsız ve test edilebilir bir katmanda yaşamasını temin eder. Protokoller ve jenerikler aracılığıyla sağlanan yeniden kullanılabilirlik, aynı mantığı farklı View'ler veya veri tipleri için tekrar tekrar yazma ihtiyacını ortadan kaldırır, bu da geliştirme hızını artırır ve kod tekrarını azaltır. Bağımlılık Enjeksiyonu, ViewModel'lerinizi daha esnek, modüler ve özellikle birim testleri için son derece uygun hale getirir.

Combine çerçevesi ile ViewModel'leriniz reaktif veri akışlarını ve eş zamansız işlemleri zarif bir şekilde yönetebilir, böylece karmaşık UI durumları ve ağ etkileşimleri daha öngörülebilir hale gelir. Kapsülleme prensipleri ise ViewModel'lerinizin iç durumunu koruyarak, uygulama genelinde tutarlılığı ve veri güvenliğini sağlar. Tüm bu bileşenler bir araya geldiğinde, sadece mevcut projelerinizi daha iyi yönetmekle kalmayacak, aynı zamanda gelecekteki değişikliklere ve yeni özelliklere çok daha kolay adapte olabilecek bir yapı inşa edeceksiniz.

Özetle, Yeniden Kullanılabilir ViewModel Mimarisi:

  • Temiz Kod: View ve iş mantığını ayırarak kod okunabilirliğini artırır.
  • Test Edilebilirlik: ViewModel'lerinizi bağımsız olarak test etmenizi sağlar, hata yakalama oranını yükseltir.
  • Ölçeklenebilirlik: Uygulamanız büyüdükçe bileşenlerinizi yönetmeyi kolaylaştırır.
  • Geliştirme Hızı: Kod tekrarını azaltır ve modüler yapısıyla yeni özellik eklemeyi hızlandırır.
  • Ekip Çalışması: Sorumlulukların net ayrımı sayesinde ekiplerin paralel çalışmasını kolaylaştırır.

SwiftUI ile kaliteli, performanslı ve sürdürülebilir uygulamalar geliştirmek isteyen her geliştirici için ViewModel mimarisi vazgeçilmez bir araçtır. Bu prensipleri benimseyerek, projelerinizin geleceğe hazır olduğundan emin olabilir, geliştirme sürecinizde daha az sorunla karşılaşarak daha verimli sonuçlar elde edebilirsiniz.

Sıkça Sorulan Sorular (SSS)

  • S: Her SwiftUI View'i için ayrı bir ViewModel gerekli midir?

    C: Hayır, her View için ayrı bir ViewModel gerekli değildir. Basit, sadece sunum amaçlı (statik metinler, basit görsel öğeler) View'ler için ViewModel'e ihtiyaç duyulmayabilir. ViewModel, genellikle iş mantığı, durum yönetimi veya veri işleme gerektiren View'ler için kullanılır. Küçük bileşenler, daha büyük bir View'in ViewModel'ini ortak kullanabilir veya sadece @State gibi yerel durum yönetim araçlarıyla yetinebilir.

  • S: ViewModel'ler arasında nasıl iletişim kurulur?

    C: ViewModel'ler arasında iletişim kurmanın birkaç yolu vardır:

    • Delegasyon (Delegation): Bir ViewModel'in başka bir ViewModel'e veya servise bir eylemin tamamlandığını veya bir durumun değiştiğini bildirmesi için bir protokol aracılığıyla delege paterni kullanılabilir.
    • Combine Publishers: Bir ViewModel, @Published özelliğini veya PassthroughSubject gibi bir Publisher'ı dışarıya açarak diğer ViewModel'lerin veya View'lerin bu değişiklikleri gözlemlemesini sağlayabilir.
    • Environment Objects: Bir ViewModel'i environmentObject olarak tanımlayarak, View hiyerarşisinde daha alt seviyedeki View'lerin veya diğer ViewModel'lerin ona erişmesini sağlayabilirsiniz. Bu, genellikle global veya uygulama çapındaki durumlar için uygundur.
    • Initializer Enjeksiyonu: Bir ViewModel, başka bir ViewModel'i bağımlılık olarak alabilir, bu da doğrudan bir iletişim yolu sağlar.
  • S: ViewModel'lerde UI mantığı olmalı mıdır?

    C: Genellikle hayır. ViewModel'ler, View'in gösterim mantığını (presentation logic) ve durumunu yönetir ancak doğrudan UI elementlerini (örneğin, bir düğmenin rengi, bir font boyutu) veya onların yerleşimini kontrol etmemelidir. ViewModel, View'in "ne" göstereceğini belirlerken, View "nasıl" göstereceğini belirlemelidir. Örneğin, bir hata mesajının içeriğini ViewModel sağlar, ancak bu mesajın kırmızı renkte ve büyük fontla gösterilmesi View'in sorumluluğundadır. Ancak, bazı durumlar (örneğin, bir navigasyon akışını tetiklemek gibi) ViewModel'den View'e dolaylı bir UI koordinasyonu gerektirebilir.

  • S: ViewModel kullanımı uygulamanın performansını etkiler mi?

    C: Doğru şekilde uygulandığında, ViewModel kullanımı uygulamanın performansını olumsuz etkilemez, aksine iyileştirebilir. ViewModel'ler, iş mantığını ve durum yönetimini View'den ayırarak, View'lerin daha hafif olmasını sağlar. @ObservableObject ve @Published mekanizmaları oldukça optimize edilmiştir ve yalnızca ilgili View'lerin değişen verilere tepki olarak yeniden çizilmesini sağlar. Aşırı karmaşık ViewModel'ler veya gereksiz yere çok sayıda Publisher kullanımı potansiyel olarak performans sorunlarına yol açabilir, ancak bu genellikle kötü tasarımdan kaynaklanır, ViewModel mimarisinin kendisinden değil. Optimize edilmiş Combine operatörleri ve akıllı durum yönetimi ile performans sorunları minimuma indirilebilir.

Yorumlar
İçeriği beğendiniz mi? Bir tartışma başlatın veya görüşlerinizi paylaşın.
Yorum Yaz

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

E-posta Bülteni
Yazılım Topluluğuna Katılın
En son güncellemeleri, yaratıcı ipuçlarını ve özel kaynakları doğrudan e-posta kutunuza alın. Tasarım ve inovasyonun geleceğini birlikte keşfedelim.