← Wszystkie wpisy

Prawdziwym kosztem SwiftData jest dyscyplina schematu

Model 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 uczynione opcjonalnym właśnie po to, by naprawić awarię migracji, która wystąpiła, gdy po raz pierwszy dodano je 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 z value-transformer oraz boilerplate NSManagedObjectContext. To, czego framework nie ukrywa, to migracja schematu; sprawia jedynie, że migracja staje się deklaratywna, a nie imperatywna. Kosztem braku uwagi poświęconej migracjom jest błąd, który kasuje dane użytkownika podczas rutynowej aktualizacji.

Teza: SwiftData jest tanie na starcie i drogie przy niechlujnej migracji. Dyscyplina to nazewnictwo, opcjonalność i VersionedSchema od pierwszego dnia, a nie od dnia, w którym uświadomimy sobie, że powinniśmy byli to zrobić.

TL;DR

  • Makro @Model zamienia klasę w trwały typ SwiftData. Framework generuje schemat na podstawie deklaracji właściwości w czasie kompilacji.
  • Dodanie nowej właściwości opcjonalnej to migracja bezkosztowa: obsługuje ją lekka migracja SwiftData. Dodanie właściwości nieopcjonalnej do istniejącego schematu wymaga VersionedSchema oraz MigrationPlan, który mówi frameworkowi, jak wypełnić nowe pole dla istniejących wierszy.
  • Kosztem pominięcia VersionedSchema od pierwszego dnia jest to, że każda nietrywialna zmiana schematu w wersji v2 grozi utratą bazy danych użytkownika, ponieważ ścieżka lekkiej migracji jest zachowawcza i rezygnuje, gdy nie potrafi wywnioskować migracji.
  • @Attribute(.unique) to właściwe narzędzie dla kluczy naturalnych (wygenerowanego UUID, zaimportowanego identyfikatora zewnętrznego). @Relationship to właściwe narzędzie dla referencji rodzic/dziecko. Oba są makrami, które generują pod spodem odpowiednią instalację Core Data.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 tego kształtu, które API ukrywa.

@Model nie wymaga osobnej deklaracji schematu trwałego magazynu. SwiftData czyta 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ć leżący u podstaw NSManagedObjectModel z Core Data nadal istnieje i to on stanowi podstawę 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 wartość”. Gdy wstawiamy model z wartością .unique, która już istnieje, SwiftData wykonuje upsert: istniejący wiersz jest aktualizowany, a nie odrzucany. Semantyka ta ma znaczenie dla kodu produktowego: .unique nie jest walidacją na poziomie UI, która zapobiega przesyłaniu duplikatów; jest gwarancją przechowywania co-najwyżej-jeden, która po cichu scala. Wzorzec id: UUID powyżej jest zalecany dla synchronizacji między procesami (gdzie chcemy stabilnego identyfikatora, który przetrwa zniknięcie wewnątrzprocesowego PersistentIdentifier), a zachowanie typu upsert jest dokładnie tym, czego potrzebujemy, gdy ten sam UUID przybywa z dwóch ścieżek synchronizacji.

Klasy @Model są typami referencyjnymi, a nie typami wartościowymi. Mutowanie właściwości w instancji ShoppingItem uruchamia ś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 What SwiftUI Is Made Of), z warstwą trwałości nałożoną na wierzch.

Pola opcjonalne to tania migracja

Pole lastModified: Date? w ShoppingItem jest opcjonalne, a opcjonalność jest tu nośna. Pole zostało dodane po wydaniu wersji 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ć dodanie bez pisania jakiegokolwiek kodu migracji: istniejące wiersze otrzymują nil; nowe wiersze otrzymują to, co ustawia init.3

Ścieżka lekkiej migracji to uprzejma ścieżka frameworka. SwiftData analizuje 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 lekkiej migracji 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.originalName lub wskazówki)

Przypadki, w których ścieżka lekkiej migracji rezygnuje:

  • Dodanie właściwości nieopcjonalnej bez wartości domyślnej do istniejącego schematu (istniejące wiersze nie mają wartości, którą można by ją wypełnić)
  • Zmiana typu właściwości (np. IntString)
  • Podział modelu na dwa modele lub scalenie dwóch w jeden
  • Wszystko, co wymaga niestandardowej logiki migracji

