Фреймворк для хранения данных Core Data был написан еще во времена Objective-C. Многим iOS-разработчикам хотелось иметь более современный инструмент, который бы поддерживал все новые возможности языка Swift. И теперь такой инструмент появился.

На WWDC 2023 представили новый фреймворк SwiftData: он создан, чтобы заменить Core Data. Он упрощает создание схемы данных, конфигурацию хранилища, а также саму работу с данными.

Я — Светлана Гладышева, iOS-разработчик компании Surf. Давайте разберёмся, что из себя представляет новый фреймворк SwiftData. А также попробуем использовать его на практике, написав небольшое приложение.

Обзор SwiftData

Фреймворк SwiftData создан на основе Core Data. Он является более высокоуровневой обёрткой над Core Data, и у него более удобный и простой синтаксис для хранения данных.

SwiftData — «Swift-native» фреймворк: всё пишется на чистом Swift. Не используются никакие другие форматы данных — в отличие от Core Data, где, например, для хранения схемы применялся формат .xcdatamodeld. SwiftData использует современные возможности языка, включая макросы, появившиеся в Swift 5.9.

SwiftData может сосуществовать вместе с Core Data. Можно настроить их таким образом, что они будут обращаться к одному и тому же хранилищу данных. Это даёт возможность переходить на новый фреймворк постепенно.

Важный момент: фреймворк SwiftData можно использовать только начиная с iOS 17.

На момент написания статьи SwiftData находится в статусе Beta. Нужно учитывать, что к моменту релиза API может немного измениться.

Создание схемы данных

Чтобы создать схему данных, не нужно создавать никаких дополнительных файлов. Достаточно добавить макрос @Model к классу, и тогда схема для него сгенерируется автоматически:

@Model
class Person {
    var name: String
    var birthDate: Date
    var address: Address
    var cars: [Car]
}

SwiftData поддерживает базовые типы данных, а также все типы, которые соответствуют протоколу Codable.

Если мы в Xcode раскроем макрос @Model, то увидим, что именно он добавляет:

Модель также можно кастомизировать с помощью макроса @Attribute . Например, можно добавить атрибут unique к полю name, чтобы сделать имя уникальным:

@Attribute(.unique) var name: String

Также с помощью макроса @Attribute можно добавить шифрование, использовать внешнее хранилище или сохранять удаленные значения.

Можно управлять связями между сущностями с помощью макроса @Relationship , например, сделать так, чтобы при удалении связанные сущности тоже удалялись:

@Relationship(.cascade) var cars: [Car]

Если вы не хотите, чтобы какое-то свойство хранилось, можно использовать для него макрос @Transient:

@Transient var accommodation: Accommodation

Конфигурация хранилища

После создания схемы данных нужно создать ModelContainer, который управляет схемой данных и конфигурацией хранилища. ModelContainer можно назвать посредником между схемой данных и самим хранилищем. Самый простой способ его создать — указать только типы данных, которые вы хотите хранить:

let modelContainer = try ModelContainer(for: [Person.self, Car.self])

Чтобы кастомизировать настройки хранилища, можно использовать ModelConfiguration. С её помощью можно указать, где будут храниться данные: в памяти или на диске. Можно указать конкретный url, где будет храниться файл с данными. Также можно дать доступ только на чтение. Есть возможность создать несколько конфигураций для разных типов данных:

let fullSchema = Schema([Person.self, Address.self, Car.self])
let personConfiguration = ModelConfiguration(
    schema: Schema([Person.self, Address.self]),
    url: URL(filePath: "/path/to/person/data.store")
  
let carConfiguration = ModelConfiguration(
    schema: Schema([Car.self]),
    url: URL(filePath: "/path/to/car/data.store")
  
let modelContainer = try ModelContainer(for: fullSchema, personConfiguration, carConfiguration)

Если нужна миграция данных с одной версии на другую, то при создании ModelContainer нужно указать план миграции:

let modelContainer = try ModelContainer(
    for: Schema([Person.self, Car.self]),
    migrationPlan: AppMigrationPlan.self
)

В SwiftUI появился специальный модификатор .modelContainer для создания контейнера:

ContentView()
    .modelContainer(for: [Person.self, Car.self])

Этот модификатор также добавляет modelContainer и связанный с ним modelContext в Environment для дальнейшего использования во всех вложенных view:

@Environment(\.modelContext) var modelContext

Изменение данных

Для создания, изменения или удаления данных в SwiftData нужен ModelContext. Это сущность, которая хранит в памяти модель данных, наблюдает за всеми сделанными изменениями, а также занимается сохранением данных.

У каждого ModelContainer есть mainContext — это специальный контекст, который привязан к MainActor. Он предназначен для работы с данными из Scenes и Views.

let modelContext = modelContainer.mainContext

Контекст также можно создать, передав ему в конструктор modelContainer:

let modelContext = ModelContext(modelContainer)

Чтобы создать сущность, нужно вызвать у контекста метод insert:

var person = Person(name: name)
modelContext.insert(person)

Для удаления есть метод delete:

modelContext.delete(person)

ModelContext загружает в память все данные, с которыми работает. Когда мы что-то создаём, изменяем или удаляем, контекст отслеживает все эти изменения и хранит их внутри себя. Даже если удалённый объект уже не отображается в списке, он все равно существует внутри контекста. Когда вызывается метод save, контекст сохраняет изменения в modelContainer и очищает своё состояние.

ModelContext поддерживает транзакции, действия undo и redo, а также автосохранение. При включенном автосохранении метод save будет вызываться по таким событиям, как уход в background или возвращение в foreground, а также будет периодически вызываться, когда приложение активно. Для MainContext автосохранение включено по умолчанию. Для контекстов, созданных вручную, его можно включить с помощью параметра isAutosaveEnabled.

Получение данных

Для получения данных в modelContext есть метод fetch, в который мы должны передать FetchDescriptor. В FetchDescriptor мы описываем, какие именно данные нам нужны и в каком порядке мы хотим их получить:

let upcomingTrips = FetchDescriptor<Trip>(
    predicate: #Predicate { $0.startDate > Date.now },
    sort: \.startDate
)

Также в FetchDescriptor можно указать другие параметры, такие как fetchLimit и fetchOffset.

Предикат может быть и более сложным, например, вот таким:

let predicate = #Predicate<Trip> { trip in
    trip.livingAccommodations.filter {
        $0.hasReservation == false
    }.count > 0
}

В SwiftUI появился новый property wrapper — @Query, который делает получение данных ещё более простым и удобным. Но основное его преимущество — Query автоматически обновляет view при каждом изменении в полученных данных.

@Query(sort: \.startDate, order: .reverse) var allTrips: [Trip]

Пример приложения

Давайте напишем небольшой словарь иностранных слов с использованием SwiftData и SwiftUI. Для удобства использования слова будут разбиты по категориям, и каждое слово будет относиться к своей категории.

Нам нужны две сущности: категория и слово.

@Model
class Category {
    @Attribute(.unique) var name: String
    @Relationship(.cascade, inverse: \Word.category) var words: [Word] = []
    
    init(name: String) {
        self.name = name
    }
}

@Model
class Word {
    var original: String
    var translation: String
    var category: Category?
    
    init(original: String, translation: String) {
        self.original = original
        self.translation = translation
    }
}

Мы хотим, чтобы у всех категорий имена не повторялись, поэтому добавляем атрибут unique и полю name. Сущности Category и Word связаны между собой. При удалении категории мы хотим удалять все слова, которые относятся к этой категории. Поэтому указали .cascade.

Теперь нужно добавить modelContainer. В SwiftUI мы можем использовать специальный модификатор .modelContainer, который добавит контейнер в наш App. При создании укажем типы двух созданных сущностей:

@main
struct WordsApp: App {
    var body: some Scene {
        WindowGroup {
            CategoriesView()
        }
        .modelContainer(for: [Category.self, Word.self])
    }
}

Далее создадим экран категорий, на котором будем отображать список категорий. Для получения категорий используем макрос @Query. Чтобы категории отображались упорядоченно, добавляем в Query сортировку по названию. При добавлении, изменении или удалении категории список будет изменяться автоматически.

struct CategoriesView: View {
    @Query(sort: \.name) var categories: [Category]
    
    var body: some View {
        List {
            ForEach(categories, id: \.id) { category in
                Text("\(category.name)")
            }
        }
    }
}

При нажатии на категорию мы хотим переходить на экран слов, относящихся к этой категории. Создадим этот экран:

struct WordsView: View {
    var category: Category
    
    var body: some View {
        List {
            ForEach(category.words, id: \.id) { word in
                VStack {
                    Text("\(word.original)")
                    Text("\(word.translation)")
                }
            }
        }
    }
}

Сюда передаём категорию, и из неё берём список слов для отображения.

Затем нам нужно добавить возможность создавать категории и удалять их. Для этого нужен modelContext, который можно получить из Environment:

@Environment(\.modelContext) var modelContext

Создание категории выглядит вот так:

func createCategory(name: String) {
    let category = Category(name: name)
    modelContext.insert(category)
}

Для удаления воспользуемся методом delete:

func deleteCategory(_ category: Category) {
    modelContext.delete(category)
}

Аналогично будут выглядеть методы создания и удаления слова:

func createWord(original: String, translation: String) {
    let word = Word(original: original, translation: translation)
    word.category = category
    category.words.append(word)
}

func deleteWord(word: Word) {
    modelContext.delete(word)
    category.words.removeAll(where: { $0 == word })
}

Поскольку на экран слов мы берём слова из переданной категории и не используем здесь @Query, то автоматически экран обновляться не будет. Поскольку мы хотим, чтобы экран обновлялся, то мы сами должны обновлять объект category, добавляя или удаляя в нём слова.

Полный код приложения

Усложняем пример

Теперь предположим, что мы не хотим по каким-то причинам использовать SwiftUI в приложении. Либо хотим сделать отдельный data-слой, не связанный с SwiftUI. Давайте попробуем сделать это.

Классы Category и Word останутся такими же: их менять не нужно. А вот инициализацию ModelContainer поменять придётся. Теперь она будет выглядеть вот так:

let modelContainer = try ModelContainer(for: [Category.self, Word.self])

Получение данных тоже поменяется: вместо макроса Query нам нужно использовать метод fetch у modelContext. ModelContext мы можем получить из modelContainer. В метод fetch мы передаем fetchDescriptor с нужной сортировкой по имени:

func fetchCategories() throws -> [Category] {
    let fetchDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\Category.name)])
    return try modelContext.fetch(fetchDescriptor)
}

