Minik Swift İpuçları: @Observable ≠ ObservableObjectTurkish
Bu seride, Swift ile iOS ve MacOS uygulamaları geliştirirken karşılaşılan bazı problemleri kısaca paylaşmayı düşünüyorum. Bu yazı için seçtiğim konu: @Observable geçişinin (migration) sessiz ama tehlikeli bir sürprizi.
iOS 17+ · Swift 5.9+
Derlediğim kaynaklar:
- Jesse Squires — SwiftUI's Observable macro is not a drop-in replacement for ObservableObject
- Apple WWDC23 — Discover Observation in SwiftUI
- Donny Wals — Understanding how and when SwiftUI decides to redraw views
- SE-0395 — Observation Proposal
Swift 5.9 ile gelen @Observable macro'su, model katmanını büyük ölçüde sadeleştirdi. Apple WWDC23'te bunu ObservableObject'in modern halefi olarak tanıttı. Topluluk da öyle benimsedi: ObservableObject'i sil, @Observable ekle, @Published'ları kaldır, devam et. Ama bu *"bul-değiştir" *yaklaşımı, production'da sessiz ve tehlikeli bir tuzak barındırıyor.
Ne Değişti?
Değişikliği anlamak için önce iki property wrapper'ın nasıl çalıştığına bakalım.
ObservableObject kullanırken model'inizi @StateObject ile sararsınız. @StateObject'in init'i @autoclosure alır. Bu şu anlama gelir: nesne yalnızca *bir kez *oluşturulur ve SwiftUI view'ı yeniden oluştururken aynı nesneyi korur.
// Eski yöntem — @StateObject ile güvenli
class ProfileStore: ObservableObject {
@Published var username = ""
init() {
// ✓ Bu init YALNIZCA BİR KEZ çalışır
loadCachedProfile()
registerForNotifications()
}
}
struct ProfileView: View {
@StateObject var store = ProfileStore()
}
@Observable'a geçtiğinizde ise @State kullanmanız gerekir. İşte kritik fark burada: @State'in init'i @autoclosure değildir. SwiftUI view struct'ını her yeniden oluşturduğunda, @State'e verdiğiniz değerin init'i tekrar çalışır.
// Yeni yöntem — dikkat!
@Observable
class ProfileStore {
var username = ""
init() {
// ⚠️ Bu init HER VIEW REBUILD'DE tekrar çalışır
loadCachedProfile() // → Tekrarlanan disk okuması
registerForNotifications() // → Duplicate listener'lar
}
}
struct ProfileView: View {
@State var store = ProfileStore()
}
Peki SwiftUI bu tekrar oluşturulan nesneleri ne yapıyor? İlk oluşturulan nesneyi koruyup geri yüklüyor — yeni nesneleri atıyor. Ama init çoktan çalışmış oluyor. Yan etkiler gerçekleşmiş, kayıtlar yapılmış, dosyalar okunmuş olduğu için gereksizce mükerrer iş yapmış oluyor.
Problemin production ortamındaki etkileri
Jesse Squires bu sorunu production'da keşfetti. Modeli, uygulama kapanırken UserDefaults'a veri kaydediyordu ve NotificationCenter'a kayıtlıydı. Migration sonrası şu oldu:
- View her rebuild'de yeni bir
ProfileStoreinstance'ı oluşturdu - Her instance
NotificationCenter'a ayrı ayrı kayıt oldu - Uygulama kapanırken birden fazla instance aynı anda
UserDefaults'a yazdı - "Son yazan kazanır" senaryosu oluştu — veriler rastgele üzerine yazıldı (race condition).
Crash ve hata mesajı olmadığı için bozulmuş veriye bilinmeyen uygulama durumlarına yol açan bir can sıkıcı bir problem gerçekten.
Squires ayrıca Xcode'un memory graph debugger'ı ile bu fazladan nesnelerin bellekte *belirsiz süre kaldığını *doğruladı. Bazıları rastgele ve öngörülemez şekilde deallocate oluyordu — bu da SwiftUI'ın iç state yönetiminde bir sorun olabileceğine işaret ediyor.
Performans Farkı: Neden @Observable Yine de Daha İyi?
Apple'ın WWDC23 oturumunda Philippe Hausler, @Observable'ın büyük avantajını açıkladı: property-level tracking. ObservableObject'te @Published property'lerden herhangi biri değiştiğinde, o nesneyi gözlemleyen *tüm view'lar *yeniden değerlendirilir. @Observable ise yalnızca *okunan property değiştiğinde *ilgili view'ı günceller.
Donny Wals da bu farkı Instruments ile doğruladı: ObservableObject kullanıldığında bir listedeki tek bir öğe değiştiğinde ekrandaki tüm hücrelerin body'si yeniden çağrılıyordu. @Observable ile yalnızca değişen hücre güncellendi.
Yani @Observable'a geçmek doğru karar — ama *nasıl *geçildiği çok kritik.
Hızlı Çözüm: configure() yöntemi
@Observable olan yapıların içindeki init fonksiyonlarında eğer yan etki *(side effect) *barındırılıyorsa, bunları ayrıştırmak için yeni bir configure() metodu yazmak akla gelen ilk refleks olabilir:
@Observable
class ProfileStore {
var username = ""
private var isConfigured = false
init() {
// ✓ Yalnızca basit atamalar — yan etki yok
}
func configure() {
guard !isConfigured else { return }
loadCachedProfile()
registerForNotifications()
isConfigured = true
}
}
struct ProfileView: View {
@State var store = ProfileStore()
var body: some View {
ProfileContent(store: store)
.task { store.configure() }
}
}
Ancak bu yöntem büyük ölçekli uygulamalarda çeşitli problemlere yol açabilir.
Her modele isConfigured flag'i ve configure() metodu eklemek gerekir. 30 model varsa 30 yerde boilerplate kod gerektirir. Daha kötüsü ise *.task { store.configure() } *çağrısı unutulursa model yarı-başlatılmış kalır — ve bunu derleyici yakalayamaz. İki aşamalı init, nesnenin *"oluşturulmuş ama hazır değil" *durumunda kalmasına izin verir. Bu başlı başına bir anti-pattern'dir.
App Seviyesinde @State Kullanmak
Problemin çözümü için uygulama genelindeki modelleri App struct'ında tanımlamak akla gelebilecek bir diğer içgüdü olabilir. App struct'ı, View struct'ları gibi sürekli yeniden oluşturulmaz — dolayısıyla @State burada daha güvenlidir:
@main
struct MyApp: App {
@State var store = ProfileStore()
var body: some Scene {
WindowGroup {
ProfileView()
.environment(store)
}
}
}
Ancak büyüyen uygulamalarda bu yöntem ölçeklenemez ve App struct'ı bir *"god object" *haline gelir. Tüm bağımlılıklar uygulama açılışında yaratılır, lazy loading kaybolur, feature modülaritesi imkansızlaşır.
Gerçek Ölçeklenebilir Çözüm: init'i Saf Tutmak
Asıl mesele hangi kolay çözüm yönteminin *(workaround) *seçildiği değil — initin tasarımıdır. Sorunun kaynağı @State veya @Observable değil; bir constructor fonksiyonunun yan etki barındırmasıdır.
Constructor fonksiyonlarının görevi sadece property ataması yapmaktır. Yan etkiler — disk okuması, network, notification kaydı — init'in işi değildir ve bu işler için ayrı katmanlar var olabilir. Bu kural @Observable öncesinde de geçerliydi ancak @StateObject'in @autoclosure'ı bu tasarım hatasını bir nevi gizliyordu, @Observable ise bu problemi hepimiz için açığa çıkardı.
@Observable
class ProfileStore {
var username: String
private let defaults: UserDefaults
private let notificationCenter: NotificationCenter
// ✓ Saf init: yalnızca atama, yan etki yok
init(
defaults: UserDefaults = .standard,
notificationCenter: NotificationCenter = .default
) {
self.username = defaults.string(forKey: "username") ?? ""
self.defaults = defaults
self.notificationCenter = notificationCenter
}
}
Burada defaults.string(forKey:) senkron bir okuma olduğu için bir yan etki değildir. Bunun aksine NotificationCenter'a kayıt yapmak ise bir yan etkidir. Bunu view'ın yaşam döngüsüne bağlamak daha iyi bir yöntemdir:
struct ProfileView: View {
@State var store = ProfileStore()
var body: some View {
ProfileContent(store: store)
.onReceive(
NotificationCenter.default.publisher(for: .profileDidUpdate)
) { _ in
store.reload()
}
}
}
Bu sayede *Notification *kaydı artık view'ın sorumluluğundadır. View deallocate olduğunda (bellekten silindiğinde) kayıt da kaybolur. Model'in init'i saf kalır, configure() gereksizleşir, isConfigured flag'i ortadan kalkar.
Büyük Ölçekli Uygulamalar
Bağımlılıkların init üzerinden enjekte edilmesi (dependency injection) proje büyüdükçe zorunlu hale gelir. Bağımlılıkları dışarıdan vermek ve modelin kendi bağımlılığını yaratmasına izin vermemek daha kolay test altyapısı kurulmasını da sağlar:
// Test'te
let store = ProfileStore(
defaults: mockDefaults,
notificationCenter: mockCenter
)
@Observable migration'ı mekanik bir *bul-değiştir *yöntemi olmayabilir. init fonksiyonunda yan etki olan her modeli gözden geçirmek gerekebilir. @Observable doğru tercihtir — ama geçiş dikkat çok dikkat istiyor.
Bu yaklaşım hem test edilebilirlik hem de modülerlik sağlar. Point-Free'nin Dependencies kütüphanesi veya kendi DI container'ınız bu pattern'i sistematik hale getirir — ama temel prensip aynı: init saf olsun, bağımlılıklar dışarıdan gelsin.
Framework'ler değişir, API'lar deprecate olur, macro'lar gelir — ama SOLID prensipleri içinde bulunduğumuz yapay zeka çağında hala geçerliliğini koruyor. Ölçeklenebilir yazılım uygulamaları geliştirmek için buna dikkat etmemiz gerekiyor. Burada karşımıza çıkan sorunun temelinde de aslında bir *Single Responsibility *ihlali yatıyordu.
Ek Okuma
- Apple — Migrating from ObservableObject to Observable
- Point-Free — Observable Architecture
- Donny Wals — Understanding how and when SwiftUI decides to redraw views
Özetle, ölçeklenebilir, temiz kod barındıran yazılım altyapıları oluşturmak için çok uzağa bakmaya gerek yok. Özellikle yapay zeka agent'larına @Observable geçişini otomatik yaptırmak eğer doğru prompt yazılmazsa çok ciddi problemlere yol açabilir. Bu yüzden bir sorunu agentic engineering ile çözerken dahi, sorunu derinlemesine anladığımızdan emin olmamız gerekiyor.
Eğer bir problem bulgusu geldiğinde bu problemi çözmek için birden fazla hızlı çözüm (workaround) gerekiyorsa, bu durum kod altyapısında *(codebase) *yapısal kronik problemlere işaret ediyor olabilir.