SwiftData hallintaan: Kattava opas tietojen pysyvyyteen iOS-sovelluksissa

Kattava SwiftData-opas iOS-kehittäjille: mallien määrittelystä kyselyihin, suhteiden hallinnasta CloudKit-synkronointiin ja iOS 26:n malliperinnöllisyyteen käytännön koodiesimerkeillä.

Johdanto: Miksi SwiftData muuttaa kaiken

Tietojen pysyvyys on jokaisen vakavasti otettavan iOS-sovelluksen ytimessä. Olipa kyseessä tehtävälista, muistiinpanosovellus tai monimutkainen liiketoimintasovellus, tarvitset luotettavan tavan tallentaa ja hakea dataa. Apple esitteli SwiftDatan WWDC23:ssa korvaamaan Core Datan monimutkaisuuden modernilla, Swiftille natiivilta tuntuvalla ratkaisulla — ja iOS 26:n myötä se on vihdoin kypsä tuotantokäyttöön.

Jos olet lukenut aiemman artikkelimme Swift 6.2:n lähestyttävästä rinnakkaisuudesta, tämä opas on luonteva jatko: kun koodisi rinnakkaisuusrakenne on kunnossa, on aika rakentaa sille vankka tietopohja. Ja jos tutustuit Liquid Glass -oppaamme, SwiftData tarjoaa tietokerroksen niiden kauniiden käyttöliittymien taakse.

Tässä oppaassa käymme läpi SwiftDatan perusteista aina edistyneisiin tekniikoihin asti. Mallien määrittelystä kyselyihin, suhteiden hallinnasta CloudKit-synkronointiin, ja iOS 26:n uudesta malliperinnöstä migraatiostrategioihin. Mukana on runsaasti käytännön koodiesimerkkejä, jotta voit soveltaa oppimaasi suoraan omissa projekteissasi.

SwiftDatan perusteet: @Model, ModelContainer ja ModelContext

SwiftDatan arkkitehtuuri rakentuu kolmen pääkomponentin varaan. Rehellisesti sanottuna, näiden ymmärtäminen on se ensimmäinen ja tärkein askel — ilman näitä ei oikein pääse alkuun.

@Model-makro: Tietomallien määrittely

@Model-makro on SwiftDatan sydän. Se muuttaa tavallisen Swift-luokan pysyvään tallennukseen sopivaksi tietomalliksi automaattisesti. Kääntäjä generoi tarvittavan tallennuslogiikan puolestasi — ei tarvitse kirjoittaa yhtään ylimääräistä boilerplate-koodia.

import SwiftData

@Model
class Projekti {
    var nimi: String
    var kuvaus: String
    var luontipvm: Date
    var onValmis: Bool
    
    init(nimi: String, kuvaus: String, luontipvm: Date = .now, onValmis: Bool = false) {
        self.nimi = nimi
        self.kuvaus = kuvaus
        self.luontipvm = luontipvm
        self.onValmis = onValmis
    }
}

Tämä yksinkertainen merkintä tekee valtavasti työtä konepellin alla. @Model lisää luokalle PersistentModel-protokollan mukaisuuden, generoi skeematiedot jokaiselle ominaisuudelle ja mahdollistaa automaattisen muutosten seurannan. Core Datan aikaiset XML-skeematiedostot? Ne jäivät historiaan.

Tuetut tietotyypit

SwiftData tukee laajaa valikoimaa tietotyyppejä suoraan:

  • Perustyypit: String, Int, Double, Float, Bool, Date, Data, URL, UUID
  • Kokoelmat: Array, Dictionary, Set — kunhan elementit ovat Codable-yhteensopivia
  • Valinnaistyypit: mikä tahansa yllä olevista Optional-kääreenä
  • Enumit: Codable-yhteensopivat enumeraatiot
  • Codable-tyypit: omat struct-rakenteet, jotka toteuttavat Codable-protokollan

ModelContainer: Tallennuksen konfigurointi

ModelContainer on pysyvyystallennuksen ydin. Se hallinnoi skeemaa, konfiguroi tallennuspaikan ja koordinoi tietokantaoperaatiot.

Jokainen SwiftData-sovellus tarvitsee vähintään yhden.

import SwiftUI
import SwiftData

@main
struct ProjektienHallintaApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Projekti.self, Tehtava.self])
    }
}

Tämä yksinkertainen rivi luo SQLite-tietokannan sovelluksen dokumenttikansioon ja rekisteröi kaikki määritellyt mallit. Mutta hei — ModelContainer tarjoaa paljon enemmän konfigurointivaihtoehtoja kuin pelkkä perusversio:

let konfiguraatio = ModelConfiguration(
    "ProjektitDB",
    schema: Schema([Projekti.self, Tehtava.self]),
    isStoredInMemoryOnly: false,
    allowsSave: true,
    groupContainer: .identifier("group.com.oma.sovellus")
)

let container = try ModelContainer(
    for: Projekti.self, Tehtava.self,
    configurations: konfiguraatio
)

Tässä voit määrittää tietokannan nimen, käyttää App Group -jakoa (esimerkiksi widgetin ja pääsovelluksen välillä), rajoittaa tallennusta vain muistiin (hyödyllistä esikatseluissa ja testeissä) tai jopa estää tallentamisen kokonaan. Varsinkin tuo App Group -jako on kultaakin arvokkaampi, jos sovelluksessasi on widget.

