Apple представила на WWDC23 большое количество новых вещей для разработки. Хранения данных — не исключение. SwiftData - это новый фреймворк для работы с хранением данных внутри приложения, который представляет собою новый уровень абстракции над уже существующем механизмом - CoreData. В данной статье будут описаны основные этапы работы с SwiftData, а именно:

  1. Создание моделей

  2. Установка атрибутов свойств

  3. Описание связей между таблицами

  4. Создание контейнера моделей

  5. Описание миграций

  6. Использование в сценариях

  7. Заключение

Создание моделей

Для начала работы со SwiftData, как и для работы с CoreData, необходимо создать модели данных, которые нужно хранить.
Работая в CoreDara, создавать и выстраивать связей между моделями не кажется самой удобной вещью, так как необходимо было вручную прописывать и править информацию в xcdatamodeld файле

С приходом SwiftData стало возможным описывать все модели и связи между ними используя новый макрос: @Model

@Model
final class Person {
    var firstName: String
    var lastName: String
	var dateOfBirth: Date
	var relatives: [Person] = []
}

@Model позволяет генерировать схему согласно тем свойствам, которые будут описаны в рамках модели. Прямо из коробки существует поддержка таких типов, как структуры, коллекций (только value type), а также модели, которые соответствуют протоколу Codable. В момент генерации через макрос добавляется поддержка двух протоколов: PersistentModel (для генерации схемы) и известный протокол Observable(для подписки на обновления модели)

Установка атрибутов свойств

В SwiftData были добавлены атрибуты, которые можно применять для свойств в модели.

  1. Unique - гарантирует, что поле будет актуальным для моделей, которые хранятся. Если уже существуют модели с не уникальными значениями, то необходимо в рамках миграции дедуплицировать модели.

@Attrbitute(.unique) var id: UUID
  1. Encrypt - сохраняет значение свойства в зашифрованном типе

@Attribute(.ecrypt) var somethingImportant: String
  1. Spotlight - включает индексацию свойства, для доступа в Spotlight(аналогично переключателю включения индексации в редакторе схемы)

@Attrbitute(.spotlight) var productName: String
  1. ExternalStorage -сохраняет значение свойства в виде двоичных данных рядом с хранилищем модели.

@Attrbitute(.externalStorage) var profileImage: UIImage
  1. Transient -временное поле, которое не участвует в генерации схемы и не будет записано в БД.

@Transient var isSelected: Bool = true 

После описания модели и атрибутов ее свойств, идёт описание связей между моделями.

Выстраивание связей между моделями внутри таблиц

Макрос @Relationships - еще одна новинка, которая позволяет описать правила удаления:

  1. Cascade - удаляет все связанные модели. Это правило в основном используется, когда модель данных имеет большую зависимость. Оно удалит все ваши записи без каких-либо указаний, если ваш объект связи будет удален.

@Relationships(.cascade) var relatives: [User]
  1. Nullify - зануляет ссылку на модель, с которой была связь.

@Relationships(.nullify) var relatives: [User]
  1. Deny - предотвращает удаление модели, поскольку она содержит одну или несколько ссылок на другие модели. Можно сказать, что полная противоположность работы с каскадным удалением.

@Relationships(.deny) var relatives: [User]
  1. NoAction - не вносит изменений ни в какие связанные модели. Однако Apple в документации явно говорит, что:

Убедитесь что вы выполняете соответствующие действия со всеми связанными моделями при использовании этого правила удаления, например, удаляете их или аннулируете их ссылки на удаленную модель. В противном случае ваши данные будут в несогласованном состоянии и могут ссылаться на несуществующие модели. (оригинал)

Создание контейнера моделей

Теперь поговорим про то, как можно взаимодействовать с данными через SwiftData. ModelContainer управляет схемой приложения и конфигурацией хранилища моделей. Кроме этого контейнер отслеживает все изменения в моделях и предоставляет большое количество действий для работы с ними, а именно:

  1. Отслеживание обновлений данных

  2. Извлечение данных

  3. Сохранение данных

  4. Откат изменений

Существует два способа создания контейнера:

  1. Простой. Для создание контейнера необходимо передать список моделей (или одну модель), схему для которой необходимо сконфигурировать.

// Самый простой способ создать контейнер, описав модели, 
// которые необходимы для построения схемы
let container = try ModelContainer(
    for: [Trip.self, Person.self]
)
  1. Чуть сложнее: Необходимо передать модели для генерации схемы а также ее конфигурацию. Она включает в себя:

    1. Работа в inMemory режиме

    2. Необходимость интеграции с CloudKit

    3. Работа в режиме ReadOnly

// Пример создания контейнера с конфигурацией
let configuration = ModelConfiguration(
	inMemory: true, 
	readOnly: true
)
let container = try ModelContainer(
    for: [Trip.self, Person.self], 
    configurations: configuration
)

Описание миграций

