SwiftData: Vodič za modernu perzistenciju podataka u iOS aplikacijama

SwiftData je Appleov moderni framework za perzistenciju podataka koji zamjenjuje složenost Core Data intuitivnom Swift-nativnom sintaksom. Vodič pokriva @Model, @Query, odnose, #Predicate filtriranje, migraciju sheme i praktične savjete za iOS razvoj.

Uvod u SwiftData

Ruku na srce — ako ste ikada radili s Core Data u iOS aplikacijama, znate koliko taj framework zna biti frustrirajući. Složeni boilerplate kod, NSManagedObject podklase, .xcdatamodeld datoteke, ručno upravljanje kontekstima... sve to čini razvoj sporijim nego što bi trebao biti. Apple je napokon odlučio riješiti te probleme i na WWDC 2023 predstavio SwiftData — potpuno novi framework za perzistenciju podataka, dizajniran od nule za Swift i SwiftUI.

SwiftData nije samo kozmetička nadogradnja Core Data. Radi se o temeljno drugačijem pristupu koji koristi Swift makroe, deklarativnu sintaksu i duboku integraciju sa SwiftUI-jem.

A ispod haube? SwiftData i dalje koristi Core Data kao svoj storage engine, što znači da dobivate modernu API površinu uz provjerenu pouzdanost Core Data sustava. Iskreno, to je pametna odluka — zašto bacati engine koji radi već 20 godina?

U ovom vodiču proći ćemo kroz sve ključne koncepte SwiftData frameworka — od definiranja modela s @Model makroom, preko postavljanja ModelContainer-a i ModelContext-a, do naprednijih tema poput odnosa između modela, filtriranja s #Predicate makroom i migracije sheme. Sve s praktičnim primjerima koda koje možete odmah primijeniti u vlastitim projektima.

Zašto SwiftData? Problemi starog pristupa s Core Data

Da bismo razumjeli zašto je SwiftData toliko značajan, moramo se prvo prisjetiti što je sve bilo problematično s Core Data. Framework koji postoji od 2005. nosi sa sobom teret Objective-C ere i mnoge koncepte koji se jednostavno ne uklapaju u moderni Swift razvoj.

Prekomjerni boilerplate kod

Core Data zahtijeva nevjerojatnu količinu pripremnog koda prije nego što uopće možete početi raditi s podacima. Morate stvoriti .xcdatamodeld datoteku, definirati entitete u grafičkom editoru, generirati NSManagedObject podklase, postaviti persistent container, managed object context — i tek onda možete krenuti s CRUD operacijama. Puno posla za nešto što bi trebalo biti jednostavno.

// Core Data pristup - samo inicijalizacija stacka
class CoreDataStack {
    static let shared = CoreDataStack()

    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "MojModel")
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Nije moguće učitati store: \(error)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        return container
    }()

    var viewContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }

    func saveContext() {
        let context = viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let error = error as NSError
                fatalError("Greška prilikom spremanja: \(error)")
            }
        }
    }
}

I to je samo za postavljanje stacka! Za svaki entitet trebate još definirati model u grafičkom editoru i generirati Swift klase. SwiftData pristup je, u usporedbi s ovim, dramatično jednostavniji.

Problemi s type safety

Core Data koristi string-bazirane ključeve za pristup atributima. To znači da kompajler ne može provjeriti ispravnost vašeg koda u vrijeme prevođenja. Greške u nazivima atributa otkrivate tek u runtimeu — što je, blago rečeno, frustrirajuće. Posebno u produkcijskim aplikacijama.

Slaba integracija sa SwiftUI-jem

Iako je Apple dodao @FetchRequest property wrapper za SwiftUI, integracija nikada nije bila baš prirodna. Core Data objekti nisu bili Observable, pa je komunikacija između modela i UI-a zahtijevala dodatne slojeve apstrakcije i poprilično ručnog rada.

@Model makro — Srce SwiftData frameworka

Najvažniji element SwiftData frameworka je @Model makro. On transformira obične Swift klase u perzistentne modele podataka, automatski generirajući sav potreban kod za čitanje, pisanje i praćenje promjena. Makro @Model ujedno čini vašu klasu konformnom protokolima Observable, Hashable, Identifiable i PersistentModel.

Osnovna definicija modela

import SwiftData

@Model
class Zadatak {
    var naslov: String
    var opis: String
    var jeDovršen: Bool
    var datumStvaranja: Date
    var prioritet: Int