ModelContext: Tietokantaoperaatioiden hallinta

ModelContext on käyttöliittymäsi tietokantaan. Se vastaa tietojen hakemisesta, lisäämisestä, poistamisesta ja muutosten tallentamisesta. Ajattele sitä työpöytänä: teet muutoksia muistissa oleviin objekteihin ja tallennat ne sitten levylle.

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    
    func lisaaProjekti() {
        let uusiProjekti = Projekti(
            nimi: "Uusi projekti",
            kuvaus: "Projektin kuvaus tähän"
        )
        modelContext.insert(uusiProjekti)
        // SwiftData tallentaa automaattisesti kun on sopiva hetki
    }
    
    func poistaProjekti(_ projekti: Projekti) {
        modelContext.delete(projekti)
    }
}

Tärkeä yksityiskohta: SwiftData tallentaa muutokset automaattisesti tietyissä tilanteissa — kun käyttäjä navigoi pois näkymästä, kun sovellus siirtyy taustalle tai kun järjestelmä katsoo sen tarpeelliseksi. Voit myös tallentaa manuaalisesti kutsumalla try modelContext.save(), mutta useimmiten automaattinen tallennus riittää mainiosti.

Kyselyt ja suodatus: @Query ja FetchDescriptor

Tietojen hakeminen tietokannasta on yksi yleisimmistä operaatioista, ja tässä SwiftData loistaa. Käytettävissä on kaksi päämekanismia: @Query-makro SwiftUI-näkymissä ja FetchDescriptor-luokka ohjelmalliseen käyttöön.

@Query-makro: Reaktiiviset kyselyt

@Query on SwiftUI-property wrapper, joka hakee tietoja tietokannasta ja päivittää näkymän automaattisesti kun tiedot muuttuvat. Jos olet käyttänyt Core Datan @FetchRequest-wrapperiä, tämä tuntuu tutulta — mutta huomattavasti siistimmältä.

struct ProjektiListaView: View {
    @Query(
        filter: #Predicate { !$0.onValmis },
        sort: \Projekti.luontipvm,
        order: .reverse
    )
    private var keskeneraisetProjektit: [Projekti]
    
    var body: some View {
        List(keskeneraisetProjektit) { projekti in
            VStack(alignment: .leading) {
                Text(projekti.nimi)
                    .font(.headline)
                Text(projekti.kuvaus)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
        }
    }
}

Huomaa #Predicate-makron käyttö suodatuksessa. Se on tyyppiturvallinen ja kääntäjä tarkistaa sen oikeellisuuden. Et voi tehdä kirjoitusvirheitä ominaisuuksien nimissä — kääntäjä yksinkertaisesti estää sen. Tämä on yksi niistä asioista, joiden vuoksi SwiftData tuntuu niin paljon mukavammalta kuin edeltäjänsä.

Monimutkaisten predikaattien rakentaminen

Predikaatit voivat olla huomattavasti monimutkaisempia kuin yksinkertainen vertailu. Ehtoja voi yhdistellä melko vapaasti:

// Hae projektit, jotka sisältävät hakusanan ja jotka luotiin viimeisen kuukauden aikana
let hakusana = "Swift"
let kuukausiSitten = Calendar.current.date(byAdding: .month, value: -1, to: .now)!

@Query(
    filter: #Predicate { projekti in
        projekti.nimi.localizedStandardContains(hakusana) &&
        projekti.luontipvm > kuukausiSitten &&
        !projekti.onValmis
    },
    sort: [
        SortDescriptor(\Projekti.luontipvm, order: .reverse),
        SortDescriptor(\Projekti.nimi)
    ]
)
private var suodatetutProjektit: [Projekti]

Ja tässä tulee iOS 26:n iloinen uutinen: nyt voit käyttää myös Codable-ominaisuuksia predikaateissa. Aiemmin tämä ei ollut mahdollista, ja se oli rehellisesti sanottuna yksi SwiftDatan turhauttavimmista rajoituksista.

FetchDescriptor: Ohjelmallinen haku

Kun tarvitset tietoja SwiftUI-näkymän ulkopuolella — esimerkiksi view modelissa tai palveluluokassa — FetchDescriptor on oikea työkalu:

func haeValmistuneetProjektit() throws -> [Projekti] {
    let descriptor = FetchDescriptor(
        predicate: #Predicate { $0.onValmis },
        sortBy: [SortDescriptor(\Projekti.luontipvm, order: .reverse)]
    )
    return try modelContext.fetch(descriptor)
}

// Sivutus suurille tietomäärille
func haeProjektitSivuittain(sivu: Int, sivunkoko: Int = 20) throws -> [Projekti] {
    var descriptor = FetchDescriptor(
        sortBy: [SortDescriptor(\Projekti.luontipvm, order: .reverse)]
    )
    descriptor.fetchOffset = sivu * sivunkoko
    descriptor.fetchLimit = sivunkoko
    return try modelContext.fetch(descriptor)
}

// Pelkkä lukumäärän hakeminen
func laskeKeskeneraiset() throws -> Int {
    let descriptor = FetchDescriptor(
        predicate: #Predicate { !$0.onValmis }
    )
    return try modelContext.fetchCount(descriptor)
}

FetchDescriptor tukee myös fetchOffset- ja fetchLimit-ominaisuuksia sivutusta varten. Tämä on kriittistä erityisesti silloin, kun tietokannassa on tuhansia rivejä — et todellakaan halua ladata kaikkia muistiin kerralla.

