SwiftData от основите до продукция: Пълно ръководство за Swift разработчици

Пълно ръководство за SwiftData — от дефиниране на модели с @Model и CRUD операции, през заявки с #Predicate и миграция, до наследяване на класове в iOS 26 и най-добрите практики за продукция.

SwiftData от основите до продукция: Пълно ръководство за модерния слой за данни в Swift

С пускането на iOS 17, Apple направи нещо, което Swift разработчиците чакаха с години — представи SwiftData, изцяло нов фреймуърк за съхранение на данни. И не, той не е просто обвивка около Core Data. SwiftData е преосмислен от нулата — проектиран да се интегрира безпроблемно със SwiftUI и да използва пълната мощ на макросите в Swift 5.9.

Честно казано, ако сте работили с Core Data, знаете болките. XML файлове за модела на данни, NSManagedObject подкласове, NSFetchRequest с неговите предикати базирани на стрингове, NSPersistentContainer конфигурация... и цялата церемониалност, която идва с това.

SwiftData елиминира всичко това. Вместо да дефинирате модел в графичен редактор, просто пишете Swift класове с макрото @Model. Вместо NSFetchRequest, използвате #Predicate — типово безопасен и проверяван от компилатора. Вместо NSPersistentContainer, имате ModelContainer, който се конфигурира с един ред код. Доста голяма разлика, нали?

В това ръководство ще разгледаме SwiftData изчерпателно — от базовите концепции и дефиниране на модели, през CRUD операции и заявки, до миграция на схемата, новата поддръжка на наследяване на класове в iOS 26 и най-добрите практики за продукционен код. Ако сте iOS разработчик, който иска да изгради стабилен слой за данни — това ръководство е точно за вас.

Основни компоненти на SwiftData

Преди да се потопим в кода, нека разберем четирите основни компонента, които изграждат архитектурата на SwiftData.

@Model — Макрото за дефиниране на модели

Макрото @Model е входната точка към SwiftData. Когато го приложите върху клас, компилаторът автоматично генерира целия необходим код за съхранение — подобно на това, което преди правехте ръчно с NSManagedObject. Класът автоматично получава съответствие с протокола PersistentModel и всички свойства стават автоматично следени за промени. С две думи — магия зад кулисите.

ModelContainer — Контейнерът за данни

ModelContainer е отговорен за цялата конфигурация на хранилището — къде се записват данните, коя схема се използва, дали хранилището е в паметта или на диска. Мислете за него като значително по-опростен наследник на NSPersistentContainer.

ModelContext — Контекстът за работа с данни

ModelContext е средата, в която извършвате всички операции с данни — създаване, четене, обновяване и изтриване. Той следи промените и ги записва в хранилището. В SwiftUI получавате достъп до него чрез @Environment(\.modelContext).

@Query — Декларативни заявки в SwiftUI

Property wrapper-ът @Query замества @FetchRequest от Core Data. Той автоматично извлича данни от хранилището и поддържа изгледа актуален при промени. Сортиране, филтриране, анимации — всичко декларативно и с минимален код.

Дефиниране на модели

Тук нещата стават наистина приятни. Дефинирането на модели в SwiftData е драматично по-просто от Core Data. Няма .xcdatamodeld файл, няма графичен редактор, няма генериране на подкласове. Просто пишете обикновени Swift класове и добавяте макрото @Model.

Базов модел с @Model

import SwiftData

@Model
final class Task {
    var title: String
    var isCompleted: Bool
    var createdAt: Date
    @Relationship(deleteRule: .cascade) var subtasks: [Subtask]

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = Date()
    }
}

@Model
final class Subtask {
    var title: String
    var isCompleted: Bool
    var task: Task?

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
    }
}

Забележете колко чист е този код. Няма NSManagedObject, няма @NSManaged, няма optional типове навсякъде. Просто стандартни Swift свойства с ясни типове. Макрото @Model автоматично прави всяко съхраняемо свойство наблюдаемо и го регистрира в схемата на SwiftData. Ако идвате от Core Data, вероятно вече се чувствате облекчени.

Работа с @Attribute

Макрото @Attribute ви дава фин контрол над начина, по който отделните свойства се съхраняват. Можете да зададете уникалност, да изключите свойства от записване или да конфигурирате трансформации. Ето пример:

import SwiftData

@Model
final class User {
    // Уникално свойство — не може да има двама потребители с един и същ имейл
    @Attribute(.unique) var email: String

    var name: String
    var bio: String

    // Изключено от запис в хранилището — изчислява се динамично
    @Attribute(.ephemeral) var isOnline: Bool

    // Съхранение на големи данни като външен файл
    @Attribute(.externalStorage) var profileImage: Data?

    // Оригиналното име на свойството в базата данни
    @Attribute(originalName: "user_name") var username: String

    init(email: String, name: String, username: String, bio: String = "") {
        self.email = email
        self.name = name
        self.username = username
        self.bio = bio
        self.isOnline = false
    }
}

Атрибутът .unique е изключително полезен — той гарантира целостта на данните на ниво хранилище. Ако се опитате да вмъкнете обект с дублиран уникален атрибут, SwiftData ще извърши upsert операция — ще обнови съществуващия запис вместо да създаде дубликат. А .externalStorage? Той казва на SwiftData да съхрани данните като външен файл, което е идеално за снимки и други тежки блокове данни (и реално подобрява производителността на заявките).

Поддържани типове данни

SwiftData поддържа широк набор от типове данни без необходимост от трансформатори:

  • Примитивни типове: String, Int, Double, Float, Bool
  • Дати и данни: Date, Data
  • URL и UUID: URL, UUID
  • Колекции: Array, Dictionary, Set (с поддържани елементи)
  • Codable стойности — автоматично се сериализират
  • enum типове, които са Codable и RawRepresentable
  • Опционални версии на всички горепосочени типове
// Пример с enum и Codable структура
enum Priority: Int, Codable {
    case low = 0
    case medium = 1
    case high = 2
    case critical = 3
}

struct Address: Codable {
    var street: String
    var city: String
    var postalCode: String
}

@Model
final class Project {
    var name: String
    var priority: Priority
    var tags: [String]
    var metadata: [String: String]
    var address: Address?

    init(name: String, priority: Priority = .medium) {
        self.name = name
        self.priority = priority
        self.tags = []
        self.metadata = [:]
    }
}

Настройка на ModelContainer

Преди да можете да работите с данни, трябва да конфигурирате ModelContainer. Най-простият начин в SwiftUI приложение е чрез модификатора .modelContainer:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self, Subtask.self])
    }
}

Този един ред код създава хранилище на диска, регистрира посочените модели и автоматично инжектира ModelContext в средата на SwiftUI. Впечатляващо, нали? Но за продукционни приложения обикновено ще ви трябва повече контрол:

@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([
            Task.self,
            Subtask.self,
            User.self,
            Project.self
        ])

        let config = ModelConfiguration(
            "MainStore",
            schema: schema,
            isStoredInMemoryOnly: false,
            allowsSave: true
        )

        do {
            container = try ModelContainer(
                for: schema,
                configurations: [config]
            )
        } catch {
            fatalError("Неуспешно създаване на ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

За тестове и визуализации в Xcode Previews е изключително удобно да използвате хранилище в паметта. Лично аз го правя постоянно — спестява страшно много време:

// Хранилище в паметта за тестове и визуализации
let previewContainer: ModelContainer = {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(
        for: Task.self, Subtask.self,
        configurations: config
    )

    // Зареждане на примерни данни
    let context = container.mainContext
    let sampleTask = Task(title: "Примерна задача")
    context.insert(sampleTask)

    return container
}()

#Preview {
    TaskListView()
        .modelContainer(previewContainer)
}

CRUD операции

CRUD операциите в SwiftData са интуитивни и директни. Няма нужда от специални методи за създаване на обекти или сложни fetch заявки за четене. Нека разгледаме всяка операция.

Създаване (Create)

struct AddTaskView: View {
    @Environment(\.modelContext) private var context
    @State private var title = ""
    @State private var priority: Priority = .medium

    var body: some View {
        Form {
            TextField("Заглавие на задачата", text: $title)
            Picker("Приоритет", selection: $priority) {
                Text("Нисък").tag(Priority.low)
                Text("Среден").tag(Priority.medium)
                Text("Висок").tag(Priority.high)
            }

            Button("Създай задача") {
                // Създаване на нов обект
                let newTask = Task(title: title)

                // Вмъкване в контекста
                context.insert(newTask)

                // Данните се записват автоматично, но може и ръчно
                try? context.save()

                title = ""
            }
        }
    }
}

Важно е да се знае, че SwiftData поддържа автоматичен запис. При стандартна конфигурация контекстът записва промените автоматично в определени моменти — например при излизане от преден план. Въпреки това, за критични данни си струва да извикате context.save() ръчно. По-добре да сте сигурни.

Четене (Read)

За четене на данни в SwiftUI изгледи използвайте @Query:

struct TaskListView: View {
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]
    @Environment(\.modelContext) private var context

    var body: some View {
        NavigationStack {
            List(tasks) { task in
                TaskRow(task: task)
            }
            .navigationTitle("Задачи")
            .overlay {
                if tasks.isEmpty {
                    ContentUnavailableView(
                        "Няма задачи",
                        systemImage: "checklist",
                        description: Text("Създайте първата си задача")
                    )
                }
            }
        }
    }
}

А за програмно четене извън SwiftUI изгледи — FetchDescriptor:

