تخطَّ إلى المحتوى
القائمة
→ كل المقالات
مقالة12 دقيقة قراءة

ما كلّفه فعلًا تغليف libVLC

بدأ SwiftVLC مشغّلَ فيديو لتطبيق IPTV وانتهى بخمسٍ وعشرين رقعة على شيفرة VLC نفسها. ملاحظات في الربط مع C، وعزل Swift 6، والمواضع التي يكفّ فيها الغلاف عن كونه غلافًا.

  • برمجيات
  • هندسة

«تلفاز» تطبيق IPTV. و‏IPTV تعني ما يقرّر المزوّد أن يرسله لك: MPEG-TS فوق UDP، وبيانات HLS تتقادم بين مقطعٍ وآخر، وحاويات MKV، وترجمات SSA تحمل تنسيقها معها، وصوتٌ بترميزاتٍ لم تشحن آبل لها فاكَّ ترميزٍ قط. إطار AVFoundation ممتاز في الصيغ التي تشحنها آبل. لكنّ تلك المجموعة وهذه تتقاطعان في نصف الحالات تقريبًا، والنصف الذي يسقط ليس نصفًا يمكنك الاعتذار عنه.

كان لا بدّ إذًا أن يكون المحرّك libVLC — نواة التشغيل لدى VideoLAN، المعروضة كواجهة C، وهي نفسها ما يعمل عليه VLC. والغلاف المعروف لمنصّات آبل هو VLCKit: Objective-C، ومفوَّضات، و‏KVO، و‏NSNotificationCenter، وإدارة خيوطٍ يدوية. لا شيء من ذلك عملٌ رديء؛ بل هو أثرٌ أمينٌ للعقد الذي كُتب فيه. غير أنّ إدخاله في تطبيق SwiftUI تحت التزامن الصارم يعني كتابة مُكيِّفٍ فوق مُكيِّف، وطبقةُ المُكيِّف هي حيث تسكن العلل.

ولم تكن محاولتي الأولى غلافًا أصلًا. ففي كانون الأول 2025 نشرتُ harflabs/VLC، حزمةً سويفتية مهمّتها كلّها أن تجعل ثنائيّ libVLC المبنيّ مسبقًا قابلًا للاستحضار عبر SPM: اشحن الـ xcframework، ثبّت إصدارًا، وامضِ. وهي مؤرشفةٌ اليوم، لأنّ التحزيم تبيّن أنه النصف السهل. بدأ SwiftVLC في شباط، وبلغ الإصدار 1.0 في تموز، وهو الآن 101 ملف مصدري، و170 ملف اختبار، وخمسٌ وعشرون رقعة على شيفرة VLC نفسها — وسأصل إليها، فهي أطرف ما في المستودع.

علامة واحدة لكل وسمٍ منشور

١٥٦ يومًا

٦ أوسمة في ٧ أيام1.0 · اليوم ١٣٦آذارنيسانأيارحزيرانتموزآب

v0.1.0 — أول وسم، يوم فتح المستودع. والعلامات القصيرة إصدارات 1.1 التجريبية.

كل وسمٍ منشور، موضوعًا على التقويم الحقيقي: عشرون وسمًا في 156 يومًا، وصل الإصدار 1.0 في اليوم 136، وستةٌ منها داخل أسبوع واحد في نيسان. قُرئت من واجهة إصدارات GitHub في 3 آب 2026.

وما يلي هو مجموعة القرارات التي كنتُ أودّ لو قرأتُ عنها قبل أن أبدأ.

اربط C مباشرة، واقبل الفاتورة

كان القرار الأول أن أنتقل من C إلى Swift بلا Objective-C في المنتصف. وهذا يشتري أشياء حقيقية: تصير الإخفاقات throws(VLCError) مصنّفةً وحاصرة، بدل رموز NSError تطابقها يدويًا. وتصير حالة المشغّل @Observable، فيتحدّث SwiftUI بلا جسر KVO. وتصير الأحداث AsyncStream<PlayerEvent> بمستهلكين مستقلّين متعدّدين بدل أسماء إشعارات. ويصير العرض سطرًا واحدًا:

struct PlayerView: View {
  @State private var player = Player()

  var body: some View {
    VideoView(player)
      .onAppear { try? player.play(url: streamURL) }
  }
}

يسلّم VideoView إلى libVLC كائن NSView/UIView عبر set_nsobject فيرسم VLC فيه مباشرة. لا إعداد CALayer، ولا MTKView، ولا AVPlayerLayer.

