Hướng Dẫn SwiftData Toàn Diện: Từ Cơ Bản Đến Model Inheritance iOS 26

Hướng dẫn SwiftData toàn diện — từ @Model, @Query, ModelContainer đến #Unique, #Index, ModelActor, History Tracking và Model Inheritance mới nhất trong iOS 26. Kèm code ví dụ và best practices thực tế.

Giới Thiệu: Tại Sao SwiftData Là Tương Lai Của Persistence Trên Apple Platform?

Nếu bạn đang phát triển ứng dụng iOS và vẫn còn dùng Core Data với những đoạn boilerplate code dài dằng dặc, thì thật sự — đã đến lúc nghiêm túc nhìn nhận lại rồi. SwiftData, framework persistence thuần Swift được Apple giới thiệu từ WWDC 2023, đã trải qua ba năm phát triển. Và giờ đây, với iOS 26, nó đã trưởng thành đủ để trở thành lựa chọn hàng đầu cho hầu hết dự án.

Mình từng là người rất gắn bó với Core Data. Thật đấy — sau nhiều năm làm việc với NSManagedObjectContext, NSFetchRequest, và tất cả các lớp trừu tượng của nó, mình đã quen đến mức cảm thấy... thoải mái. Nhưng rồi khi chuyển sang SwiftData cho một dự án mới, mình nhận ra một điều khá buồn cười: cảm giác "thoải mái" với Core Data thực chất là cảm giác "chấp nhận" sự phức tạp không cần thiết.

SwiftData thay đổi hoàn toàn cách bạn nghĩ về persistence. Không còn file .xcdatamodeld. Không còn NSPredicate với chuỗi format dễ gây lỗi. Không còn quản lý context thủ công. Model là class Swift. Query là closure Swift. Predicate được kiểm tra type-safe tại compile time.

Đơn giản và mạnh mẽ.

Trong bài viết này, chúng ta sẽ đi qua toàn bộ hành trình SwiftData — từ những khái niệm nền tảng, qua các tính năng nâng cao như #Index, #Unique, History Tracking, ModelActor, cho đến tính năng mới nhất trong iOS 26: Model Inheritance. Cuối bài, bạn sẽ có đủ kiến thức để tự tin áp dụng SwiftData vào dự án thực tế.

Nền Tảng: @Model, ModelContainer và ModelContext

Trước khi nhảy vào các tính năng nâng cao, hãy nắm vững ba trụ cột của SwiftData đã. Tin mình đi — hiểu rõ phần này sẽ giúp mọi thứ phía sau trở nên dễ hiểu hơn rất nhiều.

@Model — Biến Class Thành Persistent Model

Trong Core Data, bạn phải tạo model trong editor trực quan, rồi generate code, rồi quản lý relationship bằng tay. Khá mệt. Với SwiftData thì sao? Bạn chỉ cần thêm macro @Model:

import SwiftData

@Model
final class Task {
    var title: String
    var notes: String
    var isCompleted: Bool
    var dueDate: Date?
    var priority: Int
    var createdAt: Date

    init(title: String, notes: String = "", isCompleted: Bool = false, dueDate: Date? = nil, priority: Int = 0) {
        self.title = title
        self.notes = notes
        self.isCompleted = isCompleted
        self.dueDate = dueDate
        self.priority = priority
        self.createdAt = Date()
    }
}

Vậy thôi. Macro @Model sẽ tự động lo hết:

  • Đăng ký class làm persistent model với SwiftData
  • Tạo schema tương ứng trong database
  • Thêm khả năng observation để SwiftUI tự động cập nhật khi dữ liệu thay đổi
  • Hỗ trợ các kiểu dữ liệu cơ bản: String, Int, Bool, Date, Data, UUID, và cả Codable

Relationship — Mối Quan Hệ Giữa Các Model

SwiftData hỗ trợ relationship khá tự nhiên. Bạn chỉ cần khai báo property với kiểu là model khác, không cần setup gì phức tạp:

@Model
final class Project {
    var name: String
    var projectDescription: String

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

    init(name: String, projectDescription: String = "") {
        self.name = name
        self.projectDescription = projectDescription
    }
}

