الوحدة 4 · إطلاق تطبيق حقيقي
حفظ البيانات بـ SwiftData
17 دقراءة 4 داملأ الفراغات
الدرس في لمحة
حوّل Expense إلى نموذج في SwiftData، وجهّز الـ model container، واقرأ بـ @Query، وأضف واحذف عبر الـ model context، وعدّل الكائنات المحفوظة بـ @Bindable.
- 1تحويل Expense إلى نموذج
- 2container واحد للتطبيق
- 3القراءة بـ @Query، والكتابة عبر الـ context
- 4التعديل بـ @Bindable
- 5التصفية
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
أضف خمسة مصاريف، وأغلق Pocket Budget، ثم افتحه مجدّدًا، وستجد القائمة فارغة. كل شيء كان يعيش في الذاكرة. يتوقّع المستخدمون أن تصمد بياناتهم أمام إعادة التشغيل والتحديثات وهاتف نفدت بطاريته أثناء الحفظ. SwiftData هو إطار الحفظ الدائم (persistence) من Apple المصمّم لـ Swift: تصف نموذجك في الكود، ويتولّى هو قاعدة البيانات والمخطّط وإبقاء SwiftUI متزامنًا.
تحويل Expense إلى نموذج
import Foundation
import SwiftData
@Model
final class Expense {
var title: String
var amount: Decimal
var category: Category
var date: Date
var note: String = ""
init(title: String, amount: Decimal, category: Category, date: Date = .now) {
self.title = title
self.amount = amount
self.category = category
self.date = date
}
}تغيّر أمران عن الـ struct الذي تستخدمه منذ قسم التصميم. أولًا، @Model يتطلّب class. فالمصروف المحفوظ له هوية واحدة: يجب أن ترى القائمة وشاشة التفاصيل ونموذج التعديل كلها السجلّ نفسه، وهذا هو سلوك المرجع الذي تعلّمت أن تلجأ إليه حين تكون البيانات مشتركة. ثانيًا، تكتب المُهيِّئ بنفسك، لأن الـ classes لا تحصل على memberwise initializer.
يجعل @Model الـ class يتوافق مع PersistentModel، الذي يجلب معه Identifiable وHashable، فيستمر ForEach وNavigationLink(value:) في العمل دون تغيير. ويحتاج الـ enum المسمّى Category إلى التوافق مع Codable حتى يُخزَّن؛ أضفه إلى تعريفه. ويمكنك حذف خاصية id: يعطي SwiftData كل نموذج معرّفًا دائمًا.
container واحد للتطبيق
@main
struct PocketBudgetApp: App {
var body: some Scene {
WindowGroup {
ExpensesScreen()
}
.modelContainer(for: Expense.self)
}
}الـ model container (حاوية النماذج) يملك ملف قاعدة البيانات والمخطّط. وإنشاؤه مرة واحدة على الـ scene يضع أيضًا model context (سياق النماذج) في الـ environment. الـ context هو مساحة عملك: يتتبّع النماذج المضافة والمعدّلة والمحذوفة ويكتبها على القرص. والـ context الرئيسي يحفظ تلقائيًا، فمعظم التطبيقات لا تستدعي save() يدويًا أبدًا.
أنشئ الـ container في مكان واحد بالضبط. الـ ModelContainer المبني داخل body أو مُهيِّئ أحد الـ views يُعاد إنشاؤه كلما أعاد SwiftUI بناء ذلك الـ view، وكل نسخة تفتح المخزن من جديد. ضعه على الـ scene كما هنا، وسيتشاركه كل view تحته.
القراءة بـ @Query، والكتابة عبر الـ context
struct ExpensesScreen: View {
@Query(sort: \Expense.date, order: .reverse) private var expenses: [Expense]
@Environment(\.modelContext) private var context
var body: some View {
NavigationStack {
List {
ForEach(expenses) { expense in
NavigationLink(value: expense) {
Text(expense.title)
}
}
.onDelete { offsets in
for index in offsets {
context.delete(expenses[index])
}
}
}
.navigationTitle("Expenses")
.navigationDestination(for: Expense.self) { expense in
EditExpenseView(expense: expense)
}
}
}
}يجلب @Query كل Expense، الأحدث أولًا، ويعيد التنفيذ كلما تغيّرت البيانات المخزّنة، فأي حذف أو إضافة في أي مكان في التطبيق يحدّث هذه القائمة من تلقاء نفسها. نتيجة الـ query للقراءة فقط: أنت تغيّر البيانات عبر الـ context. وفي نموذج الإضافة من قسم SwiftUI، صار زر Save يستدعي context.insert(Expense(title: title, amount: amount, category: category, date: date)) بدل store.add(...). لم تعد المصاريف تعيش في BudgetStore؛ فهو يحتفظ الآن بإعدادات التطبيق مثل الحدّ الشهري وأسعار الصرف.
التعديل بـ @Bindable
النموذج class مراقَب، فيعطيك @Bindable bindings مباشرة إلى داخله:
struct EditExpenseView: View {
@Bindable var expense: Expense
var body: some View {
Form {
TextField("Title", text: $expense.title)
TextField("Amount", value: $expense.amount, format: .currency(code: "USD"))
DatePicker("Date", selection: $expense.date, displayedComponents: .date)
}
.navigationTitle(expense.title)
}
}لا يوجد زر Save. الكتابة تغيّر النموذج، فيلاحظ الـ context ذلك ويحفظ تلقائيًا، وتتحدّث القائمة الموجودة خلف الشاشة أيضًا. وإن أردت سلوك Cancel بدلًا من ذلك، فانسخ القيم إلى مسوّدات @State كما يفعل نموذج الإضافة، واكتبها رجوعًا عند Save.
التصفية
مرّر predicate (شرط تصفية) إلى @Query لجلب مجموعة جزئية. وحين يعتمد الفلتر على قيمة يستقبلها الـ view، ابنِ الـ query داخل init:
struct RecentExpensesView: View {
@Query private var recent: [Expense]
init(since start: Date) {
_recent = Query(filter: #Predicate<Expense> { $0.date >= start },
sort: \.date, order: .reverse)
}
var body: some View {
List(recent) { Text($0.title) }
}
}يُفحص #Predicate وقت الترجمة ويُترجم إلى query لقاعدة البيانات، فتحدث التصفية في التخزين لا في حلقة Swift. اقتصر في الـ predicates على الخصائص المخزّنة البسيطة مثل التواريخ والأرقام والنصوص. فالتصفية على خصائص من نوع enum داخل predicate لم تكن موثوقة تاريخيًا، لذلك صفِّ التصنيفات في Swift بعد الجلب، أو خزّن القيمة الخام للتصنيف كـ String إن احتجت إلى الاستعلام بها.
إضافة خاصية جديدة لاحقًا، مثل note أعلاه، تعمل دون كود إضافي حين يكون لها قيمة افتراضية: ينقل SwiftData المخزن تلقائيًا إلى الشكل الجديد. أما إعادة التسمية فتحتاج إلى تلميح، @Attribute(originalName: "oldName")، حتى ينتقل العمود القديم إلى الاسم الجديد؛ وتغيير نوع خاصية يحتاج إلى مخطّط بإصدارات (versioned schema) وخطة ترحيل (migration plan)، ويمكنك تأجيل ذلك حتى يكون لديك مستخدمون حقيقيون ببيانات حقيقية.
صار Pocket Budget الآن يصمد أمام إعادة التشغيل. والدرس الأخير يضمن أنه يستمر في العمل وأنت تغيّره، باستخدام Swift Testing، ويوصله إلى هواتف الآخرين عبر TestFlight.
أهم النقاط
@Modelيحوّل الـ class إلى بيانات محفوظة بشكل دائم؛ ويبني SwiftData مخطّط التخزين من خصائصه المخزّنة..modelContainer(for:)على الـ scene الخاص بالتطبيق ينشئ قاعدة البيانات مرة واحدة ويضع model context في الـ environment.@Queryيجلب النماذج ويرتّبها ويصفّيها للـ view، ويُبقي النتيجة محدّثة كلما تغيّرت البيانات.- أضف واحذف عبر
modelContext؛ أما التغييرات على خصائص النموذج فتُحفظ تلقائيًا، بما في ذلك التعديلات المربوطة عبر@Bindable. - استخدم container في الذاكرة (
inMemory: trueأوisStoredInMemoryOnly) للـ previews والاختبارات حتى لا تمسّ البيانات الحقيقية أبدًا.