ماكرو @Observable في SwiftUI: دليل الترحيل من ObservableObject في iOS 26

كل ما تحتاج لمعرفته لترحيل نماذجك من ObservableObject إلى ماكرو @Observable في SwiftUI مع iOS 26 وSwift 6.2: أمثلة كاملة، جدول مقارنة، أنماط @Bindable و@Environment، وحلول للأخطاء الشائعة.

@Observable في SwiftUI: دليل iOS 26

آخر تحديث: 27 يوليو 2026

ماكرو @Observable في SwiftUI هو الطريقة الحديثة لإدارة الحالة في iOS 17 وما بعده، وقد أصبح المعيار الفعلي مع Swift 6.2 وiOS 26 لأنه يستبدل بروتوكول ObservableObject بنظام يعتمد على التتبع على مستوى الخاصية بدل الكائن كاملاً. النتيجة العملية: عدد أقل من إعادة الرسم، شيفرة أقل تكراراً، وتكامل أنظف مع SwiftData وnavigation. بصراحة، بعد الترحيل، معظم مشاريعي أظهرت تحسّناً ملموساً في زمن الاستجابة على iPhone وiPad وحتى visionOS، خصوصاً في الشاشات التي تحوي قوائم طويلة.

  • ماكرو @Observable يستبدل ObservableObject و@Published، ويتتبّع التغييرات على مستوى الخاصية بدل الكائن.
  • الترحيل ليس مجرد استبدال كلمات مفتاحية: يجب فهم متى تستخدم @State، ومتى يلزم @Bindable، وكيف تعمل @Environment مع الكائنات.
  • الأداء يتحسّن ملموساً في القوائم الطويلة والشاشات المعقّدة، لأن SwiftUI يعيد تقييم الـ body فقط عند تغيّر الخصائص التي يقرأها الـ view فعلياً.
  • @ObservationIgnored ضرورية للتبعيات المحقونة (dependencies) التي لا يجب أن يراقبها SwiftUI.
  • على macOS Catalyst وvisionOS يعمل @Observable بنفس الطريقة، مع فروق دقيقة في lifecycle الـ scenes الجديدة.
  • الحد الأدنى المدعوم: iOS 17 وmacOS 14 وwatchOS 10 وtvOS 17 وvisionOS 1؛ لدعم iOS 16 وما دون تحتاج مسار ObservableObject القديم.

ما هو ماكرو @Observable ولماذا ظهر؟

ماكرو @Observable جزء من إطار Observation الذي أعلنت عنه Apple في WWDC23، ونضج مع iOS 17 وأصبح جزءاً أساسياً من Swift 6 وiOS 26. الفكرة الجوهرية بسيطة: بدل أن تُخبر SwiftUI أن كامل الكائن قد تغيّر (كما يفعل @Published)، يقوم الماكرو ببناء بنية تحتية في وقت الترجمة تسمح للـ view بمعرفة أي خاصية بالتحديد قد قرأها في جسمه، وبالتالي إعادة تقييم الـ body فقط عند تغيّر تلك الخاصية بالضبط.

في تجربتي العملية على تطبيق يعرض قائمة تحوي ألف عنصر، الانتقال من ObservableObject إلى @Observable قلّل عدد إعادة الرسم من ~1000 إلى 3 عند تعديل خاصية واحدة في نموذج مشترك. هذا ليس تحسيناً ورقياً، بل شعرت به مباشرة على iPad الأقدم.

الماكرو يعتمد على نظام الماكروهات الذي أدخلته Swift 5.9. عندما تكتب @Observable فوق فئة (class)، فإن المترجم يولّد تلقائياً بنية تخزين خاصة (backing storage) وسجلاً للـ observers، ويلتزم بالبروتوكول Observable. لا تحتاج لكتابة أي شيء بيدك: لا @Published، ولا objectWillChange، ولا مطابقة يدوية للبروتوكول. لمزيد من التفاصيل التقنية، راجع توثيق Apple الرسمي لإطار Observation.

ما الفرق بين @Observable وObservableObject؟