@Model
final class Task {
    var title: String
    var isCompleted: Bool
    var project: Project?

    init(title: String, isCompleted: Bool = false, project: Project? = nil) {
        self.title = title
        self.isCompleted = isCompleted
        self.project = project
    }
}

Attribute @Relationship(deleteRule: .cascade) đảm bảo khi xóa một Project, tất cả Task thuộc về nó cũng bị xóa theo. Ngoài .cascade, bạn còn có .nullify (mặc định), .deny, và .noAction — tùy tình huống mà chọn.

ModelContainer — Nơi Quản Lý Toàn Bộ Persistence

ModelContainer là đối tượng quản lý schema và cấu hình lưu trữ. Nó quyết định dữ liệu được lưu ở đâu (disk hay memory), quản lý migration, và phối hợp đọc/ghi.

@main
struct MyTaskApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Project.self, Task.self])
    }
}

Chỉ một dòng .modelContainer(for:) và toàn bộ view hierarchy đã có quyền truy cập vào persistence layer. So với Core Data thì đơn giản hơn đáng kể, đúng không?

Khi cần cấu hình chi tiết hơn thì sao? Bạn tạo ModelConfiguration:

let config = ModelConfiguration(
    "MyDatabase",
    schema: Schema([Project.self, Task.self]),
    isStoredInMemoryOnly: false,
    allowsSave: true,
    groupContainer: .automatic,
    cloudKitDatabase: .private("iCloud.com.myapp")
)

let container = try ModelContainer(
    for: Project.self, Task.self,
    configurations: config
)

ModelContext — Nơi Thực Hiện Các Thao Tác CRUD

ModelContext là nơi bạn thực hiện tất cả thao tác tạo, đọc, cập nhật, và xóa dữ liệu. Trong SwiftUI, bạn truy cập nó qua environment:

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

    func addTask() {
        let newTask = Task(title: "Công việc mới")
        modelContext.insert(newTask)
        // Không cần gọi save() — SwiftData tự động lưu!
    }

    func deleteTask(_ task: Task) {
        modelContext.delete(task)
    }
}

Một điểm mình rất thích: SwiftData tự động persist các thay đổi khi ứng dụng nhận sự kiện nhất định — như khi navigate giữa các view hay khi app chuyển sang background. Hầu hết trường hợp bạn không cần gọi save() thủ công. Tiện lắm.

@Query — Truy Vấn Dữ Liệu Trong SwiftUI

Property wrapper @Query chính là cầu nối giữa SwiftData và SwiftUI. Nó tự động fetch dữ liệu từ ModelContainer và cập nhật view khi dữ liệu thay đổi:

struct TaskListView: View {
    @Query(
        filter: #Predicate<Task> { !$0.isCompleted },
        sort: \Task.dueDate,
        order: .forward
    )
    private var pendingTasks: [Task]

    @Query(
        filter: #Predicate<Task> { $0.isCompleted },
        sort: \Task.createdAt,
        order: .reverse
    )
    private var completedTasks: [Task]

    var body: some View {
        List {
            Section("Chưa hoàn thành") {
                ForEach(pendingTasks) { task in
                    TaskRow(task: task)
                }
            }

            Section("Đã hoàn thành") {
                ForEach(completedTasks) { task in
                    TaskRow(task: task)
                }
            }
        }
    }
}

Điều hay ở đây là #Predicate được kiểm tra type-safe tại compile time. Viết sai tên property? Xcode báo lỗi ngay. So sánh sai kiểu dữ liệu? Xcode cũng bắt luôn. Không còn cảnh chờ đến runtime mới phát hiện lỗi như NSPredicate ngày xưa nữa.

FetchDescriptor — Truy Vấn Ngoài View

Khi cần truy vấn dữ liệu bên ngoài SwiftUI view — ví dụ trong business logic hay service layer — bạn dùng FetchDescriptor:

func fetchOverdueTasks(context: ModelContext) throws -> [Task] {
    let now = Date()
    let descriptor = FetchDescriptor<Task>(
        predicate: #Predicate {
            $0.dueDate != nil &&
            $0.dueDate! < now &&
            !$0.isCompleted
        },
        sortBy: [SortDescriptor(\Task.dueDate, order: .forward)]
    )
    return try context.fetch(descriptor)
}

