← Alle Beitrage

Die wahren Kosten von SwiftData sind Schema-Disziplin

Das ShoppingItem von Get Bananas ist das kanonische Beispiel dafür, warum Schema-Disziplin bei SwiftData wichtig ist. Das ursprüngliche Schema enthielt keinen lastModified-Zeitstempel; ihn später hinzuzufügen erforderte eine bestimmte Migrationsform, weil bereits Daten auf dem Datenträger lagen, und das Feld wurde gezielt optional gemacht, um einen Migrationsabsturz zu beheben, der auftrat, als es zuerst als nicht-optional hinzugefügt wurde.1

Die API von SwiftData besteht aus zwei Makros. @Model an einer Klasse macht sie zu einem persistenten Typ. @Attribute(.unique) an einer Property verleiht ihr eine Eindeutigkeitsbedingung. Das Framework verbirgt die Stack-Verwaltung von Core Data, den Value-Transformer-Tanz und den NSManagedObjectContext-Boilerplate. Was das Framework nicht verbirgt, ist die Schema-Migration; es macht die Migration lediglich deklarativ statt imperativ. Die Kosten dafür, die Migrationen zu ignorieren, sind der Bug, der bei einem routinemäßigen Update die Daten eines Benutzers löscht.

Die These: SwiftData ist günstig im Einstieg und teuer, wenn man schlampig migriert. Die Disziplin liegt in Benennung, Optionalität und VersionedSchema ab dem ersten Tag, nicht erst an dem Tag, an dem man merkt, dass man es hätte tun sollen.

TL;DR

  • Das @Model-Makro verwandelt eine Klasse in einen persistenten SwiftData-Typ. Das Framework generiert das Schema zur Compile-Zeit aus den Property-Deklarationen.
  • Eine neue optionale Property hinzuzufügen ist eine No-op-Migration: Die Lightweight-Migration von SwiftData erledigt das. Eine nicht-optionale Property zu einem bestehenden Schema hinzuzufügen erfordert ein VersionedSchema plus einen MigrationPlan, der dem Framework mitteilt, wie das neue Feld für bestehende Zeilen befüllt werden soll.
  • Die Kosten dafür, VersionedSchema nicht ab dem ersten Tag einzusetzen, bestehen darin, dass jede nicht-triviale v2-Schemaänderung riskiert, die Datenbank eines Benutzers zu verwerfen, weil der Lightweight-Pfad konservativ ist und abbricht, wenn er die Migration nicht ableiten kann.
  • @Attribute(.unique) ist das richtige Werkzeug für natürliche Schlüssel (eine selbst generierte UUID, eine importierte externe ID). @Relationship ist das richtige Werkzeug für Eltern/Kind-Referenzen. Beides sind Makros, die unter der Haube die richtige Core-Data-Verkabelung generieren.2

Was @Model tatsächlich tut

Ein SwiftData-Typ ist eine Swift-Klasse, auf die das @Model-Makro angewendet wird. Das ShoppingItem von Get Bananas ist die kanonische Form:

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()
    }
}

Drei Details dieser Form, die die API verbirgt.

@Model erfordert keine separate Schema-Deklaration für den Persistent Store. SwiftData liest die Klassendefinition zur Compile-Zeit und synthetisiert das Schema. Die Properties der Klasse werden zu den Attributen des Modells; ihre Swift-Typen werden zu den Spaltentypen. Es gibt keine .xcdatamodeld-Datei zu pflegen (auch wenn das zugrunde liegende NSManagedObjectModel von Core Data weiterhin existiert und zur Laufzeit das Schema trägt).2

@Attribute(.unique) ist eine Bedingung an einer einzelnen Spalte, keine PRIMARY KEY-Deklaration. Die persistente Identität von SwiftData ist der PersistentIdentifier, der pro Zeile automatisch generiert wird. Die @Attribute(.unique)-Deklaration teilt dem Framework mit: „Diese Spalte speichert höchstens eine Zeile pro Wert.” Wenn Sie ein Modell mit einem .unique-Wert einfügen, der bereits existiert, führt SwiftData ein Upsert durch: Die bestehende Zeile wird aktualisiert statt abgewiesen. Die Semantik ist für Produktcode entscheidend: .unique ist keine Validierung auf UI-Ebene, die das Absenden von Duplikaten verhindert; es ist eine Speichergarantie für höchstens eine Zeile, die stillschweigend zusammenführt. Das Muster id: UUID oben ist das empfohlene für prozessübergreifende Synchronisation (wo man einen stabilen Bezeichner möchte, der überlebt, wenn der prozessinterne PersistentIdentifier verschwindet), und das Upsert-Verhalten ist genau das, was man möchte, wenn dieselbe UUID aus zwei Sync-Pfaden eintrifft.

@Model-Klassen sind Referenztypen, keine Wertetypen. Das Mutieren einer Property an einer ShoppingItem-Instanz löst die Änderungsverfolgung von SwiftData aus; das Framework registriert die Änderung und persistiert sie beim nächsten Speichern des Kontexts. Die SwiftUI-Integration über @Query rendert jede View neu, die das passende Prädikat beobachtet. Das Muster ähnelt @Observable (behandelt in What SwiftUI Is Made Of), wobei die Persistenz darüber liegt.

Optionale Felder sind die günstige Migration

Das Feld lastModified: Date? an ShoppingItem ist optional, und die Optionalität ist tragend. Das Feld wurde nach dem Release von v1 hinzugefügt, um geräteübergreifende Synchronisation und Konfliktauflösung zu unterstützen; bestehende Zeilen auf Benutzergeräten hatten keinen lastModified-Wert. Ein optionales Feld ohne Standardwert lässt die Lightweight-Migration von SwiftData die Ergänzung erledigen, ohne dass Migrationscode geschrieben werden muss: Bestehende Zeilen erhalten nil; neue Zeilen erhalten, was der Initializer setzt.3

Der Lightweight-Migrationspfad ist der höfliche Pfad des Frameworks. SwiftData inspiziert das neue Schema und den Persistent Store, leitet die kleinste kompatible Änderung ab und wendet sie an. Die Migration ist automatisch; der Benutzer sieht nichts; die App startet normal mit den bestehenden Daten. Die Fälle, die der Lightweight-Pfad sauber handhabt:

  • Hinzufügen einer optionalen Property
  • Entfernen einer Property (die Daten werden verworfen; bestehende Lesevorgänge sehen die Spalte nicht mehr)
  • Umbenennen eines Attributs, das das Framework per Hinweis zuordnen kann (über @Attribute(originalName: ...))
  • Umbenennen einer @Model-Klasse, die das Framework zuordnen kann (über @Model.originalName oder einen Hinweis)

Die Fälle, bei denen der Lightweight-Pfad abbricht:

  • Hinzufügen einer nicht-optionalen Property ohne Standardwert zu einem bestehenden Schema (bestehende Zeilen haben keinen Wert, mit dem es befüllt werden könnte)
  • Ändern des Typs einer Property (z. B. IntString)
  • Aufteilen eines Modells in zwei Modelle oder Zusammenführen von zweien in eines
  • Alles, was benutzerdefinierte Logik zur Migration erfordert

Wenn der Lightweight-Pfad abbricht, ist das sichere Verhalten, die Migration fehlschlagen zu lassen. Das unsichere Verhalten wäre, die Datenbank zu verwerfen und von vorn zu beginnen; das Framework ist konservativ und weigert sich, das stillschweigend zu tun. Der Benutzer sieht die App beim Start mit einem Migrationsfehler abstürzen; der Entwickler sieht einen Stack-Trace, der auf die Schema-Inkompatibilität zeigt; niemand verliert Daten, aber jeder verliert Vertrauen.

Die Kosten dafür, VersionedSchema nicht ab dem ersten Tag einzusetzen, zeigen sich an der Grenze v2 → v3, wenn Sie das dritte Feature hinzufügen, dessen Schemaänderung das übersteigt, was der Lightweight-Pfad handhabt.

VersionedSchema und MigrationPlan: die Disziplin vom ersten Tag

VersionedSchema deklariert eine bestimmte Version des Modell-Schemas. MigrationPlan deklariert, wie von einer Version zur nächsten migriert wird.4 Die Form:

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)
    ]
}

Die Modellklassen selbst wandern in den Namensraum des versionierten Schemas:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

Der ModelContainer wird mit dem Migrationsplan konstruiert:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

Der Migrationsplan gibt dem Framework einen typisierten Graphen davon, wie sich das Schema entwickelt. Wenn die v2-App gegen eine v1-Datenbank startet, durchläuft das Framework den Migrationsplan, wendet die benannten Stages an und bringt die Datenbank auf v2. Wenn Sie v3 ausliefern, fügen Sie SchemaV3.self zu schemas und eine neue MigrationStage zwischen v2 und v3 hinzu.