Dynaamiset kyselyt

Joskus tarvitset kyselyitä, joiden suodatuskriteerit muuttuvat käyttäjän toiminnan perusteella. SwiftUI:n kanssa tämä onnistuu luomalla erillinen alanäkymä, joka vastaanottaa suodatusparametrit:

struct DynaaminenProjektiLista: View {
    @Query private var projektit: [Projekti]
    
    init(naytaVainKeskeneraiset: Bool, jarjestys: SortDescriptor) {
        let suodatin: Predicate? = naytaVainKeskeneraiset
            ? #Predicate { !$0.onValmis }
            : nil
        _projektit = Query(
            filter: suodatin,
            sort: [jarjestys]
        )
    }
    
    var body: some View {
        List(projektit) { projekti in
            ProjektiRiviView(projekti: projekti)
        }
    }
}

struct PaaView: View {
    @State private var naytaVainKeskeneraiset = false
    
    var body: some View {
        DynaaminenProjektiLista(
            naytaVainKeskeneraiset: naytaVainKeskeneraiset,
            jarjestys: SortDescriptor(\Projekti.nimi)
        )
        .toolbar {
            Toggle("Vain keskeneräiset", isOn: $naytaVainKeskeneraiset)
        }
    }
}

Suhteet ja tietomallin suunnittelu

Todellisissa sovelluksissa tiedot eivät koskaan elä eristyksissä. Projektilla on tehtäviä, käyttäjällä on tilauksia, blogilla on artikkeleita ja tageja. SwiftData tukee yksi-moneen ja monta-moneen -suhteita natiivisti, ja onneksi niiden määrittely on varsin suoraviivaista.

Yksi-moneen-suhde

Yleisin suhdetyyppi on yksi-moneen. Projektilla voi olla useita tehtäviä, mutta jokainen tehtävä kuuluu yhteen projektiin:

@Model
class Projekti {
    var nimi: String
    var kuvaus: String
    var luontipvm: Date
    var onValmis: Bool
    
    @Relationship(deleteRule: .cascade, inverse: \Tehtava.projekti)
    var tehtavat: [Tehtava] = []
    
    init(nimi: String, kuvaus: String, luontipvm: Date = .now, onValmis: Bool = false) {
        self.nimi = nimi
        self.kuvaus = kuvaus
        self.luontipvm = luontipvm
        self.onValmis = onValmis
    }
}

@Model
class Tehtava {
    var otsikko: String
    var onTehty: Bool
    var prioriteetti: Int
    var projekti: Projekti?
    
    init(otsikko: String, onTehty: Bool = false, prioriteetti: Int = 0) {
        self.otsikko = otsikko
        self.onTehty = onTehty
        self.prioriteetti = prioriteetti
    }
}

@Relationship-attribuutin deleteRule-parametri määrittää, mitä lapsiobjekteille tapahtuu vanhemman poistuessa:

  • .cascade — Poistaa kaikki liittyvät objektit. Kun projekti poistetaan, kaikki sen tehtävätkin lähtevät. Tämä on usein se mitä haluat vanhempi-lapsi-suhteissa.
  • .nullify — Asettaa viittauksen nil-arvoon. Tehtävä jää olemaan, mutta sen projekti-ominaisuus muuttuu nil:ksi. Tämä on oletusarvo.
  • .deny — Estää poistamisen kokonaan, jos liittyviä objekteja on olemassa.
  • .noAction — Ei tee mitään. Voi johtaa orpoihin tietueisiin, joten käytä tätä varoen.

Pieni varoituksen sana: Cascade-poistosääntö toimii luotettavasti vain, kun automaattinen tallennus on käytössä. Jos olet poistanut automaattisen tallennuksen käytöstä, cascade-poisto saattaa tehdä yllättäviä asioita — tai jättää tekemättä mitään. Tämä on tunnettu ongelma, jonka kanssa moni kehittäjä on kamppaillut.

Monta-moneen-suhde

Monta-moneen-suhteita tarvitaan esimerkiksi tagien tai kategorioiden kanssa. Tässä klassinen esimerkki:

@Model
class Artikkeli {
    var otsikko: String
    var sisalto: String
    
    @Relationship(inverse: \Tagi.artikkelit)
    var tagit: [Tagi] = []
    
    init(otsikko: String, sisalto: String) {
        self.otsikko = otsikko
        self.sisalto = sisalto
    }
}

@Model
class Tagi {
    var nimi: String
    var artikkelit: [Artikkeli] = []
    
    init(nimi: String) {
        self.nimi = nimi
    }
}

// Käyttö:
let swift = Tagi(nimi: "Swift")
let swiftui = Tagi(nimi: "SwiftUI")
let artikkeli = Artikkeli(
    otsikko: "SwiftUI-vinkkejä",
    sisalto: "..."
)
artikkeli.tagit = [swift, swiftui]

Attribuuttien konfigurointi

@Attribute-makro tarjoaa lisäkonfigurointia yksittäisille ominaisuuksille. Näitä kannattaa tuntea, sillä ne ovat käytännössä välttämättömiä oikeissa projekteissa:

@Model
class Kayttaja {
    @Attribute(.unique) var sahkoposti: String  // Uniikki rajoite
    var nimi: String
    
    @Attribute(.externalStorage) var profiilikuva: Data?  // Tallennetaan erillisenä tiedostona
    
