Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/data-persistence.md
2025-11-29 18:28:37 +08:00

12 KiB

Data Persistence

SwiftData, Core Data, and file-based storage for iOS apps.

SwiftData (iOS 17+)

Model Definition

import SwiftData

@Model
class Item {
    var name: String
    var createdAt: Date
    var isCompleted: Bool
    var priority: Int

    @Relationship(deleteRule: .cascade)
    var tasks: [Task]

    @Relationship(inverse: \Category.items)
    var category: Category?

    init(name: String, priority: Int = 0) {
        self.name = name
        self.createdAt = Date()
        self.isCompleted = false
        self.priority = priority
        self.tasks = []
    }
}

@Model
class Task {
    var title: String
    var isCompleted: Bool

    init(title: String) {
        self.title = title
        self.isCompleted = false
    }
}

@Model
class Category {
    var name: String
    var items: [Item]

    init(name: String) {
        self.name = name
        self.items = []
    }
}

Container Setup

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Item.self, Category.self])
    }
}

Querying Data

struct ItemList: View {
    // Basic query
    @Query private var items: [Item]

    // Sorted query
    @Query(sort: \Item.createdAt, order: .reverse)
    private var sortedItems: [Item]

    // Filtered query
    @Query(filter: #Predicate<Item> { $0.isCompleted == false })
    private var incompleteItems: [Item]

    // Complex query
    @Query(
        filter: #Predicate<Item> { !$0.isCompleted && $0.priority > 5 },
        sort: [
            SortDescriptor(\Item.priority, order: .reverse),
            SortDescriptor(\Item.createdAt)
        ]
    )
    private var highPriorityItems: [Item]

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

CRUD Operations

struct ItemList: View {
    @Query private var items: [Item]
    @Environment(\.modelContext) private var context

    var body: some View {
        List {
            ForEach(items) { item in
                ItemRow(item: item)
            }
            .onDelete(perform: delete)
        }
        .toolbar {
            Button("Add", action: addItem)
        }
    }

    private func addItem() {
        let item = Item(name: "New Item")
        context.insert(item)
        // Auto-saves
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            context.delete(items[index])
        }
    }
}

Custom Container Configuration

@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Item.self, Category.self])

        let config = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: false,
            allowsSave: true,
            groupContainer: .identifier("group.com.yourcompany.app")
        )

        do {
            container = try ModelContainer(for: schema, configurations: config)
        } catch {
            fatalError("Failed to configure SwiftData container: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

iCloud Sync

SwiftData syncs automatically with iCloud when:

  1. App has iCloud capability
  2. User is signed into iCloud
  3. Container uses CloudKit
let config = ModelConfiguration(
    cloudKitDatabase: .automatic
)

Core Data (All iOS Versions)

Stack Setup

class CoreDataStack {
    static let shared = CoreDataStack()

    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "MyApp")

        // Enable cloud sync
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No persistent store description")
        }
        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.yourcompany.app"
        )

        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data failed to load: \(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 {
                print("Failed to save context: \(error)")
            }
        }
    }
}

With SwiftUI

@main
struct MyApp: App {
    let coreDataStack = CoreDataStack.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, coreDataStack.viewContext)
        }
    }
}

struct ItemList: View {
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)],
        predicate: NSPredicate(format: "isCompleted == NO")
    )
    private var items: FetchedResults<Item>

    @Environment(\.managedObjectContext) private var context

    var body: some View {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

File-Based Storage

Codable Models

struct UserSettings: Codable {
    var theme: Theme
    var fontSize: Int
    var notificationsEnabled: Bool

    enum Theme: String, Codable {
        case light, dark, system
    }
}

class SettingsStore {
    private let fileURL: URL

    init() {
        let documentsDirectory = FileManager.default.urls(
            for: .documentDirectory,
            in: .userDomainMask
        ).first!
        fileURL = documentsDirectory.appendingPathComponent("settings.json")
    }

    func load() -> UserSettings {
        guard let data = try? Data(contentsOf: fileURL),
              let settings = try? JSONDecoder().decode(UserSettings.self, from: data) else {
            return UserSettings(theme: .system, fontSize: 16, notificationsEnabled: true)
        }
        return settings
    }

    func save(_ settings: UserSettings) throws {
        let data = try JSONEncoder().encode(settings)
        try data.write(to: fileURL)
    }
}

Document Directory Paths

extension FileManager {
    var documentsDirectory: URL {
        urls(for: .documentDirectory, in: .userDomainMask).first!
    }

    var cachesDirectory: URL {
        urls(for: .cachesDirectory, in: .userDomainMask).first!
    }

    var applicationSupportDirectory: URL {
        let url = urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
        try? createDirectory(at: url, withIntermediateDirectories: true)
        return url
    }
}

UserDefaults

Basic Usage

// Save
UserDefaults.standard.set("value", forKey: "key")
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")

// Load
let value = UserDefaults.standard.string(forKey: "key")
let hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")

@AppStorage

struct SettingsView: View {
    @AppStorage("fontSize") private var fontSize = 16
    @AppStorage("isDarkMode") private var isDarkMode = false
    @AppStorage("username") private var username = ""

    var body: some View {
        Form {
            Stepper("Font Size: \(fontSize)", value: $fontSize, in: 12...24)
            Toggle("Dark Mode", isOn: $isDarkMode)
            TextField("Username", text: $username)
        }
    }
}

Custom Codable Storage

extension UserDefaults {
    func set<T: Codable>(_ value: T, forKey key: String) {
        if let data = try? JSONEncoder().encode(value) {
            set(data, forKey: key)
        }
    }

    func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
        guard let data = data(forKey: key) else { return nil }
        return try? JSONDecoder().decode(type, from: data)
    }
}

// Usage
UserDefaults.standard.set(userProfile, forKey: "userProfile")
let profile = UserDefaults.standard.get(UserProfile.self, forKey: "userProfile")

Keychain (Sensitive Data)

Simple Wrapper

import Security

class KeychainService {
    enum KeychainError: Error {
        case saveFailed(OSStatus)
        case loadFailed(OSStatus)
        case deleteFailed(OSStatus)
        case dataConversionError
    }

    func save(_ data: Data, for key: String) throws {
        // Delete existing
        try? delete(key)

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
        ]

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    func load(_ key: String) throws -> Data {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess else {
            throw KeychainError.loadFailed(status)
        }

        guard let data = result as? Data else {
            throw KeychainError.dataConversionError
        }

        return data
    }

    func delete(_ key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]

        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.deleteFailed(status)
        }
    }
}

// String convenience
extension KeychainService {
    func saveString(_ value: String, for key: String) throws {
        guard let data = value.data(using: .utf8) else {
            throw KeychainError.dataConversionError
        }
        try save(data, for: key)
    }

    func loadString(_ key: String) throws -> String {
        let data = try load(key)
        guard let string = String(data: data, encoding: .utf8) else {
            throw KeychainError.dataConversionError
        }
        return string
    }
}

Usage

let keychain = KeychainService()

// Save API token
try keychain.saveString(token, for: "apiToken")

// Load API token
let token = try keychain.loadString("apiToken")

// Delete on logout
try keychain.delete("apiToken")

Migration Strategies

SwiftData Migrations

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    class Item {
        var name: String
        init(name: String) { self.name = name }
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    class Item {
        var name: String
        var createdAt: Date  // New field

        init(name: String) {
            self.name = name
            self.createdAt = Date()
        }
    }
}

enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}