Die Disziplin besteht darin, VersionedSchema bereits in v1 auszuliefern, selbst wenn es nur eine Version gibt. Die Kosten dafür sind eine zusätzliche Datei und eine zusätzliche enum-Deklaration. Die Kosten dafür, es nicht zu tun, bestehen darin, dass v2’s erste nicht-triviale Schemaänderung erfordert, v1 nachträglich in ein VersionedSchema zu hüllen, was machbar ist, aber Sorgfalt verlangt, um genau die v1-Form zu treffen, damit das Framework die bestehenden Daten als SchemaV1 identifizieren kann. Das künftige Ich, das an v2 arbeitet, zahlt die Steuer; das gegenwärtige Ich kann sie einmal zahlen und vergessen.

Benutzerdefinierte MigrationStage für die harten Fälle

Lightweight-Migrationen decken die meisten additiven Änderungen ab. Typänderungen, Aufteilungen, Zusammenführungen und bedingte Befüllungen brauchen eine 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()
        }
    )
]

Die beiden Closures feuern vor und nach dem Anwenden der strukturellen Migration durch das Framework. willMigrate läuft gegen das v1-Schema; didMigrate läuft gegen das v2-Schema. Der Closure-Körper ist normaler SwiftData-Code (Fetch-Deskriptoren, Speichern des Model-Kontexts, dieselben APIs wie in der laufenden App), der gegen einen transienten In-Migration-Kontext arbeitet.

Das Muster, das in der Produktion überlebt, ist, willMigrate leer zu halten und die gesamte Befüllungslogik in didMigrate zu legen. Das Lesen von v1-Daten innerhalb von willMigrate ist erlaubt, aber das v2-Schema existiert aus Sicht des Frameworks noch nicht, daher muss jede Berechnung in einen transienten Speicher ausgelagert werden, den die didMigrate-Closure lesen kann. Die einfachere Regel: Strukturelle Migrationen sind Sache des Frameworks; das Befüllen von v2-spezifischen Feldern an bestehenden Zeilen ist Sache von didMigrate.

Wann @Attribute und @Relationship ihre Namen verdienen

Zwei Makros erledigen den Großteil der Schema-Dekoration in @Model-Klassen.

@Attribute dekoriert eine einzelne Property mit einer Bedingung oder einem Hinweis:

  • @Attribute(.unique) erzwingt Eindeutigkeit, wie bei ShoppingItem.id
  • @Attribute(.externalStorage) speichert große Data-Blobs außerhalb der Datenbank (Bilddaten, Audio-Buffer)
  • @Attribute(originalName: "old_field_name") ordnet eine Property einer umbenannten Spalte während der Migration zu
  • @Attribute(.transformable(by: ...)) wendet einen ValueTransformer auf einen nicht-Codable-Typ an

Die richtige Disziplin: Verwenden Sie .unique für Felder, die wirklich eindeutig sein sollten (eine selbst generierte UUID, eine externe ID), verwenden Sie .externalStorage für jeden Blob über ein paar KB, verwenden Sie originalName, wenn eine v2-Umbenennung einer Property andernfalls die v1-Daten verlieren würde.

@Relationship dekoriert eine Property, die auf eine andere @Model-Klasse oder eine Sammlung davon zeigt:

@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?
}

Das deleteRule: .cascade bedeutet, dass das Löschen der übergeordneten List alle untergeordneten ShoppingItem-Zeilen löscht. Der Parameter inverse: teilt dem Framework mit, welche Property am Kind auf das Elternteil zurückzeigt; das Framework nutzt ihn für vorhersagbare bidirektionale Pflege. SwiftData kann die Inverse manchmal automatisch ableiten, und inverse: nil wird für explizit unidirektionale Beziehungen unterstützt, aber der sichere Standard ist, inverse: immer dann zu deklarieren, wenn die Ableitung mehrdeutig wäre.5

Die richtige Disziplin: Deklarieren Sie Beziehungen mit explizitem deleteRule (der Standard ist .nullify, was selten das ist, was man möchte) und deklarieren Sie inverse: immer dann, wenn die Beziehung bidirektional ist (statt sich auf die Ableitung des Frameworks zu verlassen). Die impliziten Standards sind meist falsch; die explizite Form ist ein zusätzlicher Parameter und ein für immer vermiedener Bug.

Eine Actor-Grenze überqueren: senden Sie den Bezeichner, nicht den Graphen