// Извличане на всички незавършени задачи
func fetchPendingTasks(context: ModelContext) throws -> [Task] {
    let descriptor = FetchDescriptor(
        predicate: #Predicate { !$0.isCompleted },
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    return try context.fetch(descriptor)
}

// Извличане с лимит — само първите 10 резултата
func fetchRecentTasks(context: ModelContext) throws -> [Task] {
    var descriptor = FetchDescriptor(
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = 10
    return try context.fetch(descriptor)
}

Обновяване (Update)

Обновяването в SwiftData е може би най-приятната изненада — просто променяте свойствата на обекта и толкова. SwiftData автоматично проследява промените и ги синхронизира с хранилището. Без допълнителни стъпки.

struct TaskRow: View {
    let task: Task
    @Environment(\.modelContext) private var context

    var body: some View {
        HStack {
            Button {
                // Обновяването е просто присвояване на нова стойност
                task.isCompleted.toggle()
                // Промяната се проследява автоматично
            } label: {
                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
            }

            VStack(alignment: .leading) {
                Text(task.title)
                    .strikethrough(task.isCompleted)
                Text(task.createdAt, style: .date)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Изтриване (Delete)

struct TaskListView: View {
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]
    @Environment(\.modelContext) private var context

    var body: some View {
        List {
            ForEach(tasks) { task in
                TaskRow(task: task)
            }
            .onDelete(perform: deleteTasks)
        }
    }

    private func deleteTasks(at offsets: IndexSet) {
        for index in offsets {
            // Изтриване на обект от контекста
            context.delete(tasks[index])
        }
        // Записване на промените
        try? context.save()
    }
}

// Групово изтриване на всички завършени задачи
func deleteCompletedTasks(context: ModelContext) throws {
    try context.delete(
        model: Task.self,
        where: #Predicate { $0.isCompleted }
    )
    try context.save()
}

Методът context.delete(model:where:) заслужава специално внимание — той работи директно на ниво хранилище, без да зарежда обектите в паметта. Това е значително по-ефективно от зареждане и изтриване един по един, особено когато имате стотици или хиляди записи.

Заявки и предикати

Системата за заявки в SwiftData е едно от най-значителните подобрения спрямо Core Data. И тук наистина се вижда колко добре е обмислен фреймуъркът.

Макрото #Predicate

#Predicate е макро, което преобразува Swift изрази в предикати, които SwiftData може да изпълни на ниво хранилище. Компилаторът проверява типовете и имената на свойствата, така че грешки от рода на грешно изписано име на свойство просто не могат да се случат. Край на грешките с NSPredicate базирани на стрингове (и добре, че е така).

// Търсене по текст
let searchTerm = "groceries"
let predicate = #Predicate { task in
    task.title.localizedStandardContains(searchTerm) &&
    !task.isCompleted
}

let descriptor = FetchDescriptor(
    predicate: predicate,
    sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let results = try context.fetch(descriptor)

Комбиниране на предикати

// Сложен предикат с множество условия
func buildTaskPredicate(
    searchText: String,
    showCompleted: Bool,
    minimumPriority: Priority
) -> Predicate {
    let minPriorityRaw = minimumPriority.rawValue

    return #Predicate { task in
        // Филтриране по текст (ако има въведен)
        (searchText.isEmpty || task.title.localizedStandardContains(searchText))
        &&
        // Филтриране по статус на завършеност
        (showCompleted || !task.isCompleted)
    }
}

// Използване на предиката
let predicate = buildTaskPredicate(
    searchText: "пазаруване",
    showCompleted: false,
    minimumPriority: .medium
)
var descriptor = FetchDescriptor(predicate: predicate)
descriptor.sortBy = [
    SortDescriptor(\Task.createdAt, order: .reverse)
]
let filteredTasks = try context.fetch(descriptor)

Динамични заявки с @Query

В SwiftUI изгледите можете да направите @Query динамичен чрез инициализатора на изгледа. Този подход е полезен, когато параметрите на заявката зависят от потребителски вход:

struct FilteredTasksView: View {
    @Query private var tasks: [Task]

    init(showCompleted: Bool = false, searchText: String = "") {
        let predicate = #Predicate { task in
            showCompleted || !task.isCompleted
        }

        _tasks = Query(
            filter: predicate,
            sort: \Task.createdAt,
            order: .reverse
        )
    }

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}

Броене на резултати и агрегиране

// Броене без зареждане на обекти в паметта
func countPendingTasks(context: ModelContext) throws -> Int {
    let descriptor = FetchDescriptor(
        predicate: #Predicate { !$0.isCompleted }
    )
    return try context.fetchCount(descriptor)
}

// Проверка дали съществуват резултати
func hasOverdueTasks(context: ModelContext, deadline: Date) throws -> Bool {
    var descriptor = FetchDescriptor(
        predicate: #Predicate { task in
            !task.isCompleted && task.createdAt < deadline
        }
    )
    descriptor.fetchLimit = 1
    let results = try context.fetch(descriptor)
    return !results.isEmpty
}

Релации между модели

Релациите в SwiftData се дефинират чрез макрото @Relationship и обикновени Swift свойства. SwiftData поддържа един-към-много и много-към-много релации, както и инверсни релации, които автоматично се синхронизират. Нека видим как работи на практика.

Един-към-много релация

@Model
final class Author {
    var name: String
    var bio: String

    // Един автор може да има много книги
    // При изтриване на автора — всички негови книги също се изтриват
    @Relationship(deleteRule: .cascade, inverse: \Book.author)
    var books: [Book]

    init(name: String, bio: String = "") {
        self.name = name
        self.bio = bio
        self.books = []
    }
}

@Model
final class Book {
    var title: String
    var publishedDate: Date
    var isbn: String

    // Всяка книга принадлежи на един автор
    var author: Author?

    init(title: String, isbn: String, publishedDate: Date = Date()) {
        self.title = title
        self.isbn = isbn
        self.publishedDate = publishedDate
    }
}

Параметърът deleteRule определя какво се случва с релацираните обекти при изтриване. Ето опциите:

  • .cascade — изтрива и всички свързани обекти (подходящо за силни зависимости)
  • .nullify — задава релацията на nil, без да изтрива свързаните обекти (по подразбиране)
  • .deny — предотвратява изтриването, ако има свързани обекти
  • .noAction — не прави нищо (внимавайте с тази — може да остави осиротели обекти)

Много-към-много релация

@Model
final class Student {
    var name: String
    var studentId: String

    // Всеки студент може да посещава много курсове
    @Relationship(inverse: \Course.students)
    var courses: [Course]

    init(name: String, studentId: String) {
        self.name = name
        self.studentId = studentId
        self.courses = []
    }
}

@Model
final class Course {
    var title: String
    var code: String
    var credits: Int

    // Всеки курс може да има много студенти
    var students: [Student]

    init(title: String, code: String, credits: Int) {
        self.title = title
        self.code = code
        self.credits = credits
        self.students = []
    }
}

// Работа с много-към-много релация
func enrollStudent(student: Student, in course: Course) {
    student.courses.append(course)
    // Инверсната релация автоматично се обновява
    // course.students вече съдържа student
}

Самореференчиращи се релации

SwiftData поддържа и самореференчиращи се релации — изключително полезни за йерархични структури като категории или организационни диаграми:

@Model
final class Category {
    var name: String

    // Родителска категория
    var parent: Category?

    // Дъщерни категории
    @Relationship(deleteRule: .cascade, inverse: \Category.parent)
    var children: [Category]

    init(name: String, parent: Category? = nil) {
        self.name = name
        self.parent = parent
        self.children = []
    }

    // Изчисляемо свойство за пълния път
    var fullPath: String {
        if let parent {
            return "\(parent.fullPath) > \(name)"
        }
        return name
    }
}

Миграция на схемата

В реалния свят моделът на данни не остава статичен. Добавяте нови свойства, преименувате полета, реорганизирате релации. Това е неизбежно. SwiftData предоставя структуриран подход за управление на тези промени чрез VersionedSchema и SchemaMigrationPlan.

Дефиниране на версии на схемата

// Версия 1 — оригиналният модел
enum TaskSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Task.self]
    }

    @Model
    final class Task {
        var title: String
        var isCompleted: Bool
        init(title: String) {
            self.title = title
            self.isCompleted = false
        }
    }
}