    @Attribute(.spotlight) var esittely: String?  // Indeksoidaan Spotlight-hakua varten
    
    @Transient var valiaikainenTieto: String = ""  // Ei tallenneta tietokantaan
    
    init(sahkoposti: String, nimi: String) {
        self.sahkoposti = sahkoposti
        self.nimi = nimi
    }
}

Huomaa erityisesti @Transient-merkintä — se kertoo SwiftDatalle, ettei kyseistä ominaisuutta tule tallentaa. Tämä on hyödyllistä väliaikaisille lasketuille arvoille tai tilatiedoille, joiden ei pidä päätyä tietokantaan. Olen itse käyttänyt tätä esimerkiksi validointitilojen ja väliaikaisten käyttöliittymätilojen tallentamiseen.

iOS 26:n uutuus: Malliperinnöllisyys

Nyt päästään siihen osaan, josta olen aidosti innoissani. WWDC25:ssä esitelty malliperinnöllisyys on SwiftDatan merkittävin uusi ominaisuus, ja se on ominaisuus jota kehittäjät ovat toivoneet ihan ensimmäisestä SwiftData-versiosta lähtien.

Perinnöllisyyden perusteet

Malliperinnöllisyys on luonteva valinta silloin, kun sinulla on useita mallityyppejä jotka jakavat yhteisiä ominaisuuksia mutta eroavat tietyiltä osin:

@Model
class Matka {
    var kohde: String
    var alkupvm: Date
    var loppupvm: Date
    var muistiinpanot: String
    
    init(kohde: String, alkupvm: Date, loppupvm: Date, muistiinpanot: String = "") {
        self.kohde = kohde
        self.alkupvm = alkupvm
        self.loppupvm = loppupvm
        self.muistiinpanot = muistiinpanot
    }
}

@available(iOS 26, *)
@Model
class Tyomatka: Matka {
    var paivaraha: Double
    var yritys: String
    var kokousaiheet: [String]
    
    init(kohde: String, alkupvm: Date, loppupvm: Date, paivaraha: Double, yritys: String, kokousaiheet: [String] = []) {
        self.paivaraha = paivaraha
        self.yritys = yritys
        self.kokousaiheet = kokousaiheet
        super.init(kohde: kohde, alkupvm: alkupvm, loppupvm: loppupvm)
    }
}

@available(iOS 26, *)
@Model
class Lomamatka: Matka {
    var syy: LomaSyy
    var budjetti: Double
    
    init(kohde: String, alkupvm: Date, loppupvm: Date, syy: LomaSyy, budjetti: Double) {
        self.syy = syy
        self.budjetti = budjetti
        super.init(kohde: kohde, alkupvm: alkupvm, loppupvm: loppupvm)
    }
}

enum LomaSyy: String, Codable {
    case rentoutuminen
    case seikkailu
    case kulttuuri
    case perheloma
}

Huomaa @available(iOS 26, *) -merkintä aliluokissa. Tämä on pakollinen, koska malliperinnöllisyys vaatii iOS 26:n tai uudemman version.

ModelContainerin konfigurointi

Kun käytät malliperinnöllisyyttä, sinun täytyy rekisteröidä sekä yliluokka että aliluokat ModelContainer-konfiguraatiossa:

@main
struct MatkasovelluksApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Matka.self, Tyomatka.self, Lomamatka.self])
    }
}

Kyselyt perinnöllisyyden kanssa

Tässä perinnöllisyys alkaa todella näyttää voimansa. Voit hakea kaikki matkat riippumatta tyypistä tai suodattaa tiettyyn alityyppiin:

// Kaikki matkat — sekä työ- että lomamatkat
@Query var kaikkiMatkat: [Matka]

// Pelkästään työmatkat
@Query var tyomatkat: [Tyomatka]

// Pelkästään lomamatkat tietyllä budjetilla
@Query(
    filter: #Predicate { $0.budjetti < 2000 }
)
var edullisetLomat: [Lomamatka]

Tämä on merkittävä etu verrattuna vaihtoehtoisiin ratkaisuihin, kuten enum-pohjaiseen tyyppierotteluun tai erillisiin malleihin. Koodi pysyy selkeänä ja kyselyt tuntuvat luontevilta.

Arkkitehtuurimallit ja parhaat käytännöt

SwiftDatan suunnitteluperiaatteet kannustavat suoraviivaiseen käyttöön, mutta isommissa projekteissa selkeä arkkitehtuuri on ehdottoman välttämätön. Luota minuun — olen nähnyt projekteja, joissa tietokantalogiikka on ripoteltu sinne tänne näkymiin, ja lopputulos ei ole kaunis.

Repository-malli: Tietokantakerroksen abstrahointi

Vaikka SwiftData on suunniteltu käytettäväksi suoraan näkymissä @Query-makron avulla, monimutkaisemmissa sovelluksissa erillinen tietokantakerros on lähes aina järkevä ratkaisu:

protocol ProjektiRepository {
    func haeKaikki() throws -> [Projekti]
    func haeKeskeneraiset() throws -> [Projekti]
    func haeNimella(_ nimi: String) throws -> [Projekti]
    func lisaa(_ projekti: Projekti) throws
    func poista(_ projekti: Projekti) throws
    func tallenna() throws
}