// Giới hạn số lượng kết quả
func fetchRecentTasks(context: ModelContext, limit: Int = 10) throws -> [Task] {
    var descriptor = FetchDescriptor<Task>(
        sortBy: [SortDescriptor(\Task.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = limit
    return try context.fetch(descriptor)
}

#Unique và #Index — Tối Ưu Schema Cho Dữ Liệu Lớn

Từ iOS 18, SwiftData bổ sung hai macro quan trọng cho việc tối ưu hiệu năng và đảm bảo tính toàn vẹn dữ liệu: #Unique#Index. Nếu app của bạn xử lý dataset lớn, đây là hai thứ bạn không nên bỏ qua.

#Unique — Ngăn Chặn Dữ Liệu Trùng Lặp

Macro #Unique cho phép bạn định nghĩa constraint để không có hai model nào trùng nhau dựa trên một hoặc nhiều property:

@Model
final class Contact {
    #Unique<Contact>([\.email])

    var name: String
    var email: String
    var phone: String

    init(name: String, email: String, phone: String) {
        self.name = name
        self.email = email
        self.phone = phone
    }
}

Khi bạn insert một Contact với email đã tồn tại, SwiftData sẽ tự động upsert — cập nhật bản ghi hiện có thay vì tạo bản mới. Tính năng này cực kỳ hữu ích khi sync dữ liệu từ server (mình đã tiết kiệm được kha khá code nhờ nó).

Bạn cũng có thể tạo compound uniqueness — tức là đảm bảo tính duy nhất dựa trên tổ hợp nhiều property cùng lúc:

@Model
final class Meeting {
    #Unique<Meeting>([\.title, \.date, \.roomId])

    var title: String
    var date: Date
    var roomId: String
    var attendees: [String]

    init(title: String, date: Date, roomId: String, attendees: [String] = []) {
        self.title = title
        self.date = date
        self.roomId = roomId
        self.attendees = attendees
    }
}

Với compound uniqueness, bạn có thể có nhiều meeting cùng tên — miễn là chúng diễn ra vào ngày khác hoặc ở phòng khác. Khá linh hoạt.

#Index — Tăng Tốc Truy Vấn Đáng Kể

Khi dataset lớn (hàng nghìn, hàng chục nghìn bản ghi), việc filter và sort có thể bắt đầu chậm. #Index tạo chỉ mục tối ưu, giúp SwiftData dùng binary search thay vì duyệt tuần tự:

@Model
final class Article {
    #Index<Article>([\.publishDate], [\.category], [\.publishDate, \.category])

    var title: String
    var content: String
    var publishDate: Date
    var category: String
    var isPublished: Bool

    init(title: String, content: String, publishDate: Date, category: String) {
        self.title = title
        self.content = content
        self.publishDate = publishDate
        self.category = category
        self.isPublished = false
    }
}

Một lưu ý nhỏ: chỉ nên index những property mà bạn thường xuyên dùng để filter hoặc sort. Đừng index tất cả mọi thứ — mỗi index tốn thêm dung lượng lưu trữ và làm chậm thao tác ghi.

Trong ví dụ trên, mình tạo ba index riêng: một cho publishDate, một cho category, và một compound index cho cả hai. Compound index đặc biệt hiệu quả khi bạn hay query theo cả ngày publish lẫn category cùng lúc.

#Predicate Nâng Cao và #Expression

Macro #Predicate không chỉ dừng lại ở các phép so sánh đơn giản đâu. Bạn hoàn toàn có thể viết predicate phức tạp với nhiều điều kiện kết hợp:

// Predicate phức tạp với nhiều điều kiện
let searchText = "swift"
let minPriority = 3

let predicate = #Predicate<Task> {
    $0.title.localizedStandardContains(searchText) &&
    $0.priority >= minPriority &&
    !$0.isCompleted
}

#Expression — Tính Toán Trong Query (iOS 18+)

Macro #Expression cho phép thực hiện các phép tính phức tạp hơn ngay trong query. Ví dụ, đếm số task đã hoàn thành trong một project:

let completedCount = #Expression<[Task], Int> { tasks in
    tasks.filter { $0.isCompleted }.count
}

let predicate = #Predicate<Project> { project in
    completedCount.evaluate(project.tasks) > 5
}

