İçindekiler
- Problem
- Tasarım kararları ve alternatifler
- Sisteme yalnız bir protocol üzerinden ulaşmak
- 64 slot'un önünde bir kuyruk
- Çağrılar tek bir actor'da değil, sırayla çalışır
- Politika tetiklenme tarihine uygulanır
- Tipli hata ile doğrulama
- Uygulama ve sunucu için ayrı library'ler
- CI'ın bulduğu bir hata
- Kullanım
- Sınırlar
Problem
UserNotifications'ın bazı davranışları size haber vermeden başarısız olur. iOS bir uygulamanın yalnız en yakın zamanda tetiklenecek 64 bekleyen yerel bildirimini tutar, gerisini hata vermeden atar. Önümüzdeki üç ayın her günü için tek seferlik hatırlatıcı planlayan bir uygulama sisteme 64'ten fazlasını verir ve sonrakileri sessizce kaybeder. Bazı geçersiz değerler de hata fırlatmaz: 60 saniyeden kısa tekrarlayan bir UNTimeIntervalNotificationTrigger ya da sıfır, negatif veya NaN bir interval Objective-C exception'ı fırlatır; bu da bir catch'e ulaşmak yerine süreci sonlandırır. UNUserNotificationCenter delegate'i ise Swift 6 concurrency'ye ve unit test'lere uymayan bir callback kümesidir, çünkü gerçek center bir host uygulama ister.
iOS-Notification-Framework reposundaki Swift paketi NotificationKit, UserNotifications'ın üzerinde bu dört noktayı ele alan bir katman. 2.0.0 sürümü 15 Ekim 2026'da yayınlandı. İki library'si var: uygulamalar için NotificationKit (iOS 17, macOS 14, watchOS 10, visionOS 1) ve push gönderen sunucular ile komut satırı araçları için NotificationKitAPNs. İkisinin de Apple SDK'sı dışında bağımlılığı yok.
2.0'ın arkasında bir geçmiş var. Repo Ağustos 2025'te bir prototip olarak açıldı, 1.0.0 Şubat 2026'da yayınlandı, Haziran 2026'da küçük tipler eklendi. Yeniden yazımı başlatan commit 7aa405b 6.173 satırı siliyor ve nedenini yazıyor: her tip doğrudan UNUserNotificationCenter.current(), UserDefaults.standard ve Date()'e uzanıyordu, bu yüzden hiçbir şey unit test'te çalıştırılamıyordu; testler de yalnız builder'ların set ettiği özellikleri geri okuyordu. Bu yazının geri kalanı onun yerine gelen tasarımı anlatıyor.
Tasarım kararları ve alternatifler
Sisteme yalnız bir protocol üzerinden ulaşmak
NotificationCenterClient, paketin sistemden istediklerini listeler: izin, ayarlar, istek ekleme ve silme, kategoriler ve badge. UserNotificationCenterClient bunu UNUserNotificationCenter'a eşler; InMemoryNotificationCenter testlerde ve SwiftUI önizlemelerinde onun yerine geçer. In-memory center, planlamanın bağlı olduğu yerlerde sistemi taklit eder: yalnız en yakın 64 bekleyen bildirimi tutar, düşürdüklerini kaydeder, anlık bildirimleri hemen teslim eder ve aynı geçersiz değerleri reddeder.
Alternatif, Apple'ın bunun için tasarlamadığı UNUserNotificationCenter'ı doğrudan mock'lamak ya da planlamayı test etmeden bırakmaktı. Test double'ının bedeli, gerçek center'dan ayrışabilmesi. Repo bu açığı iki yoldan daraltıyor: SystemMappingTests değerlerin UNNotificationRequest'e nasıl dönüştüğünü kontrol ediyor, NotificationLab örnek uygulamasındaki üç UI testi de simülatörde gerçek center'a karşı çalışıyor.
64 slot'un önünde bir kuyruk
NotificationScheduler, planladığı her bildirimi kaydedilen bir durumda tutan bir actor. Diğer kodun eklediği bildirimlerin yanına sığan en yakın olanları sisteme verir, gerisini kuyruğa alır. refresh() teslim edilenleri unutur, kuyruktakileri boşalan slot'lara taşır ve daha erken bir bildirim slot'a ihtiyaç duyduğunda daha geç olanı kuyruğa geri alır.
Kararın kendisi iki saf tipte. SlotPlanner sistemin hangi bildirimleri tutacağına, FireDateResolver sessiz saatlere ve sıklık sınırına karar verir. İkisi de cihaz olmadan test ediliyor. Plan uygulanırken silmeler eklemelerden önce yapılır; böylece sistem hiçbir anda 64'ten fazlasını tutmaz ve kendi başına bir bildirim atmaz.
Durum bir NotificationStore'a yazılır. FileNotificationStore JSON'u Application Support'a atomik olarak yazar ve 8 MB'tan büyük dosyaları reddeder; InMemoryNotificationStore testler içindir.
Çağrılar tek bir actor'da değil, sırayla çalışır
Burada yalnız actor yetmiyor. Actor'lar her await'te reentrant'tır ve planlama center'ı birkaç kez bekler; iki eşzamanlı schedule çağrısı birbirinin arasına girip aynı slot'u iki kez verebilir. Private bir serialized yardımcısı her işlemi kendinden önce başlayanın arkasına zincirler. Bir test 100 bildirimi eşzamanlı planlayıp sonucu kontrol ediyor. Bedeli: yavaş bir çağrı bir sonrakini bekletir.
Politika tetiklenme tarihine uygulanır
Yerel bildirim tetiklendiğinde uygulamanın kodu çalışmaz; yani sessiz saat kararı teslim anında verilemez. FireDateResolver kararı planlama anında verir, ama şu anki saate değil bildirimin tetikleneceği tarihe bakar. 22:00–07:30 penceresinde, 21:00'de 23:00 için kurulan tek seferlik bir hatırlatıcı 07:30'a kayar. Tekrarlayan bildirimler kullanıcının seçtiği saati korur; time-sensitive ve critical bildirimler varsayılan olarak sessiz saatten geçer.
FrequencyCap, belirli uzunluktaki herhangi bir pencerede en fazla N bildirime izin verir ve hem öncekileri hem sonrakileri sayar. Cevap yalnız istenen tarih ya da mevcut bir bildirimin bir interval sonrası olabileceği için arama yalnız bu adaylara ikili arama ile bakar ve her zaman biter. Parametreli bir test sonucu 40 rastgele planda brute-force aramayla karşılaştırıyor.
Tipli hata ile doğrulama
LocalNotification.validate() ve NotificationTrigger.validate(), sistemin çöktüğü ya da yok saydığı değerler için NotificationValidationError fırlatır: kısa ya da sonlu olmayan interval'ler, aralık dışı tarih bileşenleri, geçmişteki tek seferlik tarihler, 0...1 dışındaki relevance score ve ses düzeyleri, geçersiz bölgeler. Özel veri [AnyHashable: Any] yerine bir JSONValue; böylece Sendable ve Codable oluyor ve kuyrukla birlikte kaydedilebiliyor. Dönüştürme 32 iç içe seviyede ve 10.000 değerde durur. İki center da aynı validate()'i çağırır; test double'ı sistemin reddedeceğini reddeder.
Uygulama ve sunucu için ayrı library'ler
NotificationKitAPNs APNs client'ını taşır: URLSession üzerinden HTTP/2, CryptoKit ile imzalanıp 50 dakika yeniden kullanılan ES256 provider token'ları, ExpiredProviderToken'da bir kez yeniden deneme. Ayrı bir library olması imzalama kodunu uygulamaların dışında tutuyor; PushPayload ve DeviceToken ise ortak, yani sunucu uygulamanın okuduğu tipin aynısını encode ediyor. Yalnız Apple framework'lerini kullanmak bir bağımlılık ağacından kaçınıyor; bedeli Linux desteği, README bunu yol haritasında listeliyor.
CI'ın bulduğu bir hata
Yeniden yazım PR #31 olarak açıldı ve ilk koşusu kırmızıydı. Hatalardan biri toolchain'ler hakkında iyi bir ders.
Notification Service Extension için temel sınıf olan MediaAttachmentService, medya indirmesini capture list'inde özgün bildirim içeriğini, downloader'ı ve teslim nesnesini tutan bir Task'ta başlatıyordu. Yerelde Swift 6.4 ile derleniyordu. CI runner'ında Swift 6.3 (Xcode 26.6) "pattern that the region-based isolation checker does not understand" hatasıyla durdu.
93efabc düzeltmesi girdileri private bir MediaJob değerine topluyor; task artık tek bir Sendable değer yakalıyor. Uygunluk @unchecked ve yorum bunun neden geçerli olduğunu yazıyor: UNNotificationContent yalnız okunuyor ve mutableCopy ile kopyalanıyor, hiç mutate edilmiyor. Davranış değişmedi: kilitli bir OneTimeDelivery, content handler'ı hâlâ tam bir kez çağırıyor; ya indirme bitince ya da sistem serviceExtensionTimeWillExpire'ı çağırdığında.
Ders README'ye yazıldı: CI, macos-26 runner'ının varsayılan Xcode'uyla derliyor, paket ise Xcode 27 ile geliştiriliyor. Yerelde geçmek, desteklenen en eski toolchain'de geçmek demek değil.
Aynı PR iki CI sorununu daha düzeltti. Örnek uygulamanın UI testleri "Timed out while launching application via Xcode" ile zaman aşımına uğruyordu; push testi kendini atlamadan önce uygulamayı başlatıp izin penceresine cevap veriyordu ve ilk açılış soğuk bir simülatörle yarışıyordu (90887f3). Link kontrolü de yalnız merge'den sonra var olacak 331 adreste başarısız oluyordu (5149dfb). Bunlar ancak yeni CI kırmızıya dönebildiği için görüldü: d715e25 eski CI'ın testleri continue-on-error ile koşturduğunu, yani hataların onu hiç kırmızıya çeviremediğini not ediyor.
Kullanım
README'deki hızlı başlangıç, yeni bir iOS uygulaması için eksiksiz bir ContentView.swift. Hem örnek uygulamanın hem swift test'in derlediği ReadmeSamples.swift dosyasından kopyalanmış:
swift
1import NotificationKit2import SwiftUI3 4let scheduler = NotificationScheduler() // one for the whole app5 6struct ContentView: View {7 var body: some View {8 Button("Remind me in 10 seconds") {9 Task {10 do {11 try await scheduler.requestAuthorization()12 try await scheduler.schedule(LocalNotification(13 id: "water",14 title: "Time for a glass of water",15 body: "You asked for this reminder 10 seconds ago.",16 trigger: .after(seconds: 10)17 ))18 } catch {19 print("Could not schedule the reminder:", error)20 }21 }22 }23 }24}Sessiz saatler ve sıklık sınırı scheduler'a verilen bir politika:
swift
1func makeScheduler() throws -> NotificationScheduler {2 NotificationScheduler(policy: SchedulingPolicy(3 // One-time notifications that would fire between 22:00 and 07:30 arrive at 07:30.4 quietHours: try QuietHours(from: "22:00", to: "07:30"),5 // At most three notifications in any hour; later ones move back.6 frequencyCap: try FrequencyCap(limit: 3, per: 3600)7 ))8}In-memory center sistemin sınırına sahip olduğu için 64 slot davranışı düz bir unit test'te kontrol edilebilir:
swift
1func scheduledCount() async throws -> Int {2 let center = InMemoryNotificationCenter(authorizationStatus: .authorized)3 let scheduler = NotificationScheduler(center: center, store: InMemoryNotificationStore())4 let reminders = (1...70).map { LocalNotification(id: "r\($0)", title: "Reminder \($0)", trigger: .after(minutes: Double($0))) }5 try await scheduler.schedule(reminders)6 return await center.pending.count // 64: the other 6 wait in the scheduler's queue7}v2.0.0 tag'inde swift test 109 Swift Testing testi koşturuyor: NotificationKit için 15 suite'te 95, NotificationKitAPNs için 4 suite'te 14.
Sınırlar
- Kuyruk yalnız uygulama çalışırken ilerler. Planlama üzerine DocC makalesi
refresh()'in açılışta ve uygulama ön plana döndüğünde çağrılmasını söylüyor. Kullanıcı uygulamayı açmazsa kuyruktakiler sisteme ulaşmaz; tarihi geçen bildirim düşürülür ve expired olarak raporlanır, ya daoverdueBehavior.deliverNowise geç teslim edilir. - Teslim bir çıkarım. Sistemin artık tutmadığı ve tarihi geçmiş tek seferlik bir bildirim teslim edilmiş sayılır.
- Politika değişikliği geriye işlemez.
updatePolicy(_:)dokümanı, planlanmış bildirimlerin tarihlerini koruduğunu yazıyor. - Olay akışları sınırlı.
NotificationEventHandlervarsayılan olarak akış başına okunmamış en yeni 32 olayı tutar; kimsenin okumadığı olaylar düşer. - Platformlar farklı. Konum trigger'ları yalnız iOS ve watchOS'ta çalışır; watchOS'ta ek dosya yok ve adlandırılmış sesler yerine varsayılan ses çalar.
NotificationKitAPNsCryptoKit'e ihtiyaç duyar ve Linux'ta derlenmez. - Testler ağda durur. APNs testleri Apple'ın sunucularına değil bir
APNsTransportstub'ına karşı koşar. UI testleri bir banner dokunuşunu, 64'ü aşan kuyruğu ve bir simülatör push'unu kapsar.
README'deki yol haritasında Swift Package Index kaydı, Live Activity'ler için broadcast push kanalları ve NotificationKitAPNs'in swift-crypto ile Linux derlemesi var.
Kaynak kod: github.com/muhittincamdali/iOS-Notification-Framework