الفرق ليس تجميلياً، بل معماري. ObservableObject يعتمد على Combine ويرسل إشعار objectWillChange واحداً لكل تغيير، يتلقّاه كل view يشترك في الكائن. هذا يعني أن view يعرض user.name فقط سيُعاد تقييمه عندما تتغيّر user.age. أمّا @Observable، فيسجّل داخل كل view الخصائص التي قرأها بالفعل، ولا يوقظه إلا تغيّر إحدى تلك الخصائص.

الميزةObservableObject@Observable
الحد الأدنى المدعومiOS 13iOS 17 (macOS 14, watchOS 10, visionOS 1)
يعتمد علىCombineماكروهات Swift + بنية تتبّع منخفضة المستوى
يتطلّب @Publishedنعملا
حبيبية التتبّعمستوى الكائنمستوى الخاصية
الـ property wrapper في الـ view@StateObject / @ObservedObject@State فقط (أو تمرير مباشر)
الربط ثنائي الاتجاه@ObservedObject@Bindable
التوافق مع SwiftDataمحدودكامل (SwiftData مبني عليه)
الأداء في القوائم الطويلةيعيد رسم كل الصفوفيعيد رسم الصفوف المتأثّرة فقط

الفارق العملي الثاني الذي كثيراً ما يُهمل: التداخل. مع ObservableObject، إذا احتوى نموذجك على نموذج آخر من نفس النوع، فإن تغييرات النموذج الداخلي لا تنتشر تلقائياً، وكنت مضطراً لإعادة إرسال objectWillChange يدوياً. مع @Observable، يعمل التداخل بشفافية كاملة، وهذا وحده يكفي لتبرير الترحيل في المشاريع متوسّطة الحجم.

دليل الترحيل خطوة بخطوة

سأشرح الترحيل بمثال عملي كامل. لنفترض أن لديك نموذج UserProfileViewModel بالطريقة القديمة:

// قبل: النمط القديم مع ObservableObject
import SwiftUI
import Combine

final class UserProfileViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var email: String = ""
    @Published var isLoading: Bool = false

    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }
        let user = await service.fetchCurrentUser()
        name = user.name
        email = user.email
    }
}

struct ProfileView: View {
    @StateObject private var viewModel: UserProfileViewModel

    init(service: UserService) {
        _viewModel = StateObject(wrappedValue: UserProfileViewModel(service: service))
    }

    var body: some View {
        Form {
            TextField("الاسم", text: $viewModel.name)
            TextField("البريد", text: $viewModel.email)
            if viewModel.isLoading { ProgressView() }
        }
        .task { await viewModel.load() }
    }
}

الآن نفس الشيء بعد الترحيل إلى @Observable:

// بعد: النمط الحديث مع @Observable
import SwiftUI
import Observation

@Observable
final class UserProfileViewModel {
    var name: String = ""
    var email: String = ""
    var isLoading: Bool = false

    @ObservationIgnored
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }
        let user = await service.fetchCurrentUser()
        name = user.name
        email = user.email
    }
}

struct ProfileView: View {
    @State private var viewModel: UserProfileViewModel

    init(service: UserService) {
        _viewModel = State(initialValue: UserProfileViewModel(service: service))
    }

    var body: some View {
        Form {
            TextField("الاسم", text: $viewModel.name)
            TextField("البريد", text: $viewModel.email)
            if viewModel.isLoading { ProgressView() }
        }
        .task { await viewModel.load() }
    }
}

خمس تغييرات جوهرية لاحظها: (1) حذفنا مطابقة ObservableObject؛ (2) حذفنا كل @Published؛ (3) وضعنا @ObservationIgnored على التبعية المحقونة service؛ (4) استبدلنا @StateObject بـ @State؛ (5) استخدمنا $viewModel.name مباشرة، لأن @State لكائن @Observable تعرض bindings تلقائياً.

كيف تستخدم @Bindable و@State بشكل صحيح؟