    init(naslov: String, opis: String = "", prioritet: Int = 0) {
        self.naslov = naslov
        self.opis = opis
        self.jeDovršen = false
        self.datumStvaranja = Date()
        self.prioritet = prioritet
    }
}

To je doslovno sve što trebate za potpuno funkcionalan perzistentni model. Nema grafičkog editora, nema generiranih klasa, nema boilerplate koda.

Makro @Model automatski obrađuje sva pohranjena svojstva i pretvara ih u getere i setere koji čitaju i pišu podatke u SwiftData storage sustav. Kad sam prvi put vidio koliko je ovo jednostavno u usporedbi s Core Data, iskreno — bio sam oduševljen.

Atributi za fino podešavanje

SwiftData pruža @Attribute makro za dodatnu konfiguraciju svojstava modela. Dva najčešće korištena atributa su .unique za označavanje jedinstvenih vrijednosti i .externalStorage za pohranu velikih podataka izvan glavne baze:

@Model
class Korisnik {
    var ime: String
    var prezime: String

    @Attribute(.unique)
    var email: String

    @Attribute(.externalStorage)
    var profilnaSlika: Data?

    @Attribute(.spotlight)
    var biografija: String?

    init(ime: String, prezime: String, email: String) {
        self.ime = ime
        self.prezime = prezime
        self.email = email
    }
}

Atribut .unique osigurava da ne postoje dva korisnika s istim emailom. Ako pokušate umetnuti korisnika s postojećim emailom, SwiftData će izvršiti upsert — ažurirati postojeći zapis umjesto stvaranja duplikata. Atribut .externalStorage govori SwiftData da veliku binarnu datoteku (poput slike) pohrani izvan SQLite baze, čime se poboljšavaju performanse upita.

Podržani tipovi podataka

SwiftData podržava širok spektar tipova za svojstva modela:

  • Osnovni tipoviString, Int, Double, Float, Bool, Date, Data, URL, UUID
  • KolekcijeArray, Dictionary, Set (pod uvjetom da elementi konformiraju Codable)
  • Enumeracije — enum tipovi koji konformiraju Codable
  • Strukturirani tipovi — bilo koji Codable struct
  • Opcionalni tipovi — svi navedeni tipovi mogu biti opcionalni

ModelContainer i ModelContext — Infrastruktura za perzistenciju

SwiftData koristi dva ključna objekta za upravljanje podacima: ModelContainer koji stvara i upravlja bazom podataka, te ModelContext koji prati promjene objekata u memoriji. Hajde da ih pogledamo malo detaljnije.

Postavljanje ModelContainera

Najjednostavniji način za postavljanje SwiftData u vašoj aplikaciji je dodavanje .modelContainer modifikatora na glavni App struct:

import SwiftUI
import SwiftData

@main
struct MojaAplikacija: App {
    var body: some Scene {
        WindowGroup {
            PocetniPogled()
        }
        .modelContainer(for: [Zadatak.self, Kategorija.self])
    }
}

Ovaj jedan redak koda radi nekoliko stvari istovremeno: stvara SQLite bazu podataka (ako ne postoji), registrira vaše modele, stvara glavni ModelContext i stavlja ga u SwiftUI environment. Kad se aplikacija prvi put pokrene, SwiftData kreira bazu; pri sljedećim pokretanjima učitava postojeću. Jednostavno i elegantno.

Napredna konfiguracija s ModelConfiguration

Za složenije scenarije možete koristiti ModelConfiguration za preciznije upravljanje pohranom:

@main
struct MojaAplikacija: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([Zadatak.self, Kategorija.self])

        let konfiguracija = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: false,
            cloudKitDatabase: .automatic
        )

        do {
            return try ModelContainer(
                for: schema,
                configurations: [konfiguracija]
            )
        } catch {
            fatalError("Nije moguće stvoriti ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            PocetniPogled()
        }
        .modelContainer(sharedModelContainer)
    }
}

Parametar cloudKitDatabase: .automatic automatski omogućuje iCloud sinkronizaciju vaših podataka. A parametar isStoredInMemoryOnly? Izuzetno koristan za testiranje i SwiftUI Previewe — kada je postavljen na true, podaci se čuvaju samo u memoriji i ne zapisuju na disk.

ModelContext — Rad s podacima

ModelContext je objekt koji zapravo koristite za sve operacije s podacima — umetanje, ažuriranje, brisanje i dohvaćanje. U SwiftUI pogledima pristupate mu preko environmenta:

struct MojPogled: View {
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        Button("Dodaj zadatak") {
            let noviZadatak = Zadatak(naslov: "Novi zadatak")
            modelContext.insert(noviZadatak)
            // Nije potrebno ručno zvati save() - SwiftData automatski sprema!
        }
    }
}

Jedna od najboljih značajki SwiftData je automatsko spremanje. Ne morate ručno pozivati save() nakon svake operacije — SwiftData to radi automatski na temelju UI događaja i korisničkog unosa.

Naravno, ako želite, automatsko spremanje možete isključiti postavljanjem autosaveEnabled na false u konfiguraciji containera. Ali za većinu aplikacija, zadano ponašanje je sasvim ok.

@Query makro — Dohvaćanje podataka u SwiftUI-ju

Makro @Query je SwiftData ekvivalent Core Data @FetchRequest-a, ali znatno moćniji i jednostavniji za korištenje. On automatski dohvaća podatke iz baze, prati promjene i osvježava pogled kad god se podaci promijene.

Osnovno korištenje

struct ListaZadataka: View {
    // Dohvati sve zadatke, sortirane po datumu stvaranja
    @Query(sort: \Zadatak.datumStvaranja, order: .reverse)
    private var zadaci: [Zadatak]

    @Environment(\.modelContext) private var modelContext

    var body: some View {
        NavigationStack {
            List {
                ForEach(zadaci) { zadatak in
                    HStack {
                        Image(systemName: zadatak.jeDovršen
                            ? "checkmark.circle.fill"
                            : "circle")
                            .foregroundStyle(zadatak.jeDovršen ? .green : .gray)

                        VStack(alignment: .leading) {
                            Text(zadatak.naslov)
                                .strikethrough(zadatak.jeDovršen)
                            Text(zadatak.datumStvaranja, style: .date)
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                    }
                    .onTapGesture {
                        zadatak.jeDovršen.toggle()
                    }
                }
                .onDelete(perform: obrisiZadatke)
            }
            .navigationTitle("Moji zadaci")
        }
    }

    private func obrisiZadatke(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(zadaci[index])
        }
    }
}

Ono što je posebno elegantno kod @Query makroa jest da automatski prati bazu podataka za promjene. Kad dodate, izbrišete ili izmijenite zadatak bilo gdje u aplikaciji, svi pogledi koji koriste @Query za taj model automatski se ažuriraju. Nema potrebe za ručnim osvježavanjem ili obavještavanjem — jednostavno radi.

Filtriranje s #Predicate

Za složenije upite koristimo #Predicate makro koji pruža type-safe filtriranje podataka. Za razliku od Core Data NSPredicate-a koji koristi stringove, #Predicate se provjerava u vrijeme kompilacije:

struct NedovršeniZadaciPogled: View {
    // Dohvati samo nedovršene zadatke visokog prioriteta
    @Query(
        filter: #Predicate<Zadatak> { zadatak in
            zadatak.jeDovršen == false && zadatak.prioritet >= 3
        },
        sort: \Zadatak.prioritet,
        order: .reverse
    )
    private var hitniZadaci: [Zadatak]

    var body: some View {
        List(hitniZadaci) { zadatak in
            VStack(alignment: .leading) {
                Text(zadatak.naslov)
                    .font(.headline)
                Text("Prioritet: \(zadatak.prioritet)")
                    .font(.caption)
                    .foregroundStyle(.red)
            }
        }
    }
}

Dinamičko filtriranje

Evo jednog uzorka koji ćete koristiti iznimno često — dinamičko filtriranje gdje korisnik može mijenjati kriterije pretrage. Trik je u korištenju init parametra u podpogledu:

struct FiltriranaListaZadataka: View {
    @Query private var zadaci: [Zadatak]

    init(pojamPretrage: String, samoNedovršeni: Bool) {
        let predikat = #Predicate<Zadatak> { zadatak in
            (pojamPretrage.isEmpty ||
             zadatak.naslov.localizedStandardContains(pojamPretrage)) &&
            (!samoNedovršeni || zadatak.jeDovršen == false)
        }

        _zadaci = Query(
            filter: predikat,
            sort: \Zadatak.datumStvaranja,
            order: .reverse
        )
    }

    var body: some View {
        List(zadaci) { zadatak in
            Text(zadatak.naslov)
        }
    }
}

