Fallstricke beim Einsatz von SwiftData

Seit der Veröffentlichung vor einem Jahr nutze ich SwiftData heute in all meinen aktiven Projekten und freue mich über die simple Model-Deklaration im Code und die tiefe Integration mit SwiftUI. Die meiste Zeit ist die Arbeit mit dem Framework einfach nur angenehm und die App-Entwicklung geht effizient vonstatten.

Aber …

Während meiner Arbeit mit SwiftData stieß ich bisweilen auf so manche Fehler und Probleme, deren Ursache – wenigstens für mich – nicht auf den ersten Blick ersichtlich war. So stürzten Apps nach Aktivieren des iCloud-Supports auf einmal ab. Oder View-Updates bei Property-Änderungen werden nicht getriggert.

Es hat mich teils recht viel Zeit gekostet, Lösungen für diese am Ende doch einfachen Probleme zu finden. Im Folgenden trage ich meine top Drei der Fallstricke zusammen, mit denen ich bei der Arbeit mit SwiftData bisher konfrontiert wurde.

CloudKit-Support nur mit Standardwerten/Optionals

Im Grunde ist die Gestaltung von Models via SwiftData äußerst simpel. Das nachfolgende Listing zeigt eine erste Version eines Book-Models, das ich in einer persönlich entwickelten App nutze. Es setzt sich aus einem Titel als Pflichtfeld sowie zwei optionalen Eigenschaften (Autor und Cover) zusammen.

@Model final class Book {
    var title: String
    
    var author: Author?
    
    @Attribute(.externalStorage) var cover: Data?
    
    init(title: String, author: Author? = nil, cover: Data? = nil) {
        self.title = title
        self.author = author
        self.cover = cover
    }
}

Dieses Model lässt sich ohne Einschränkungen verwenden, wenigstens so lange, bis man CloudKit-Support ergänzt. Bindet man ein solches Model dann in den zugrundeliegenden Model-Container ein, kommt es bereits beim App-Start zum Crash.

Das sorgt für Verwunderung, doch wenigstens liefert die Konsole die Ursache für das Problem: Bei Einsatz von CloudKit mit SwiftData müssen alle Properties des Models entweder:

  • optional sein (was auf author und cover zutrifft) oder
  • über einen Standardwert verfügen.

Im Falle von title trifft keine der beiden Voraussetzungen zu, daher der Absturz. Die Lösung des Problems ist entsprechend erfreulich einfach (wenngleich ich sie bezüglich der Model-Gestaltung recht unschön finde). Es muss lediglich für title ein Standardwert gesetzt werden, und schon lässt sich das Model auch mit CloudKit-Support nutzen.

@Model final class Book {

    // Dank des neuen Standardwerts klappt es jetzt auch mit dem CloudKit-Support.
    var title = ""
    
    var author: Author?
    
    @Attribute(.externalStorage) var cover: Data?
    
    init(title: String, author: Author? = nil, cover: Data? = nil) {
        self.title = title
        self.author = author
        self.cover = cover
    }
}

Explizite Inverse-Relationships sind immer einseitig

Zugegeben ist dieser Fallstrick keine große Sache, sorgte aber bei der Model-Gestaltung bisweilen für Stirnrunzeln, insbesondere bei komplexeren Abhängigkeiten zwischen den verschiedenen Model-Typen.

Kommen explizit definierte Inverse-Relationships zum Einsatz, ist sicherzustellen, dass diese explizite Deklaration nur auf einer Seite des Models erfolgt. Der nachfolgende (gekürzte) Code zeigt beispielhaft die Beziehung der beiden Typen Author und Book, die explizit jeweils als Inverse-Relationship gekennzeichnet ist.

@Model final class Author {
    /* ... */
    
    @Relationship(deleteRule: .cascade, inverse: \Book.author) var books: [Book]?
    
    /* ... */
}

@Model final class Book {
    /* ...  */
    
    @Relationship(inverse: \Author.books) var author: Author?
    
    /* ... */
}

Solch explizit definierte Inverse-Relationships führen zu folgendem Compiler-Fehler:

Circular reference resolving attached macro ‚Relationship‘

Die Lösung ist dafür gleichermaßen simpel: Die explizit gesetzte Inverse-Relationship muss auf einer Seite der Beziehung aufgelöst werden. In diesem Fall entschied ich mich dazu, die Deklaration aus der Book-Klasse zu entfernen, da innerhalb von Author das @Relationship-Makro zwecks der Delete-Rule ohnehin benötigt wird:

@Model final class Author {
    /* ... */
    
    @Relationship(deleteRule: .cascade, inverse: \Book.author) var books: [Book]?
    
    /* ... */
}

@Model final class Book {
    /* ...  */
    
    var author: Author?
    
    /* ... */
}

@Transient triggert keine View-Updates

Zu guter Letzt komme ich zu einem Aspekt, der mich gefühlt beinahe um den Verstand gebracht hatte (dafür hat er sich jetzt in meinem Kopf festgesetzt und ich werde hoffentlich voraussichtlich nie wieder über diesen Fallstrick stolpern).

Mithilfe des @Transient-Makros lassen sich Properties im SwiftData-Model deklarieren, die nicht persistiert werden sollen. Das funktioniert einwandfrei, jedoch gibt es ein Problem: Updates solcher @Transient-Properties triggern keine View-Updates.

Das nachfolgend erdachte Beispiel soll das erläutern. Hier wird die Book-Klasse um eine Eigenschaft isFavorite ergänzt, die in diesem Fall nicht persistiert werden soll. Daher erfolgt eine entsprechende Deklaration mittels @Transient.

@Model final class Book {
    /* ... */
    
    @Transient var isFavorite = false
    
    /* ... */
}

Implementiere ich nun einen Button, der den isFavorite-Status wechselt, stelle ich zwar fest, dass die isFavorite-Property an sich korrekt aktualisiert wird, die zugehörige View (sprich das Label des Buttons) aber nicht auf jene Aktualisierung reagiert.

struct FavoriteBookButton: View {
    let book: Book
    
    var body: some View {
        Button {
            book.isFavorite.toggle()
        } label: {
            Image(systemName: book.isFavorite ? "star.fill" : "star")
        }
    }

Möchte man, dass eine Property eines SwiftData-Models nicht persistiert wird und gleichzeitig eine Änderung an ihr View-Updates auslösen kann, muss eine andere Deklaration statt @Transient her.

So erreicht man via des Einsatzes von @Attribute(.ephemeral) das gewünschte Ziel. So deklarierte Properties werden ebenfalls nicht persistiert, triggern bei Änderung aber ein View-Update.

@Model final class Book {
    /* ... */
    
    @Attribute(.ephemeral) var isFavorite = false
    
    /* ... */
}

Durch diese Änderung am Book-Model funktioniert jetzt der FavoriteBookButton und aktualisiert das Label bei jeder Betätigung. Die View bleibt also dieselbe, nur die Model-Deklaration musste angepasst werden.