← Alle Beiträge

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 zunächst 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, Migrationen keine Aufmerksamkeit zu schenken, sind jener Bug, der bei einem Routine-Update die Daten eines Benutzers löscht.

Die These: SwiftData ist günstig im Einstieg und teuer, wenn schlampig migriert wird. Die Disziplin besteht aus Benennung, Optionalität und VersionedSchema vom ersten Tag an — nicht ab dem Tag, an dem Sie merken, dass Sie es gebraucht hätten.

Kurzfassung

  • Das @Model-Makro macht aus einer Klasse einen persistenten SwiftData-Typ. Das Framework erzeugt das Schema zur Compile-Zeit aus den Property-Deklarationen.
  • Eine neue optionale Property hinzuzufügen ist eine Migration ohne Aufwand: Die Lightweight-Migration von SwiftData erledigt das. Eine nicht-optionale Property zu einem bestehenden Schema hinzuzufügen erfordert ein VersionedSchema samt MigrationPlan, der dem Framework mitteilt, wie das neue Feld für bestehende Zeilen zu füllen ist.
  • Wer vom ersten Tag an auf VersionedSchema verzichtet, zahlt damit, dass jede nicht-triviale Schema-Änderung in v2 die Datenbank eines Benutzers zu verlieren droht, denn der Lightweight-Pfad ist konservativ und bricht ab, wenn er die Migration nicht ableiten kann.
  • @Attribute(.unique) ist das richtige Werkzeug für natürliche Schlüssel (eine selbst erzeugte UUID, eine importierte externe ID). @Relationship ist das richtige Werkzeug für Eltern-Kind-Referenzen. Beide sind Makros, die unter der Haube die passende Core-Data-Verrohrung erzeugen.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 zeigt 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 persistenten Store. SwiftData liest die Klassendefinition zur Compile-Zeit und synthetisiert daraus 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 das Schema zur Laufzeit trägt).2

@Attribute(.unique) ist eine Bedingung für eine einzelne Spalte, keine PRIMARY KEY-Deklaration. Die persistente Identität in SwiftData ist der PersistentIdentifier, der automatisch pro Zeile erzeugt wird. Die Deklaration @Attribute(.unique) teilt dem Framework mit: „Diese Spalte speichert höchstens eine Zeile pro Wert.” Fügen Sie ein Modell mit einem .unique-Wert ein, der bereits existiert, führt SwiftData ein Upsert durch: Die vorhandene Zeile wird aktualisiert statt abgelehnt. Diese Semantik ist für Produktcode entscheidend: .unique ist keine Validierung auf UI-Ebene, die das Absenden von Duplikaten verhindert; es ist eine Speichergarantie nach dem Prinzip „höchstens eines”, die stillschweigend zusammenführt. Das obige Muster id: UUID ist das empfohlene für die prozessübergreifende Synchronisierung (wenn Sie einen stabilen Bezeichner brauchen, der es überlebt, dass der prozessinterne PersistentIdentifier verschwindet), und das Upsert-Verhalten ist genau das, was Sie wollen, wenn dieselbe UUID über zwei Sync-Pfade eintrifft.

@Model-Klassen sind Referenztypen, keine Werttypen. Das Ändern 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-Anbindung über @Query rendert jede View neu, die das passende Predicate beobachtet. Das Muster ähnelt @Observable (behandelt in What SwiftUI Is Made Of), nur mit Persistenz obendrauf.

Optionale Felder sind die günstige Migration

Das Feld lastModified: Date? an ShoppingItem ist optional, und diese Optionalität trägt Last. Das Feld kam nach dem Release von v1 hinzu, um geräteübergreifende Synchronisierung und Konfliktauflösung zu unterstützen; bestehende Zeilen auf den Geräten der Benutzer 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 bekommen nil; neue Zeilen bekommen, was der Initializer setzt.3

Der Lightweight-Migrationspfad ist der höfliche Pfad des Frameworks. SwiftData prüft das neue Schema und den persistenten Store, leitet die kleinstmögliche kompatible Änderung ab und wendet sie an. Die Migration läuft automatisch; der Benutzer sieht nichts davon; die App startet ganz normal auf den bestehenden Daten. Diese Fälle behandelt der Lightweight-Pfad sauber:

  • Eine optionale Property hinzufügen
  • Eine Property entfernen (die Daten fallen weg; bestehende Lesezugriffe sehen die Spalte nicht mehr)
  • Ein Attribut umbenennen, das das Framework über einen Hinweis zuordnen kann (mit @Attribute(originalName: ...))
  • Eine @Model-Klasse umbenennen, die das Framework zuordnen kann (mit @Model.originalName oder einem Hinweis)

Diese Fälle lassen den Lightweight-Pfad abbrechen:

  • Eine nicht-optionale Property ohne Standardwert zu einem bestehenden Schema hinzufügen (bestehende Zeilen haben keinen Wert, mit dem sie gefüllt werden könnte)
  • Den Typ einer Property ändern (z. B. IntString)
  • Ein Modell in zwei Modelle aufteilen oder zwei zu einem zusammenführen
  • Alles, was zur Migration eigene Logik braucht

Bricht der Lightweight-Pfad ab, ist das sichere Verhalten, die Migration scheitern 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 erlebt einen Absturz beim Start mit einem Migrationsfehler; die Entwicklerin sieht einen Stacktrace, der auf die Schema-Abweichung zeigt; niemand verliert Daten, aber alle verlieren Vertrauen.

Der Verzicht auf VersionedSchema vom ersten Tag an rächt sich an der Grenze von v2 zu v3, wenn Sie die dritte Funktion hinzufügen, deren Schema-Änderung über das hinausgeht, was der Lightweight-Pfad leistet.

VersionedSchema und MigrationPlan: die Disziplin vom ersten Tag

VersionedSchema deklariert eine bestimmte Version des Modellschemas. 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 darüber, wie sich das Schema entwickelt. Startet die v2-App gegen eine v1-Datenbank, durchläuft das Framework den Migrationsplan, wendet die benannten Stufen an und bringt die Datenbank auf v2. Wenn Sie v3 ausliefern, ergänzen Sie SchemaV3.self in schemas und eine neue MigrationStage zwischen v2 und v3. Das vollständige Migrationsmodell — welche Änderungen automatisch laufen, welche eine deklarierte Stufe brauchen und welcher Prüfsummen-Absturz droht, wenn Sie ein V2 deklarieren, das Sie gar nicht benötigen — ist Thema des Begleitartikels SwiftData migrations: lightweight vs custom.

Die Disziplin besteht darin, VersionedSchema schon in v1 auszuliefern, selbst wenn es nur eine Version gibt. Das kostet eine zusätzliche Datei und eine zusätzliche enum-Deklaration. Der Verzicht kostet, dass die erste nicht-triviale Schema-Änderung in v2 verlangt, v1 nachträglich in ein VersionedSchema zu hüllen — machbar, aber es braucht Sorgfalt, die exakte v1-Form zu treffen, damit das Framework die bestehenden Daten als SchemaV1 erkennt. Ihr künftiges Ich zahlt die Steuer bei v2; Ihr heutiges Ich kann sie einmal zahlen und die Sache 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, bevor und nachdem das Framework die strukturelle Migration anwendet. willMigrate läuft gegen das v1-Schema; didMigrate läuft gegen das v2-Schema. Der Rumpf der Closure ist ganz normaler SwiftData-Code (Fetch-Descriptors, Speichern des Model-Kontexts, dieselben APIs wie in der laufenden App), der gegen einen flüchtigen Kontext während der Migration arbeitet.

Das Muster, das die Produktion überlebt, hält willMigrate leer und legt die gesamte Befüllungslogik in didMigrate. v1-Daten innerhalb von willMigrate zu lesen ist erlaubt, aber aus Sicht des Frameworks existiert das v2-Schema noch nicht, also muss jede Berechnung in einem flüchtigen Speicher abgelegt werden, den die didMigrate-Closure lesen kann. Die einfachere Regel: Strukturelle Migrationen sind Sache des Frameworks; v2-eigene Felder in bestehenden Zeilen zu befüllen ist Sache von didMigrate.

Wann @Attribute und @Relationship ihre Namen verdienen

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

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

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

Die richtige Disziplin: .unique für Felder verwenden, die wirklich eindeutig sein sollen (eine selbst erzeugte UUID, eine externe ID), .externalStorage für jeden Blob jenseits weniger KB, und originalName dann, wenn eine Umbenennung in v2 sonst die v1-Daten verlieren würde.

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

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

Die richtige Disziplin: Beziehungen mit explizitem deleteRule deklarieren (der Standard ist .nullify, was selten gewollt ist) und inverse: immer dann deklarieren, wenn die Beziehung bidirektional ist (statt sich auf die Ableitung des Frameworks zu verlassen). Die impliziten Standardwerte sind meist falsch; die explizite Form ist ein zusätzlicher Parameter und ein für immer gesparter 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, den ein ModelContext hält; das Framework kann nicht zusichern, dass dieser Graph aus einem anderen Actor heraus sicher lesbar ist, deshalb bleibt der Typ bewusst nicht Sendable. Die Konformität zu erzwingen lässt das Data Race nicht verschwinden; es versteckt es nur.7

Das Muster, das funktioniert, sendet die Identität und die einfachen Werte und holt sie auf der anderen Seite neu. PersistentIdentifier ist Sendable und überquert die Grenze daher sauber. Ziehen Sie die skalaren Werte heraus, die das Ziel braucht (einen Namen, ein Flag, ein Delta in einer kleinen Struct), reichen Sie diese zusammen mit dem Bezeichner weiter, und lassen Sie den empfangenden Actor das Modell anhand des Bezeichners aus seinem eigenen Kontext neu laden:

// 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 Fehlerfall 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 verzögert geladene Properties, die im Quellkontext nie eingelagert wurden, lösen sich gegen den falschen Kontext auf (oder gar nicht), und die daraus folgenden Bugs sind von der leisen Sorte. Der Bezeichner plus die extrahierten Werte ist der sichere Vertrag; der Graph ist es nicht. Ein ModelActor kapselt diese Disziplin, indem er einen Kontext besitzt und Werte statt Instanzen herausgibt.7

CloudKit-Sync und die Falle des App-Group-Entitlements

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

Erstens der Speicherort des Stores. Mit der Standard-ModelConfiguration kopiert SwiftData den bestehenden Store für Sie in den App-Group-Container, sobald eine App von „ohne Gruppe” zu „mit App Group” wechselt; Apples Formulierung lautet, SwiftData „copies the existing store to the app group container”.8 Mit einer eigenen Store-URL gehört der Speicherort Ihnen: Sie kopieren die Datei selbst in den neuen Container und richten die Konfiguration darauf aus. Der Standardpfad ist genau deshalb bequem, weil das Framework das Kopieren übernimmt; der eigene Pfad tauscht diese Bequemlichkeit gegen Kontrolle.

Zweitens das Entitlement. Jedes Mitglied der App Group, das einen CloudKit-synchronisierten Store liest, muss dasselbe CloudKit-Entitlement tragen, denn jeder dieser Prozesse wird den Container auf eigene Rechnung synchronisieren. Genau darin liegt die Falle: Ein Widget oder eine Extension hat weder das Laufzeitbudget noch das Vordergrundfenster, um einen ausführlichen Sync zu betreiben, und das CloudKit-Entitlement zwingt sie, es trotzdem 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 Widget und Extensions lesen, ohne je zu synchronisieren. Legen Sie den Sync dorthin, wo eine App im Vordergrund 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 ausgeliefert hätten haben wollen.