Gdy ścieżka lekkiej migracji rezygnuje, bezpiecznym zachowaniem jest niepowodzenie migracji. Niebezpiecznym zachowaniem byłoby porzucenie 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; deweloper 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 dodajemy trzecią funkcję, której zmiana schematu przekracza to, co obsługuje ścieżka lekkiej migracji.

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 tego, jak schemat ewoluuje. Gdy aplikacja wydawana w wersji v2 uruchamia się na bazie danych v1, framework przechodzi przez plan migracji, stosuje nazwane etapy i doprowadza bazę danych do v2. Gdy wydajemy v3, dodajemy SchemaV3.self do schemas oraz nowy MigrationStage między v2 a v3.

Dyscyplina polega na tym, by wydać VersionedSchema w wersji v1, nawet gdy istnieje tylko jedna wersja. Kosztem takiego postępowania jest jeden dodatkowy plik i jedna dodatkowa deklaracja enum. Kosztem niezrobienia tego jest to, że pierwsza nietrywialna zmiana schematu w wersji v2 wymaga wstecznego opakowania v1 w VersionedSchema, co jest wykonalne, ale wymaga staranności, aby dopasować dokładny kształt v1, tak by framework mógł zidentyfikować istniejące dane jako SchemaV1. Przyszły-ty pracujący nad v2 zapłaci podatek; obecny-ty może zapłacić go raz i zapomnieć o nim.

Niestandardowy MigrationStage dla trudnych przypadków

Lekkie migracje obejmują większość zmian addytywnych. Zmiany typów, podziały, scalenia i 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 dwa 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 API używane w działającej aplikacji), operujący na przejściowym kontekście wewnątrzmigracyjnym.

Wzorzec, który przetrwa produkcję, polega na utrzymywaniu willMigrate pustym i umieszczeniu całej logiki wypełniania w didMigrate. Odczyt danych v1 wewnątrz willMigrate jest dozwolony, ale schemat v2 jeszcze nie istnieje z perspektywy frameworka, więc każde obliczenie musi zostać odłożone do magazynu przejściowego, 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 wierszach to zadanie didMigrate.

Kiedy @Attribute i @Relationship zasługują na swoje nazwy

Dwa makra wykonują większość pracy dekoracji schematu w klasach @Model.

@Attribute dekoruje pojedynczą właściwość ograniczeniem lub wskazówką:

  • @Attribute(.unique) wymusza unikalność, jak w ShoppingItem.id
  • @Attribute(.externalStorage) przechowuje duże bloby Data poza bazą danych (dane obrazów, bufory audio)
  • @Attribute(originalName: "old_field_name") dopasowuje właściwość do kolumny o zmienionej nazwie podczas migracji
  • @Attribute(.transformable(by: ...)) stosuje ValueTransformer do typu niezgodnego z Codable

Właściwa dyscyplina: używaj .unique dla pól, które rzeczywiście powinny być unikalne (wygenerowanego UUID, identyfikatora zewnętrznego), używaj .externalStorage dla każdego bloba przekraczającego kilka KB, używaj originalName, gdy zmiana nazwy właściwości w v2 inaczej utraciłaby dane 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ść w dziecku wskazuje z powrotem na rodzica; framework używa go do przewidywalnego dwukierunkowego utrzymania. SwiftData potrafi czasem wywnioskować odwrotność automatycznie, a inverse: nil jest obsługiwane dla jawnie jednokierunkowych relacji, ale bezpieczną wartością domyślną jest deklarowanie inverse: zawsze, gdy wnioskowanie byłoby niejednoznaczne.5

Właściwa dyscyplina: deklaruj relacje z jawnym deleteRule (domyślną wartością jest .nullify, która rzadko jest tym, czego chcemy) i deklaruj 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 na zawsze zaoszczędzony błąd.

Przekraczanie granicy aktora: wyślij identyfikator, a nie graf

Klasa @Model nie jest Sendable, a właściwym ruchem jest przestać próbować ją taką uczynić. Instancja to referencja do żywego grafu obiektów przechowywanego przez ModelContext; framework nie może zagwarantować, że ten graf jest bezpieczny do odczytu z innego aktora, więc typ jest celowo pozostawiony nie-Sendable. Wymuszenie zgodności nie sprawia, że wyścig danych znika; ukrywa go.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. Wyciągnij dowolne wartości skalarne, których potrzebuje cel (nazwę, flagę, deltę w małej strukturze), przekaż je obok identyfikatora i pozwól odbierającemu aktorowi ponownie pobrać model z własnego kontekstu przy użyciu 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