Eine @Model-Klasse ist nicht Sendable, und der richtige Zug ist, gar nicht erst zu versuchen, sie dazu zu machen. Die Instanz ist eine Referenz in einen lebenden Objektgraphen, der von einem ModelContext gehalten wird; das Framework kann nicht versprechen, dass dieser Graph sicher von einem anderen Actor gelesen werden kann, daher wird der Typ bewusst nicht-Sendable belassen. Die Konformität zu erzwingen lässt die Data Race nicht verschwinden; es verbirgt sie.7

Das Muster, das funktioniert, ist, die Identität und die einfachen Werte zu senden und dann auf der anderen Seite neu abzurufen. PersistentIdentifier ist Sendable, überquert die Grenze also sauber. Ziehen Sie die skalaren Werte heraus, die das Ziel benötigt (einen Namen, ein Flag, ein Delta in einem kleinen Struct), übergeben Sie diese zusammen mit dem Bezeichner und lassen Sie den empfangenden Actor das Modell aus seinem eigenen Kontext über den Bezeichner neu abrufen:

// 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

Der zu vermeidende Fehlermodus ist, den Modellgraphen selbst zu übergeben. Wenn ein Teil des Graphen die Grenze überquert, erhält der Empfänger ein Modell, das auf der anderen Seite nur teilweise hydratisiert: Beziehungen und faul geladene Properties, die im Quellkontext nie eingelesen wurden, lösen sich gegen den falschen Kontext auf (oder gar nicht), und die nachfolgenden Bugs sind von der leisen Sorte. Der Bezeichner plus extrahierte Werte ist der sichere Vertrag; der Graph ist es nicht. Ein ModelActor kapselt diese Disziplin, indem er einen Kontext besitzt und Werte statt Instanzen ausgibt.7

CloudKit-Sync und die Falle der App-Group-Entitlement

Einen SwiftData-Store in einen App-Group-Container zu verschieben, damit ein Widget oder eine Extension ihn lesen kann, interagiert mit CloudKit-Sync auf eine Weise, die Apps nach dem Release beißt. Zwei Fakten lassen den Rest folgen.

Erstens der Speicherort des Stores. Mit der Standard-ModelConfiguration kopiert SwiftData den bestehenden Store für Sie in den App-Group-Container, wenn sich eine App von „keine Gruppe” zu „App-Group” entwickelt; Apple formuliert es so, dass SwiftData „den bestehenden Store in den App-Group-Container kopiert”.8 Mit einer benutzerdefinierten Store-URL besitzen Sie den Speicherort: Sie kopieren die Datei selbst in den neuen Container und richten die Konfiguration darauf aus. Der Standardpfad ist genau deshalb der bequeme, weil das Framework die Kopie erstellt; der benutzerdefinierte Pfad tauscht diesen Komfort gegen Kontrolle.

Zweitens die Entitlement. Jedes App-Group-Mitglied, das einen CloudKit-synchronisierten Store liest, muss dieselbe CloudKit-Entitlement tragen, weil jeder dieser Prozesse diesen Container im eigenen Namen synchronisiert. Diese Anforderung ist die Falle: Ein Widget oder eine Extension hat weder das Laufzeitbudget noch das Vordergrundfenster, um einen ausführlichen Sync anzutreiben, und ihm die CloudKit-Entitlement zu geben zwingt es, es zu versuchen. Die Lösung ist, in zwei ModelConfiguration-Instanzen aufzuteilen: einen synchronisierten Store (CloudKit-Entitlement, im Besitz der Haupt-App) und einen lokalen Store im App-Group-Container, den das Widget und die Extensions lesen, ohne je zu synchronisieren. Legen Sie den Sync dorthin, wo eine im Vordergrund laufende App ihn gut erledigen kann, und halten Sie die zum Lesen geteilten Daten aus dem Sync-Pfad heraus.8

Was ich anders bauen würde

Drei Muster, die die Apps im Cluster entweder ausliefern oder gerne ausgeliefert hätten.

Liefern Sie VersionedSchema ab v1. Jede ausgelieferte @Model-Klasse sollte ab dem ersten Tag innerhalb eines VersionedSchema leben. Die Kosten sind ein umhüllendes enum pro Schema-Version. Der Nutzen ist, dass v2’s erste nicht-triviale Änderung eine einzeilige Ergänzung zu MigrationPlan.schemas ist statt eines zweitägigen nachträglichen Refactorings.

