EduFlow
أساسيات Swift لتطبيقات iOS
⁦0%⁩

الوحدة 4 · إطلاق تطبيق حقيقي

حفظ البيانات بـ SwiftData

17 دقراءة 4 داملأ الفراغات

الدرس في لمحة

حوّل Expense إلى نموذج في SwiftData، وجهّز الـ model container، واقرأ بـ @Query، وأضف واحذف عبر الـ model context، وعدّل الكائنات المحفوظة بـ @Bindable.

  1. 1تحويل Expense إلى نموذج
  2. 2container واحد للتطبيق
  3. 3القراءة بـ @Query، والكتابة عبر الـ context
  4. 4التعديل بـ @Bindable
  5. 5التصفية

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

أضف خمسة مصاريف، وأغلق Pocket Budget، ثم افتحه مجدّدًا، وستجد القائمة فارغة. كل شيء كان يعيش في الذاكرة. يتوقّع المستخدمون أن تصمد بياناتهم أمام إعادة التشغيل والتحديثات وهاتف نفدت بطاريته أثناء الحفظ. SwiftData هو إطار الحفظ الدائم (persistence) من Apple المصمّم لـ Swift: تصف نموذجك في الكود، ويتولّى هو قاعدة البيانات والمخطّط وإبقاء SwiftUI متزامنًا.

تحويل Expense إلى نموذج

Expense.swift
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 واحد للتطبيق

PocketBudgetApp.swift
@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 تحته.

الـ ModelContainer يملك المخزن على القرص. والـ ModelContext الرئيسي التابع له موجود في الـ environment الخاص بـ SwiftUI. تقرأ الـ views عبر Query وتكتب عبر الـ context باستخدام insert وdelete؛ ويحفظ الـ context تلقائيًا في المخزن فتتحدّث نتائج Query. المخزن على القرص ملف SQLite ModelContext يحفظ تلقائيًا @Query expenses يقرأ ويبقى محدّثًا context.insert / delete يكتب يُنشأ مرة واحدة عبر .modelContainer(for:)
الـ container والـ context والـ queries

القراءة بـ @Query، والكتابة عبر الـ context

ExpensesScreen.swift
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 مباشرة إلى داخله:

Swift
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:

Swift
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 والاختبارات حتى لا تمسّ البيانات الحقيقية أبدًا.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

لماذا يتحوّل Expense من struct إلى final class حين يصبح نموذجًا في SwiftData؟

السؤال 2 من 4

تستخدم قائمة @Query private var expenses: [Expense]. كيف تحذف مصروفًا سحبه المستخدم جانبًا؟

السؤال 3 من 4

تربط شاشة تعديل حقل TextField بـ $expense.title عبر @Bindable var expense: Expense. متى يُحفظ التغيير؟

السؤال 4 من 4

يجب أن تعرض الـ preview الخاصة بقائمة المصاريف بيانات تجريبية دون أن تمسّ قاعدة البيانات الحقيقية. ماذا تستخدم؟

تدريب

املأ الفراغات

احفظ المصاريف بـ SwiftData

اجعل Expense نموذجًا في SwiftData وأكمل القائمة: يجب أن تجلب المصاريف الأحدث أولًا، وتحذف ما يسحبه المستخدم جانبًا، وتضيف مصروف قهوة عند النقر على الزر. الـ scene الخاص بالتطبيق فيه .modelContainer(for: Expense.self) أصلًا.

@ final class Expense { var title: String var amount: Decimal var date: Date init(title: String, amount: Decimal, date: Date = .now) { self.title = title self.amount = amount self.date = date } } struct ExpensesList: View { @(sort: \Expense.date, order: .reverse) private var expenses: [Expense] @Environment(\.modelContext) private var context var body: some View { List { ForEach(expenses) { expense in Text(expense.title) } .onDelete { offsets in for index in offsets { context.(expenses[index]) } } } .toolbar { Button("Add coffee") { context.(Expense(title: "Coffee", amount: 4.5)) } } } }