// Версия 2 — добавяне на приоритет
enum TaskSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Task.self]
    }

    @Model
    final class Task {
        var title: String
        var isCompleted: Bool
        var priority: Int
        init(title: String, priority: Int = 0) {
            self.title = title
            self.isCompleted = false
            self.priority = priority
        }
    }
}

// Версия 3 — добавяне на дата на краен срок и бележки
enum TaskSchemaV3: VersionedSchema {
    static var versionIdentifier = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Task.self]
    }

    @Model
    final class Task {
        var title: String
        var isCompleted: Bool
        var priority: Int
        var dueDate: Date?
        var notes: String
        init(title: String, priority: Int = 0) {
            self.title = title
            self.isCompleted = false
            self.priority = priority
            self.notes = ""
        }
    }
}

Дефиниране на план за миграция

enum TaskMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [TaskSchemaV1.self, TaskSchemaV2.self, TaskSchemaV3.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3]
    }

    // Лека миграция — достатъчна когато само добавяте нови свойства
    // с подразбиращи се стойности
    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: TaskSchemaV1.self,
        toVersion: TaskSchemaV2.self
    )

    // Персонализирана миграция — когато трябва да трансформирате данни
    static let migrateV2toV3 = MigrationStage.custom(
        fromVersion: TaskSchemaV2.self,
        toVersion: TaskSchemaV3.self,
        willMigrate: { context in
            // Предварителна обработка преди миграция, ако е необходимо
        },
        didMigrate: { context in
            // След миграцията — задаване на стойности по подразбиране
            let tasks = try context.fetch(FetchDescriptor())
            for task in tasks {
                if task.priority > 1 {
                    // Задачи с висок приоритет получават краен срок след 7 дни
                    task.dueDate = Calendar.current.date(
                        byAdding: .day, value: 7, to: Date()
                    )
                }
            }
            try context.save()
        }
    )
}

Прилагане на плана за миграция

