ZaatarLABS
تواصل معنا ←
→ كل المقالات
The Smart Budget · هندسة

كيف تعمل مشاركة الأسرة دون الوثوق بخادم

حسابا iCloud، وميزانية مشتركة واحدة، ولا خادم خلفي أتحكم فيه. لم يستطع NSPersistentCloudKitContainer فعل ذلك، فبنيتُ محرك المزامنة بيدي. إليك ما يتطلبه ذلك فعلًا.

14 دقيقة قراءة

يتيح The Smart Budget للأسرة مشاركة ميزانية واحدة. شخصان، وحسابا iCloud منفصلان، وكلاهما يعدّل المعاملات والميزانيات نفسها في الوقت الفعلي. القيد الذي فرضته على نفسي: لا خادم أتحكم فيه في المنتصف. تتزامن بياناتك المالية مباشرةً عبر CloudKit من Apple، من حساب إلى حساب. لا أراها أبدًا، ولا يوجد خادم خلفي يمكن أن أسرّب منه شيئًا، أو يُطلب بأمر قضائي، أو يُخترق.

لدى Apple إطار عمل يبدو أنه يفعل هذا بالضبط — NSPersistentCloudKitContainer مع CKShare. قضيتُ ثلاثة أيام أحاول أن أجعله يعمل. ثم حذفته وكتبتُ محرك المزامنة بنفسي. هذه قصة السبب، وكيف يبدو البديل، مع الشيفرة الحقيقية.

النهج البديهي، والانهيار

المسار الذي توصي به Apple هو: أن تبني Core Data على NSPersistentCloudKitContainer (NSPCKC)، فيعكس مخزنك إلى قاعدة بيانات CloudKit خاصة تلقائيًا، ثم تستدعي share(...) لإنشاء CKShare للكائنات التي تريد مشاركتها.

وهو ينهار. ليس فورًا — وهذا هو الجزء القاسي. يعمل في العرض التوضيحي ويموت في الحالة الحقيقية الفوضوية، عندما يصل CKShare المخصص الخاص بك إلى قاعدة البيانات الخاصة نفسها التي ينشغل إطار العمل بعكسها. من التعليق الذي يقبع الآن حيث كان NSPCKC:

/// Apple's `NSPersistentCloudKitContainer` is gone. It crashed when our custom `CKShare`
/// landed in the same private DB it was mirroring (it tries to ingest the share as a
/// `cloudkit.share` Core Data entity that doesn't exist in the model). Replacing it with
/// our own bare-CloudKit pipeline removes the framework collision and gives us one path
/// for all sync, single-user and household.

يرى إطار العمل سجلًا من النوع cloudkit.share في قاعدة البيانات التي يعكسها، فيحاول استيعابه كما لو كان أحد كيانات Core Data الخاصة بك. لا يوجد كيان cloudkit.share في نموذجك، لأنه نوع نظامي في CloudKit. انفجار.

ولم تكن المشكلة الأعمق هذا الانهيار بعينه — بل أنني لم أملك أي طريقة لإصلاحه. من وثيقة خطة إعادة الكتابة:

أنتجت ثلاثة أيام من اختبار NSPersistentCloudKitContainer + CKShare لمشاركة الأسرة نمطًا متكررًا: كل إصلاح يكشف نمط فشل جديدًا (تعطّلات zone-not-found، وحلقات «Can't find metadata»، وتوقيتات متتالية تنتج تكرارات، وإعادات ضبط CKKS لحالة جزئية لا يمكن التعافي منها). العكس التلقائي في إطار العمل غامض — عندما يسوء الأمر لا نملك API لفحصه أو تصحيحه؛ الشيء الوحيد الذي ينجح هو «امسح كل شيء وابدأ من جديد».

هذا هو الاتهام الحقيقي. عندما يعمل الشيء التلقائي، يكون سحرًا. وعندما لا يعمل، لا يوجد مَنفذ تمدّ إليه يديك. لميزة يعني فيها «ابدأ من جديد» «احذف الميزانية المشتركة لعائلة»، هذا وحده كافٍ للاستبعاد. ملخّص وثيقة التصميم للمقايضة التي أجريتها: «مقايضة تعقيد مصارعة إطار العمل بتعقيد التحكم الصريح.» سأختار التحكم الصريح في كل مرة يتعلق فيها الأمر بأموال مستخدميّ.

البديل: وضعان، ومحرك واحد

استبدلتُ NSPCKC بمسار يعتمد على CloudKit مباشرةً: أنا أملك الدفع (push)، والسحب (pull)، وحل التعارضات، وترجمة السجلات. المزامنة الشخصية لحساب واحد ومشاركة الأسرة تعملان عبر المحرك نفسه — ولا تختلفان إلا في منطقة CloudKit (zone) وقاعدة البيانات التي تشيران إليها. النواة actor:

/// Custom CloudKit sync layer for ALL of the user's shareable data.
/// **Two modes, one engine:**
/// - **Personal mode** (default): syncs `Shared.sqlite` to a `personal-zone` in the
///   user's private CloudKit DB. No `CKShare`.
/// - **Household mode**: syncs to a `household-shared` zone in the owner's private DB,
///   with a `CKShare` attached.
/// **Conflict resolution:** last-writer-wins by `lastModified`.
actor HouseholdSync {
    static let shared = HouseholdSync()

حيلة «لا خادم مركزي» كلها تكمن في طريقة وصول المشارك إلى البيانات. هناك أربعة أوضاع، وكل وضع يختار منطقته وقاعدة بياناته:

enum Mode { case off; case personal; case householdOwner; case householdParticipant }

private var currentZoneID: CKRecordZone.ID? {
    switch currentMode {
    case .personal:
        return CKRecordZone.ID(zoneName: Self.personalZoneName, ownerName: CKCurrentUserDefaultName)
    case .householdOwner:
        return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: CKCurrentUserDefaultName)
    case .householdParticipant:
        let ownerName = UserDefaults.standard.string(forKey: Self.householdOwnerCKUserIDKey) ?? CKCurrentUserDefaultName
        return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: ownerName)
    default: return nil
    }
}
private var currentDatabase: CKDatabase? {
    switch currentMode {
    case .personal, .householdOwner: return container.privateCloudDatabase
    case .householdParticipant:      return container.sharedCloudDatabase
    default: return nil
    }
}

يكتب المالك في منطقة household-shared داخل قاعدة بياناته الخاصة هو. ويقرأ المشارك المنطقة نفسها ويكتب فيها عبر قاعدة بياناته المشتركة هو، معيدًا بناء (zoneName, ownerName) الخاصين بالمالك من معرّف مستخدم CloudKit للمالك الذي حُفظ عندما قبل الدعوة. قاعدتا بيانات خاصتان، تربطهما مشاركة واحدة. خوادم Apple تتولى المزامنة؛ ولا خادم لأحد يتولى المنطق.

أربع قطع تغذّي ذلك المحرك.

LocalChangeWatcher — تحويل عمليات الحفظ إلى عمليات دفع

مراقِب لـ NSManagedObjectContextDidSave. في كل مرة يحفظ فيها المخزن المشترك، يبني عمليات دفع للكائنات المُدرَجة/المحدَّثة/المحذوفة ويطلق عملية دفع. والجزء الدقيق الذي يحمل الثقل كله هو ما يتخطاه:

private func handle(_ notification: Notification) {
    guard SyncStatus.shared.isActive else { return }
    guard let sharedStore = controller.sharedStore else { return }
    // Skip saves emitted by HouseholdSync itself (pull's apply + push's metadata writeback)
    if let savingContext = notification.object as? NSManagedObjectContext,
       Self.skipContexts.contains(ObjectIdentifier(savingContext)) {
        return
    }
    for object in inserted.union(updated) {
        guard object.objectID.persistentStore == sharedStore else { continue }
        guard RecordTranslator.shareableEntityNames.contains(object.entity.name ?? "") else { continue }
        ops.append(.init(cloudKitRecordName: recordName, entityName: entityName,
                         kind: .upsert, objectIDURI: object.objectID.uriRepresentation().absoluteString))
    }
    Task {
        await PushQueue.shared.enqueue(ops)
        await HouseholdSync.shared.pushPendingChanges()
    }
}

مجموعة skipContexts هذه مفهرسة بـ ObjectIdentifier عن قصد — فقراءة context.transactionAuthor من طابور آخر قد تسبب قفلًا متبادلًا (deadlock) في Core Data. ومن دون هذا التخطي، سيؤدي تطبيق تغيير مسحوب إلى عملية حفظ بحد ذاته، فيُدفع مرة أخرى، فيسحبه الشريك ويعيد حفظه… وهذا يقودنا إلى أفضل خطأ برمجي في المشروع (أدناه).

