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 và #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: Transaction và Change. 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, externalId và lastModified 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ả Conference và Workshop. 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
#Uniqueconstraint 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
@Modeltrê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@ModelActortí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 và #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.