SwiftUI'da veri akışı ve MVC mimarisi

SwiftUI 2019 senesinde Apple tarafından duyurulan, adından da anlaşılacağı gibi Swift programlama dili yardımıyla iOS ve MacOS uygulamaları oluşturmak için geliştirilmiş ve geliştirilmeye devam edilen bir framework.

2020 yılının başında iOS öğrenmeye başlayan ve React gibi declarative dünyadan çıkmış biri olarak çok ilgimi çekmiş kullanmak istemiştim fakat o günün şartlarıyla çok fazla eksiği olduğunu ve sektörün benimsemesinin uzun yıllar alacağını farkettikten sonra bir köşeden gelişimini takip etmeye devam etmiştim. Daha sonra 2. versiyonu 2020'de yayınlandıktan sonra benim gibi birçok declarative framework seven geliştiricinin tekrar ilgisini çekmekle kalmamış aynı zamanda birçok eksiğini gidermiş vaziyetteydi. Bu versiyondan sonra birçok bireysel geliştirici, özellikle yurtdışında, projelerini SwiftUI ile geliştirmeye başladı ve çok başarılı ürünler ortaya çıkardılar.

2021 yazında benim de yaklaşık 3 aylık bir yaz stajı sırasında SwiftUI'ı profesyonel bir projede kullanma şansım oldu. UIKit ile 3 ay uğraşabileceğim bir uygulamayı belki de 3-4 hafta da kabaca bitirmiştim. Tabi ki birçok sorunla karşılaştım. En önemlisi ne o sırada bulunduğum şirkette ne de internet üzerinde çok fazla SwiftUI ile karşılaştığım problemleri sorabileceğim ve cevap alabileceğim kitlenin olmamasıydı. Yine de declarative yapısı sayesinde bazı problemlere birazcık hacky(!) de olsa çözümler üretmemi sağlamıştı.

SwiftUI kendi içerisinde Swift dilini struct veri yapısını bol bol kullanan ve mümkün olduğunca da bunu teşvik eden bir yapıya sahip. Bunun sebebi olarak struct'ların copy-by-value olmalarından kaynaklanan daha lightweight yapısı ve bellekte kolay oluşturulup kolay yok edilebilen yapısı olduğunu düşünüyorum. Struct'ların hafif yapısı bir avantaj gibi görünse de, programlamadaki hemen hemen her şey gibi, aynı zamanda dezavantaj olarak görebileceğimiz bir problemle geliyor. Struct'lar default olarak state tutamıyor. Farklı bir şekilde söylemek gerekirse, struct içerisinde tanımlanan değişkenlerin değerleri değiştirilemez. Apple mühendisleri bu soruna property wrapper'ları geliştirerek çözüm üretmişler. Property wrapper'ları anlamak, SwiftUI içerisindeki veri akışını (data flow) anlamak için kritik bir nokta diyebilirim.

State

En yaygın kullanılan property wrapper bu olabilir. Adı üstünde state değeri tutuyor. Bununla tutulan değer primitive bir tip olmak zorunda bu noktayı gözden kaçırmayın sakın. Genelde lokal state'leri tutmak için kullanıldığını söyleyebilirim. Kullanım olarak bir form içindeki kullanıcı adı ve parola gibi değerleri tutmak örnek olarak verilebilir. Kullanımı ise bir değişken yaratmaktan farksız, sadece başına State eklemeyi unutmayın yeter.

@State private var number: Int = 0

Binding

Yaygın kullanılan property wrapper'lardan bir diğeri. SwiftUI'ın composition prensibini benimsemesi sebebiyle aslında her view bir diğer view'ın ya çocuğu ya da ebeveyni olarak görülüyor. Üniversite'de veri yapıları dersi aldıysanız eğer ağaç (tree) veri yapısındaki parent-child ilişkisini anımsamak ne demek istediğimi anlamanıza yardımcı olacaktır.

Bu yapı parent olan bir view'dan child olan bir view'a veriyi göndermek ve ordan da değerini değiştirilebilmesini mümkün kılmaktadır. Child dediğimiz view, parent'ındaki state'in bir nevi referansını tutar ve anlık değerine hem ulaşıp kullanabilir hem de değiştirebilir.

@Binding var number: Int