#Expression mở rộng đáng kể khả năng query, cho phép bạn thực hiện aggregation và computation ngay trong predicate — không cần fetch toàn bộ dữ liệu ra rồi xử lý thủ công nữa. Nói thật là tính năng này đã giúp mình tối ưu được vài query tốn tài nguyên.

Lưu Ý Quan Trọng Khi Dùng #Predicate

Có một cái "bẫy" mà nhiều developer mới làm quen với SwiftData hay dính: #Predicate chỉ có thể dịch scalar values (String, Int, UUID, Date...) sang SQL. Bạn không thể so sánh trực tiếp object phức tạp bên trong predicate:

// ❌ Sai — không thể so sánh object
let targetProject = someProject
let predicate = #Predicate<Task> {
    $0.project == targetProject // Lỗi!
}

// ✅ Đúng — so sánh qua identifier
let targetId = someProject.persistentModelID
let predicate = #Predicate<Task> {
    $0.project?.persistentModelID == targetId
}

Mẹo: luôn trích xuất giá trị scalar ra biến local trước khi dùng trong #Predicate. Điều này giúp macro hiểu được chính xác những constant nào cần đưa vào query SQL.

ModelActor — Xử Lý Dữ Liệu Nặng Trên Background

Đây là vấn đề kinh điển: làm sao import hoặc xử lý lượng lớn dữ liệu mà không block UI? Trong Core Data, bạn phải tạo background context, quản lý thread safety bằng tay, và... thành thật mà nói, luôn có cảm giác hồi hộp không biết mình đã handle đúng chưa. SwiftData giải quyết chuyện này thanh lịch hơn nhiều với @ModelActor.

@ModelActor
actor DataImporter {

    func importContacts(from jsonData: Data) throws {
        let decoder = JSONDecoder()
        let contactDTOs = try decoder.decode([ContactDTO].self, from: jsonData)

        for dto in contactDTOs {
            let contact = Contact(
                name: dto.name,
                email: dto.email,
                phone: dto.phone
            )
            modelContext.insert(contact)
        }

        try modelContext.save()
    }

    func purgeOldTasks(olderThan date: Date) throws -> Int {
        let descriptor = FetchDescriptor<Task>(
            predicate: #Predicate {
                $0.isCompleted && $0.createdAt < date
            }
        )
        let oldTasks = try modelContext.fetch(descriptor)
        let count = oldTasks.count

        for task in oldTasks {
            modelContext.delete(task)
        }

        try modelContext.save()
        return count
    }
}

Macro @ModelActor tự động tạo ModelContext riêng chạy trên background thread. Gọi từ SwiftUI view cũng rất gọn gàng:

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

    var body: some View {
        Button("Import Contacts") {
            Task {
                let importer = DataImporter(
                    modelContainer: modelContext.container
                )
                try await importer.importContacts(from: jsonData)
            }
        }
    }
}

Lưu ý quan trọng: Đừng tạo ModelActor bên trong SwiftUI view body. Mỗi lần view redraw sẽ tạo ModelContext mới, dẫn đến lãng phí bộ nhớ và thậm chí crash. Hãy tạo nó trong Task block hoặc quản lý lifecycle cho cẩn thận.

History Tracking — Theo Dõi Lịch Sử Thay Đổi Dữ Liệu

Từ iOS 18, SwiftData cung cấp History API cho phép theo dõi mọi thay đổi trong data store theo thời gian. Nghe có vẻ đơn giản nhưng đây là tính năng cực kỳ mạnh, đặc biệt trong các tình huống như:

  • Đồng bộ dữ liệu với remote server
  • Xử lý thay đổi từ các process khác (ví dụ Widget Extension)
  • Tạo tính năng undo/redo ở cấp persistence
  • Audit log cho dữ liệu quan trọng

Cách Hoạt Động