@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(
                for: TaskSchemaV3.Task.self,
                migrationPlan: TaskMigrationPlan.self
            )
        } catch {
            fatalError("Неуспешна миграция: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

Леката миграция (.lightweight) е подходяща за прости промени — добавяне на нови свойства с подразбиращи се стойности, преименуване (с @Attribute(originalName:)) и добавяне или премахване на индекси. Персонализираната миграция (.custom) влиза в действие когато трябва да трансформирате данни — например да разделите едно поле на две, да обедините записи или да изчислите нови стойности. В моя опит повечето миграции могат да минат с леката версия, но е добре да познавате и двата варианта.

Ново в iOS 26: Наследяване на модели

Тук е може би най-вълнуващата част. Една от най-дългоочакваните функции идва с iOS 26 — поддръжка на наследяване на класове с @Model. Преди iOS 26, всеки @Model клас трябваше да бъде final и нямаше начин да наследява друг @Model клас. Това беше сериозно ограничение, което принуждаваше разработчиците да прибягват до композиция или протоколи за случаи, в които наследяването е естественият избор.

Базов клас и подкласове

// Базов клас за събитие — вече не е задължително да бъде final
@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
final class SocialEvent: Event {
    var attendees: [String]
    var dressCode: String

    init(title: String, date: Date, attendees: [String], dressCode: String = "Casual") {
        self.attendees = attendees
        self.dressCode = dressCode
        super.init(title: title, date: date)
    }
}

// Работно събитие
@available(iOS 26, *)
@Model
final class WorkEvent: Event {
    var organizer: String
    var isRequired: Bool
    var meetingLink: String?

    init(title: String, date: Date, organizer: String, isRequired: Bool = false) {
        self.organizer = organizer
        self.isRequired = isRequired
        super.init(title: title, date: date)
    }
}

// Спортно събитие
@available(iOS 26, *)
@Model
final class SportEvent: Event {
    var sport: String
    var teams: [String]
    var venue: String

    init(title: String, date: Date, sport: String, teams: [String], venue: String) {
        self.sport = sport
        self.teams = teams
        self.venue = venue
        super.init(title: title, date: date, location: venue)
    }
}

Полиморфни заявки

Едно от най-мощните предимства на наследяването е възможността да правите заявки към базовия клас и да получите всички подтипове наведнъж:

@available(iOS 26, *)
struct EventCalendarView: View {
    // Заявка към базовия клас — връща ВСИЧКИ видове събития
    @Query(sort: \Event.date) private var allEvents: [Event]

    // Заявка само към работни събития
    @Query(
        filter: #Predicate { $0.isRequired },
        sort: \WorkEvent.date
    )
    private var requiredMeetings: [WorkEvent]

    var body: some View {
        List {
            Section("Всички събития") {
                ForEach(allEvents) { event in
                    EventRow(event: event)
                }
            }

            Section("Задължителни срещи") {
                ForEach(requiredMeetings) { meeting in
                    WorkEventRow(event: meeting)
                }
            }
        }
    }
}

@available(iOS 26, *)
struct EventRow: View {
    let event: Event

    var body: some View {
        VStack(alignment: .leading) {
            Text(event.title)
                .font(.headline)
            Text(event.date, style: .date)
                .font(.subheadline)

            // Проверка на типа за специфично визуализиране
            if let social = event as? SocialEvent {
                Text("\(social.attendees.count) участници")
                    .font(.caption)
                    .foregroundStyle(.blue)
            } else if let work = event as? WorkEvent {
                Label(
                    work.isRequired ? "Задължително" : "Незадължително",
                    systemImage: work.isRequired ? "exclamationmark.circle" : "info.circle"
                )
                .font(.caption)
            } else if let sport = event as? SportEvent {
                Text(sport.sport)
                    .font(.caption)
                    .foregroundStyle(.green)
            }
        }
    }
}

Практически ползи от наследяването

Наследяването на модели решава няколко важни архитектурни проблема:

  • Полиморфизъм — работите с хетерогенни колекции от обекти чрез общ базов тип, без да жонглирате с enum-и или протоколни абстракции
  • Споделена логика — общите свойства и методи живеят в базовия клас, без дублиране
  • Едно хранилище — всички подтипове се съхраняват в една таблица с дискриминатор колона, което опростява заявките
  • Гъвкавост при заявки — извличате всички типове през базовия клас или само конкретен подтип
// Регистриране в ModelContainer — базовият клас е достатъчен
@available(iOS 26, *)
let container = try ModelContainer(
    for: Event.self  // Автоматично включва всички подкласове
)

// Програмно извличане на всички социални събития тази седмица
@available(iOS 26, *)
func fetchThisWeekSocialEvents(context: ModelContext) throws -> [SocialEvent] {
    let now = Date()
    let endOfWeek = Calendar.current.date(byAdding: .day, value: 7, to: now)!

    let descriptor = FetchDescriptor(
        predicate: #Predicate { event in
            event.date >= now && event.date <= endOfWeek
        },
        sortBy: [SortDescriptor(\.date)]
    )
    return try context.fetch(descriptor)
}

Имайте предвид, че наследяването в iOS 26 има някои ограничения. Не е добра идея йерархията да бъде прекалено дълбока — препоръчително е да се придържате към едно ниво (базов клас и директни подкласове). Промените в йерархията на наследяване също изискват внимателна миграция на схемата.

Оптимизация на производителността

SwiftData е проектиран да бъде ефективен по подразбиране, но в продукционни приложения с големи обеми данни се налага да обърнете допълнително внимание на производителността. Ето ключовите техники.

Ограничаване на извличаните данни

// Извличане само на нужните свойства
func fetchTaskTitles(context: ModelContext) throws -> [Task] {
    var descriptor = FetchDescriptor()

    // Зареждане само на заглавието и статуса — не зарежда подзадачи,
    // бележки и други тежки свойства
    descriptor.propertiesToFetch = [\.title, \.isCompleted]

    // Ограничаване на броя резултати
    descriptor.fetchLimit = 50

    // Пропускане на първите 20 резултата (пагинация)
    descriptor.fetchOffset = 20

    return try context.fetch(descriptor)
}

// Пагинация с пълна реализация
struct PaginatedTasksView: View {
    @State private var tasks: [Task] = []
    @State private var currentPage = 0
    @Environment(\.modelContext) private var context

    private let pageSize = 25

    var body: some View {
        List {
            ForEach(tasks) { task in
                TaskRow(task: task)
            }

            // Зареждане на следващата страница при достигане на края
            Button("Зареди още") {
                loadNextPage()
            }
        }
        .onAppear { loadNextPage() }
    }

    private func loadNextPage() {
        var descriptor = FetchDescriptor(
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        descriptor.fetchLimit = pageSize
        descriptor.fetchOffset = currentPage * pageSize

        if let newTasks = try? context.fetch(descriptor) {
            tasks.append(contentsOf: newTasks)
            currentPage += 1
        }
    }
}

Фонови контексти

За тежки операции — като импортиране на големи масиви от данни или сложни трансформации — използвайте фонови контексти. Главната нишка ви благодари:

actor DataImporter {
    let container: ModelContainer

    init(container: ModelContainer) {
        self.container = container
    }

    // Импортиране на данни във фонов контекст
    func importTasks(from data: [TaskDTO]) async throws {
        let context = ModelContext(container)

        // Деактивиране на автоматичния запис за по-добра производителност
        // при групови операции
        context.autosaveEnabled = false

        for (index, dto) in data.enumerated() {
            let task = Task(title: dto.title)
            task.isCompleted = dto.isCompleted
            context.insert(task)

            // Периодичен запис на всеки 100 обекта
            // за управление на паметта
            if index % 100 == 0 {
                try context.save()
            }
        }

        // Финален запис
        try context.save()
    }
}

// Използване в SwiftUI
struct ImportView: View {
    @Environment(\.modelContext) private var context
    @State private var isImporting = false

    var body: some View {
        Button("Импортирай данни") {
            isImporting = true

            Task {
                let importer = DataImporter(
                    container: context.container
                )

                do {
                    try await importer.importTasks(from: sampleData)
                    isImporting = false
                } catch {
                    print("Грешка при импортиране: \(error)")
                    isImporting = false
                }
            }
        }
        .disabled(isImporting)
    }
}

Ефективно използване на @Query

// Избягвайте заявки, които зареждат прекалено много данни

// ЛОШО — зарежда всички задачи, дори ако показвате само 10
@Query private var allTasks: [Task]

// ДОБРЕ — ограничете броя резултати
@Query(
    sort: \Task.createdAt,
    order: .reverse
) private var recentTasks: [Task]

// ОЩЕ ПО-ДОБРЕ — филтрирайте на ниво заявка
@Query(
    filter: #Predicate { !$0.isCompleted },
    sort: \Task.createdAt,
    order: .reverse
) private var pendingTasks: [Task]

Индексиране за по-бързи заявки

@Model
final class Article {
    // Индексиране на свойства, по които често филтрирате или сортирате
    @Attribute(.unique) var slug: String
    var title: String
    var content: String
    var publishedAt: Date
    var isPublished: Bool
    var category: String
    var viewCount: Int

    init(slug: String, title: String, content: String) {
        self.slug = slug
        self.title = title
        self.content = content
        self.publishedAt = Date()
        self.isPublished = false
        self.category = ""
        self.viewCount = 0
    }
}

// Индексите се дефинират в Schema — подобрява бързината на заявки
// по publishedAt и category
extension Article {
    static let schemaMetadata: Schema = Schema(
        [Article.self],
        version: Schema.Version(1, 0, 0)
    )
}

Най-добри практики за продукция

Изграждането на стабилен слой за данни изисква повече от познаване на API-то. Ето практики, които съм намерил за полезни в реални продукционни приложения.

Обработка на грешки

SwiftData операциите могат да хвърлят грешки и е критично важно да ги обработвате правилно. Не подценявайте тази стъпка:

enum DataError: LocalizedError {
    case saveFailed(underlying: Error)
    case fetchFailed(underlying: Error)
    case migrationFailed(underlying: Error)
    case containerCreationFailed(underlying: Error)

    var errorDescription: String? {
        switch self {
        case .saveFailed(let error):
            return "Неуспешен запис на данни: \(error.localizedDescription)"
        case .fetchFailed(let error):
            return "Неуспешно извличане на данни: \(error.localizedDescription)"
        case .migrationFailed(let error):
            return "Неуспешна миграция: \(error.localizedDescription)"
        case .containerCreationFailed(let error):
            return "Неуспешно създаване на хранилище: \(error.localizedDescription)"
        }
    }
}

// Централизиран слой за работа с данни
@Observable
final class DataService {
    private let container: ModelContainer

    var lastError: DataError?

    init() throws {
        do {
            container = try ModelContainer(for: Task.self, Subtask.self)
        } catch {
            throw DataError.containerCreationFailed(underlying: error)
        }
    }

    var mainContext: ModelContext {
        container.mainContext
    }

    func save() throws {
        do {
            try mainContext.save()
        } catch {
            let dataError = DataError.saveFailed(underlying: error)
            lastError = dataError
            throw dataError
        }
    }

    func fetch(
        _ descriptor: FetchDescriptor
    ) throws -> [T] {
        do {
            return try mainContext.fetch(descriptor)
        } catch {
            let dataError = DataError.fetchFailed(underlying: error)
            lastError = dataError
            throw dataError
        }
    }
}

Архитектурни шаблони

За по-големи приложения е добра практика да изолирате слоя за данни зад абстракция. Това улеснява тестването и позволява смяна на имплементацията без промени в бизнес логиката. Звучи като допълнителна работа, но вярвайте ми — оправдава се:

// Протокол за хранилище — дефинира интерфейса за работа с данни
protocol TaskRepository {
    func fetchAll() async throws -> [Task]
    func fetchPending() async throws -> [Task]
    func create(title: String) async throws -> Task
    func update(_ task: Task) async throws
    func delete(_ task: Task) async throws
    func deleteCompleted() async throws
}

// Конкретна имплементация с SwiftData
final class SwiftDataTaskRepository: TaskRepository {
    private let context: ModelContext

    init(context: ModelContext) {
        self.context = context
    }

    func fetchAll() async throws -> [Task] {
        let descriptor = FetchDescriptor(
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        return try context.fetch(descriptor)
    }

    func fetchPending() async throws -> [Task] {
        let descriptor = FetchDescriptor(
            predicate: #Predicate { !$0.isCompleted },
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        return try context.fetch(descriptor)
    }

    func create(title: String) async throws -> Task {
        let task = Task(title: title)
        context.insert(task)
        try context.save()
        return task
    }

    func update(_ task: Task) async throws {
        // Промените се проследяват автоматично
        try context.save()
    }

    func delete(_ task: Task) async throws {
        context.delete(task)
        try context.save()
    }

    func deleteCompleted() async throws {
        try context.delete(
            model: Task.self,
            where: #Predicate { $0.isCompleted }
        )
        try context.save()
    }
}

// Мок имплементация за тестове
final class MockTaskRepository: TaskRepository {
    var tasks: [Task] = []
    var shouldThrow = false

    func fetchAll() async throws -> [Task] {
        if shouldThrow { throw DataError.fetchFailed(underlying: NSError()) }
        return tasks
    }

    func fetchPending() async throws -> [Task] {
        return tasks.filter { !$0.isCompleted }
    }

    func create(title: String) async throws -> Task {
        let task = Task(title: title)
        tasks.append(task)
        return task
    }

    func update(_ task: Task) async throws {}

    func delete(_ task: Task) async throws {
        tasks.removeAll { $0.id == task.id }
    }

    func deleteCompleted() async throws {
        tasks.removeAll { $0.isCompleted }
    }
}

Тестване на SwiftData

За unit тестове използвайте in-memory контейнер — гарантира изолирани и бързи тестове:

import Testing
import SwiftData

@Suite("Тестове за Task модела")
struct TaskModelTests {

    let container: ModelContainer
    let context: ModelContext

    init() throws {
        // In-memory контейнер за тестове — няма запис на диска
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try ModelContainer(
            for: Task.self, Subtask.self,
            configurations: config
        )
        context = container.mainContext
    }

    @Test("Създаване на задача с правилни начални стойности")
    func createTask() throws {
        let task = Task(title: "Тестова задача")
        context.insert(task)
        try context.save()

        let descriptor = FetchDescriptor()
        let tasks = try context.fetch(descriptor)

        #expect(tasks.count == 1)
        #expect(tasks.first?.title == "Тестова задача")
        #expect(tasks.first?.isCompleted == false)
    }

    @Test("Изтриване на завършени задачи")
    func deleteCompletedTasks() throws {
        // Подготовка
        let task1 = Task(title: "Завършена")
        task1.isCompleted = true
        let task2 = Task(title: "Незавършена")

        context.insert(task1)
        context.insert(task2)
        try context.save()

        // Действие
        try context.delete(
            model: Task.self,
            where: #Predicate { $0.isCompleted }
        )
        try context.save()

        // Проверка
        let remaining = try context.fetch(FetchDescriptor())
        #expect(remaining.count == 1)
        #expect(remaining.first?.title == "Незавършена")
    }

    @Test("Каскадно изтриване на подзадачи")
    func cascadeDeleteSubtasks() throws {
        let task = Task(title: "Главна задача")
        let subtask1 = Subtask(title: "Подзадача 1")
        let subtask2 = Subtask(title: "Подзадача 2")
        task.subtasks = [subtask1, subtask2]

        context.insert(task)
        try context.save()

        // Изтриване на главната задача
        context.delete(task)
        try context.save()

        // Подзадачите трябва да са изтрити заради каскадното правило
        let subtasks = try context.fetch(FetchDescriptor())
        #expect(subtasks.isEmpty)
    }

    @Test("Търсене с предикат")
    func searchWithPredicate() throws {
        let task1 = Task(title: "Купи хляб")
        let task2 = Task(title: "Купи мляко")
        let task3 = Task(title: "Пиши код")

        context.insert(task1)
        context.insert(task2)
        context.insert(task3)
        try context.save()

        let searchTerm = "Купи"
        let descriptor = FetchDescriptor(
            predicate: #Predicate { task in
                task.title.localizedStandardContains(searchTerm)
            }
        )
        let results = try context.fetch(descriptor)

        #expect(results.count == 2)
    }
}

Обработка на конкурентен достъп

Когато работите с множество контексти (например главен и фонов), трябва да внимавате за конкурентния достъп. Тук @ModelActor е вашият най-добър приятел:

// Безопасна работа с фонов контекст чрез ModelActor
@ModelActor
actor BackgroundProcessor {

    // Групово обновяване на статус
    func markAllAsCompleted() throws {
        let descriptor = FetchDescriptor(
            predicate: #Predicate { !$0.isCompleted }
        )
        let tasks = try modelContext.fetch(descriptor)

        for task in tasks {
            task.isCompleted = true
        }

        try modelContext.save()
    }

    // Синхронизиране на данни от сървър
    func syncFromServer(data: [TaskDTO]) throws {
        modelContext.autosaveEnabled = false

        for dto in data {
            // Проверка дали задачата вече съществува
            let existingDescriptor = FetchDescriptor(
                predicate: #Predicate { task in
                    task.title == dto.title
                }
            )

            let existing = try modelContext.fetch(existingDescriptor)

            if let existingTask = existing.first {
                // Обновяване на съществуваща задача
                existingTask.isCompleted = dto.isCompleted
            } else {
                // Създаване на нова задача
                let newTask = Task(title: dto.title)
                newTask.isCompleted = dto.isCompleted
                modelContext.insert(newTask)
            }
        }

        try modelContext.save()
    }
}

// Използване на ModelActor
struct SyncView: View {
    @Environment(\.modelContext) private var context

    var body: some View {
        Button("Синхронизирай") {
            Task {
                let processor = BackgroundProcessor(
                    modelContainer: context.container
                )
                try await processor.syncFromServer(data: serverData)
            }
        }
    }
}

Работа с CloudKit

SwiftData поддържа синхронизация с CloudKit с минимална конфигурация. Буквално няколко реда код:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Task.self, isAutosaveEnabled: true)
        // CloudKit се активира автоматично ако проектът има
        // CloudKit entitlement и iCloud контейнер
    }
}

