Wprowadzenie — dlaczego SwiftData zmienia zasady gry
Przez ponad dekadę Core Data był jedynym oficjalnym rozwiązaniem do persystencji danych w ekosystemie Apple. I bądźmy szczerzy — działał solidnie, ale wymagał sporej dawki boilerplate'u. Pliki .xcdatamodeld, podklasy NSManagedObject, ręczne zarządzanie kontekstem i żmudne konfigurowanie stacku. Dla wielu programistów (zwłaszcza tych zaczynających przygodę z iOS) był po prostu za trudny na start.
Na WWDC 2023 Apple pokazało SwiftData — nowy framework do persystencji, zbudowany od podstaw z myślą o nowoczesnym Swifcie i SwiftUI. Zamiast plików XML i edytora modeli definiujesz schemat bezpośrednio w kodzie Swift za pomocą makr. Zamiast NSFetchRequest — używasz deklaratywnego @Query. A integracja ze SwiftUI? Tak naturalna, że widoki odświeżają się automatycznie przy każdej zmianie danych.
Od premiery SwiftData przeszło sporą ewolucję. W iOS 18 pojawiły się ograniczenia unikalności (#Unique), indeksy (#Index), śledzenie historii zmian i własne magazyny danych. W iOS 26 (Xcode 26) doszło dziedziczenie modeli oraz szereg poprawek stabilności. Framework dojrzał na tyle, że naprawdę nadaje się do produkcyjnych aplikacji.
W tym artykule przejdziemy przez SwiftData od zera do poziomu zaawansowanego — od definiowania modeli, przez zapytania i relacje, aż po operacje w tle z @ModelActor, migracje schematów i optymalizację wydajności. Wszystko z działającymi przykładami kodu, gotowymi do wrzucenia do Twojego projektu.
Wymagania i konfiguracja projektu
SwiftData wymaga iOS 17+ (lub macOS 14+, watchOS 10+, tvOS 17+) i Xcode 15 lub nowszego. Jeśli chcesz korzystać z nowszych funkcji jak #Unique czy #Index, potrzebujesz iOS 18+ i Xcode 16+. Najnowsze rzeczy, takie jak dziedziczenie modeli, wymagają już iOS 26 i Xcode 26.
Dobra wiadomość — nie musisz ciągnąć żadnych zewnętrznych zależności. SwiftData jest częścią SDK Apple, więc po prostu importujesz go w pliku źródłowym:
import SwiftData
import SwiftUI
Tworzenie nowego projektu ze SwiftData
W Xcode, tworząc nowy projekt, zaznacz opcję Storage: SwiftData. Xcode wygeneruje za Ciebie podstawową konfigurację z kontenerem modelu. Jeśli dodajesz SwiftData do istniejącego projektu, musisz samodzielnie skonfigurować ModelContainer — pokażemy to za chwilę.
Definiowanie modeli — makro @Model
Sercem SwiftData jest makro @Model. Wystarczy oznaczyć nim klasę Swift, a framework sam ogarnie persystencję — bez żadnych plików XML, bez edytora modeli danych. Serio, to takie proste.
Najprostszy model
import SwiftData
@Model
class Zadanie {
var tytul: String
var opis: String
var ukonczone: Bool
var dataUtworzenia: Date
init(tytul: String, opis: String = "", ukonczone: Bool = false) {
self.tytul = tytul
self.opis = opis
self.ukonczone = ukonczone
self.dataUtworzenia = Date()
}
}
I to wszystko. Żadnego NSManagedObject, żadnego pliku .xcdatamodeld. Makro @Model generuje conformance do protokołu PersistentModel, obsługę śledzenia zmian i serializacji do SQLite. Jeśli kiedykolwiek walczyłeś z konfiguracją Core Data, to właśnie poczułeś ulgę.
Obsługiwane typy właściwości
SwiftData radzi sobie z większością standardowych typów Swift:
- Typy proste:
String,Int,Double,Bool,Float - Typy Foundation:
Date,UUID,URL,Data - Kolekcje:
Array,Dictionary(z typamiCodable) - Enumy: o ile są
Codablei mająRawValue - Struktury: o ile są
Codable - Relacje: inne typy oznaczone
@Model
Dostosowywanie właściwości za pomocą @Attribute
Makro @Attribute daje Ci precyzyjną kontrolę nad sposobem przechowywania właściwości:
@Model
class Uzytkownik {
var imie: String
var nazwisko: String
@Attribute(.unique)
var email: String
@Attribute(.externalStorage)
var zdjecie: Data?
@Attribute(.spotlight)
var bio: String?
@Attribute(.encrypt)
var haslo: String
init(imie: String, nazwisko: String, email: String) {
self.imie = imie
self.nazwisko = nazwisko
self.email = email
self.haslo = ""
}
}
Najważniejsze opcje @Attribute:
.unique— gwarantuje unikalność wartości w bazie danych.externalStorage— duże dane (np. obrazki) trafiają poza główny plik SQLite.spotlight— włącza indeksowanie w Spotlight.encrypt— szyfruje dane na dysku.transformable(by:)— używa niestandardowego transformera
Ograniczenia unikalności z #Unique (iOS 18+)
Od iOS 18 możesz definiować złożone ograniczenia unikalności za pomocą makra #Unique:
@Model
class Produkt {
#Unique<Produkt>([\.nazwaKategorii, \.kodProduktu])
var nazwaKategorii: String
var kodProduktu: String
var cena: Double
init(nazwaKategorii: String, kodProduktu: String, cena: Double) {
self.nazwaKategorii = nazwaKategorii
self.kodProduktu = kodProduktu
self.cena = cena
}
}
Gdy próbujesz wstawić duplikat, SwiftData automatycznie robi upsert — aktualizuje istniejący rekord zamiast tworzyć nowy. To ogromne ułatwienie przy synchronizacji danych z serwera (wcześniej trzeba było pisać to ręcznie).
Indeksowanie z #Index (iOS 18+)
Żeby przyspieszyć często wykonywane zapytania, możesz użyć makra #Index:
@Model
class Artykul {
#Index<Artykul>([\.datapublikacji], [\.kategoria, \.datapublikacji])
var tytul: String
var tresc: String
var kategoria: String
var datapublikacji: Date
init(tytul: String, tresc: String, kategoria: String) {
self.tytul = tytul
self.tresc = tresc
self.kategoria = kategoria
self.datapublikacji = Date()
}
}
Indeksy działają na poziomie SQLite i potrafią drastycznie przyspieszyć filtrowanie i sortowanie na dużych zbiorach danych. Warto dodawać je od razu tam, gdzie wiesz, że będziesz dużo filtrować.
ModelContainer i ModelContext — silnik SwiftData
Żeby SwiftData zaczął działać, potrzebujesz dwóch rzeczy: ModelContainer (kontener definiujący schemat i konfigurację magazynu) oraz ModelContext (kontekst do odczytu i zapisu danych). Brzmi groźnie, ale w praktyce konfiguracja jest banalna.
Konfiguracja kontenera w SwiftUI
Najprostszy sposób to jedna linijka z modyfikatorem .modelContainer(for:) w głównym widoku aplikacji:
@main
struct MojaAplikacjaApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Zadanie.self, Uzytkownik.self])
}
}
SwiftData sam tworzy plik SQLite w katalogu danych aplikacji, konfiguruje schemat i udostępnia kontekst w środowisku SwiftUI. Nie musisz się o nic martwić.
Zaawansowana konfiguracja kontenera
Jeśli potrzebujesz większej kontroli — np. konfiguracji CloudKit, własnego katalogu zapisu czy wyłączenia automatycznego zapisu — tworzysz kontener ręcznie:
@main
struct MojaAplikacjaApp: App {
let kontener: ModelContainer
init() {
let schemat = Schema([Zadanie.self, Uzytkownik.self])
let konfiguracja = ModelConfiguration(
"MojaBaza",
schema: schemat,
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: .automatic,
cloudKitDatabase: .private("iCloud.com.moja.aplikacja")
)
do {
kontener = try ModelContainer(
for: schemat,
configurations: [konfiguracja]
)
} catch {
fatalError("Nie udało się utworzyć kontenera: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(kontener)
}
}
ModelContext — operacje CRUD
W widokach SwiftUI kontekst jest dostępny przez środowisko. Dodawanie i usuwanie danych to dosłownie kilka linii:
struct ContentView: View {
@Environment(\.modelContext) private var kontekst
func dodajZadanie() {
let noweZadanie = Zadanie(tytul: "Naucz się SwiftData")
kontekst.insert(noweZadanie)
// Zapis odbywa się automatycznie!
}
func usunZadanie(_ zadanie: Zadanie) {
kontekst.delete(zadanie)
}
}
Ważna uwaga: SwiftData automatycznie zapisuje zmiany w odpowiedzi na zdarzenia systemowe — np. przejście aplikacji do tła czy nawigację między widokami. Nie musisz ręcznie wywoływać save(), chyba że potrzebujesz natychmiastowego zapisu (np. przed wysłaniem danych na serwer). To faktycznie spora zmiana w porównaniu z Core Data.
Zapytania — @Query i FetchDescriptor
SwiftData daje Ci dwa sposoby pobierania danych: deklaratywne @Query (idealne w widokach SwiftUI) oraz imperatywne FetchDescriptor (świetne w logice biznesowej i operacjach w tle).
@Query — deklaratywne zapytania w SwiftUI
Makro @Query automatycznie pobiera dane i odświeża widok przy każdej zmianie. Szczerze? To chyba moja ulubiona funkcja SwiftData:
struct ListaZadan: View {
@Query(
filter: #Predicate<Zadanie> { !$0.ukonczone },
sort: \Zadanie.dataUtworzenia,
order: .reverse
)
private var aktywneZadania: [Zadanie]
var body: some View {
List(aktywneZadania) { zadanie in
Text(zadanie.tytul)
}
}
}
Dynamiczne sortowanie i filtrowanie
Tu jest pewien haczyk. @Query nie obsługuje bezpośrednio dynamicznej zmiany sortowania w runtime. Żeby to obejść, musisz przenieść @Query do podwidoku i przekazywać parametry sortowania przez inicjalizator:
struct ListaZadanFiltrowana: View {
@Query private var zadania: [Zadanie]
init(sortujPo: SortDescriptor<Zadanie>, tylkoAktywne: Bool) {
let predykat: Predicate<Zadanie>? = tylkoAktywne
? #Predicate { !$0.ukonczone }
: nil
_zadania = Query(
filter: predykat,
sort: [sortujPo]
)
}
var body: some View {
List(zadania) { zadanie in
Text(zadanie.tytul)
}
}
}
// Użycie w widoku nadrzędnym:
struct ContentView: View {
@State private var sortowanieRosnace = true
var body: some View {
ListaZadanFiltrowana(
sortujPo: SortDescriptor(
\Zadanie.dataUtworzenia,
order: sortowanieRosnace ? .forward : .reverse
),
tylkoAktywne: true
)
}
}
Trochę więcej kodu niż byśmy chcieli, ale działa niezawodnie. Mam nadzieję, że Apple uprości to w przyszłych wersjach.
FetchDescriptor — zapytania poza SwiftUI
W logice biznesowej lub operacjach w tle sięgaj po FetchDescriptor:
func pobierzOstatnieZadania(kontekst: ModelContext, limit: Int = 10) throws -> [Zadanie] {
var deskryptor = FetchDescriptor<Zadanie>(
predicate: #Predicate { !$0.ukonczone },
sortBy: [SortDescriptor(\Zadanie.dataUtworzenia, order: .reverse)]
)
deskryptor.fetchLimit = limit
return try kontekst.fetch(deskryptor)
}
// Zliczanie rekordów — bez ładowania obiektów do pamięci
func policzAktywneZadania(kontekst: ModelContext) throws -> Int {
let deskryptor = FetchDescriptor<Zadanie>(
predicate: #Predicate { !$0.ukonczone }
)
return try kontekst.fetchCount(deskryptor)
}
Pro tip: Jeśli potrzebujesz tylko liczbę rekordów, zawsze używaj fetchCount() zamiast pobierania tablicy i sprawdzania .count. Różnica wydajności przy dużych zbiorach danych jest kolosalna — fetchCount() wykonuje SELECT COUNT(*) na poziomie SQLite, bez ładowania czegokolwiek do pamięci.
Relacje między modelami
SwiftData automatycznie wykrywa relacje na podstawie typów właściwości. Jeśli klasa oznaczona @Model ma właściwość będącą inną klasą @Model, framework tworzy relację sam z siebie. Nie musisz niczego konfigurować ręcznie (choć możesz).
Relacja jeden-do-wielu
@Model
class Projekt {
var nazwa: String
var opis: String
@Relationship(deleteRule: .cascade)
var zadania: [Zadanie] = []
init(nazwa: String, opis: String = "") {
self.nazwa = nazwa
self.opis = opis
}
}
@Model
class Zadanie {
var tytul: String
var ukonczone: Bool
var projekt: Projekt?
init(tytul: String, ukonczone: Bool = false) {
self.tytul = tytul
self.ukonczone = ukonczone
}
}
Reguły usuwania
Makro @Relationship pozwala określić, co stanie się z powiązanymi obiektami po usunięciu rodzica:
.nullify(domyślna) — właściwość w powiązanych obiektach ustawiana nanil.cascade— usunięcie rodzica automatycznie usuwa wszystkie dzieci.deny— blokuje usunięcie rodzica, jeśli ma powiązane obiekty.noAction— nie robi nic (ostrożnie z tym, bo łatwo zostawić osierocone rekordy)
Uwaga na kolejność elementów w relacjach
To znany problem, który potrafi zaskoczyć. SwiftData nie gwarantuje zachowania kolejności elementów w tablicach relacji. Po ponownym załadowaniu modelu z dysku kolejność elementów może się zmienić. Jeśli kolejność jest dla Ciebie istotna, dodaj właściwość sortOrder: Int do modelu potomnego i sortuj wyniki ręcznie. Trochę upierdliwe, ale na razie nie ma lepszego rozwiązania.
Operacje w tle z @ModelActor
Domyślnie SwiftData działa na głównym wątku (MainActor). To sprawdza się świetnie przy małych zbiorach danych, ale przy importowaniu tysięcy rekordów lub synchronizacji z API — interfejs zamiera. Użytkownik widzi zamrożoną aplikację i zaczyna nerwowo tapać w ekran. Rozwiązanie? @ModelActor.
Tworzenie aktora do operacji w tle
import SwiftData
@ModelActor
actor MenedzerDanych {
func importujZadania(_ dane: [ZadanieDTO]) throws {
for dto in dane {
let zadanie = Zadanie(tytul: dto.tytul)
zadanie.opis = dto.opis
modelContext.insert(zadanie)
}
try modelContext.save()
}
func usunUkonczone() throws {
let deskryptor = FetchDescriptor<Zadanie>(
predicate: #Predicate { $0.ukonczone }
)
let ukonczone = try modelContext.fetch(deskryptor)
for zadanie in ukonczone {
modelContext.delete(zadanie)
}
try modelContext.save()
}
}
Makro @ModelActor automatycznie tworzy dedykowany ModelContext z własnym seryjnym executorem. Operacje wykonują się sekwencyjnie na prywatnej kolejce, nie blokując głównego wątku. Bezpieczeństwo wątkowe out of the box.
Wywoływanie aktora z widoku
struct ContentView: View {
@Environment(\.modelContext) private var kontekst
var body: some View {
Button("Importuj dane") {
Task.detached {
let menedzer = MenedzerDanych(
modelContainer: kontekst.container
)
try await menedzer.importujZadania(daneZSerwera)
}
}
}
}
Przekazywanie danych między aktorami
I tu dochodzimy do ważnego punktu. Modele SwiftData nie są Sendable — nie możesz ich po prostu przekazywać między aktorami. Masz dwa wyjścia:
1. PersistentIdentifier — przekaż identyfikator i pobierz obiekt w docelowym kontekście:
@ModelActor
actor MenedzerDanych {
func oznaczJakoUkonczone(id: PersistentIdentifier) throws {
guard let zadanie = modelContext[id] as Zadanie? else { return }
zadanie.ukonczone = true
try modelContext.save()
}
}
2. DTO (Data Transfer Object) — zdefiniuj prostą strukturę Sendable do przenoszenia danych:
struct ZadanieDTO: Sendable {
let id: UUID
let tytul: String
let opis: String
let ukonczone: Bool
}
@ModelActor
actor MenedzerDanych {
func pobierzWszystkie() throws -> [ZadanieDTO] {
let zadania = try modelContext.fetch(FetchDescriptor<Zadanie>())
return zadania.map { ZadanieDTO(
id: $0.id, tytul: $0.tytul,
opis: $0.opis, ukonczone: $0.ukonczone
)}
}
}
Osobiście częściej korzystam z podejścia DTO — jest bardziej jawne i łatwiejsze w debugowaniu. PersistentIdentifier jest za to wygodniejsze, gdy potrzebujesz zmodyfikować istniejący obiekt.
Migracje schematów
Modele danych ewoluują z czasem — dodajesz właściwości, zmieniasz typy, usuwasz pola. To normalne. SwiftData obsługuje dwa rodzaje migracji: lekkie (automatyczne) i złożone (ręczne).
Lekkie migracje
SwiftData automatycznie ogarnia następujące zmiany bez żadnej dodatkowej konfiguracji:
- Dodanie nowej właściwości (z wartością domyślną)
- Usunięcie istniejącej właściwości
- Zmiana nazwy właściwości (z użyciem
@Attribute(originalName:)) - Dodanie atrybutów
.unique,.externalStorage
// Zmiana nazwy właściwości z zachowaniem danych
@Model
class Zadanie {
@Attribute(originalName: "tytul")
var nazwa: String // Przemianowane z "tytul" na "nazwa"
var opis: String
var ukonczone: Bool
var priorytet: Int = 0 // Nowa właściwość z wartością domyślną
}
Złożone migracje z VersionedSchema
Gdy zmiana wymaga transformacji danych (np. dodanie ograniczenia unikalności do kolumny, w której już są duplikaty), musisz zdefiniować wersjonowane schematy i plan migracji. To trochę więcej roboty, ale daje pełną kontrolę:
// Wersja 1 schematu
enum SchematV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Zadanie.self]
}
@Model
class Zadanie {
var tytul: String
var ukonczone: Bool
init(tytul: String) {
self.tytul = tytul
self.ukonczone = false
}
}
}
// Wersja 2 — dodajemy priorytet i datę
enum SchematV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Zadanie.self]
}
@Model
class Zadanie {
var tytul: String
var ukonczone: Bool
var priorytet: Int
var dataUtworzenia: Date
init(tytul: String) {
self.tytul = tytul
self.ukonczone = false
self.priorytet = 0
self.dataUtworzenia = Date()
}
}
}
// Plan migracji
enum PlanMigracji: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchematV1.self, SchematV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchematV1.self,
toVersion: SchematV2.self
) { kontekst in
// Transformacja danych
let zadania = try kontekst.fetch(FetchDescriptor<SchematV2.Zadanie>())
for zadanie in zadania {
zadanie.priorytet = 0
zadanie.dataUtworzenia = Date()
}
try kontekst.save()
}
}
Następnie przekaż plan migracji do kontenera:
let kontener = try ModelContainer(
for: SchematV2.Zadanie.self,
migrationPlan: PlanMigracji.self
)
Fajne jest to, że SwiftData sam wykona wszystkie potrzebne migracje w odpowiedniej kolejności — nawet jeśli użytkownik przeskakuje z wersji 1 bezpośrednio na wersję 5. Nie musisz się martwić o pośrednie kroki.
Optymalizacja wydajności — praktyczne wskazówki
SwiftData jest całkiem wydajny od razu po wyjęciu z pudełka, ale przy dużych zbiorach danych (myślę tu o tysiącach rekordów) warto znać kilka sztuczek.
1. Filtruj w predykacie, nie w Swift
To chyba najczęstszy błąd, jaki widzę u początkujących. Predykaty tłumaczone są na zapytania SQL — filtrowanie odbywa się na poziomie bazy danych, co jest wielokrotnie szybsze niż ładowanie wszystkiego do pamięci:
// Dobrze — filtrowanie na poziomie bazy
let deskryptor = FetchDescriptor<Zadanie>(
predicate: #Predicate { $0.priorytet > 3 && !$0.ukonczone }
)
let wyniki = try kontekst.fetch(deskryptor)
// Źle — ładowanie wszystkiego i filtrowanie w pamięci
let wszystkie = try kontekst.fetch(FetchDescriptor<Zadanie>())
let wyniki = wszystkie.filter { $0.priorytet > 3 && !$0.ukonczone }
2. Optymalizuj kolejność warunków w predykatach
Umieszczaj najbardziej restrykcyjne warunki na początku i preferuj szybkie porównania (np. Int) przed wolniejszymi (np. String). Niewielka zmiana, która potrafi przyspieszyć zapytania na dużych tabelach.
3. Używaj fetchLimit i fetchOffset do paginacji
var deskryptor = FetchDescriptor<Zadanie>(
sortBy: [SortDescriptor(\Zadanie.dataUtworzenia, order: .reverse)]
)
deskryptor.fetchLimit = 20
deskryptor.fetchOffset = 0 // Strona 1
let strona1 = try kontekst.fetch(deskryptor)
deskryptor.fetchOffset = 20 // Strona 2
let strona2 = try kontekst.fetch(deskryptor)
4. Prefetch relacji, gdy wiesz, że będą potrzebne
Relacje w SwiftData ładowane są leniwie (lazy loading). To zazwyczaj dobrze, ale jeśli wiesz, że będziesz potrzebować powiązanych obiektów od razu, użyj prefetching — w przeciwnym razie wpadniesz w klasyczny problem N+1:
var deskryptor = FetchDescriptor<Projekt>()
deskryptor.relationshipKeyPathsForPrefetching = [\.zadania]
let projekty = try kontekst.fetch(deskryptor)
Bez prefetching każdy dostęp do projekt.zadania wyzwala osobne zapytanie SQL. Przy 50 projektach to 50 dodatkowych zapytań. Z prefetching — jedno.
5. Sortuj w SwiftData, nie w Swift
Tak samo jak filtrowanie — sortowanie na poziomie bazy danych jest zawsze szybsze niż sortowanie tablicy w pamięci. Używaj SortDescriptor w FetchDescriptor zamiast .sorted() w Swift.
SwiftData w podglądach Xcode (Previews)
Integracja z Xcode Previews to jedno z tych ułatwień, które naprawdę doceniasz na co dzień. Jedna kluczowa zasada — zawsze używaj kontenera in-memory w podglądach, żeby nie zaśmiecać prawdziwej bazy danych:
#Preview {
let konfiguracja = ModelConfiguration(isStoredInMemoryOnly: true)
let kontener = try! ModelContainer(
for: Zadanie.self,
configurations: konfiguracja
)
// Wstaw przykładowe dane
let kontekst = kontener.mainContext
kontekst.insert(Zadanie(tytul: "Przykładowe zadanie"))
kontekst.insert(Zadanie(tytul: "Drugie zadanie", ukonczone: true))
return ListaZadan()
.modelContainer(kontener)
}
Debugowanie SwiftData
Żeby zobaczyć zapytania SQL generowane przez SwiftData, dodaj argument uruchomienia w Xcode:
- Otwórz schemat uruchamiania (Edit Scheme → Run → Arguments)
- Dodaj argument:
-com.apple.CoreData.SQLDebug 1 - Dla pełnych szczegółów (włącznie z planem zapytań):
-com.apple.CoreData.SQLDebug 3
W konsoli zobaczysz dokładne zapytania SQL — dzięki temu możesz zweryfikować, czy predykaty i indeksy działają tak jak powinny. Nie lekceważ tego narzędzia, potrafi zaoszczędzić godziny debugowania.
Najczęstsze pułapki i jak ich unikać
Na koniec — zbiór najczęstszych błędów, na które natkniesz się pracując ze SwiftData. Lepiej wiedzieć o nich wcześniej niż debugować je o trzeciej w nocy:
- Force-unwrapping optionali w predykatach — powoduje ciche niepowodzenie zapytania. Zawsze sprawdzaj opcjonalność porównaniem z
nil. - Przekazywanie modeli między aktorami — modele SwiftData nie są
Sendable. UżywajPersistentIdentifierlub DTO (opisałem to wyżej). - Sortowanie za pomocą
Comparable— konformancja modelu doComparablenie działa wSortDescriptor. SwiftData sortuje po wewnętrznym kluczu głównym, kompletnie ignorując Twoją implementację. - Brak wartości domyślnych przy nowych właściwościach — nowa właściwość bez wartości domyślnej powoduje błąd migracji. Zawsze dodawaj domyślną wartość.
- Oczekiwanie natychmiastowej dostępności danych po
save()— dane powiązane relacjami mogą nie być od razu widoczne w zapytaniach. Poczekaj na następny cykl run loop. - Enumy bez
CodableiRawValue— SwiftData wymaga, by enumy używane jako właściwości modelu miałyRawValuei konformowały doCodable. Bez tego dostaniesz tajemniczy crash w runtime.
FAQ — Najczęściej zadawane pytania
Czy SwiftData całkowicie zastąpi Core Data?
Nie w najbliższym czasie. Apple aktywnie rozwija oba frameworki. SwiftData jest zbudowany na Core Data i oba mogą współistnieć w tym samym projekcie (korzystając z tego samego pliku SQLite). Dla nowych projektów w SwiftUI — SwiftData to zdecydowanie lepsza opcja. Ale dla istniejących projektów z głęboką integracją UIKit i Core Data migracja może nie być opłacalna.
Czy mogę używać SwiftData bez SwiftUI?
Tak, jak najbardziej. Chociaż integracja ze SwiftUI (zwłaszcza @Query) jest główną zaletą frameworka, możesz spokojnie używać ModelContainer, ModelContext i FetchDescriptor w dowolnym kodzie Swift — również z UIKit. Jedyne ograniczenie: makro @Query działa wyłącznie w widokach SwiftUI.
Jak synchronizować dane SwiftData przez iCloud?
SwiftData obsługuje synchronizację iCloud przez CloudKit. Wystarczy skonfigurować cloudKitDatabase w ModelConfiguration i włączyć uprawnienie CloudKit w projekcie. SwiftData automatycznie synchronizuje dane między urządzeniami. Jedno zastrzeżenie — synchronizacja CloudKit wymaga, aby wszystkie właściwości miały wartości domyślne lub były opcjonalne.
Czy SwiftData działa z widgetami i App Intents?
Tak. SwiftData współgra z widgetami, App Intents i rozszerzeniami aplikacji. Kluczowe jest użycie App Group Container — skonfiguruj groupContainer w ModelConfiguration, żeby główna aplikacja i widget współdzieliły ten sam plik bazy danych.
Jak testować kod używający SwiftData?
Najlepsza praktyka to kontenery in-memory w testach. Tworzysz ModelContainer z flagą isStoredInMemoryOnly: true, wstawiasz testowe dane i wykonujesz asercje. Każdy test startuje z czystym stanem, testy są szybkie i od siebie niezależne. Jeśli testujesz migracje — tam potrzebujesz kontenera z trwałym zapisem i wersjonowanymi schematami.