History API gồm hai thành phần chính: TransactionChange. Transaction nhóm tất cả thay đổi xảy ra trong một lần ModelContext.save(). Mỗi Transaction chứa một tập hợp Changes, được sắp xếp theo thứ tự.

@ModelActor
actor HistoryProcessor {

    func processRecentChanges(since token: DefaultHistoryToken?) throws -> DefaultHistoryToken? {
        var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()

        if let token {
            descriptor.predicate = #Predicate { transaction in
                transaction.token > token
            }
        }

        let transactions = try modelContext.fetchHistory(descriptor)
        var latestToken: DefaultHistoryToken?

        for transaction in transactions {
            for change in transaction.changes {
                switch change {
                case .insert(let insert):
                    let model = insert.changedPersistentIdentifier
                    print("Inserted: \(model)")
                case .update(let update):
                    let model = update.changedPersistentIdentifier
                    print("Updated: \(model)")
                case .delete(let delete):
                    let model = delete.changedPersistentIdentifier
                    print("Deleted: \(model)")
                default:
                    break
                }
            }
            latestToken = transaction.token
        }

        return latestToken
    }
}

Token — Bookmark Cho Lịch Sử

Token hoạt động giống như bookmark — giúp app theo dõi transaction cuối cùng đã xử lý. Lần sau bạn chỉ cần query những transaction sau token đó, không cần xử lý lại toàn bộ lịch sử. Rất hiệu quả.

#Preserve — Giữ Lại Thông Tin Khi Xóa

Bình thường khi xóa model, tất cả dữ liệu sẽ mất. Nhưng đôi khi bạn cần giữ lại vài thông tin quan trọng (ví dụ để thông báo cho server). Macro #Preserve giải quyết chuyện này:

@Model
final class AuditableItem {
    #Preserve([\.externalId, \.lastModified])

    var name: String
    var externalId: String
    var lastModified: Date

    init(name: String, externalId: String) {
        self.name = name
        self.externalId = externalId
        self.lastModified = Date()
    }
}

Khi AuditableItem bị xóa, externalIdlastModified vẫn được lưu trong history — cho phép bạn thông báo cho server biết item nào đã bị xóa và khi nào.

Model Inheritance — Tính Năng Lớn Nhất Của SwiftData Trong iOS 26

Nói không ngoa, đây là tính năng mà cộng đồng iOS đã mong chờ từ rất lâu. Trước iOS 26, các model SwiftData không thể kế thừa lẫn nhau — bạn buộc phải dùng composition hoặc protocol để xoay xở. Giờ đây, với Model Inheritance, bạn có thể xây dựng model hierarchy theo kiểu hướng đối tượng một cách tự nhiên.

Khi Nào Nên Dùng Inheritance?

Trước khi đi vào code, cần xác định rõ: inheritance phù hợp khi các model có quan hệ IS-A (là một loại của). Ví dụ:

  • BusinessTrip là một Trip
  • AudioMessage là một Message
  • PremiumUser là một User

Còn nếu quan hệ là HAS-A (có một), thì composition qua relationship vẫn là lựa chọn tốt hơn. Đừng ép inheritance vào chỗ không cần nhé.

Ví Dụ Thực Tế: Hệ Thống Quản Lý Sự Kiện

import SwiftData

// Base model — chứa các property chung
@Model
class Event {
    var title: String
    var eventDescription: String
    var startDate: Date
    var endDate: Date
    var location: String

    init(title: String, eventDescription: String = "", startDate: Date, endDate: Date, location: String = "") {
        self.title = title
        self.eventDescription = eventDescription
        self.startDate = startDate
        self.endDate = endDate
        self.location = location
    }
}

// Subclass cho hội nghị
@available(iOS 26, *)
@Model
final class Conference: Event {
    var speakers: [String]
    var maxAttendees: Int
    var registrationUrl: String

    init(title: String, eventDescription: String = "", startDate: Date, endDate: Date, location: String = "", speakers: [String] = [], maxAttendees: Int = 100, registrationUrl: String = "") {
        self.speakers = speakers
        self.maxAttendees = maxAttendees
        self.registrationUrl = registrationUrl
        super.init(title: title, eventDescription: eventDescription, startDate: startDate, endDate: endDate, location: location)
    }
}

