Берите самое лёгкое хранилище, которое подходит данным: мелкие настройки кладите в UserDefaults, секреты в Keychain, документы и бинарные данные в файлы, структурированные связанные данные в SwiftData или Core Data.
**UserDefaults: несколько мелких настроек: пройден ли онбординг, последняя вкладка, переключатель. Это plist, который целиком грузится в память и не шифруется.
Keychain: токены, пароли, ключи. Шифруется системой и, в отличие от всего остального здесь, переживает удаление приложения.
Файлы через FileManager: Codable JSON, картинки, загрузки, экспорт. От выбранной папки зависит, попадёт ли файл в бэкап и может ли система его удалить.
SwiftData**: выбор по умолчанию для структурированных связанных данных в новом приложении на iOS 17+: классы с @Model, @Query в SwiftUI.
Core Data: тот же движок с визуальной моделью; по-прежнему ответ для существующих хранилищ и для возможностей, которых нет в SwiftData.
SQLite через GRDB или Realm: когда нужен голый SQL и полный контроль или команда уже на этом работает.
CloudKit: слой синхронизации поверх локального хранилища, а не его замена.
Две классические ошибки: токен в UserDefaults (это читаемый plist, который попадает в бэкапы) и целая база данных ради трёх настроек.
iOSMiddle
Хранилище iOS
UserDefaults, Keychain, Core Data, SwiftData, FileManager
Закрыто вопросов
0 / 25
- 0Повторить
- 7Часто спрашивают
Вопросы
UserDefaults представляет собой небольшое хранилище «ключ-значение» для пользовательских настроек; оно сохраняется как property list в контейнере приложения и читается в память при запуске.
Только property-list-типы: String, числа и Bool, Date, Data, а также массивы и словари со строковыми ключами из них. Под plist понимают XML- или бинарный формат Apple ровно для этих типов, тот же, что у Info.plist.
Свой тип сначала нужно закодировать в Data, например через JSONEncoder. Если передать его в set(_:forKey:) напрямую, в рантайме будет исключение о недопустимом аргументе (invalid argument).
Подходит для: флагов, выбранной темы, последней открытой вкладки, порядка сортировки: мелких значений, которые читаются синхронно.
Не подходит для: секретов (это читаемый plist, который попадает в бэкапы; для них есть Keychain), больших данных и постоянно растущих списков (файл целиком лежит в памяти и перезаписывается), всего, что нужно искать или связывать.
@AppStorage в SwiftUI работает как property wrapper поверх того же хранилища, а UserDefaults(suiteName:) открывает отдельное, например общее с виджетом через App Group.
По этому ключу никто ничего не записал: true в @AppStorage служит лишь запасным значением внутри property wrapper, а bool(forKey:) на отсутствующий ключ отвечает false. Зарегистрируйте значение по умолчанию через register(defaults:) при запуске.
Экран настроек показывает переключатель включённым, а SoundPlayer молчит, пока пользователь не выключит и снова не включит его. Два читателя дают два разных значения по умолчанию.
register(defaults: ["soundEnabled": true]) заполняет registration domain, к которому обращается любой читатель, если ничего не записано. На диск он не сохраняется, поэтому вызывайте его при каждом запуске до первого чтения.
Типизированные геттеры превращают «нет значения» в false, 0 или 0.0. Если нужно отличить «ещё не выбирал» от «выключил», используйте object(forKey:) == nil.
Чтобы вернуть значение по умолчанию, вызывайте removeObject(forKey:); set(false, forKey:) сохраняет настоящий выбор, который потом уже не отличить.
Suite из UserDefaults(suiteName:) представляет собой отдельное хранилище, и регистрировать значения нужно и в нём, иначе виджет разойдётся с приложением.
synchronize() ничего не решает. Apple прямо пишет, что он не нужен: система записывает данные сама.
В Keychain: система шифрует его ключами, привязанными к устройству и его код-паролю, а UserDefaults пишет в обычный plist, который читается из незашифрованного бэкапа или на джейлбрейкнутом телефоне.
API состоит из четырёх C-функций: SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete, которые принимают словари с ключами kSec…. Токену подходит элемент класса kSecClassGenericPassword, определяемый сервисом и аккаунтом.
SecItemAdd не перезаписывает, а возвращает errSecDuplicateItem, поэтому сначала обновляйте элемент и добавляйте его, только если его ещё нет.
Задавайте kSecAttrAccessible явно: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, если токен нужен фоновому обновлению на заблокированном телефоне.
Keychain предназначен для небольших секретов. Большие данные шифруйте в файл, а в Keychain храните только ключ.
Да: элементы Keychain лежат вне контейнера приложения, поэтому переживают удаление, и после переустановки они снова на месте. Класс доступности определяет, когда элемент можно расшифровать, а группа доступа задаёт, какие ваши приложения и расширения могут его читать.
Переживает удаление: после переустановки может подхватиться токен предыдущего пользователя. Обычно помогает флаг первого запуска в UserDefaults, который как раз удаляется вместе с приложением: если флага нет, очистите свои элементы Keychain. Apple это поведение никогда не обещала, поэтому строить на нём функции не стоит.
Доступность (kSecAttrAccessible): WhenUnlocked используется по умолчанию и не работает на заблокированном телефоне; AfterFirstUnlock доступен после первой разблокировки с момента загрузки, что нужно фоновым задачам и виджетам; WhenPasscodeSetThisDeviceOnly требует код-пароль, и элемент уничтожается, если код-пароль снять.
**ThisDeviceOnly: элемент никогда не переезжает на другое устройство через восстановление из бэкапа или iCloud Keychain. Подходит для токенов и ключей, привязанных к устройству.
Группы доступа**: каждый элемент принадлежит одной из них, по умолчанию это team ID плюс bundle ID. Добавьте группу Keychain Sharing (или используйте идентификатор App Group) и передайте её в kSecAttrAccessGroup, чтобы делить элементы между приложением, его расширениями и другими вашими приложениями.
**kSecAttrSynchronizable**: со значением true элемент синхронизируется через iCloud Keychain на другие устройства пользователя.
У каждого приложения свой контейнер-песочница: в Documents лежит созданное пользователем, в Library/Application Support хранятся собственные данные приложения, и обе папки попадают в бэкап; Library/Caches в бэкап не попадает и может быть очищена; tmp служит временным местом для черновой работы.
Documents: то, потерю чего пользователь заметит. Попадает в бэкап и видна в приложении «Файлы», если вы это включили.
Library/Application Support: данные, которые приложение создаёт, но пользователь не просматривает, например база данных. Тоже попадает в бэкап, поэтому исключайте то, что можно скачать заново.
Library/Caches: всё, что можно скачать снова. В бэкап не попадает, и система может удалить содержимое при нехватке места, поэтому каждое чтение должно переживать отсутствие файла.
tmp: черновые файлы для одной операции; удаляйте их сами и считайте, что между запусками они могут исчезнуть.
Видео на 500 МБ, которое можно скачать заново, должно лежать в Caches, а не в Documents. Забивать iCloud-бэкап пользователя кэшем нельзя: это известная причина отказа в App Review; большим файлам, которые должны жить вне Caches, ставьте isExcludedFromBackup.
Класс защиты: по умолчанию файлы доступны после первой разблокировки. Полная защита подходит для чувствительных данных, но тогда фоновая задача не прочитает такой файл на заблокированном телефоне.
Codable состоит из пары Swift-протоколов, реализацию которых компилятор синтезирует для структур, enum и классов; NSCoding является старым протоколом из Objective-C для классов-наследников NSObject, архивируемым через NSKeyedArchiver. В новом коде используют Codable.
NSCoding означает написанные руками encode(with:) и init?(coder:) для каждого поля. Он всё ещё встречается в init(coder:) у вью из сторибордов и в старых архивах на диске.
Если архивировать всё же нужно, реализуйте NSSecureCoding и декодируйте через unarchivedObject(ofClass:from:). Старые API разархивации без проверки классов устарели: декодирование произвольных классов из файла открывает вектор атаки.
Codable проверяется на этапе компиляции, работает с value-типами и любым энкодером (JSON, plist), переименовывает поля через CodingKeys, а нестандартный случай решает свой init(from:).
Миграция старого архива: один раз разархивируйте его через NSKeyedUnarchiver, запишите обратно в JSON и удалите старый файл.
Никогда не пишите поверх живого файла: положите новые байты рядом и подставьте их переименованием, которое файловая система делает атомарно.
data.write(to: url, options: .atomic) именно так и работает: временный файл в той же директории, потом rename. После падения останется либо целиком старый файл, либо целиком новый, но не половина каждого.
Цена вполне реальна: .atomic требует места под обе копии. Если замену вы подготовили сами (докачанный файл, пересобранный кэш), подставляйте её через FileManager.replaceItemAt(_:withItemAt:): он сохраняет расширенные атрибуты и дату создания оригинала.
Атомарность не означает долговечности. Rename упорядочивает запись, но не заставляет байты долететь до флеш-памяти. Внезапное отключение питания всё ещё может стоить вам последнего сохранения, но обычный исход: либо целиком старый, либо целиком новый файл.
Сначала кодируйте, потом подменяйте. Кодировщик, который может бросить исключение, должен упасть до того, как что-то коснулось живого файла: ради этого Data и собирают в памяти, а не пишут потоком в назначение.
Когда процессов два, это отдельная задача. Файлу в App Group, который делят виджет или расширение, нужен NSFileCoordinator: атомарность работает в пределах одной записи, а координация не даёт двум писателям перемешаться.
Как только данные становятся изменяемой коллекцией, перестаньте делать это руками. SQLite под SwiftData или Core Data даёт журнал, построчную запись и восстановление после падения, которые вам не пришлось проектировать.
В пути к контейнеру есть UUID, который может измениться (восстановление из бэкапа, перенос на новое устройство, переустановка), поэтому ранее сохранённый абсолютный путь указывает на несуществующую директорию. Храните имя файла, а не путь.
Путь в песочнице выглядит как /var/mobile/Containers/Data/Application/<UUID>/Documents/avatar.jpg, и вам принадлежит только хвост. Сохраняйте его, а URL собирайте при чтении из URL.documentsDirectory (или из контейнера App Group).
Правило касается всех мест, где путь может спрятаться: колонка в базе, UserDefaults, запись CloudKit, закэшированный в синглтоне URL, переживший процесс, в котором его построили.
Файлы вне песочницы (document picker, iCloud Drive): скопируйте файл в свой контейнер сразу при выборе или сохраните bookmark (bookmarkData() / URL(resolvingBookmarkData:bookmarkDataIsStale:)) и оборачивайте каждый доступ в startAccessingSecurityScopedResource() / stopAccessingSecurityScopedResource().
Четыре части: NSManagedObjectModel описывает схему, NSPersistentStoreCoordinator связывает эту модель с файлами хранилища, NSManagedObjectContext служит черновиком, в котором вы получаете и меняете объекты, а NSPersistentContainer создаёт их все и владеет ими.
Модель: сущности, атрибуты и связи из файла .xcdatamodeld, скомпилированные в .momd внутри бандла.
Координатор и хранилища: координатор стоит между контекстами и одним или несколькими NSPersistentStore, обычно SQLite, иногда в памяти.
Контекст: отслеживает вставленные, изменённые и удалённые объекты; на диск ничего не попадает до save(). Каждый контекст привязан к очереди: viewContext работает на главной очереди для UI, newBackgroundContext() нужен для тяжёлой работы, и обращаться к контексту можно только в его собственной очереди: внутри perform или, для viewContext, напрямую на главном потоке.
Контейнер: вызовите loadPersistentStores один раз при запуске; persistentStoreDescriptions задают URL файла, параметры миграции и отслеживание истории. NSPersistentCloudKitContainer является подклассом, который добавляет синхронизацию через iCloud.
Core Data работает как менеджер графа объектов, а не ORM: он отслеживает связи, фолты, undo и уведомления об изменениях, а SQLite служит лишь хранилищем под ним. ModelContainer и ModelContext из SwiftData построены на тех же частях.
SwiftData представляет собой Swift-нативный слой над движком Core Data, где схема описывается классами с @Model, а не файлом .xcdatamodeld. Выбирайте его для нового приложения на iOS 17+, а на Core Data оставайтесь при существующем хранилище или когда нужны возможности, которых в SwiftData нет.
Те же идеи, новые имена: ModelContainer играет роль NSPersistentContainer, ModelContext играет роль NSManagedObjectContext, @Query играет роль @FetchRequest, а #Predicate заменяет строковый NSPredicate типизированным. @ModelActor даёт фоновый контекст вместо блоков perform.
Детали задаются макросами: @Attribute(.unique), @Relationship(deleteRule:inverse:), @Transient, а с iOS 18 ещё #Index и #Unique.
Что вы получаете: гораздо меньше шаблонного кода, предикаты с проверкой при компиляции, обновления SwiftUI через Observation, автосохранение, синхронизацию с CloudKit одним аргументом ModelConfiguration.
Где Core Data всё ещё сильнее: NSFetchedResultsController для списков на UIKit, свои mapping-модели, пакетные вставка и обновление, тонкая настройка политик слияния и поддержка iOS 16 и старше.
Оба могут работать с одним хранилищем: при совпадающих именах сущностей и одном URL файла существующее приложение на Core Data можно переводить экран за экраном, а не переписывать разом.
SwiftData моложе, поэтому в его ограничения первым делом упираются трансляция предикатов и пограничные случаи миграций.
Каждый из них оборачивает запрос (сущность, предикат, порядок сортировки) и следит за сохранёнными изменениями в своём контексте, поэтому список обновляется сам, и вам не нужно перезапрашивать данные после каждой записи.
**NSFetchRequest**: описывает, что загрузить: predicate, sortDescriptors и fetchBatchSize, чтобы полностью загружались только видимые строки.
**NSFetchedResultsController**: инструмент для UIKit. Он группирует результаты в секции через sectionNameKeyPath и сообщает об изменениях делегату; с diffable data source метод controller(_:didChangeContentWith:) отдаёт готовый снапшот.
**@FetchRequest**: тот же механизм в виде property wrapper для SwiftUI; контекст он берёт из окружения через \.managedObjectContext. @SectionedFetchRequest добавляет секции.
**@Query**: аналог в SwiftData с #Predicate и SortDescriptor; читает \.modelContext, который устанавливает .modelContainer(for:).
Динамические фильтры: @Query нельзя изменить после создания, поэтому передайте строку поиска в дочернюю вью и соберите запрос в её init. У @FetchRequest можно менять nsPredicate прямо во время работы.
Они видят только изменения, дошедшие до их контекста. Фоновое сохранение Core Data появится только с automaticallyMergesChangesFromParent; SwiftData сливает изменения сам.
Текст поиска интерполируется прямо в строку формата предиката, поэтому имя с апострофом вроде O'Brien делает её неразбираемой, и NSPredicate(format:) бросает исключение, которое роняет приложение. Передавайте текст аргументом через %@.
**Почему catch не спасает**: падение вызывает NSInvalidArgumentException «Unable to parse the format string», исключение Objective-C (NSException). Swift-овый do/catch ловит только Swift-ошибки Error, а исключение летит ещё при создании предиката, до вызова fetch.
Это ещё и инъекция: всё, что ввёл пользователь, становится синтаксисом предиката. Поиск по x' OR TRUEPREDICATE OR name == 'y возвращает все строки, а это уже утечка данных, как только тот же предикат ограничивает строки по владельцу или аккаунту.
Как работает исправление: %@ подставляет значение как константу, которую NSPredicate сам берёт в кавычки и экранирует, а %K делает то же для key path, так что ввод не может изменить структуру запроса.
В SwiftData: #Predicate захватывает Swift-значения и проверяется типами при компиляции, так что такой баг там просто не написать.
Как поймать в следующий раз: юнит-тест, который ищет O'Brien, и кастомное правило SwiftLint, помечающее \( внутри NSPredicate(format:.
Для связи нужны обратная связь (inverse) и явное правило удаления, и именно правило решает, удалятся ли вместе с родителем дети, просто отвяжутся или удаление будет запрещено.
Правил четыре: cascade (удалить связанные объекты), nullify (оставить их и обнулить ссылку), deny (запретить удаление, пока связанные объекты есть) и noAction (оставить граф несогласованным, что почти никогда не нужно).
Nullify стоит по умолчанию, поэтому без явного cascade удаление плейлиста оставляет его треки с playlist == nil. Это и есть «осиротевшие» треки из вопроса.
Задавайте правило через @Relationship(deleteRule:) в SwiftData или через выпадающий список в редакторе Core Data и ставьте cascade только на владеющей стороне. Cascade со стороны трека удалил бы плейлист при удалении одного трека.
Обратные связи держат обе стороны графа согласованными. SwiftData выводит обратную связь, когда кандидат очевиден, поэтому как только у модели появляются две связи к одному типу, указывайте её явно, иначе свяжется не та пара.
Связь многие-ко-многим задаётся массивом с обеих сторон, соединительную таблицу хранилище ведёт само, но порядок не сохраняется. Если пользователь может переставлять элементы, храните позицию отдельным атрибутом.
NSBatchDeleteRequest выполняется в SQLite, а не в контексте: валидация не запускается, а объекты в памяти об этом не узнают. Вливайте удалённые ID обратно и удаляйте через контекст, когда важны правила графа.
article принадлежит viewContext, а меняется на очереди фонового контекста, причём в background он вообще не зарегистрирован, так что этот save() ничего не записывает. Передавайте NSManagedObjectID и загружайте объект внутри другого контекста.
Managed-объект привязан к своему контексту, а контекст привязан к своей очереди. Обращение к нему с другой очереди создаёт гонку данных (data race), которая иногда работает, а иногда портит граф. Аргумент запуска -com.apple.CoreData.ConcurrencyDebug 1 превращает каждое такое нарушение в немедленное падение при разработке.
NSManagedObjectID можно безопасно передавать между потоками; existingObject(with:) в целевом контексте возвращает этот объект, при необходимости загружая его из хранилища, и бросает ошибку, если объект удалён.
У вставленного, но не сохранённого объекта временный ID (isTemporaryID), который другие контексты разрешить не могут. Сначала сохраните или вызовите obtainPermanentIDs(for:).
Полученный объект часто оказывается фолтом (fault), то есть заглушкой, атрибуты которой загружаются при первом обращении. Срабатывание фолта выполняет запрос на очереди контекста-владельца, и это ещё одна причина не трогать объект откуда-то ещё.
Не сохраняйте ID объектов как идентичность: они меняются, если хранилище пересоздано. Держите собственный id.
В SwiftData правило то же: модели не Sendable, поэтому передавайте PersistentIdentifier в @ModelActor и загружайте модель там.
Пишите в контексте, из которого UI не читает, а результаты передавайте через идентификаторы, а не через объекты.
SwiftData: тип с @ModelActor владеет собственным ModelContext на фоновом исполнителе, и вызывающий просто делает await. Модельные объекты не Sendable, поэтому наружу отдавайте PersistentIdentifier после сохранения (до него они временные) и перезапрашивайте объект на другой стороне. Создавайте актор из фоновой задачи: актор, созданный на главном акторе, иногда выполняет свой контекст на главном потоке.
Core Data: newBackgroundContext() или performBackgroundTask, и любое обращение к контексту внутри блока perform. Между контекстами передавайте NSManagedObjectID и разрешайте его через existingObject(with:); передача самого managed-объекта приводит к классическому падению.
Слияние с UI: включите viewContext.automaticallyMergesChangesFromParent и задайте политику слияния вроде NSMergeByPropertyObjectTrumpMergePolicy, иначе фоновое сохранение либо не появится на экране, либо упадёт конфликтом. SwiftData сливает сам, и @Query перезапускается без вашего участия.
Режьте работу на куски: сохраняйте каждые несколько сотен строк, а не один раз в конце, и сбрасывайте контекст между кусками, чтобы он не разросся до размера импорта. На время импорта автосохранение выключайте.
Для массового удаления и обновления есть короткий путь: NSBatchDeleteRequest и NSBatchUpdateRequest работают прямо в SQLite, но проходят мимо контекстов, поэтому возвращённые идентификаторы придётся влить в viewContext руками.
Диагностируйте аргументом запуска -com.apple.CoreData.SQLDebug 1: он печатает каждый SQL-запрос и работает и для SwiftData. Почти всегда медленный импорт оказывается сохранением на каждую строку.
Сделайте идентификатор с сервера идентичностью модели через ограничение уникальности: тогда импорт уже существующей записи обновит имеющуюся строку, а не добавит вторую.
SwiftData: @Attribute(.unique) var serverID: String. Вставка модели, чьё уникальное значение уже в базе, выполняет upsert: свойства существующей строки обновляются, дубликата нет, исключения тоже.
Core Data: ограничение объявляется на сущности в редакторе модели, и после этого merge policy обязательна. С дефолтной NSErrorMergePolicy сохранение падает на конфликте ограничения; NSMergeByPropertyObjectTrumpMergePolicy означает «входящие значения побеждают по каждому свойству».
Никогда не опирайтесь на PersistentIdentifier или NSManagedObjectID. Они идентифицируют строку в этом хранилище, меняются при пересоздании и миграции и ничего не значат для сервера.
Хранилище с синхронизацией через CloudKit вообще не поддерживает ограничения уникальности, поэтому синхронизируемая схема дедуплицирует руками, и ей нужен детерминированный победитель (меньший id, более ранний createdAt), когда два устройства создали одно и то же офлайн.
Делайте это одним запросом. Достаньте существующие строки для всей пачки одним предикатом со списком id и сложите в словарь; выборка на каждую запись даёт N+1, из-за которого импорт 20 тысяч строк тянется минутами.
Сравнение по ограничению идёт по точному сохранённому значению и чувствительно к регистру и диакритике, так что нормализуйте идентификатор, если сервер этого не гарантирует.
Для аддитивных изменений пусть фреймворк выполнит lightweight-миграцию, кастомную стадию пишите только там, где нужно преобразовать существующие данные, и тестируйте на реальных старых хранилищах: неудачная миграция означает падение при запуске.
Lightweight-миграция: добавление и удаление атрибутов, новые entity, новый обязательный атрибут со значением по умолчанию, переименование через renaming identifier. NSPersistentContainer выводит её сам: shouldMigrateStoreAutomatically и shouldInferMappingModelAutomatically включены по умолчанию.
Кастомная миграция в Core Data: для того, что вывести нельзя: разделить entity, вычислить значение. Либо .xcmappingmodel с сабклассом NSEntityMigrationPolicy, либо, начиная с iOS 17, NSStagedMigrationManager, который связывает lightweight- и кастомные стадии в коде.
SwiftData: каждая выпущенная схема описывается как VersionedSchema, и все они связываются в SchemaMigrationPlan. Стадия бывает .lightweight либо .custom с замыканиями willMigrate / didMigrate, которые правят данные вокруг изменения. Вкладывайте модели каждой версии внутрь её схемы и никогда не редактируйте уже выпущенную.
Как не сломать
Любое изменение требует новой версии модели. Правка текущего .xcdatamodel на месте даёт ошибку «The model used to open the store is incompatible with the one used to create the store».
Тестируйте миграцию на хранилищах, сохранённых каждой выпущенной версией, а не на свежесозданном: пользователи пропускают релизы, а баги живут в данных, которых вы не ожидали.
Долгую миграцию на старте может убить watchdog. Тяжёлую правку данных выносите из неё или показывайте экран прогресса.
Удалить и пересоздать хранилище можно как аварийный выход для тестировщиков в debug-сборке, но никогда как запасной вариант в продакшене для данных пользователя.
Дайте каждому тесту собственное хранилище, живущее только в памяти, чтобы тест начинался с пустой базы и ничего не доживало до следующего.
Держите сильную ссылку на контейнер до конца теста: контекст его не удерживает, и преждевременно освобождённый контейнер роняет тест или теряет хранилище.
SwiftData: ModelConfiguration(isStoredInMemoryOnly: true) и свежий ModelContainer, создаваемый прямо в тесте. Тестируемый код должен принимать контейнер или контекст параметром, а не тянуться к глобальному.
Core Data: соберите контейнер в тесте и укажите описанию хранилища тип «в памяти»; приём с передачей контейнера тот же.
Тестируйте свой код, а не фреймворк: написанные вами предикаты, преобразование между сетевыми и хранимыми моделями, правила устранения дублей, шаги миграции.
Миграции: исключение, которому нужен настоящий файл. Подложите копию хранилища старой версии, прогоните план миграции и проверьте результат.
Повторяйте продакшеновую модель конкурентности. Если приложение пишет через модельный актор, пусть и тест пишет так же, иначе он будет зелёным, пока реальный путь ловит гонку.
Поставьте перед хранилищем протокол репозитория, и большую часть приложения можно тестировать на заглушке в памяти, а контейнер понадобится только тестам самого хранилища.
Расширение работает в отдельном процессе со своим контейнером, поэтому всё общее должно лежать в каталоге App Group, который разрешено открывать обоим таргетам.
Добавьте одинаковую App Group приложению и расширению и стройте все общие пути от containerURL(forSecurityApplicationGroupIdentifier:), а не от собственной папки Documents.
SwiftData: направьте ModelConfiguration(url:) на файл внутри группового контейнера. В Core Data так же задайте url в описании хранилища до его загрузки.
Настройки: UserDefaults(suiteName:) с идентификатором группы, и виджет читает тот же suite. Стандартные UserDefaults не общие.
Элементы Keychain делятся через группу доступа, которую передают в kSecAttrAccessGroup: группу Keychain Sharing или сам идентификатор App Group. Без неё запросы расширения просто ничего не находят.
Оба процесса могут держать хранилище открытым одновременно, поэтому по возможности делайте виджет только читающим, включайте отслеживание истории изменений, чтобы замечать записи приложения, и после сохранения вызывайте WidgetCenter.shared.reloadTimelines(ofKind:).
Помните про заблокированный экран: виджет рисуется и на нём, поэтому нужные ему файлы и элементы Keychain должны быть доступны после первой разблокировки, а не только в разблокированном состоянии.
Удалить строки не значит удалить данные: нужно уничтожить файл хранилища вместе с его спутниками, а затем вычистить всё, что живёт вне его (Keychain, настройки, кэши и контейнер App Group).
Хранилище SQLite состоит из трёх файлов: Model.sqlite, -wal и -shm. Если убрать только первый, свежие записи останутся в write-ahead log. Используйте destroyPersistentStore(at:type:options:) у координатора, а в SwiftData подойдёт container.deleteAllData() или пересоздание контейнера поверх удалённого файла.
Удаление через контекст лишь помечает страницы свободными; байты остаются в файле, пока их что-нибудь не перезапишет. Если «удалено» должно означать удалено, уходить должен сам файл.
Элементы Keychain переживают и выход из аккаунта, и удаление приложения. Удаляйте каждый используемый kSecClass явно: за вас этого никто не сделает.
removePersistentDomain(forName:) очищает suite настроек, но работающий процесс держит уже закэшированные значения, поэтому сбрасывайте объекты, которые их хранят, а не надейтесь на перезапуск.
Все забытые остатки лежат снаружи вашего хранилища: URLCache, cookies, WKWebsiteDataStore, миниатюры в Caches и tmp, контейнер App Group, который читает виджет, и сам виджет, продолжающий рисовать старого пользователя, пока вы не перезагрузите его таймлайны.
Лучше устроить всё так, чтобы чистить было нечего: дайте каждому аккаунту свою директорию в Application Support и положите туда хранилище, файлы и миниатюры. Тогда выход из аккаунта сводится к удалению одной директории и пересборке контейнера.
Выбирайте GRDB или голый SQLite, когда нужен настоящий SQL (FTS5, CTE, оконные функции), большие данные с интенсивной записью или простые value-типы без фолтов; Realm сегодня остаётся в основном унаследованным выбором.
Признаки, что нужен GRDB или голый SQLite
Нужен SQL, который Core Data не выражает: оконные функции, рекурсивные CTE, полнотекстовый поиск через FTS5, настоящие отчётные запросы.
Нагрузка большая или с преобладанием записи, и вы хотите сами выбирать индексы, джойны и границы транзакций.
Нужны простые value-типы: без фолтов, без managed object context и без правил привязки к потокам.
Схема или миграции общие с сервером или с кроссплатформенным кодом, например модулем KMP.
Чем вы платите
Фолтами, undo, автоматическими уведомлениями об изменениях для SwiftUI, валидацией модели и почти бесплатной синхронизацией с CloudKit, которую дают Core Data и SwiftData. GRDB закрывает часть этого через ValueObservation и свой мигратор, но этот слой теперь ваш.
Realm
Собственный движок не на SQLite, с «живыми» объектами и простым API. MongoDB объявила устаревшими Atlas Device Sync и Realm SDK в 2024 году и отключила синхронизацию в 2025-м; база живёт как open source-проект сообщества, поэтому для нового проекта её сложно обосновать.
Проверьте, что фильтрация действительно происходит в SQLite: транслируемый предикат отсекает строки в базе, а замыкание поверх уже полученного массива сперва поднимает в память всю таблицу.
#Predicate превращается в SQL только из выражений, которые SwiftData умеет транслировать: вызов своей функции отклоняется при компиляции, а вычислимое свойство или свойство с @Transient падает при выполнении запроса. Привычный обход (выбрать всё и отфильтровать на Swift) как раз и делает список медленным.
Индексируйте колонки, по которым фильтруете и сортируете: макрос #Index в SwiftData, галочка индексации в редакторе модели Core Data. Сортировка по неиндексированной колонке пересортировывает всю таблицу на каждый запрос.
Берите страницами, а не целиком: fetchLimit и fetchOffset у FetchDescriptor либо предикат по последнему значению сортировки, который остаётся корректным, когда сверху добавляются новые строки.
Запрашивайте только те свойства, которые показывает ячейка, а картинки и прочие большие объекты держите в файлах, храня в записи лишь имя файла, чтобы выборка списка не тянула мегабайты в память.
Обход связи внутри ячейки порождает отдельный запрос на строку, классическую проблему N+1. Подгружайте связи заранее ключевыми путями предзагрузки или денормализуйте единственное значение, нужное ячейке.
Измеряйте аргументом запуска -com.apple.CoreData.SQLDebug 1, а не гадайте. Один SQL-запрос на каждую видимую строку указывает на фолты связей (N+1); один SELECT по всей таблице без WHERE означает, что фильтр отработал в Swift.
Каждый контекст сохраняется относительно снимка, который он получил при выборке, и когда строка в хранилище перестаёт совпадать с этим снимком, какие значения выживут, решает merge policy, а не ваш код.
Core Data держит снимок строки для каждого зарегистрированного объекта. При сохранении он сравнивает снимок с хранилищем; расхождение превращается в NSMergeConflict, и mergePolicy контекста разрешает его до того, как что-то будет записано.
Политик пять. NSErrorMergePolicy стоит по умолчанию и заставляет сохранение бросить ошибку, чтобы вы разобрались сами; две политики уровня свойств (…ObjectTrump, …StoreTrump) сливают данные по атрибутам относительно снимка, поэтому кандидатами становятся только реально изменённые контекстом атрибуты; NSOverwriteMergePolicy пишет поверх хранилища все значения из памяти, включая поля, на которые этот контекст даже не смотрел; NSRollbackMergePolicy выбрасывает изменения из памяти.
Глобально включить ObjectTrump, чтобы ошибки перестали приходить, и есть ровно тот способ, которым правка и пропадает. Конфликт был настоящим, его просто разрешили молча.
Держите контексты недолгоживущими. Экрану редактирования дайте дочерний контекст: отмена его выбрасывает, сохранение проталкивает изменения в родителя, и вчерашний снимок не доживает до сегодня.
Если контекст обязан жить долго, stalenessInterval = 0 и refresh(_:mergeChanges: true) не дают ему отдавать значения, которые хранилище уже обогнало.
В SwiftData нет публичного API merge policy, поэтому храните в модели поле updatedAt или версию и сравнивайте их перед перезаписью.
Переключите хранилище на конфигурацию с CloudKit, но сначала приведите уже выпущенную схему к правилам CloudKit (каждый атрибут опциональный или со значением по умолчанию, каждая связь опциональная и с обратной связью, без ограничений уникальности), иначе хранилище не загрузится.
Сам переключатель: ModelConfiguration(cloudKitDatabase:) в SwiftData или NSPersistentCloudKitContainer вместо NSPersistentContainer, плюс возможность iCloud и идентификатор контейнера.
В CloudKit нет обязательных колонок и односторонних связей, поэтому обязательное (non-optional) свойство просто не даст загрузить хранилище.
Это исправление схемы означает новую версию модели: выпустите его как lightweight-миграцию (VersionedSchema или новая версия .xcdatamodel) и проверьте на хранилищах из каждого выпущенного релиза, прежде чем включать синхронизацию.
Ограничения уникальности не поддерживаются вовсе: @Attribute(.unique) придётся убрать, а устранение дублей после слияния становится вашей задачей.
Синхронизация согласована в конечном счёте и сливает данные по свойствам, побеждает последний записавший. Всё, что требует настоящего разрешения конфликтов (счётчик, общий список), должно синхронизировать операции, а не итоговые значения.
Приватная база принадлежит текущей учётной записи iCloud. Пользователь без неё всё равно должен получить работающее приложение, поэтому считайте синхронизацию надстройкой над локальным хранилищем, а не самим хранилищем.
Тестируйте на двух устройствах с одной учётной записью, смотрите в CloudKit Console и помните: схема попадает в продакшен только после ручного продвижения, и принимаются лишь дополняющие изменения.