Machen Sie jeden Zeitstempel optional. Felder wie lastModified, createdAt und updatedAt, die für geräteübergreifende Synchronisation oder Konfliktauflösung existieren, sollten in v1 optional sein, wenn das v1-Produkt sie nicht benötigt. Optionalität hält die Migration zu v2 (wenn Sie sie dann brauchen) günstig. Sie an bestehenden Zeilen während didMigrate zu füllen ist eine Schleife; sie ab v1 nicht-optional zu machen ist eine Bedingung, die das Nachbefüllen von Benutzerdaten brechen kann.

Verwenden Sie UUIDs als natürlichen Schlüssel, nicht den PersistentIdentifier. Der PersistentIdentifier von SwiftData ist prozessintern. Geräteübergreifende Synchronisation, MCP-Integration (behandelt in Two Agent Ecosystems, One Shopping List) und jede prozessübergreifende Referenz brauchen einen stabilen Bezeichner. Eine UUID mit @Attribute(.unique) ist die richtige Form; der prozessinterne PersistentIdentifier ist die falsche Form für alles, was eine Prozessgrenze überquert.

Wann @Model die falsche Antwort ist

Drei Fälle, in denen SwiftData nicht das richtige Werkzeug ist:

Schlüssel/Wert-Zustand mit einem Datensatz. App-Einstellungen, die vom Benutzer gewählte Sprache, der Zeitstempel des letzten Syncs. Verwenden Sie UserDefaults oder NSUbiquitousKeyValueStore (behandelt in Five Apple Platforms, Three Shared Files). Der Overhead von SwiftData für eine einzelne Zeile ist verschwendete Zeremonie; Schlüssel/Wert-Speicher sind das richtige Substrat.

Server-autoritative Daten ohne Offline-Schreibvorgänge. Eine Liste, die von einer REST-API abgerufen und nur lesend angezeigt wird. SwiftData ist überdimensioniert, wenn die Quelle der Wahrheit der Server ist und der lokale Cache nur ein Cache ist. Ein einfacher Codable-Snapshot in Documents/ plus ein im Speicher gehaltenes Array genügt; die SwiftData-Migrationssteuer lohnt sich nicht, wenn die Daten einen Hard-Reset nicht überleben müssen.

Mehrprozess-Koordination. SwiftData arbeitet innerhalb eines Prozesses. Ein MCP-Server, der außerhalb der iOS-App läuft, kann den SwiftData-Container der App weder lesen noch schreiben. Prozessübergreifender Zustand braucht eine andere Form: eine iCloud-Drive-JSON-Datei, einen geteilten App-Group-Container oder eine explizite Synchronisationsschicht, die Prozesse überbrückt. (Get Bananas kombiniert SwiftData genau aus diesem Grund mit iCloud-Drive-JSON.)6

Die Daten sind große Blobs, die sich selten ändern. Eine 10-MB-Audiodatei, ein 50-MB-Bilddatensatz. Verwenden Sie @Attribute(.externalStorage), wenn die Blobs innerhalb von SwiftData-Zeilen liegen; andernfalls verwenden Sie das Dateisystem direkt mit Metadaten in SwiftData, die auf Datei-URLs zeigen.

Was das Muster für Apps bedeutet, die auf iOS 26+ ausliefern

Drei Erkenntnisse.

  1. Die Makros sind der einfache Teil. Die Migrationen sind die Kosten. @Model und @Attribute sind zweizeilige Deklarationen, die viel Core-Data-Verkabelung verbergen. Migrationsdisziplin ist das, wofür Sie über die Lebensdauer der App tatsächlich zahlen; entwerfen Sie v1 mit Blick auf v2.

  2. VersionedSchema ab dem ersten Tag ist für ausgelieferte Apps nicht verhandelbar. Das umhüllende enum ist eine zusätzliche Datei. Die nachträglichen Kosten, es später hinzuzufügen, sind weit höher.

  3. Optionale Felder und explizite Beziehungen sind die günstige Versicherung. Optionale Zeitstempel für Sync-Metadaten, explizite deleteRule und inverse: an Beziehungen. Beides sind winzige Deklarationen, die viel v2-Flexibilität erkaufen.

