Zero-login на SwiftData и CloudKit: дневник без аккаунтов#
Личный дневник — это, наверное, худшая категория приложений для формы регистрации. Пользователь открывает приложение, чтобы написать о самом уязвимом дне за месяц, а первое, что видит, — «Создайте аккаунт», «Придумайте пароль», «Подтвердите email». Это не гипотеза: именно на этом шаге я потерял бы часть аудитории Lanternly, дневникового приложения, которое сейчас в разработке как конкурент Day One.
Решение, к которому я пришёл: zero-login — приложение вообще не имеет понятия «аккаунт». Профиль хранится локально, а синхронизация между iPhone и iPad идёт через приватную базу CloudKit того iCloud-аккаунта, который уже настроен на устройстве. Ни экрана входа, ни пароля, ни сервера, который этот пароль должен был бы проверять. В этой статье — архитектура, ограничения SwiftData при работе с CloudKit и то, как zero-login влияет на продуктовые решения вроде экспорта и импорта данных.
Почему zero-login, а не «просто Sign in with Apple»#
Первый вопрос, который мне задавали коллеги: «Sign in with Apple — это же одна кнопка, зачем усложнять?». Разница принципиальная. Даже самая лёгкая авторизация подразумевает аккаунт — сущность, которую нужно где-то регистрировать, связывать с данными, обрабатывать при смене iCloud ID, поддерживать в support-переписке («я не могу войти», «данные пропали после смены телефона»).
Zero-login убирает саму категорию проблемы: нет аккаунта — нет входа, нет забытого пароля, нет миграции при смене устройства, потому что «устройство» и «личность пользователя» здесь не разделены явно. CloudKit и так знает, кто владелец данных — это тот, кто вошёл в iCloud на этом устройстве. Мне не нужно повторно решать задачу, которую Apple уже решила на уровне ОС.
Это решение недёшево доставалось: оно означает, что у Lanternly нет пути «войти с другого iCloud-аккаунта и увидеть свои данные» — только через явный перенос. Для дневника, где данные и так privacy-sensitive, я считаю это оправданным компромиссом, а не недоработкой.
Архитектура: SwiftData локально, CloudKit — транспорт синхронизации#
SwiftData (наследник Core Data, объявленный на WWDC23) даёт локальное хранение из коробки, а ModelConfiguration с параметром cloudKitDatabase превращает его в offline-first синхронизацию без единой строчки серверного кода:
import SwiftData
enum JournalStore {
static let cloudContainerID = "iCloud.com.yourteam.yourapp"
static let schema = Schema([
JournalEntry.self,
JournalAttachment.self,
Profile.self,
])
/// Test/CI builds pass `-localStoreOnly` to skip CloudKit entirely —
/// there is no entitlement to sign against in CI, and simulator
/// screenshots would otherwise crash trying to reach a private database.
private static var localStoreOnly: Bool {
#if DEBUG
CommandLine.arguments.contains("-localStoreOnly")
#else
false
#endif
}
@MainActor
static func makeContainer() -> ModelContainer {
guard !localStoreOnly else {
return makeLocalContainer()
}
let cloudConfig = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private(cloudContainerID)
)
// CloudKit can be unavailable for reasons that have nothing to do
// with the schema: no iCloud sign-in, restricted account, missing
// entitlement in a dev build. Falling back to a local-only store
// keeps the app usable instead of crashing on first launch.
if let container = try? ModelContainer(for: schema, configurations: cloudConfig) {
return container
}
return makeLocalContainer()
}
@MainActor
private static func makeLocalContainer() -> ModelContainer {
let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none)
guard let container = try? ModelContainer(for: schema, configurations: localConfig) else {
fatalError("Could not create local ModelContainer: schema is invalid")
}
return container
}
}Обратите внимание на -localStoreOnly: это не теоретический флаг. В CI и на скриншотах симулятора CloudKit недоступен — нет подписанного энтайтлмента, а иногда и живого iCloud-аккаунта. Без явного локального фолбэка приложение падает на самом первом запуске тестового рантайма, а не в проде — что ещё хуже, потому что маскирует реальную проблему под «тесты нестабильны».
Ограничения CloudKit-совместимой модели: что придётся переписать в @Model#
Здесь начинается менее приятная часть zero-login-архитектуры. CloudKit накладывает жёсткие требования на схему, и SwiftData не может их обойти — оно лишь молча отключает несовместимые фичи или падает на старте. Три конкретных ограничения, с которыми я столкнулся ещё до первой строчки кода синхронизации:
- Никаких уникальных constraints.
@Attribute(.unique)работает в чисто локальном SwiftData, но недоступен, как только конфигурация подключает CloudKit — потому что CloudKit не может атомарно проверить уникальность между устройствами, которые в моменте могут быть офлайн. - Каждый non-optional атрибут обязан иметь значение по умолчанию. Схема без дефолта для обязательного поля падает на старте контейнера, а не мягко деградирует.
- Каждая связь обязана быть optional — включая to-many связи, которые вы бы в чисто локальном приложении сделали non-optional с дефолтным пустым массивом.
import SwiftData
@Model
final class JournalEntry {
var id: UUID = UUID()
var createdAt: Date = Date.now
var text: String = ""
var mood: Int?
var tags: [String] = []
// CloudKit requires every relationship to be optional — even
// to-many ones you would normally default to an empty array.
@Relationship(deleteRule: .cascade, inverse: \JournalAttachment.entry)
var attachments: [JournalAttachment]? = []
// No @Attribute(.unique) here: CloudKit cannot enforce atomic
// uniqueness across devices, so SwiftData disables it the moment
// a CloudKit-backed configuration is used. Uniqueness, if you need
// it, becomes an application-level check instead of a schema one.
init(id: UUID = UUID(), text: String = "", mood: Int? = nil) {
self.id = id
self.text = text
self.mood = mood
}
}Практический вывод: часть бизнес-инвариантов, которые раньше жили на уровне схемы (уникальный id, обязательное поле), переезжает в валидацию на уровне приложения. Это не баг CloudKit, а следствие distributed-природы синхронизации — атомарные проверки уникальности через сеть между офлайн-устройствами физически невозможны без централизованного арбитра, а централизованный арбитр — это ровно тот сервер, от которого zero-login-архитектура пытается избавиться.
Приватная база CloudKit: что на самом деле означает «свой iCloud»#
Важно не переупрощать формулировку «данные в iCloud пользователя, значит E2E из коробки» — это не совсем точно, и я предпочитаю говорить об этом честно, а не как маркетинговый слоган.
Приватная база CloudKit (private(cloudContainerID)) физически размещает данные в личном iCloud-хранилище конкретного пользователя — они не проходят через сервер, который контролирую я. Каждая запись шифруется ключами, сгенерированными на доверенном устройстве пользователя, до отправки в облако. Но полное end-to-end шифрование, при котором даже у Apple нет доступа к ключам, включается только когда пользователь сам активирует Advanced Data Protection в настройках iCloud — это опциональная настройка, а не поведение по умолчанию. Без неё Apple технически может выдать данные по законному запросу; с ней — не может, потому что ключей у неё физически нет.
Для Lanternly это означает честную коммуникацию с пользователем в описании приватности, а не обещание «военного шифрования» без нюансов. Приватная база CloudKit — это существенно лучше, чем собственный сервер с моим полным доступом к базе, но это не абсолютный zero-knowledge по умолчанию — и разработчик, который заявляет обратное, вводит пользователя в заблуждение.
Экспорт без замков и импорт из Day One: как zero-login меняет продуктовые решения#
Zero-login — это не только про архитектуру синхронизации, это ограничение, которое каскадом влияет на весь продукт. Если нет аккаунта на сервере, то нет и «облачного» экспорта через backend API — экспорт обязан быть локальной операцией на устройстве, где физически лежат данные.
Для Lanternly это вылилось в конкретное продуктовое решение: печать дневника в книгу (PDF и EPUB) генерируется целиком on-device, без загрузки текста на сторонний сервис для рендеринга. И, что не менее важно продуктово, — экспорт без замков: без premium-ограничения «только 10 страниц бесплатно» и без обязательной подписки для получения собственных данных обратно в открытом формате. Данные принадлежат пользователю, а не подписке.
Импорт из Day One работает по той же логике: приложение читает локальный экспорт конкурента и переносит записи в собственную SwiftData-модель на устройстве — снова без сервера-посредника, который бы временно держал у себя чужие личные записи в процессе миграции.
Свой бэкенд vs Firebase vs CloudKit + SwiftData#
| Критерий | Свой бэкенд | Firebase | CloudKit + SwiftData |
|---|---|---|---|
| Регистрация пользователя | Своя (email/OAuth) — обязательна | Firebase Auth — обязательна | Не нужна: identity = вход в iCloud на устройстве |
| Где физически данные | Ваш сервер/БД | Серверы Google | Приватная база iCloud пользователя |
| E2E-шифрование по умолчанию | Зависит от вашей реализации | Нет (данные читаемы на сервере Google) | Нет по умолчанию; включается Advanced Data Protection пользователя |
| Инфраструктура и DevOps | Полная ответственность разработчика | Управляемая, но платная при росте | Отсутствует — контейнер приложения, не сервер |
| Кросс-платформенность | Любая | iOS/Android/Web | Только экосистема Apple |
| Офлайн-first из коробки | Нужно реализовывать вручную | Частичный офлайн-кэш Firestore | Встроен в SwiftData + ModelConfiguration |
| Ограничения схемы | Нет (своя БД) | Гибкая NoSQL-схема | Нет unique constraints, optional/default обязательны |
Итог и чек-лист перед релизом#
Zero-login на SwiftData и CloudKit — не универсальный рецепт, а осознанный компромисс: полный отказ от кросс-платформенности вне Apple и от части гибкости схемы в обмен на отсутствие сервера, отсутствие экрана логина и данные, физически принадлежащие пользователю. Для приложения вроде личного дневника, живущего целиком в экосистеме Apple, этот компромисс воспринимается пользователем не как ограничение, а как обещание: «эти записи видите только вы».
Перед тем как выкатывать такую архитектуру в прод, стоит закрыть короткий чек-лист:
- Схема SwiftData совместима с CloudKit: без
@Attribute(.unique), все non-optional поля с дефолтом, все связи optional - Есть явный локальный фолбэк, если CloudKit недоступен (нет входа в iCloud, ограниченный аккаунт, dev-сборка без энтайтлмента)
- Тестовые/CI-сборки используют флаг вроде
-localStoreOnly, а не пытаются достучаться до реального CloudKit-контейнера - В описании приватности честно указано, когда включается полное E2E (Advanced Data Protection), а не подразумевается по умолчанию
- Экспорт данных — локальная операция на устройстве, не требующая сервера-посредника
- Продуктовые ограничения (paywall, лимиты) не блокируют доступ пользователя к собственным данным
Подробнее о резолюции конфликтов и подписках на изменения в CloudKit — в статье «CloudKit без сервера: offline-first синхронизация в проде», где я разбираю ту же архитектуру на примере медицинских данных MeteoHealth.



