Bir Moda Uygulamasında Firebase (Part 3): Bildirimler, Gözlem ve Dersler
Android’de hiç gelmeyen bildirimler, Crashlytics ile çökmeden önceki son adımlar ve canlıdan çıkan 20 hata, 20 çözüm
Bu yazı, “Bir Moda Uygulamasında Firebase” serisinin son bölümü. Part 1’de temelleri, Part 2’de Firebase Auth’u ve Firestore ile DM özelliğini anlattım. Bu bölümde bildirimlere, gözlemlenebilirliğe ve canlıdan çıkan derslere bakıyoruz.
8. Firebase Cloud Messaging: bildirimin cihaza ulaşması kolay, görünmesi zor
Neden? Takip istekleri, yorumlar ve DM’ler için bildirim gerekiyordu. FCM, Android ve iOS’a (APNs üzerinden) tek bir API ile bildirim göndermemizi sağlıyor. Bildirimleri backend gönderiyor; uygulamanın görevi token’ı backend’e iletmek ve gelen mesajı doğru göstermek.
Nasıl? Token yönetimi. Bir kullanıcının birden fazla cihazı olabilir. Bu yüzden token’ı kullanıcıya değil, (kullanıcı, cihaz ID'si, platform) üçlüsüne bağlıyoruz. Token giriş anında login isteğiyle birlikte gidiyor ve onTokenRefresh ile her yenilendiğinde tekrar gönderiliyor. Logout'ta backend o cihazın token'ını silmeli; aksi halde çıkış yapmış bir cihaz başkasının bildirimlerini almaya devam eder.
Nasıl? Üç durum, üç farklı davranış. FCM’de bir mesajın nasıl görüneceği, uygulamanın durumuna ve mesajın tipine bağlı:
Arka plan handler’ı bu tabloya göre yazılmış:
@pragma('vm:entry-point') // release build'de tree-shaking'e karşı
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
WidgetsFlutterBinding.ensureInitialized();
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp(); // ayrı bir isolate'tayız
}
// notification payload'u varsa OS zaten gösterdi; çift bildirim olmasın
if (message.notification == null) {
await presentAndroidNotification(message);
}
}Bu handler top-level bir fonksiyon olmalı, çünkü ayrı bir isolate’ta çalışır ve uygulamanızın belleğine erişemez. FirebaseMessaging.onBackgroundMessage bir kez ve runApp'ten önce kaydedilmeli. Android sadece ilk kayıtlı handler'ı tutar.
Bildirim başlığını okurken de iki kaynağı birden kontrol ediyoruz. Backend bazen notification, bazen sadece datagönderebiliyor:
String? notificationTitleOf(RemoteMessage message) {
final title = message.notification?.title;
if (title != null && title.isNotEmpty) return title;
return _stringData(message, const ['title', 'notification_title']);
}Hata-çözüm: Android’de hiç bildirim gelmiyordu. Bu, projenin en öğretici hatasıydı; çünkü tek bir neden değil, üç ayrı neden aynı belirtiyi veriyordu:
1. İzin, Activity olmadan istendi. Android 13+ POST_NOTIFICATIONS iznini çalışma zamanında ister. Biz bunu runApp'ten önce, AppStart içinde istiyorduk. O an bir Activity olmadığı için istek hata fırlattı. Hata try/catch içinde olduğu için, aynı bloğun geri kalanı (token alma, onMessage dinleyicisi) da sessizce atlandı. Çözüm: izni ilk frame'den sonra istemek.
WidgetsBinding.instance.addPostFrameCallback((_) {
PushNotificationService.instance.requestPermissions();
});2. Yanlış bildirim ikonu. Manifest’te varsayılan bildirim ikonu olarak adaptive launcher ikonu (@mipmap/ic_launcher) verilmişti. Android bu durumda bildirimi göstermeyebilir ya da beyaz bir kare gösterir. Bildirim ikonu şeffaf arka planlı, tek renkli bir drawable olmalı (@drawable/ic_stat_notification).
3. Yanlış Firebase uygulaması. Dart tarafındaki firebase_options.dart dosyasındaki Android appId, prod paket adına kayıtlı Firebase Android uygulaması değil, başka bir uygulamayı gösteriyordu. Token üretiliyor, backend gönderiyor, ama mesaj cihaza hiç ulaşmıyordu. Part 1'deki "tek doğruluk kaynağı" uyarısının nedeni bu.
Bu düzeltmeyle birlikte manifest’teki elle yazılmış FlutterFirebaseMessagingService tanımlarını da kaldırdık; bu servisleri firebase_messaging eklentisi zaten kendi manifest'iyle ekliyor.
iOS tarafı için iki not:
- iOS’ta
getToken(), APNs token'ı gelmeden çağrılırsaapns-token-not-sethatası verir. Uygulama açılır açılmaz token istemek yerinegetAPNSToken()'ın dolmasını bekleyin ya daonTokenRefresh'e güvenin. setForegroundNotificationPresentationOptions()parametresiz çağrıldığında tüm seçeneklerfalseolur. Yani uygulama açıkken iOS bildirim göstermez. Bu bizim bilinçli tercihimiz (DM ekranı açıkken bildirim istemiyoruz), ama çoğu ekip bunu fark etmeden yapıyor.
Test etmek için: Firebase konsolundaki “Test mesajı gönder” ekranı sadece notification payload'u gönderir. Data-only senaryoları test etmek için FCM HTTP v1 API'sini doğrudan kullanın. Uygulamayı ayrı ayrı ön planda, arka planda ve tamamen kapalıyken deneyin.
9. Analytics: olayları tek bir kapıdan geçirmek
Neden? Bir moda uygulamasında asıl soru “kullanıcılar hangi kombinlere bakıyor ve hangi ürün linkine tıklıyor?” sorusu. Firebase Analytics ücretsiz, BigQuery’ye dışa aktarılabiliyor ve Remote Config ile A/B Testing’in hedefleme altyapısını oluşturuyor.
Nasıl? Uygulamanın hiçbir yerinde FirebaseAnalytics.instance doğrudan çağrılmıyor. Tüm olaylar AnalyticsManager adlı tek bir sınıftan geçiyor:
await AnalyticsManager.logEvent(
AnalyticsConstants.outfitViewed,
parameters: {'outfit_id': outfit.id},
);
await AnalyticsManager.logScreenView(AnalyticsConstants.screenHome);
await AnalyticsManager.setUserProperties(userId: user.id);Bu yaklaşımın üç faydası var:
- Tutarlı isimlendirme. Olay adları
AnalyticsConstantsiçinde sabit. Birioutfit_view, diğerioutfitViewedyazamaz. Analytics'te bir olay adını sonradan değiştirmek, geçmiş veriyle bağlantıyı koparır. - Tek açma/kapama noktası.
setAnalyticsEnabled(false)ile kullanıcı onayı (GDPR/KVKK) tek satırda yönetilebiliyor. - Sağlayıcı bağımsızlığı. Yarın PostHog ya da başka bir araç eklemek istersek değişecek tek dosya bu.
Dikkat edilecek noktalar:
- Ekran görüntülemeleri için
logScreenViewkullanın. Projede eski bir servis, ekranlar içinpage_viewadında özel bir olay gönderiyordu. Mobilde Analytics zatenscreen_viewolayını tanır ve raporlarını bunun üzerine kurar. Özel bir olay bu raporlarda görünmez ve aynı bilgiyi iki farklı isimle saymaya başlarsınız. - Logout’ta kimliği temizleyin.
clearUserProperties()setUserId(null)çağırır. Bunu yapmazsanız aynı cihazda giriş yapan ikinci kullanıcının olayları ilk kullanıcıya yazılır. - Kişisel veri göndermeyin. E-posta veya tam ad gibi bilgiler olay parametresi ya da user property olarak gönderilmemeli; bu Google Analytics kullanım koşullarına aykırıdır. Backend’in sayısal kullanıcı ID’si yeterli.
- DebugView kullanın. Geliştirme sırasında olayları anında görmek için Android’de
adb shell setprop debug.firebase.analytics.app <paket_adı>, iOS'ta-FIRDebugEnabledargümanını kullanın. Aksi halde olaylar konsola saatler sonra düşer.
10. Crashlytics: çökmeden önce ne oldu?
Neden? Bir çökme raporundaki stack trace size hatanın nerede olduğunu söyler, ama neden olduğunu çoğu zaman söylemez. Bu yüzden Crashlytics’i yalnızca çökmeleri toplayan bir araç olarak değil, çökmeden önceki son adımları kaydeden bir kara kutu olarak kurguladık.
Nasıl? Yine tek bir kapı. Analytics’te olduğu gibi uygulamanın hiçbir yerinde FirebaseCrashlytics.instance doğrudan çağrılmıyor. Her şey AppLogger adlı tek bir sınıftan geçiyor:
class AppLogger {
static void logEvent(String message) {
FirebaseCrashlytics.instance.log(message);
}
static void recordError(
Object exception,
StackTrace stack, {
String? reason,
bool fatal = false,
Map<String, Object>? customKeys,
}) {
customKeys?.forEach(FirebaseCrashlytics.instance.setCustomKey);
FirebaseCrashlytics.instance.recordError(
exception,
stack,
reason: reason,
fatal: fatal,
);
}
static Future<void> setUserIdentifier(String userId) =>
FirebaseCrashlytics.instance.setUserIdentifier(userId);
}Bu sınıfla dört şey yapıyoruz:
- Log satırları.
log()ile yazılan mesajlar bir sonraki çökme ya da non-fatal hata raporuna eklenir. Ekran geçişlerini (Navigation: home → outfit_detail) ve önemli kullanıcı aksiyonlarını buraya yazıyoruz. Rapor açıldığında kullanıcının son adımları orada duruyor. - Custom key’ler. Uygulama sürümü, build numarası, platform, işletim sistemi sürümü ve dil gibi bilgileri açılışta bir kez key olarak yazıyoruz. “Bu sadece Android’de mi, sadece eski sürümde mi?” sorusunu konsolda bu key’lere göre filtreleyerek cevaplıyoruz.
- Non-fatal hatalar. Uygulamayı çökertmeyen ama yanlış giden her şeyi (
catchbloğuna düşen bir istek, beklenmeyen bir yanıt)recordError(..., fatal: false)ile gönderiyoruz.reasonparametresi konsolda hatanın başlığı gibi çalışıyor. - Kullanıcı kimliği.
setUserIdentifierile backend'in sayısal kullanıcı ID'sini veriyoruz. "Uygulama bende çöküyor" diyen bir kullanıcının raporlarını bu ID ile bulabiliyoruz. E-posta ya da isim göndermiyoruz; Analytics'teki kural burada da geçerli.
Örnek: çökmeyen ama takılan splash ekranı. Bazı kullanıcılar splash ekranında takılı kalıyordu ama uygulama çökmüyordu. Çökme olmayınca rapor da olmuyordu. Çözüm, açılış akışının her adımını hem log satırı hem de custom key olarak yazmaktı:
void _trackSplashStep(String step, Duration elapsed, {Exception? error}) {
AppLogger.logEvent('Splash: $step');
FirebaseCrashlytics.instance
..setCustomKey('last_splash_step', step)
..setCustomKey('splash_duration_ms', elapsed.inMilliseconds);
if (error != null) {
AppLogger.recordError(
error,
StackTrace.current,
reason: 'Splash failed at $step',
customKeys: {'flow_step': step},
);
}
}Splash zaman aşımına uğradığında bu fonksiyon bir hatayla çağrılıyor ve rapor non-fatal olarak gidiyor. Konsolda raporları last_splash_step değerine göre gruplayınca takılmanın hangi adımda yoğunlaştığı görünüyor. Çökmeyen bir sorunu, çökme aracıyla görünür hale getirmiş oluyoruz.
Get Kadriye Macit’s stories in your inbox
Join Medium for free to get updates from this writer.
Karşılaştığımız tuzak: hata yakalayıcıların birbirini ezmesi. FlutterError.onError ve PlatformDispatcher.instance.onError tek bir değer tutar; en son atanan kazanır. Bizde bu ikisi hem Crashlytics'in başlatıcı sınıfında hem de main.dart'ta atanıyordu. Kod çalışıyor gibi görünüyordu, ama başlatma sırası değiştiğinde hatalar sessizce kaybolabilirdi. Daha da kritik olanı: başlatıcıdaki PlatformDispatcher.onError hatayı hiçbir yere raporlamadan true dönüyordu; yani o sırada oluşan hataları yutuyordu. Kural: bu iki yakalayıcıyı tek bir yerde atayın.
Flutter için önerilen kurulum şu:
FlutterError.onError =
FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};Dikkat edilecek noktalar:
- Debug’da toplamayı kapatın.
setCrashlyticsCollectionEnabled(!kDebugMode)kullanın; aksi halde geliştirme sırasındaki hatalar prod panosunu doldurur. - Sembolleri yükleyin. Android’de
com.google.firebase.crashlyticsGradle eklentisi mapping dosyalarını yükler; iOS'ta dSYM yüklemesini CI'a ekleyin. Flutter tarafında--obfuscate --split-debug-infokullanıyorsanız Dart sembollerinifirebase crashlytics:symbols:uploadile ayrıca yüklemeniz gerekir. Yoksa stack trace'ler okunamaz. - Web’i ayrı düşünün. Crashlytics web’i desteklemiyor. Tek kapı burada da işe yarıyor:
kIsWebkontrolünüAppLoggeriçinde bir kez yapmak yeterli.
11. Sahadan hata-çözüm özeti
Seri boyunca anlattığım hataların hepsi bu tabloda. Bir sorunla karşılaştığınızda belirtiden geriye doğru arayabilirsiniz.
12. Kapanış: yayına almadan önce kontrol listesi
Bu seriden tek bir cümle aklınızda kalacaksa şu olsun: Firebase’i kendi backend’inizle birlikte kullanmak çok güçlü bir model, ama iki dünyanın kimliği ve konfigürasyonu tek bir doğruluk kaynağına bağlanmadıkça hatalar sessizce birikir. Bizim yaşadığımız hataların neredeyse hepsi ya “iki kopya” ya da “sessizce yutulan bir catch" kaynaklıydı.
Kendi Flutter + Firebase projenizi yayına almadan önce şu listeyi kontrol edin:
Kurulum
Firebase.initializeApptek bir yerde çağrılıyor; kritik olmayan işler ilk frame'den sonra.- Her ortamın ayrı Firebase projesi var ve config dosyaları
flutterfire configureile üretildi. - App Check açık; debug provider sadece
kDebugMode'da, iOS'ta App Attest, web'de reCAPTCHA Enterprise. - Enforcement açılmadan önce doğrulanmış istek oranı izlendi.
Veri ve güvenlik
- Firestore’a erişen her kullanıcının bir Firebase oturumu var (gerekirse custom token ile).
- Security Rules
if trueiçermiyor ve Emulator üzerinde test ediliyor. - Kurallar, henüz var olmayan belgeleri (
resource == null) ayrıca ele alıyor. listkuralları yalnızca sorgunun kanıtlayabileceği koşulları içeriyor; kurallar filtre değildir.- Retry edilen yazmalar idempotent; birden fazla yazma
WriteBatchya da transaction içinde. - Dinleyicilerde
limitvar; snapshot verisi tekrarget()ile okunmuyor. - Güvenilmesi gereken sayaçlar (örneğin beğeni) sunucuda güncelleniyor.
Kimlik
- Apple: nonce doğru yönde, isim ilk girişte kaydediliyor.
- Google: her flavor için doğru Web client ID ve iOS URL şeması.
- Logout’ta Firebase Auth, Google Sign-In, Analytics kullanıcı ID’si ve FCM token kaydı temizleniyor.
Bildirimler ve gözlemlenebilirlik
- Bildirim izni ilk frame’den sonra isteniyor; Android ikonu tek renkli.
- Arka plan handler’ı top-level,
@pragma('vm:entry-point')ile işaretli ve bir kez kaydediliyor. - Ön plan, arka plan ve kapalı durumları hem
notificationhemdatamesajlarıyla test edildi. - Remote Config’te kısa
fetchTimeout, kodda varsayılanlar ve JSON için güvenli okuma var. - Zorunlu güncelleme,
minimum_app_versionyükseltilerek gerçek bir cihazda test edildi. - Hata yakalayıcılar tek bir yerde atanıyor; hiçbir
catchhatayı raporlamadan yutmuyor.
Bu yazının arkasındaki uygulama: Apparelte
Bu serideki bütün örnekler Apparelte’nin kodundan geliyor. Apparelte, kombinlerin alışveriş linkleriyle birlikte paylaşıldığı bir sosyal moda uygulaması. Beğendiğin bir kombini gördüğünde parçaların nereden alınabileceğini de hemen görüyorsun.
👉 apparelte.com üzerinden göz atabilir, uygulamayı indirip kendi kombinlerini paylaşabilirsin.
Sorularını ya da kendi Firebase hikâyelerini X’te bekliyorum: @kadriyemct
Buraya kadar okuduğun için teşekkürler. Umarım bir sonraki Firebase projeni canlıya alırken işine yarar. 🙌