Liefern Sie VersionedSchema ab v1 aus. Jede ausgelieferte @Model-Klasse sollte vom ersten Tag an in einem VersionedSchema leben. Der Preis ist ein umhüllendes enum pro Schema-Version. Der Nutzen: Die erste nicht-triviale Änderung in v2 ist eine einzeilige Ergänzung in MigrationPlan.schemas statt eines zweitägigen nachträglichen Refactorings.

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

Nutzen Sie UUIDs als natürlichen Schlüssel, nicht den PersistentIdentifier. Der PersistentIdentifier von SwiftData gilt nur prozessintern. Geräteübergreifender Sync, 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 einzigen Datensatz. App-Einstellungen, die gewählte Sprache des Benutzers, der Zeitstempel des letzten Syncs. Nehmen Sie UserDefaults oder NSUbiquitousKeyValueStore (behandelt in Five Apple Platforms, Three Shared Files). Der Overhead von SwiftData für eine einzelne Zeile ist verschwendete Zeremonie; Key-Value-Stores sind das richtige Substrat.

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

Prozessübergreifende 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 beschreiben. Prozessübergreifender Zustand braucht eine andere Form: eine JSON-Datei in iCloud Drive, einen gemeinsamen App-Group-Container oder eine explizite Synchronisierungsschicht, die Prozesse verbindet. (Get Bananas kombiniert SwiftData genau deshalb mit JSON in iCloud Drive.)6

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

Wann Core Data immer noch gewinnt

SwiftData ist eine Schicht über Core Data, kein Ersatz für alles daran, und drei Jahre später gehört eine bestimmte Menge an Aufgaben weiterhin dem älteren Framework. Sich für eine davon für Core Data zu entscheiden ist keine Alt-Entscheidung; es ist die aktuell richtige.

Aggregate auf Datenbankseite. SwiftData hat kein Gegenstück zu den NSExpression-basierten Fetches von Core Data, die sum, average, min und max bis nach SQLite durchreichen, sodass die Datenbank sie berechnet, ohne Zeilen zu laden9. In SwiftData holen und reduzieren Sie im Arbeitsspeicher, was den Sinn bei einer großen Tabelle zunichtemacht. Der dokumentierte Notausgang ist die Koexistenz: Apple beschreibt den Betrieb von „two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store”, womit die Core-Data-Seite das per SQL durchgereichte Aggregat gegen die Datei laufen lassen kann, die SwiftData gehört9. Die Mechanik samt der Anforderung NSPersistentHistoryTrackingKey behandelt SwiftData performance is a storage problem.

Freigabe und die öffentliche CloudKit-Datenbank. Der automatische iCloud-Sync von SwiftData setzt unter der Haube auf NSPersistentCloudKitContainer, und der Container, den er konfiguriert, spiegelt Ihren Store in die private CloudKit-Datenbank des Benutzers10. Zusammenarbeit zwischen verschiedenen iCloud-Benutzern über CKShare sowie das Veröffentlichen in die öffentliche Datenbank sind dokumentierte Fähigkeiten von Core Data + CloudKit, für die es Stand der iOS-27-Betas keine API auf SwiftData-Ebene gibt10. Eine App, deren Kernfunktion geteilte Listen oder gemeinsam bearbeitete Dokumente sind, steigt für die synchronisierten Stores entweder auf Core Data ab oder baut die CloudKit-Schicht von Hand.

Batch-Updates auf Store-Ebene. Der NSBatchUpdateRequest von Core Data schreibt passende Zeilen direkt im Store um, ohne Objekte zu laden11. Der ModelContext von SwiftData hat die Lösch-Hälfte (delete(model:where:) nimmt ein Predicate), aber kein Gegenstück für Batch-Updates, sodass ein Massen-Umschreiben eines Felds in SwiftData bedeutet, jedes betroffene Modell zu materialisieren.