// Roditeljski pogled
struct GlavniPogled: View {
    @State private var pojamPretrage = ""
    @State private var samoNedovršeni = false

    var body: some View {
        VStack {
            TextField("Pretraži...", text: $pojamPretrage)
                .textFieldStyle(.roundedBorder)
                .padding(.horizontal)

            Toggle("Samo nedovršeni", isOn: $samoNedovršeni)
                .padding(.horizontal)

            FiltriranaListaZadataka(
                pojamPretrage: pojamPretrage,
                samoNedovršeni: samoNedovršeni
            )
        }
    }
}

Roditeljski pogled drži stanje filtera, a podpogled prima parametre i na temelju njih inicijalizira @Query s odgovarajućim predikatom. Kad god se parametri promijene, SwiftUI ponovno stvara podpogled s novim upitom. Čisto i pregledno.

Odnosi između modela — @Relationship

Stvarne aplikacije rijetko imaju samo jedan model podataka. (Kad bi bilo tako jednostavno!) SwiftData podržava odnose između modela — jedan-prema-jedan, jedan-prema-mnogo i mnogo-prema-mnogo — s intuitivnom sintaksom koja koristi standardne Swift tipove.

Definiranje odnosa

@Model
class Kategorija {
    var naziv: String
    var boja: String
    var ikona: String

    @Relationship(deleteRule: .cascade, inverse: \Zadatak.kategorija)
    var zadaci: [Zadatak] = []

    init(naziv: String, boja: String = "blue", ikona: String = "folder") {
        self.naziv = naziv
        self.boja = boja
        self.ikona = ikona
    }
}

@Model
class Zadatak {
    var naslov: String
    var opis: String
    var jeDovršen: Bool
    var datumStvaranja: Date
    var prioritet: Int

    var kategorija: Kategorija?

    @Relationship(deleteRule: .nullify)
    var oznake: [Oznaka] = []

    init(naslov: String, opis: String = "", prioritet: Int = 0) {
        self.naslov = naslov
        self.opis = opis
        self.jeDovršen = false
        self.datumStvaranja = Date()
        self.prioritet = prioritet
    }
}

@Model
class Oznaka {
    var naziv: String

    @Attribute(.unique)
    var slug: String

    var zadaci: [Zadatak] = []

    init(naziv: String, slug: String) {
        self.naziv = naziv
        self.slug = slug
    }
}

U ovom primjeru imamo tri modela s različitim vrstama odnosa. Kategorija ima odnos jedan-prema-mnogo sa Zadatak — jedna kategorija može sadržavati mnogo zadataka. Zadatak i Oznaka imaju odnos mnogo-prema-mnogo — svaki zadatak može imati više oznaka, i svaka oznaka može pripadati više zadataka.

Pravila brisanja

Atribut deleteRule definira što se događa s povezanim objektima kad obrišete roditeljski objekt. Ovo je bitno razumjeti jer krivi odabir može dovesti do gubitka podataka:

  • .nullify (zadano) — postavlja referencu na nil u povezanim objektima
  • .cascade — briše sve povezane objekte (koristite kad djeca ne mogu postojati bez roditelja)
  • .deny — sprečava brisanje ako postoje povezani objekti
  • .noAction — ne radi ništa s povezanim objektima

U našem primjeru, brisanje kategorije s .cascade pravilom automatski briše sve zadatke u toj kategoriji. S druge strane, brisanje zadatka s .nullify pravilom samo uklanja vezu s oznakama, ali ne briše same oznake.

Rad s odnosima u praksi

// Dodavanje zadatka u kategoriju
func dodajZadatak(u kategoriju: Kategorija, naslov: String) {
    let noviZadatak = Zadatak(naslov: naslov)
    noviZadatak.kategorija = kategoriju
    modelContext.insert(noviZadatak)
    // SwiftData automatski ažurira kategorija.zadaci
}

// Dodavanje oznake zadatku
func dodajOznaku(zadatku zadatak: Zadatak, naziv: String) {
    let oznaka = Oznaka(naziv: naziv, slug: naziv.lowercased())
    zadatak.oznake.append(oznaka)
    // Nije potrebno eksplicitno spremati - autosave!
}

// Upit s odnosom - zadaci u određenoj kategoriji
struct ZadaciKategorijePogled: View {
    var kategorija: Kategorija

    var body: some View {
        List(kategorija.zadaci) { zadatak in
            Text(zadatak.naslov)
        }
        .navigationTitle(kategorija.naziv)
    }
}

