Фреймворк для хранения данных 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:
Meet SwiftData
Build an app with SwiftData
Model your schema with SwiftData
Migrate to SwiftData
Dive deeper into SwiftData
Больше полезного про iOS — в нашем телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь >>