Prawdziwym kosztem SwiftData jest dyscyplina schematu
ShoppingItem z aplikacji Get Bananas to kanoniczny przykład tego, dlaczego dyscyplina schematu w SwiftData ma znaczenie. 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 zadeklarowane jako opcjonalne właśnie po to, by naprawić awarię migracji, która wystąpiła przy pierwszym dodaniu go jako nieopcjonalnego.1
API SwiftData to dwa makra. @Model na klasie czyni z niej typ trwały. @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. To, czego framework nie ukrywa, to migracja schematu; po prostu czyni ją deklaratywną zamiast imperatywną. Kosztem nieuwagi przy migracjach jest błąd, który wymazuje dane użytkownika podczas rutynowej aktualizacji.
Teza: SwiftData jest tani na początku i drogi przy niedbałych migracjach. Dyscyplina to nazewnictwo, opcjonalność i VersionedSchema od pierwszego dnia, a nie od dnia, w którym uświadomi się Pan/Pani, że powinno się je było wprowadzić.
TL;DR
- Makro
@Modelzamienia klasę w trwały typ SwiftData. Framework generuje schemat z deklaracji właściwości w czasie kompilacji. - Dodanie nowej właściwości opcjonalnej to migracja bez zmian: lekka migracja SwiftData obsługuje to automatycznie. Dodanie właściwości nieopcjonalnej do istniejącego schematu wymaga
VersionedSchemaorazMigrationPlan, który mówi frameworkowi, jak wypełnić nowe pole dla istniejących wierszy. - Kosztem pominięcia
VersionedSchemaod pierwszego dnia jest to, że każda nietrywialna zmiana schematu w v2 ryzykuje porzuceniem bazy danych użytkownika, ponieważ ścieżka lekkiej migracji jest zachowawcza i przerywa, gdy nie potrafi wywnioskować migracji. @Attribute(.unique)to właściwe narzędzie dla kluczy naturalnych (wygenerowanegoUUID, zaimportowanego identyfikatora zewnętrznego).@Relationshipto właściwe narzędzie dla referencji rodzic/dziecko. Oba są makrami, które generują pod spodem właściwą instalację Core Data.2
Co tak naprawdę robi @Model
Typ SwiftData to klasa Swift z zastosowanym makrem @Model. Kanoniczny kształt z Get Bananas to ShoppingItem:
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 osobnej deklaracji schematu trwałego magazynu. SwiftData odczytuje definicję klasy w czasie kompilacji i syntetyzuje schemat. Właściwości klasy stają się atrybutami modelu; ich typy Swift stają się typami kolumn. Nie ma pliku .xcdatamodeld do utrzymania (choć podstawowy NSManagedObjectModel z Core Data nadal istnieje i jest tym, co stanowi zaplecze schematu w czasie wykonania).2
@Attribute(.unique) to ograniczenie na pojedynczej kolumnie, a nie deklaracja PRIMARY KEY. Trwałą tożsamością SwiftData jest PersistentIdentifier, generowany automatycznie dla każdego wiersza. Deklaracja @Attribute(.unique) mówi frameworkowi: „ta kolumna przechowuje co najwyżej jeden wiersz na każdą wartość”. Gdy wstawia się model z wartością .unique, która już istnieje, SwiftData wykonuje upsert: istniejący wiersz jest aktualizowany, a nie odrzucany. Te semantyki mają znaczenie dla kodu produktowego: .unique nie jest walidacją na poziomie UI, która zapobiega zgłaszaniu duplikatów; jest gwarancją „co najwyżej jeden” w przechowywaniu, która po cichu scala. Wzorzec id: UUID powyżej jest tym zalecanym dla synchronizacji międzyprocesowej (gdzie chce się stabilnego identyfikatora przeżywającego znikający w procesie PersistentIdentifier), a zachowanie upsert to dokładnie to, czego się chce, gdy ten sam UUID dociera z dwóch ścieżek synchronizacji.
Klasy @Model są typami referencyjnymi, nie wartościowymi. Mutowanie właściwości na instancji ShoppingItem wyzwala śledzenie zmian SwiftData; framework rejestruje zmianę i utrwala ją przy następnym zapisie kontekstu. Integracja z SwiftUI poprzez @Query ponownie renderuje każdy widok obserwujący pasujący predykat. Wzorzec jest podobny do @Observable (omówionego w Z czego zbudowany jest SwiftUI), z dodaną na wierzchu warstwą trwałości.
Pola opcjonalne to tania migracja
Pole lastModified: Date? na ShoppingItem jest opcjonalne, a ta opcjonalność jest nośna. Pole zostało dodane po wydaniu v1, aby wspierać synchronizację między urządzeniami i rozwiązywanie konfliktów; istniejące wiersze 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 migracyjnego: istniejące wiersze otrzymują nil; nowe wiersze otrzymują to, co ustawia init.3
Ścieżka lekkiej migracji to uprzejma ścieżka frameworka. SwiftData inspekcjonuje nowy schemat i trwały magazyn, wnioskuje najmniejszą zgodną zmianę i ją stosuje. Migracja jest automatyczna; użytkownik nic 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ą porzucane; istniejące odczyty nie widzą już 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 przerywa:
- Dodanie właściwości nieopcjonalnej bez wartości domyślnej do istniejącego schematu (istniejące wiersze nie mają wartości do wypełnienia)
- 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 przerywa, bezpiecznym zachowaniem jest niepowodzenie migracji. Niebezpiecznym zachowaniem byłoby porzucenie bazy danych i zaczęcie od nowa; framework jest zachowawczy i odmawia robienia tego po cichu. Użytkownik widzi awarię aplikacji przy uruchomieniu z błędem migracji; deweloper widzi ślad stosu wskazujący na niezgodność schematu; nikt nie traci danych, ale wszyscy tracą zaufanie.
Koszt pominięcia VersionedSchema od pierwszego dnia ujawnia się na granicy v2 → v3, gdy dodaje się trzecią funkcję, której zmiana schematu przekracza 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 modelowe 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 tego, jak schemat ewoluuje. Gdy aplikacja wydająca v2 uruchamia się przeciwko bazie danych v1, framework przechodzi po planie migracji, stosuje nazwane etapy i sprowadza bazę danych do v2. Gdy wydaje się v3, dodaje się SchemaV3.self do schemas i nowy MigrationStage między v2 a v3.
Dyscyplina polega na wydaniu 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 staranności, aby dopasować dokładny kształt v1, 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 typu, podziały, scalenia i populacje 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 względem schematu v1; didMigrate działa względem schematu v2. Ciało domknięcia to normalny kod SwiftData (deskryptory pobrania, zapisy kontekstu modelu, te same APIy używane w działającej aplikacji), operujący względem przejściowego kontekstu w trakcie migracji.
Wzorzec, który przeżywa w produkcji, polega na utrzymywaniu willMigrate pustego i umieszczeniu całej logiki populacji w didMigrate. Odczyt danych v1 wewnątrz willMigrate jest dozwolony, ale schemat v2 nie istnieje jeszcze z perspektywy frameworka, więc każde obliczenie musi być umieszczone w przejściowym magazynie, który domknięcie didMigrate może odczytać. Prostsza zasada: migracje strukturalne to robota frameworka; populowanie pól tylko-v2 na istniejących wierszach to robota didMigrate.
Kiedy @Attribute i @Relationship zarabiają 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 nie-Codable
Właściwa dyscyplina: używać .unique dla pól, które rzeczywiście powinny być unikalne (wygenerowane UUID, zewnętrzny identyfikator), używać .externalStorage dla każdego bloba większego niż kilka KB, używać originalName, gdy zmiana nazwy właściwości w v2 w przeciwnym razie utraciłaby dane v1.
@Relationship dekoruje właściwość wskazującą 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 wiersze ShoppingItem. Parametr inverse: mówi frameworkowi, która właściwość na dziecku wskazuje z powrotem na rodzica; framework używa tego do przewidywalnej dwukierunkowej obsługi. SwiftData może czasem wnioskować inwersję automatycznie, a inverse: nil jest wspierane dla jawnie jednokierunkowych relacji, ale bezpiecznym domyślem jest deklarowanie inverse:, ilekroć wnioskowanie byłoby niejednoznaczne.5
Właściwa dyscyplina: deklarować relacje z jawnym deleteRule (domyślną wartością jest .nullify, co rzadko jest tym, czego się chce) i deklarować inverse:, ilekroć relacja jest dwukierunkowa (zamiast polegać na wnioskowaniu frameworka). Niejawne wartości domyślne są zwykle złe; jawna forma to jeden dodatkowy parametr i na zawsze zaoszczędzony błąd.
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 jedno opakowujące enum na wersję schematu. Korzyścią jest to, że pierwsza nietrywialna zmiana w v2 jest jednowierszowym dodaniem do MigrationPlan.schemas, a nie dwudniowym retroaktywnym refaktoringiem.
Każdy znacznik czasu uczynić 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ść utrzymuje migrację do v2 (gdy się ich potrzebuje) tanią. Wypełnianie ich na istniejących wierszach 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 w obrębie procesu. Synchronizacja między urządzeniami, integracja MCP (omówiona w Dwa ekosystemy agentów, jedna lista zakupów) i każda referencja poza procesem potrzebują stabilnego identyfikatora. UUID z @Attribute(.unique) to właściwy kształt; w-procesowy PersistentIdentifier to zły kształt dla wszystkiego, co przekracza granicę procesu.
Kiedy @Model jest złą odpowiedzią
Trzy przypadki, w których SwiftData nie jest właściwym narzędziem:
Stan klucz/wartość pojedynczego rekordu. Ustawienia aplikacji, wybrany przez użytkownika język, znacznik czasu ostatniej synchronizacji. Należy używać UserDefaults lub NSUbiquitousKeyValueStore (omówione w Pięć platform Apple, trzy współdzielone pliki). Narzut SwiftData dla pojedynczego wiersza to zmarnowana ceremonia; magazyny klucz-wartość są właściwym substratem.
Dane autorytatywne po stronie serwera bez zapisów offline. Lista pobierana 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 jest tylko pamięcią podręczną. Prosty zrzut Codable w Documents/ plus tablica buforowana w pamięci wystarczy; podatek migracyjny SwiftData nie jest wart zapł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ć do 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 JSON na iCloud Drive 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 są wewnątrz wierszy SwiftData; w przeciwnym razie używać systemu plików bezpośrednio z metadanymi w SwiftData wskazującymi na URL-e plików.
Co wzorzec oznacza dla aplikacji wydawanych na iOS 26+
Trzy wnioski.
-
Makra to łatwa część. Migracje to koszt.
@Modeli@Attributeto dwuwierszowe deklaracje, które ukrywają sporo instalacji Core Data. Dyscyplina migracyjna to to, za co rzeczywiście się płaci przez cykl życia aplikacji; należy projektować v1 z myślą o v2. -
VersionedSchemaod pierwszego dnia jest nienegocjowalna dla wydawanych aplikacji. Opakowująceenumto jeden dodatkowy plik. Retroaktywny koszt dodania go później jest znacznie wyższy. -
Pola opcjonalne i jawne relacje to tania polisa ubezpieczeniowa. Opcjonalne znaczniki czasu dla metadanych synchronizacji, jawne
deleteRuleiinverse:na relacjach. Oba to drobne deklaracje, które kupują dużo elastyczności w v2.
Pełny klaster Apple Ecosystem: typowane App Intents dla Apple Intelligence; serwery MCP dla agentów cross-LLM; pytanie o routing między nimi; Foundation Models dla LLM on-device i protokołu Tool; Live Activities dla maszyny stanów Lock Screen na iOS; kontrakt środowiska wykonawczego watchOS na Apple Watch; wnętrzności SwiftUI dla substratu frameworka; model mentalny przestrzeni RealityKit dla scen visionOS; wzorce Liquid Glass dla warstwy wizualnej; wieloplatformowe wydawanie dla zasięgu między urządzeniami. Hub znajduje się w serii Apple Ecosystem. Szerszy kontekst iOS-z-agentami-AI można znaleźć w przewodniku iOS Agent Development.
FAQ
Jaka jest różnica między @Model a NSManagedObject z Core Data?
@Model to makro Swift, które generuje pod spodem instalację NSManagedObject. SwiftData używa Core Data jako swojego magazynu zaplecza, więc model wykonawczy jest taki sam; różnica leży w warstwie powierzchniowej. @Model usuwa plik .xcdatamodeld, ceremonię transformatora wartości i zarządzanie cyklem życia NSManagedObjectContext. Otrzymuje się ten sam trwały magazyn z API ukształtowanym po Swiftowsku.
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, aby 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; warto budżetować na to w v1.
Kiedy powinienem/powinnam używać @Attribute(.unique)?
Gdy pole jest kluczem naturalnym dla wiersza: wygenerowanym UUID, zaimportowanym zewnętrznym identyfikatorem, przypisanym slugiem. SwiftData traktuje .unique jako upsert: jeśli wstawi się model, którego wartość .unique już istnieje, istniejący wiersz jest aktualizowany zamiast dołączania nowego wiersza. Te semantyki sprawiają, że ścieżki synchronizacji typu upsert (ten sam UUID przychodzący z dwóch urządzeń) są bezpieczne; to także powód, dla którego .unique to złe narzędzie na polach z nazwami wyświetlanymi, takich jak title, ponieważ dwóch użytkowników wpisujących ten sam tytuł po cichu scaliłoby swoje wiersze, zamiast wytworzyć dwa odrębne rekordy.
Jak obsłużyć pole nieopcjonalne dodane do istniejącego schematu?
Należy użyć MigrationStage.custom z domknięciem didMigrate, które wypełnia pole na istniejących wierszach. Albo, łatwiej: zadeklarować pole jako opcjonalne w nowej wersji schematu i wypełniać je leniwie przy dostępie. Opcjonalność to tańsza migracja; dodanie nieopcjonalnych wymaga jawnej logiki populacji.
Czym jest PersistentIdentifier w porównaniu z moim własnym UUID?
PersistentIdentifier to ID wiersza SwiftData w obrębie procesu; jest generowany automatycznie i przeżywa cykl życia działającego procesu. Własny UUID z @Attribute(.unique) jest stabilnym identyfikatorem międzyprocesowym i międzyurządzeniowym. Należy używać PersistentIdentifier dla referencji w obrębie procesu 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
-
Autorska aplikacja Get Bananas, aplikacja listy zakupów w SwiftUI, która paruje SwiftData z synchronizacją JSON na iCloud Drive i serwerem MCP. Model
ShoppingItemewoluował przez wczesny cykl rozwojowy; polelastModified: Date?zostało dodane po początkowym schemacie (commit268a00dz 2025-12-01, „Make lastModified optional to fix migration crash”), ponieważ uczynienie go nieopcjonalnym psuło migrację, gdy istniejące wiersze nie miały wartości do wypełnienia. ↩ -
Apple Developer, „SwiftData” i „Adding and editing persistent data in your app”. Makro
@Model, powierzchnia ograniczeń@Attributeoraz relacja doNSManagedObjectModelz Core Data. ↩↩ -
Apple Developer, „Preserving your app’s model data across launches” i „Adopting SwiftData for a Core Data app”. Semantyka lekkich migracji i to, co wyzwala framework do przerwania. ↩
-
Apple Developer, „VersionedSchema” i „SchemaMigrationPlan”. Deklaracje schematów wersjonowanych, definicje etapów migracji oraz konstruktor
ModelContainerprzyjmujący plan migracji. ↩ -
Apple Developer, „Defining data relationships with enumerations and model classes” i „Schema.Relationship”. Makro
@Relationship, opcjedeleteRule(.cascade,.nullify,.deny,.noAction) oraz rola parametruinverse:w utrzymaniu dwukierunkowej relacji. ↩ -
Analiza autora w Dwa ekosystemy agentów, jedna lista zakupów, 29 kwietnia 2026, oraz Pięć platform Apple, trzy współdzielone pliki. Wzorce synchronizacji międzyprocesowej i międzyurządzeniowej Get Bananas + Return, które uzupełniają (a czasem zastępują) SwiftData wewnątrz wieloprocesowego workflow. ↩