Ein Deployment-Ziel unterhalb von iOS 17. SwiftData verlangt iOS 17; Core Data reicht so weit zurück, wie eine App überhaupt noch unterstützt, und sein CloudKit-Container bis iOS 131012. Eine Codebasis mit langem OS-Schwanz hat die Wahl nicht.

Erwähnenswert ist, was von dieser Liste verschwunden ist: „Sie brauchen NSFetchedResultsController außerhalb einer View” war ein Punkt für Core Data, bis die iOS-27-Betas ResultsObserver einführten, der einen Fetch überall in der App über Swift Observation beobachtet13. Die Liste der Lücken schrumpft von Release zu Release. Die Wette: Neue Apps auf SwiftData beginnen, für die oben genannten Aufgaben Koexistenz einsetzen und einen vollen Core-Data-Stack nur dann als Antwort betrachten, wenn Freigabe, die öffentliche Datenbank oder das Deployment-Ziel dazu zwingen.

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 eine Menge Core-Data-Verrohrung verbergen. Migrationsdisziplin ist das, was Sie über die Lebensdauer der App tatsächlich bezahlen; entwerfen Sie v1 mit Blick auf v2.

  2. VersionedSchema vom ersten Tag an ist für ausgelieferte Apps nicht verhandelbar. Das umhüllende enum ist eine zusätzliche Datei. Der nachträgliche Preis, es später einzuführen, ist erheblich höher.

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

Der vollständige Cluster zum Apple-Ökosystem: typisierte App Intents für Apple Intelligence; MCP-Server für LLM-übergreifende Agenten; die Routing-Frage zwischen beiden; Foundation Models für On-Device-LLM und das Tool-Protokoll; Live Activities für die Zustandsmaschine des Sperrbildschirms unter iOS; der watchOS-Laufzeitvertrag auf der Apple Watch; SwiftUI-Interna für das Substrat des Frameworks; RealityKits räumliches mentales Modell für visionOS-Szenen; Liquid-Glass-Muster für die visuelle Schicht; Multi-Plattform-Auslieferung für die geräteübergreifende Reichweite. Der Knotenpunkt ist die Apple Ecosystem Series. Für den breiteren Kontext zu iOS mit KI-Agenten siehe den iOS Agent Development guide.

Häufige Fragen

Was ist der Unterschied zwischen @Model und NSManagedObject von Core Data?

@Model ist ein Swift-Makro, das die NSManagedObject-Verrohrung unter der Haube erzeugt. SwiftData nutzt Core Data als Backing Store, das Laufzeitmodell ist also dasselbe; der Unterschied liegt an der Oberfläche. @Model beseitigt die .xcdatamodeld-Datei, die Value-Transformer-Zeremonie und die Lebenszyklusverwaltung des NSManagedObjectContext. Sie bekommen denselben persistenten Store mit einer Swift-förmigen API.

Brauche ich VersionedSchema, wenn ich das Schema nie ändern will?

Wenn Ihre App womöglich eine v2 ausliefert: ja. Wenn es eine einmalige Demo ist: nein. VersionedSchema ab v1 kostet eine zusätzliche enum-Deklaration. Es nachträglich bei v2 einzuführen kostet, die exakte v1-Schemaform zu treffen, damit das Framework bestehende Daten erkennt — machbar, aber fehleranfällig. Die meisten ausgelieferten Apps brauchen irgendwann eine Schema-Änderung; planen Sie sie in v1 ein.

Wann sollte ich @Attribute(.unique) einsetzen?