class SwiftDataProjektiRepository: ProjektiRepository {
    private let modelContext: ModelContext
    
    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }
    
    func haeKaikki() throws -> [Projekti] {
        let descriptor = FetchDescriptor(
            sortBy: [SortDescriptor(\Projekti.luontipvm, order: .reverse)]
        )
        return try modelContext.fetch(descriptor)
    }
    
    func haeKeskeneraiset() throws -> [Projekti] {
        let descriptor = FetchDescriptor(
            predicate: #Predicate { !$0.onValmis },
            sortBy: [SortDescriptor(\Projekti.luontipvm, order: .reverse)]
        )
        return try modelContext.fetch(descriptor)
    }
    
    func haeNimella(_ nimi: String) throws -> [Projekti] {
        let descriptor = FetchDescriptor(
            predicate: #Predicate { projekti in
                projekti.nimi.localizedStandardContains(nimi)
            }
        )
        return try modelContext.fetch(descriptor)
    }
    
    func lisaa(_ projekti: Projekti) throws {
        modelContext.insert(projekti)
        try tallenna()
    }
    
    func poista(_ projekti: Projekti) throws {
        modelContext.delete(projekti)
        try tallenna()
    }
    
    func tallenna() throws {
        try modelContext.save()
    }
}

Tämä lähestymistapa tuo mukanaan useita etuja: se erottaa tietokantalogiikan käyttöliittymästä, mahdollistaa yksikkötestauksen mock-toteutuksilla ja helpottaa mahdollista siirtymää toiseen tallennusratkaisuun tulevaisuudessa. Se myös tekee koodista helpommin luettavaa kollegoille (tai itsellesi kuukauden päästä).

Validointi mallissa

Validointilogiikka kuuluu malliin, ei näkymään. Tämä tekee koodista uudelleenkäytettävää ja testattavaa:

@Model
class Projekti {
    var nimi: String
    var kuvaus: String
    var luontipvm: Date
    var onValmis: Bool
    
    @Relationship(deleteRule: .cascade, inverse: \Tehtava.projekti)
    var tehtavat: [Tehtava] = []
    
    var onValidi: Bool {
        !nimi.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
        nimi.count <= 100 &&
        kuvaus.count <= 500
    }
    
    var keskeneraistenTehtavienMaara: Int {
        tehtavat.filter { !$0.onTehty }.count
    }
    
    var edistymisprosentti: Double {
        guard !tehtavat.isEmpty else { return 0 }
        let valmiit = tehtavat.filter { $0.onTehty }.count
        return Double(valmiit) / Double(tehtavat.count) * 100
    }
    
    init(nimi: String, kuvaus: String, luontipvm: Date = .now, onValmis: Bool = false) {
        self.nimi = nimi
        self.kuvaus = kuvaus
        self.luontipvm = luontipvm
        self.onValmis = onValmis
    }
}

Esikatselut ja testaus

SwiftUI-esikatseluissa SwiftData-mallit vaativat muistinsisäisen konttien käyttöä. Tämä on tärkeää, jotta testidataa ei päädy oikeaan tietokantaan:

struct ProjektiNakymaEsikatselut {
    static var esikatseluContainer: ModelContainer {
        let konfiguraatio = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(
            for: Projekti.self, Tehtava.self,
            configurations: konfiguraatio
        )
        
        // Lisää esikatseludataa
        let konteksti = container.mainContext
        let esimerkki = Projekti(nimi: "Esimerkkiprojekti", kuvaus: "Kuvaus tähän")
        konteksti.insert(esimerkki)
        
        let tehtava1 = Tehtava(otsikko: "Ensimmäinen tehtävä", onTehty: true)
        let tehtava2 = Tehtava(otsikko: "Toinen tehtävä")
        tehtava1.projekti = esimerkki
        tehtava2.projekti = esimerkki
        
        return container
    }
}

#Preview {
    ProjektiListaView()
        .modelContainer(ProjektiNakymaEsikatselut.esikatseluContainer)
}

@ModelActor: Taustakontekstin hallinta

@ModelActor on SwiftDatan ratkaisu taustasäikeisiin tietokantaoperaatioihin. Se luo oman aktoriympäristön omalla ModelContext-instanssillaan, mikä mahdollistaa raskaat operaatiot pääsäikeen ulkopuolella. Tämä on ehdottoman tärkeää, jos sovelluksesi käsittelee suurempia datamääriä.

@ModelActor
actor TietojenTuontipalvelu {
    func tuoProjektitJSONsta(_ data: Data) throws -> Int {
        let dekooderi = JSONDecoder()
        dekooderi.dateDecodingStrategy = .iso8601
        
        let projektitDTO = try dekooderi.decode([ProjektiDTO].self, from: data)
        
        for dto in projektitDTO {
            let projekti = Projekti(
                nimi: dto.nimi,
                kuvaus: dto.kuvaus,
                luontipvm: dto.luontipvm
            )
            modelContext.insert(projekti)
            
            for tehtavaDTO in dto.tehtavat {
                let tehtava = Tehtava(
                    otsikko: tehtavaDTO.otsikko,
                    onTehty: tehtavaDTO.onTehty,
                    prioriteetti: tehtavaDTO.prioriteetti
                )
                tehtava.projekti = projekti
                modelContext.insert(tehtava)
            }
        }
        
        try modelContext.save()
        return projektitDTO.count
    }
}