Tryb awarii, którego należy unikać, to przekazywanie samego grafu modelu. Gdy część grafu przekracza granicę, odbiorca otrzymuje model, który częściowo hydratuje się po drugiej stronie: relacje i leniwie ładowane właściwości, które nigdy nie zostały sprowadzone w kontekście źródłowym, rozwiązują się względem niewłaściwego kontekstu (lub wcale), a błędy, które po tym następują, są tego cichego rodzaju. Identyfikator wraz z wyodrębnionymi wartościami to bezpieczny kontrakt; graf nim nie jest. ModelActor zamyka tę dyscyplinę w sobie, posiadając kontekst i wydając wartości zamiast instancji.7

Synchronizacja CloudKit i pułapka entitlement grupy aplikacji

Przeniesienie magazynu SwiftData do kontenera grupy aplikacji, tak by widget lub extension mogły go odczytać, wchodzi w interakcję z synchronizacją CloudKit w sposób, który gryzie aplikacje po ich wydaniu. Dwa fakty sprawiają, że reszta wynika sama.

Po pierwsze, lokalizacja magazynu. Przy domyślnej ModelConfiguration SwiftData kopiuje za nas istniejący magazyn do kontenera grupy aplikacji, gdy aplikacja ewoluuje od braku grupy do grupy aplikacji; sformułowanie Apple brzmi, że SwiftData „kopiuje istniejący magazyn do kontenera grupy aplikacji”.8 Przy niestandardowym adresie URL magazynu lokalizacja należy do nas: sami kopiujemy plik do nowego kontenera i kierujemy konfigurację na niego. Ścieżka domyślna jest tą wygodną właśnie dlatego, że framework wykonuje kopiowanie; ścieżka niestandardowa wymienia tę wygodę na kontrolę.

Po drugie, entitlement. Każdy członek grupy aplikacji, który odczytuje magazyn synchronizowany przez CloudKit, musi posiadać ten sam entitlement CloudKit, ponieważ każdy z tych procesów będzie synchronizował ten kontener we własnym imieniu. To wymaganie jest pułapką: widget lub extension nie ma budżetu czasu wykonania ani okna pierwszego planu, by przeprowadzić rozwlekłą synchronizację, a przekazanie mu entitlement CloudKit zmusza go do próby. Naprawą jest podział na dwie instancje ModelConfiguration: jeden magazyn synchronizowany (entitlement CloudKit, należący do aplikacji głównej) i jeden magazyn lokalny w kontenerze grupy aplikacji, który widget i extensions odczytują, nigdy nie synchronizując. Umieść synchronizację tam, gdzie aplikacja na pierwszym planie może ją wykonać dobrze, i trzymaj dane współdzielone-do-odczytu poza ścieżką synchronizacji.8

Co zbudowałbym inaczej

Trzy wzorce, które aplikacje w klastrze albo wydają, albo żałują, że ich nie wydały.

Wydaj VersionedSchema od wersji v1. Każda wydawana klasa @Model powinna żyć wewnątrz VersionedSchema od pierwszego dnia. 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, a nie dwudniowym wstecznym refaktorem.

Uczyń każdy znacznik czasu opcjonalnym. Pola takie jak lastModified, createdAt i updatedAt, które istnieją na potrzeby 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 faktycznie ich potrzebujemy) 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ć uzupełnianie na danych użytkownika.

Używaj UUID jako klucza naturalnego, a nie PersistentIdentifier. PersistentIdentifier SwiftData jest wewnątrzprocesowy. Synchronizacja między urządzeniami, integracja MCP (omówiona w Two Agent Ecosystems, One Shopping List) oraz każda referencja poza procesem potrzebują stabilnego identyfikatora. UUID z @Attribute(.unique) to właściwy kształt; wewnątrzprocesowy PersistentIdentifier to niewłaściwy kształt dla czegokolwiek, 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ść typu pojedynczy rekord. Ustawienia aplikacji, wybrany przez użytkownika język, znacznik czasu ostatniej synchronizacji. Używaj UserDefaults lub NSUbiquitousKeyValueStore (omówionych w Five Apple Platforms, Three Shared Files). Narzut SwiftData dla pojedynczego wiersza to zmarnowany ceremoniał; magazyny klucz-wartość to właściwe podłoże.