هذا الجزء الذي يُربك المطوّرين الجدد على النظام. القاعدة البسيطة: @State يستخدمه الـ view الذي يملك النموذج ويحدّد عمره، أما @Bindable فيستخدمه أي view يستلم النموذج ويحتاج bindings ثنائية الاتجاه له.

// الأب: يملك النموذج
struct ProfileView: View {
    @State private var viewModel = UserProfileViewModel(service: .live)

    var body: some View {
        NavigationStack {
            NameEditorView(viewModel: viewModel)
        }
    }
}

// الابن: يستلم النموذج ويحتاج bindings
struct NameEditorView: View {
    @Bindable var viewModel: UserProfileViewModel

    var body: some View {
        Form {
            TextField("الاسم الأول", text: $viewModel.name)
            TextField("البريد الإلكتروني", text: $viewModel.email)
        }
    }
}

الخطأ الأكثر شيوعاً الذي أراه في مراجعات الشيفرة: مطوّرون يستخدمون @State في الـ child view أيضاً. النتيجة كارثية؛ في كل مرة يُعاد فيها تقييم الأب، يُنشأ instance جديد من الـ ViewModel في الابن، ويضيع كل ما كتبه المستخدم. @State يجب أن يكون في مكان واحد: عند مالك النموذج الحقيقي.

هناك حالة استثنائية. إذا كان الابن يقرأ من النموذج فقط دون كتابة، لست بحاجة إلى @Bindable أبداً؛ يكفي تمرير النموذج كخاصية عادية let viewModel: UserProfileViewModel. الـ observation يعمل تلقائياً لأي مرجع لكائن @Observable.

ترحيل @EnvironmentObject إلى @Environment

مع @Observable، لم تعد بحاجة إلى property wrapper مخصّص للكائنات المُحقونة عبر البيئة. تستخدم @Environment نفسه (نفس الذي تستخدمه لقيم مثل colorScheme) مع نوع الكائن مباشرة:

@Observable
final class AppSession {
    var currentUser: User?
    var theme: Theme = .system
}

// الحقن في الجذر
@main
struct MyApp: App {
    @State private var session = AppSession()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(session)
        }
    }
}

// القراءة من أي مكان في الشجرة
struct HeaderView: View {
    @Environment(AppSession.self) private var session

    var body: some View {
        Text(session.currentUser?.name ?? "زائر")
    }
}

// إذا احتجت bindings من قيمة البيئة
struct SettingsView: View {
    @Environment(AppSession.self) private var session

    var body: some View {
        @Bindable var session = session
        Picker("المظهر", selection: $session.theme) {
            Text("النظام").tag(Theme.system)
            Text("فاتح").tag(Theme.light)
            Text("داكن").tag(Theme.dark)
        }
    }
}

لاحظ الحيلة الأخيرة. عند الحاجة إلى binding من قيمة بيئة، تعيد تعريفها محلياً بـ @Bindable var session = session. هذا التركيب الغريب هو الطريقة الرسمية المعتمدة، وسيعمل بدون overhead لأنه مجرد wrapper يعرض bindings دون إنشاء نسخة جديدة.

الترحيل هنا يوفّر ميزة عملية كبيرة. إذا نسيت حقن كائن @EnvironmentObject، كان تطبيقك ينهار مع runtime crash. مع @Environment(Type.self)، يمكن أن تكون القيمة اختيارية إذا صرّحت بها كذلك، وتحصل على تحذيرات ترجمة أوضح.

متى تحتاج @ObservationIgnored؟

@ObservationIgnored يخبر الماكرو: "لا تنشئ بنية تتبّع لهذه الخاصية". استخدمها في ثلاث حالات:

  • التبعيات المحقونة: كائنات الخدمات، الـ repositories، الـ networking clients. لا تتغيّر أثناء عمر النموذج، ولا يهمّ SwiftUI مراقبتها.
  • القيم المشتقّة (cached): إذا كنت تخزّن قيمة محسوبة يدوياً كذاكرة مؤقتة، ضع @ObservationIgnored عليها لتجنّب حلقات لا نهائية.
  • الـ Tasks والـ Timers: مراجع Task أو Timer يجب أن تكون خارج نطاق التتبّع دائماً.