SwiftData koristi lijeno učitavanje (lazy loading) za odnose. To znači da se povezani objekti učitavaju iz baze tek kad im stvarno pristupite — čime se štedi memorija i poboljšavaju performanse. Praktično, to je nešto o čemu ne morate razmišljati u svakodnevnom radu, ali dobro je znati da se to događa u pozadini.

Nove mogućnosti u iOS 18 — #Index, #Unique i History API

S iOS-om 18, SwiftData je dobio značajna poboljšanja koja ga čine još moćnijim. Evo najvažnijih novosti.

#Index makro za brže upite

Makro #Index omogućuje stvaranje indeksa na svojstvima modela, čime se dramatično ubrzavaju upiti koji filtriraju ili sortiraju po tim svojstvima:

@Model
class Zadatak {
    #Index<Zadatak>([\. datumStvaranja], [\.prioritet], [\.jeDovršen, \.datumStvaranja])

    var naslov: String
    var opis: String
    var jeDovršen: Bool
    var datumStvaranja: Date
    var prioritet: Int

    init(naslov: String, opis: String = "", prioritet: Int = 0) {
        self.naslov = naslov
        self.opis = opis
        self.jeDovršen = false
        self.datumStvaranja = Date()
        self.prioritet = prioritet
    }
}

Složeni indeks [\.jeDovršen, \.datumStvaranja] optimizira upite koji istovremeno filtriraju po statusu dovršenosti i sortiraju po datumu — što je upravo ono što biste najčešće radili u todo aplikaciji. Ako imate veću bazu podataka, razlika u brzini može biti osjetna.

#Unique makro za složena ograničenja jedinstvenosti

Dok @Attribute(.unique) radi za jedno svojstvo, #Unique makro omogućuje definiranje jedinstvenosti na temelju kombinacije više svojstava:

@Model
class PrijavaNaSeminar {
    #Unique<PrijavaNaSeminar>([\.korisnikId, \.seminarId])

    var korisnikId: String
    var seminarId: String
    var datumPrijave: Date

    init(korisnikId: String, seminarId: String) {
        self.korisnikId = korisnikId
        self.seminarId = seminarId
        self.datumPrijave = Date()
    }
}

Ovaj primjer osigurava da se isti korisnik ne može prijaviti na isti seminar dvaput. Pokušaj umetanja duplikata rezultira upsertom umjesto greške — što je zapravo prilično elegantno rješenje.

History API za praćenje promjena

Jedna od najuzbudljivijih novih mogućnosti u iOS-u 18 je History API. Omogućuje praćenje povijesti promjena u bazi podataka, što je posebno korisno za sinkronizaciju s udaljenim serverima:

// Dohvaćanje povijesti promjena
func provjeriPromjene() async throws {
    let deskriptor = HistoryDescriptor<DefaultHistoryTransaction>()

    let transakcije = try modelContext.fetchHistory(deskriptor)

    for transakcija in transakcije {
        for promjena in transakcija.changes {
            switch promjena {
            case .insert(let umetanje):
                print("Novi objekt umetnut: \(umetanje.changedPersistentIdentifier)")
            case .update(let ažuriranje):
                print("Objekt ažuriran: \(ažuriranje.changedPersistentIdentifier)")
            case .delete(let brisanje):
                print("Objekt obrisan: \(brisanje.changedPersistentIdentifier)")
            }
        }
    }
}

History API otvara vrata za napredne scenarije poput inkrementalne sinkronizacije, undo/redo sustava na razini baze podataka i reagiranja na promjene iz widgeta ili ekstenzija aplikacije.

FetchDescriptor — Upiti izvan SwiftUI pogleda

Dok je @Query makro savršen za SwiftUI poglede, ponekad trebate dohvatiti podatke izvan konteksta pogleda — u view modelima, servisima ili pozadinskim zadacima. Za to služi FetchDescriptor:

// Dohvaćanje podataka s FetchDescriptor-om
func dohvatiHitneZadatke() throws -> [Zadatak] {
    let predikat = #Predicate<Zadatak> { zadatak in
        zadatak.jeDovršen == false && zadatak.prioritet >= 4
    }

    var deskriptor = FetchDescriptor<Zadatak>(
        predicate: predikat,
        sortBy: [SortDescriptor(\.prioritet, order: .reverse)]
    )
    deskriptor.fetchLimit = 10

    return try modelContext.fetch(deskriptor)
}