// За ръчна конфигурация с CloudKit
let container = try ModelContainer(
    for: Task.self,
    configurations: ModelConfiguration(
        cloudKitDatabase: .automatic
    )
)

Практически съвети за продукция

  1. Винаги тествайте миграциите — създайте unit тестове, които симулират миграция от всяка предишна версия. Грешка при миграция в продукция означава загуба на потребителски данни. А това не искате.
  2. Използвайте .unique атрибути — те предотвратяват дублирани записи и поддържат upsert семантика, което е идеално за синхронизация с API.
  3. Не зареждайте всичко наведнъж — използвайте fetchLimit и fetchOffset за пагинация. За списъци с хиляди елементи това е критично.
  4. Групови операции извършвайте във фонов контекст@ModelActor е създаден точно за това.
  5. Деактивирайте autosave за групови операции — задайте context.autosaveEnabled = false и извикайте save() ръчно на определени интервали.
  6. Обработвайте грешки навсякъде — всяка операция с контекста може да хвърли грешка. Не използвайте try! в продукционен код. Никога.
  7. Изолирайте слоя за данни — Repository шаблонът отделя бизнес логиката от имплементацията на хранилището.
  8. Проектирайте за бъдещи миграции — винаги добавяйте нови свойства с подразбиращи се стойности. Планирайте версионирането на схемата от самото начало.