// Subclass cho workshop
@available(iOS 26, *)
@Model
final class Workshop: Event {
    var instructor: String
    var difficulty: String
    var materialsRequired: [String]

    init(title: String, eventDescription: String = "", startDate: Date, endDate: Date, location: String = "", instructor: String, difficulty: String = "Trung bình", materialsRequired: [String] = []) {
        self.instructor = instructor
        self.difficulty = difficulty
        self.materialsRequired = materialsRequired
        super.init(title: title, eventDescription: eventDescription, startDate: startDate, endDate: endDate, location: location)
    }
}

Lưu ý dòng @available(iOS 26, *) — Model Inheritance chỉ khả dụng từ iOS 26 trở lên. Nếu dự án cần hỗ trợ iOS cũ hơn, bạn phải dùng availability check.

Cấu Hình ModelContainer Với Subclass

Khi dùng inheritance, nhớ đăng ký cả base class lẫn subclass với ModelContainer:

@main
struct EventManagerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Event.self, Conference.self, Workshop.self])
    }
}

Query Với Inheritance — Deep và Shallow Search

Đây là một trong những lợi thế lớn nhất của inheritance. Bạn có thể thực hiện cả deep search (tìm tất cả, bao gồm subclass) và shallow search (chỉ lấy một loại cụ thể):

struct EventListView: View {
    // Deep search — lấy TẤT CẢ event (Conference + Workshop + Event thuần)
    @Query(sort: \Event.startDate)
    private var allEvents: [Event]

    // Shallow search — chỉ lấy Conference
    @Query(sort: \Conference.startDate)
    private var conferences: [Conference]

    // Shallow search — chỉ lấy Workshop
    @Query(sort: \Workshop.startDate)
    private var workshops: [Workshop]

    var body: some View {
        NavigationStack {
            List {
                Section("Tất cả sự kiện (\(allEvents.count))") {
                    ForEach(allEvents) { event in
                        EventRow(event: event)
                    }
                }

                Section("Hội nghị (\(conferences.count))") {
                    ForEach(conferences) { conference in
                        ConferenceRow(conference: conference)
                    }
                }

                Section("Workshop (\(workshops.count))") {
                    ForEach(workshops) { workshop in
                        WorkshopRow(workshop: workshop)
                    }
                }
            }
            .navigationTitle("Quản Lý Sự Kiện")
        }
    }
}

Khi query Event, SwiftData trả về tất cả instance — bao gồm cả ConferenceWorkshop. Cần kiểm tra kiểu cụ thể? Dùng as? là xong.

Schema Migration Với Inheritance

Khi thêm subclass mới vào schema đã có, bạn cần tạo migration plan. Nếu không, dữ liệu cũ có thể bị mất (và tin mình, đó không phải trải nghiệm vui vẻ gì):

@available(iOS 26, *)
enum EventSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Event.self, Conference.self, Workshop.self]
    }
}

@available(iOS 26, *)
enum EventMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [EventSchemaV1.self, EventSchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: EventSchemaV1.self,
        toVersion: EventSchemaV2.self
    )
}

Kết Hợp SwiftData Với CloudKit Sync

SwiftData hỗ trợ đồng bộ CloudKit khá straightforward. Về cơ bản, bạn chỉ cần cấu hình ModelConfiguration với CloudKit container:

let config = ModelConfiguration(
    cloudKitDatabase: .private("iCloud.com.yourapp.container")
)

let container = try ModelContainer(
    for: Project.self, Task.self,
    configurations: config
)

Tuy nhiên, có vài hạn chế cần biết trước khi dùng CloudKit với SwiftData:

  • Chỉ hỗ trợ private database — shared và public CloudKit database chưa được hỗ trợ (tính đến iOS 26)
  • Tất cả property phải có giá trị mặc định hoặc là optional
  • Relationship phải là optional
  • #Unique constraint không hoạt động với CloudKit sync

Điểm cuối hơi phiền, nhưng hy vọng Apple sẽ cải thiện trong các bản cập nhật tới.

