Prawdziwym kosztem SwiftData jest dyscyplina schematu
Klasa ShoppingItem z 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 konkretnej postaci migracji, ponieważ dane istniały już na dysku, a pole zostało oznaczone jako opcjonalne właśnie po to, by naprawić awarię migracji, która pojawiła się, gdy dodano je najpierw jako nieopcjonalne.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 value transformerów oraz boilerplate NSManagedObjectContext. Tym, czego framework nie ukrywa, jest migracja schematu; sprawia jedynie, że migracja staje się deklaratywna zamiast imperatywnej. Kosztem braku uwagi poświęconej migracjom jest błąd, który przy rutynowej aktualizacji kasuje dane użytkownika.
Teza: SwiftData jest tanie na starcie i drogie przy niechlujnej migracji. Dyscyplina polega na nazewnictwie, opcjonalności i VersionedSchema od pierwszego dnia, a nie od dnia, w którym okazuje się, że trzeba je było wprowadzić.
W skrócie
- Makro
@Modelzamienia klasę w trwały typ SwiftData. Framework generuje schemat na etapie kompilacji na podstawie deklaracji właściwości. - Dodanie nowej właściwości opcjonalnej to migracja bez wysiłku: lekka migracja SwiftData obsłuży ją sama. Dodanie właściwości nieopcjonalnej do istniejącego schematu wymaga
VersionedSchemaorazMigrationPlan, który mówi frameworkowi, jak wypełnić nowe pole w istniejących wierszach. - Rezygnacja z
VersionedSchemaod pierwszego dnia kosztuje tyle, że każda nietrywialna zmiana schematu w wersji v2 grozi utratą bazy danych użytkownika, ponieważ ścieżka lekka jest zachowawcza i przerywa pracę, gdy nie potrafi wywnioskować migracji. @Attribute(.unique)to właściwe narzędzie dla kluczy naturalnych (wygenerowanyUUID, zaimportowany identyfikator zewnętrzny).@Relationshipto właściwe narzędzie dla odniesień rodzic/dziecko. Oba są makrami, które pod spodem generują odpowiednią instalację Core Data.2
Co naprawdę robi @Model
Typ SwiftData to klasa Swift z zastosowanym makrem @Model. Klasa ShoppingItem z Get Bananas pokazuje kanoniczną postać:
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 tej postaci, które API ukrywa.
@Model nie wymaga osobnej deklaracji schematu dla magazynu trwałego. SwiftData czyta definicję klasy na etapie kompilacji i syntetyzuje z niej schemat. Właściwości klasy stają się atrybutami modelu; ich typy Swift stają się typami kolumn. Nie ma pliku .xcdatamodeld, który trzeba by utrzymywać (choć leżący pod spodem NSManagedObjectModel z Core Data wciąż istnieje i to on niesie schemat w czasie działania).2
@Attribute(.unique) to ograniczenie na pojedynczej kolumnie, a nie deklaracja PRIMARY KEY. Tożsamością trwałą w SwiftData jest PersistentIdentifier, generowany automatycznie dla każdego wiersza. Deklaracja @Attribute(.unique) mówi frameworkowi: „ta kolumna przechowuje co najwyżej jeden wiersz na wartość”. Gdy wstawiany jest model z wartością .unique, która już istnieje, SwiftData wykonuje upsert: istniejący wiersz zostaje zaktualizowany, a nie odrzucony. Ta semantyka ma znaczenie w kodzie produktowym: .unique nie jest walidacją na poziomie interfejsu, która zapobiega wysłaniu duplikatów; to gwarancja magazynowa „co najwyżej jeden”, która po cichu scala. Powyższy wzorzec id: UUID jest zalecany dla synchronizacji międzyprocesowej (gdy potrzebny jest stabilny identyfikator przeżywający zniknięcie wewnątrzprocesowego PersistentIdentifier), a zachowanie typu upsert to dokładnie to, czego się oczekuje, gdy ten sam UUID przychodzi dwiema ścieżkami synchronizacji.
Klasy @Model są typami referencyjnymi, nie typami wartościowymi. Zmiana właściwości w instancji ShoppingItem uruchamia śledzenie zmian w SwiftData; framework rejestruje zmianę i utrwala ją przy kolejnym zapisie kontekstu. Integracja z SwiftUI poprzez @Query odświeża każdy widok obserwujący pasujący predykat. Wzorzec przypomina @Observable (omówione w What SwiftUI Is Made Of), tyle że z warstwą trwałości na wierzchu.
Pola opcjonalne to tania migracja
Pole lastModified: Date? w ShoppingItem jest opcjonalne i ta opcjonalność dźwiga ciężar całej konstrukcji. Pole dodano po wydaniu wersji v1, aby obsłużyć synchronizację między urządzeniami i rozstrzyganie 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ć dodanie bez pisania jakiegokolwiek kodu migracji: istniejące wiersze dostają nil; nowe dostają to, co ustawi inicjalizator.3
Ścieżka lekkiej migracji to uprzejma ścieżka frameworku. SwiftData bada nowy schemat oraz magazyn trwały, wnioskuje najmniejszą zgodną zmianę i ją stosuje. Migracja jest automatyczna; użytkownik niczego nie widzi; aplikacja startuje 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 przepadają; istniejące odczyty nie widzą już kolumny)
- Zmiana nazwy atrybutu, którą framework potrafi dopasować dzięki wskazówce (za pomocą
@Attribute(originalName: ...)) - Zmiana nazwy klasy
@Model, którą framework potrafi dopasować (za pomocą@Model.originalNamelub wskazówki)
Przypadki, przy których ścieżka lekka przerywa:
- Dodanie do istniejącego schematu właściwości nieopcjonalnej bez wartości domyślnej (istniejące wiersze nie mają czym jej wypełnić)
- Zmiana typu właściwości (np.
Int→String) - Podział modelu na dwa modele albo scalenie dwóch w jeden
- Wszystko, co do migracji wymaga własnej logiki
Gdy ścieżka lekka przerywa, bezpiecznym zachowaniem jest doprowadzenie migracji do porażki. Zachowaniem niebezpiecznym byłoby porzucenie bazy danych i zaczęcie od nowa; framework jest zachowawczy i odmawia zrobienia tego po cichu. Użytkownik widzi awarię aplikacji przy starcie z błędem migracji; programista widzi ślad stosu wskazujący na niezgodność schematu; nikt nie traci danych, ale wszyscy tracą zaufanie.
Koszt rezygnacji z VersionedSchema od pierwszego dnia ujawnia się na granicy v2 → v3, przy dodawaniu trzeciej funkcji, której zmiana schematu przekracza możliwości ścieżki lekkiej.
VersionedSchema i MigrationPlan: dyscyplina od pierwszego dnia
VersionedSchema deklaruje konkretną wersję schematu modelu. MigrationPlan deklaruje, jak migrować z jednej wersji do następnej.4 Postać:
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 wersjonowanego schematu:
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 w wersji v2 startuje na bazie danych w wersji v1, framework przechodzi plan migracji, stosuje nazwane etapy i doprowadza bazę do v2. Przy wydaniu v3 dodaje się SchemaV3.self do schemas oraz nową MigrationStage pomiędzy v2 a v3. Pełny model migracji — które zmiany są automatyczne, które wymagają zadeklarowanego etapu i jaka awaria sumy kontrolnej czeka na tego, kto zadeklaruje niepotrzebne V2 — jest tematem tekstu towarzyszącego SwiftData migrations: lightweight vs custom.
Dyscyplina polega na wydaniu VersionedSchema już w v1, nawet gdy wersja jest tylko jedna. Kosztuje to jeden plik więcej i jedną deklarację enum więcej. Rezygnacja kosztuje tyle, że pierwsza nietrywialna zmiana schematu w v2 wymaga wstecznego opakowania v1 w VersionedSchema — co jest wykonalne, lecz wymaga staranności w odtworzeniu dokładnej postaci v1, aby framework rozpoznał istniejące dane jako SchemaV1. Przyszłe wcielenie programisty zapłaci ten podatek przy v2; obecne może zapłacić go raz i o nim zapomnieć.
Własny MigrationStage do trudnych przypadków
Lekkie migracje pokrywają większość zmian addytywnych. Zmiany typów, podziały, scalenia i wypełnienia warunkowe wymagają 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()
}
)
]
Oba domknięcia uruchamiają się przed zastosowaniem migracji strukturalnej przez framework i po nim. willMigrate działa na schemacie v1; didMigrate działa na schemacie v2. Ciało domknięcia to zwykły kod SwiftData (deskryptory pobrania, zapisy kontekstu modelu, te same API co w działającej aplikacji), pracujący na przejściowym kontekście migracyjnym.
Wzorzec, który przeżywa produkcję, pozostawia willMigrate pustym i umieszcza całą logikę wypełniania w didMigrate. Odczyt danych v1 wewnątrz willMigrate jest dozwolony, lecz z perspektywy frameworku schemat v2 jeszcze nie istnieje, więc wynik każdego obliczenia trzeba odłożyć do magazynu przejściowego, który domknięcie didMigrate zdoła odczytać. Prostsza reguła: migracje strukturalne to zadanie frameworku; wypełnienie pól właściwych dla v2 w istniejących wierszach to zadanie didMigrate.
Kiedy @Attribute i @Relationship zasługują na swoje nazwy
Dwa makra wykonują większość pracy dekorowania 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 dźwięku)@Attribute(originalName: "old_field_name")dopasowuje właściwość do przemianowanej kolumny podczas migracji@Attribute(.transformable(by: ...))stosujeValueTransformerdo typu niebędącego Codable
Właściwa dyscyplina: .unique dla pól, które naprawdę powinny być unikalne (wygenerowany UUID, identyfikator zewnętrzny), .externalStorage dla każdego bloba powyżej kilku KB, originalName wtedy, gdy zmiana nazwy właściwości w v2 w innym razie oznaczałaby utratę danych z v1.
@Relationship dekoruje właściwość wskazującą na inną klasę @Model lub na 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ść dziecka wskazuje z powrotem na rodzica; framework wykorzystuje go do przewidywalnego utrzymania relacji dwukierunkowej. SwiftData bywa w stanie wywnioskować odwrotność samodzielnie, a inverse: nil jest obsługiwane dla relacji jawnie jednokierunkowych, jednak bezpiecznym domyślnym wyborem 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, rzadko ta pożądana) i deklarować inverse: zawsze, gdy relacja jest dwukierunkowa (zamiast polegać na wnioskowaniu frameworku). Wartości domyślne bywają zwykle błędne; postać jawna to jeden parametr więcej i błąd oszczędzony na zawsze.
Przekraczanie granicy aktora: wyślij identyfikator, nie graf
Klasa @Model nie jest Sendable i właściwym ruchem jest zaprzestanie prób uczynienia jej taką. Instancja to referencja do żywego grafu obiektów trzymanego przez ModelContext; framework nie może obiecać, że graf ten da się bezpiecznie czytać z innego aktora, dlatego typ celowo pozostaje bez Sendable. Wymuszenie zgodności nie sprawia, że wyścig danych znika; jedynie go ukrywa.7
Wzorzec, który działa, polega na wysłaniu tożsamości i zwykłych wartości, a następnie ponownym pobraniu po drugiej stronie. PersistentIdentifier jest Sendable, więc przekracza granicę czysto. Należy wyciągnąć te wartości skalarne, których potrzebuje miejsce docelowe (nazwę, flagę, różnicę w małej strukturze), przekazać je obok identyfikatora, a odbierającemu aktorowi pozwolić pobrać model z własnego kontekstu na podstawie tego identyfikatora:
// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)
// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem
Trybem awarii, którego trzeba unikać, jest przekazanie samego grafu modelu. Gdy fragment grafu przekracza granicę, odbiorca dostaje model, który po drugiej stronie nawadnia się tylko częściowo: relacje i właściwości ładowane leniwie, nigdy niezmaterializowane w kontekście źródłowym, rozwiązują się względem niewłaściwego kontekstu (albo wcale), a błędy, które z tego wynikają, należą do cichych. Identyfikator wraz z wyciągniętymi wartościami to bezpieczny kontrakt; graf nim nie jest. ModelActor domyka tę dyscyplinę, posiadając kontekst i wydając wartości zamiast instancji.7
Synchronizacja z CloudKit i pułapka uprawnienia App Group
Przeniesienie magazynu SwiftData do kontenera App Group, aby widżet lub rozszerzenie mogły go czytać, wchodzi w interakcję z synchronizacją CloudKit w sposób, który gryzie aplikacje już po wydaniu. Dwa fakty pozwalają wyprowadzić resztę.
Po pierwsze, położenie magazynu. Przy domyślnej ModelConfiguration SwiftData sam kopiuje istniejący magazyn do kontenera App Group, gdy aplikacja przechodzi od braku grupy do App Group; Apple formułuje to tak, że SwiftData „copies the existing store to the app group container”.8 Przy własnym adresie URL magazynu położenie należy do programisty: to on kopiuje plik do nowego kontenera i sam kieruje tam konfigurację. Ścieżka domyślna jest wygodna właśnie dlatego, że framework wykonuje kopiowanie; ścieżka własna wymienia tę wygodę na kontrolę.
Po drugie, uprawnienie. Każdy członek App Group czytający magazyn synchronizowany z CloudKit musi nieść to samo uprawnienie CloudKit, ponieważ każdy z tych procesów będzie synchronizował ten kontener na własny rachunek. Ten wymóg jest właśnie pułapką: widżet ani rozszerzenie nie mają budżetu czasu wykonania ani okna pierwszego planu, by przeprowadzić rozgadaną synchronizację, a wręczenie im uprawnienia CloudKit zmusza je do próby. Rozwiązaniem jest podział na dwie instancje ModelConfiguration: jeden magazyn synchronizowany (uprawnienie CloudKit, należący do aplikacji głównej) i jeden magazyn lokalny w kontenerze App Group, który widżet i rozszerzenia czytają, nigdy nie synchronizując. Synchronizację warto umieścić tam, gdzie aplikacja na pierwszym planie wykona ją dobrze, a dane współdzielone do odczytu trzymać poza ścieżką synchronizacji.8
Co zbudowałbym inaczej
Trzy wzorce, które aplikacje z tego klastra albo wydają, albo żałują, że nie wydały.
Wydawaj VersionedSchema od v1. Każda wydana klasa @Model powinna od pierwszego dnia żyć wewnątrz VersionedSchema. Kosztem jest jeden opakowujący enum na wersję schematu. Korzyścią jest to, że pierwsza nietrywialna zmiana w v2 staje się jednolinijkowym dodatkiem do MigrationPlan.schemas zamiast dwudniowej wstecznej refaktoryzacji.
Każdy znacznik czasu niech będzie opcjonalny. Pola takie jak lastModified, createdAt i updatedAt, istniejące na potrzeby synchronizacji między urządzeniami lub rozstrzygania konfliktów, powinny być w v1 opcjonalne, jeśli produkt w wersji v1 ich nie potrzebuje. Opcjonalność utrzymuje niski koszt migracji do v2 (wtedy, gdy będą już potrzebne). Wypełnienie ich w istniejących wierszach podczas didMigrate to jedna pętla; uczynienie ich nieopcjonalnymi od v1 to ograniczenie, które potrafi rozbić uzupełnianie danych użytkowników.
Jako klucza naturalnego używaj UUID, nie PersistentIdentifier. PersistentIdentifier w SwiftData żyje wewnątrz procesu. Synchronizacja między urządzeniami, integracja z MCP (omówiona w Two Agent Ecosystems, One Shopping List) oraz każde odniesienie spoza procesu wymagają stabilnego identyfikatora. UUID z @Attribute(.unique) ma właściwą postać; wewnątrzprocesowy PersistentIdentifier ma postać niewłaściwą 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ść w jednym rekordzie. Ustawienia aplikacji, wybrany przez użytkownika język, znacznik czasu ostatniej synchronizacji. Warto sięgnąć po UserDefaults lub NSUbiquitousKeyValueStore (omówione w Five Apple Platforms, Three Shared Files). Narzut SwiftData dla pojedynczego wiersza to zmarnowana ceremonia; magazyny klucz-wartość są tu właściwym podłożem.
Dane rozstrzygane po stronie serwera, bez zapisów offline. Lista pobrana z API REST i wyświetlana tylko do odczytu. SwiftData to przerost formy, jeśli źródłem prawdy jest serwer, a lokalna pamięć podręczna pozostaje wyłącznie pamięcią podręczną. Wystarczy prosty zrzut Codable w Documents/ plus tablica trzymana w pamięci; podatek migracyjny SwiftData nie opłaca się, jeśli dane nie muszą przetrwać twardego resetu.
Koordynacja międzyprocesowa. SwiftData działa wewnątrz procesu. Serwer MCP działający poza aplikacją iOS nie może czytać ani zapisywać kontenera SwiftData tej aplikacji. Stan międzyprocesowy potrzebuje innej postaci: pliku JSON w iCloud Drive, współdzielonego kontenera App Group albo jawnej warstwy synchronizacji spinającej procesy. (Get Bananas łączy SwiftData z JSON-em w iCloud Drive dokładnie z tego powodu.)6
Dane to duże bloby, które zmieniają się rzadko. Plik dźwiękowy o rozmiarze 10 MB, zbiór obrazów o rozmiarze 50 MB. Warto użyć @Attribute(.externalStorage), jeśli bloby siedzą w wierszach SwiftData; w przeciwnym razie lepiej sięgnąć wprost po system plików, a w SwiftData trzymać metadane wskazujące adresy URL plików.
Kiedy Core Data wciąż wygrywa
SwiftData to warstwa nad Core Data, a nie zamiennik wszystkiego, co Core Data potrafi, i trzy lata później konkretny zestaw zadań nadal należy do starszego frameworku. Wybór Core Data do jednego z nich nie jest decyzją podyktowaną zaszłościami; jest decyzją poprawną tu i teraz.
Agregaty po stronie bazy danych. SwiftData nie ma odpowiednika pobrań Core Data opartych na NSExpression, tych, które spychają sum, average, min i max do SQLite, aby baza policzyła je bez ładowania wierszy9. W SwiftData pobiera się i redukuje w pamięci, co przy dużej tabeli przekreśla cały sens. Udokumentowanym wyjściem awaryjnym jest koegzystencja: Apple opisuje uruchomienie „two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store”, co pozwala stronie Core Data policzyć agregat zepchnięty do SQL na pliku należącym do SwiftData9. Mechanikę, wraz z wymogiem NSPersistentHistoryTrackingKey, omawia SwiftData performance is a storage problem.
Udostępnianie i publiczna baza CloudKit. Automatyczna synchronizacja iCloud w SwiftData jedzie pod spodem na NSPersistentCloudKitContainer, a kontener, który konfiguruje, odzwierciedla magazyn w prywatnej bazie CloudKit użytkownika10. Współpraca pomiędzy różnymi użytkownikami iCloud poprzez CKShare oraz publikowanie do bazy publicznej to udokumentowane możliwości Core Data + CloudKit, bez żadnego API na poziomie SwiftData na czas bet iOS 2710. Aplikacja, której podstawową funkcją są współdzielone listy lub dokumenty tworzone wspólnie, albo schodzi do Core Data dla magazynów synchronizowanych, albo buduje warstwę CloudKit ręcznie.
Aktualizacje wsadowe na poziomie magazynu. NSBatchUpdateRequest z Core Data przepisuje pasujące wiersze bezpośrednio w magazynie, bez ładowania obiektów11. ModelContext w SwiftData ma połowę usuwającą (delete(model:where:) przyjmuje predykat), lecz nie ma odpowiednika dla aktualizacji wsadowej, więc masowe przepisanie pola w SwiftData oznacza zmaterializowanie każdego objętego nim modelu.
Próg wdrożenia poniżej iOS 17. SwiftData wymaga iOS 17; Core Data sięga tak daleko wstecz, jak daleko sięga wsparcie samej aplikacji, a jego kontener CloudKit aż do iOS 131012. Baza kodu z długim ogonem systemów nie ma tu wyboru.
Warto nazwać to, co z tej listy wypadło: „potrzebny jest NSFetchedResultsController poza widokiem” było punktem dla Core Data do czasu, aż bety iOS 27 dodały ResultsObserver, który obserwuje pobranie w dowolnym miejscu aplikacji przez Swift Observation13. Lista luk kurczy się z każdym wydaniem. Rozsądny zakład: nowe aplikacje zaczynać na SwiftData, dla wymienionych wyżej zadań sięgać po koegzystencję, a pełny stos Core Data traktować jako odpowiedź dopiero wtedy, gdy wymusza to udostępnianie, baza publiczna albo próg wdrożenia.
Co ten wzorzec oznacza dla aplikacji wydawanych na iOS 26+
Trzy wnioski.
-
Makra są łatwą częścią. Kosztem są migracje.
@Modeli@Attributeto dwuwierszowe deklaracje ukrywające sporo instalacji Core Data. Dyscyplina migracji jest tym, za co naprawdę się płaci przez cały cykl życia aplikacji; wersję v1 warto projektować z myślą o v2. -
VersionedSchemaod pierwszego dnia jest w wydawanych aplikacjach nienegocjowalne. Opakowującyenumto jeden plik więcej. Wsteczny 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:w relacjach. Obie rzeczy to drobne deklaracje, które kupują sporo swobody w v2.
Pełny klaster o ekosystemie Apple: typowane App Intents dla Apple Intelligence; serwery MCP dla agentów działających w różnych LLM; pytanie o routing pomiędzy nimi; Foundation Models dla LLM na urządzeniu i protokołu Tool; Live Activities dla maszyny stanów ekranu blokady w iOS; kontrakt środowiska uruchomieniowego watchOS na Apple Watch; wnętrzności SwiftUI jako podłoże frameworku; przestrzenny model myślowy RealityKit dla scen visionOS; wzorce Liquid Glass dla warstwy wizualnej; wydawanie wieloplatformowe dla zasięgu na wielu urządzeniach. Węzłem centralnym jest Apple Ecosystem Series. Szerszy kontekst iOS z agentami AI opisuje iOS Agent Development guide.
Często zadawane pytania
Czym różni się @Model od NSManagedObject z Core Data?
@Model to makro Swift, które pod spodem generuje instalację NSManagedObject. SwiftData używa Core Data jako magazynu bazowego, więc model w czasie działania pozostaje ten sam; różnica leży w powierzchni. @Model usuwa plik .xcdatamodeld, ceremonię value transformerów oraz zarządzanie cyklem życia NSManagedObjectContext. Otrzymuje się ten sam magazyn trwały z API o kształcie Swift.
Czy potrzebuję VersionedSchema, jeśli nigdy nie planuję zmieniać schematu?
Jeśli aplikacja może kiedyś wydać v2 — tak. Jeśli to jednorazowe demo — nie. VersionedSchema od v1 kosztuje jedną dodatkową deklarację enum. Dodanie go wstecznie przy v2 kosztuje odtworzenie dokładnej postaci schematu v1, aby framework rozpoznał istniejące dane, co jest wykonalne, lecz podatne na błędy. Większość wydawanych aplikacji prędzej czy później będzie potrzebowała zmiany schematu; warto ją przewidzieć już w v1.
Kiedy stosować @Attribute(.unique)?
Gdy pole jest kluczem naturalnym wiersza: wygenerowanym UUID, zaimportowanym identyfikatorem zewnętrznym, przypisanym slugiem. SwiftData traktuje .unique jako upsert: przy wstawieniu modelu, którego wartość .unique już istnieje, istniejący wiersz zostaje zaktualizowany zamiast dołożenia nowego. To właśnie ta semantyka czyni bezpiecznymi ścieżki synchronizacji typu upsert (ten sam UUID przychodzący z dwóch urządzeń); to także powód, dla którego .unique jest złym narzędziem w polach wyświetlanych, takich jak title, bo dwoje użytkowników wpisujących ten sam tytuł po cichu scaliłoby swoje wiersze zamiast utworzyć dwa odrębne rekordy.
Jak poradzić sobie z polem nieopcjonalnym dodanym do istniejącego schematu?
Należy użyć MigrationStage.custom z domknięciem didMigrate, które wypełni pole w istniejących wierszach. Albo prościej: zadeklarować pole jako opcjonalne w nowej wersji schematu i wypełniać je leniwie przy dostępie. Opcjonalność to tańsza migracja; dodanie pola nieopcjonalnego wymaga jawnej logiki wypełniania.
Czym jest PersistentIdentifier w porównaniu z własnym UUID?
PersistentIdentifier to wewnątrzprocesowy identyfikator wiersza w SwiftData; powstaje automatycznie i żyje tyle, ile działający proces. Własny UUID z @Attribute(.unique) to stabilny identyfikator międzyprocesowy i międzyurządzeniowy. PersistentIdentifier nadaje się do odniesień wewnątrz procesu, w obrębie aplikacji. UUID nadaje się do wszystkiego, co przekracza granicę procesu (synchronizacja między urządzeniami, integracje zewnętrzne, narzędzia MCP, wywołania sieciowe).
Kiedy nadal wybierać Core Data zamiast SwiftData?
Cztery przypadki na czas bet iOS 27: agregaty po stronie bazy danych (pobrania NSExpression, których SwiftData nie ma), udostępnianie między użytkownikami iCloud przez CKShare lub publiczną bazę CloudKit (synchronizacja SwiftData obejmuje bazę prywatną), aktualizacje wsadowe na poziomie magazynu (NSBatchUpdateRequest) oraz progi wdrożenia poniżej iOS 1791011. W przypadku agregatów nie trzeba porzucać SwiftData: wystarczy uruchomić koegzystujący stos Core Data na tym samym pliku magazynu.
Źródła
-
Get Bananas autora, aplikacja SwiftUI z listą zakupów, która łączy SwiftData z synchronizacją JSON przez iCloud Drive oraz serwerem MCP. Model
ShoppingItemewoluował we wczesnym cyklu rozwoju; polelastModified: Date?dodano po pierwotnym schemacie (commit268a00dz 1 grudnia 2025, „Make lastModified optional to fix migration crash”), ponieważ uczynienie go nieopcjonalnym psuło migrację, gdy istniejące wiersze nie miały czym go wypełnić. ↩ -
Apple Developer, “SwiftData” oraz “Adding and editing persistent data in your app”. Makro
@Model, powierzchnia ograniczeń@Attributei 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 to, co skłania framework do przerwania. ↩
-
Apple Developer, “VersionedSchema” oraz “SchemaMigrationPlan”. Deklaracje wersjonowanych schematów, definicje etapów migracji i 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) i rola parametruinverse:w utrzymaniu relacji dwukierunkowych. ↩ -
Analiza autora w Two Agent Ecosystems, One Shopping List, 29 kwietnia 2026, oraz Five Apple Platforms, Three Shared Files. Wzorce synchronizacji międzyprocesowej i międzyurządzeniowej z Get Bananas i Return, które uzupełniają (a czasem zastępują) SwiftData w przepływie wieloprocesowym. ↩
-
Apple Developer, “PersistentIdentifier” (zgodny z
Sendable) oraz “ModelActor”. Zespół SwiftData potwierdził podczas SwiftData Group Lab na WWDC 2026, że obiekty@Modelnie sąSendablei nie należy zmuszać ich do zgodności, ponieważ stanowią graf referencji żyjący wewnątrz kontekstu; zalecanym kontraktem granicznym jest przekazaniePersistentIdentifier, który jestSendable, wraz z wyciągniętymi zwykłymi wartościami i ponowne pobranie w kontekście docelowym, a przekazanie grafu modelu zostawia odbiorcy obiekt tylko częściowo nawodniony. Sparafrazowane na podstawie lokalnie transkrybowanego nagrania SwiftData Group Lab z WWDC 2026; Apple nie publikuje oficjalnych napisów do sesji lab. ↩↩ -
Apple Developer, “Adopting SwiftData for a Core Data app”, gdzie stwierdzono, że przy konfiguracji domyślnej „SwiftData copies the existing store to the app group container”, podczas gdy własny adres URL magazynu pozostawia zarządzanie położeniem programiście. Wymóg uprawnienia CloudKit dla członków App Group oraz podział na dwie
ModelConfiguration(jedną synchronizowaną, jedną lokalną), aby utrzymać widżety i rozszerzenia poza ścieżką synchronizacji, opisano podczas SwiftData Group Lab na WWDC 2026. Sparafrazowane na podstawie lokalnie transkrybowanego nagrania SwiftData Group Lab z WWDC 2026; Apple nie publikuje oficjalnych napisów do sesji lab. ↩↩ -
Apple, sesja 10189 z WWDC 2023, “Migrate to SwiftData”, źródło ujęcia o koegzystencji („two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store”), oraz Apple Developer, “NSExpression”, mechanizm stojący za pobraniami agregatów spychanymi do SQL w Core Data, dla których SwiftData nie oferuje odpowiednika. Lukę potwierdził panel inżynierów SwiftData na SwiftData Group Lab podczas WWDC 2026 (sparafrazowane na podstawie lokalnie transkrybowanego nagrania). ↩↩↩
-
Apple Developer, “Syncing model data across a person’s devices”, gdzie stwierdzono, że „SwiftData uses the
NSPersistentCloudKitContainerclass from Core Data to handle CloudKit synchronization”; “NSPersistentCloudKitContainer” (iOS 13.0+), którego streszczenie opisuje odzwierciedlanie „select persistent stores to a CloudKit private database”; oraz “Sharing Core Data objects between iCloud users”, udokumentowana ścieżka Core Data dla współpracy opartej naCKShare. Dokumentacja SwiftData nie udostępnia żadnego API do udostępniania ani do bazy publicznej na czas bet iOS 27. ↩↩↩↩ -
Apple Developer, “NSBatchUpdateRequest” oraz “ModelContext.delete(model:where:includeSubclasses:)”, oparte na predykacie usuwanie wsadowe w SwiftData. Dokumentacja
ModelContextw SwiftData nie wymienia żadnego odpowiednika dla aktualizacji wsadowej. ↩↩ -
Dostępność na platformach według dokumentacji Apple Developer: SwiftData (iOS 17.0+) oraz Core Data (iOS 3.0+). ↩
-
Apple Developer, “ResultsObserver” (beta iOS 27.0), który „observes and tracks changes to a collection of persistent models in a model context” i jest zgodny z
Observable, pełniąc rolę obserwacji poza widokiem, która wcześniej wymagałaNSFetchedResultsControllerz Core Data. ↩