PushQueue — طابور FIFO دائم يدمج نفسه

الشبكة تنقطع. والتطبيقات تُغلَق قسرًا. لذلك فإن طابور عمليات الدفع المعلّقة actor مدعوم بملف، يُكتب إلى القرص بشكل ذري مع كل تعديل، ويزيل التكرارات عند الإدخال:

func enqueue(_ ops: [Operation]) async {
    for op in ops {
        // URI-based dedupe FIRST so a stale optimistic-UUID op for the same
        // managed object gets dropped.
        if let uri = op.objectIDURI {
            queue.removeAll { $0.objectIDURI == uri || $0.cloudKitRecordName == op.cloudKitRecordName }
        } else {
            queue.removeAll { $0.cloudKitRecordName == op.cloudKitRecordName }
        }
        queue.append(op)
    }
    await persist()
}

الحمولة لا تُسلسَل — فعملية الدفع تعيد قراءة الحالة الحالية للكائن المُدار وقت الدفع. فإذا عدّلت معاملة خمس مرات في ثانيتين، تندمج كلها في عملية دفع واحدة للحالة النهائية دون أي جهد إضافي.

RecordTranslator — Core Data ⇄ CKRecord، في الاتجاهين

دوال نقية، بلا إدخال/إخراج، تعمل داخل context.perform الخاص بالمستدعي. كل كائن قابل للمشاركة يحمل cloudKitRecordName ثابتًا كي يتفق الجهازان على هوية واحدة لكل صف منطقي. في اتجاه الخروج، لا يرمّز سوى العلاقات من نوع to-one كمراجع، ويتخطى أي علاقة لم يُدفع هدفها بعد:

for (name, rel) in object.entity.relationshipsByName where !rel.isToMany {
    guard let target = object.value(forKey: name) as? NSManagedObject else { record[name] = nil; continue }
    guard let targetName = target.value(forKey: "cloudKitRecordName") as? String, !targetName.isEmpty else {
        continue   // target not pushed yet — next cycle fixes it
    }
    let targetID = CKRecord.ID(recordName: targetName, zoneID: zoneID)
    record[name] = CKRecord.Reference(recordID: targetID, action: .none)
}

وفي اتجاه العودة، هناك شرط حماية يبدو تدقيقًا مبالغًا فيه، وهو في الحقيقة جوهر المسألة كلها:

let target = resolveReference(ref, destinationEntity: rel.destinationEntity!.name!, in: context)
if target != nil {
    object.setValue(target, forKey: name)
}
// else: target not pulled to this context yet — leave as-is. Critically we do NOT
// overwrite to nil and create orphan rows that dedupBudgets would group under a
// single nil-category bucket, summing 9 different-category Budgets into one row.

إذا وصلت معاملة قبل الفئة التي تشير إليها، فيجب ألا تجعل العلاقة فارغة (nil). افعل ذلك، وستُنشئ صفوفًا يتيمة — سيدمجها تمرير إزالة التكرار (التالي) معًا بكل سرور. وهكذا بالضبط أنتجتُ ذات مرة صف ميزانية واحدًا بقيمة 6,800 AED من تسع فئات لا علاقة بينها.

الأجزاء الصعبة

HouseholdDedup — التقارب دون منسّق

عندما يكون هاتفان دون اتصال وينشئ كلاهما فئة «البقالة»، تحصل على تكرارات بمجرد أن يتزامنا. لا يوجد خادم ليحكم بينهما. الحل هو التقارب: كلا الجهازين يُجري التمرير الحتمي نفسه على البيانات نفسها، فينتج مجموعة الحذف نفسها، التي تنتشر بعد ذلك عبر مسار الدفع العادي. لا حاجة إلى تنسيق.

/// **Determinism:** both devices run dedup independently. As long as both pick the same
/// canonical for each group (we use lexicographically-smallest `cloudKitRecordName`,
/// preferring `isSystem` rows for Categories), they produce the same delete set →
/// converges to the same final state under LWW.

قبل حذف نسخة مكررة، يعيد توجيه كل علاقة إلى النسخة الباقية ويحدّث lastModified كي يفوز الدمج وفق قاعدة «آخر كاتب يفوز» على جهاز الشريك:

let canonical = pickCanonicalCategory(from: dupes)
for dupe in dupes where dupe.objectID != canonical.objectID {
    if let txns = dupe.transactions as? Set<Transaction> {
        for txn in txns { txn.category = canonical; txn.lastModified = now }
    }
    // budgets, splits, aliases, recurringSeries likewise…
    context.delete(dupe)
}

علّمتني حادثة الـ 6,800 AED أن أرتاب في مفتاح التجميع. نسخة مبكرة جمعت الميزانيات التي بلا فئة تحت النص الحرفي "nil" ودمجت تسع فئات مختلفة في صف واحد ضخم. الآن تتخطى الميزانيات التي بلا فئة تمامًا، والمترجم (أعلاه) يرفض إنشاء القيم الفارغة من الأساس. حارسان مستقلان لخطأ واحد، لأن هذا الخطأ يظهر كرقم خاطئ في ميزانية شخص ما، ولا يوجد خطأ أسوأ في تطبيق مالي من رقم خاطئ يُعرض بثقة.

HouseholdShareAcceptance — خطاف الدعوة الذي يخفيه SwiftUI عنك

عندما يضغط شريكك على رابط المشاركة، يحتاج iOS إلى تسليم تطبيقك CKShare.Metadata. يحدث ذلك عبر استدعاء من UIWindowSceneDelegate — وهو ما لا يملكه تطبيق SwiftUI خالص. لذلك توفّر واحدًا عبر UIApplicationDelegateAdaptor:

func windowScene(
    _ windowScene: UIWindowScene,
    userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
    let metadata = cloudKitShareMetadata
    Task {
        do { try await HouseholdSync.shared.acceptShare(metadata: metadata) }
        catch { /* … */ }
    }
}

المندوب نفسه يتعامل مع حالات الإيقاظ بالإشعارات الصامتة: CKNotification تم التحقق منه يطلق عملية سحب + دفع، و— وهذه تفصيلة توفّر عليك ساعات — يُبلغ دائمًا بـ .newData في النهاية. أبلِغ بـ .failed وسيبدأ iOS بتقييد إشعاراتك في الخلفية، وفجأة تتأخر المزامنة «الفورية» عشر دقائق.

HouseholdIdentity — أن تعرف من أنت، بشكل متزامن

نصف المنطق يحتاج إلى الإجابة عن «أيّ هذه السجلات لي؟» دون رحلة ذهاب وإياب عبر الشبكة. لذلك يُستخرَج معرّف مستخدم CloudKit للمستخدم الحالي مرة واحدة ويُخزَّن مؤقتًا في UserDefaults:

func refresh() async {
    do {
        let recordID = try await CKContainer(identifier: PersistenceController.cloudKitContainerID).userRecordID()
        currentUserID = recordID.recordName
        await runClaimSweepIfNeeded()
    } catch {
        Self.logger.info("Could not resolve CloudKit user ID (likely no iCloud account)…")
    }
}

ذلك المعرّف المخزَّن يُختم في ownerCloudKitUserID وloggedByMemberID على السجلات، وبه تعرف «مغادرة الأسرة» أيّ الصفوف يحتفظ بها كل شخص، وبه ينسب تقرير الإنفاق لكل فرد فنجان قهوة إلى الشريك الصحيح.

إنشاء المشاركة وقبولها

من جهة المالك: أنشئ المنطقة، ثم أنشئ CKShare واحدًا فوق المنطقة كلها (لا لكل سجل)، وبدّل دورك، وأعد ضبط رمز التغيير، وأعد إدراج كل شيء في الطابور كي يُصرَّف إلى المنطقة الجديدة، واحذف المنطقة الشخصية التي لم تعد صالحة:

_ = try await privateDB.save(CKRecordZone(zoneID: householdZoneID))
let share = CKShare(recordZoneID: householdZoneID)
share[CKShare.SystemFieldKey.title] = "The Smart Budget — Household" as CKRecordValue
share.publicPermission = .none
_ = try await privateDB.save(share)

setRole(.owner)
UserDefaults.standard.removeObject(forKey: Self.serverChangeTokenKey)
await PushQueue.shared.clear()
await reenqueueAllLocalRecords(controller: controller)
await deletePersonalZoneServerSide()