Wenn das Feld ein natürlicher Schlüssel für die Zeile ist: eine selbst erzeugte UUID, eine importierte externe ID, ein vergebener Slug. SwiftData behandelt .unique als Upsert: Fügen Sie ein Modell ein, dessen .unique-Wert bereits existiert, wird die vorhandene Zeile aktualisiert statt eine neue angehängt. Genau diese Semantik macht Upsert-artige Sync-Pfade sicher (dieselbe UUID von zwei Geräten); sie ist auch der Grund, warum .unique das falsche Werkzeug für Anzeigenamen 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 gehe ich mit einem nicht-optionalen Feld um, das zu einem bestehenden Schema hinzukommt?

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

Was ist PersistentIdentifier im Vergleich zu meiner eigenen UUID?

PersistentIdentifier ist die prozessinterne Zeilen-ID von SwiftData; sie wird automatisch erzeugt und lebt so lange wie der laufende Prozess. Ihre eigene UUID mit @Attribute(.unique) ist ein stabiler prozess- und geräteübergreifender Bezeichner. Nutzen Sie PersistentIdentifier für prozessinterne Referenzen innerhalb der App. Nutzen Sie eine UUID für alles, was eine Prozessgrenze überquert (geräteübergreifender Sync, externe Integrationen, MCP-Tools, Netzwerkaufrufe).

Wann sollte ich weiterhin Core Data statt SwiftData wählen?

Vier Fälle, Stand der iOS-27-Betas: Aggregate auf Datenbankseite (NSExpression-Fetches, die SwiftData fehlen), Freigabe zwischen iCloud-Benutzern über CKShare oder die öffentliche CloudKit-Datenbank (der Sync von SwiftData deckt die private Datenbank ab), Batch-Updates auf Store-Ebene (NSBatchUpdateRequest) und Deployment-Ziele unterhalb von iOS 1791011. Für den Aggregat-Fall müssen Sie SwiftData nicht aufgeben: Betreiben Sie einen koexistierenden Core-Data-Stack gegen dieselbe Store-Datei.