Для получения слов нам в fetchDescriptor кроме сортировки нужно передать предикат, с помощью которого будем получать слова только из нужной категории.

func fetchWords(category: Category) throws -> [Word] {
    let categoryName = category.name
    let fetchDescriptor = FetchDescriptor(
        predicate: #Predicate { $0.category?.name == categoryName },
        sortBy: [SortDescriptor(\Word.original)]
    )
    return try modelContext.fetch(fetchDescriptor)
}

Создание и удаление сущностей останется без изменений.

К сожалению, на момент написания статьи в SwiftData нет возможности использовать аналог Query вне SwiftUI. Поэтому данные не будут автоматически обновляться при изменениях, и их нужно обновлять вручную.

Полный код этого приложения


Появление SwiftData сильно упростило работу с данными по сравнению с Core Data. К сожалению, его можно использовать только начиная с iOS 17.

Больше информации про SwiftData можно узнать в видео с WWDC 2023:

Больше полезного про iOS — в нашем телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь >>

Комментарии (2)


  1. sryze
    21.06.2023 05:51

    Работает ли оно с такой штукой, как NSFetchedResultsController (или чем-то подобным)? Посмотрел документацию и вроде бы не нашел способа отслеживать измененияю в хранилище.


  1. SergeyMild
    21.06.2023 05:51

    придем через 2 - 3 года когда ios 17 будет минимальной