تعرض ذلك الـ CKShare المجرّد باستخدام UICloudSharingController الأصلي من Apple — لا تحتاج إلى NSPCKC لتشغيل واجهة الدعوة في النظام، وهو الأمر الذي يفترض الجميع أنه يربطك بإطار العمل. ومن جهة المشارك، اقبل واحفظ معرّف المالك:

_ = try await container.accept(metadata)
// Owner's CKUserID is encoded in the share's zoneID — persist it so currentZoneID
// can construct the right (zoneName, ownerName) tuple on subsequent pushes/pulls.
let ownerName = metadata.share.recordID.zoneID.ownerName
UserDefaults.standard.set(ownerName, forKey: Self.householdOwnerCKUserIDKey)
setRole(.participant)

حل التعارضات في كل ذلك يعتمد على «آخر كاتب يفوز» وفق طابع زمني lastModified — تفوز النسخة المحلية فقط إذا كانت أحدث بشكل صارم، وإلا تُطبَّق النسخة البعيدة. بسيط، ويتقارب.

قصتان من الميدان لا تزال الشيفرة تتذكرهما

عاصفة الدفع الجامحة إلى iCloud (أُصلحت في 29 يونيو 2026). كانت مجموعة skipContexts (من LocalChangeWatcher) تُفرَّغ على الطابور الخطأ بالنسبة إلى كتلة المراقِب. ظل التفريغ يخسر السباق، فكانت كل عملية إعادة كتابة للبيانات الوصفية تتسرّب إلى الخارج كعملية دفع جديدة — يسحبها الشريك ويدفعها مرة أخرى — حلقة مزامنة لا نهائية تقصف iCloud. كان الإصلاح سطرًا واحدًا يتعلق بموعد تفريغ مجموعة التخطي. حلقات التغذية الراجعة في شيفرة المزامنة لا تعلن عن نفسها؛ إنها فقط تذيب ميزانية المعدّل بهدوء.

حارس السحب القديم. تلتقط عملية الجلب لقطة للوضع + المنطقة الحاليين قبل أن تبدأ. فإذا بدّل acceptShare أو disconnect الوضع أثناء التنفيذ، تُتجاهَل نتائج الجلب بدل أن تُقرأ خطأً — لأن خطأ «zone gone» أثناء عملية جلب بدأتها في الوضع القديم ليس هو نفسه «أنهى شريكك المشاركة»، ومعاملته كأنه كذلك وضعت ذات مرة بيانات أحد الزوجين في المنطقة الخطأ.

هل سأستخرج هذا كـ SDK؟

هناك حزمة CloudKitHouseholdSync نظيفة مختبئة هنا — HouseholdSync، وPushQueue، وRecordTranslator، وLocalChangeWatcher، وإزالة التكرار، وخطاف المشهد. الناس يسألون باستمرار. جوابي الصادق: ليس بعد. فيها افتراضات خاصة بـ Smart Budget مدمجة في صلبها (مفتاح إزالة التكرار القائم على الأسماء بالأحرف الصغيرة، وترتيب اعتماديات الكيانات) سيتعيّن عليّ تعميمها بعناية، ولا أريد نشر مكتبة مزامنة قبل أن تثبت نفسها مع أسر حقيقية في بيئة الإنتاج. خطة الاستخراج مكتوبة. وستصدر بعد أن يستحق الشيء ذلك.

الخلاصة ليست «لا تستخدم NSPersistentCloudKitContainer أبدًا» — فلتطبيق بمستخدم واحد يريد مجرد نسخ احتياطي على iCloud، هو رائع، ويجب أن تستخدمه. بل هي: في اللحظة التي تمزج فيها CKShare مخصصًا في قاعدة بيانات خاصة يعكسها Core Data، تكون قد غادرت الطريق المعبّد، ولا يمنحك إطار العمل أي أدوات للنجاة خارجه. امتلاك المسار يعني شيفرة أكثر. لكنه أيضًا النسخة الوحيدة التي، عندما يسوء شيء ما في أموال عائلة مشتركة، أملك فيها مَنفذًا أمدّ يدي منه وأصلحه — بدل «امسح كل شيء وابدأ من جديد».

The Smart Budget متاح الآن على iPhone. مشاركة الأسرة ميزة في Pro؛ ومحرك المزامنة الشخصية تحتها هو الشيفرة نفسها، ويعمل للجميع. لا شيء يمر عبر خادم أتحكم فيه.

— Omar

بقلم Omar Al Homaidi · The Smart Budget · @zaatarlabs