Введение
Firebase - это мощная платформа для разработки мобильных приложений, но стандартная документация описывает только базовый сценарий: одно приложение = один Firebase проект. В реальных проектах часто возникают более сложные требования:
Подключение к Firebase проекту, где ваше приложение не зарегистрировано
Переключение между средами: Development и Production
Миграция данных между разными Firebase проектами
Использование разных проектов для разных функциональных модулей
В этой статье мы глубоко погрузимся в технические детали работы с Firebase и рассмотрим:
Как и почему можно использовать Firebase проект без регистрации Bundle ID
Традиционные способы переключения Firebase конфигураций (compile-time)
Динамическое переключение проектов во время работы приложения (runtime)
Практический пример: миграция пользовательских данных между приложениями
Безопасность и важные моменты
Заключение
В процессе разработки мобильных приложений иногда возникает необходимость мигрировать/синхронизировать наши данные между разными версиями приложения или даже между разными приложениями. В нашем случае стояла задача перенести данные из старого приложения в новое, используя Firebase Firestore как промежуточное хранилище.
Когда у вас возникает такая потребность переноса данных через Firebase, вы просто создаёте один проект в Firebase и подключаете к нему два разных приложения. Теперь два разных приложения используют один проект и могут писать и читать в одну базу данных. Тогда у вас никаких проблем, всё понятно и можете сразу переходить к разделу №2.
Также часто одно приложение может использовать несколько Firebase проектов, чтобы, например, разделить собираемую аналитику и крашлитику для debug-сборок и prod-сборок.
У нас отчасти так и было: старое приложение использовало два Firebase проекта. Давайте старое приложение будем называть OldApp, а новое - NewApp. Кэп.
Так вот OldApp использовало два Firebase:
OldAppFB - проект для продакшен-сборок
SandboxFB - проект для дебаг-сборок.
NewApp тоже использовало два Firebase:
NewAppFB - проект для продакшен-сборок
SandboxFB - проект для дебаг-сборок.
Как видите, у нас два разных приложения уже было подключено к одному проекту. Однако это было дебаг-пространство. Необходимо было OldApp подключить дополнительно к третьему проекту - это к продакшен-проекту нового приложения NewAppFB, чтобы мы могли записывать данные в БД нового проекта.
Тут мы столкнулись с первой сложностью. По разным причинам Firebase может не позволить подключить приложение к дополнительному проекту. Или это может быть просто нецелесообразно ;) Плюс нам нужно было менять Firebase проект динамически в runtime, поэтому традиционные "статические" Compile-Time способы разделения сред и проектов нам не подходили.
Итак: главная сложность заключалась в том, что нам нужно было:
Использовать Firebase проект, в котором не зарегистрирован Bundle ID нашего приложения
Динамически переключаться между разными Firebase проектами во время работы приложения
Давайте углубимся в детали.
1. Можно ли использовать Firebase проект без регистрации в нём нашего Bundle ID?
Зачем нам это нужно?
Когда мы разрабатываем систему миграции данных между приложениями, возникает ключевой вопрос: можем ли мы подключиться к Firebase проекту, в котором наше приложение официально не зарегистрировано? На первый взгляд это кажется невозможным - Firebase ведь должен знать, какое приложение к нему обращается.
Однако, понимая внутреннее устройство Firebase SDK и механизмы аутентификации запросов, мы обнаружим, что для определённых сервисов (в первую очередь Firestore) Bundle ID вашего приложения не играет никакой роли. Это открывает возможности для реализации миграции данных без необходимости регистрировать каждое приложение в каждом Firebase проекте.
Для подключения нашего iOS приложения к Firebase проекту, используется GoogleService-Info.plist. Что такое этот GoogleService-Info.plist? Это конфигурационный файл, содержащий ключи для подключения к Firebase проекту. Давайте разберем, что на самом деле содержится в конфигурационном файле:
<!-- Упрощенная структура GoogleService-Info.plist -->
<plist>
<dict>
<!-- Ключ для доступа к Google APIs -->
<key>API_KEY</key>
<string>AIzaSyC_XXXXXXXXXXXXXXXXXXXXXXXXXX</string>
<!-- Идентификатор проекта в Google Cloud -->
<key>PROJECT_ID</key>
<string>my-firebase-project</string>
<!-- Уникальный ID приложения в Firebase -->
<key>GOOGLE_APP_ID</key>
<string>1:123456789012:ios:abc123def456</string>
<!-- Bundle ID приложения, зарегистрированного в Console -->
<key>BUNDLE_ID</key>
<string>com.company.registered.app</string>
<!-- OAuth client ID для Google Sign-In -->
<key>CLIENT_ID</key>
<string>123456789012-abcdefg.apps.googleusercontent.com</string>
<!-- URL базы данных Firestore/Realtime Database -->
<key>DATABASE_URL</key>
<string>https://my-firebase-project.firebaseio.com</string>
<!-- URL Cloud Storage -->
<key>STORAGE_BUCKET</key>
<string>my-firebase-project.appspot.com</string>
</dict>
</plist>Почему важно понимать структуру plist?
Каждое поле в GoogleService-Info.plist играет свою роль в процессе подключения к Firebase. Когда вы понимаете, какое поле за что отвечает, вы можете осознанно принимать решения о том, какие сервисы будут работать при использовании "чужого" конфигурационного файла.
Например, API_KEY и PROJECT_ID - это главные идентификаторы для построения URL запросов к серверам Firebase. А BUNDLE_ID - это лишь метаданные, которые проверяются только определёнными сервисами.
Нас интересуют в первую очередь PROJECT_ID, GOOGLE_APP_ID, BUNDLE_ID
PROJECT_ID - Идентификатор проекта
Что это:
Уникальное имя вашего Firebase проекта в глобальном пространстве Google Cloud
Используется для построения URL и endpoint'ов
Примеры использования:
// Firestore URL
"https://firestore.googleapis.com/v1/projects/{PROJECT_ID}/databases/(default)"
// Realtime Database URL
"https://{PROJECT_ID}-default-rtdb.firebaseio.com/"
// Storage URL
"https://firebasestorage.googleapis.com/v0/b/{PROJECT_ID}.appspot.com"Особенности:
Создается при создании Firebase проекта
Нельзя изменить после создания
Должен быть глобально уникальным
Один PROJECT_ID на весь проект
GOOGLE_APP_ID - Идентификатор приложения внутри проекта Firebase
Что это:
Уникальный идентификатор конкретного приложения (iOS, Android, Web) внутри конкретного Firebase проекта
Связывает аналитику, крэш-репорты и другие данные с конкретным приложением
Формат:
1:123456789012:ios:abc123def456
│ │ │ │
│ │ │ └── Хэш приложения (уникальный для платформы)
│ │ └── Платформа (ios, android, web)
│ └── Номер проекта Google Cloud
└── Версия формата (обычно 1)
Особенности:
Создается при регистрации приложения в Firebase проекте
Разный для каждого подключенного приложения (iOS, Android и Web) к одному проекту
Один проект может иметь несколько GOOGLE_APP_ID
Что это даёт на практике?
GOOGLE_APP_ID - это связующее звено между вашим приложением и Firebase проектом. Он генерируется при регистрации приложения в Firebase Console и является уникальным для каждой комбинации "проект + платформа + приложение".
Важно понимать: один Firebase проект может иметь несколько GOOGLE_APP_ID - по одному для каждого зарегистрированного приложения (iOS, Android, Web). При этом все они имеют доступ к одним и тем же данным в Firestore, одним и тем же пользователям в Authentication и т.д.
BUNDLE_ID - Идентификатор приложения (Bundle Identifier)
Что это:
идентификатор пакета - это уникальный строковый идентификатор, который однозначно определяет ваше приложение в экосистеме Apple (App Store, iOS, macOS).
это строка в формате reverse DNS notation (обратная DNS нотация), которая обычно выглядит так: com.yourcompany.yourapp
Какие поля действительно важны? Давайте разберем, что проверяет каждый Firebase сервис. Разберём прежде всего больше всего интересующий нас запрос в Firestore.
Как работает Firebase SDK
Инициализация Firebase
// При вызове FirebaseApp.configure() SDK делает:
// 1. Читает GoogleService-Info.plist
let options = FirebaseOptions(contentsOfFile: plistPath)
// 2. Создаёт HTTP клиент для Google API
let apiKey = options.apiKey
let projectId = options.projectId
// 3. Настраивает endpoints
let firestoreEndpoint = "https://firestore.googleapis.com/v1/projects/\(projectId)/databases/(default)"
let authEndpoint = "https://identitytoolkit.googleapis.com/v1"
// 4. ВСЁ! Bundle ID НЕ используется для подключенияЧто происходит "под капотом"?
Когда вы вызываете FirebaseApp.configure(), SDK не устанавливает постоянное соединение с сервером и не проходит "рукопожатие". Вместо этого он просто сохраняет параметры из plist в память и готовит HTTP-клиент для будущих запросов.
Каждый последующий вызов к Firestore, Authentication или другому сервису - это отдельный HTTPS запрос к REST API Google. И именно содержимое этих запросов определяет, будет ли операция успешной.
Когда вы делаете запрос к Firestore:
let db = Firestore.firestore()
db.collection("users").document("user1").getDocument()Под капотом происходит слдущее:
POST https://firestore.googleapis.com/v1/projects/{PROJECT_ID}/databases/(default)/documents:commit
Headers:
X-Goog-Api-Key: AIzaSyC_XXXXXXXXXXXXXXX
X-Firebase-GMPID: 1:123456789012:ios:abc123def456
Authorization: Bearer ya29.xxxxxxxx
Body: {...}Проверяет:
- API_KEY - для аутентификации запросов к Google API
- PROJECT_ID - определяет базу данных
- GOOGLE_APP_ID - идентификатор приложения в проекте
НЕ проверяет:
- BUNDLE_ID - игнорируется!
Ключевой момент: Firebase SDK отправляет GOOGLE_APP_ID в заголовке X-Firebase-GMPID, но сервер Firestore использует его только для аналитики и логирования, а не для авторизации доступа. Доступ к данным контролируется через Security Rules и Authentication токены.
Вывод: Firestore работает с любым Bundle ID, если есть валидный GOOGLE_APP_ID из проекта.
Даже при использовании "чужого" Bundle ID, безопасность обеспечивается через Security Rules:
// Firestore Security Rules
rules_version = '1';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}Это означает:
Приложение A (Bundle ID:
com.company.oldapp) может писать данныеПриложение B (Bundle ID:
com.company.newapp) может читать те же данныеБезопасность обеспечивается через Authentication, а не Bundle ID. Для этого вам достаточно включить анонимную аутентификацию в Firebase
Когда Bundle ID критичен?
Однако, есть сервисы, где Bundle ID важен и сервис без его совпадения просто не будет работать, или будет работать частично, или с ограничениями. Давайте посмотрим, где это важно:
Регистрации APNs токена (Push-уведомления)
// iOS отправляет APNs token на Firebase // Firebase проверяет: "Этот Bundle ID зарегистрирован в проекте?" Messaging.messaging().apnsToken = deviceTokenOAuth провайдеры (Google Sign In, Apple Sign In)
// При авторизации через Google/Apple проверяется: // "Этот Bundle ID разрешен для OAuth?" GIDSignIn.sharedInstance.signIn(with: config)Apple Sign In - требует точное совпадение Bundle ID
Google Sign In - требует точное совпадение Bundle ID
однако Email/Password, Anonymous Auth работают с любым Bundle ID
Dynamic Links
// При создании deep link проверяется Bundle ID DynamicLinks.dynamicLinks().handleUniversalLink(url)
В итоге мы получаем такую картину допустимости несовпадения Bundle ID приложения и зарегистрированного Bundle ID в конфигурационном файле и работоспособности сервисов Firebase:
Сервис Firebase | Работает с чужим Bundle ID | Ограничения | Альтернативы |
|---|---|---|---|
Firestore | Полностью | Нет | - |
Realtime Database | Полностью | Нет | - |
Authentication | Частично | Google/Apple Sign-In не работают | Custom Auth, Email/Password |
Cloud Storage | Полностью | Нет | - |
Remote Config | Полностью | Нет | - |
Crashlytics | Частично | Отчеты привязываются к зарегистрированному app | Использовать собственный сбор ошибок |
Analytics | Частично | Данные идут в зарегистрированное app | Google Analytics API |
Cloud Messaging | Не работает | Требует точный Bundle ID + APNs | Собственные уведомления |
Dynamic Links | Не работает | Проверяет Bundle ID | Universal Links |
App Check | Не работает | Привязан к Bundle ID | Кастомная аутентификация |
Почему одни сервисы требуют Bundle ID, а другие - нет?
Разница кроется в архитектуре каждого сервиса:
Firestore, Realtime Database, Cloud Storage - это "чистые" data-сервисы. Они проверяют только авторизацию пользователя через токен и соответствие Security Rules. Им всё равно, откуда пришёл запрос.
Cloud Messaging (Push-уведомления) - требует точный Bundle ID, потому что Apple Push Notification Service (APNs) привязывает токены устройств к конкретному Bundle ID. Если Bundle ID не совпадает, APNs просто не доставит push.
Google/Apple Sign-In - OAuth провайдеры настраиваются на конкретный Bundle ID в их консолях (Google Cloud Console, Apple Developer Portal). При несовпадении OAuth flow просто не запустится.
Analytics, Crashlytics - технически работают с любым Bundle ID, но данные агрегируются по зарегистрированному приложению. Вы увидите отчёты, но они будут привязаны не к вашему приложению в консоли.
Вывод для миграции: если вам нужен только Firestore для передачи данных - Bundle ID не критичен. Достаточно иметь валидный GOOGLE_APP_ID и настроенную анонимную авторизацию.
Итак, для нашей ситуации, где нам необходим только доступ до Firestore, и мы не можем подключить OldApp к тому же Firebase проекту NewAppFB, что и NewApp, вполне подходит вариант, в котором мы будем использовать один GoogleService-Info.plist от NewApp в проекте NewAppFB как для самого NewApp так и для OldApp, без регистрации OldApp в NewAppFB (получении отдельного GoogleService-Info.plist для OldApp в NewAppFB).
Важно! Когда это (отсутствие регистрации Bundle ID) НЕ работает?
Случай 1: Разные Google Cloud Projects
Если Firebase проекты созданы в разных Google Cloud аккаунтах:
Project A: Firebase Project (Organization: Company A)
Project B: Firebase Project (Organization: Company B)Тогда API_KEY от Project A не будет работать для Project B, даже если подменить PROJECT_ID.
Случай 2: VPC Service Controls
Если Firebase проект использует VPC Service Controls:
// Может быть настроено ограничение по Bundle ID
{
"allowedServices": ["firestore.googleapis.com"],
"allowedBundleIds": ["com.company.newapp"] // Только этот Bundle ID
}Случай 3: App Check
Если включен Firebase App Check:
// App Check проверяет Bundle ID через Apple Device Check
let providerFactory = YourAppCheckProviderFactory()
AppCheck.setAppCheckProviderFactory(providerFactory)Учитывайте эти особенности, при рассмотрении возможности использовать Firebase проект без регистрации в нём нашего Bundle ID
Итак, эти 3 принципиальных ограничения к нам не относились, поэтому мы решили первую проблему с подключением приложения к проекту.
Почему это работает:
REST API Firebase идентифицирует проект по PROJECT_ID и API_KEY
Авторизация запросов происходит через токены Authentication
BUNDLE_ID в plist - это метаданные для локального использования
Зачем нам это знание:
Мы можем использовать один GoogleService-Info.plist в нескольких приложениях
Не нужно регистрировать OldApp в проекте NewAppFB
Достаточно скопировать plist от NewApp в OldApp и получить доступ к Firestore
Теперь давайте углубимся во вторую проблему - это динамическое переключение между разными Firebase проектами во время работы приложения. Но сначала пробежимся, по традиционным вариантам переключения проектов.
2. Традиционные способы переключения Firebase проектов (Compile-Time)
Обычно в документации и в примерах используется статичные определения конфигурации Firebase во время компиляции.
Compile-time подходы - это основа, с которой стоит начинать. Они проще в реализации, надежнее и рекомендуются Google для большинства сценариев разделения сред.
Xcode Configurations + Build Scripts
Этот подход основан на конфигурациях сборки, которые используют разные GoogleService-Info.plist. Файлы хранятся в разных местах, с дефолтным именем GoogleService-Info.plist. При сборке нужный файл копируется скриптом в корень бандла.Шаг 1: Создаем конфигурации в Xcode.
Для этого в Xcode создаётся две конфигурации сборки Project → Info → Configurations:
- Debug (Development Firebase)
- Production (Production Firebase)Шаг 2: Храним несколько GoogleService-Info.plist
При таком подходе мы храним разные конфигурацонные файлы под одинаковым дефолтным именем, например так.
Resources/ ├── Development/ │ └── GoogleService-Info.plist # dev-project └── Production/ └── GoogleService-Info.plist # production-projectВажно: что эти файлы НЕ добавляются в Target Membership, иначе будет ошибка компиляции из-за одинаковых имён файлов! Один из них (нужный) копируется скриптом при сборке.
Шаг 3: Создание скрипта в Build Phase Script для копирования нужного plist
Добавляем Run Script Phase перед компиляцией:
# Определяем конфигурацию CONFIGURATION_FOLDER="" if [ "${CONFIGURATION}" == "Debug" ]; then CONFIGURATION_FOLDER="Development" else CONFIGURATION_FOLDER="Production" fi # Путь к исходному файлу SOURCE_PLIST="${SRCROOT}/Resources/${CONFIGURATION_FOLDER}/GoogleService-Info.plist" # Путь назначения в бандле DEST_PLIST="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist" # Проверяем существование if [ ! -f "$SOURCE_PLIST" ]; then echo "error: GoogleService-Info.plist not found at ${SOURCE_PLIST}" exit 1 fi # Копируем файл cp "$SOURCE_PLIST" "$DEST_PLIST" echo "Copied GoogleService-Info.plist from ${CONFIGURATION_FOLDER}"
Теперь при сборке скрипт будет копировать нужный файл конфигурации в корень банда и соответственно будет использоваться нужный Firebase проект.>Плюсы:
Простота и надежность
Рекомендовано Google
Минусы:
Нельзя переключаться без пересборки
Нельзя использовать несколько проектов одновременно в одной сборке
Это основной и самый распространенный Compile-time подход для переключения проектов Firebase. Редко, когда для Compile-time подхода используют другие варианты, но мы их кратко тоже рассмотрим.
CocoaPods Configurations
Если используете CocoaPods, можно настроить разные Firebase SDK для разных конфигураций:# Podfile target 'MyApp' do # Общие поды pod 'Alamofire' # Firebase только для определенных конфигураций pod 'Firebase/Core', :configurations => ['Debug', 'Production'] pod 'Firebase/Firestore', :configurations => ['Debug', 'Production'] endНо это не решает проблему динамического переключения проектов.
Multiple Targets
Этот подход подразумевает создание отдельных таргетов для каждого Firebase проекта, например:MyApp-Dev (Bundle ID: com.company.app.dev)MyApp-Prod (Bundle ID: com.company.app)MyApp-Migration (Bundle ID: com.company.app.migration)В чём суть подхода: каждый таргет имеет свой
GoogleService-Info.plistи Bundle ID и у вас в каждом таргете можно сразу положить нужный конфигурационный файл в корень бандла (не будет конфликта и не нужно скриптов копирования) и далее использовать дефолтную инициализацию Firebase в коде, как в предыдущих вариантах.
Такой подход в каких-то вариантах может быть подходящий, но так же и может быть проблемным из-за разных таргетов.
Итак, ни один из вариантов Compile-Time подхода не позволяет нам динамически переключать проекты в коде. Однако, Firebase поддерживает создание нескольких экземпляров FirebaseApp одновременно
3. Динамическое переключение Firebase проектов (Runtime)
Все Compile-Time подходы, рассмотренные выше, имеют общий недостаток: Firebase проект «зашит» в сборку на этапе компиляции. А что если нам нужно:
Подключиться к другому Firebase проекту прямо во время работы приложения?
Работать с двумя или более Firebase проектами одновременно?
Дать пользователю возможность мигрировать данные из одного проекта в другой?
Именно такая задача стояла перед нами: OldApp должен был записать данные в NewAppFB (проект нового приложения), при этом продолжая использовать свой основной OldAppFB для аналитики и крашлитики.
Ключевая идея: отказ от стандартной инициализации
Стандартный подход Firebase предполагает, что вы вызываете FirebaseApp.configure() без параметров, и SDK автоматически ищет файл GoogleService-Info.plist в корне бандла. Но это ограничивает вас одним проектом на сборку.
Мы пошли другим путём:
Храним все конфигурационные файлы с уникальными именами в бандле
Никогда не используем стандартный
FirebaseApp.configure()без параметровВсегда явно загружаем нужную конфигурацию из файла и передаём options
Это даёт нам полный контроль над тем, к какому Firebase проекту подключено приложение в данный момент.
Структура файлов конфигурации
Поскольку мы не используем стандартное имя GoogleService-Info.plist, все наши конфигурационные файлы имеют уникальные имена:
Resources/Firebase/
├── GoogleService-Info-Production.plist # OldAppFB (продакшен)
├── GoogleService-Info-Sandbox.plist # SandboxFB (дебаг)
└── GoogleService-Info-Migration.plist # NewAppFB (для миграции данных)
Важно: Все эти файлы добавлены в Target Membership - конфликта не будет, так как имена разные. Никаких Build Script'ов для копирования не требуется!
Типы конфигураций
Определим enum для удобной работы с разными конфигурациями:
enum FBConfigurationType: String, CaseIterable {
case production = "Production"
case sandbox = "Sandbox"
case migration = "Migration"
// Имя plist-файла (без расширения)
var googleInfoPlistName: String {
"GoogleService-Info-\(self.rawValue)"
}
// Ожидаемый PROJECT_ID для валидации
var expectedProjectID: String {
switch self {
case .production: "oldapp-production" // OldAppFB
case .sandbox: "shared-sandbox" // SandboxFB
case .migration: "newapp-production" // NewAppFB
}
}
// Человекочитаемое название
var displayName: String {
switch self {
case .production: "Production (OldApp)"
case .sandbox: "Sandbox (Debug)"
case .migration: "Migration (NewApp)"
}
}
}Как загружать конфигурацию из файла с уникальным именем?
Поскольку мы не используем стандартное имя, Firebase не найдёт конфигурацию автоматически. Нужно:
Найти путь к файлу в бандле через
Bundle.main.path(forResource:ofType:)Создать FirebaseOptions из этого файла
Передать options в
FirebaseApp.configure(options:)
func loadFirebaseConfig(type: FBConfigurationType) {
// 1. Ищем путь к plist-файлу в бандле
guard let configPath = Bundle.main.path(
forResource: type.googleInfoPlistName, // "GoogleService-Info-Production"
ofType: "plist"
) else {
NSLog("Config not found: \(type.googleInfoPlistName).plist")
return
}
NSLog("Found config at: \(configPath)")
// 2. Загружаем FirebaseOptions из файла
guard let options = FirebaseOptions(contentsOfFile: configPath) else {
NSLog("Failed to load Firebase options from: \(configPath)")
return
}
// 3. Конфигурируем Firebase с явной передачей options
FirebaseApp.configure(options: options)
NSLog("Firebase configured with: \(type.displayName)")
}
Default App vs Named App
Firebase SDK поддерживает два типа приложений:
Default App - создаётся через
FirebaseApp.configure(options:)без имениДоступен через
FirebaseApp.app()Используется по умолчанию всеми сервисами:
Firestore.firestore(),Auth.auth()и т.д.Может быть только один в приложении
Named App - создаётся через
FirebaseApp.configure(name:options:)с уникальным именемДоступен через
FirebaseApp.app(name: "имя")Для работы нужно явно указывать app:
Firestore.firestore(app: namedApp)Можно создать несколько с разными именами
Наш подход:
Production и Sandbox - ис��ользуют default app (но с ручной загрузкой options!)
Migration - использует named app, чтобы можно было работать с двумя проектами одновременно
Почему Migration - это Named App?
Представьте ситуацию: приложение запущено с Production конфигурацией (Crashlytics шлёт репорты в OldAppFB). Пользователь нажимает «Мигрировать данные». Нам нужно:
Продолжать отправлять крэш-репорты в OldAppFB (Production)
Одновременно записывать данные в NewAppFB (Migration)
Если бы Migration был default app, нам пришлось бы удалить текущий default app - и Crashlytics перестал бы работать!
Поэтому для Migration мы создаём отдельный named app, который сосуществует с default app:
private func configureFirebaseApp(type: FBConfigurationType, options: FirebaseOptions) {
if type == .migration {
// Migration - создаём Named App (отдельно от default)
FirebaseApp.configure(name: type.rawValue, options: options)
NSLog("Migration Firebase configured as named app")
} else {
// Production/Sandbox - используем Default App
FirebaseApp.configure(options: options)
NSLog("\(type.rawValue) Firebase configured as default app")
}
}Получение FirebaseApp для нужной конфигурации
extension FBConfigurationType {
/// Получить FirebaseApp для данной конфигурации
var firebaseApp: FirebaseApp? {
switch self {
case .migration:
// Named app - получаем по имени
FirebaseApp.app(name: self.rawValue)
case .production, .sandbox:
// Default app
FirebaseApp.app()
}
}
}
// Использование:
let migrationApp = FBConfigurationType.migration.firebaseApp
let productionApp = FBConfigurationType.production.firebaseAppПереключение между конфигурациями
Когда нужно переключить default app (например, с Production на Sandbox), мы сталкиваемся с важным ограничением Firebase SDK: нельзя просто вызвать configure() повторно - это приведёт к крашу приложения с ошибкой «Default app has already been configured».
Поэтому процесс переключения требует предварительного удаления существующего приложения. Вот что важно учитывать:
1. Проверка необходимости переключения. Перед удалением текущей конфигурации нужно убедиться, что переключение действительно необходимо. Если PROJECT_ID текущего приложения совпадает с целевым - переключение не нужно, и мы экономим ресурсы.
2. Синхронное удаление. Метод FirebaseApp.delete() возвращает результат синхронно (несмотря на то, что есть версия с completion handler). Это означает, что после удаления можно сразу создавать новый app без ожидания.
3. Потеря состояния. При удалении FirebaseApp теряется всё связанное с ним состояние: авторизация пользователя, кэш Firestore, активные слушатели. Это нужно учитывать в логике приложения.
4. Разная логика для default и named app. Default app удаляется при несовпадении PROJECT_ID, а named app (Migration) удаляется всегда перед пересозданием, чтобы избежать конфликтов.
private func cleanupExistingConfigurations(for type: FBConfigurationType) {
// Удаляем default app если Project ID не совпадает с новым
// Это важно: если PROJECT_ID совпадает, переконфигурация не нужна
if let defaultApp = FirebaseApp.app(),
defaultApp.options.projectID != type.expectedProjectID {
NSLog("Deleting existing default Firebase app (PROJECT_ID mismatch)")
NSLog("Current: \(defaultApp.options.projectID ?? "nil")")
NSLog("Target: \(type.expectedProjectID)")
_ = FirebaseApp.delete(defaultApp)
}
// Для Migration - удаляем старый named app если есть
// Named apps независимы от default, поэтому удаляем по имени
if type == .migration, let migrationApp = type.firebaseApp {
NSLog("Deleting existing Migration Firebase app")
_ = FirebaseApp.delete(migrationApp)
}
}Полный менеджер конфигураций
Теперь соберём всё вместе в единый менеджер. Почему именно такая архитектура?
Singleton-паттерн - менеджер должен быть единственным источником правды о текущей конфигурации Firebase. Если разные части приложения будут создавать свои экземпляры менеджера, мы потеряем контроль над состоянием.
Разделение ответственности - методы разбиты на мелкие функции, каждая из которых делает одну вещь: поиск файла, загрузка options, конфигурация app, валидация. Это упрощает отладку и тестирование.
Защита от повторной инициализации - метод loadFirebaseConfig сначала проверяет, не сконфигурировано ли уже приложение с нужным PROJECT_ID. Это предотвращает лишние операции и потенциальные крэши.
Валидация после конфигурации - после каждой конфигурации мы проверяем, что подключились к правильному проекту. В production-среде несовпадение PROJECT_ID - это критическая ошибка, которую нужно обнаружить как можно раньше.
Поддержка Objective-C - аннотация @objc позволяет использовать менеджер из legacy Objective-C кода, что важно для существующих проектов. Именно поэтому здесь мы также используем GCD, а не async/await. Если у вас не поддержки Objective-C кода, то эти аннотации можно удалить и, конечно, лучше использовать async/await.
import FirebaseCore
import FirebaseFirestore
@objc class FirebaseConfigurationManager: NSObject {
// MARK: - Singleton
// Единственный экземпляр менеджера для всего приложения
@objc static let shared = FirebaseConfigurationManager()
// MARK: - State
// Текущая активная конфигурация
private var currentConfiguration: FBConfigurationType = .sandbox
// Флаг режима миграции (когда нужен доступ к NewAppFB)
private var isMigrationModeEnabled: Bool = false
// MARK: - Initialization
// Private init гарантирует использование только через shared
override private init() {
super.init()
loadInitialConfiguration()
}
// MARK: - Public API
/// Возвращает текущую конфигурацию.
/// Полезно для отображения в UI или логирования.
func getCurrentConfiguration() -> FBConfigurationType {
currentConfiguration
}
/// Переключает Firebase на указанную конфигурацию.
/// Этот метод безопасен для повторного вызова - если конфигурация
/// уже активна, ничего не произойдёт.
func switchConfiguration(to configuration: FBConfigurationType) {
currentConfiguration = configuration
loadFirebaseConfig(type: configuration)
NSLog("Switched to \(configuration.displayName)")
}
/// Включает или выключает режим миграции.
/// При включении - активирует Migration конфигурацию.
/// При выключении - возвращает конфигурацию по умолчанию для текущей сборки.
@objc func setMigrationMode(_ enabled: Bool) {
isMigrationModeEnabled = enabled
if enabled {
// Включаем Migration - создаём named app для записи в NewAppFB
switchConfiguration(to: .migration)
} else {
// Выключаем - возвращаемся к основной конфигурации
#if DEBUG
switchConfiguration(to: .sandbox)
#else
switchConfiguration(to: .production)
#endif
}
}
// MARK: - Private: Initial Setup
/// Определяет начальную конфигурацию на основе типа сборки.
/// DEBUG сборки используют Sandbox (чтобы не засорять production аналитику),
/// RELEASE сборки используют Production.
private func loadInitialConfiguration() {
#if DEBUG
currentConfiguration = .sandbox
#else
currentConfiguration = .production
#endif
}
// MARK: - Private: Firebase Configuration
private func loadFirebaseConfig(type: FBConfigurationType) {
// Шаг 1: Проверяем, не сконфигурировано ли уже правильно
// Это оптимизация - избегаем лишних операций
if let existingApp = type.firebaseApp,
existingApp.options.projectID == type.expectedProjectID {
NSLog("\(type.rawValue) Firebase already configured correctly")
return
}
// Шаг 2: Очищаем старые конфигурации
cleanupExistingConfigurations(for: type)
// Шаг 3: Находим plist-файл в бандле
guard let configPath = Bundle.main.path(
forResource: type.googleInfoPlistName,
ofType: "plist"
) else {
NSLog("Config not found: \(type.googleInfoPlistName).plist")
return
}
// Шаг 4: Загружаем FirebaseOptions
guard let options = FirebaseOptions(contentsOfFile: configPath) else {
NSLog("Failed to load options from: \(configPath)")
return
}
// Шаг 5: Конфигурируем Firebase App
configureFirebaseApp(type: type, options: options)
// Шаг 6: Валидируем подключение
validateConfiguration(type: type)
}
private func cleanupExistingConfigurations(for type: FBConfigurationType) {
// Удаляем default app если Project ID не совпадает
if let defaultApp = FirebaseApp.app(),
defaultApp.options.projectID != type.expectedProjectID {
_ = FirebaseApp.delete(defaultApp)
}
// Для Migration удаляем старый named app
if type == .migration, let migrationApp = type.firebaseApp {
_ = FirebaseApp.delete(migrationApp)
}
}
private func configureFirebaseApp(type: FBConfigurationType, options: FirebaseOptions) {
if type == .migration {
// Named app для миграции - сосуществует с default app
FirebaseApp.configure(name: type.rawValue, options: options)
NSLog("Migration Firebase configured (named app)")
} else {
// Default app для Production/Sandbox
FirebaseApp.configure(options: options)
NSLog("\(type.rawValue) Firebase configured (default app)")
}
}
private func validateConfiguration(type: FBConfigurationType) {
guard let app = type.firebaseApp else {
NSLog("Firebase app not found for \(type.rawValue)")
return
}
let projectID = app.options.projectID ?? "unknown"
if projectID == type.expectedProjectID {
NSLog("Validated: \(type.rawValue) → \(projectID)")
} else {
// В production это критическая ошибка!
NSLog("PROJECT_ID mismatch! Expected: \(type.expectedProjectID), Got: \(projectID)")
}
}
}Инициализация в AppDelegate
// AppDelegate.swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Инициализируем Firebase
let configManager = FirebaseConfigurationManager.shared
let currentConfig = configManager.getCurrentConfiguration()
NSLog("Configuring Firebase: \(currentConfig.displayName)")
configManager.switchConfiguration(to: currentConfig)
// Проверяем результат
if let app = FirebaseApp.app() {
NSLog("Firebase ready. Project: \(app.options.projectID ?? "unknown")")
}
return true
}Работа с Migration Firestore
После включения режима миграции нам нужно получить доступ к Firestore нового проекта (NewAppFB). Здесь важно понимать разницу между default app и named app.
Когда вы вызываете Firestore.firestore() без параметров, вы получаете Firestore для default app. Но Migration - это named app, поэтому нужно явно указать, для какого приложения мы хотим получить Firestore.
Функция getMigrationFirestore() инкапсулирует эту логику и добавляет проверку: если Migration app не сконфигурирован (например, забыли вызвать setMigrationMode(true)), мы получим nil вместо краша приложения.
/// Получаем Firestore для миграции (отдельный проект!)
/// Важно: возвращает nil если Migration app не сконфигурирован
func getMigrationFirestore() -> Firestore? {
// Получаем named app "Migration"
guard let migrationApp = FBConfigurationType.migration.firebaseApp else {
NSLog("Migration app not configured. Did you call setMigrationMode(true)?")
return nil
}
// Создаём Firestore для этого конкретного app
return Firestore.firestore(app: migrationApp)
}
// Использование:
// 1. Сначала включаем режим миграции
FirebaseConfigurationManager.shared.setMigrationMode(true)
// 2. Получаем Firestore для нового проекта
guard let migrationDb = getMigrationFirestore() else {
showError("Не удалось подключиться к серверу миграции")
return
}
// 3. Работаем с Firestore нового проекта
migrationDb.collection("users").document("user123").setData([
"name": "John Doe",
"migratedAt": FieldValue.serverTimestamp()
])4. Практический пример: Миграция данных
Теперь соберём всё вместе и реализуем полноценный пример миграции данных из OldApp в NewApp через Firebase. Этот раздел покажет, как теория из предыдущих разделов применяется на практике.
Сценарий использования
Представим типичную ситуацию: компания выпускает новое приложение NewApp, и нужно дать пользователям возможность перенести свои данные из старого приложения OldApp.
Пользователь открывает OldApp, нажимает кнопку «Перенести данные в новое приложение», и его профиль, настройки и другие данные сохраняются в Firebase. Затем он открывает NewApp, авторизуется, и приложение автоматически загружает его данные.
Архитектура миграции
Процесс миграции состоит из нескольких этапов, которые выполняются в определённом порядке:
OldApp Firebase (NewAppFB) NewApp
[Bundle: com.company.oldapp] [Project: newapp-production] [Bundle: com.company.newapp]
│ │ │
│ 1. Собрать локальные данные │ │
│ 2. Включить Migration mode │ │
│ 3. Авторизоваться анонимно │ │
├────────────────────────────────>│ │
│ 4. Записать данные в Firestore │ │
├────────────────────────────────>│ │
│ │ 5. Читать данные из Firestore │
│ │<─────────────────────────────────┤
│ │ 6. Импортировать в локальную БД │Важно понимать: OldApp и NewApp никогда не взаимодействуют напрямую. Firebase Firestore выступает как промежуточное хранилище - «почтовый ящик», куда OldApp кладёт данные, а NewApp их забирает.
Структура данных в Firestore
Для миграции мы используем простую структуру - коллекцию users, где каждый документ представляет профиль пользователя:
users/
└── {user_id}/ # Уникальный ID пользователя (email или внутренний ID)
├── name: "Иван Петров" # Имя пользователя
├── email: "ivan@example.com" # Email
├── avatarUrl: "https://..." # URL аватара
├── role: "premium" # Роль или тип подписки
├── settings: { # Пользовательские настройки
│ ├── theme: "dark"
│ ├── language: "ru"
│ └── notifications: true
│ }
├── createdAt: Timestamp # Дата регистрации (из OldApp)
└── migratedAt: Timestamp # Дата миграции
Это пример возможной структуры данных
Плоская иерархия - упрощает чтение и запись, меньше вложенных запросов
user_id как ключ документа - позволяет быстро найти данные пользователя
migratedAt - помогает отслеживать, когда данные были перенесены, и обрабатывать повторные миграции
Менеджер миграции данных
Менеджер миграции - это класс, который координирует весь процесс переноса данных. Он отвечает за:
Включение режима миграции (активация named app)
Анонимную авторизацию в Firebase (для доступа к Firestore)
Сбор локальных данных из OldApp
Запись данных в Firestore
Почему используется анонимная авторизация? Потому что мы не можем использовать Google Sign-In или Apple Sign-In с чужим Bundle ID (как обсуждалось в разделе 1). Анонимная авторизация работает без проверки Bundle ID и позволяет получить доступ к Firestore.
Почему Singleton? Миграция - это операция, которая не должна выполняться параллельно. Если пользователь случайно нажмёт кнопку дважды, повторная миграция будет заблокирована флагом isMigrationInProgress.
import FirebaseCore
import FirebaseFirestore
import FirebaseAuth
/// Ошибки миграции - каждый тип ошибки описывает конкретную проблему
enum MigrationError: LocalizedError {
case firebaseNotConfigured // Migration app не инициализирован
case authenticationFailed // Не удалось авторизоваться
case noDataToMigrate // Нет данных для переноса
case firestoreError(Error) // Ошибка при записи в Firestore
var errorDescription: String? {
switch self {
case .firebaseNotConfigured:
"Сервис миграции не настроен. Обратитесь в поддержку."
case .authenticationFailed:
"Не удалось подключиться к серверу. Проверьте интернет-соединение."
case .noDataToMigrate:
"Нет данных для переноса."
case .firestoreError(let error):
"Ошибка сохранения: \(error.localizedDescription)"
}
}
}
/// Менеджер миграции данных
class DataMigrationManager {
// MARK: - Singleton
static let shared = DataMigrationManager()
// MARK: - Dependencies
private let configManager = FirebaseConfigurationManager.shared
// MARK: - State
// Защита от параллельных миграций
private var isMigrationInProgress = false
private init() {}
// MARK: - Public API
/// Запускает процесс миграции данных.
/// - Parameters:
/// - userId: Уникальный идентификатор пользователя (email или внутренний ID)
/// - completion: Callback с результатом операции
func migrateData(
userId: String,
completion: @escaping (Result<Void, MigrationError>) -> Void
) {
// Защита от повторного вызова
guard !isMigrationInProgress else {
NSLog("Migration already in progress, ignoring duplicate call")
return
}
isMigrationInProgress = true
NSLog("Starting migration for user: \(userId)")
// Шаг 1: Включаем режим миграции
// Это создаёт named app "Migration", подключённый к NewAppFB
configManager.setMigrationMode(true)
// Шаг 2: Даём время на инициализацию Firebase
// Firebase SDK требует небольшую паузу после configure()
// для полной готовности всех сервисов
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.performMigration(userId: userId, completion: completion)
}
}
// MARK: - Private: Migration Steps
private func performMigration(userId: String, completion: @escaping (Result<Void, MigrationError>) -> Void) {
// Получаем Firebase App для миграции
guard let migrationApp = FBConfigurationType.migration.firebaseApp else {
NSLog("Migration Firebase app not found")
finish(with: .failure(.firebaseNotConfigured), completion: completion)
return
}
// Создаём Auth и Firestore для Migration app
// Важно: используем app-specific экземпляры, а не default
let migrationAuth = Auth.auth(app: migrationApp)
let migrationDb = Firestore.firestore(app: migrationApp)
// Авторизуемся анонимно
// Это необходимо для прохождения Security Rules (request.auth != null)
migrationAuth.signInAnonymously { [weak self] result, error in
if let error = error {
NSLog("Anonymous auth failed: \(error.localizedDescription)")
self?.finish(with: .failure(.authenticationFailed), completion: completion)
return
}
NSLog("Authenticated anonymously: \(result?.user.uid ?? "unknown")")
// Собираем локальные данные
guard let userData = self?.collectLocalData() else {
NSLog("No data to migrate")
self?.finish(with: .failure(.noDataToMigrate), completion: completion)
return
}
// Сохраняем в Firestore
self?.saveToFirestore(
db: migrationDb,
userId: userId,
data: userData,
completion: completion
)
}
}
/// Собирает данные пользователя из локального хранилища OldApp
private func collectLocalData() -> [String: Any]? {
var data: [String: Any] = [:]
// Пример сбора данных, например из UserDefaults
if let name = UserDefaults.standard.string(forKey: "userName") {
data["name"] = name
}
if let email = UserDefaults.standard.string(forKey: "userEmail") {
data["email"] = email
}
if let avatarUrl = UserDefaults.standard.string(forKey: "userAvatarUrl") {
data["avatarUrl"] = avatarUrl
}
if let role = UserDefaults.standard.string(forKey: "userRole") {
data["role"] = role
}
// Настройки
let settings: [String: Any] = [
"theme": UserDefaults.standard.string(forKey: "theme") ?? "light",
"language": UserDefaults.standard.string(forKey: "language") ?? "ru",
"notifications": UserDefaults.standard.bool(forKey: "notifications")
]
data["settings"] = settings
// Дата регистрации (если есть)
if let createdTimestamp = UserDefaults.standard.object(forKey: "userCreatedAt") as? TimeInterval {
data["createdAt"] = Date(timeIntervalSince1970: createdTimestamp)
}
return data.isEmpty ? nil : data
}
/// Сохраняет данные в Firestore
private func saveToFirestore(
db: Firestore,
userId: String,
data: [String: Any],
completion: @escaping (Result<Void, MigrationError>) -> Void
) {
// Формируем документ
var document = data
document["migratedAt"] = FieldValue.serverTimestamp()
// Путь: users/{userId}
let docRef = db.collection("users").document(userId)
// Используем merge: true чтобы не перезаписать существующие данные
// (если пользователь уже начал использовать NewApp)
docRef.setData(document, merge: true) { [weak self] error in
if let error = error {
NSLog("Firestore write failed: \(error.localizedDescription)")
self?.finish(with: .failure(.firestoreError(error)), completion: completion)
} else {
NSLog("Data migrated successfully!")
self?.finish(with: .success(()), completion: completion)
}
}
}
/// Завершает миграцию и вызывает callback
private func finish(
with result: Result<Void, MigrationError>,
completion: @escaping (Result<Void, MigrationError>) -> Void
) {
isMigrationInProgress = false
// Вызываем completion на main thread для безопасного обновления UI
DispatchQueue.main.async {
completion(result)
}
}
}
Чтение данных в NewApp
Теперь посмотрим на другую сторону процесса - как NewApp читает мигрированные данные.
В NewApp ситуация проще: приложение работает со своим «родным» Firebase проектом (NewAppFB), поэтому нам не нужны named apps и специальные конфигурации. Мы просто используем стандартный Firestore.firestore().
Процесс импорта выглядит так:
Пользователь авторизуется в NewApp
Приложение запрашивает данные из коллекции
usersпоuserIdЕсли данные найдены - применяем настройки к локальному хранилищу
Показываем пользователю подтверждение успешного импорта
Важный момент: мы используем async/await для асинхронных операций с Firestore. Это делает код более читаемым и позволяет легко обрабатывать ошибки через try/catch.
import FirebaseFirestore
/// Класс для импорта данных в NewApp
class DataImporter {
// Default Firestore - это уже NewAppFB, потому что NewApp
// использует GoogleService-Info.plist от NewAppFB
private let db = Firestore.firestore()
/// Импортирует данные пользователя, мигрированные из OldApp
/// - Parameter userId: Идентификатор пользователя (должен совпадать с тем, что использовался в OldApp)
/// - Returns: Словарь с данными пользователя или nil если данные не найдены
func importUserData(userId: String) async throws -> [String: Any]? {
NSLog("Checking for migrated data for user: \(userId)")
// Читаем документ пользователя
let docRef = db.collection("users").document(userId)
let snapshot = try await docRef.getDocument()
// Проверяем, существует ли документ
guard snapshot.exists, let data = snapshot.data() else {
NSLog("No migrated data found for user: \(userId)")
return nil
}
NSLog("Found migrated data, applying settings...")
// Применяем данные к локальному хранилищу
applyUserData(data)
return data
}
/// Применяет импортированные данные к локальному хранилищу
private func applyUserData(_ data: [String: Any]) {
// Профиль пользователя
if let name = data["name"] as? String {
UserDefaults.standard.set(name, forKey: "userName")
NSLog("Name: \(name)")
}
if let email = data["email"] as? String {
UserDefaults.standard.set(email, forKey: "userEmail")
NSLog("Email: \(email)")
}
if let avatarUrl = data["avatarUrl"] as? String {
UserDefaults.standard.set(avatarUrl, forKey: "userAvatarUrl")
NSLog("Avatar URL imported")
}
if let role = data["role"] as? String {
UserDefaults.standard.set(role, forKey: "userRole")
NSLog("Role: \(role)")
}
// Настройки
if let settings = data["settings"] as? [String: Any] {
if let theme = settings["theme"] as? String {
UserDefaults.standard.set(theme, forKey: "theme")
NSLog("Theme: \(theme)")
}
if let language = settings["language"] as? String {
UserDefaults.standard.set(language, forKey: "language")
NSLog("Language: \(language)")
}
if let notifications = settings["notifications"] as? Bool {
UserDefaults.standard.set(notifications, forKey: "notifications")
NSLog("Notifications: \(notifications)")
}
}
NSLog("All settings imported successfully")
}
}
// Пример использования в ViewController:
class ProfileViewController: UIViewController {
private let importer = DataImporter()
func checkForMigratedData() {
// userId должен быть известен после авторизации
guard let userId = getCurrentUserId() else { return }
Task {
do {
if let data = try await importer.importUserData(userId: userId) {
// Показываем уведомление об успешном импорте
await MainActor.run {
showSuccessAlert("Данные из старого приложения успешно импортированы!")
// Обновляем UI с новыми данными
refreshUI()
}
}
} catch {
NSLog("Import error: \(error)")
// Не показываем ошибку пользователю - это не критично
}
}
}
}
5. Безопасность и важные моменты
При работе с Firebase, особенно в сценарии миграции между приложениями, необходимо уделить особое внимание безопасности. В этом разделе рассмотрим ключевые аспекты.
Firestore Security Rules
Security Rules - это первая и самая важная линия защиты ваших данных в Firebase. Они выполняются на сервере Google, и их нельзя обойти с клиента.
Для нашего сценария миграции правила должны обеспечивать:
Только авторизованные пользователи могут читать и писать данные
Ограничение размера документа предотвращает злоупотребления
Запрет доступа ко всему остальному - принцип минимальных привилегий
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /migration_data/{userId}/devices/{deviceId} {
allow read, write: if request.auth != null;
allow write: if request.resource.size() < 1048576; // 1MB limit
}
match /{document=**} {
allow read, write: if false;
}
}
}
Почему request.auth != null достаточно?
В нашем сценарии мы используем анонимную авторизацию. Может показаться, что это небезопасно - ведь любой может авторизоваться анонимно. Но на практике это обеспечивает достаточный уровень защиты:
Злоумышленнику нужно знать структуру вашей базы данных
Нужно знать правильный
userIdдля доступа к даннымАнонимная авторизация всё равно создаёт сессию, которую можно отслеживать
При необходимости можно добавить rate limiting через Cloud Functions
Авторизация - независимые сессии!
Это один из самых неочевидных и важных моментов при работе с несколькими Firebase Apps. Каждый FirebaseApp имеет полностью независимую сессию авторизации.
Что это значит на практике? Если пользователь авторизован в default app (Production), это никак не влияет на состояние авторизации в named app (Migration). Это два разных пользователя с точки зрения Firebase!
// Scenario: пользователь авторизован в Production app через Google Sign-In
// Default app (Production)
let productionUser = Auth.auth().currentUser
print(productionUser?.uid) // "abc123" - реальный Google аккаунт
print(productionUser?.email) // "user@gmail.com"
// Named app (Migration)
let migrationApp = FirebaseApp.app(name: "Migration")!
let migrationUser = Auth.auth(app: migrationApp).currentUser
print(migrationUser?.uid) // nil - пользователь НЕ авторизован!
// После анонимной авторизации в Migration:
Auth.auth(app: migrationApp).signInAnonymously { result, error in
print(result?.user.uid) // "xyz789" - ДРУГОЙ пользователь!
// Это анонимный пользователь, не связанный с Google аккаунтом
}Почему это важно для миграции?
Мы не можем использовать Firebase UID для связывания данных между OldApp и NewApp, потому что:
В OldApp пользователь авторизован в OldAppFB - у него один UID
В Migration (NewAppFB) мы авторизуемся анонимно - совершенно другой UID
В NewApp пользователь авторизуется заново - третий UID
Решение: используйте внешний идентификатор, который не зависит от Firebase:
Email пользователя
ID из вашей собственной системы авторизации
Phone number
Любой другой стабильный идентификатор
// Правильно: используем email как ключ документа
let userId = Auth.auth().currentUser?.email ?? "unknown"
db.collection("users").document(userId).setData(userData)
// Неправильно: Firebase UID разный в разных проектах!
let userId = Auth.auth().currentUser?.uid
db.collection("users").document(userId).setData(userData)
Дополнительные меры безопасности
1. Шифрование чувствительных данных
Если вы мигрируете пароли, токены или другие секреты, шифруйте их перед записью в Firestore:
import CryptoKit
// Шифруем пароль перед сохранением
let key = SymmetricKey(size: .bits256)
let encrypted = try AES.GCM.seal(password.data(using: .utf8)!, using: key)
userData["encryptedPassword"] = encrypted.combined?.base64EncodedString()
2. Ограничение времени жизни данных
Миграционные данные не должны храниться вечно. Настройте автоматическое удаление через Cloud Functions или TTL:
// Cloud Function для удаления старых данных
exports.cleanupOldMigrations = functions.pubsub
.schedule('every 24 hours')
.onRun(async (context) => {
const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000); // 30 дней
// Удаляем документы старше 30 дней
});
3. Логирование и мониторинг
Отслеживайте аномальную активность в Firebase Console:
Необычно большое количество записей
Доступ к данным других пользователей
Ошибки авторизации
6. Заключение
Мы прошли большой путь - от понимания внутреннего устройства Firebase SDK до реализации полноценной системы миграции данных между приложениями. Давайте подведём итоги и выделим ключевые моменты.
Что мы узнали
1. Bundle ID - не барьер для Firestore
Главное открытие этой статьи: Firebase Firestore не проверяет Bundle ID вашего приложения при обращении к базе данных. Это означает, что вы можете использовать GoogleService-Info.plist от одного приложения в другом - и это будет работать.
Конечно, есть ограничения: Push-уведомления, Google Sign-In и некоторые другие сервисы требуют точного совпадения Bundle ID. Но для нашей задачи - миграции данных через Firestore - это не проблема.
2. Firebase поддерживает несколько приложений одновременно
Стандартная документация Firebase фокусируется на сценарии «одно приложение - один проект». Но SDK поддерживает создание нескольких экземпляров FirebaseApp с разными конфигурациями. Это открывает возможности для:
Работы с несколькими Firebase проектами из одного приложения
Миграции данных между проектами
A/B тестирования разных Firebase конфигураций
Разделения аналитики и данных между средами
3. Архитектура решения имеет значение
Правильная организация кода критически важна:
Default App для основной работы приложения (Crashlytics, Analytics, основной Firestore)
Named App для специальных задач (миграция в сторонний проект)
Централизованный менеджер для контроля состояния и предотвращения конфликтов
Валидация PROJECT_ID для раннего обнаружения ошибок конфигурации
4. Безопасность требует внимания
Даже в сценарии с анонимной авторизацией можно обеспечить достаточный уровень безопасности:
Security Rules на уровне Firestore
Ограничение размера документов
Использование внешних идентификаторов вместо Firebase UID
Шифрование чувствительных данных
Ключевые выводы
Тезис | Пояснение |
|---|---|
Bundle ID не критичен для Firestore | Можно использовать plist от другого приложения для доступа к базе данных |
Все plist с уникальными именами | Никаких скриптов копирования, все файлы в Target Membership, полный контроль |
Default App для основной работы | Production/Sandbox переключаются через удаление и пересоздание |
Named App для специальных задач | Migration существует параллельно с default app, не мешая основной работе |
Независимые сессии авторизации | Каждый FirebaseApp - отдельный пользователь, используйте внешние ID |
Security Rules обязательны | Они выполняются на сервере и обеспечивают реальную защиту |
Когда использовать какой подход?
Задача | Рекомендуемый подход |
|---|---|
Разделение Dev/Prod сред | Compile-Time (Build Scripts) - проще и надёжнее |
Миграция данных между приложениями | Runtime с Named App - единственный вариант |
Работа с двумя проектами одновременно | Default App + Named App |
A/B тестирование Firebase | Runtime с переключением |
Отладка Firebase подключения | Runtime с возможностью переключения в UI |
Что дальше?
Если вы планируете реализовать подобную систему в своём проекте, вот несколько рекомендаций:
Начните с простого - убедитесь, что базовое подключение к «чужому» Firebase проекту работает, прежде чем усложнять архитектуру
Тестируйте Security Rules - используйте Firebase Emulator Suite для локального тестирования правил безопасности
Логируйте всё - при работе с несколькими Firebase Apps легко запутаться, какой проект активен в данный момент
Думайте о пользователе - миграция должна быть простой и понятной, без технических деталей в UI
Надеюсь, эта статья была полезной! Если у вас есть вопросы или вы хотите поделиться своим опытом работы с Firebase - пишите в комментариях.
Исходный код примеров из статьи доступен на GitHub
Автор: Сергей Волков
Дата: Декабрь 2025