Dane autorytatywne po stronie serwera bez zapisów offline. Lista pobrana z REST API i wyświetlana tylko do odczytu. SwiftData to przesada, jeśli źródłem prawdy jest serwer, a lokalna pamięć podręczna to po prostu cache. Prosty zrzut Codable w Documents/ plus tablica buforowana w pamięci wystarczają; podatek migracyjny SwiftData nie jest wart zapłacenia, jeśli dane nie przetrwają twardego resetu.

Koordynacja między procesami. SwiftData operuje wewnątrz procesu. Serwer MCP działający poza aplikacją iOS nie może odczytać ani zapisać kontenera SwiftData aplikacji. Stan między procesami potrzebuje innego kształtu: pliku JSON w iCloud Drive, współdzielonego kontenera App Group lub jawnej warstwy synchronizacji, która łączy procesy. (Get Bananas paruje SwiftData z plikiem JSON w iCloud Drive właśnie z tego powodu.)6

Dane to duże bloby, które rzadko się zmieniają. Plik audio o rozmiarze 10 MB, zbiór obrazów o rozmiarze 50 MB. Używaj @Attribute(.externalStorage), jeśli bloby znajdują się wewnątrz wierszy SwiftData; w przeciwnym razie używaj bezpośrednio systemu plików, a metadane w SwiftData niech wskazują na adresy URL plików.

Co ten wzorzec oznacza dla aplikacji wydawanych na iOS 26+

Trzy wnioski.

  1. Makra to łatwa część. Migracje to koszt. @Model i @Attribute to dwulinijkowe deklaracje, które ukrywają sporo instalacji Core Data. Dyscyplina migracji to to, za co faktycznie płacimy przez cały cykl życia aplikacji; projektuj v1 z myślą o v2.

  2. VersionedSchema od pierwszego dnia jest nienegocjowalne dla aplikacji wydawanych. Opakowujący enum to jeden dodatkowy plik. Wsteczny koszt dodania go później jest znacznie wyższy.

  3. Pola opcjonalne i jawne relacje to tanie ubezpieczenie. Opcjonalne znaczniki czasu dla metadanych synchronizacji, jawny deleteRule i inverse: na relacjach. Oba to maleńkie deklaracje, które kupują sporo elastyczności w v2.

Pełny klaster Apple Ecosystem: typowane App Intents dla Apple Intelligence; serwery MCP dla agentów wielo-LLM-owych; pytanie o routing między nimi; Foundation Models dla LLM na urządzeniu i protokół Tool; Live Activities dla maszyny stanów ekranu blokady na iOS; kontrakt watchOS runtime na Apple Watch; wnętrzności SwiftUI dla podłoża frameworka; przestrzenny model myślowy RealityKit dla scen visionOS; wzorce Liquid Glass dla warstwy wizualnej; wydawanie wieloplatformowe dla zasięgu między urządzeniami. Hub znajduje się w Apple Ecosystem Series. Szerszy kontekst iOS-z-agentami-AI znajdziesz 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 wspierającego, więc model w czasie wykonania jest taki sam; różnicą jest powierzchnia. @Model usuwa plik .xcdatamodeld, ceremoniał value-transformer oraz zarządzanie cyklem życia NSManagedObjectContext. Otrzymujemy ten sam trwały magazyn z API o kształcie Swift.

Czy potrzebuję VersionedSchema, jeśli nigdy nie planuję zmieniać schematu?

Jeśli aplikacja może wydać wersję v2 — tak. Jeśli to jednorazowe demo — nie. Kosztem VersionedSchema od v1 jest jedna dodatkowa deklaracja enum. Kosztem dodania go wstecznie przy v2 jest dopasowanie dokładnego kształtu schematu v1, tak by framework rozpoznał istniejące dane, co jest wykonalne, ale podatne na błędy. Większość wydawanych aplikacji prędzej czy później będzie potrzebować zmiany schematu; przewidź to w budżecie w v1.

Kiedy powinienem używać @Attribute(.unique)?