// Brojanje objekata bez dohvaćanja svih podataka
func brojNedovršenihZadataka() throws -> Int {
    let predikat = #Predicate<Zadatak> { zadatak in
        zadatak.jeDovršen == false
    }

    let deskriptor = FetchDescriptor<Zadatak>(predicate: predikat)
    return try modelContext.fetchCount(deskriptor)
}

// Brisanje više objekata odjednom
func obrisiDovršeneZadatke() throws {
    let predikat = #Predicate<Zadatak> { zadatak in
        zadatak.jeDovršen == true
    }

    try modelContext.delete(model: Zadatak.self, where: predikat)
}

Metoda fetchCount je posebno korisna jer vraća samo broj rezultata bez učitavanja samih objekata u memoriju. Za velike baze podataka, razlika u performansama može biti značajna u usporedbi s dohvaćanjem svih objekata pa brojanjem elemenata niza.

Migracija sheme — Upravljanje promjenama modela

Jedna od najvećih briga kod bilo kojeg sustava za perzistenciju jest — što se događa kad promijenite strukturu modela? Korisnici imaju postojeće baze podataka s podacima, pa ne možete jednostavno obrisati sve i krenuti ispočetka.

Automatska lagana migracija

Dobra vijest: SwiftData automatski rukuje jednostavnim promjenama poput dodavanja novih svojstava s zadanim vrijednostima ili brisanja svojstava. Za te slučajeve ne morate pisati nikakav dodatni kod — SwiftData to rješava sam.

Ručna migracija s VersionedSchema

Za složenije promjene, poput preimenovanja svojstava ili transformacije podataka, trebate definirati verzije sheme:

// Verzija 1 - originalna shema
enum SemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [ZadatakV1.self]
    }

    @Model
    class ZadatakV1 {
        var naslov: String
        var dovršen: Bool

        init(naslov: String, dovršen: Bool = false) {
            self.naslov = naslov
            self.dovršen = dovršen
        }
    }
}

// Verzija 2 - dodano polje prioritet i preimenovano dovršen u jeDovršen
enum SemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [ZadatakV2.self]
    }

    @Model
    class ZadatakV2 {
        var naslov: String
        var jeDovršen: Bool
        var prioritet: Int

        init(naslov: String, jeDovršen: Bool = false, prioritet: Int = 0) {
            self.naslov = naslov
            self.jeDovršen = jeDovršen
            self.prioritet = prioritet
        }
    }
}

// Plan migracije
enum PlanMigracije: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SemaV1.self, SemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migracijaV1naV2]
    }

    static let migracijaV1naV2 = MigrationStage.lightweight(
        fromVersion: SemaV1.self,
        toVersion: SemaV2.self
    )
}

Za primjenu plana migracije, proslijedite ga prilikom stvaranja ModelContainer-a:

let container = try ModelContainer(
    for: SemaV2.ZadatakV2.self,
    migrationPlan: PlanMigracije.self
)

Važno je napomenuti da SwiftData trenutno podržava samo lagane (lightweight) migracije. Za složene transformacije podataka koje zahtijevaju prilagođenu logiku, i dalje trebate Core Data heavy-weight migracije ili ručno rješenje. To je, iskreno, jedan od većih nedostataka SwiftData u ovom trenutku.

Rad sa SwiftData u pozadini

U stvarnim aplikacijama često trebate raditi s podacima u pozadinskim nitima — na primjer, prilikom sinkronizacije s serverom ili obrade velikih skupova podataka. SwiftData ModelContext mora ostati na niti koja ga je stvorila, pa za pozadinski rad trebate stvoriti novi kontekst:

// Pozadinska obrada podataka
func sinkronizirajPodatke(container: ModelContainer) async throws {
    // Dohvati podatke s servera
    let podaciServera = try await dohvatiSaPoslužitelja()

    // Stvori novi ModelContext za pozadinsku nit
    let pozadinskiKontekst = ModelContext(container)
    pozadinskiKontekst.autosaveEnabled = false

    for podaci in podaciServera {
        let noviZadatak = Zadatak(
            naslov: podaci.naslov,
            opis: podaci.opis,
            prioritet: podaci.prioritet
        )
        pozadinskiKontekst.insert(noviZadatak)
    }

    // Ručno spremi sve promjene odjednom
    try pozadinskiKontekst.save()
}

