ما كلّفه فعلًا تغليف 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
نفسها — وسأصل إليها، فهي أطرف ما في المستودع.
علامة واحدة لكل وسمٍ منشور
١٥٦ يومًا
v0.1.0 — أول وسم، يوم فتح المستودع. والعلامات القصيرة إصدارات 1.1 التجريبية.
وما يلي هو مجموعة القرارات التي كنتُ أودّ لو قرأتُ عنها قبل أن أبدأ.
اربط 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>،
تتشاركه أحداثُ المشغّل،
وقيودُ السجل، ونداءاتُ الحوارات، واكتشافُ العارضات، ونيّةُ التشغيل.
والجدير بالتدوين سطرٌ واحد من تنفيذه: تلتقط broadcast المشتركين المطابقين تحت
القفل، ثم تُسلّم القيمة خارجه.
وليس هذا خيارًا يتعلق بالأداء. فالتسليم إلى AsyncStream يستأنف مهمّةَ مستهلك،
وهو يحوز قفل سجلّ حالة تلك المهمّة. وإلغاءٌ متزامنٌ للمهمّة نفسها يكون حائزًا
ذلك القفل أصلًا، فيستدعي onTermination، الذي يستدعي unsubscribe، الذي يطلب
قفل المذيع. فإن سلّمتَ وأنت حائزٌ للقفل حصل لديك استعصاء AB-BA: قفلان، وخيطان،
وترتيبان متعاكسان. وهو من نوع الاستعصاء الذي لا يظهر في الاختبارات أبدًا، ويظهر
فورًا بين يدي من يسحب شريط بثٍّ حيّ في القطار.
ويحمل النوع نفسه تمييزًا يبدو إفراطًا في الهندسة إلى أن يتوقف عن كونه كذلك.
فـfinishAll() تُغلق المشتركين الحاليين وتسمح بالاشتراك مجددًا. أما
terminate() فتُغلقهم وتجعل كل استدعاء subscribe لاحق يعيد تدفقًا منتهيًا
فورًا. وكلاهما موجود لأنّ بعض المذيعين يُبلَغ إليهم عبر خاصية محسوبة —
فـhandler.dialogs تبني اشتراكًا جديدًا مع كل وصول. وحين يكون المنتِج خلفها قد
زال نهائيًا، يجب أن ينال المشترك المتأخّر تدفقًا منتهيًا لا تدفقًا حيًّا لن
يُطعمه أحد أبدًا. وبدون terminate() لا يكون العطب انهيارًا، بل await لا يعود
أبدًا، وهذا أسوأ بكثير في التشخيص.
ترتيب deinit جزءٌ من الواجهة
تفعل Player.deinit أربعة أشياء بترتيبٍ غير قابل للتفاوض:
- إلغاء مهمّة استهلاك الأحداث
EventBridge.invalidate()— فصل مستمعي C، وإنهاء الاستمرارات، وتحرير المخزن المحتجَزlibvlc_media_player_stop_async()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_nsobjectNSView الخاص بـ VLCPIPViewController
إطار PIP.framework الخاص · معطّل افتراضيًا
ولذلك يُشحن معطّلًا افتراضيًا، خلف تفعيلٍ صريح allowsPrivateMacOSAPI، وكل
إشارة إلى رمزٍ خاص في المكتبة تعيش في ملفٍ واحد بالضبط. لا لأنّ ذلك يجعله آمنًا
لمتجر التطبيقات — فهو ليس كذلك، وهذا هو سبب وجود التفعيل الصريح — بل لأنّ
مدقّقًا يسأل «أيّ واجهاتٍ خاصة تمسّها هذه المكتبة؟» ينبغي أن يقرأ ملفًا واحدًا
وينتهي، لا أن يثق ببحثٍ نصّي عبر مئة ملف.
ويظهر مبدأ الصدق نفسه في موضعٍ أصغر: يُبلِغ الواجهةُ الخلفية الأصلية على iOS عمدًا بأنّ «صورة داخل صورة» غير متاحة في المحاكي. فمحاكي AVKit قد يُبلغ بسرورٍ عن متحكّم PiP عامل بينما تبقى نافذة النظام سوداء. واختبارٌ ينجح في وجه ذلك ليس اختبارًا ناجحًا، بل كذبةٌ بعلامة خضراء، ولذلك يجب اختبار المسار كاملًا على جهازٍ فعلي.
خمسٌ وعشرون رقعة
الأمر الذي لا يخبرك به أحد عن البناء فوق محرّك C كبير: تكفّ في لحظةٍ ما عن قراءة ترويساته وتبدأ بقراءة شيفرته، وبعد ذلك بقليل تبدأ بتغييرها.
يضمّ scripts/patches/
خمسًا وعشرين رقعة مرتّبة تُطبَّق على مراجعة VLC مثبّتة
عند البناء، مع بيان تحقّق بالبصمات.
- ٢٥
- رقعة
- ٤٬٦٠٦
- سطرًا مغيَّرًا
- ٥٦
- ملفًا من VLC
- خرج الفيديو و«صورة داخل صورة»
- واجهة libVLC بلغة C
- نواة الدخل والمشغّل
- فاكّات التغليف — MP4، TS، HLS
- بنية البناء والاختبار
- Chromecast والبثّ الخارج
- اكتشاف UPnP
- avcodec
عدد الرقع التي تمسّ هذه المنطقة
المصدر، من ترويسة كل رقعة
- ٩تعيد إنتاج إيداعٍ رسميّ بلا تغيير
- ١٦كُتبت لأجل SwiftVLC
بعضها نقلٌ لإصلاحاتٍ رسمية وصلت بعد التثبيت. الرقعة 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 كبيرة، فالنصيحة الصادقة أن ترصد ميزانيةً لشيفرتها لا لترويساتها. فسطح الواجهة عطلةُ نهاية أسبوعٍ لكل وحدة، ويكتب نفسه في أغلبه. أما ما يكلّف شهورًا فهو ذلك الجزء الذي تكون فيه افتراضاتُ المحرّك وضماناتُ لغتك صحيحةً معًا ومتنافيةً معًا، ولا يمكن تغيير إلا أحدهما بتحرير مستودعك أنت.