İçindekiler
Problem
Bir WidgetKit widget'ı küçük bir SwiftUI view'ı gibi görünür. Ama bu paketin 2.0.0 sürümünde düzeltilen hataların önemli bir kısmı view'ın düzeninden değil, çevresindeki veriden çıktı: ilerleme halkasını NaN'a çeviren sıfır değerli bir hedef, bitiş tarihi başlangıçtan önce olan bir geri sayım, 23 saat süren bir günde 86.400 saniye eklenerek hesaplanan "bir sonraki yenileme" ya da her timeline isteği için yalnızca birkaç saniye çalışan bir extension içinde bağlantı bekleyen bir network isteği.
WidgetDevelopmentKit, iOS uygulamalarına widget ekleyen geliştiriciler için bir Swift paketi. Home Screen, Lock Screen ve StandBy widget'ları için hazır SwiftUI view'ları, widget boyutuna göre ayarlanmış grafik view'ları, widget butonları için App Intents ve Live Activity'ler için ActivityKit attribute'ları ve view'ları içeriyor. Benim için asıl önemli kısım view'ların altında: timeline'lar, uygulamanın widget'la paylaştığı veri ve sunucudan veri yükleme için yardımcılar. Widget extension'ı yine siz yazıyorsunuz; paket size view'ları ve her widget'ın ihtiyaç duyduğu tarih, depolama ve retry mantığını veriyor.
2.0.0 sürümü 14 Ekim 2026'da yayımlandı. Paketin tamamının derlendiği ve testlerinin çalıştığı ilk sürüm bu. Bunun nedeni de bu yazının bir parçası.
Tasarım kararları ve alternatifler
Tek target, tek product, sıfır bağımlılık. Manifest, Swift 6 tools ve language mode, iOS 17 ve macOS 14 ile tek bir WidgetDevelopmentKit library target'ı tanımlıyor. Önceki v1.0.0 manifest'i birden fazla product ve kodun kullanmadığı bağımlılıklar tanımlıyordu, üstelik hiç yüklenmiyordu. Paketi Core ve Views gibi modüllere bölmek uygulamanın daha az kod link'lemesini sağlardı; ama bu boyutta bir paket için public API yüzeyini ve bakım yükünü de artırırdı. Widget extension'ın bellek sınırı dar olduğu için third-party kod çekmemek daha güçlü bir gerekçeydi.
İnce view'lar, saf aritmetik. SwiftUI body'lerini unit test etmek zor. Bu yüzden gerçek veriyle bozulan aritmetik, Core altında saf kod olarak duruyor: WidgetMath ve WidgetFormat enum'larındaki fonksiyonlar ile hesabı init içinde yapan MonthGrid ve CountdownComponents value type'ları. Bunlar simulator gerektirmeden macOS'ta swift test ile çalışıyor. Alternatif, view'ların snapshot testleriydi. Pakette henüz yok ve README bunu açıkça yazıyor.
Saniye değil, takvim aritmetiği. WidgetUpdatePolicy, timeline'ın ne zaman yenileneceğini tarif ediyor ve bunu WidgetKit'in TimelineReloadPolicy tipine çeviriyor. .nextHour durumu calendar.dateInterval(of: .hour, for:), .startOfNextDay ise calendar.date(byAdding: .day, …) kullanıyor; çünkü yaz saati geçiş günlerinde 3.600 ya da 86.400 saniye eklemek yanlış sonuç verir. WidgetMath.daysRemaining(until:from:calendar:) bir aralığı kesmek yerine iki günün başlangıcını karşılaştırıyor. Bir test bunu America/New_York saat diliminde 8 Mart 2026'daki ABD geçişiyle sabitliyor.
Bağımlılıklar protocol değil, closure. WidgetDataLoader bir Transport ve bir Sleep alıyor; ikisi de @Sendable async closure. WidgetSnapshotStore ise UserDefaults'unu ve bir saati dışarıdan alıyor. Tek metotluk bir bağımlılık için closure, protocol'den daha az kalıp kod istiyor; testler de ne URLProtocol stub'ına ne de gerçek beklemeye ihtiyaç duyuyor.
Yalnızca başarılı olabilecek isteği yeniden dene. WidgetNetworkError.isTransient, HTTP 408 ve 5xx ile timedOut, networkConnectionLost, notConnectedToInternet ve cannotConnectToHost için true dönüyor. 4xx yanıt, decode hatası ya da iptal edilen istek yeniden denenmiyor. Varsayılan RetryPolicy iki deneme yapıyor; başlangıç beklemesi 500 ms ve sonraki her denemede ikiye katlanıyor. URLSession.widgetDefault ise 10 ve 15 saniyelik timeout'ları olan, waitsForConnectivity = false ayarlı ephemeral bir session. Extension, bütçesini beklemeye harcamak yerine hızlı fail edip önbellekteki veriye düşmeli.
Process'ler arası state. Bir widget butonu AppIntent'ini uygulamada değil, extension process'inde çalıştırır. WidgetStateStore flag'leri, sayaçları ve bekleyen aksiyon kuyruğunu, Info.plist'teki WidgetDevelopmentKitAppGroup anahtarının gösterdiği App Group'ta tutuyor. App Intent'ler parametresiz bir init() istediği için store onlara inject edilemiyor; konfigürasyon noktası Info.plist anahtarı, testler için init(defaults:) public kalıyor. RecordActionIntent, son 50 aksiyonu tutan bir kuyruğa ekleme yapıyor ve uygulama bu kuyruğu drainPendingActions() ile okuyor. Kaldırılan ActionIntent ise extension içinde bir notification gönderiyordu; ayrı bir process olan uygulama bu bildirimi hiçbir zaman alamıyordu.
Sıraya konan Live Activity güncellemeleri. LiveActivityStateMachine bir actor ve transition(to:) typed throws kullanıyor: throws(StateMachineError<State>) reddedilen geçişin from ve to state'lerini bildiriyor. Güncellemeler push, background refresh ya da uygulama açılışından sırasız gelebilir. Bu yüzden makine .delivered durumundan .preparing durumuna geri dönmek gibi bir geçişi reddediyor. Bedeli, her çağrıda bir await.
Render sırasında Date() yok. Geri sayım view'ları timeline entry tarihi olan bir referenceDate alıyor; böylece her entry kendi anını çiziyor. StandBy geri sayımında artık saniye sütunu yok, çünkü statik bir snapshot her saniye güncellenemez.
Ne yanlış gitti, ne öğrendim
Repo Ağustos 2025'te açıldı. İlk commit'ler doküman, CI şablonu ve modülleri, derlenen koddan daha hızlı ekledi. v1.0.0 etiketindeki (Şubat 2026) manifest yüklenmiyor: test target'ı invalid custom path hatası veriyor. Haziran 2026 ortasındaki bir düzenleme library target'ını derlenir hâle getirdi; ama target'ta altı dosyada yalnızca yaklaşık 240 satır kaldı ve Quick/Nimble test target'ı hâlâ derlenmiyordu.
Yeniden kurulum 14 Ekim 2026'da PR #28 ("feat!: restore the widget components library and prepare 2.0.0") ile merge edildi. Hiçbir target'ın derlemediği yaklaşık 10.500 satır kaynak kod ve yaklaşık 6.000 satır Quick/Nimble testi silindi. Saklamaya değer view'lar geçmişten target'a geri alındı. Hiçbiri paketi başarıyla build etmeyen on bir eski workflow, tek bir build, test ve docs workflow'uyla değiştirildi (5a917d9). CHANGELOG, v1.0.0'ın derlenemediğini açıkça yazıyor; bu sürümün major olmasının bir nedeni de bu.
Kodu geçmişten geri almak onu yeniden okumak demekti ve bu, gerçek hatalar çıkardı. Bir örnek 4e54d54 commit'i. TimerActivityView, isPaused set edilmiş olsa bile Text(endTime, style: .timer) render ediyordu. Yani duraklatılmış bir timer, Lock Screen'de ve Dynamic Island'da geri saymaya devam ediyordu ve pausedTimeRemaining alanı hiç okunmuyordu. Düzeltme, kararı view'dan hesaplanan ContentState.pausedLabel özelliğine taşıdı: timer çalışırken nil, duraklatıldığında sabit bir m:ss etiketi. iOS'a özel üç test çalışan timer'ı, duraklatılmış timer'ı ve kalan süresi olmayan duraklatılmış timer'ı (0:00 gösteriyor) kapsıyor.
Network kodunun hikâyesi de benzerdi. Eski NetworkDataProvider, 404 gibi 4xx yanıtları da yeniden deniyordu (yalnız 429 hariçti) ve extension içinde bağlantı bekliyordu. 2bdc1f4 commit'i 473 satırını 137 satırlık bir loader ve 65 satırlık bir Endpoint builder ile değiştirdi. Testleri transport'u senaryoya bağlıyor: önce 503, sonra timeout, sonra 200 geldiğinde üç istek yapılmalı ve 100 ms ile 200 ms'lik beklemeler gerçekten beklenmeden kaydedilmeli; 404 geldiğinde tek istek yapılmalı ve hiç bekleme olmamalı.
Daha sessiz ders araçlarla ilgiliydi. .swiftlint.yml içinde tırnaksız bir */Generated/* satırı vardı ve YAML bunu alias olarak okuyordu. SwiftLint dosyayı okuyamadığı için hata vermeden varsayılan kurallarla çalışıyordu (56c762e). README örneklerinin API'den kopması da aynı riski taşıyordu. ReadmeSyncTests (9d52da6) artık README.md'deki her Swift bloğunun (Package.swift parçası hariç), satır satır, Examples/ altındaki bir dosyada ya da ReadmeExamplesTests.swift içinde bulunduğunu kontrol ediyor; ikisi de derleniyor. Bir kalite kontrolü, ancak bozulduğunda bir şey kırmızıya dönüyorsa işe yarar.
Kullanım
Paketi Swift Package Manager ile widget extension'a ekleyin:
swift
1dependencies: [2 .package(url: "https://github.com/muhittincamdali/iOS-Widget-Development-Kit.git", from: "2.0.0")3],4targets: [5 .target(name: "YourApp", dependencies: [6 .product(name: "WidgetDevelopmentKit", package: "iOS-Widget-Development-Kit")7 ])8]Timeline provider, uygulamanın App Group'a kaydettiği veriyi okuyor. TimelineSchedule entry tarihlerini üretiyor, TimelineBuilder entry'leri sıralayıp bir WidgetUpdatePolicy'yi reload politikasına çeviriyor; staleValue ise süresi dolmuş olsa bile önbellekteki değeri döndürüyor:
swift
1struct StepsProvider: TimelineProvider {2 let store = WidgetSnapshotStore(defaults: UserDefaults(suiteName: "group.com.example.widgets") ?? .standard)3 4 func placeholder(in context: Context) -> StepsEntry {5 StepsEntry(date: .now, steps: 6_000)6 }7 8 func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {9 completion(placeholder(in: context))10 }11 12 func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {13 completion(makeTimeline(now: .now))14 }15 16 func makeTimeline(now: Date) -> Timeline<StepsEntry> {17 // Written by the app; stale data is better than an empty widget.18 let steps = store.staleValue(Int.self, forKey: "steps") ?? 019 let entries = TimelineSchedule20 .dates(from: now, every: 15 * 60, count: 4)21 .map { StepsEntry(date: $0, steps: steps) }22 return TimelineBuilder(entries: entries)23 .reloading(.every(minutes: 60), from: now)24 .build()25 }26}Uygulama değeri bir ömürle yazıyor ve WidgetKit'ten yenileme istiyor:
swift
1try store.save(steps, forKey: "steps", lifetime: 30 * 60)2WidgetCenter.shared.reloadTimelines(ofKind: "steps")Widget butonu, paketteki intent'lerden birini extension içinde çalıştırıyor:
swift
1struct WaterButton: View {2 var body: some View {3 Button(intent: IncrementIntent(counterId: "water", amount: 250)) {4 Label("+250 ml", systemImage: "drop.fill")5 }6 }7}Bu bloklar CI'ın build ettiği Examples/WidgetKitExample ile test target'ından geliyor. Örnek uygulamada üç widget var: bir geri sayım, bir adım widget'ı ve interaktif bir su widget'ı.
Sınırlar
- SwiftUI view body'leri için snapshot ya da UI testi yok. Testler mantığı kapsıyor, view'ların görünüşünü değil. README görselleri WidgetKit dışında
ImageRendererile render ediliyor; bu yüzden widget çerçevesi, Lock Screen vibrancy'si ve StandBy gece modu yaklaşık. WidgetStateStore.incrementveenqueueAction,UserDefaultsüzerinde oku-değiştir-yaz yapıyor. Process'ler arasında atomik değiller; uygulama ve extension aynı anda yazarsa bir güncelleme kaybolabilir.- Paket yerelde Xcode 27 ile, CI'da ise GitHub'ın
macos-26image'ındaki varsayılan Xcode ile build edildi. Daha eski Xcode sürümleri denenmedi. - Cihaz üzerinde bellek ya da süre ölçümü yok. Network timeout'ları extension bütçesine göre seçildi ama profillenmedi.
- Örnek uygulamada Live Activity yok. Live Activity view'ları state testleri ve derleme ile doğrulanıyor.
- Lock Screen view'ları ve Live Activity'ler yalnızca iOS'ta; watchOS, tvOS ve visionOS desteklenmiyor.
README'deki yol haritasında view body'leri için snapshot testleri ve örnek bir Live Activity var. API referansı DocC yorumlarından üretiliyor; CHANGELOG ise 2.0.0'daki her değişikliği kaydediyor.