Любое приложение развивается и хранилище данных вместе с ним. Для того чтобы обновить модель данных для нынешних пользователей приложения, необходимо проводить миграцию данных.
Существует два основных вида миграции:

  1. Легковесная(автоматическая) миграция (например, расширение контракта, перевод неопционального поля в опциональный)

  2. Ручная миграция (например, установка атрибута unique, изменение типа данных) Для того чтобы применить миграции, необходимо создать перечисление, который будет соответствовать протоколу SchemaMigrationPlan. Для реализации протокола необходимо указать:

  3. Массив schemas, который содержит перечисление всех Схем приложения, которые были созданы.

  4. Массив stages, который содержит реализации миграций:

    1. Легковесная миграция

static let migrateVltoV2 = MigrationStage.lightweight(
	fromVersion: PeopleSchemaV1.self,
	toVersion: PeopleSchemaV2.self
)
  1. Ручная миграция

static let migrateV2toV3 = MigrationStage.custom(
	fromVersion: PeopleSchemaV2.self, 
    toVersion: PeopleSchemaV3.self,
    willMigrate: { _ in
		// Исполняемый код
    },
    didMigrate: { _ in
		// Исполняемый код
	}
)

В итоге должен получиться следующий код:

enum SamplePeopleMigrationPlan: SchemaMigrationPlan {
	static var schemas: [any VersionedSchema.Type] {
	    [
			PeopleSchemaV1.self, 
			PeopleSchemaV2.self, 
			PeopleSchemaV3.self
		]
  	}

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

	static let migrateVltoV2 = MigrationStage.lightweight(
		fromVersion: PeopleSchemaV1.self,
		toVersion: PeopleSchemaV2.self
	)

	static let migrateV2toV3 = MigrationStage.custom(
		fromVersion: PeopleSchemaV2.self, 
	    toVersion: PeopleSchemaV3.self,
	    willMigrate: { _ in
			// Исполняемый код
	    },
	    didMigrate: { _ in
			// Исполняемый код
		}
	)
}
  1. Необходимо добавить план миграции в конфигурацию контейнера.

let container = ModelContainer(
	for: Person.self, 
	migrationPlan: SamplePeopleMigrationPlan.self
)

Использование в сценариях

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

import SwiftData

struct FamilyList: View {

    @Query(
		sort: \.lastName, 
		filter: { $0.firstName > $1.firstName },
		order: .reverse
	)
    private var people: [Person]

    @Environment(\.modelContext)
    private var modelContext

    var body: some View {
        NavigationStack {
            List {
                ForEach(people, id: \.id)  { person in
                    Text(person.name)
                }
                .onDelete(perform: deleteRelative)
            }
            .navigationTitle(Constants.title)
            .toolbar {
                Button(action: addRelative) {
                    Text(Constants.toolbarTitle)
                }
            }
        }
    }
}

Для доступа к данным из контекста есть возможность обратиться через FetchDescriptor (iOS 17.0+), который содержит дженерик, удовлетворяющий протокол PersistentModel.
FetchDescriptor принимает в себя:

  1. Новый предикат (типизированный, через конструктор)

  2. Порядок сортировки

let context = container.mainContext

let maturePeople = FetchDescriptor<Person>(
    predicate: #Predicate { $0.ages > 20 },
    sort: \.age
)
maturePeople = 10

let results = context.fetch(maturePeople)

Для сохранения/удаления/изменения необходимо получить инстанс из контекста. При выключенном автосохранении необходимо, как и при использовании CoreData, вызывать метод save() в контексте.

Заключение

При первой прикидке кажется, что релиз SwiftData поспособствует понижению порога входа для использования CoreData. К тому же добавит больше безопасности в работу с хранением данных, извлечением, в тоже время оставит возможность для сложной работы с различными видами контекстов и продвинутыми режимами работы. Но при всех очевидных плюсах закрался один существенный недостаток: минимальная версия начинается с iOS 17, что делает использование SwiftData непозволительно дорогим для крупных приложений, но почти идеальным решением для работы над различными pet-проектами.

Полезные материалы

  1. Meet SwiftData—WWDC 2023

  2. Build an App with SwiftData - WWDC 2023

  3. Migrate to SwiftData - WWDC 2023

  4. Проект с примерами использования SwiftData

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


  1. Beginer_First
    10.08.2023 11:54

    Спасибо - интересная статья.

    Но при всех очевидных плюсах закрался один существенный недостаток: минимальная версия начинается с iOS 17, что делает использование SwiftData непозволительно дорогим для крупных приложений, но почти идеальным решением для работы над различными pet-проектами.

    Все же лучше иметь чем не иметь, а крупные приложения если не вымрут рано или поздно дойдут до нужной версии (вопрос про базу пользователей ток)


  1. anonymous
    10.08.2023 11:54

    НЛО прилетело и опубликовало эту надпись здесь