أما الفاتورة فهي أنك صرتَ تملك كل قواعد أعمار C بنفسك، بلا طبقةٍ على هيئة ARC تختبئ خلفها. كل كائن في libVLC يتبع النمط ذاته — init يخصّص، وdeinit يحرّر، وعمر كائن Swift يملك عمر مؤشّر C — وهذا الجزء آليّ. أما الإخفاقات المثيرة فتقع كلّها عند المفاصل.

مشكلة المؤشّرات التي يسلّمها لك Swift 6

ليس OpaquePointer ولا UnsafeMutableRawPointer من نوع Sendable في ظلّ العزل المناطقي. وهذا صوابٌ من المترجم ومزعجٌ على الدوام، لأنّ تحرير كائن C خارج الخيط الرئيسي يعني التقاط مؤشّر داخل مُغلَّفٍ @Sendable. ويسمح SwiftVLC بطريقتين اثنتين لا ثالث لهما.

للالتقاط داخل مُغلَّفٍ واحد، ربطٌ محليّ ينسحب من الفحص:

nonisolated(unsafe) let p = pointer
DispatchQueue.global(qos: .utility).async {
  libvlc_media_player_release(p)
}

فالمؤشّر قابلٌ للنقل بداهةً ويبقى صالحًا طوال النطاق المحيط، وهذه هي الحجّة كلّها. وللمؤشّرات التي يجب أن تُقرأ وتُكتب من خيوطٍ عدّة عبر الزمن، Mutex حالتُه معلَّمةٌ صراحةً بأنها غير مفحوصة:

private struct State: @unchecked Sendable {
  var selfBox: UnsafeMutableRawPointer?
}
private let state = Mutex(State())

فدلالات sending في Mutex تريد الحالة قابلةً للإرسال إلى المستدعى، و@unchecked تفي بذلك بينما يتولّى القفل الإقصاءَ الفعلي.

وثمّة طريقة ثالثة، وهي محظورة في هذه الشيفرة: تهريب المؤشّر عبر Int(bitPattern:) والعودة به. إنها تُترجَم، وتُسكِت التحذير، وتُتلف الشيئين اللذين جعلا التحذير جديرًا بالوجود: النوع والقصد. وبعد ستة أشهر لن يستطيع قارئ تلك الدالّة أن يميّز مؤشّرًا حيًّا من عددٍ صحيح، ويكون المترجم قد أُقنع بالكفّ عن المساعدة. والقاعدة تستحق التنصيص عليها في وثيقة البنية تحديدًا لأنّ الاختصار سهل المنال في الواحدة بعد منتصف الليل.

الأحداث، والاستعصاء القابع في قاعها

تعبر الأحداث ثلاث طبقات: نداءات C التي تُطلَق على خيوط libVLC نفسها، ثم مذيعٌ متعدّد المستهلكين، ثم خصائص @Observable على المُمثِّل الرئيسي. والطبقة الوسطى نوعٌ عامّ صغير، Broadcaster<Element: Sendable>، تتشاركه أحداثُ المشغّل، وقيودُ السجل، ونداءاتُ الحوارات، واكتشافُ العارضات، ونيّةُ التشغيل.

كيف يصل حدث من libVLC إلى واجهة SwiftUIنداء C على أحد خيوط libVLC يُحوَّل إلى حدث سويفتي مصنّف، ثم يُسلَّم إلى مذيع متعدّد المستهلكين يعمل على أي خيط. وينال كل مشترك تدفّقه الخاص؛ ومستهلك المشغّل يعمل على المُمثِّل الرئيسي ويحدّث الخصائص المرصودة التي تقرأها SwiftUI.خيوط libVLC نفسهاplayerEventCallbackmapEvent()أي خيط · SendableBroadcaster<PlayerEvent>التقاط المشتركين تحت القفل،وتسليم القيم بعد تحريرهالمستهلكون — تدفّق AsyncStream لكلٍّ منهممستهلك أحداث المشغّل@MainActorمراقب PiPController@MainActor‏for-await الخاص بكأي عزلخصائص ⁦@Observable⁩ ← SwiftUI
يعبر الحدث حدَّي عزلٍ قبل أن تراه SwiftUI. والحدّ الثاني هو موضع التصميم: يعمل المذيع على أيّ خيطٍ صادف أن يكون libVLC عليه، وينال كلُّ مشتركٍ تدفّقه الخاص لا تدفّقًا مشتركًا.

والجدير بالتدوين سطرٌ واحد من تنفيذه: تلتقط broadcast المشتركين المطابقين تحت القفل، ثم تُسلّم القيمة خارجه.