Custom Data Store — Lưu Trữ Theo Cách Của Bạn

Từ iOS 18, SwiftData cho phép tạo custom data store với bất kỳ backend nào bạn muốn — JSON file, remote API, hay in-memory store cho testing. Riêng in-memory store thì mình dùng rất thường xuyên:

// Ví dụ: In-memory store cho Unit Testing
func createTestContainer() throws -> ModelContainer {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    return try ModelContainer(
        for: Task.self, Project.self,
        configurations: config
    )
}

// Sử dụng trong test
func testAddTask() throws {
    let container = try createTestContainer()
    let context = ModelContext(container)

    let task = Task(title: "Test Task", priority: 1)
    context.insert(task)
    try context.save()

    let descriptor = FetchDescriptor<Task>()
    let tasks = try context.fetch(descriptor)

    XCTAssertEqual(tasks.count, 1)
    XCTAssertEqual(tasks.first?.title, "Test Task")
}

In-memory store đặc biệt tiện cho unit testing: nhanh, không lưu xuống disk, và mỗi test case bắt đầu với database trống hoàn toàn. Clean.

Best Practices — Những Bài Học Từ Thực Tế

Sau nhiều tháng làm việc với SwiftData trong các dự án production, đây là những bài học mình đúc kết được. Một số có vẻ hiển nhiên, nhưng tin mình — khi đang code lúc 2 giờ sáng thì rất dễ quên.

1. Tách Business Logic Khỏi View

Đừng nhét tất cả logic CRUD vào SwiftUI view. Tạo các lớp service riêng sẽ giúp code dễ test và dễ maintain hơn rất nhiều:

@Observable
class TaskService {
    private let modelContext: ModelContext

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }

    func createTask(title: String, priority: Int, project: Project? = nil) -> Task {
        let task = Task(title: title, priority: priority, project: project)
        modelContext.insert(task)
        return task
    }

    func toggleCompletion(_ task: Task) {
        task.isCompleted.toggle()
    }

    func fetchByPriority(minPriority: Int) throws -> [Task] {
        let descriptor = FetchDescriptor<Task>(
            predicate: #Predicate { $0.priority >= minPriority },
            sortBy: [SortDescriptor(\Task.priority, order: .reverse)]
        )
        return try modelContext.fetch(descriptor)
    }
}

2. Dùng Xcode Previews Với SwiftData

SwiftData hoạt động rất tốt với Xcode Previews. Tạo preview container với dữ liệu mẫu giúp quá trình phát triển UI nhanh hơn đáng kể:

struct TaskListView_Previews: PreviewProvider {
    static var previews: some View {
        TaskListView()
            .modelContainer(previewContainer)
    }

    @MainActor
    static var previewContainer: ModelContainer = {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(for: Task.self, configurations: config)

        // Thêm dữ liệu mẫu
        let sampleTasks = [
            Task(title: "Học SwiftData", priority: 3),
            Task(title: "Viết unit test", priority: 2),
            Task(title: "Deploy app", priority: 1)
        ]

        for task in sampleTasks {
            container.mainContext.insert(task)
        }

        return container
    }()
}

3. Xử Lý Migration Cẩn Thận

Luôn version schema từ đầu. Ngay cả khi bạn nghĩ mình sẽ không bao giờ thay đổi schema. Vì sao? Chi phí thêm versioning sau này cao hơn nhiều so với setup ban đầu:

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

enum TaskMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [TaskSchemaV1.self]
    }

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

4. Validate Dữ Liệu Trong Model

SwiftData không có built-in validation như Core Data, nên bạn cần tự thêm logic validation vào model:

@Model
final class Task {
    var title: String {
        didSet {
            title = String(title.prefix(200))
        }
    }
    var priority: Int {
        didSet {
            priority = max(0, min(5, priority))
        }
    }

    static func exists(title: String, in context: ModelContext) throws -> Bool {
        let descriptor = FetchDescriptor<Task>(
            predicate: #Predicate { $0.title == title }
        )
        return try context.fetchCount(descriptor) > 0
    }
}

5. Cẩn Thận Với Autosave