Şimdiye kadar bahsettiğim bu iki yapıyı anlama ve kullanabilmek çok önemli çünkü SwiftUI kendi içerisinde yaygın olarak bu yapıları kullanmakta. Örnek vermek gerekirse basit bir textfield ya da date picker bileşenleri two-way binding prensibiyle çalışmaktadırlar.

Basit bir counter uygulaması yapalım. Şimdiye kadar bahsettiğim State ve Binding yapılarını daha iyi anlayalım. Xcode'u ateşledikten sonra adını kafanıza göre belirlediğiniz bir proje oluşturabilirsiniz. Oluşturmak istediğimiz uygulamanın son halini aşağıdaki görselden görebilirsiniz.

Simulator Screen Recording - iPhone 13 - 2022-03-29 at 15.08.49.gif

Hemen aşağıda paylaştığım kod bloğunda, görseldeki uygulamanın en ilkel versiyonunu görebilirsiniz. Kod tekrarı var, business logic ve view iç içe geçmiş vs. Aşama aşama bu kodu daha iyi hale getirelim. Bu süreçte veri akışını, property wrapper'ları ve kodu nasıl mvc tasarım kalıbı ve composition yaklaşımlarını kullanarak daha temiz hale getirebileceğimizi anlayalım.

import SwiftUI

struct ContentView: View {
    @State private var number: Int = 0

    var body: some View {
        VStack {
            Text("Number: \(number)")
                .font(.system(size: 30, weight: .semibold))

            HStack {
                Button {
                    number += 1
                } label: {
                    Text("Increase")
                        .padding()
                        .background(.green)
                        .foregroundColor(.white)
                        .clipShape(Capsule())
                }

                Button {
                    number -= 1
                } label: {
                    Text("Decrease")
                        .padding()
                        .background(.red)
                        .foregroundColor(.white)
                        .clipShape(Capsule())
                }
            }
        }
    }
}

Beni şahsen ilk rahatsız eden şey iki adet buton var, butonların sadece arkaplan renkleri ve title'ları farklı. Bunu daha iyi bir şekilde yazabilmek mümkün. Ayrı bir subview (ya da component) oluşturup, o oluşturduğumuz yapıyı kod tekrarı yapmadan kullanabiliriz.

Bunun için ilk önce enum kullanarak bir state objesi oluşturmak istiyorum.

enum ButtonType: String {
    case increment = "Increase"
    case decrement = "Decrease"
}

Daha sonra ayrı bir subview oluşturalım. Bu subview içerisinde butonun tipini ve butona bind edeceğimiz değeri tutan iki ayrı değişken, subview gövdesi (yani body) ve birkaç yardımcı metod olacak. Şimdilik hemen yukarıda oluşturduğumuz enum'ı da bu view içerisinde tutalım daha sonra extension'lar yardımıyla daha temiz hale getiririz.

struct CustomButton: View {

    enum ButtonType: String {
        case increment = "Increase"
        case decrement = "Decrease"
    }

    @Binding var number: Int
    var type: ButtonType

    var body: some View {
        Button {
            action()
        } label: {
            Text(buttonTitle)
                .padding()
                .background(backgroundColor)
                .foregroundColor(.white)
                .clipShape(Capsule())
        }
    }

    private var buttonTitle: String {
        if type == .increment {
            return type.rawValue
        } else {
            return type.rawValue
        }
    }

    private var backgroundColor: Color {
        if type == .increment {
            return .green
        } else {
            return .red
        }
    }

    private func action() {
        if type == .increment {
            number += 1
        } else {
            number -= 1
        }
    }
}

Oluşturduğumuz bu custom button sayesinde content view içerisinde daha temiz bir yapıya kavuşmuş olacağız. Nasıl mı?

struct ContentView: View {
    @State private var number: Int = 0

    var body: some View {
        VStack {
            Text("Number: \(number)")
                .font(.system(size: 30, weight: .semibold))

            HStack {
                CustomButton(number: $number, type: .increment)

                CustomButton(number: $number, type: .decrement)
            }
        }
    }
}

Butonun nasıl görüneceğine bir nevi kendi içerisinde karar vermiş olduk. Bu da aslında ulaşmak istediğimiz ilk noktaydı. Bu örnekte State ve Binding kullanımı gayet güzel anlaşılmıştır diye düşünüyorum. Ayrıca subview oluşturarak composition yapmaya da başladık. Fakat custom button'ın son bıraktığımız hali benim tam içime sinmediği için hemen aşağıda biraz daha temizleme işlemi yapıyor olacağım.