وليس هذا خيارًا يتعلق بالأداء. فالتسليم إلى AsyncStream يستأنف مهمّةَ مستهلك، وهو يحوز قفل سجلّ حالة تلك المهمّة. وإلغاءٌ متزامنٌ للمهمّة نفسها يكون حائزًا ذلك القفل أصلًا، فيستدعي onTermination، الذي يستدعي unsubscribe، الذي يطلب قفل المذيع. فإن سلّمتَ وأنت حائزٌ للقفل حصل لديك استعصاء AB-BA: قفلان، وخيطان، وترتيبان متعاكسان. وهو من نوع الاستعصاء الذي لا يظهر في الاختبارات أبدًا، ويظهر فورًا بين يدي من يسحب شريط بثٍّ حيّ في القطار.

دورة AB-BA بين قفل المذيع وقفل سجلّ حالة المهمّةالإذاعة تحوز قفل المذيع وتحتاج قفل سجلّ حالة المهمّة لاستئناف مستهلك، بينما الإلغاء المتزامن يحوز قفل السجل ويحتاج قفل المذيع لإلغاء الاشتراك. كلٌّ ينتظر القفل الذي يحوزه الآخر.broadcast(event)خيط libVLCإلغاء المهمّةأي خيطقفلالمذيعقفل سجلّحالة المهمّةيحوزيحوزالتسليم يستأنف المستهلك ← فيطلب‏onTermination ← unsubscribe ← فيطلب
قفلان، وخيطان، وترتيبان متعاكسان في الحيازة. ولا يفعل أيٌّ من الطرفين شيئًا غريبًا — أحدهما يوصّل حدثًا والآخر يلغي مهمّة — ولهذا بالضبط تسهل كتابة الدورة ويصعب رؤيتها.

ويحمل النوع نفسه تمييزًا يبدو إفراطًا في الهندسة إلى أن يتوقف عن كونه كذلك. فـfinishAll() تُغلق المشتركين الحاليين وتسمح بالاشتراك مجددًا. أما terminate() فتُغلقهم وتجعل كل استدعاء subscribe لاحق يعيد تدفقًا منتهيًا فورًا. وكلاهما موجود لأنّ بعض المذيعين يُبلَغ إليهم عبر خاصية محسوبة — فـhandler.dialogs تبني اشتراكًا جديدًا مع كل وصول. وحين يكون المنتِج خلفها قد زال نهائيًا، يجب أن ينال المشترك المتأخّر تدفقًا منتهيًا لا تدفقًا حيًّا لن يُطعمه أحد أبدًا. وبدون terminate() لا يكون العطب انهيارًا، بل await لا يعود أبدًا، وهذا أسوأ بكثير في التشخيص.

ترتيب deinit جزءٌ من الواجهة

تفعل Player.deinit أربعة أشياء بترتيبٍ غير قابل للتفاوض:

  1. إلغاء مهمّة استهلاك الأحداث
  2. EventBridge.invalidate() — فصل مستمعي C، وإنهاء الاستمرارات، وتحرير المخزن المحتجَز
  3. libvlc_media_player_stop_async()
  4. libvlc_media_player_release()

فصلُ المستمعين قبل تحرير المشغّل هو ما يمنع نداءً من الانطلاق إلى ذاكرةٍ محرَّرة أثناء الهدم. بدّل الخطوتين 2 و4 وسيظلّ كل شيء يُترجَم، وستظلّ الاختبارات تنجح، وستصل تقارير الانهيار بعد أسابيع من مستخدمين انقطعت شبكتهم في اللحظة الخطأ. وهذا أقلّ أنواع القرارات التصميمية بريقًا وأكثرها حملًا للثقل، ولذلك كُتب بدل أن يُترك لاستنتاج قارئ.

حيث يكفّ الغلاف عن كونه غلافًا

«صورة داخل صورة» هي الموضع الذي كفّ فيه التجريد عن أن يكون ملكي.

والحقيقة الحاكمة أنّ libVLC ينسخ مؤشّرات نداءات ذاكرة الفيديو لديك وسياقها المبهم حين يُفتح خرجُ فيديو. ومسحُ متغيّرات النداء على مشغّل الوسائط بعد ذلك لا يُبطل نسخةً يحوزها ذلك الخرج أصلًا. فالهدم البديهي — امسح النداءات ثم حرّر السياق — هو استخدامٌ بعد التحرير ينتظر خرجًا لم يفرغ منك بعد.