Ključno pravilo koje si morate zapamtiti: ModelContainer možete slobodno proslijeđivati između niti, ali ModelContext morate koristiti samo na niti koja ga je stvorila. Ako prekršite ovo pravilo, možete očekivati nepredvidive greške ili rušenja aplikacije. Vjerujte mi, debugiranje takvih problema nije zabavno.

SwiftData u Previewima i testovima

Jedna od praktičnih prednosti SwiftData je jednostavnost postavljanja za SwiftUI Previewe i unit testove. Korištenjem in-memory konfiguracije stvarate izoliran kontekst s testnim podacima:

// SwiftUI Preview s testnim podacima
#Preview {
    let konfiguracija = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(
        for: Zadatak.self,
        configurations: konfiguracija
    )

    // Dodaj testne podatke
    let kontekst = container.mainContext
    let testniZadaci = [
        Zadatak(naslov: "Napisati dokumentaciju", prioritet: 3),
        Zadatak(naslov: "Popraviti bug u prijavi", prioritet: 5),
        Zadatak(naslov: "Ažurirati ovisnosti", prioritet: 1)
    ]
    testniZadaci.forEach { kontekst.insert($0) }

    return ListaZadataka()
        .modelContainer(container)
}

// Unit test
@Test func testDodavanjeZadatka() async throws {
    let konfiguracija = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(
        for: Zadatak.self,
        configurations: konfiguracija
    )

    let kontekst = ModelContext(container)
    let zadatak = Zadatak(naslov: "Testni zadatak", prioritet: 2)
    kontekst.insert(zadatak)
    try kontekst.save()

    let deskriptor = FetchDescriptor<Zadatak>()
    let sviZadaci = try kontekst.fetch(deskriptor)

    #expect(sviZadaci.count == 1)
    #expect(sviZadaci.first?.naslov == "Testni zadatak")
}

Parametar isStoredInMemoryOnly: true osigurava da svaki preview i test započinje s čistom bazom podataka, bez utjecaja na stvarne podatke aplikacije. Pouzdano i ponovljivo testiranje — baš kako treba biti.

SwiftData vs Core Data — Kada koristiti koji?

S obzirom na to da oba frameworka koegzistiraju, legitimno je pitanje kada koristiti koji. Evo smjernica za 2026. godinu.

Koristite SwiftData kada:

  • Počinjete novi projekt s iOS 17+ / macOS 14+ kao minimalnim zahtjevom
  • Vaša aplikacija koristi SwiftUI kao primarni UI framework
  • Imate relativno jednostavne modele podataka bez iznimno složenih odnosa
  • Želite brži razvoj s manje boilerplate koda
  • Trebate automatsku iCloud sinkronizaciju za privatne podatke
  • Preferirate Swift-nativnu sintaksu i makroe

Ostanite s Core Data kada:

  • Vaša aplikacija mora podržavati starije verzije iOS-a (prije iOS 17)
  • Imate složene modele s dubokim hijerarhijama odnosa
  • Trebate heavy-weight migracije sa složenim transformacijama podataka
  • Vaša aplikacija koristi UIKit s dubokom Core Data integracijom
  • Trebate dijeljene iCloud baze podataka (ne samo privatne)
  • Imate specifične potrebe za Objective-C kompatibilnošću

Hibridni pristup

Ne morate birati isključivo jedan framework. Sasvim je legitimno koristiti oba istovremeno — Core Data za postojeće složene modele, a SwiftData za nove značajke. Samo pazite da obje sheme budu usklađene ako dijele istu bazu podataka.

Savjeti za debugiranje SwiftData aplikacija

Budući da SwiftData interno koristi Core Data, svi alati za debugiranje Core Data rade i sa SwiftData. Najkorisniji je launch argument za logiranje SQL upita:

// U Xcode: Product > Scheme > Edit Scheme > Run > Arguments
// Dodajte launch argument:
// -com.apple.CoreData.SQLDebug 1

// Ovo će u konzoli prikazati sve SQL upite koje SwiftData izvršava:
// CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZNASLOV, t0.ZJEDOVRSEN ...
// CoreData: annotation: fetch Zadatak

Također, za pronalaženje lokacije SQLite baze podataka na simulatoru:

// -com.apple.CoreData.Logging.stderr 1
// Ili pronađite bazu ručno:
// ~/Library/Developer/CoreSimulator/Devices/[UUID]/data/Containers/Data/Application/[UUID]/Library/Application Support/