// Käyttö näkymässä
struct TuontiView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var tuodaanko = false
    
    var body: some View {
        Button("Tuo projektit") {
            tuodaanko = true
            Task {
                let palvelu = TietojenTuontipalvelu(
                    modelContainer: modelContext.container
                )
                let maara = try await palvelu.tuoProjektitJSONsta(jsonData)
                tuodaanko = false
            }
        }
        .disabled(tuodaanko)
    }
}

Hyvä uutinen: iOS 26:n myötä aiempi ärsyttävä bugi, joka esti näkymäpäivitykset @ModelActor-kontekstissa tehdyistä muutoksista, on vihdoin korjattu. Tämä tekee taustakontekstien käytöstä oikeasti luotettavaa tuotantosovelluksissa.

CloudKit-synkronointi

Yksi SwiftDatan parhaista ominaisuuksista on saumaton integraatio CloudKitin kanssa. Se mahdollistaa tietojen automaattisen synkronoinnin käyttäjän laitteiden välillä ilman yhtään palvelinpuolen koodia. Kuulostaa liian hyvältä ollakseen totta? No, melkein.

Perusasetukset

CloudKit-synkronoinnin käyttöönotto vaatii muutaman vaiheen:

  1. Avaa projektin Signing & Capabilities -asetukset
  2. Lisää iCloud-kyvykkyys
  3. Valitse CloudKit vaihtoehdoista
  4. Luo uusi CloudKit-kontti painamalla +
  5. Lisää Background Modes -kyvykkyys
  6. Aktivoi Remote Notifications

SwiftData-koodissa itsessään ei tarvita juuri mitään muutoksia — synkronointi käynnistyy automaattisesti, kun iCloud-kontti on konfiguroitu oikein. Tämä on yksi niistä asioista, jotka SwiftData tekee todella hyvin.

CloudKit-yhteensopivuuden vaatimukset

CloudKit asettaa tietomallille kuitenkin tiettyjä rajoituksia, jotka on ehdottomasti hyvä tietää etukäteen (ennen kuin törmäät niihin tuotannossa):

  • Ei uniikkeja rajoitteita: @Attribute(.unique) ei toimi CloudKit-synkronoinnin kanssa. Jos tarvitset uniikkeja arvoja, joudut toteuttamaan tarkistuksen manuaalisesti.
  • Oletusarvot pakollisia: Kaikkien ominaisuuksien täytyy olla joko valinnaisia (Optional) tai niillä täytyy olla oletusarvo.
  • Suhteet valinnaisia: Kaikkien suhteiden (@Relationship) täytyy olla valinnaisia.
  • Vain kevyet migraatiot: Kun sovellus on julkaistu, skeemamuutokset rajoittuvat kevyisiin migraatioihin — uusia ominaisuuksia ja entiteettejä voi lisätä, mutta olemassa olevia ei voi poistaa tai muokata.
// CloudKit-yhteensopiva malli
@Model
class CloudProjekti {
    var nimi: String = ""           // Oletusarvo
    var kuvaus: String = ""         // Oletusarvo
    var luontipvm: Date = .now      // Oletusarvo
    var onValmis: Bool = false      // Oletusarvo
    
    @Relationship(deleteRule: .cascade)
    var tehtavat: [CloudTehtava]? = []  // Valinnainen suhde
    
    init(nimi: String, kuvaus: String) {
        self.nimi = nimi
        self.kuvaus = kuvaus
    }
}

Skeeman alustaminen

Jos synkronoinnissa esiintyy ongelmia — osittaista datakatoa tai synkronoimattomia suhteita — syy liittyy usein siihen, ettei pilven skeema vastaa paikallista mallia. Tähän on onneksi yksinkertainen ratkaisu:

#if DEBUG
do {
    try container.initializeCloudKitSchema()
} catch {
    print("CloudKit-skeeman alustus epäonnistui: \(error)")
}
#endif

Tämä pakottaa CloudKitin päivittämään skeemansa vastaamaan paikallista tietomallia. Käytä tätä vain kehitysaikana — tuotantosovelluksessa sen kutsuminen ei ole tarpeen eikä suotavaa.

Migraatiostrategiat

Kun tietomalli muuttuu sovelluksen elinkaaren aikana (ja kyllä, se muuttuu aina), tarvitset migraatiostrategian olemassa olevan datan säilyttämiseksi.

Kevyt migraatio

SwiftData suorittaa kevyen migraation automaattisesti, kun muutokset ovat riittävän yksinkertaisia: uusien ominaisuuksien lisääminen oletusarvoilla, ominaisuuksien tekeminen valinnaisiksi tai uusien mallien lisääminen. Sinun ei tarvitse kirjoittaa mitään migraatiokoodia näissä tapauksissa. Kätevää.

Vaiheistettu migraatio (SchemaMigrationPlan)

Monimutkaisemmissa muutoksissa — kuten ominaisuuksien uudelleennimeämisessä tai tietojen muuntamisessa — tarvitset SchemaMigrationPlan-luokkaa:

enum ProjektiMigraatio: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [ProjektiSkemaV1.self, ProjektiSkemaV2.self]
    }
    
    static var stages: [MigrationStage] {
        [migraatioV1V2]
    }
    
    static let migraatioV1V2 = MigrationStage.custom(
        fromVersion: ProjektiSkemaV1.self,
        toVersion: ProjektiSkemaV2.self
    ) { context in
        // Muunna olemassa oleva data
        let vanhatProjektit = try context.fetch(
            FetchDescriptor()
        )
        
        for projekti in vanhatProjektit {
            // Esimerkki: lisää uusi laskennallinen arvo
            projekti.slug = projekti.nimi
                .lowercased()
                .replacingOccurrences(of: " ", with: "-")
        }
        
        try context.save()
    }
}