Чести грешки, които да избягвате

Позволете ми да спомена и няколко грешки, които виждам често (и самият аз съм допускал):

// ГРЕШКА 1: Използване на контекста в грешна нишка
// ModelContext НЕ е thread-safe — използвайте го само от нишката,
// на която е създаден

// ГРЕШКА — достъп до главния контекст от фонова нишка
Task.detached {
    let context = container.mainContext // Опасно!
    // ... операции
}

// ПРАВИЛНО — създайте нов контекст за фоновата нишка
Task.detached {
    let context = ModelContext(container) // Безопасно
    // ... операции
}

// ГРЕШКА 2: Предаване на модели между контексти
// Модел, извлечен от един контекст, НЕ може да се използва в друг

// ГРЕШКА
let task = try mainContext.fetch(descriptor).first!
let backgroundContext = ModelContext(container)
backgroundContext.delete(task) // Може да доведе до срив

// ПРАВИЛНО — извлечете отново от правилния контекст
let taskId = task.persistentModelID
let backgroundContext = ModelContext(container)
if let backgroundTask = backgroundContext.model(for: taskId) as? Task {
    backgroundContext.delete(backgroundTask) // Безопасно
}

// ГРЕШКА 3: Забравяне на try при save()
context.insert(newTask)
// context.save() // Компилаторът ще ви предупреди, но не пропускайте обработката
try context.save() // Винаги обработвайте грешките

Заключение

SwiftData наистина представлява фундаментална промяна в начина, по който управляваме данни в Swift приложенията. От декларативното дефиниране на модели с @Model, през типово безопасните заявки с #Predicate, до структурираната миграция с VersionedSchema — всичко е проектирано с мисълта за продуктивността на разработчика.

С iOS 26 и поддръжката на наследяване на класове, SwiftData затваря една от последните празнини спрямо Core Data и отваря вратата за по-елегантни архитектурни решения. Полиморфните заявки и споделената логика в базовите класове правят моделирането на сложни домейни значително по-естествено.

Ключът към успешното използване на SwiftData в продукция? Комбинацията от правилна архитектура (Repository шаблон), внимателна обработка на грешки, планирана миграция на схемата и оптимизация чрез ограничаване на заявките и фонови контексти. Следвайки практиките от това ръководство, ще изградите слой за данни, който ще ви служи надеждно.

SwiftData не е просто по-нов Core Data. Той е модерен, Swift-native фреймуърк, който уважава парадигмите на езика и екосистемата. Ако тепърва започвате нов проект или планирате миграция от Core Data — сега е моментът.