@Observable
final class TimerViewModel {
    var secondsElapsed: Int = 0
    var isRunning: Bool = false

    @ObservationIgnored
    private var task: Task<Void, Never>?

    @ObservationIgnored
    private let clock: any Clock<Duration>

    init(clock: any Clock<Duration> = ContinuousClock()) {
        self.clock = clock
    }

    func start() {
        isRunning = true
        task = Task { [weak self] in
            while !Task.isCancelled {
                try? await self?.clock.sleep(for: .seconds(1))
                await MainActor.run { self?.secondsElapsed += 1 }
            }
        }
    }
}

القاعدة الذهنية: إذا كانت الخاصية حالة يعرضها الـ UI، اتركها بدون علامة. إذا كانت أداة تستخدمها منطقة النموذج داخلياً، ضع @ObservationIgnored. لمزيد من الأنماط الدقيقة في إدارة الحالة داخل المهام غير المتزامنة، راجع دليلنا حول البرمجة المتزامنة السهلة في Swift 6.2.

أخطاء الترحيل الشائعة وكيفية تجنّبها

1. إعادة إنشاء ViewModel عن طريق الخطأ

هذا الخطأ الأكثر شيوعاً وإرباكاً. الشيفرة التالية خاطئة:

// خطأ شائع
struct ParentView: View {
    let userId: UUID
    var body: some View {
        let vm = UserProfileViewModel(id: userId)  // ينشأ في كل تقييم!
        return ChildView(viewModel: vm)
    }
}

في كل مرة يُعاد فيها تقييم body (وهذا يحدث كثيراً في SwiftUI)، تُنشأ نسخة جديدة. الحل: استخدم @State مع مُهيّئ صريح:

struct ParentView: View {
    @State private var viewModel: UserProfileViewModel

    init(userId: UUID) {
        _viewModel = State(initialValue: UserProfileViewModel(id: userId))
    }

    var body: some View {
        ChildView(viewModel: viewModel)
    }
}

2. الحلقات اللانهائية بسبب computed properties

إذا كان لديك computed يقرأ خاصية ويكتب إليها في نفس الطلب، ستحصل على إعادة رسم لا نهائية. اجعل computed properties تعتمد فقط على خصائص مخزّنة، ولا تُغيّر أي حالة داخلها.

3. خلط ObservableObject مع @Observable في نفس الشاشة

ممكن تقنياً لكنه غير مستحسن أبداً، إذ سيصبح تدفّق البيانات صعب التتبّع. أوصي بترحيل شاشة كاملة دفعة واحدة بدل الخلط في نفس الـ view hierarchy.

4. نسيان @ObservationIgnored على الـ subscribers

إذا كنت تحتفظ بـ Set<AnyCancellable> بجانب @Observable class للتوافق مع Combine، ضع @ObservationIgnored عليها، وإلا فقد يحاول SwiftUI مراقبتها ويسبّب سلوكاً غريباً.

ملاحظات عبر منصات Apple: iPad وMac Catalyst وvisionOS

عملت على ترحيل تطبيقات تعمل على iPhone وiPad وMac Catalyst وvisionOS، وهنا الفروق الدقيقة التي يجب أن تعرفها:

iPad مع Multiple Scenes: عند استخدام SceneStorage أو مشاهد متعدّدة، تأكّد أن كل scene ينشئ نسخته الخاصة من الـ ViewModel عبر @State. إذا شاركت النموذج عبر مشاهد بالغلط، ستتصادم التحديثات. الحل النظيف هو حقن الحالة العالمية عبر @Environment والاحتفاظ بالحالة المحلية لكل scene داخل @State.

Mac Catalyst: يعمل @Observable بشكل مطابق تقريباً لـ iPad. الفرق الوحيد الذي واجهته: على macOS 15 وما قبل، بعض تفاعلات AppKit-bridged (مثل NSTextField المضمّن) لا تلتقط تحديثات @Observable بنفس سرعة SwiftUI الأصلي. الحل: تجنّب bridging غير الضروري.