enum ProjektiSkemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Projekti.self]
    }
    
    @Model
    class Projekti {
        var nimi: String
        var kuvaus: String
        
        init(nimi: String, kuvaus: String) {
            self.nimi = nimi
            self.kuvaus = kuvaus
        }
    }
}

enum ProjektiSkemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Projekti.self]
    }
    
    @Model
    class Projekti {
        var nimi: String
        var kuvaus: String
        var slug: String  // Uusi ominaisuus
        
        init(nimi: String, kuvaus: String, slug: String = "") {
            self.nimi = nimi
            self.kuvaus = kuvaus
            self.slug = slug
        }
    }
}

Migraatiosuunnitelma rekisteröidään ModelContainer-konfiguraatiossa:

.modelContainer(
    for: Projekti.self,
    migrationPlan: ProjektiMigraatio.self
)

Kumoa ja toista (Undo/Redo)

SwiftData tukee sisäänrakennettua kumoa/toista-toiminnallisuutta, ja se on yllättävän helppo ottaa käyttöön:

@main
struct ProjektienHallintaApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Projekti.self, isUndoEnabled: true)
    }
}

struct ProjektiMuokkausView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.undoManager) private var undoManager
    @Bindable var projekti: Projekti
    
    var body: some View {
        Form {
            TextField("Nimi", text: $projekti.nimi)
            TextField("Kuvaus", text: $projekti.kuvaus)
        }
        .toolbar {
            ToolbarItem(placement: .topBarLeading) {
                Button("Kumoa") {
                    undoManager?.undo()
                }
                .disabled(!(undoManager?.canUndo ?? false))
            }
            ToolbarItem(placement: .topBarTrailing) {
                Button("Toista") {
                    undoManager?.redo()
                }
                .disabled(!(undoManager?.canRedo ?? false))
            }
        }
    }
}

Kun isUndoEnabled on päällä, jokainen tietomalliin tehty muutos rekisteröidään automaattisesti kumoa-historiaan. Käyttäjä voi kumota muutoksia painikkeilla, pikanäppäimillä tai iPhonessa jopa ravistamalla laitetta. Pienellä vaivalla saat valtavan käyttökokemushyödyn.

Suorituskyvyn optimointi

SwiftData on suunniteltu suorituskykyiseksi oletusarvoisesti, mutta suurilla tietomäärillä optimointi muuttuu kriittiseksi. Tässä muutama keskeinen tekniikka.

Sivutus ja lazy-lataus

func haeSivutettuLista(
    sivunumero: Int,
    sivunkoko: Int = 50
) throws -> [Projekti] {
    var descriptor = FetchDescriptor(
        sortBy: [SortDescriptor(\Projekti.luontipvm, order: .reverse)]
    )
    descriptor.fetchLimit = sivunkoko
    descriptor.fetchOffset = sivunumero * sivunkoko
    return try modelContext.fetch(descriptor)
}

Eräkäsittely suurille datamäärille

Kun tuot suuria tietomääriä, tallentaminen eräajona auttaa välttämään muistiongelmia:

@ModelActor
actor MassatuontiPalvelu {
    func tuoSuuriDatajoukko(_ kohteet: [ProjektiDTO]) throws {
        let erakoko = 100
        
        for eraIndeksi in stride(from: 0, to: kohteet.count, by: erakoko) {
            let eranLoppu = min(eraIndeksi + erakoko, kohteet.count)
            let era = kohteet[eraIndeksi..

Muistinkäytön hallinta

SwiftData pitää haettuja objekteja muistissa ModelContext-instanssin elinkaaren ajan. Suurilla tietomäärillä tämä voi johtaa muistiongelmiin. Käytännön ratkaisuja ovat sivutetut kyselyt, erillisten kontekstien käyttö lyhytaikaisiin operaatioihin ja tarpeettomien objektien vapauttaminen kutsumalla modelContext.reset().

SwiftData vs. Core Data: Kumpi valita?

Tämä kysymys nousee esiin jokaisessa iOS-kehittäjäyhteisössä, ja se on hyvä kysymys. Tässä tiivistetty näkemykseni:

  • Uusi projekti, iOS 17+: Valitse SwiftData. Se on modernimpi, Swiftille natiivi ja selkeästi Applen tulevaisuuden suunta.
  • Olemassa oleva Core Data -projekti: Ei kiirettä siirtyä. Core Data on edelleen täysin tuettu ja vakaa. Migraatio SwiftDataan on mahdollinen, mutta harvoin välttämätön.
  • Monimutkainen tietomalli: Core Data tarjoaa edelleen enemmän kontrollia ja joustavuutta. SwiftDatan rajoitukset — erityisesti CloudKit-yhteensopivuudessa — voivat aiheuttaa päänvaivaa.
  • iOS 26+: SwiftData on ensisijainen valinta. Malliperinnöllisyys, parannetut predikaatit ja korjatut bugit tekevät siitä vihdoin tuotantovalmiin.

Oleellista on muistaa, että SwiftData ja Core Data käyttävät samaa tallennusmoottoria (SQLite) ja jakavat monia sisäisiä mekanismeja. SwiftData on käytännössä Core Datan moderni käyttöliittymä. Voit jopa käyttää molempia samassa projektissa rinnakkain, jos tarve vaatii.

Käytännön esimerkki: Projektinhallintasovellus

Eli kootaan kaikki yhteen yhdellä käytännön esimerkillä. Rakennetaan yksinkertainen projektinhallintasovellus, joka hyödyntää kaikkia edellä käsiteltyjä ominaisuuksia:

import SwiftUI
import SwiftData

// Sovelluksen päärakenne
@main
struct ProjektienHallintaApp: App {
    var body: some Scene {
        WindowGroup {
            ProjektiNavigaatioView()
        }
        .modelContainer(
            for: [Projekti.self, Tehtava.self],
            isUndoEnabled: true
        )
    }
}

// Päänäkymä navigaatiolla
struct ProjektiNavigaatioView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(
        sort: \Projekti.luontipvm,
        order: .reverse
    )
    private var projektit: [Projekti]
    
    @State private var hakuteksti = ""
    @State private var naytaVainKeskeneraiset = false
    
    var suodatetutProjektit: [Projekti] {
        projektit.filter { projekti in
            let vastaaHakua = hakuteksti.isEmpty ||
                projekti.nimi.localizedCaseInsensitiveContains(hakuteksti)
            let vastaaSuodatinta = !naytaVainKeskeneraiset || !projekti.onValmis
            return vastaaHakua && vastaaSuodatinta
        }
    }
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(suodatetutProjektit) { projekti in
                    NavigationLink(value: projekti) {
                        ProjektiRiviView(projekti: projekti)
                    }
                }
                .onDelete(perform: poistaProjektit)
            }
            .searchable(text: $hakuteksti, prompt: "Hae projekteja")
            .navigationTitle("Projektit")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Toggle("Keskeneräiset", isOn: $naytaVainKeskeneraiset)
                        .toggleStyle(.switch)
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        lisaaUusiProjekti()
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .navigationDestination(for: Projekti.self) { projekti in
                ProjektiYksityiskohtaView(projekti: projekti)
            }
        }
    }
    
    private func lisaaUusiProjekti() {
        let uusi = Projekti(
            nimi: "Uusi projekti",
            kuvaus: "Kirjoita kuvaus tähän"
        )
        modelContext.insert(uusi)
    }
    
    private func poistaProjektit(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(suodatetutProjektit[index])
        }
    }
}

// Projektirivin näkymä
struct ProjektiRiviView: View {
    let projekti: Projekti
    
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack {
                Text(projekti.nimi)
                    .font(.headline)
                Spacer()
                if projekti.onValmis {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundColor(.green)
                }
            }
            Text(projekti.kuvaus)
                .font(.subheadline)
                .foregroundColor(.secondary)
                .lineLimit(2)
            HStack {
                Text("\(projekti.tehtavat.count) tehtävää")
                    .font(.caption)
                    .foregroundColor(.secondary)
                if !projekti.tehtavat.isEmpty {
                    Text("• \(Int(projekti.edistymisprosentti))% valmis")
                        .font(.caption)
                        .foregroundColor(.blue)
                }
            }
        }
        .padding(.vertical, 4)
    }
}

Yhteenveto ja seuraavat askeleet

SwiftData on kehittynyt valtavasti ensimmäisestä julkaisustaan. iOS 26:n myötä se on vihdoin saavuttanut kypsyystason, joka tekee siitä luotettavan valinnan useimpiin iOS-sovelluksiin. Käydään vielä läpi tärkeimmät opit:

  • @Model, ModelContainer ja ModelContext muodostavat SwiftDatan ytimen — ymmärrä näiden roolit ja yhteispeli.
  • @Query ja FetchDescriptor tarjoavat joustavat kyselymekanismit niin SwiftUI-näkymissä kuin ohjelmallisessakin käytössä.
  • Suhteet ja poistosäännöt mahdollistavat monimutkaisten tietomallien rakentamisen — mutta muista cascade-poiston rajoitukset.
  • Malliperinnöllisyys (iOS 26) avaa uusia mahdollisuuksia tietomallien suunnitteluun, ja se on vihdoin täällä.
  • CloudKit-synkronointi toimii lähes automaattisesti, mutta tietomallien on noudatettava CloudKitin rajoituksia.
  • Migraatiostrategiat ovat välttämättömiä sovelluksen elinkaaren hallinnassa — älä jätä niitä viime tippaan.
  • @ModelActor mahdollistaa raskaat operaatiot pääsäikeen ulkopuolella turvallisesti.

SwiftData ei vielä kata aivan kaikkia käyttötapauksia — esimerkiksi edistyneet CloudKit-synkronointistrategiat ja julkiset tietokannat vaativat edelleen Core Dataa tai suoraa CloudKit-rajapintaa. Mutta suurimmalle osalle sovelluksia SwiftData on nyt se oikea valinta.

Seuraavaksi suosittelen kokeilemaan näitä konsepteja omassa projektissasi. Aloita yksinkertaisesta mallista, lisää suhteita, kokeile kyselyitä ja rakenna vähitellen monimutkaisempaa tietokantalogiikkaa. Parhaiten oppii tekemällä — ja SwiftDatan kanssa tekeminen on oikeasti yllättävän mukavaa.

Tietoa Kirjoittajasta Editorial Team

Our team of expert writers and editors.