struct CustomButton: View {
    @Binding var number: Int
    var type: CustomButton.ButtonType

    var body: some View {
        Button {
            action()
        } label: {
            Text(buttonTitle)
                .padding()
                .background(backgroundColor)
                .foregroundColor(.white)
                .clipShape(Capsule())
        }
    }
}

Yukarıdaki kod bloğunda ulaşmak istediğim sonucu görebilirsiniz. Tekrar ediyorum bu işlemleri yapmanıza gerek yok, bu aşama sadece benim kod yazma düzenimin ve clean code anlayışımın ekstradan bir temsili olacak.

Ben ButtonType'ı, CustomButton adlı yapının bir extension'ı olarak görüyorum ve bu sebeple hemen aşağıda bir extension içerisine taşıyorum.

extension CustomButton {
    enum ButtonType: String {
        case increment = "Increase"
        case decrement = "Decrease"
    }
}

Daha sonra ise sıra CustomButton içerisinde ona hangi rengi ve hangi title'ı vereceğine karar veren iki adet property var ve butonun hangi aksiyonu almasına karar veren bir adet fonksiyon var. Bunları da private bir extension içerisine almak istiyorum. private extension CustomButton {

    var buttonTitle: String {
        if type == .increment {
            return type.rawValue
        } else {
            return type.rawValue
        }
    }

    var backgroundColor: Color {
        if type == .increment {
            return .green
        } else {
            return .red
        }
    }

    func action() {
        if type == .increment {
            number += 1
        } else {
            number -= 1
        }
    }
}

Bu extension'ları aynı dosyada tutmayı tercih ediyorum ama kesinlikle ulaşmak istediğim şey view ile logic'i mümkün olduğunca birbirinden ayrıştırmak. Burada action metodu belki doğrudan private bir fonksiyon olarak CustomButton içerisine yazılabilirdi fakat ben bu metodu da implementation detail olarak gördüğümden extension içerisine aldım. Son olarak CustomButton ve ilgili extension'lar ayrı bir dosyaya alınabilir. Burada isimlendirmeler daha iyi yapılabilirdi belki ama o konuda affınıza sığınıyorum. :)

Dikkat ettiyseniz, SwiftUI'ın view'ları aslında birer view controller gibi çalışıyor. Evrimsel olarak olaya bakmak istersek; UIViewController'a, UIView'dan daha yakın bir akraba olduğunu söyleyebiliriz. : )

Şu zamana kadar yaptığımız işlemlerin bir adım ötesi state'i ve onu değiştirme işlemini ayrı bir controller oluşturarak, yarattığımız view'ları tamamen veriden habersiz dummy bir hale getirmek olacak. Bu controller'ın global bir obje olmasını ve lazım olan bütün view'lardan ulaşılması gerekiyor. Bunun sebebi birden fazla gereksiz dependency injection yapmaktan kaçmak ve kodu okunabilirlikten uzaklaştırmamak.

ObservableObject ve Published

Her iOS Developer'ın da bildiği gibi eğer state tutmak istiyorsak bunu class'ları kullanarak yaparız. Fakat farklı bir alemde olduğumuzu unutmayalım. Burası SwiftUI evreni, burada oluşturacağınız class'larda state takibi yapacaksak ve o state'e göre view'ları render etmeyi planlıyorsak eğer, ObservableObject'i miras almalıyız. Bu nerden geldi derseniz, SwiftUI geliştiricilerinin struct ile oluşturulan view'larda bu problemi çözebilmek için SwiftU'a gömülü olarak Combine kullandığını belirtmek isterim. Bununla birlikte class içerisinde tutacağımız değişkenlerin takibini yapabilmek için, değişken tanımının hemen önünde Published adında yine Combine'a ait bir property wrapper da kullanmamız gerekecek.

Yeni bir swift dosyası oluşturalım ve adına StateController diyelim. Bu dosya içerisinde hemen aşağıdaki yapıyı tutuyor olacağız. Oluşturulan class'ın final olarak belirtilmesi tamamen kişisel tercih, siz öyle yapmak zorunda değilsiniz. Ayrıca number değişkeninin de private(set) değil de, default olarak internal olarak kullanabilirsiniz fakat ben genelde eğer yapabiliyorsam oluşabilecek muhtemel bug'ları önleyebilmek için erişimleri mümkün olduğunca kısıtlamaya çalıştığım için burada da bu access modifier'ı kullandım.

