Запустите flutterfire configure, чтобы зарегистрировать приложения и сгенерировать firebase_options.dart, добавьте firebase_core и по плагину на каждый продукт, а в main() до runApp дожидайтесь только Firebase.initializeApp().
Что пишет CLI: lib/firebase_options.dart с отдельными FirebaseOptions для каждой платформы, плюс нативные google-services.json и GoogleService-Info.plist, которые читают пуши, Crashlytics и вход через Google
**Порядок в main()**: первым идёт WidgetsFlutterBinding.ensureInitialized(), потому что initializeApp общается с нативным кодом через platform channel
Один пакет на продукт: firebase_auth, cloud_firestore, firebase_messaging; после добавления того, что требует нативной настройки (например, Crashlytics), перезапустите flutterfire configure
Короткий старт: FirebaseFirestore.instance и остальные точки входа ленивые, поэтому больше ничего ждать не нужно; загрузка профиля с await до runApp без сети подвешивает приложение на пустом экране
FlutterMiddle
Firebase и бэкенд-сервисы
FlutterFire, Auth, Firestore, FCM, Remote Config, Crashlytics, App Check, Supabase
Закрыто вопросов
0 / 24
- 0Повторить
- 2Часто спрашивают
Вопросы
get() читает один раз и возвращает Future; snapshots() возвращает Stream, который выдаёт значение сразу и потом при каждом изменении, пока вы не отмените подписку. Для записи служат add, set, update и delete на ссылке на коллекцию или документ.
**get()**: разовое чтение для редко меняющихся данных или pull-to-refresh; идёт на сервер, а без сети берёт кеш, GetOptions(source: Source.cache) читает только кеш
**snapshots()**: realtime-слушатель: первое событие часто приходит из кеша, дальше идут изменения; живёт, пока его не отменят (StreamBuilder делает это при dispose), и за каждое изменение списывает чтения
**add и set**: add создаёт документ с автоматическим id; doc(id).set(data) перезаписывает документ целиком, а SetOptions(merge: true) сливает поля
**update**: меняет только переданные поля и падает с not-found, если документа нет
**delete: удаляет документ, но не его подколлекции
Типизация**: withConverter один раз маппит данные в модель вместо приведений doc['field'] по всему коду
Firestore хранит документы в коллекциях, умеет составные запросы по индексам и берёт плату за каждую операцию с документом; Realtime Database представляет собой одно JSON-дерево с очень низкой задержкой и простыми запросами, оплата идёт за хранение и трафик. Для новых приложений по умолчанию берут Firestore.
Модель данных: в Firestore документы до 1 MiB лежат в коллекциях и подколлекциях; в Realtime Database чтение узла скачивает всё, что под ним, поэтому дерево держат плоским
Запросы: Firestore фильтрует и сортирует по нескольким полям через индексы; Realtime Database сортирует или фильтрует по одному ключу на запрос, поэтому дерево строят под каждое чтение
Офлайн: обе кешируют на мобильных; в Firestore кеш включён по умолчанию, в Realtime Database нужен setPersistenceEnabled(true)
Стоимость: Firestore берёт деньги за каждое чтение, запись и удаление документа, а Realtime Database берёт за гигабайты хранения и скачивания, поэтому множество мелких обновлений там дёшевы
Когда Realtime Database: онлайн-присутствие через onDisconnect(), индикатор «печатает», живые курсоры или состояние игры: маленькие значения, которые меняются много раз в секунду
Пакет google_sign_in получает Google ID-токен, GoogleAuthProvider.credential оборачивает его, а signInWithCredential обменивает на пользователя Firebase. Поломка только в сторовой сборке почти всегда означает, что не добавлен SHA-отпечаток ключа подписи Play.
google_sign_in 7.x: один раз вызовите GoogleSignIn.instance.initialize(), потом authenticate(); из аккаунта Firebase нужен только authentication.idToken. Закрытое окно выбора бросает GoogleSignInException с кодом canceled. На Android отсутствующий SHA-отпечаток или неверный client id тоже приходит как canceled сразу после выбора аккаунта, поэтому логируйте e.description и проверяйте сборку, установленную из Play (internal testing)
Отпечатки на Android: Play переподписывает бандл своим ключом, поэтому добавьте SHA-1 и SHA-256 debug-ключа, upload-ключа и ключа Play App Signing в Android-приложение в Firebase и скачайте свежий google-services.json
iOS: добавьте REVERSED_CLIENT_ID из GoogleService-Info.plist как URL scheme в Info.plist, иначе флоу не сможет вернуться в приложение
Выходите из обоих: GoogleSignIn.instance.signOut() и FirebaseAuth.instance.signOut(), иначе плагин сохраняет прежнюю Google-сессию
Apple: правило App Store 4.8 ожидает рядом с Google столь же приватный способ входа
authStateChanges() отдаёт первый User? только после того, как SDK восстановит сохранённую сессию с диска. Первый кадр отрисовался раньше, чем появился ответ, и вы приняли null за «не залогинен», отсюда экран логина.
Лечится третьим состоянием: неизвестно, не залогинен, залогинен. Пока стрим ещё в ожидании, держите сплэш и никуда не переходите.
Три стрима: authStateChanges() срабатывает на вход и выход; idTokenChanges() ещё и на каждое обновление токена; userChanges() ещё и на правки профиля вроде updateDisplayName
Частая ошибка: читать FirebaseAuth.instance.currentUser сразу после initializeApp(): пока идёт восстановление, там ещё может быть null
Персистентность: на мобильных сессия сохраняется по умолчанию и переживает перезапуск; завершает её только signOut() или отозванный refresh-токен
flutterfire configure создаёт три конфига, а не один: lib/firebase_options.dart плюс нативные google-services.json (Android) и GoogleService-Info.plist (iOS). Вы подменили только Dart-файл, и нативные SDK по-прежнему смотрят в прод.
Dart и натив: Firebase.initializeApp(options: ...) решает, что видит Dart; выбирайте опции по appFlavor, который задаёт --flavor. FCM, Crashlytics и вход через Google стартуют нативно и читают plist и JSON
Android: Gradle предпочитает android/app/src/<flavor>/google-services.json файлу из android/app/, поэтому достаточно положить по файлу в папку каждого флейвора
iOS: папок под флейворы нет: --ios-build-config=<configuration> добавляет build phase, которая копирует plist, записанный через --ios-out, в бандл для этой build configuration Xcode, поэтому запускайте команду по разу на каждую конфигурацию (Debug-staging, Release-staging, ...)
Отдельные проекты: по проекту Firebase на окружение, а не несколько приложений в одном проекте, чтобы данные, правила и квоты не смешивались
Быстрая проверка: выводите Firebase.app().options.projectId в лог при каждом не-релизном запуске
signInWithCredential залогинил пользователя в другой аккаунт. У анонимной сессии был свой uid, вход через Google создал новый, и все документы, привязанные к старому uid, остались без хозяина.
Нужно было вызвать currentUser!.linkWithCredential(googleCredential): uid не меняется, провайдер добавляется, isAnonymous становится false, и все документы, правила и пути в Storage продолжают работать.
Если привязка не удалась: когда у этого Google-аккаунта уже есть пользователь Firebase, приходит credential-already-in-use, и автоматического слияния нет: возьмите ID-токен гостя, войдите через e.credential и поручите Cloud Function проверить этот токен и перенести данные
Анонимные аккаунты недолговечны: новое устройство, очистка данных или переустановка на Android дают новый uid без пути назад, а проекты на Identity Platform могут автоматически удалять их через 30 дней
UX: предлагайте войти, пока ценность на экране («Войдите, чтобы сохранить 12 игр»), а не стеной на старте
В Firestore нет джойнов, поэтому экрану, который должен отрисоваться за один поход в сеть, нужен документ под этот экран: скопируйте три свежих комментария прямо в документ поста.
Источником правды оставьте posts/{postId}/comments/{commentId}, а в посте держите commentCount и массив recentComments из трёх объектов {id, authorName, text}, который обновляет триггер Cloud Function или транзакция при каждой записи комментария.
Почему не запрос на каждый пост: 30 постов плюс запрос комментариев к каждому дают 31 поход в сеть и до 120 оплачиваемых чтений на каждую загрузку ленты
Копии устаревают: если пользователь сменил имя, все скопированные authorName тоже надо обновить; триггер на users/{uid} разносит изменение, либо вы миритесь со старыми именами в старых постах
Встраивайте только ограниченное: документ не больше 1 MiB, и каждое чтение скачивает его целиком, поэтому список, который растёт без предела, живёт в подколлекции
Запросы поверх родителей: «все комментарии этого пользователя» получаются через collectionGroup('comments').where('authorId', isEqualTo: uid), так что подколлекции вас не запирают (нужен индекс области collection group по authorId)
Практическое правило: сначала перечислите экраны, потом спроектируйте по документу или запросу на каждый экран
Вы видите два события снапшота на одну запись: сначала локальное, с hasPendingWrites true и ещё пустым createdAt, потом серверное, с настоящим временем. Строка прыгает, когда ваша собственная сортировка трактует null не так, как итоговое значение.
Почему прыгает: FieldValue.serverTimestamp() в локальном событии читается как null. Firestore orderBy("createdAt") и так ставит ожидающую строку после всех настоящих, так что прыжок значит, что код пересортировывает в Dart (null превращается в epoch или DateTime.now()) либо за это время пришло сообщение с более ранним серверным временем
Как отличить: doc.metadata.hasPendingWrites помечает локальную запись; snapshots(includeMetadataChanges: true) присылает событие и тогда, когда поменялся только этот флаг, так что можно сменить часики на галочку
Чините сортировку: если сортируете в Dart, пишите клиентский sentAt рядом с серверным createdAt и сортируйте по нему, пока createdAt равен null, или показывайте ожидающие строки в фиксированном слоте «отправляется» внизу
Офлайн: snap.metadata.isFromCache говорит, что список может быть устаревшим; это повод для маленькой плашки «офлайн», а не для спиннера
Нет. Future от set() завершается только после подтверждения сервера, а локальный кеш применил запись сразу; настоящий баг здесь в самом await записи в UI.
Firestore устроен как локальная база, которая синхронизируется, а не как удалённая база с кешем. Стройте UI поверх кеша, а сеть пусть подтверждает позже.
Офлайн-персистентность: по умолчанию включена на Android и iOS: кеш документов (по умолчанию 100 МБ, Settings.cacheSizeBytes) плюс очередь записей, которая переживает перезапуск и проигрывается по порядку
Как надо: отправьте запись, пусть UI ведёт слушатель, а отказ обработайте в error-колбэке Future; waitForPendingWrites() оставьте для редкого «всё ли сохранено?»
Нужна сеть: runTransaction и чтения с Source.server без сети падают, в очередь они не встают
Правила: запись, нарушающая правила, локально принимается и отклоняется только на сервере, поэтому строка может исчезнуть через несколько минут
Это гонка read-modify-write: оба клиента читают stock: 1, оба проходят проверку, оба пишут 0, и на один товар получается два заказа. Чтение, проверка и обе записи должны выполняться в одном runTransaction.
Как работает клиентская транзакция: tx.get запоминает прочитанную версию; при коммите сервер отклоняет транзакцию, если какой-то из этих документов изменился, и SDK перезапускает вашу функцию, по умолчанию до пяти попыток (maxAttempts)
Тело без побочных эффектов: оно может выполниться несколько раз, поэтому никаких setState, HTTP-запросов и побочных эффектов; чтобы прервать, бросьте исключение, и ничего не запишется
Сначала чтения: Firestore требует, чтобы все tx.get шли до первого tx.set или tx.update
Офлайн: транзакции нужен сервер, без сети она падает; WriteBatch тоже атомарен, но без чтений, и офлайн встаёт в очередь как обычная запись
Когда это действительно важно: платежи и складские остатки проводите в Cloud Function и запретите в правилах запись stock с клиента, потому что модифицированный клиент может просто обойти вашу транзакцию
Запросу нужен составной индекс по userId, status и createdAt. Однопольные индексы Firestore строит сам, но запрос, которому нужен составной, отклоняет, а эмулятор индексы вообще не проверяет, поэтому он вас и не предупредил.
Прочитайте ошибку: в тексте исключения есть ссылка на консоль, которая создаёт ровно этот индекс; он строится несколько минут, и до этого запрос продолжает падать
Исправьте навсегда: храните индексы в firestore.indexes.json, деплойте их через firebase deploy --only firestore:indexes, а firebase firestore:indexes > firestore.indexes.json выгружает те, что кто-то накликал в консоли
Отсутствующие поля: документ без createdAt молча выпадает из любого запроса с сортировкой по этому полю
Покажите ошибку: дайте слушателю onError, иначе отсутствующий индекс выглядит просто как пустой список
Правила выполняются на серверах Google для каждого запроса и решают по request.auth, сохранённому документу (resource) и входящему (request.resource). Клиент ничего не гарантирует: SDK ходит в Firestore напрямую, а ваш конфиг лежит внутри бинарника.
Спрятанная кнопка остаётся вопросом UX: любой, у кого есть значения из firebase_options.dart, может вызвать API со своим токеном; на пути у него только правила и серверный код
**resource и request.resource**: владельца для update и delete проверяйте по сохранённым данным, а новые данные (типы, размеры, ownerId == request.auth.uid, поля, которые нельзя менять) валидируйте на create и update
Правила не работают как фильтры: запрос проверяется целиком: collection('notes').snapshots() отклоняется с permission-denied, пока в нём нет .where('ownerId', isEqualTo: uid), доказывающего, что каждый результат разрешён
Как показать ошибку: она приходит как FirebaseException с кодом permission-denied; дайте каждому слушателю onError и отменяйте слушатели до signOut(), иначе все они разом упадут, когда request.auth станет null
Тестируйте: правила являются кодом, поэтому покройте разрешённый и запрещённый случай на эмуляторе
Сообщение с блоком notification рисует операционная система, и она намеренно не рисует его, пока приложение на переднем плане; оно приходит в FirebaseMessaging.onMessage как данные, и показать его должны вы сами.
Foreground: onMessage срабатывает, на экране ничего нет; рисуйте через flutter_local_notifications или на iOS попросите системный баннер через setForegroundNotificationPresentationOptions
Background: уведомление показывает системная шторка, а при тапе срабатывает onMessageOpenedApp
Terminated: getInitialMessage() один раз возвращает сообщение, которое запустило приложение; если его пропустить, тап на холодном старте откроет главный экран
Фоновый обработчик: функция верхнего уровня с @pragma('vm:entry-point'), иначе релизная сборка вырежет её tree shaking'ом; она работает в отдельном изоляте, поэтому сама вызывает Firebase.initializeApp() и не видит состояния приложения
Data-only сообщения: нигде ничего не рисуют и только будят обработчик; iOS их троттлит, поэтому не полагайтесь на них для того, что пользователь обязан увидеть
Настройка: requestPermission() (Android 13+ и iOS спрашивают пользователя), отправляйте getToken() и каждый onTokenRefresh на бэкенд, а для iOS загрузите в консоль Firebase APNs auth key
Подписка на onMessage никогда не отменяется. Каждое открытие входящих добавляет ещё одного слушателя, который переживает свой экран, поэтому один пуш запускает все обработчики: по баннеру на каждое открытие, плюс setState() called after dispose() от уже мёртвых стейтов.
Почему копится: FirebaseMessaging.onMessage является broadcast-стримом на всё приложение; listen возвращает StreamSubscription, и если её выбросить, cancel() не вызовет никто
Утечка: замыкание каждого обработчика держит свой State, поэтому каждый закрытый экран входящих остаётся в памяти до конца жизни приложения
Исправление: сохранить подписку в поле и отменить её в dispose(); то же касается onMessageOpenedApp, onTokenRefresh и ручного snapshots().listen
Где это должно жить: обработка пушей в foreground относится к уровню приложения: один слушатель, настроенный при старте, обновляет общее состояние, а экраны только читают его
Как поймать: линт cancel_subscriptions ловит сохранённую подписку, которую никто не отменяет (но не выброшенный результат listen); остальное поймает виджет-тест, который дважды открывает экран через pumpWidget, отправляет одно сообщение и ожидает один баннер
Подключение плагина ловит только нативные краши. Ошибки Dart попадают в Crashlytics, только если вы сами передаёте их через FlutterError.onError и PlatformDispatcher.instance.onError.
Ошибки фреймворка: FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError покрывает build, layout, paint и колбэки жестов
Всё остальное: PlatformDispatcher.instance.onError ловит ошибки из Future, на которые не поставили await, и ошибки, брошенные вне фреймворка; изолятам, которые вы запускаете сами, нужен свой addErrorListener
Читаемые отчёты: setCustomKey и log() для контекста, recordError(e, s, fatal: false) для обработанных сбоев, setUserIdentifier с непрозрачным id, никогда не email
Почему всё ещё пусто: отчёты отправляются при следующем запуске, а не в момент краша, а сбор в debug обычно выключен через setCrashlyticsCollectionEnabled(!kDebugMode); именно эту сборку вы и проверяли
Обфусцированные сборки: стектрейсы нечитаемы, пока вы не загрузите символы из --split-debug-info командой firebase crashlytics:symbols:upload
Логируйте события, которые отвечают на продуктовые вопросы (шаги воронки, использование функций, покупки), с несколькими типизированными параметрами, явно отслеживайте экраны и никогда не отправляйте персональные данные: ни email, ни имён, ни телефонов, ни свободного текста.
События: logEvent(name: 'checkout_started', parameters: {...}); имя может содержать до 40 символов из букв, цифр и подчёркиваний, префиксы firebase_, google_ и ga_ зарезервированы. Предпочитайте рекомендованные события (logPurchase, logLogin), чтобы их понимали стандартные отчёты
Лимиты: до 25 параметров на событие и 500 разных имён событий на приложение; опечатка навсегда создаёт отдельное событие, поэтому держите имена константами в одном файле
Экраны: автоматический трекинг видит одну Flutter-активити или один view controller, поэтому добавьте FirebaseAnalyticsObserver в навигатор или вызывайте logScreenView сами
Пользователи: setUserId с непрозрачным id и setUserProperty для сегментов вроде тарифа; никогда не email
Согласие: держите setAnalyticsCollectionEnabled(false), пока пользователь не согласится, если этого требует закон, и передавайте его выбор через setConsent
Как увидеть: отчёты отстают до суток; пользуйтесь DebugView через adb shell setprop debug.firebase.analytics.app <package> или аргумент запуска -FIRDebugEnabled на iOS
Никогда не ждите сетевой fetch до первого кадра. Задайте defaults, сделайте activate() того, что скачал прошлый запуск, отрисуйте экран и дайте новым значениям приехать в фоне.
Локально и мгновенно: setDefaults({...}) видит каждый пользователь, пока ни один fetch не удался, поэтому у каждого читаемого ключа должен быть default; activate() не ходит в сеть, его можно ждать на старте
Сеть: вызывайте fetchAndActivate() без await после runApp и уменьшите fetchTimeout с его 60 секунд по умолчанию
Почему новое значение «ничего не делает»: minimumFetchInterval по умолчанию 12 часов, и fetch внутри этого окна возвращает закешированные значения; Duration.zero ставьте только в debug
Обновления в реальном времени: onConfigUpdated сообщает, что появились новые значения; применяются они только после вашего activate()
Частая ошибка: читать getBool внутри build(): activate() посреди сессии перещёлкнет экран прямо под пользователем. Читайте значения один раз в своё состояние на границе, например при открытии экрана
Callable пробрасывает клиенту только HttpsError (код, сообщение и details). Любое другое исключение схлопывается в internal без сообщения (намеренно, чтобы stack trace не утёк публичному клиенту).
Чините на сервере: бросайте HttpsError('failed-precondition', 'Card expired') для всего, что пользователь должен прочитать; настоящие баги пусть остаются internal, ищите их в логах функции
На клиенте: приходит FirebaseFunctionsException с кодами в стиле gRPC (unauthenticated, permission-denied, unavailable, internal); маппите его в свои типы ошибок в слое данных, чтобы тип вендора не доходил до UI
Почему только в проде: настоящий сбой вызван тем, чего нет в эмуляторе: отсутствующим секретом или переменной окружения, IAM-ролью, другим регионом. Откройте логи функции в Cloud Logging, там stack trace
Callable и HTTP: httpsCallable сам прикладывает токены Auth и App Check; onRequest подходит для вебхуков, где всё проверяете вы сами
Регион: клиент по умолчанию ходит в us-central1; функции, задеплоенной в другой регион, нужен FirebaseFunctions.instanceFor(region: 'europe-west1')
Повторы: callable не повторяются автоматически, поэтому со всем, что списывает деньги, передавайте ключ идемпотентности
Firestore тарифицирует чтение по документам, поэтому ищите код, который читает намного больше документов, чем показывает экран: запросы без ограничения, слушатель на каждую строку списка, фильтрацию или подсчёт в Dart и слушатели, которые утекают или пересоздаются.
Подключение слушателя стоит одно чтение на каждый документ результата, а дальше по одному на каждый изменившийся документ. Долгоживущий ограниченный слушатель дешёв; каждый паттерн ниже ломает одно из этих двух слов.
**Нет limit()**: snapshots() на всю коллекцию платит за каждый документ и растёт вместе с данными, а не с пользователями
Слушатель на строку: 50 видимых строк, у каждой из которых свой db.doc('users/...').snapshots(), дают 50 лишних слушателей; скопируйте authorName прямо в документ строки
Фильтрация и подсчёт в Dart: платите вы за то, что запрос нашёл, а не за то, что оставили; фильтруйте через where, считайте через query.count().get(), который тарифицируется за каждую пачку до 1000 записей индекса, а не за каждый документ
**Стримы, созданные в build()**: StreamBuilder(stream: query.snapshots()), написанный инлайн, переподписывается на каждом ребилде. С включённым кешем (мобильные) быстрое переподключение оплачивает только изменённые документы, но в вебе (там кеш по умолчанию выключен) или после разрыва дольше 30 минут оно тарифицируется как совершенно новый запрос, а экран мигает состоянием загрузки. Создавайте стрим один раз на экран
Утечки: слушатели, оставшиеся висеть на экранах, с которых пользователь ушёл; используйте autoDispose или настоящий dispose()
Вне приложения: триггер Cloud Function, который пишет в документ, на который сам подписан, крутится бесконечно; поставьте бюджетный алерт и загляните в дашборд использования, прежде чем читать код
asyncExpand не переходит к следующему событию авторизации, пока не завершится внутренний стрим, а стрим snapshots() не завершается никогда. После выхода и входа под другим аккаунтом экран продолжает показывать заказы первого пользователя от слушателя, который всё ещё подключён.
Почему залипает: asyncExpand склеивает стримы последовательно: слушает внутренний стрим одного события и копит следующие внешние события, пока тот не закончится. Слушатель Firestore заканчивается только на cancel() или ошибке, поэтому null и новый User ждут в буфере вечно
При строгих правилах: после выхода старый слушатель обычно падает с permission-denied, поэтому экран может сначала мигнуть ошибкой; не рассчитывайте на это, чтобы разблокировать конвейер
Исправление: switchMap из rxdart отменяет предыдущую внутреннюю подписку на каждом событии авторизации, поэтому слушатель всегда один и привязан к текущему uid; в Riverpod то же даёт stream-провайдер, который делает ref.watch провайдера авторизации
Как поймать: юнит-тест с фейковым StreamController авторизации: выдать пользователя A, затем null, затем пользователя B и ожидать заказы B и вызов onCancel у слушателя A
App Check подтверждает, что запрос пришёл из вашего настоящего, неизменённого приложения на настоящем устройстве. Он отвечает на «это моё приложение?», но никогда на «можно ли этому пользователю?»; за это по-прежнему отвечают security rules.
Как работает: FirebaseAppCheck.instance.activate(...) вызывают до любого другого продукта; провайдер (Play Integrity на Android, App Attest на iOS) выдаёт вердикт, который обменивается на короткоживущий токен, и его автоматически несёт каждый вызов Firestore, Storage и Functions
Раскатка: сначала выпустите SDK и следите за метриками в консоли, пока почти весь трафик не станет подтверждённым; если включить enforcement раньше, все старые установленные версии перестанут работать без возможности это исправить
Debug-сборки: симуляторы и CI не проходят аттестацию: используйте debug-провайдеры, зарегистрируйте выведенный в лог debug-токен в консоли и никогда не выпускайте этот провайдер в релиз
Повторное использование: обычный токен можно переиспользовать до истечения срока; для чувствительных callable включите на функции потребление токена и вызывайте её с HttpsCallableOptions(limitedUseAppCheckToken: true); на собственном бэкенде отправляйте getLimitedUseToken()
Чего он не делает: он ничего не авторизует: залогиненный пользователь по-прежнему читает всё, что разрешают правила, а пропатченный рантайм на рутованном устройстве всё ещё может слать запросы
Три слоя: интерфейс репозитория, чтобы widget-тесты вообще не видели Firebase, Emulator Suite для кода, который реально ходит в Firestore, и JavaScript-библиотека для тестов самих правил.
Widget- и unit-тесты: Firebase.initializeApp() требует platform channels, поэтому widget-тест, который до него дошёл, падает. Держите DocumentSnapshot, Timestamp и Query за репозиторием и подставляйте фейк; fake_cloud_firestore служит заменой в памяти, правила он проверяет лишь частично (только операции с одним документом), поэтому настоящие правила тестируйте в эмуляторе
Emulator Suite: firebase emulators:start --only auth,firestore,functions, затем useFirestoreEmulator и useAuthEmulator сразу после initializeApp. На Android-эмуляторе плагины сами превращают localhost в 10.0.2.2; реальному устройству нужен адрес вашей машины в локальной сети
Никогда не трогать прод: используйте id проекта, начинающийся с demo-: эмуляторы работают полностью офлайн, а продукт, который вы забыли направить в эмулятор, упадёт, вместо того чтобы достучаться до настоящего проекта
В CI: firebase emulators:exec одной командой поднимает эмуляторы, наполняет данными, гоняет тесты и всё гасит
Правила: Dart-обвязки для них нет; тестируйте через @firebase/rules-unit-testing и проверяйте и разрешённый, и запрещённый случай
Firestore подходит для realtime и offline-first приложений с чтениями «по экрану»; Supabase выбирают, когда данные реляционные и нужен SQL; свой бэкенд нужен, когда решают размещение данных, готовая серверная команда или нестандартная логика. И не пускайте SDK вендора в виджеты: это и есть весь путь отступления.
Firestore: realtime-обновления и офлайн-кеш, который SDK ведёт за вас: почти вся механика offline-first приложения бесплатно. Тарифицирует каждое чтение документа, джойнов нет, отчётность строят в другом месте (обычно через экспорт в BigQuery)
Supabase: Postgres: джойны, представления, транзакции, SQL-отчёты и политики row-level security вместо правил. Оплата по тарифу и размеру инстанса, а не за чтения, и встроенного офлайн-кеша нет, так что синхронизацию вы пишете сами
Свой бэкенд: когда решают требования к размещению данных, уже существующая серверная команда или логика, которую не выразить правилами; тогда auth, пуши, хранилище и эксплуатация ложатся на вас
Что на самом деле привязывает: клиентский SDK, просочившийся наверх: DocumentSnapshot в виджете, Timestamp в модели. Правила и триггеры не переносятся; аккаунты пользователей экспортируются относительно легко
Практическое правило: по интерфейсу репозитория на агрегат и доменные модели без типов вендора; в этом разница между миграцией и переписыванием