Der vollständige Apple-Ecosystem-Cluster: typisierte App Intents für Apple Intelligence; MCP-Server für LLM-übergreifende Agenten; die Routing-Frage zwischen ihnen; Foundation Models für On-Device-LLM und das Tool-Protokoll; Live Activities für die State Machine auf dem Sperrbildschirm unter iOS; der watchOS-Laufzeit-Vertrag auf der Apple Watch; SwiftUI-Interna für das Framework-Substrat; RealityKits räumliches mentales Modell für visionOS-Szenen; Liquid-Glass-Muster für die visuelle Ebene; Multi-Plattform-Auslieferung für geräteübergreifende Reichweite. Der Hub befindet sich bei der Apple Ecosystem Series. Für einen breiteren Kontext zu iOS mit AI-Agenten siehe den iOS Agent Development guide.

FAQ

Was ist der Unterschied zwischen @Model und Core Datas NSManagedObject?

@Model ist ein Swift-Makro, das die NSManagedObject-Verkabelung unter der Haube generiert. SwiftData nutzt Core Data als Backing Store, daher ist das Laufzeitmodell dasselbe; der Unterschied ist die Oberfläche. @Model beseitigt die .xcdatamodeld-Datei, die Value-Transformer-Zeremonie und die Lebenszyklus-Verwaltung des NSManagedObjectContext. Sie erhalten denselben Persistent Store mit einer Swift-förmigen API.

Brauche ich VersionedSchema, wenn ich das Schema nie zu ändern plane?

Wenn Ihre App vielleicht eine v2 ausliefert, ja. Wenn es eine einmalige Demo ist, nein. Die Kosten von VersionedSchema ab v1 sind eine zusätzliche enum-Deklaration. Die Kosten, es nachträglich bei v2 hinzuzufügen, bestehen darin, genau die v1-Schemaform zu treffen, damit das Framework bestehende Daten erkennt, was machbar, aber fehleranfällig ist. Die meisten ausgelieferten Apps werden irgendwann eine Schemaänderung benötigen; planen Sie sie in v1 ein.

Wann sollte ich @Attribute(.unique) verwenden?

Wenn das Feld ein natürlicher Schlüssel für die Zeile ist: eine selbst generierte UUID, eine importierte externe ID, ein zugewiesener Slug. SwiftData behandelt .unique als Upsert: Wenn Sie ein Modell einfügen, dessen .unique-Wert bereits existiert, wird die bestehende Zeile aktualisiert statt einer neuen Zeile angehängt. Diese Semantik macht Upsert-artige Sync-Pfade (dieselbe UUID, die von zwei Geräten kommt) sicher; sie ist auch der Grund, warum .unique das falsche Werkzeug für Anzeigename-Felder wie title ist, denn zwei Benutzer, die denselben Titel tippen, würden ihre Zeilen stillschweigend zusammenführen, statt zwei verschiedene Datensätze zu erzeugen.

Wie behandle ich ein nicht-optionales Feld, das einem bestehenden Schema hinzugefügt wird?

Verwenden Sie eine MigrationStage.custom mit einer didMigrate-Closure, die das Feld an bestehenden Zeilen befüllt. Oder, einfacher: Deklarieren Sie das Feld als optional in der neuen Schema-Version und füllen Sie es faul beim Zugriff. Optionalität ist die günstigere Migration; nicht-optionale Ergänzungen brauchen explizite Befüllungslogik.

Was ist PersistentIdentifier gegenüber meiner eigenen UUID?

PersistentIdentifier ist die prozessinterne Zeilen-ID von SwiftData; sie wird automatisch generiert und überlebt die Lebensdauer des laufenden Prozesses. Ihre eigene UUID mit @Attribute(.unique) ist ein stabiler prozess- und geräteübergreifender Bezeichner. Verwenden Sie PersistentIdentifier für prozessinterne Referenzen innerhalb der App. Verwenden Sie eine UUID für alles, was eine Prozessgrenze überquert (geräteübergreifende Synchronisation, externe Integrationen, MCP-Tools, Netzwerkaufrufe).

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. 

Verwandte Beiträge

SwiftData-Migrationen: Lightweight vs. Custom, und wann Sie kein V2 brauchen

Das Migrationsmodell von SwiftData nutzt VersionedSchema, MigrationStage und SchemaMigrationPlan. Die meisten Schemaände…

12 Min. Lesezeit

SwiftData in iOS 27: Observation und History

iOS 27 verleiht SwiftData erstklassige Beobachtung von Änderungen mit ResultsObserver, Beobachtung der Persistent Histor…

11 Min. Lesezeit

Die Cleanup-Schicht ist der eigentliche Markt für KI-Agenten

Charlie Labs hat den Schwenk vom Bau von Agenten zum Aufräumen nach ihnen vollzogen. Der KI-Agenten-Markt verlagert sich…

11 Min. Lesezeit