Введение: что такое SwiftData и зачем он вообще нужен
SwiftData — это современный фреймворк Apple для локального хранения данных, впервые показанный на WWDC 2023 и доступный начиная с iOS 17. Если вы работали с Core Data, то знаете, сколько боли приносит этот фреймворк — громоздкие .xcdatamodeld-файлы, NSManagedObject-подклассы, обязательная настройка стека. SwiftData — это, по сути, Core Data, но переосмысленный для эпохи Swift.
Меньше шаблонного кода, декларативный подход, нативная интеграция со SwiftUI. И при этом под капотом всё тот же проверенный движок Core Data.
В этом руководстве мы пройдём весь путь: от создания первой модели до настройки синхронизации с iCloud через CloudKit. Будут рабочие примеры кода, практические советы и разбор подводных камней, которые вы вряд ли найдёте в официальной документации.
Настройка проекта: ModelContainer и ModelContext
Итак, работа со SwiftData строится вокруг двух ключевых объектов: ModelContainer и ModelContext.
Контейнер (ModelContainer) — это хранилище, которое управляет схемой данных и связью с физической базой (по умолчанию SQLite). Контекст (ModelContext) — это своего рода «черновик», через который вы работаете с данными: вставляете, обновляете, удаляете. Если вы знакомы с Core Data, то это прямые аналоги NSPersistentContainer и NSManagedObjectContext.
Подключение на уровне приложения
Самый простой способ настроить SwiftData — добавить модификатор .modelContainer(for:) к корневой сцене:
import SwiftUI
import SwiftData
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: [Task.self, Category.self])
}
}
}
Этот модификатор создаёт контейнер для указанных моделей и внедряет ModelContext в окружение SwiftUI. Все дочерние view автоматически получают доступ к контексту через @Environment(\.modelContext). Честно говоря, по сравнению с настройкой стека Core Data — это просто праздник.
Расширенная настройка контейнера
Если нужна более тонкая настройка, используйте ModelConfiguration:
let config = ModelConfiguration(
"MyAppStore",
schema: Schema([Task.self, Category.self]),
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic
)
let container = try ModelContainer(
for: Task.self, Category.self,
configurations: config
)
Параметр isStoredInMemoryOnly: true пригодится для тестирования и превью в SwiftUI — данные живут только в оперативной памяти и не записываются на диск. Очень удобно для юнит-тестов.
Определение моделей с макросом @Model
Помните, как в Core Data модели определялись через визуальный редактор .xcdatamodeld и громоздкие NSManagedObject-подклассы? SwiftData радикально упрощает этот процесс. Достаточно добавить макрос @Model к обычному Swift-классу:
import SwiftData
@Model
class Task {
var title: String
var notes: String
var isCompleted: Bool
var createdAt: Date
var priority: Int
init(title: String, notes: String = "", isCompleted: Bool = false, priority: Int = 0) {
self.title = title
self.notes = notes
self.isCompleted = isCompleted
self.createdAt = Date()
self.priority = priority
}
}
Макрос @Model автоматически генерирует всю инфраструктуру для сохранения, загрузки и отслеживания изменений. Поддерживаются стандартные типы: String, Int, Double, Bool, Date, Data, URL, UUID, а также перечисления, соответствующие Codable.
Атрибуты свойств
Для дополнительной настройки поведения свойств есть макрос @Attribute:
@Model
class User {
@Attribute(.unique) var email: String // уникальное значение
@Attribute(.externalStorage) var avatar: Data? // хранение в отдельном файле
@Attribute(.spotlight) var name: String // индексация для Spotlight
@Transient var temporaryScore: Int = 0 // не сохраняется в базу
init(email: String, name: String, avatar: Data? = nil) {
self.email = email
self.name = name
self.avatar = avatar
}
}
Атрибут .unique гарантирует уникальность значения — при попытке вставить дубликат SwiftData выполнит upsert (обновление существующей записи вместо создания новой). Атрибут .externalStorage рекомендуется для больших бинарных данных вроде изображений — они хранятся отдельно от основной базы SQLite, что заметно улучшает производительность.
CRUD-операции: создание, чтение, обновление, удаление
Давайте разберём все четыре базовые операции на примере приложения для управления задачами.
Создание (Create)
Для добавления новой записи используется метод insert(_:) контекста:
struct AddTaskView: View {
@Environment(\.modelContext) private var modelContext
@State private var title = ""
var body: some View {
Form {
TextField("Название задачи", text: $title)
Button("Добавить") {
let task = Task(title: title)
modelContext.insert(task)
// SwiftData автоматически сохранит изменения
}
}
}
}
По умолчанию SwiftData включает автосохранение — изменения записываются в базу при определённых событиях (уход приложения в фон, изменения UI и т.д.). Если нужно сохранить принудительно — вызывайте try modelContext.save().
Чтение (Read)
Для выборки данных в SwiftUI есть обёртка свойства @Query:
struct TaskListView: View {
@Query(sort: \Task.createdAt, order: .reverse)
private var tasks: [Task]
var body: some View {
List(tasks) { task in
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
VStack(alignment: .leading) {
Text(task.title)
Text(task.createdAt, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
@Query автоматически обновляет view при любых изменениях данных. Удалили задачу в другом экране — список тут же перерисуется. Никаких делегатов и NSFetchedResultsController.
Обновление (Update)
А вот обновление в SwiftData — это, пожалуй, самое приятное. Просто меняете свойство объекта, и всё:
struct TaskDetailView: View {
@Bindable var task: Task
var body: some View {
Form {
TextField("Название", text: $task.title)
Toggle("Выполнена", isOn: $task.isCompleted)
Stepper("Приоритет: \(task.priority)", value: $task.priority, in: 0...5)
}
}
}
Поскольку модели SwiftData — это классы (ссылочные типы), любое изменение свойства напрямую обновляет объект в контексте. SwiftData сам отслеживает все изменения и сохраняет их. Никакого явного вызова save не нужно.
Удаление (Delete)
Для удаления записи — метод delete(_:) контекста:
struct TaskListView: View {
@Environment(\.modelContext) private var modelContext
@Query private var tasks: [Task]
var body: some View {
List {
ForEach(tasks) { task in
TaskRowView(task: task)
}
.onDelete { indexSet in
for index in indexSet {
modelContext.delete(tasks[index])
}
}
}
}
}
@Query: фильтрация, сортировка и динамические запросы
Макрос @Query — это гораздо больше, чем просто «показать всё из базы». Он поддерживает фильтрацию через предикаты, сортировку и ограничение количества результатов.
Фильтрация с #Predicate
Для фильтрации данных используется макрос #Predicate, который принимает обычное Swift-выражение:
// Только невыполненные задачи
@Query(filter: #Predicate<Task> { !$0.isCompleted })
private var pendingTasks: [Task]
// Задачи с высоким приоритетом
@Query(filter: #Predicate<Task> { $0.priority >= 4 })
private var highPriorityTasks: [Task]
// Поиск по названию
@Query(filter: #Predicate<Task> { task in
task.title.localizedStandardContains("покупки")
})
private var shoppingTasks: [Task]
Важный момент: фильтрация с #Predicate выполняется на уровне базы данных, а не в памяти. Это значительно эффективнее, чем загрузить все записи и потом фильтровать через .filter() в Swift. На больших объёмах данных разница будет ощутимой.
Сортировка с SortDescriptor
Для многоуровневой сортировки используйте массив SortDescriptor:
// Сортировка по приоритету (убывание), затем по названию
@Query(sort: [
SortDescriptor(\Task.priority, order: .reverse),
SortDescriptor(\Task.title)
])
private var sortedTasks: [Task]
Совет из практики: всегда добавляйте хотя бы два критерия сортировки — основной и запасной. Иначе порядок записей с одинаковым значением первого критерия будет непредсказуемым, и UI может «прыгать».
Динамические запросы
У @Query есть одно серьёзное ограничение: параметры фильтрации и сортировки задаются при объявлении свойства и не могут меняться на лету. Для динамических запросов используется паттерн с вынесением @Query в отдельное дочернее view:
// Родительское view с элементами управления
struct TasksScreen: View {
@State private var searchText = ""
@State private var showCompleted = false
@State private var sortOrder = SortDescriptor(\Task.createdAt, order: .reverse)
var body: some View {
FilteredTaskList(
searchText: searchText,
showCompleted: showCompleted,
sortOrder: sortOrder
)
}
}
// Дочернее view с @Query
struct FilteredTaskList: View {
@Query private var tasks: [Task]
init(searchText: String, showCompleted: Bool, sortOrder: SortDescriptor<Task>) {
let predicate = #Predicate<Task> { task in
(searchText.isEmpty || task.title.localizedStandardContains(searchText)) &&
(showCompleted || !task.isCompleted)
}
_tasks = Query(filter: predicate, sort: [sortOrder])
}
var body: some View {
List(tasks) { task in
TaskRowView(task: task)
}
}
}
Суть простая: при каждом изменении параметров SwiftUI пересоздаёт дочернее view с новыми аргументами для @Query, и запрос выполняется заново. Не самый элегантный паттерн (было бы здорово, если бы Apple сделали @Query динамическим из коробки), но работает надёжно.
FetchDescriptor для продвинутых сценариев
Когда нужен полный контроль — ограничение количества записей, смещение, пакетная обработка — на помощь приходит FetchDescriptor:
// Последние 10 задач
var descriptor = FetchDescriptor<Task>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 10
let recentTasks = try modelContext.fetch(descriptor)
// Подсчёт записей без загрузки данных
let count = try modelContext.fetchCount(
FetchDescriptor<Task>(predicate: #Predicate { !$0.isCompleted })
)
Связи между моделями
Реальные приложения редко обходятся одной моделью — данные почти всегда связаны между собой. SwiftData поддерживает связи «один к одному», «один ко многим» и «многие ко многим».
Один ко многим (one-to-many)
Это самый распространённый тип связи. Например, у одной категории может быть много задач:
@Model
class Category {
var name: String
var color: String
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task]
init(name: String, color: String = "blue") {
self.name = name
self.color = color
self.tasks = []
}
}
@Model
class Task {
var title: String
var isCompleted: Bool
var category: Category?
init(title: String, isCompleted: Bool = false, category: Category? = nil) {
self.title = title
self.isCompleted = isCompleted
self.category = category
}
}
Обратите внимание на deleteRule: .cascade — при удалении категории все её задачи тоже удалятся. Если хотите сохранить задачи при удалении категории, используйте .nullify (это поведение по умолчанию) — тогда свойство category у задач просто станет nil.
Правила удаления
SwiftData поддерживает три правила удаления:
- .nullify (по умолчанию) — связанные объекты сохраняются, ссылка обнуляется
- .cascade — связанные объекты удаляются вместе с родителем
- .noAction — ничего не происходит (будьте осторожны: можно получить «осиротевшие» записи в базе)
Есть один неочевидный момент: каскадное удаление корректно работает только при включённом автосохранении. Если вы отключили его (autosaveEnabled: false), каскадное удаление может не сработать. Это известная проблема SwiftData, и на неё стоит обратить внимание, если вы управляете сохранением вручную.
Многие ко многим (many-to-many)
Для связи «многие ко многим» всё ещё проще — используйте массивы с обеих сторон:
@Model
class Student {
var name: String
var courses: [Course]
init(name: String, courses: [Course] = []) {
self.name = name
self.courses = courses
}
}
@Model
class Course {
var title: String
var students: [Student]
init(title: String, students: [Student] = []) {
self.title = title
self.students = students
}
}
SwiftData сам создаёт промежуточную таблицу для хранения связи — вам не нужно об этом думать. Приятно, правда?
Миграция схемы данных
По мере развития приложения модель данных неизбежно меняется. SwiftData предлагает два уровня миграции: лёгкую (автоматическую) и ручную с использованием версий схемы.
Лёгкая миграция
SwiftData автоматически справляется с простыми изменениями:
- Добавление новых свойств с значениями по умолчанию
- Удаление свойств
- Переименование свойств (через
@Attribute(originalName:))
@Model
class Task {
var title: String
var isCompleted: Bool
// Переименовано свойство — SwiftData знает, откуда брать данные
@Attribute(originalName: "desc")
var notes: String
// Новое свойство с значением по умолчанию — безопасно добавлять
var priority: Int = 0
}
Версионированная миграция
Для более сложных изменений — преобразования типов, объединения полей — понадобятся VersionedSchema и SchemaMigrationPlan. Код получается довольно объёмным, но зато у вас полный контроль над процессом:
enum TaskSchemaV1: VersionedSchema {
static var versionIdentifier: Schema.Version = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Task.self] }
@Model
class Task {
var title: String
var isCompleted: Bool
init(title: String, isCompleted: Bool = false) {
self.title = title
self.isCompleted = isCompleted
}
}
}
enum TaskSchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Task.self] }
@Model
class Task {
var title: String
var isCompleted: Bool
var priority: Int
init(title: String, isCompleted: Bool = false, priority: Int = 0) {
self.title = title
self.isCompleted = isCompleted
self.priority = priority
}
}
}
enum TaskMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[TaskSchemaV1.self, TaskSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: TaskSchemaV1.self,
toVersion: TaskSchemaV2.self
) { context in
// Произвольная логика миграции
let tasks = try context.fetch(FetchDescriptor<TaskSchemaV1.Task>())
for task in tasks {
// Перенос данных или трансформация
}
try context.save()
}
}
Затем подключите план миграции к контейнеру:
let container = try ModelContainer(
for: TaskSchemaV2.Task.self,
migrationPlan: TaskMigrationPlan.self
)
Наследование моделей — новинка iOS 26
Одно из самых ожидаемых нововведений SwiftData в iOS 26 (анонсировано на WWDC 2025) — поддержка наследования классов. До iOS 26 модели SwiftData не могли наследоваться друг от друга, и приходилось дублировать общие свойства. Это реально раздражало.
Как это работает
Теперь можно создавать полноценные иерархии моделей:
@Model
class Event {
var title: String
var date: Date
var location: String
init(title: String, date: Date, location: String) {
self.title = title
self.date = date
self.location = location
}
}
@available(iOS 26, *)
@Model
class WorkEvent: Event {
var meetingLink: String
var attendees: [String]
init(title: String, date: Date, location: String,
meetingLink: String, attendees: [String] = []) {
self.meetingLink = meetingLink
self.attendees = attendees
super.init(title: title, date: date, location: location)
}
}
@available(iOS 26, *)
@Model
class SocialEvent: Event {
var theme: String
init(title: String, date: Date, location: String, theme: String) {
self.theme = theme
super.init(title: title, date: date, location: location)
}
}
Запросы с наследованием
SwiftData поддерживает как «широкие» запросы по базовому классу (вернут все типы событий), так и «узкие» — по конкретному подклассу:
// Все события (и WorkEvent, и SocialEvent)
@Query private var allEvents: [Event]
// Только рабочие события — через предикат с оператором is
@Query(filter: #Predicate<Event> { $0 is WorkEvent })
private var workEvents: [Event]
Учтите: наследование моделей доступно только с iOS 26, поэтому все использования нового API нужно оборачивать в @available(iOS 26, *).
Синхронизация с CloudKit
Одна из сильных сторон SwiftData — встроенная поддержка синхронизации данных через iCloud с помощью CloudKit. Настройка на первый взгляд проста, но есть серьёзные ограничения, о которых лучше узнать до релиза, а не после.
Настройка
- Добавьте capability iCloud в настройках таргета Xcode
- Выберите CloudKit и создайте (или выберите существующий) контейнер
- Добавьте capability Background Modes и включите Remote Notifications
- Укажите
cloudKitDatabase: .automaticв конфигурации контейнера
let config = ModelConfiguration(
cloudKitDatabase: .automatic
)
let container = try ModelContainer(
for: Task.self, Category.self,
configurations: config
)
После этого SwiftData будет автоматически синхронизировать данные между устройствами пользователя. Звучит волшебно, но дьявол, как всегда, в деталях.
Обязательные правила для CloudKit-совместимых моделей
CloudKit накладывает жёсткие ограничения на модели данных. И вот что особенно неприятно — нарушение любого из них приводит к молчаливому отказу синхронизации без каких-либо ошибок в консоли:
- Нельзя использовать
@Attribute(.unique)— CloudKit не поддерживает атомарные проверки уникальности между устройствами - Все свойства должны быть опциональными или иметь значения по умолчанию
- Все связи (relationships) должны быть опциональными
- Нельзя использовать упорядоченные связи
// Правильно — совместимо с CloudKit
@Model
class CloudTask {
var title: String = ""
var notes: String?
var isCompleted: Bool = false
var category: Category? // опциональная связь
init(title: String = "", notes: String? = nil, isCompleted: Bool = false) {
self.title = title
self.notes = notes
self.isCompleted = isCompleted
}
}
Ограничения миграции при CloudKit
После публикации приложения в App Store с CloudKit-синхронизацией изменения схемы становятся крайне ограниченными. И это не шутка:
- Нельзя удалять сущности или атрибуты — даже неиспользуемые
- Нельзя переименовывать — CloudKit воспримет это как удаление + создание нового
- Нельзя менять тип атрибута (например,
StringнаInt) - Можно только добавлять новые сущности и атрибуты с значениями по умолчанию
Поэтому тщательно продумайте схему данных до первого релиза. Переделывать потом будет больно.
Особенности поведения синхронизации
Важно понимать: синхронизация SwiftData через CloudKit — это не реалтайм-база данных типа Firebase. Apple динамически регулирует частоту синхронизации в зависимости от состояния сети, заряда батареи и настроек пользователя. По поведению это ближе к тому, как синхронизируются «Заметки» или «Фото».
Тестировать синхронизацию лучше на реальных устройствах — в симуляторе она работает нестабильно. И ещё один подводный камень: если на macOS синхронизация работает в debug-режиме, но отказывает в TestFlight или App Store — проверьте, что CloudKit.framework добавлен в «Frameworks, Libraries, and Embedded Content». На это можно потратить несколько часов отладки (спрашивайте, откуда знаю).
SwiftData vs Core Data: когда что использовать
Выбор между SwiftData и Core Data в 2026 году зависит от конкретного проекта.
Выбирайте SwiftData, если:
- Начинаете новый проект на SwiftUI с таргетом iOS 17+
- Модель данных относительно простая
- Хотите минимум шаблонного кода и максимум интеграции со SwiftUI
- Нужна синхронизация с iCloud без долгой настройки
Оставайтесь на Core Data, если:
- Поддерживаете приложение на UIKit с уже работающим Core Data
- Нужны продвинутые возможности:
NSFetchedResultsController, составные предикаты, производные атрибуты - Требуется поддержка iOS ниже 17
- Работаете со сложной моделью данных с множеством связей
- Критична максимальная производительность на больших объёмах данных
Гибридный подход
Поскольку SwiftData построен поверх Core Data, оба фреймворка могут мирно сосуществовать в одном проекте. Стратегия «Core Data для существующего, SwiftData для нового» позволяет мигрировать постепенно, без лишних рисков. А если вдруг окажется, что SwiftData не хватает какой-то возможности — переход обратно на Core Data пройдёт без потери данных, потому что под капотом это одна и та же база.
Практический пример: приложение «Менеджер задач»
Давайте соберём всё вместе и посмотрим, как выглядит структура реального приложения:
import SwiftUI
import SwiftData
// MARK: - Модели
@Model
class TaskCategory {
var name: String
var colorHex: String
@Relationship(deleteRule: .cascade, inverse: \TodoItem.category)
var items: [TodoItem]
init(name: String, colorHex: String = "#007AFF") {
self.name = name
self.colorHex = colorHex
self.items = []
}
}
@Model
class TodoItem {
var title: String
var notes: String
var isCompleted: Bool
var dueDate: Date?
var priority: Int
var createdAt: Date
var category: TaskCategory?
init(title: String, notes: String = "", priority: Int = 0,
dueDate: Date? = nil, category: TaskCategory? = nil) {
self.title = title
self.notes = notes
self.isCompleted = false
self.priority = priority
self.dueDate = dueDate
self.createdAt = Date()
self.category = category
}
}
// MARK: - Главный экран
struct TodoListView: View {
@Environment(\.modelContext) private var context
@State private var searchText = ""
var body: some View {
NavigationStack {
FilteredTodoList(searchText: searchText)
.navigationTitle("Мои задачи")
.searchable(text: $searchText, prompt: "Поиск задач")
}
}
}
// MARK: - Отфильтрованный список
struct FilteredTodoList: View {
@Environment(\.modelContext) private var context
@Query private var items: [TodoItem]
init(searchText: String) {
let predicate = #Predicate<TodoItem> { item in
searchText.isEmpty || item.title.localizedStandardContains(searchText)
}
_items = Query(
filter: predicate,
sort: [
SortDescriptor(\TodoItem.isCompleted),
SortDescriptor(\TodoItem.priority, order: .reverse),
SortDescriptor(\TodoItem.createdAt, order: .reverse)
]
)
}
var body: some View {
List {
ForEach(items) { item in
TodoRowView(item: item)
}
.onDelete { indexSet in
for index in indexSet {
context.delete(items[index])
}
}
}
}
}
Этот пример демонстрирует все ключевые паттерны: модели со связями, динамическую фильтрацию, множественную сортировку и удаление свайпом. Добавьте .modelContainer(for: [TaskCategory.self, TodoItem.self]) к корневому view — и приложение готово.
Часто задаваемые вопросы (FAQ)
Можно ли использовать SwiftData без SwiftUI?
Да, SwiftData работает в UIKit-приложениях и даже в серверном коде на Swift. Но макрос @Query доступен только внутри SwiftUI-view. Для UIKit используйте FetchDescriptor и ручную выборку через modelContext.fetch(). Автоматическое обновление UI придётся реализовывать самостоятельно — это, конечно, менее удобно.
Поддерживает ли SwiftData многопоточность?
ModelContext привязан к одному потоку (обычно @MainActor). Для фоновых операций создавайте отдельный контекст через ModelContainer и оборачивайте работу с ним в ModelActor. Это обеспечит безопасную конкурентную работу с данными.
Как протестировать код со SwiftData?
Используйте конфигурацию с isStoredInMemoryOnly: true для тестового контейнера. Данные живут только в памяти, и каждый тест стартует с чистого состояния. В SwiftUI-превью тоже рекомендуется in-memory контейнер — иначе превью будут работать заметно медленнее.
Какая минимальная версия iOS поддерживает SwiftData?
SwiftData требует iOS 17, macOS 14 (Sonoma), watchOS 10 или tvOS 17 и выше. Наследование моделей доступно начиная с iOS 26. Если нужна поддержка более ранних версий — Core Data или гибридный подход.
Как отлаживать запросы SwiftData?
Добавьте аргумент запуска -com.apple.CoreData.SQLDebug 1 в схеме Xcode (Product → Scheme → Edit Scheme → Arguments). В консоли появятся все SQL-запросы, которые генерирует SwiftData — это незаменимо для отладки проблем с производительностью. Для диагностики CloudKit-синхронизации используйте CloudKit Console на сайте Apple Developer.