SwiftData tự động save — đó vừa là ưu điểm vừa là "gotcha". Trong trường hợp form editing nơi user có thể cancel, bạn cần xử lý khéo léo hơn một chút:

struct EditTaskView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    let task: Task

    @State private var editTitle: String = ""
    @State private var editPriority: Int = 0

    var body: some View {
        Form {
            TextField("Tiêu đề", text: $editTitle)
            Stepper("Ưu tiên: \(editPriority)", value: $editPriority, in: 0...5)
        }
        .onAppear {
            editTitle = task.title
            editPriority = task.priority
        }
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Lưu") {
                    task.title = editTitle
                    task.priority = editPriority
                    dismiss()
                }
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Hủy") {
                    dismiss()
                }
            }
        }
    }
}

Bằng cách dùng @State riêng cho editing, thay đổi chỉ được apply vào model khi user nhấn "Lưu". Đơn giản nhưng hiệu quả.

So Sánh Nhanh: SwiftData vs Core Data

Để giúp bạn quyết định nên chuyển sang SwiftData hay chưa, đây là so sánh nhanh giữa hai framework:

  • Định nghĩa Model: Core Data dùng visual editor + generated code; SwiftData chỉ cần macro @Model trên Swift class
  • Query: Core Data dùng NSFetchRequest + NSPredicate (string-based, dễ lỗi); SwiftData dùng #Predicate (type-safe, compile-time check)
  • SwiftUI Integration: Core Data dùng @FetchRequest; SwiftData dùng @Query — gọn hơn đáng kể
  • Concurrency: Core Data quản lý bằng tay với performBackgroundTask; SwiftData dùng @ModelActor tích hợp Swift concurrency
  • Migration: Core Data dùng mapping model phức tạp; SwiftData dùng VersionedSchema + MigrationPlan
  • Minimum Target: Core Data hỗ trợ từ iOS rất cũ; SwiftData yêu cầu iOS 17+
  • CloudKit Sync: Cả hai đều hỗ trợ, nhưng SwiftData chỉ hỗ trợ private database
  • Model Inheritance: Core Data đã hỗ trợ từ lâu; SwiftData mới có từ iOS 26

Kết luận ngắn gọn: Dự án mới target iOS 17+? Dùng SwiftData. Cần hỗ trợ iOS cũ hoặc shared/public CloudKit? Stick với Core Data. Tin tốt là hai framework có thể coexist trong cùng ứng dụng, nên bạn hoàn toàn có thể migrate dần dần.

Kết Luận: SwiftData Đã Sẵn Sàng Cho Production

Sau ba năm phát triển, SwiftData đã chứng minh được mình. Với iOS 26 và những cải tiến về Model Inheritance, các bug fix quan trọng (đặc biệt cho @ModelActor view updates), cùng với việc nhiều tính năng được back-port về iOS 17 — SwiftData giờ đây thực sự xứng đáng là lựa chọn hàng đầu cho persistence trên Apple platform.

Tóm lại những gì chúng ta đã đi qua:

  • @Model + @Query: Nền tảng đơn giản nhưng mạnh mẽ cho persistence trong SwiftUI
  • #Unique + #Index: Tối ưu tính toàn vẹn và hiệu năng cho dataset lớn
  • #Predicate + #Expression: Query type-safe mà vẫn dễ viết, dễ đọc
  • @ModelActor: Xử lý dữ liệu nặng trên background an toàn
  • History Tracking: Theo dõi và sync thay đổi dữ liệu hiệu quả
  • Model Inheritance (iOS 26): Xây dựng model hierarchy tự nhiên như OOP truyền thống

Nếu bạn chưa từng thử SwiftData, bây giờ chính là thời điểm tốt nhất. Còn nếu đã dùng từ iOS 17 thì hãy cập nhật lên các tính năng mới — đặc biệt #Index#Unique sẽ giúp app chạy nhanh và ổn định hơn rõ rệt.

Chúc bạn code vui! Và nhớ rằng — persistence không nhất thiết phải phức tạp. Với SwiftData, nó thực sự không phức tạp.

Về Tác Giả Editorial Team

Our team of expert writers and editors.