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

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

SwiftData iOS 26: @Model, CRUD и миграция

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 — сега е моментът.

За Автора Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.