Quellen


  1. Get Bananas des Autors, eine SwiftUI-Einkaufslisten-App, die SwiftData mit JSON-Sync über iCloud Drive und einem MCP-Server verbindet. Das ShoppingItem-Modell entwickelte sich über den frühen Entwicklungszyklus hinweg; das Feld lastModified: Date? kam nach dem ursprünglichen Schema hinzu (Commit 268a00d vom 01.12.2025, „Make lastModified optional to fix migration crash”), weil es als nicht-optionales Feld die Migration zerbrach, sobald bestehende Zeilen keinen Wert zum Befüllen hatten. 

  2. Apple Developer, “SwiftData” und “Adding and editing persistent data in your app”. Das @Model-Makro, die Bedingungsfläche von @Attribute und der Bezug zum NSManagedObjectModel von Core Data. 

  3. Apple Developer, “Preserving your app’s model data across launches” und “Adopting SwiftData for a Core Data app”. Die Semantik der Lightweight-Migration und was das Framework zum Abbruch bringt. 

  4. Apple Developer, “VersionedSchema” und “SchemaMigrationPlan”. Deklarationen versionierter Schemata, Definitionen von Migrationsstufen und der ModelContainer-Konstruktor, der einen Migrationsplan entgegennimmt. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” und “Schema.Relationship”. Das @Relationship-Makro, die deleteRule-Optionen (.cascade, .nullify, .deny, .noAction) und die Rolle des inverse:-Parameters bei der Pflege bidirektionaler Beziehungen. 

  6. Analyse des Autors in Two Agent Ecosystems, One Shopping List, 29. April 2026, und Five Apple Platforms, Three Shared Files. Die prozess- und geräteübergreifenden Sync-Muster von Get Bananas und Return, die SwiftData innerhalb eines Multi-Prozess-Workflows ergänzen (und mitunter ersetzen). 

  7. Apple Developer, “PersistentIdentifier” (konform zu Sendable) und “ModelActor”. Das SwiftData-Team bestätigte im SwiftData Group Lab der WWDC 2026, dass @Model-Objekte nicht Sendable sind und nicht zur Konformität gezwungen werden sollten, weil sie ein Referenzgraph innerhalb eines Kontexts sind; der empfohlene Grenzvertrag ist, den Sendable-PersistentIdentifier samt extrahierter einfacher Werte zu übergeben und im Zielkontext neu zu laden, und dass das Übergeben des Modellgraphen dem Empfänger ein nur teilweise hydratisiertes Objekt hinterlässt. Sinngemäß wiedergegeben nach einer lokal transkribierten Aufzeichnung des SwiftData Group Lab der WWDC 2026; Apple veröffentlicht keine offiziellen Untertitel für die Labs. 

  8. Apple Developer, “Adopting SwiftData for a Core Data app”, wo es heißt, dass SwiftData mit der Standardkonfiguration „copies the existing store to the app group container”, während eine eigene Store-URL den Speicherort in Ihrer Verantwortung belässt. Die CloudKit-Entitlement-Anforderung für App-Group-Mitglieder und die Aufteilung auf zwei ModelConfiguration-Instanzen (eine synchronisiert, eine lokal), um Widgets und Extensions aus dem Sync-Pfad herauszuhalten, wurden im SwiftData Group Lab der WWDC 2026 beschrieben. Sinngemäß wiedergegeben nach einer lokal transkribierten Aufzeichnung des SwiftData Group Lab der WWDC 2026; Apple veröffentlicht keine offiziellen Untertitel für die Labs. 

  9. Apple, WWDC-2023-Session 10189, “Migrate to SwiftData”, Quelle der Koexistenz-Formulierung („two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store”), sowie Apple Developer, “NSExpression”, der Mechanismus hinter den per SQL durchgereichten Aggregat-Fetches von Core Data, zu denen SwiftData kein Gegenstück bietet. Die Lücke wurde vom SwiftData-Engineering-Panel im SwiftData Group Lab der WWDC 2026 bestätigt (sinngemäß wiedergegeben nach einer lokal transkribierten Aufzeichnung). 

  10. Apple Developer, “Syncing model data across a person’s devices”, wo es heißt, dass „SwiftData uses the NSPersistentCloudKitContainer class from Core Data to handle CloudKit synchronization”; “NSPersistentCloudKitContainer” (iOS 13.0+), dessen Kurzbeschreibung das Spiegeln von „select persistent stores to a CloudKit private database” schildert; und “Sharing Core Data objects between iCloud users”, der dokumentierte Core-Data-Weg für Zusammenarbeit über CKShare. Die SwiftData-Dokumentation legt Stand der iOS-27-Betas keine API für Freigabe oder die öffentliche Datenbank offen. 

  11. Apple Developer, “NSBatchUpdateRequest” und “ModelContext.delete(model:where:includeSubclasses:)”, das Predicate-basierte Batch-Löschen von SwiftData. Die Dokumentation des ModelContext von SwiftData führt kein Gegenstück für Batch-Updates auf. 

  12. Plattformverfügbarkeit laut Apple-Developer-Dokumentation: SwiftData (iOS 17.0+) und Core Data (iOS 3.0+). 

  13. Apple Developer, “ResultsObserver” (iOS 27.0 Beta), der „observes and tracks changes to a collection of persistent models in a model context” und Observable entspricht und damit die Beobachtungsrolle außerhalb einer View übernimmt, die zuvor den NSFetchedResultsController von Core Data erforderte. 

Verwandte Beiträge

SwiftData-Migrationen: leichtgewichtig oder benutzerdefiniert – und wann Sie kein V2 brauchen

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

18 Min. Lesezeit

SwiftData in iOS 27: Observation und History

iOS 27 gibt SwiftData echte Änderungsbeobachtung: ResultsObserver, HistoryObserver für die Persistent History und Codabl…

14 Min. Lesezeit

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

Charlie Labs baut keine Agenten mehr, sondern räumt hinter ihnen auf. Der KI-Agenten-Markt verlagert sich von Generierun…

15 Min. Lesezeit