import Foundation

final class StateController: ObservableObject {

    @Published private(set) var number: Int = 0

    func increase() {
        number += 1
    }

    func decrease() {
        number -= 1
    }
}

StateObject, ObservedObject ve EnvironmentObject

State'lerin sadece primitive veri türlerini tutabildiğini söylemiştim. Eğer class'dan türetilmiş bir obje tutmaya çalışsanız da bu konuda başarılı olamayacaksınız. Bu soruna alternatif olarak StateObject geliştirildi. Zannediyorum yine 2. versiyonda gelen bir property wrapper bu. Yazdığımız controller'dan bir instance oluşturup bunu app içerisinde global olarak kullanılabilir bir hale getirmek istiyoruz. Bu çözüme hemen aşağıdaki kod bloğunu inceleyerek nasıl ulaşabileceğimizi görebilirsiniz.

import SwiftUI

@main
struct CounterApp: App {
    @StateObject var stateController = StateController()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(stateController)
        }
    }
}

Yukarıda da görüldüğü gibi Xcode projesinin yaratırken otomatik olarak oluşan CounterApp dosyasına (ya da siz projenizi nasıl adlandırdıysanız ona göre değişir) iki satır kod yazarak istediğimiz yeteneklere kavuşabiliyoruz. ContentView içerisinden de nasıl erişilebilineceğine hemen aşağıdaki kod bloğunu inceleyerek görebilirsiniz.

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var stateController: StateController

    var body: some View {
        VStack {
            Text("Number: \(stateController.number)")
                .font(.system(size: 30, weight: .semibold))

            HStack {
                CustomButton(type: .increment)

                CustomButton(type: .decrement)
            }
        }
    }
}

Yukarıdaki kod bloğundan da farkedeceğiniz gibi CustomButton'a artık state'i bind etmek zorunda değiliz. Onu global olarak tuttuğumuz için CustomButton'da bir parametre eksik daha temiz bir yapıya kavuşuyor.

import SwiftUI

struct CustomButton: View {
    @EnvironmentObject var stateController: StateController
    var type: CustomButton.ButtonType

    var body: some View {
        Button {
            action()
        } label: {
            Text(buttonTitle)
                .padding()
                .background(backgroundColor)
                .foregroundColor(.white)
                .clipShape(Capsule())
        }
    }
}

CustomButton içerisinden hemen yukarıdaki örnekte de görebileceğiniz gibi global state objesine yine ContentView içerisinde kullandığımız gibi EnvironmentObject adlı property wrapper'ı kullanarak ulaşabiliyoruz. Son olarak yapmamız gereken şey, private extension içerisinde tuttuğumuz buton basıldığında çalışan action adlı metodun işlevini değiştirmek.

func action() {
    if type == .increment {
        stateController.increase()
    } else {
        stateController.decrease()
    }
}

Bu yaptığımız son değişikliklerden sonra ise aslında MVC mimarisini oluşturmuş olduk diyebiliriz. UIKit ile alışageldiğimizden daha farklı bir yapıyla buraya ulaştık (mesela SwiftUI içerisine gömülü binding yeteneğinden faydalandık) ama temel olarak yapmak istediğimiz şeyi (veri ile view'ı birbirinden ayırmak) bize sağlıyor.

SwiftUI projelerinde MVC gibi yine MVVM'i çok kolay bir şekilde implemente edebiliriz fakat SwiftUI üzerinde MVVM kullanmak bazen kulağı ters elle gösteriyormuşum hissini uyandırabiliyor bende. Şimdiye kadar SwiftUI'ın nimetlerinden en çok faydalanmamızı sağlayan mimarinin MVC olduğuna karar veren biri olarak bol bol composition yapmayı, view'lar mümkün olduğunca küçük ve tekrar kullanılabilir olarak yazmayı öneriyorum.

Yazıyı buraya kadar okuduğunuz için teşekkür ediyor ve eğer daha fazla içerik (hatta Türkçe olarak) üretmemi istiyorsanız bana şurdan kahve ısmarlayabilirsiniz, yok ben kahve ısmarlamam ama bir çayını içerim diyorsanız şurdan buluşma düzenleyebilirsiniz. Yazdıklarımı faydalı buluyorsanız eğer yorum yazabilir, paylaşabilir ve bu sayede daha fazla kişiye ulaşmalarını sağlayabilirsiniz. Sevgiler 🤞