visionOS: هنا ظهرت مفاجأة إيجابية. التحسينات في الأداء أكثر وضوحاً على visionOS منها على iPhone. السبب هو أن ImmersiveSpace وWindowGroup في visionOS يعيدان تقييم views أكثر بسبب تتبّع العين، فالحدّ من إعادة الرسم يوفّر moment-to-moment latency ملموس. إذا كنت تبني تجربة spatial، الترحيل إلى @Observable ليس اختياراً بل ضرورة أداء.

watchOS: لا فروق جوهرية، لكن يجب أن تكون أكثر صرامة في تجنّب حسابات متكرّرة داخل computed properties، لأن الوقت المخصّص لإعادة التقييم شحيح.

لدمج @Observable مع أنماط التنقّل الحديثة، راجع دليلنا حول NavigationStack وNavigationPath في iOS 26، حيث يعمل الاثنان بتناغم كامل. وعند البناء فوق SwiftData (وهو مبني أصلاً على Observation)، لن تحتاج لأي عمل إضافي: كل @Model هو أيضاً @Observable ضمنياً. راجع دليل SwiftData الشامل للمزيد.

استخدام withObservationTracking خارج SwiftUI

ميزة قليلاً ما تُذكر: يمكنك استخدام إطار Observation خارج SwiftUI تماماً. الدالة withObservationTracking تسمح بمراقبة تغييرات خصائص كائنات @Observable في أي سياق:

import Observation

func observeUserChanges(_ user: UserProfileViewModel) {
    withObservationTracking {
        print("الاسم الحالي: \(user.name)")
    } onChange: {
        Task { @MainActor in
            observeUserChanges(user)  // إعادة الاشتراك
        }
    }
}

هذا مفيد بشكل خاص في كتابة عملاء AppKit/UIKit التي تريد الاستفادة من نموذج @Observable دون الانتقال الكامل إلى SwiftUI. راجع مقترح Swift Evolution SE-0395 للتفاصيل الكاملة عن دلالات الإطار.

الأسئلة الشائعة

هل يعمل @Observable على iOS 16 وما دون؟

لا، الحد الأدنى الرسمي هو iOS 17 وmacOS 14 وwatchOS 10 وtvOS 17 وvisionOS 1. لدعم إصدارات أقدم يمكنك استخدام حزمة Perception من Apple، التي تقلّد نفس الواجهة عبر بروتوكولات، أو الإبقاء على مسار ObservableObject خلف #available.

هل يجب علي ترحيل مشروعي بالكامل دفعة واحدة؟

لا. الترحيل التدريجي هو النهج الموصى به. رحّل شاشة واحدة كاملة في كل مرة، وتجنّب خلط النمطين في نفس الـ view hierarchy. المشاريع الكبيرة تنتهي عادة بمرحلة انتقالية تدوم أسابيع، وهذا طبيعي.

لماذا لا يتحدّث الـ view رغم أن الخاصية تغيّرت؟

السبب الأشيع هو أن الـ view لم يقرأ الخاصية في body مباشرة، بل قرأها من computed property. تأكّد أن الوصول للخاصية يحدث ضمن نطاق body. سبب آخر: وضعت @ObservationIgnored بالخطأ على الخاصية.

هل @Observable يعمل مع async/await وactor isolation؟

نعم بشكل كامل. يمكنك تعليم الفئة بـ @MainActor إذا أردت ضمان أن كل تعديلات الحالة تحدث على الـ main actor، وهذا هو النمط الأنسب لنماذج SwiftUI. في Swift 6، هذا يصبح شبه إلزامي لتجنّب تحذيرات data race.

هل يمكن استخدام @Observable مع structs؟

لا. الماكرو يعمل مع class فقط، لأن آلية التتبّع تتطلّب هوية مرجعية (reference identity). للـ structs، استخدم @State مباشرة؛ لا تحتاج Observation أصلاً.

Hiroshi Sato
عن الكاتب Hiroshi Sato

Apple Platforms specialist building for iOS, macOS, visionOS, and the occasional watchOS app nobody asked for.