Prawdziwym kosztem SwiftData jest dyscyplina schematu
Gatunek: shipped-code. Wpis dokumentuje decyzje schematyczne SwiftData w Get Bananas, Return i Reps: trzy aplikacje, w których schemat albo przetrwał czystą migrację, albo zapłacił podatek za brak planowania migracji. ShoppingItem z Get Bananas to kanoniczny przykład. Pierwotny schemat nie zawierał znacznika czasu lastModified; dodanie go później wymagało określonego kształtu migracji, ponieważ istniejące dane były już zapisane na dysku, a pole zostało zaprojektowane jako opcjonalne właśnie po to, by naprawić awarię migracji, która wystąpiła, gdy zostało dodane jako nieopcjonalne.1
API SwiftData to dwa makra. @Model zastosowane do klasy czyni ją typem trwałym. @Attribute(.unique) na właściwości nadaje jej ograniczenie unikalności. Framework ukrywa zarządzanie stosem Core Data, taniec z transformatorami wartości oraz boilerplate NSManagedObjectContext. Tym, czego framework nie ukrywa, jest migracja schematu; sprawia jedynie, że migracja jest deklaratywna zamiast imperatywnej. Kosztem braku uwagi poświęconej migracjom jest błąd, który wymazuje dane użytkownika podczas rutynowej aktualizacji.
Teza: SwiftData jest tani na start i drogi przy niedbałej migracji. Dyscyplina to nazewnictwo, opcjonalność oraz VersionedSchema od pierwszego dnia, a nie od dnia, w którym uświadomi się Pan/Pani, że powinien był to zrobić.
TL;DR
- Makro
@Modelzamienia klasę w trwały typ SwiftData. Framework generuje schemat z deklaracji właściwości w czasie kompilacji. - Dodanie nowej opcjonalnej właściwości to migracja bez kosztów: lekka migracja SwiftData obsługuje to automatycznie. Dodanie nieopcjonalnej właściwości do istniejącego schematu wymaga
VersionedSchemaorazMigrationPlan, który mówi frameworkowi, jak wypełnić nowe pole dla istniejących rekordów. - Kosztem pominięcia
VersionedSchemaod pierwszego dnia jest to, że każda nietrywialna zmiana schematu w v2 grozi utratą bazy danych użytkownika, ponieważ ścieżka lekka jest zachowawcza i wycofuje się, gdy nie potrafi wywnioskować migracji. @Attribute(.unique)to właściwe narzędzie dla kluczy naturalnych (UUIDwygenerowany przez Pana/Panią, zewnętrzny identyfikator zaimportowany do systemu).@Relationshipto właściwe narzędzie dla referencji rodzic/dziecko. Oba są makrami, które generują odpowiednią warstwę Core Data pod spodem.2
Co tak naprawdę robi @Model
Typ SwiftData to klasa Swift z zastosowanym makrem @Model. ShoppingItem z Get Bananas to kanoniczny kształt:
import Foundation
import SwiftData
@Model
final class ShoppingItem {
@Attribute(.unique) var id: UUID
var name: String
var amount: String
var section: String
var isChecked: Bool
var isOptional: Bool
var sortOrder: Int
var lastModified: Date?
init(id: UUID = UUID(), name: String, amount: String, section: String,
isOptional: Bool = false, sortOrder: Int = 0) {
self.id = id
self.name = name
self.amount = amount
self.section = section
self.isChecked = false
self.isOptional = isOptional
self.sortOrder = sortOrder
self.lastModified = Date()
}
}
Trzy szczegóły dotyczące tego kształtu, które API ukrywa.
@Model nie wymaga oddzielnej deklaracji schematu trwałego magazynu. SwiftData odczytuje definicję klasy w czasie kompilacji i syntezuje schemat. Właściwości klasy stają się atrybutami modelu; ich typy Swift stają się typami kolumn. Nie ma pliku .xcdatamodeld do utrzymywania (choć podstawowy NSManagedObjectModel z Core Data nadal istnieje i to on stanowi podstawę schematu w czasie wykonywania).2
@Attribute(.unique) to ograniczenie pojedynczej kolumny, a nie deklaracja PRIMARY KEY. Trwałą tożsamością SwiftData jest PersistentIdentifier, generowany automatycznie dla każdego rekordu. Deklaracja @Attribute(.unique) mówi frameworkowi: „ta kolumna przechowuje co najwyżej jeden rekord na wartość”. Gdy wstawia się model z wartością .unique, która już istnieje, SwiftData wykonuje upsert: istniejący rekord zostaje zaktualizowany, a nie odrzucony. Semantyka ma znaczenie dla kodu produktowego: .unique to nie walidacja na poziomie UI, która zapobiega przesyłaniu duplikatów; to gwarancja przechowywania „co najwyżej jednego”, która po cichu scala. Wzorzec id: UUID powyżej jest tym zalecanym do synchronizacji między procesami (gdy chce się mieć stabilny identyfikator, który przetrwa zniknięcie procesowego PersistentIdentifier), a zachowanie upsert to dokładnie to, czego można sobie życzyć, gdy ten sam UUID dociera z dwóch ścieżek synchronizacji.
Klasy @Model są typami referencyjnymi, a nie wartościowymi. Modyfikacja właściwości na instancji ShoppingItem wyzwala śledzenie zmian SwiftData; framework rejestruje zmianę i utrwala ją przy następnym zapisie kontekstu. Integracja SwiftUI poprzez @Query ponownie renderuje każdy widok obserwujący pasujący predykat. Wzorzec jest podobny do @Observable (omówionego w What SwiftUI Is Made Of), z trwałością nałożoną na wierzch.
Pola opcjonalne to tania migracja
Pole lastModified: Date? w ShoppingItem jest opcjonalne, a opcjonalność jest kluczowa. Pole zostało dodane po wydaniu v1, aby wspierać synchronizację między urządzeniami i rozwiązywanie konfliktów; istniejące rekordy na urządzeniach użytkowników nie miały żadnej wartości lastModified. Pole opcjonalne bez wartości domyślnej pozwala lekkiej migracji SwiftData obsłużyć to dodanie bez pisania jakiegokolwiek kodu migracji: istniejące rekordy otrzymują nil; nowe rekordy otrzymują to, co ustawia init.3
Ścieżka lekkiej migracji to grzeczna ścieżka frameworka. SwiftData inspekcjonuje nowy schemat i trwały magazyn, wnioskuje najmniejszą zgodną zmianę i ją stosuje. Migracja jest automatyczna; użytkownik niczego nie widzi; aplikacja uruchamia się normalnie na istniejących danych. Przypadki, które ścieżka lekka obsługuje czysto:
- Dodanie właściwości opcjonalnej
- Usunięcie właściwości (dane są usuwane; istniejące odczyty już nie widzą kolumny)
- Zmiana nazwy atrybutu, którą framework potrafi dopasować po wskazówce (przy użyciu
@Attribute(originalName: ...)) - Zmiana nazwy klasy
@Model, którą framework potrafi dopasować (przy użyciu@Model.originalNamelub wskazówki)
Przypadki, w których ścieżka lekka się wycofuje:
- Dodanie nieopcjonalnej właściwości bez wartości domyślnej do istniejącego schematu (istniejące rekordy nie mają wartości, którą można by ją wypełnić)
- Zmiana typu właściwości (np.
Int→String) - Podział modelu na dwa modele lub scalenie dwóch w jeden
- Cokolwiek, co wymaga niestandardowej logiki do migracji
Gdy ścieżka lekka się wycofuje, bezpiecznym zachowaniem jest niepowodzenie migracji. Niebezpiecznym zachowaniem byłoby usunięcie bazy danych i rozpoczęcie od nowa; framework jest zachowawczy i odmawia robienia tego po cichu. Użytkownik widzi awarię aplikacji przy uruchomieniu z błędem migracji; programista widzi ślad stosu wskazujący na niezgodność schematu; nikt nie traci danych, ale każdy traci zaufanie.
Koszt pominięcia VersionedSchema od pierwszego dnia ujawnia się na granicy v2 → v3, gdy dodaje się trzecią funkcję, której zmiana schematu wykracza poza to, co obsługuje ścieżka lekka.
VersionedSchema i MigrationPlan: dyscyplina od pierwszego dnia
VersionedSchema deklaruje konkretną wersję schematu modelu. MigrationPlan deklaruje, jak migrować z jednej wersji do następnej.4 Kształt:
import SwiftData
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] = [
SchemaV1.self,
SchemaV2.self,
]
static var stages: [MigrationStage] = [
MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
]
}
Same klasy modeli przenoszą się do przestrzeni nazw schematu wersjonowanego:
extension SchemaV1 {
@Model
final class ShoppingItemV1 { /* v1 fields */ }
}
extension SchemaV2 {
@Model
final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}
ModelContainer jest konstruowany z planem migracji:
let container = try ModelContainer(
for: ShoppingItemV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration("ShoppingList")
)
Plan migracji daje frameworkowi typowany graf ewolucji schematu. Gdy aplikacja wydająca v2 uruchamia się przeciwko bazie danych v1, framework przechodzi przez plan migracji, stosuje nazwane etapy i doprowadza bazę danych do v2. Gdy wydaje się v3, dodaje się SchemaV3.self do schemas oraz nowy MigrationStage między v2 a v3.
Dyscypliną jest wydanie VersionedSchema w v1, nawet gdy istnieje tylko jedna wersja. Kosztem zrobienia tego jest jeden dodatkowy plik i jedna dodatkowa deklaracja enum. Kosztem nierobienia tego jest to, że pierwsza nietrywialna zmiana schematu w v2 wymaga retroaktywnego owinięcia v1 w VersionedSchema, co jest wykonalne, ale wymaga ostrożności, by dopasować dokładny kształt v1 tak, by framework mógł zidentyfikować istniejące dane jako SchemaV1. Przyszły Pan/Pani pracujący nad v2 zapłaci ten podatek; obecny Pan/Pani może zapłacić go raz i o nim zapomnieć.
Niestandardowy MigrationStage dla trudnych przypadków
Lekkie migracje pokrywają większość zmian addytywnych. Zmiany typów, podziały, scalenia oraz wypełnienia warunkowe potrzebują MigrationStage.custom:
static var stages: [MigrationStage] = [
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Read v1 rows; stage any derived state to a transient store
// (UserDefaults / temp file) since the v1 and v2 contexts do
// not share state, and didMigrate cannot read v1.
let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
stageDerivedState(from: v1Items)
},
didMigrate: { context in
// Populate v2-only fields on existing rows
let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
for item in v2Items where item.lastModified == nil {
item.lastModified = Date()
}
try context.save()
}
)
]
Te dwie domknięcia uruchamiają się przed i po zastosowaniu przez framework migracji strukturalnej. willMigrate działa na schemacie v1; didMigrate działa na schemacie v2. Ciało domknięcia to normalny kod SwiftData (deskryptory pobierania, zapisy kontekstu modelu, te same APIy używane w działającej aplikacji), operujące przeciwko przejściowemu kontekstowi w trakcie migracji.
Wzorzec, który przetrwa w produkcji, to utrzymywanie pustego willMigrate i umieszczanie całej logiki wypełniania w didMigrate. Odczytywanie danych v1 wewnątrz willMigrate jest dozwolone, ale schemat v2 jeszcze nie istnieje z perspektywy frameworka, więc każde obliczenie musi zostać przygotowane w przejściowym magazynie, który domknięcie didMigrate może odczytać. Prostsza zasada: migracje strukturalne to zadanie frameworka; wypełnianie pól istniejących tylko w v2 na istniejących rekordach to zadanie didMigrate.
Kiedy @Attribute i @Relationship zasługują na swoje nazwy
Dwa makra wykonują większość pracy dekoracyjnej schematu w klasach @Model.
@Attribute dekoruje pojedynczą właściwość ograniczeniem lub wskazówką:
@Attribute(.unique)wymusza unikalność, jak wShoppingItem.id@Attribute(.externalStorage)przechowuje duże blobyDatapoza bazą danych (dane obrazów, bufory audio)@Attribute(originalName: "old_field_name")dopasowuje właściwość do przemianowanej kolumny podczas migracji@Attribute(.transformable(by: ...))stosujeValueTransformerdo typu nieobjętego Codable
Właściwa dyscyplina: stosować .unique dla pól, które naprawdę powinny być unikalne (UUID wygenerowany przez Pana/Panią, zewnętrzny identyfikator), stosować .externalStorage dla każdego bloba powyżej kilku KB, stosować originalName, gdy zmiana nazwy właściwości w v2 mogłaby w przeciwnym razie utracić dane v1.
@Relationship dekoruje właściwość, która wskazuje na inną klasę @Model lub ich kolekcję:
@Model
final class List {
var name: String
@Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
var items: [ShoppingItem] = []
}
@Model
final class ShoppingItem {
var name: String
var list: List?
}
deleteRule: .cascade oznacza, że usunięcie nadrzędnej List usuwa wszystkie podrzędne rekordy ShoppingItem. Parametr inverse: mówi frameworkowi, która właściwość na dziecku wskazuje z powrotem na rodzica; framework używa tego do przewidywalnego utrzymania dwukierunkowego. SwiftData może czasami wnioskować odwrotność automatycznie, a inverse: nil jest obsługiwany dla relacji jawnie jednokierunkowych, ale bezpieczną wartością domyślną jest deklarowanie inverse: zawsze, gdy wnioskowanie byłoby niejednoznaczne.5
Właściwa dyscyplina: deklarować relacje z jawnym deleteRule (domyślną wartością jest .nullify, która rzadko jest tym, czego się chce) i deklarować inverse: zawsze, gdy relacja jest dwukierunkowa (zamiast polegać na wnioskowaniu frameworka). Niejawne wartości domyślne są zwykle błędne; forma jawna to jeden dodatkowy parametr i błąd zaoszczędzony na zawsze.
Co zbudowałbym inaczej
Trzy wzorce, które aplikacje w klastrze albo wydają, albo żałują, że ich nie wydały.
Wydawać VersionedSchema od v1. Każda wydawana klasa @Model powinna żyć wewnątrz VersionedSchema od pierwszego dnia. Kosztem jest jeden owijający enum na każdą wersję schematu. Korzyścią jest to, że pierwsza nietrywialna zmiana w v2 to dodanie jednej linii do MigrationPlan.schemas zamiast dwudniowego retroaktywnego refaktoringu.
Robić każdy znacznik czasu opcjonalnym. Pola takie jak lastModified, createdAt i updatedAt, które istnieją dla synchronizacji między urządzeniami lub rozwiązywania konfliktów, powinny być opcjonalne w v1, jeśli produkt v1 ich nie potrzebuje. Opcjonalność sprawia, że migracja do v2 (gdy się ich potrzebuje) jest tania. Wypełnianie ich na istniejących rekordach podczas didMigrate to jedna pętla; uczynienie ich nieopcjonalnymi od v1 to ograniczenie, które może zepsuć backfill na danych użytkownika.
Używać UUID jako klucza naturalnego, a nie PersistentIdentifier. PersistentIdentifier SwiftData jest procesowy. Synchronizacja między urządzeniami, integracja MCP (omówiona w Two Agent Ecosystems, One Shopping List) i każda referencja poza procesem potrzebują stabilnego identyfikatora. UUID z @Attribute(.unique) to właściwy kształt; procesowy PersistentIdentifier to niewłaściwy kształt dla wszystkiego, co przekracza granicę procesu.
Kiedy @Model jest niewłaściwą odpowiedzią
Trzy przypadki, w których SwiftData nie jest właściwym narzędziem:
Stan klucz/wartość pojedynczego rekordu. Ustawienia aplikacji, wybrany język użytkownika, znacznik czasu ostatniej synchronizacji. Należy używać UserDefaults lub NSUbiquitousKeyValueStore (omówionych w Five Apple Platforms, Three Shared Files). Narzut SwiftData dla pojedynczego rekordu to zmarnowana ceremonia; magazyny klucz-wartość są właściwym substratem.
Dane autorytatywne po stronie serwera bez zapisów offline. Lista pobrana z REST API i wyświetlana tylko do odczytu. SwiftData jest przesadą, jeśli źródłem prawdy jest serwer, a lokalna pamięć podręczna to po prostu pamięć podręczna. Wystarcza prosty snapshot Codable w Documents/ plus tablica buforowana w pamięci; podatek migracyjny SwiftData nie jest wart płacenia, jeśli dane nie przeżywają twardego resetu.
Koordynacja wieloprocesowa. SwiftData działa wewnątrz procesu. Serwer MCP działający poza aplikacją iOS nie może odczytywać ani zapisywać kontenera SwiftData aplikacji. Stan międzyprocesowy potrzebuje innego kształtu: pliku JSON na iCloud Drive, współdzielonego kontenera App Group lub jawnej warstwy synchronizacji łączącej procesy. (Get Bananas paruje SwiftData z iCloud Drive JSON właśnie z tego powodu.)6
Dane to duże bloby, które rzadko się zmieniają. Plik audio 10 MB, zestaw obrazów 50 MB. Należy używać @Attribute(.externalStorage), jeśli bloby znajdują się wewnątrz rekordów SwiftData; w przeciwnym razie używać systemu plików bezpośrednio z metadanymi w SwiftData wskazującymi na URL plików.
Co ten wzorzec oznacza dla aplikacji wydawanych na iOS 26+
Trzy wnioski.
-
Makra to łatwa część. Migracje to koszt.
@Modeli@Attributeto dwuwierszowe deklaracje, które ukrywają dużo warstwy Core Data. Dyscyplina migracji to to, za co tak naprawdę płaci się przez cały okres życia aplikacji; należy projektować v1 z myślą o v2. -
VersionedSchemaod pierwszego dnia jest niepodlegające negocjacji dla wydawanych aplikacji. Owijającyenumto jeden dodatkowy plik. Retroaktywny koszt dodania go później jest znacznie wyższy. -
Pola opcjonalne i jawne relacje to tanie ubezpieczenie. Opcjonalne znaczniki czasu dla metadanych synchronizacji, jawne
deleteRuleiinverse:na relacjach. Oba to drobne deklaracje, które kupują dużo elastyczności v2.
Pełny klaster Apple Ecosystem: typowane App Intents dla Apple Intelligence; serwery MCP dla agentów między-LLM; pytanie o routing między nimi; Foundation Models dla on-device LLM i protokołu Tool; Live Activities dla maszyny stanów Lock Screen na iOS; kontrakt watchOS runtime na Apple Watch; SwiftUI internals dla substratu frameworka; model myślowy przestrzenny RealityKit dla scen visionOS; wzorce Liquid Glass dla warstwy wizualnej; wydawanie wieloplatformowe dla zasięgu między urządzeniami. Centrum znajduje się na stronie Apple Ecosystem Series. Dla szerszego kontekstu iOS-z-agentami-AI, zobacz iOS Agent Development guide.
FAQ
Jaka jest różnica między @Model a NSManagedObject z Core Data?
@Model to makro Swift, które generuje warstwę NSManagedObject pod spodem. SwiftData używa Core Data jako swojego magazynu zaplecza, więc model wykonawczy jest taki sam; różnica leży w powierzchni. @Model usuwa plik .xcdatamodeld, ceremonię transformatora wartości oraz zarządzanie cyklem życia NSManagedObjectContext. Otrzymuje się ten sam trwały magazyn z API ukształtowanym jak Swift.
Czy potrzebuję VersionedSchema, jeśli nigdy nie planuję zmieniać schematu?
Jeśli aplikacja może wydać v2, tak. Jeśli to jednorazowe demo, nie. Kosztem VersionedSchema od v1 jest jedna dodatkowa deklaracja enum. Kosztem dodania go retroaktywnie w v2 jest dopasowanie dokładnego kształtu schematu v1, by framework rozpoznał istniejące dane, co jest wykonalne, ale podatne na błędy. Większość wydawanych aplikacji w końcu będzie potrzebować zmiany schematu; należy uwzględnić to w budżecie v1.
Kiedy powinienem/powinnam używać @Attribute(.unique)?
Gdy pole jest naturalnym kluczem dla rekordu: UUID, który Pan/Pani wygenerował, zewnętrzny identyfikator, który Pan/Pani zaimportował, slug, który Pan/Pani przypisał. SwiftData traktuje .unique jako upsert: jeśli wstawi się model, którego wartość .unique już istnieje, istniejący rekord jest aktualizowany, a nie dołączany jest nowy rekord. Ta semantyka jest tym, co czyni bezpiecznymi ścieżki synchronizacji w stylu upsert (ten sam UUID przychodzący z dwóch urządzeń); jest to również powód, dla którego .unique to niewłaściwe narzędzie dla pól nazw wyświetlanych takich jak title, ponieważ dwóch użytkowników wpisujących ten sam tytuł po cichu scaliłoby swoje rekordy zamiast wyprodukować dwa odrębne rekordy.
Jak obsłużyć nieopcjonalne pole dodane do istniejącego schematu?
Należy użyć MigrationStage.custom z domknięciem didMigrate, które wypełnia pole na istniejących rekordach. Lub, łatwiej: zadeklarować pole jako opcjonalne w nowej wersji schematu i leniwie wypełniać przy dostępie. Opcjonalność to tańsza migracja; nieopcjonalne dodania potrzebują jawnej logiki wypełniania.
Co to PersistentIdentifier w stosunku do mojego własnego UUID?
PersistentIdentifier to procesowy identyfikator rekordu SwiftData; jest generowany automatycznie i przeżywa cykl życia działającego procesu. Pana/Pani własny UUID z @Attribute(.unique) to stabilny identyfikator między procesami i między urządzeniami. Należy używać PersistentIdentifier dla referencji procesowych wewnątrz aplikacji. Należy używać UUID dla wszystkiego, co przekracza granicę procesu (synchronizacja między urządzeniami, integracje zewnętrzne, narzędzia MCP, wywołania sieciowe).
Bibliografia
-
Autorskie Get Bananas, aplikacja listy zakupów SwiftUI, która łączy SwiftData z synchronizacją JSON na iCloud Drive oraz serwerem MCP. Model
ShoppingItemewoluował we wczesnym cyklu rozwoju; polelastModified: Date?zostało dodane po początkowym schemacie (commit268a00dz 2025-12-01, „Make lastModified optional to fix migration crash”), ponieważ uczynienie go nieopcjonalnym łamało migrację, gdy istniejące rekordy nie miały wartości do wypełnienia. ↩ -
Apple Developer, „SwiftData” oraz „Adding and editing persistent data in your app”. Makro
@Model, powierzchnia ograniczeń@Attributeoraz związek zNSManagedObjectModelz Core Data. ↩↩ -
Apple Developer, „Preserving your app’s model data across launches” oraz „Adopting SwiftData for a Core Data app”. Semantyka lekkiej migracji i co wyzwala wycofanie się frameworka. ↩
-
Apple Developer, „VersionedSchema” oraz „SchemaMigrationPlan”. Deklaracje schematu wersjonowanego, definicje etapów migracji oraz konstruktor
ModelContainerprzyjmujący plan migracji. ↩ -
Apple Developer, „Defining data relationships with enumerations and model classes” oraz „Schema.Relationship”. Makro
@Relationship, opcjedeleteRule(.cascade,.nullify,.deny,.noAction) oraz rola parametruinverse:w utrzymaniu relacji dwukierunkowej. ↩ -
Autorska analiza w Two Agent Ecosystems, One Shopping List, 29 kwietnia 2026, oraz Five Apple Platforms, Three Shared Files. Wzorce synchronizacji między procesami i między urządzeniami Get Bananas + Return, które uzupełniają (a czasami zastępują) SwiftData wewnątrz wieloprocesowego przepływu pracy. ↩