والعلاج يربط كل سياقٍ محتجَز بمؤشّر libvlc_media_player_t واحدٍ بعينه، فتستطيع المتحكّمات المتعاقبة على المقبض الأصلي نفسه أن تتسلّم ذرّيًّا، بينما لا يستطيع خرجٌ متداخل أن يمسّ أبعاد خرجٍ آخر ولا مجمّعه ولا حالة تنظيفه. والتقاعد يوقف عمل العرض الجديد فورًا، لكنّ السياق لا يُحرَّر إلا بعد أن يعود آخر تحريرٍ محسوبٍ لذلك المقبض وتنضب كل النداءات الجارية. ولا تُعامَل مهلةٌ زمنية ولا ملاحظةٌ عابرة لخرج الفيديو دليلًا على الأمان. وهذه الجملة هي السياسة كلّها: مع نداءات C، عبارة «الغالب أنه انتهى الآن» ليست عمرًا.

كما فرضت «صورة داخل صورة» القرار الذي أنا أقلّ ارتياحًا له وأكثر ثقةً بصوابه. على iOS تستخدم PiPVideoView مسار الرسم الأصلي الخاص بـ libVLC ومتحكّم النظام الذي يملكه؛ أما إنشاء PiPController مباشرةً فيثبّت نداءات ذاكرة فيديو عامّة ويقود AVSampleBufferDisplayLayer. وعلى macOS تقتطع مرآةُ عازل العيّنات العامّة عند حجم طبقةٍ 1:1 بدل أن تتحجّم داخل لوحة PiP — أي أنّ الصورة ببساطة خطأ على الشاشة. والمسار الذي يعمل إطارٌ خاص: تحميل PIPViewController من PIP.framework في زمن التشغيل، ونقل عرض الرسم الحقيقي لـ VLC إليه.

  • VideoViewكل المنصّات

    set_nsobjectNSView / UIView

    ‏VLC يرسم داخل عرضك

  • PiPVideoViewiOS

    وسيط الرسمخرج عازل العيّنات في VLCAVPictureInPictureController

    المتحكّم يملكه libVLC

  • PiPControllerمباشر، واجهة عامّة

    نداءات vmemCVPixelBufferCMSampleBufferAVSampleBufferDisplayLayer

    الطبقة تملكها SwiftVLC · BGRA ثمانيّ، نطاق قياسي

  • PiPVideoViewmacOS، بتفعيل صريح

    set_nsobject‏NSView الخاص بـ VLCPIPViewController

    إطار PIP.framework الخاص · معطّل افتراضيًا

أربعة مسارات للعرض. اقرأها بحثًا عن الملكية لا عن الصناديق: على iOS يملك libVLC متحكّم «صورة داخل صورة» ثم يعيده، وفي المسار المباشر تملك SwiftVLC الطبقة، أما المسار الوحيد الذي يعرض بشكل صحيح على macOS فيملكه إطارٌ خاص.

ولذلك يُشحن معطّلًا افتراضيًا، خلف تفعيلٍ صريح allowsPrivateMacOSAPI، وكل إشارة إلى رمزٍ خاص في المكتبة تعيش في ملفٍ واحد بالضبط. لا لأنّ ذلك يجعله آمنًا لمتجر التطبيقات — فهو ليس كذلك، وهذا هو سبب وجود التفعيل الصريح — بل لأنّ مدقّقًا يسأل «أيّ واجهاتٍ خاصة تمسّها هذه المكتبة؟» ينبغي أن يقرأ ملفًا واحدًا وينتهي، لا أن يثق ببحثٍ نصّي عبر مئة ملف.

ويظهر مبدأ الصدق نفسه في موضعٍ أصغر: يُبلِغ الواجهةُ الخلفية الأصلية على iOS عمدًا بأنّ «صورة داخل صورة» غير متاحة في المحاكي. فمحاكي AVKit قد يُبلغ بسرورٍ عن متحكّم PiP عامل بينما تبقى نافذة النظام سوداء. واختبارٌ ينجح في وجه ذلك ليس اختبارًا ناجحًا، بل كذبةٌ بعلامة خضراء، ولذلك يجب اختبار المسار كاملًا على جهازٍ فعلي.

خمسٌ وعشرون رقعة

الأمر الذي لا يخبرك به أحد عن البناء فوق محرّك C كبير: تكفّ في لحظةٍ ما عن قراءة ترويساته وتبدأ بقراءة شيفرته، وبعد ذلك بقليل تبدأ بتغييرها.

يضمّ scripts/patches/ خمسًا وعشرين رقعة مرتّبة تُطبَّق على مراجعة VLC مثبّتة عند البناء، مع بيان تحقّق بالبصمات.

