Migracje SwiftData: lekkie kontra niestandardowe — i kiedy nie potrzeba V2
Mechanizm migracji schematu w SwiftData stanowi strukturalne ulepszenie w stosunku do Core Data, ma jednak jedną pułapkę, w którą zespoły wciąż wpadają: deklarowanie nowego VersionedSchema dla zmian, które SwiftData obsłużyłoby automatycznie poprzez wartości domyślne w deklaracji. Rezultatem jest awaria na urządzeniu z komunikatem „Duplicate version checksums across stages detected”, mimo że kod wyglądał poprawnie i kompilował się bez ostrzeżeń. Rzeczywisty model migracji frameworka opiera się na trzech elementach (VersionedSchema, MigrationStage, SchemaMigrationPlan) oraz trzech typach migracji (automatyczna lekka, deklarowana lekka, niestandardowa)1. Większość zmian schematu jest automatyczna. Niektóre wymagają zadeklarowanego etapu lekkiego. Mała mniejszość wymaga etapu niestandardowego z domknięciami willMigrate i didMigrate.
Niniejszy wpis przeprowadza model migracji w odniesieniu do dokumentacji Apple, nazywa przypadki obsługiwane przez każdy typ migracji oraz omawia nowe wsparcie dla dziedziczenia klas wprowadzone w iOS 26. Punktem wyjścia jest pytanie „co deklaruję, a co SwiftData obsługuje za mnie”, ponieważ ta decyzja przesądza o tym, czy migracja zostanie wdrożona czysto, czy też ulegnie awarii przy pierwszym uruchomieniu.
TL;DR
- Migracje SwiftData komponują trzy protokoły:
VersionedSchema(migawka typów modeli w danej wersji),MigrationStage(pojedyncze przejście fromVersion-to-toVersion z przypadkami.lightweightlub.custom) orazSchemaMigrationPlan(uporządkowana lista etapów)1. - Dodanie nowej właściwości
@Modelz wartością domyślną w deklaracji (var foo: Bool = false) nie wymaga nowegoVersionedSchema. SwiftData obsługuje takie dodanie automatycznie jako migrację lekką. Zadeklarowanie dla niej V2 powoduje awarie „Duplicate version checksums across stages detected”. - Migracje lekkie obsługują: dodawanie/zmianę nazwy/usuwanie encji, atrybutów, relacji; zmianę typu relacji; deklarowanie
@Attribute(originalName:)w celu śledzenia zmian nazw; określanie reguł usuwania. Większość zmian schematu mieści się tutaj. - Migracje niestandardowe (
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) obsługują transformacje danych: rozdzielanie jednej kolumny na dwie, obliczanie pól pochodnych, przenoszenie danych między modelami.willMigratema stary kontekst;didMigratema nowy kontekst. - iOS 26 dodaje dziedziczenie klas dla typów
@Model2. Schematy adoptujące dziedziczenie przeskakują do nowej wersji z lekkim etapem migracji z poprzedniej wersji modelu płaskiego.
Model trójelementowy
Migracja SwiftData jest zbudowana z trzech elementów.
VersionedSchema
Migawka typów modeli w określonej wersji schematu1. Protokół wymaga:
static var versionIdentifier: Schema.Version. Trójka wersji semantycznej (Schema.Version(1, 0, 0)).static var models: [any PersistentModel.Type]. Tablica typów@Modelw tej wersji.
enum SchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
final class Item {
var name: String
var createdAt: Date
init(name: String, createdAt: Date) {
self.name = name
self.createdAt = createdAt
}
}
}
Wzorzec enum z zagnieżdżonymi typami stanowi konwencję. Każdy VersionedSchema umieszcza swoje klasy modeli w przestrzeni nazw, dzięki czemu wiele schematów o tej samej nazwie modelu może współistnieć w bazie kodu w trakcie migracji.
MigrationStage
Pojedyncze przejście między dwoma typami VersionedSchema3. Dwa przypadki:
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Deklaruje przejście, które SwiftData obsługuje bez kodu aplikacji. Parametrami są same typyVersionedSchema(np.SchemaV1.self), a nie surowe wartościSchema.Version..custom(fromVersion:toVersion:willMigrate:didMigrate:). Deklaruje przejście z kodem uruchamianym przed migracją danych i/lub po niej. Te same typy parametrów co.lightweightdla argumentów wersji.
SchemaMigrationPlan
Uporządkowana lista etapów, która prowadzi schemat z dowolnej wcześniejszej wersji do wersji bieżącej1.
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: { context in
// Pre-migration: read old data, prepare it
try context.save()
},
didMigrate: { context in
// Post-migration: backfill new fields
let descriptor = FetchDescriptor<SchemaV3.Item>()
let items = try context.fetch(descriptor)
for item in items {
item.computedField = computeFromExisting(item)
}
try context.save()
}
)
}
ModelContainer jest konfigurowany zarówno z bieżącym schematem, jak i z planem migracji:
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
SwiftData odczytuje przy tworzeniu kontenera bieżącą wersję schematu z magazynu trwałego, przechodzi przez etapy planu od tej wersji do bieżącej i stosuje każdy etap po kolei.
Co lekkie migracje obsługują automatycznie
Większość zmian schematu nie wymaga niestandardowego etapu1:
- Dodanie atrybutu z wartością domyślną.
var foo: Bool = falsena istniejącym@Modeljest automatyczne. - Dodanie nowej encji (klasy modelu). Nowe typy pojawiają się, gdy ich
VersionedSchemajest tym bieżącym; istniejące dane są zachowywane. - Usunięcie atrybutu lub encji. SwiftData usuwa kolumnę lub tabelę.
- Zmiana nazwy atrybutu lub encji. Należy dodać
@Attribute(originalName: "oldName")do właściwości, aby zachować dane; SwiftData mapuje starą nazwę na nową. - Zmiana typu relacji. Z jeden-do-wielu, wiele-do-wielu itd.
- Określenie reguł usuwania.
@Relationship(deleteRule: .cascade)i podobne dodatki są lekkie.
Dla zmian z tej listy właściwym wzorcem jest nie deklarowanie nowego VersionedSchema w ogóle, jeśli typy modeli są poza tym niezmienione. SwiftData wykonuje lekką migrację automatycznie wobec istniejącego schematu.
Pułapka: dodanie pola nie wymaga V2
Najczęstszy błąd migracji SwiftData: programista dodaje nową właściwość z wartością domyślną w deklaracji (var foo: Bool = false), a następnie deklaruje SchemaV2 odwołujący się do tych samych typów modeli co SchemaV1. Kompilacja przebiega czysto. Pierwsze uruchomienie na urządzeniu z istniejącymi danymi V1 ulega awarii z komunikatem Duplicate version checksums across stages detected, ponieważ zarówno SchemaV1, jak i SchemaV2 rozwiązują się do tej samej sumy kontrolnej (typy modeli nie zmieniły się w sposób, który SwiftData uznaje za różny).
Poprawny wzorzec: pozostawić istniejący VersionedSchema w spokoju, dodać nową właściwość do modelu z wartością domyślną w deklaracji i pozwolić, aby automatyczna lekka migracja SwiftData ją obsłużyła. Bez MigrationPlan, bez MigrationStage, bez potrzeby V2.
// V1 schema
enum SchemaV1: VersionedSchema {
@Model
final class Item {
var name: String
// BEFORE: just these two properties
var createdAt: Date
// AFTER: add a third with inline default
var isFavorite: Bool = false // Lightweight, automatic
}
}
Zmiana var isFavorite: Bool = false zostaje wdrożona bez żadnej deklaracji MigrationStage. Inicjalizator ModelContainer, który nie przekazuje migrationPlan:, działa:
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
Schemat V2 jest wymagany tylko wtedy, gdy zmiana nie może być lekka (transformacja danych, podział modelu, restrukturyzacja dziedziczenia wymagająca niestandardowej logiki). W tych przypadkach V2 jest realne, a SchemaMigrationPlan orkiestruje przejście.
Kiedy migracje niestandardowe są wymagane
Migracje niestandardowe zarabiają na swoją złożoność w trzech przypadkach:
1. Rozdzielenie jednego pola na wiele. Pole String zawierające "Last, First" staje się dwoma polami: firstName i lastName. Migracja musi odczytać starą wartość, sparsować ją i zapisać nowe pola.
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let descriptor = FetchDescriptor<SchemaV2.Person>()
let people = try context.fetch(descriptor)
for person in people {
let parts = person.fullName.split(separator: ", ", maxSplits: 1)
person.lastName = String(parts.first ?? "")
person.firstName = String(parts.dropFirst().first ?? "")
}
try context.save()
}
)
Domknięcie didMigrate wykonuje się wobec kontekstu nowego schematu, więc nowe pola są dostępne. Stare fullName może wymagać odroczenia usunięcia do czasu, aż nowe pola zostaną wypełnione; oczyszczenie to kolejny etap V2-do-V3.
2. Obliczanie pól pochodnych. Nowy @Attribute zależny od istniejących danych musi zostać uzupełniony w czasie migracji.
3. Przenoszenie danych między modelami. Reorganizacja, w której dane z Item są dzielone między Item a nowy model Tag, wymaga niestandardowej logiki przypisywania tagów ze starych danych.
Zasada ogólna: lekka, gdy zmienia się kształt schematu; niestandardowa, gdy zmienia się kształt danych.
willMigrate kontra didMigrate
Etapy niestandardowe mają dwa domknięcia, wywoływane w różnych momentach4:
willMigrate uruchamia się przed zastosowaniem migracji schematu przez SwiftData. Kontekst modelu, który otrzymuje domknięcie, jest kontekstem starego schematu. Należy go używać do przechwytywania danych, denormalizacji ich lub przygotowywania stanu pomocniczego, zanim schemat zmieni się pod spodem.
didMigrate uruchamia się po migracji schematu. Kontekst modelu należy do nowego schematu. Należy go używać do uzupełniania nowych pól, obliczania danych pochodnych lub finalizowania migracji.
Każde z domknięć może być nil, jeśli nie jest potrzebne. Większość migracji niestandardowych używa wyłącznie didMigrate; willMigrate jest przydatne, gdy migracja musi odczytać stare dane, które nie będą dostępne po zmianie schematu.
Domknięcie otrzymuje ModelContext i może pobierać, modyfikować i zapisywać. Domknięcie jest rzucające; błędy propagują się poza migrację i ją przerywają.
iOS 26: dziedziczenie klas dla @Model
iOS 26 wprowadza dziedziczenie klas dla modeli SwiftData2. Modele mogą teraz mieć relacje rodzic–dziecko:
@Model
class Vehicle {
var make: String
var year: Int
init(make: String, year: Int) {
self.make = make
self.year = year
}
}
@Model
final class Car: Vehicle {
var doorCount: Int
init(make: String, year: Int, doorCount: Int) {
self.doorCount = doorCount
super.init(make: make, year: year)
}
}
Schematy adoptujące dziedziczenie przeskakują do nowej wersji z lekkim etapem migracji z poprzedniej wersji modelu płaskiego. Przejście jest automatyczne, jeśli dziedziczenie zachowuje istniejące właściwości; nowe pola w podklasie podążają za standardowym wzorcem wartości domyślnych w deklaracji.
Wzorzec pasuje do przypadków, w których wiele typów @Model współdzieli charakterystyki: rodzic Vehicle z dziećmi Car, Truck, Motorcycle; rodzic Account z dziećmi CheckingAccount, SavingsAccount. Wspólne właściwości żyją na rodzicu; specyfika żyje na dzieciach.
Testowanie migracji
Migracja, która się kompiluje, nie jest migracją, którą można wdrożyć. Trzy wzorce testowe warte uruchomienia przed wydaniem:
1. Test pełnego przebiegu na kopii bazy produkcyjnej. Należy pobrać niedawną bazę o kształcie produkcyjnym (lub wygenerować syntetyczne dane V1 przez testy), otworzyć ją kontenerem świadomym V2 i zweryfikować, że dane migrują poprawnie. Test wyłapuje błędy migracji niestandardowych, których kontroler typów wykryć nie potrafi.
2. Stara wersja nadal się uruchamia. Należy zbudować poprzednią wersję aplikacji, uruchomić ją raz w celu wytworzenia danych V1, następnie zbudować nową wersję aplikacji i zweryfikować, że uruchamia się bez awarii. Test wyłapuje pułapkę „Duplicate version checksums” i podobne błędy deklaracji.
3. Odzyskiwanie po nieudanej migracji. Co się dzieje, jeśli migracja rzuci wyjątek? Zachowanie SwiftData zależy od konfiguracji kontenera; w aplikacjach produkcyjnych nieobsłużony błąd migracji nie powinien po cichu usuwać danych użytkownika. Należy przetestować ścieżkę awarii jawnie i zdecydować, co aplikacja robi (wycofanie, zapytanie, odzyskanie z kopii zapasowej).
Wpis klastra Single Source of Truth post omawia powiązaną kwestię: co się dzieje, gdy magazyn SwiftData zostaje zastąpiony poprzez synchronizację międzyprocesową. Migracje są lokalno-ewolucyjnym odpowiednikiem tego wzorca.
Najczęstsze tryby awarii
Trzy wzorce z dzienników awarii SwiftData:
Deklarowanie V2 dla zmiany, którą SwiftData obsłużyłoby automatycznie. Awaria „Duplicate version checksums”. Naprawa: nie deklaruj nowego schematu dla dodawania właściwości z wartościami domyślnymi w deklaracji; pozwól SwiftData obsłużyć je automatycznie.
Kod migracji niestandardowej, który nie zapisuje. Domknięcie didMigrate, które modyfikuje encje, ale nie wywołuje context.save(), wytwarza migrację, która uruchamia się raz, traci swoją pracę i ponawia uruchomienie przy każdym starcie (ponieważ migracja sprawia wrażenie niezakończonej). Naprawa: każde domknięcie modyfikujące dane musi wykonać try context.save() przed powrotem.
Zmiana nazwy właściwości bez @Attribute(originalName:). SwiftData traktuje nową właściwość jako nową, a starą jako usuniętą; istniejące dane na starej właściwości zostają porzucone. Naprawa: zadeklaruj @Attribute(originalName: "oldName") var newName: ..., aby SwiftData mapowało dane przez zmianę nazwy.
Co ten wzorzec oznacza dla aplikacji iOS 26+
Trzy wnioski.
-
Domyślnie bez drabiny
VersionedSchema. Dodawanie właściwości z wartościami domyślnymi w deklaracji, usuwanie nieużywanych pól, zmiana nazw z@Attribute(originalName:). Wszystko lekkie i automatyczne. DrabinaVersionedSchemajest dla zmian, których SwiftData naprawdę nie potrafi obsłużyć automatycznie (transformacje danych, niestandardowa logika, restrukturyzacje dziedziczenia). -
Używaj
MigrationStage.customdo transformacji danych, nie do zmian kształtu schematu. DomknięciawillMigrateididMigratesłużą do kodu operującego na danych, nie do deklarowania, że schemat się zmienił. Zmiany kształtu schematu płyną przez etapy lekkie. -
Testuj migracje z prawdziwymi danymi V1, nie tylko z syntetycznymi danymi testowymi. Migracje, które przechodzą na syntetycznych przebiegach pełnych, mogą nadal zawodzić na danych o kształcie produkcyjnym z przypadkami granicznymi (pola dopuszczające null, których schemat nie obejmował, duże zbiory danych trafiające na limit czasu itd.). Koszt testowania jest mały; koszt awarii migracji przy pierwszym uruchomieniu jest realny.
Pełny klaster Apple Ecosystem: typowane App Intents; serwery MCP; pytanie o routing; Foundation Models; rozróżnienie runtime kontra oprzyrządowanie LLM; trzy powierzchnie; wzorzec pojedynczego źródła prawdy; Two MCP Servers; hooki dla rozwoju Apple; Live Activities; runtime watchOS; wnętrze SwiftUI; przestrzenny model mentalny RealityKit; dyscyplina schematów SwiftData; wzorce Liquid Glass; wieloplatformowe wdrażanie; macierz platform; framework Vision; Symbol Effects; inferencja Core ML; Writing Tools API; Swift Testing; Privacy Manifest; Dostępność jako platforma; typografia SF Pro; przestrzenne wzorce visionOS; framework Speech; o czym odmawiam pisać. Hub znajduje się w Apple Ecosystem Series. Szerszy kontekst iOS-z-agentami-AI znajduje się w przewodniku iOS Agent Development.
FAQ
Czy zawsze potrzebny jest SchemaMigrationPlan?
Nie. Aplikacje z pojedynczą wersją schematu (wydanie początkowe lub aplikacje, które wprowadzały tylko lekkie zmiany) nie potrzebują SchemaMigrationPlan. Inicjalizator ModelContainer przyjmuje modele schematu bezpośrednio. Parametr migrationPlan: staje się konieczny przy pierwszej deklaracji niestandardowego etapu migracji (lub przy pierwszej decyzji programisty o zadeklarowaniu jawnej drabiny wersji).
Skąd wiadomo, czy moja zmiana jest lekka?
Lista kwalifikująca się do lekkich migracji według Apple1: dodawanie encji/atrybutów/relacji, ich usuwanie, zmiana nazw z @Attribute(originalName:), zmiana liczebności relacji, określanie reguł usuwania. Jeśli zmiana pasuje do jednej z nich, a struktura klasy modelu jest poza tym niezmieniona, migracja jest automatyczna i nie wymaga drabiny VersionedSchema. Jeśli zmiana wymaga transformacji danych (obliczenie, podział, przeniesienie danych), jest niestandardowa.
Czy willMigrate i didMigrate mogą być oba ustawione?
Tak. Oba domknięcia są opcjonalne pojedynczo, ale można je oba dostarczyć. willMigrate uruchamia się wobec kontekstu starego schematu przed migracją SwiftData; didMigrate uruchamia się wobec kontekstu nowego schematu po niej. Te dwa pokrywają odpowiednio przygotowanie i finalizację.
Co się dzieje, jeśli migracja rzuci błąd?
Błąd propaguje się poza inicjalizację ModelContainer. Kontener nie otwiera się. Zachowanie aplikacji zależy od sposobu, w jaki programista obsługuje błąd: niektóre aplikacje wyświetlają interfejs odzyskiwania, niektóre próbują przywrócić z kopii zapasowej, niektóre usuwają uszkodzony magazyn i zaczynają od nowa. SwiftData nie usuwa po cichu danych użytkownika przy awarii migracji; awaria należy do aplikacji.
Jak przetestować migrację bez wpływu na dane produkcyjne?
Zbuduj cel testowy, który tworzy ModelContainer wskazujący na tymczasowy adres URL pliku, wypełnia go danymi V1, a następnie otwiera go nowym kontenerem zawierającym plan migracji. Zweryfikuj, że migrowane dane spełniają oczekiwania. Wzorzec działa zarówno w testach jednostkowych, jak i integracyjnych; aby uzyskać najbardziej realistyczne wyniki, użyj kopii rzeczywistej bazy o kształcie produkcyjnym.
Czy dziedziczenie klas iOS 26 działa z istniejącymi schematami?
Tak, z lekką migracją. Aplikacje adoptujące dziedziczenie przeskakują do nowej wersji schematu (np. V4) i deklarują MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). Płaskie właściwości klasy nadrzędnej pozostają, a właściwości specyficzne dla podklasy są dodawane z wartościami domyślnymi w deklaracji. Lekka migracja SwiftData obsługuje zmianę strukturalną.
Bibliografia
-
Dokumentacja Apple Developer: odniesienia do protokołów
VersionedSchemaiSchemaMigrationPlan. Model migracji. Zobacz także powiązany przewodnik Adopting SwiftData for a Core Data app, zawierający pełną narrację ewolucji schematu. ↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration (sesja 291 WWDC 2025). Wprowadzenie dziedziczenia klas SwiftData w iOS 26. ↩↩
-
Dokumentacja Apple Developer:
MigrationStagez przypadkami.lightweight(fromVersion:toVersion:)i.custom(fromVersion:toVersion:willMigrate:didMigrate:). ↩ -
Dokumentacja Apple Developer:
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)dla sygnatury przypadku. Semantyka willMigrate-uruchamia-się-wobec-starego-kontekstu i didMigrate-uruchamia-się-wobec-nowego-kontekstu jest udokumentowana w sesji 291 WWDC 2025 SwiftData: Dive into inheritance and schema migration, tej samej sesji, do której odniesienia czyniono w kontekście dodania dziedziczenia w iOS 26. ↩