Otvaranje SQLite datoteke u alatima poput DB Browser for SQLite može vam pomoći da vizualno provjerite stanje baze i brzo identificirate probleme s podacima. Osobno koristim ovaj pristup gotovo svakodnevno kad radim sa SwiftData.

Praktični savjeti i česte zamke

Evo nekoliko savjeta koji će vam uštedjeti vrijeme i frustraciju pri radu sa SwiftData.

1. Pažljivo s #Predicate i složenim tipovima

Makro #Predicate može prevoditi samo izraze koji uspoređuju skalarne vrijednosti (brojevi, stringovi, UUID). Ne možete uspoređivati složene objekte unutar predikata — ako to pokušate, aplikacija će se srušiti u runtimeu. Umjesto toga, koristite identifikator:

// KRIVO - uzrokuje pad aplikacije
let predicate = #Predicate<Zadatak> { zadatak in
    zadatak.kategorija == mojaKategorija  // Runtime crash!
}

// ISPRAVNO - koristite persistentModelID
let kategorijaId = mojaKategorija.persistentModelID
let predicate = #Predicate<Zadatak> { zadatak in
    zadatak.kategorija?.persistentModelID == kategorijaId
}

2. Ne zaboravite na thread safety

SwiftData modeli nisu thread-safe. Nikad ne prosljeđujte PersistentModel objekte između niti. Umjesto toga, proslijedite PersistentIdentifier i dohvatite objekt u novom kontekstu:

// KRIVO
Task.detached {
    zadatak.naslov = "Ažurirano"  // Opasno! Pristup iz krive niti!
}

// ISPRAVNO
let zadatakId = zadatak.persistentModelID
Task.detached {
    let kontekst = ModelContext(container)
    if let zadatak = kontekst.model(for: zadatakId) as? Zadatak {
        zadatak.naslov = "Ažurirano"
        try kontekst.save()
    }
}

3. Koristite Codable strukture za složene tipove

Za kompleksne ugniježdene podatke koji ne trebaju vlastiti entitet, koristite Codable strukture:

struct Adresa: Codable {
    var ulica: String
    var grad: String
    var postanskiBroj: String
    var država: String
}

@Model
class Korisnik {
    var ime: String
    var adresa: Adresa?  // SwiftData automatski serijalizira Codable tipove

    init(ime: String, adresa: Adresa? = nil) {
        self.ime = ime
        self.adresa = adresa
    }
}

4. Optimizirajte performanse s fetchLimit

Uvijek postavljajte fetchLimit kad ne trebate sve rezultate. Ovo je posebno važno za veće baze podataka — razlika u brzini može biti značajna:

var deskriptor = FetchDescriptor<Zadatak>(
    predicate: #Predicate { $0.jeDovršen == false },
    sortBy: [SortDescriptor(\.datumStvaranja, order: .reverse)]
)
deskriptor.fetchLimit = 20  // Dohvati samo prvih 20 rezultata

Zaključak

SwiftData predstavlja značajan korak naprijed u perzistenciji podataka za Apple platforme. S intuitivnom sintaksom temeljenom na makroima, dubokom integracijom sa SwiftUI-jem i automatskim upravljanjem mnoštvom složenosti koje je ranije zahtijevalo ručni rad, razvoj aplikacija postaje brži i ugodniji.

Naravno, framework i dalje sazrijeva. Nedostaje podrška za heavy-weight migracije, dijeljene iCloud baze, a performanse u nekim scenarijima još zaostaju za Core Data. No, Apple aktivno razvija SwiftData — iOS 18 je donio #Index, #Unique i History API, dok iOS 26 uvodi nasljeđivanje modela. Trend je jasan.

Za nove projekte, SwiftData je očigledan izbor. Za postojeće aplikacije s Core Data, razmotrite postupnu migraciju — koristite SwiftData za nove značajke dok postojeći kod ostaje na Core Data. U svakom slučaju, upoznavanje sa SwiftData danas znači pripremu za budućnost iOS razvoja.

Ako ste pratili naš prethodni vodič o Observation frameworku i @Observable makrou, primijetit ćete da je SwiftData dizajniran da savršeno surađuje s tim sustavom. Makro @Model automatski čini vaše modele Observable, što znači da SwiftUI pogledi reagiraju na promjene podataka bez ikakvog dodatnog koda. Zajedno, ovi frameworki tvore moderan ekosustav za izradu podatkovnih aplikacija na Apple platformama.

O Autoru Editorial Team

Our team of expert writers and editors.