٢٥
رقعة
٤٬٦٠٦
سطرًا مغيَّرًا
٥٦
ملفًا من VLC
  • خرج الفيديو و«صورة داخل صورة»٨
  • واجهة libVLC بلغة C٨
  • نواة الدخل والمشغّل٦
  • فاكّات التغليف — MP4، TS، HLS٤
  • بنية البناء والاختبار٣
  • Chromecast والبثّ الخارج٢
  • اكتشاف UPnP٢
  • avcodec١

عدد الرقع التي تمسّ هذه المنطقة

المصدر، من ترويسة كل رقعة

  • ٩تعيد إنتاج إيداعٍ رسميّ بلا تغيير
  • ١٦كُتبت لأجل SwiftVLC
أين تقع الرقع الخمس والعشرون، محسوبةً بتحليل كل فرقٍ لا بقراءة عنوانه — وتُحسب الرقعة مرةً لكل منطقة تعدّلها، ولذلك يتجاوز مجموع الأعمدة خمسًا وعشرين. أما المصدر فمن ترويسة كل رقعة: تسعٌ تعيد إنتاج إيداعٍ رسميّ بلا تغيير، والستّ عشرة الباقية كُتبت هنا. وهي تغيّر معًا 4606 أسطر في 56 ملفًا من VLC.

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

وبعضها أصيل. تصلح الرقعة 8 استخدامًا بعد التحرير حين يتعذّر تحضير قطعة مقطعٍ مشفَّر: تحرّر ISegment::toChunk() مصدرَ القطعة مرتين على مسار فشل prepareChunk()، مرةً صراحةً عبر recycleSource()، ومرةً حين تحذف القطعة، لأنّ ~AbstractChunk() تحرّر المصدر الذي تملكه القطعة. ولقطع المقاطع لا تكون إعادةُ التدوير قابلةً للتخزين فتمضي مباشرةً إلى delete، فيتوزّع التحرير الثاني عبر جدول دوالٍّ افتراضية فوق ذاكرة محرَّرة. وهي تتكرّر تحت AddressSanitizer، وموضع الانهيار يطابق بلاغًا قائمًا في VLC عن EXC_BAD_ACCESS متقطّع على iOS ‏arm64 بعد نحو عشر دقائق من تشغيل HLS.

وتأمّل شروط تلك العلّة: المقاطع المشفّرة وحدها، وفقط حين يتعذّر استحضار مفتاح AES-128. شبكةٌ متذبذبة، وجهازٌ حقيقي، وعشر دقائق. هذه العلّة ليست موجودة في حزمة اختباراتك، بل في تقييمات مستخدميك.

اشحن ما يستحضره مستخدموك فعلًا

قرارا بنيةٍ تحتية استحقّا كلفتهما.

الحالة المنشورة للمستودع — main وكل وسم إصدار — تحمل الصيغة البعيدة للاعتماد الثنائي، url: مع checksum:، فتكون الحالة الافتراضية للمستودع هي الحالة التي يستحضرها المستهلك. والتطوير المحلي يقلبها إلى مسارٍ على القرص عبر سكربت، ويعيد التكامل المستمر كتابتها إلى آخر xcframework منشور قبل تشغيل الاختبارات. فتجري الاختبارات إذًا على الثنائي نفسه الذي يناله غريبٌ من SPM، لا على الذي يرقد في مجلد Vendor/ عندي.

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

هل استحقّ ذلك؟

خمسة أشهر من أول إيداع إلى 1.0، لمكتبةٍ جزؤها المثير خمسة عشر ملفًا من مئةٍ ونيّف. وكل هذه الخمسة عشر تجلس على حدٍّ فاصل بين نماذج ملكيةٍ متخالفة: أعمارُ C اليدوية، وعزلُ Swift 6 في زمن الترجمة، وتوقّعاتُ AVKit غير الموثّقة حول من يحتجز من. وما تبقّى طباعة.

وإن كنت تفكّر في تغليف مكتبة C كبيرة، فالنصيحة الصادقة أن ترصد ميزانيةً لشيفرتها لا لترويساتها. فسطح الواجهة عطلةُ نهاية أسبوعٍ لكل وحدة، ويكتب نفسه في أغلبه. أما ما يكلّف شهورًا فهو ذلك الجزء الذي تكون فيه افتراضاتُ المحرّك وضماناتُ لغتك صحيحةً معًا ومتنافيةً معًا، ولا يمكن تغيير إلا أحدهما بتحرير مستودعك أنت.

أحدث المقالات

المواضيع

كل النتائج