CloudKit без сервера: offline-first синхронизация в проде#
Когда я проектировал синхронизацию для MeteoHealth — приложения, которое ведёт медицинские метрики пользователя на iPhone, iPad и Apple Watch, — первый вопрос был не «какой фреймворк», а «нужен ли мне сервер вообще». Личные данные о здоровье, три платформы, требование работать без сети в самолёте или в подвале больницы — и никакого желания поддерживать бэкенд, базу данных и DevOps ради одного приложения.
Ответ, к которому я пришёл после нескольких месяцев в проде: сервер не нужен, если правильно спроектировать offline-first синхронизацию поверх CloudKit. Эта статья — не пересказ документации Apple, а конкретный опыт: что сработало, что сломалось и какие решения я бы принял иначе, зная то, что знаю сейчас.
Почему я выбирал между CloudKit, Firebase и Supabase#
На старте это выглядело как стандартный выбор BaaS. Firebase — зрелый, кросс-платформенный, огромное комьюнити. Supabase — открытый, Postgres под капотом, приятный DX. CloudKit — только Apple-экосистема, зато встроен в ОС.
Решающими для меня оказались три фактора, специфичных именно для медицинских данных:
- Данные физически хранятся в личном iCloud пользователя, а не на серверах, которые я обязан администрировать, патчить и защищать. Это не просто удобство — это меньшая поверхность атаки и меньше ответственности за хранение чувствительных данных.
- Аутентификация уже решена. Apple ID и так есть у пользователя, мне не нужно городить регистрацию, восстановление пароля и всё, что с этим связано для health-данных.
- Нулевая инфраструктура. Ни одного сервера, который может упасть в 3 часа ночи. Ни одного счёта за хостинг, который растёт вместе с базой пользователей.
Обратная сторона — vendor lock-in на Apple и потолок кастомизации: сложные кросс-записевые транзакции и произвольные серверные запросы в CloudKit сделать сложнее, чем в Postgres. Для приложения, живущего только в экосистеме Apple, этот компромисс оказался оправданным.
| Критерий | CloudKit | Firebase | Supabase |
|---|---|---|---|
| Собственный сервер | Не нужен | Не нужен (управляемый) | Нужен self-host или Supabase Cloud |
| Хранение данных | Приватная база iCloud пользователя | Серверы Google | Postgres (managed/self-host) |
| Кросс-платформенность | Только Apple-экосистема | iOS/Android/Web | iOS/Android/Web |
| Аутентификация | Apple ID «из коробки» | Firebase Auth (нужна настройка) | Supabase Auth (нужна настройка) |
| Offline-first | Встроен в Core Data через NSPersistentCloudKitContainer | Firestore offline-кэш | Требует ручной реализации |
| Стоимость при росте | Бесплатные квоты на пользователя от Apple | Растёт с тратами на Firestore-запросы | Растёт с инстансом Postgres |
Архитектура: Core Data поверх NSPersistentCloudKitContainer#
Первая версия синхронизации в MeteoHealth построена на NSPersistentCloudKitContainer — это самый быстрый путь получить offline-first поведение, потому что Core Data и так уже умеет работать локально без сети, а NSPersistentCloudKitContainer добавляет к этому зеркалирование в CloudKit «под капотом».
import CoreData
import CloudKit
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "MeteoHealthModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
// Required for CloudKit mirroring: history tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.pro.dodecaidr.meteohealth"
)
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error loading store: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
// Field-level last-writer-wins. Good enough for most entities,
// but not for health metrics where "who wrote last" isn't "who is right".
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}Важный нюанс, который не сразу очевиден по документации: у CloudKit-совместимых моделей Core Data есть жёсткие ограничения — все атрибуты должны быть опциональными или иметь значение по умолчанию, уникальные constraints не поддерживаются, а отношения должны быть опциональными. Это заставило меня пересмотреть модель данных ещё до первой строчки кода синхронизации: часть бизнес-инвариантов, которые раньше проверялись на уровне схемы, переехали в валидацию на уровне приложения.
Приватная база и зоны: как устроена синхронизация между iPhone, iPad и Apple Watch#
CloudKit разделяет данные на три вида баз: публичную, приватную и shared. Для персональных медицинских данных подходит только приватная база (private database) — она физически лежит в личном iCloud-хранилище пользователя, недоступна другим пользователям и не проходит через инфраструктуру, которую я контролирую.
Внутри приватной базы данные группируются в зоны (CKRecordZone). По умолчанию всё падает в defaultZone, но для offline-first синхронизации кастомные зоны дают критичное преимущество: атомарные batch-операции сохранения и удаления в пределах одной зоны, а также независимые change-токены — можно синхронизировать группу связанных записей (например, все метрики за один визит к врачу) как единое целое, не рискуя получить частично применённое состояние на другом устройстве.
На практике для MeteoHealth это означало одну зону HealthMetrics для всех трёх платформ — iPhone, iPad и Apple Watch читают и пишут в одну и ту же зону приватной базы одного и того же iCloud-аккаунта. Никакого отдельного сервера для «синхронизации между устройствами» не требуется: CloudKit и есть транспорт.
Конфликты в проде: что на самом деле означает serverRecordChanged#
Вот где начинается настоящая инженерия, а не туториал. NSPersistentCloudKitContainer резолвит конфликты автоматически через NSMergePolicy — но на уровне поля, а не записи, и без доступа к «предковой» версии записи. Для большинства полей это нормально: побеждает поле, изменённое позже. Но для медицинских измерений «кто записал последним» и «кто прав» — разные вопросы: если пользователь ввёл значение на iPhone в 9:00, а на Apple Watch синхронизация с задержкой доставила автоматическое измерение за 8:45 только в 9:05, наивный last-writer-wins перезапишет более раннее, но не менее валидное значение.
Здесь NSPersistentCloudKitContainer оказался слишком высокоуровневым: он не даёт доступа к трём версиям записи, которые лежат в основе serverRecordChanged — локальной, серверной и предковой. Для критичных сущностей я спустился на уровень необработанного CKRecord через CKSyncEngine.
CKSyncEngine: когда стоит спуститься на уровень ниже Core Data#
CKSyncEngine, представленный на WWDC23 для iOS 17+, — это низкоуровневый, но декларативный API: он берёт на себя букву черновой работы (change-токены, retry, батчинг push-уведомлений), но отдаёт вам полный контроль над тем, что именно отправляется и как разрешаются конфликты.
import CloudKit
final class HealthSyncEngine: NSObject, CKSyncEngineDelegate {
private var syncEngine: CKSyncEngine!
private let zoneID = CKRecordZone.ID(zoneName: "HealthMetrics")
func configure(savedState: CKSyncEngine.State.Serialization?) {
let configuration = CKSyncEngine.Configuration(
database: CKContainer(identifier: "iCloud.pro.dodecaidr.meteohealth").privateCloudDatabase,
stateSerialization: savedState,
delegate: self
)
syncEngine = CKSyncEngine(configuration)
}
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) {
switch event {
case .stateUpdate(let update):
persistState(update.stateSerialization)
case .fetchedRecordZoneChanges(let event):
event.modifications.forEach { applyToLocalStore($0.record) }
event.deletions.forEach { deleteFromLocalStore(recordID: $0.recordID) }
case .sentRecordZoneChanges(let event):
// Records CloudKit rejected because the server version moved on
// while we were offline.
for failure in event.failedRecordSaves {
guard failure.error.code == .serverRecordChanged,
let serverRecord = failure.error.serverRecord else { continue }
let resolved = mergeHealthRecord(local: failure.record, server: serverRecord)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(resolved.recordID)])
stageForNextSave(resolved)
}
default:
break
}
}
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await CKSyncEngine.RecordZoneChangeBatch(
pendingChanges: context.options.scope.pendingRecordZoneChanges
) { recordID in
recordToSave(for: recordID)
}
}
}Ключевой момент здесь — failedRecordSaves с кодом ошибки .serverRecordChanged. В отличие от NSPersistentCloudKitContainer, CKSyncEngine отдаёт мне саму серверную запись — со свежим recordChangeTag, без которого повторная отправка снова была бы отклонена. Я собираю новую запись поверх серверной версии и применяю собственную бизнес-логику:
/// serverRecordChanged gives us the current server record with a valid
/// change tag. Re-sending the local record's stale tag would just be
/// rejected again — we must copy the server record and reapply our fields.
private func mergeHealthRecord(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
let localRecordedAt = local["recordedAt"] as? Date ?? .distantPast
let serverRecordedAt = server["recordedAt"] as? Date ?? .distantPast
// The freshest measurement wins — not whoever reached the server first.
if localRecordedAt > serverRecordedAt {
merged["value"] = local["value"]
merged["recordedAt"] = local["recordedAt"]
merged["source"] = local["source"]
}
return merged
}Так конфликт резолвится по смыслу данных (время фактического измерения), а не по случайности сетевой задержки. Для остальных, менее критичных сущностей MeteoHealth я оставил NSPersistentCloudKitContainer — не вижу смысла переписывать весь стек на CKSyncEngine там, где property-level last-writer-wins никого не подведёт.
Push и подписки: как устройства узнают об изменениях мгновенно#
Без пуш-уведомлений синхронизация между iPhone, iPad и Apple Watch работала бы только по таймеру или при открытии приложения — не то, что ожидает пользователь от «одного iCloud-аккаунта». CKDatabaseSubscription следит за изменениями во всей приватной базе (включая новые зоны) и присылает тихий push при каждом изменении:
func setupDatabaseSubscription(database: CKDatabase) async throws {
let subscription = CKDatabaseSubscription(subscriptionID: "meteohealth-private-db-changes")
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // silent push, no banner
subscription.notificationInfo = notificationInfo
try await database.save(subscription)
}Получив такой push, приложение не показывает пользователю ничего — оно просто запускает дозагрузку изменений через CKSyncEngine:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo),
notification.subscriptionID == "meteohealth-private-db-changes" else {
completionHandler(.noData)
return
}
Task {
try? await syncEngine.fetchChanges()
completionHandler(.newData)
}
}Для NSPersistentCloudKitContainer подобную подписку заводить вручную не нужно — она уже настроена внутри фреймворка. Ручная настройка нужна только там, где вы работаете с CKSyncEngine напрямую.
Грабли, лимиты и чек-лист для продакшена#
То, что не попадает ни в один туториал, но стоило мне реальных часов отладки:
- CloudKit Dashboard — не место для миграций на живую. Изменение схемы записи в проде без предварительного деплоя в Development-окружение и промоушена гарантированно ловит несовместимость на части устройств, которые ещё не обновились.
- Rate limits тихие. CloudKit не бросает эффектную ошибку при превышении квоты запросов — иногда операции просто откладываются и выполняются позже. Если логика приложения ждёт мгновенного ответа, это выглядит как «зависшая» синхронизация.
- Симулятор лжёт про офлайн-режим. Тестировать реальные сценарии обрыва сети и последующего merge конфликтов нужно на физических устройствах с выключенным Wi-Fi, а не через отладочные тумблеры симулятора.
- iCloud-аккаунт может быть не залогинен или в ограниченном режиме (Family Sharing, управляемый Apple ID). Приложение обязано изящно деградировать — работать локально и показывать понятный статус, а не падать.
- Первая полная синхронизация после переустановки — самый тяжёлый сценарий по трафику и времени; я специально тестирую его отдельно, а не полагаюсь на инкрементальные сценарии, где всё работает гладко.
Чек-лист перед релизом фичи с CloudKit-синхронизацией:
- Схема Core Data совместима с ограничениями CloudKit (опциональные атрибуты, без уникальных constraints)
- Критичные сущности используют явную стратегию резолюции конфликтов, а не property trump по умолчанию
- Настроена и протестирована
CKDatabaseSubscription(для ручногоCKSyncEngine) - Протестирован сценарий: два устройства офлайн одновременно вносят конфликтующие изменения
- Протестирован сценарий: аккаунт не залогинен в iCloud
- Протестирована первая полная синхронизация «с нуля»
- Логи не считают тихие retry за фатальные ошибки
Итог. CloudKit — не универсальный ответ на вопрос «как синхронизировать данные», но для приложения, живущего целиком в экосистеме Apple и работающего с персональными данными пользователя, это оправданная замена собственному бэкенду: меньше инфраструктуры, встроенная аутентификация и данные физически в iCloud пользователя, а не на моих серверах. Цена — необходимость понимать разницу между удобным, но грубым NSPersistentCloudKitContainer и точным, но многословным CKSyncEngine, и выбирать инструмент под конкретную сущность, а не под весь проект разом.