Gdy pole jest kluczem naturalnym dla wiersza: wygenerowanym UUID, zaimportowanym identyfikatorem zewnętrznym, przypisanym slug. SwiftData traktuje .unique jako upsert: jeśli wstawimy model, którego wartość .unique już istnieje, istniejący wiersz jest aktualizowany, a nie dołączany jako nowy. Ta semantyka sprawia, że ścieżki synchronizacji w stylu upsert (ten sam UUID przychodzący z dwóch urządzeń) są bezpieczne; jest też powodem, dla którego .unique to niewłaściwe narzędzie na polach nazw wyświetlanych, takich jak title, ponieważ dwoje użytkowników wpisujących ten sam tytuł po cichu scaliłoby swoje wiersze, zamiast utworzyć dwa odrębne rekordy.

Jak obsłużyć pole nieopcjonalne dodane do istniejącego schematu?

Użyj MigrationStage.custom z domknięciem didMigrate, które wypełnia pole na istniejących wierszach. Albo, łatwiej: zadeklaruj pole jako opcjonalne w nowej wersji schematu i wypełniaj je leniwie przy dostępie. Opcjonalność to tańsza migracja; dodania nieopcjonalne potrzebują jawnej logiki wypełniania.

Czym jest PersistentIdentifier w porównaniu z moim własnym UUID?

PersistentIdentifier to wewnątrzprocesowe ID wiersza w SwiftData; jest generowany automatycznie i przetrwa czas życia działającego procesu. Twój własny UUID z @Attribute(.unique) to stabilny identyfikator między procesami i między urządzeniami. Używaj PersistentIdentifier dla referencji wewnątrzprocesowych w obrębie aplikacji. Używaj UUID dla wszystkiego, co przekracza granicę procesu (synchronizacja między urządzeniami, integracje zewnętrzne, narzędzia MCP, wywołania sieciowe).

References


  1. Author’s Get Bananas, a SwiftUI shopping list app that pairs SwiftData with iCloud Drive JSON sync and an MCP server. The ShoppingItem model evolved across the early development cycle; the lastModified: Date? field was added after the initial schema (commit 268a00d on 2025-12-01, “Make lastModified optional to fix migration crash”) because making it non-optional broke migration when existing rows had no value to populate it. 

  2. Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The @Model macro, the @Attribute constraint surface, and the relationship to Core Data’s NSManagedObjectModel

  3. Apple Developer, “Preserving your app’s model data across launches” and “Adopting SwiftData for a Core Data app”. Lightweight migration semantics and what triggers the framework to bail. 

  4. Apple Developer, “VersionedSchema” and “SchemaMigrationPlan”. Versioned schema declarations, migration stage definitions, and the ModelContainer constructor that takes a migration plan. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” and “Schema.Relationship”. The @Relationship macro, deleteRule options (.cascade, .nullify, .deny, .noAction), and the role of the inverse: parameter in bidirectional relationship maintenance. 

  6. Author’s analysis in Two Agent Ecosystems, One Shopping List, April 29, 2026, and Five Apple Platforms, Three Shared Files. The Get Bananas + Return cross-process and cross-device sync patterns that complement (and sometimes replace) SwiftData inside a multi-process workflow. 

  7. Apple Developer, “PersistentIdentifier” (conforms to Sendable) and “ModelActor”. The SwiftData team confirmed during the WWDC 2026 SwiftData Group Lab that @Model objects are not Sendable and should not be forced to conform, because they are a reference graph living inside a context; the recommended boundary contract is to pass the Sendable PersistentIdentifier plus extracted plain values and re-fetch on the destination context, and that passing the model graph leaves the receiver with a partially hydrated object. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

  8. Apple Developer, “Adopting SwiftData for a Core Data app”, which states that with the default configuration “SwiftData copies the existing store to the app group container,” while a custom store URL leaves the location for you to manage. The CloudKit-entitlement requirement for app-group members and the two-ModelConfiguration split (one synced, one local) for keeping widgets and extensions out of the sync path were described during the WWDC 2026 SwiftData Group Lab. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

Powiązane artykuły

Migracje SwiftData: lightweight kontra custom oraz kiedy V2 nie jest potrzebne

Model migracji SwiftData opiera się na VersionedSchema, MigrationStage i SchemaMigrationPlan. Większość zmian schematu n…

12 min czytania

SwiftData w iOS 27: Observation i historia

iOS 27 daje SwiftData pełnoprawną obserwację zmian dzięki ResultsObserver, obserwację trwałej historii dzięki HistoryObs…

10 min czytania

Warstwa porządkowa to prawdziwy rynek agentów AI

Charlie Labs zmieniło kierunek z budowania agentów na sprzątanie po nich. Rynek agentów AI przesuwa się z generowania w …

11 min czytania