Разработка
Используйте транзакции вместо сохранения в SwiftData и Core Data
Фреймворк SwiftData вводит метод transaction в ModelContext, предоставляя разработчикам более элегантный способ организации и управления операциями с данными.
Обеспечение согласованности и целостности данных имеет решающее значение в операциях сохранения данных. Фреймворк SwiftData вводит метод transaction
в ModelContext
, предоставляя разработчикам более элегантный способ организации и управления операциями с данными. В этой статье мы рассмотрим, как использовать концепцию транзакций для создания более надежных и эффективных операций сохранения данных.
Понимание транзакций
В сфере баз данных транзакция — это мощная и фундаментальная концепция. Она объединяет несколько связанных операций базы данных в неделимую логическую единицу, следуя принципу «все или ничего» — либо все операции выполняются успешно, либо в случае ошибки происходит полный откат, как будто этих операций никогда не было. Этот механизм дает надежные гарантии безопасности операциям с данными, гарантируя их согласованность и целостность.
Хотя и SwiftData, и Core Data используют SQLite, поддерживающий транзакции, в качестве базового механизма хранения данных, интересно, что Core Data выбирает более абстрактный (или неясный) способ обработки транзакций. В его основном API (за исключением постоянного отслеживания истории) редко встречаются концепции и интерфейсы операций, связанные с транзакциями.
Неявный механизм обработки транзакций в Core Data
Core Data не предоставляет явных команд управления транзакциями, таких как BEGIN TRANSACTION
или COMMIT
; вместо этого он неявно интегрирует концепцию транзакций во фреймворк. При вызове метода save
Core Data автоматически упаковывает все изменения в текущем контексте в одну транзакцию и отправляет их в SQLite.
Давайте разберемся в этом механизме на примере кода. Сначала рассмотрим пример, в котором метод save
вызывается несколько раз:
let newItem = Item(context: viewContext) newItem.timestamp = Date() try? viewContext.save() // First transaction let newItem1 = Item(context: viewContext) newItem1.timestamp = Date() try? viewContext.save() // Second transaction
По другому, мы можем объединить все операции в один вызов save
:
let newItem = Item(context: viewContext) newItem.timestamp = Date() let newItem1 = Item(context: viewContext) newItem1.timestamp = Date() try? viewContext.save() // Single transaction encompassing all operations
Эта разница не только влияет на производительность, но и, что более важно, на надежность операций с данными. Рассмотрим реальный сценарий: создание Topic
и добавление в него изображений. Такие составные операции требуют, чтобы все шаги были успешно завершены; в противном случае необходим полный откат. В этом случае использование одной транзакции особенно важно:
do { let topic = Topic(context: context) let image = Image(context: context) image.topic = topic try context.save() // Package all operations into a single transaction } catch { context.rollback() // Complete rollback in case of an error }
Операция отката Core Data (rollback
) всегда действует на всю транзакцию. Она восстанавливает контекст на момент последнего успешного вызова сохранения, обеспечивая согласованность данных.
Влияние консолидации транзакций на производительность
В Core Data и SwiftData правильное использование транзакций не только обеспечивает согласованность данных, но и может значительно повысить производительность приложения. Давайте посмотрим, как фреймворк обрабатывает транзакции.
Чтобы увидеть конкретные детали того, как Core Data строит транзакции, мы можем включить отладочный вывод в Xcode с помощью опции: -com.apple.CoreData.SQLDebug 1
. Вот простая операция вставки данных:
let newItem = Item(context: viewContext) newItem.timestamp = Date() try? viewContext.save()
Через вывод отладки мы видим, что Core Data фактически создает две отдельные транзакции для этой простой операции:
// Transaction 1: Assigning primary key CoreData: sql: BEGIN EXCLUSIVE // Start transaction CoreData: sql: SELECT Z_MAX FROM Z_PRIMARYKEY WHERE Z_ENT = ? CoreData: annotation: getting max pk for entityID = 1 CoreData: sql: UPDATE OR FAIL Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_ENT = ? AND Z_MAX = ? CoreData: annotation: updating max pk for entityID = 1 with old = 6 and new = 7 CoreData: sql: pragma auto_vacuum CoreData: annotation: sql execution time: 0.0000s CoreData: sql: pragma auto_vacuum=2 CoreData: annotation: sql execution time: 0.0000s CoreData: sql: COMMIT // Commit transaction // Transaction 2: Inserting data and updating history tracking CoreData: sql: BEGIN EXCLUSIVE CoreData: sql: INSERT INTO ZITEM(Z_PK, Z_ENT, Z_OPT, ZTIMESTAMP) VALUES(?, ?, ?, ?) CoreData: details: SQLite bind[0] = (int64)7 CoreData: details: SQLite bind[1] = (int64)1 CoreData: details: SQLite bind[2] = (int64)1 CoreData: details: SQLite bind[3] = (timestamp)753434654.313978 ... Updating historical data CoreData: sql: COMMIT
Этот вывод раскрывает важный факт:
- Дополнительные накладные расходы: Каждый раз при вызове save Core Data или SwiftData приходится выполнять дополнительные операции на уровне фреймворка, что приводит к дополнительным накладным расходам на транзакцию.
- Триггеры уведомлений: Механизмы реагирования фреймворка на данные (такие как уведомления
didSave
или постоянное отслеживание истории) также запускаются на основе каждой транзакции. Частые фиксации транзакций не только увеличивают накладные расходы на работу с базой данных, но и приводят к увеличению количества ответов на уведомления, что негативно сказывается на отзывчивости пользовательского интерфейса.
Поэтому объединение связанных операций с данными в одну транзакцию — это не только хорошая практика, но и стратегия повышения производительности приложения.
Для более глубокого изучения механизма первичного ключа (Z_PK
) в Core Data вы можете обратиться к статье «Как Core Data сохраняет данные в SQLite«.
API транзакций SwiftData
SwiftData вводит метод transaction
в ModelContext
, предоставляя разработчикам более элегантный и явный способ обработки транзакций. Такой дизайн не только упрощает операции с транзакциями, но и направляет разработчиков на принятие «транзакционного» мышления программирования, поощряя упаковку связанной бизнес-логики в полные транзакционные единицы.
public func transaction(block: () throws -> Void) throws
Метод transaction
имеет две важные особенности:
- Автоматический коммит: Разработчикам не нужно явно вызывать метод сохранения; SwiftData автоматически сохранит данные после выполнения замыкания.
- Немедленное сохранение: Даже если в
mainContext
включена функцияautosaveEnabled
(автоматическое сохранение), этот метод будет игнорировать эту настройку, обеспечивая сохранение данных сразу после завершения закрытия.
Вот реальный пример использования:
try? modelContext.transaction { let item = Item(timestamp: Date()) modelContext.insert(item) let item2 = Item(timestamp: Date()) modelContext.insert(item2) }
Преимущества такого подхода очевидны: код понятнее, замысел более явный, обеспечивается атомарность связанных операций и согласованность данных.
Реализация более комплексной обработки транзакций в ModelActor
Благодаря тому, что SwiftData представила элегантную модель параллельного программирования @ModelActor
, мы можем построить более безопасную и эффективную архитектуру операций с данными. В этой архитектуре все операции по изменению данных инкапсулируются внутри актора и выполняются в фоновом контексте, а контекст основного потока отвечает только за получение данных. Поэтому нам также необходимо обеспечить механизм обработки транзакций, основанный на модели акторов.
Пожалуйста, прочитайте книгу «Practical SwiftData: Создание приложений SwiftUI с помощью современных подходов» и «Конкурентное программирование в SwiftData«, чтобы узнать, как использовать @ModelActor
.
@ModelActor public actor DataHandler {} extension DataHandler { func save(_ saveImmediately: Bool) throws { if saveImmediately, modelContext.hasChanges { try modelContext.save() } } /// Internal transaction method accepting a synchronous closure; return value does not need to conform to Sendable func transaction<T>(_ block: () throws -> T) throws -> T { let result = try block() try save(true) return result } /// Internal transaction method accepting an asynchronous closure; return value needs to conform to Sendable func transaction<T: Sendable>(_ block: () async throws -> T) async throws -> T { let result = try await block() try save(true) return result } }
Чтобы облегчить использование за пределами модуля данных, нам также необходимо обеспечить более удобные публичные интерфейсы:
extension DataHandler { /// Public transaction method accepting a synchronous closure; return value conforms to Sendable /// - Parameter block: Synchronous operation closure accepting a DataHandler instance public func transaction<T: Sendable>(_ block: (DataHandler) throws -> T) throws -> T { let result = try block(self) try save(true) return result } /// Public transaction method accepting an asynchronous closure; return value conforms to Sendable /// - Parameter block: Asynchronous operation closure accepting a DataHandler instance func transaction<T: Sendable>(_ block: (DataHandler) async throws -> T) async throws -> T { let result = try await block(self) try save(true) return result } }
Описанный выше подход применим и к Core Data. Пожалуйста, прочитайте статью «Переосмысление Core Data разработки с помощью принципов SwiftData«, чтобы узнать, как добиться в Core Data такого же опыта параллельного программирования, как и в SwiftData.
Такая реализация дает множество преимуществ:
- Безопасность параллелизма: Обеспечивает потокобезопасность операций с данными с помощью акторов и кастомных исполнителей.
- Понятные интерфейсы: Предоставляет полный набор внутренних и внешних интерфейсов для обработки транзакций.
- Безопасность типов: Правильно обрабатывает требования протокола Sendable, добавляя обработку возвращаемых значений.
- Простота использования: Унифицированный шаблон обработки транзакций упрощает процесс разработки.
Используя эти методы транзакций вместо прямых вызовов save
, мы можем лучше контролировать детализацию транзакций, избегать создания слишком большого количества транзакций, тем самым повышая производительность приложения и обеспечивая надежность операций с данными.