مقالة19 دقيقة قراءة

ربط libVLC بـ‏Swift 6 مباشرةً

استغرق بناء SwiftVLC أربعة أشهر ونصفًا وخمسًا وعشرين رقعة على VLC. تجربة الربط المباشر مع C، من إدارة الذاكرة والأحداث إلى ميزة صورة داخل صورة التي ما زالت تحتاج إلى عمل.

عرض بصيغة Markdown

«تلفاز» تطبيق IPTV متاح في متجر التطبيقات على iPhone وiPad وMac وApple TV. في هذا النوع من التطبيقات، لا أختار صيغ الوسائط التي تصلني. قد يرسل المزوّد MPEG-TS عبر UDP، أو قوائم HLS تتغير بين المقاطع، أو ملفات MKV، أو ترجمات SSA بتنسيقها الخاص، أو صوتًا بترميز لا تدعمه آبل.

يعمل AVFoundation جيدًا مع الصيغ التي تدعمها آبل. لذلك لا يصح القول إنه لا يصلح لتطبيقات IPTV عمومًا؛ قد تبني به تطبيق HLS يعمل دون مشاكل. تبدأ الصعوبة عندما تحتاج إلى صيغ أخرى.

جرّبت حالة بسيطة: ملف MP4 يحتوي على فيديو H.264 وصوت AAC، أعدت تغليفه في Matroska باستخدام -c copy. تحققت من البصمات للتأكد من أن بيانات الصوت والفيديو لم تتغير. عمل ملف MP4، لكن ملف MKV أعاد الخطأ -11828، أي صيغة الوسيط هذه غير مدعومة. لم تصل المحاولة إلى فك ترميز المحتوى، لأن AVFoundation لا يقرأ حاوية Matroska.

  • فُكّ ترميزها
  • تُفتح ومسارات ناقصة
  • مرفوضة — ‏−11828
الملفّmacOS 26iOS 26
base.mp4H.264 · AAC
h264_aac.mkvsame streams, remuxed
vp9_opus.webmVP9 · Opus
h264_aac.flvH.264 · AAC
h264_ac3.mp4Dolby Digital
h264_aac.tsH.264 · AAC
h264_mp2.tsMPEG-1 Layer II
hevc_aac.tsHEVC · AACno video
h264_dts.tsH.264 · DTSno audio
h264_opus.tsH.264 · Opusno audio

كلّ نجاحٍ ناقصٍ يُبلغ أنّ isPlayable == true ولا يرفع خطأ. وHEVC داخل تيّار نقلٍ أمرٌ مألوف في IPTV.

نتائج عشرة ملفات باستخدام AVAssetReader لفحص العينات المفكوكة فعليًا، على macOS 26 ومحاكي iOS 26. لا يعتمد الفحص على isPlayable وحده.

يمكن التعامل مع الخطأ الصريح بإظهار رسالة مناسبة. الأصعب هو ما حدث في الصفوف الثلاثة الأخيرة: يفتح macOS فيديو HEVC داخل تيار نقل، ويعيد isPlayable == true دون خطأ، لكن كائن الوسائط الناتج لا يحتوي على مسار فيديو. هذه صيغة مألوفة في IPTV. يرى المستخدم شاشة سوداء ويسمع الصوت، ثم يبلغ عن عطل في التطبيق، وهو محق.

لاحقًا، احتجت إلى ملف واحد لاختبار تطبيقات العرض الخاصة بالمكتبة. أنشأت ملف Matroska مدته ستون ثانية، يحتوي على ثلاث نسخ للفيديو، وثلاثة مسارات صوت Opus، وثلاثة مسارات ترجمة منها العربية، وستة فصول مسمّاة، وصورة غلاف مرفقة. جمع الملف احتياجات التطبيق التي لم أستطع الوصول إليها عبر AVFoundation.

كنتُ أستعمل VLCKit أصلًا

اخترت libVLC، نواة تشغيل الوسائط من VideoLAN وواجهة C التي يستخدمها VLC نفسه. كنت أستخدمها أصلًا من خلال VLCKit، مكتبة VideoLAN لمنصات آبل المكتوبة بـ Objective-C.

تعكس واجهة VLCKit أسلوب تطوير أطر الوسائط في 2013: بروتوكول delegate، وإشعارات، وخاصية id drawable لإسناد واجهة العرض. هذا الأسلوب ما زال مستخدمًا في برامج كثيرة، ولم يكن سبب انتقالي إلى حل آخر.

كنت أحتاج إلى ميزة «صورة داخل صورة». لا يوفرها VLCKit 3: لا توجد فيه الأنواع والبروتوكولات الخاصة بها، ولا ملف VLCDrawable.h. تأتي الميزة في VLCKit 4، الذي كان في طور ألفا منذ أيلول 2022 ويُوزّع من مسار يحمل اسم unstable.

كان أمامي خياران: الاعتماد على نسخة تجريبية للحصول على الميزة، أو ربط واجهة C بنفسي. اخترت الثاني، ومنه جاءت بقية القرارات المتعلقة بالتزامن وإدارة الذاكرة والتعديلات على المحرّك.

حيث يتقدّم الغلاف

مسار الاستدعاء
‏Swift 6، ورَبْطٌ مباشر بلغة C دون Objective-C في الوسط
‏Objective-C من أوّلها إلى آخرها — ولا سطر Swift واحد في مصادرها
التزامن
المشغّل على المُمثِّل الرئيسيّ وقابلٌ للرصد، وأنواع الوسائط Sendable، والتحميل يأخذ قيمةً مُرسَلة
لا دعم لتزامن Swift. والأصل أن يصل نداء المفوَّض متزامنًا على خيط libVLC نفسه، والقفز إلى الرئيسيّ شأنك أنت
الأحداث
تدفّق AsyncStream لكلّ مشترك، مستقلّ، وسياسة تخزين لكلّ مسار
مفوَّضٌ واحد ومركز إشعارات، وخمسٌ من نداءاتها ما تزال تناولك NSNotification
الأخطاء
أخطاءٌ مصنّفة، تسع حالات، تحمل كلٌّ منها سياقها
لا نوع خطأ البتّة — إرجاع فارغ، وقيم منطقيّة، ونصٌّ واحد يخصّ الخيط ويصلح حتى النداء التالي
SwiftUI
تأتي معها واجهة عرض جاهزة
أسنِد سطح الرسم واكتب غلافك لـ SwiftUI بنفسك

حيث تتقدّم VLCKit

الربط
أرشيفٌ ساكن في كلّ شريحة
إطارٌ ديناميكيّ — وأُسقط الربط الساكن عمدًا في الإصدار 4
مسار إعادة الربط في LGPL
الربط الساكن لا يترك للمستهلك مسارًا عمليًّا، والمسألة المفتوحة التي تطلبه بلا جواب
الربط الديناميكيّ هو الجواب المتعارف عليه، وهو ما توزّعه VideoLAN نفسها
تقارير الأعطال
مجرّدةٌ من رموز التنقيح، فالعطل داخل libVLC لا تُفَكّ رموزه
رموز التنقيح تأتي داخل الإطار
المدى
من iOS 18 فصاعدًا، ومعها Mac Catalyst التي لا شريحة لها في VLCKit
رجوعًا إلى iOS 12 وmacOS 10.13، ومعها watchOS — أي نحو ستّ سنوات إضافيّة من الأجهزة
المحرّك
‏libVLC 4 وحده، وهو لم يصدر إصدارًا مستقرًّا قطّ
خطّان: واحدٌ على libVLC 3 الصادر وما يزال يُصلَح، وآخر على 4
السجلّ
تطبيقٌ واحد
‏VLC على iOS وiPadOS وtvOS — وقد انتقل إلى هذه النسخة الاختباريّة بعينها عبر SPM في تموز
القائمون عليها
شخصٌ واحد
‏VideoLAN وVideolabs، على مصادر تعود إلى أوّل استيرادٍ للإطار سنة 2007

لكلٍّ منهما مجموعة رقعٍ خاصّة به على VLC — سبعٌ وعشرون لـ VLCKit، وخمسٌ وعشرون لـ SwiftVLC. ورقع المحرّك هي الكلفة المعتادة لإصدار libVLC على منصّات آبل، لا ميزةٌ تميّز أيًّا منهما.

مقارنة مع VLCKit 4.0.0-a22، لا مع وصف الإصدار الثالث في README. تعرض مزايا واجهة Swift ومزايا توزيع المحرّك كلًا على حدة.

واصل VLCKit 4 التطور: أضاف visionOS وwatchOS، وأعاد كتابة إدارة الأحداث، وحسّن دقة الانتقال داخل الوسائط، وأضاف دعم Swift Package Manager قبل أسبوعين من كتابة المقالة. لذلك ستكون المقارنة غير عادلة إذا اعتمدت فقط على README الخاص بالإصدار الثالث، الذي كان المستودع يعرضه آنذاك.

وفي اختياري مخاطرة مماثلة: أعتمد على libVLC 4 قبل صدور نسخة مستقرة منه. أما VLCKit فما زال يحتفظ بإصدار يعمل مع libVLC 3 المستقر ويصلحه. إذا كانت أولوية المشروع الاعتماد على محرّك مستقر، فذلك يرجّح VLCKit في هذه المقارنة.

وهناك بلاغ مفتوح من فئة Blocker على متتبّع VideoLAN، سجله مطوّر آخر في تموز. يصف فشل إعادة توصيل خرج الفيديو عند إعادة استخدام المشغّل: يستمر الصوت وتبقى الصورة سوداء على tvOS مع MPEG-TS مباشر. أي أن المشكلة تظهر عند تبديل القنوات، وهي وظيفة أساسية في التطبيق.

محاولة إصلاح VLCKit

في كانون الأول 2025 نشرت harflabs/VLC. أجريت أربعة إيداعات في مساء واحد، ثم توقفت عن تطويرها.

كانت الحزمة تعيد توزيع أطر VLCKit 3.7.0 الثلاثة، بإطار لكل منصة، على هيئة أهداف ثنائية في SPM. استخدمت شروط المنصة لاختيار الإطار المناسب. لم يكن VLCKit 3 يملك Package.swift، وأردت إضافته إلى المشروع من خلال رابط حزمة.

أرشفت الحزمة لاحقًا. كان ملفها يعلن swift-tools-version: 6.0، لكن ترويسات الإطار لا تحتوي على توصيفات للتزامن. سهّلت إضافة VLCKit إلى المشروع، وبقي على تطبيق SwiftUI التعامل مع نموذج كائنات يعتمد على delegate واحد ولا يوضح ضمانات التزامن التي تحتاجها Swift 6.

بدأ SwiftVLC في شباط، ووصل إلى الإصدار 1.0 في تموز. استغرق العمل أربعة أشهر ونصفًا.

ما يتطلبه الربط المباشر مع C

كان القرار الأول أن أنتقل من C إلى Swift بلا Objective-C في المنتصف.

تظهر بساطة واجهة الاستخدام في هذا المثال، حيث يكفي View واحد لعرض الفيديو:

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

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

يمرر VideoView كائنًا من نوع NSView أو UIView إلى libVLC عبر set_nsobject، فيرسم VLC الفيديو فيه مباشرة. لا تحتاج إلى إنشاء طبقة عرض أو استخدام MTKView أو AVPlayerLayer.

لكن الربط المباشر يجعل إدارة أعمار كائنات C مسؤوليتي. لكل كائن خطوة إنشاء وخطوة تحرير، ويجب أن يبقى المؤشّر صالحًا طوال الفترة التي يستخدمه فيها كائن Swift. يسهل وصف هذا في الحالة البسيطة؛ وتظهر الأخطاء عند انتقال الكائنات بين الخيوط أو وصول أحداث أثناء تحريرها.

طريقتان لنقل مؤشّر

لا يطابق OpaquePointer ولا UnsafeMutableRawPointer بروتوكول Sendable. تمنع المكتبة القياسية هذه المطابقة عن قصد، لأن المترجم لا يعرف طبيعة الذاكرة التي يشير إليها المؤشر أو من يستطيع الوصول إليها.

قد يسمح العزل القائم على المناطق بنقل قيمة منفصلة من نطاق عزل إلى آخر، لكنه لا يحل الحالة هنا. فالمؤشر تلتقطه دالة مغلقة هاربة (escaping closure) موسومة بـ @Sendable، كي تحرر كائن C خارج الخيط الرئيسي. هذا الالتقاط لا يعادل نقل قيمة منفصلة.

أستخدم طريقتين في SwiftVLC. عند التقاط مؤشّر داخل closure واحدة، يمكن تعطيل الفحص على الربط المحلي وحده:

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

يتحمل هذا المسار مسؤولية ضمان بقاء المؤشّر صالحًا حتى انتهاء العمل الذي التقطه. أما الحالة التي تشاركها عدة خيوط، فأضعها داخل بنية موسومة بـ @unchecked Sendable وأحمي الوصول إليها باستخدام Mutex. هنا يعتمد الأمان على القفل وعلى طريقة استخدامه.

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

مع ذلك، يوجد استثناء واحد في الشيفرة: مسار لاختبارات التأهيل يحتفظ بعنوان المشغّل في UInt، ثم يعيده مباشرة إلى مؤشّر. يذكر تعليق الحقل أن السبب هو عدم مطابقة OpaquePointer لـ Sendable. تركت هذا الموضع كما هو، لكنه يظل استثناءً من القاعدة يحتاج إلى مراجعة.

إدارة عمر المؤشرات

تمر الأحداث بثلاث مراحل: callbacks من C على خيوط libVLC، ثم Broadcaster الذي يوزعها على المشتركين، ثم خصائص @Observable على @MainActor.

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

تنسخ Broadcaster.broadcast قائمة المشتركين وهي تحمل القفل، ثم تحرره قبل تشغيل المرشحات وتسليم القيم.

قد يحدث توقف متبادل إذا جرى التسليم أثناء الاحتفاظ بالقفل. مرشّح المشترك شيفرة خارجية قد تستدعي Broadcaster من جديد للاشتراك أو البث أو الاستعلام. سيحاول الاستدعاء الحصول على القفل نفسه، وهو لا يسمح بإعادة حيازته.

كذلك، تعمل onTermination فورًا على الخيط الذي ألغى المهمة المستهلكة. وهي تستدعي unsubscribe التي تحتاج إلى القفل نفسه. إذا وقع الإلغاء أثناء البث، فقد ينتظر الخيط قفلًا يحمله بالفعل.

أضفت اختبارًا يمنع عودة هذا الخطأ، لأن نقل سطر التسليم إلى داخل القفل قد يعيده بسهولة. لم يكن الاختبار موجودًا عندما كُتبت الشيفرة الأولى. وقد يفوت هذا النوع من المشاكل اختبارات سريعة، ثم يظهر أثناء استخدام التطبيق للبث المباشر.

يوفر النوع طريقتين للإغلاق. تنهي finishAll() تدفقات المشتركين الحاليين وتسمح باشتراكات جديدة. أما terminate() فتنهي التدفقات الحالية وتجعل أي اشتراك لاحق يعيد تدفقًا منتهيًا فورًا.

نحتاج إلى الثانية لأن بعض الخصائص المحسوبة تنشئ اشتراكًا مع كل قراءة. إذا انتهى المنتج قبل الاشتراك، يجب أن يحصل المستهلك على تدفق منتهٍ. وإلا سيبقى ينتظر في await دون أن تصل قيمة أو إشارة انتهاء.

قسّمت الأحداث أيضًا إلى تدفقين. يجمع callback في libVLC أحداث توقيت وأحداث تحكم، لكن إسقاط أحداث التوقيت عند ازدحامها لا يبرر إسقاط حدث تحكم.

نداء C واحد، على خيط libVLC

التحكّم24 حالة

هويّة الوسيط، وقدراته، ودورة حياته، ونهاياته.

كلٌّ منها يخبر بأمرٍ وقع مرّة واحدة، ولا يعيده حدثٌ لاحق، فسقوط واحدٍ منها ضياعُ خبر.

بلا حدّتنمو الذاكرة بمقدار تأخّر المستهلك في معدّل التحكّم، لا في معدّل الساعة.

التوقيت4 حالات

ساعة التشغيل، وامتلاء المخزن، وعدّادات العرض.

كلٌّ منها يَنسخ ما قبله، فإسقاط المتراكم يكلّف دقّةً ولا يكلّف غيرها.

أحدث 4المستهلك المتأخّر يتخطّى العيّنات البائتة ويظلّ يتلقّى الأحدث.

حدث timeChanged وحده ينطلق نحو ثلاثين مرّة في الثانية. وعبر مخزنٍ محدود واحد، يفقد المستهلكُ المتوقّف ثانيتين ما اصطفّ خلفه — تغيُّرَ وسيطٍ، أو بلوغَ نهاية.

الحدّ الذي لا يحلّه

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

تستخدم أحداث التوقيت والتحكم مخزنين منفصلين، حتى لا تطرد كثرة تحديثات التوقيت حدث تحكم. يظل دمج الأحداث مسألة أخرى.

يعتمد ترتيب تحرير الموارد على ما لا يزال يستخدم كل مورد. قبل تحرير كائن، يجب التأكد من انتهاء الجهات التي قد تقرأه:

ما الذي يفرض ترتيب هدم المشغّليبدأ الهدم على المُمثِّل الرئيسيّ بتصفير سطح الرسم، ثمّ يقفز إلى طابور عامّ. وخارج المُمثِّل الرئيسيّ تتلاقى ثلاثة قيود منفصلة — بقاء سطح الرسم بعد التحرير، وإبطال جسر الأحداث أوّلًا، وإيقاف المشغّل أوّلًا — كلّها عند نداء التحرير، فيثبت آخرًا. والتحرير لا ينقص إلّا عدّاد المراجع، فيكتمل الهدم حين يفرغ آخر مالكٍ معدود لا حين يعود التحرير.المُمثِّل الرئيسيّ · هدمٌ معزولset_nsobject(handle, nil)خيط الخرج يقرأ عدمًا لا عرضًايوشك أن يُحرَّرقفزٌ إلى طابور عامّخارج المُمثِّل الرئيسيّالفصل ينتظر نداءً طائرًا،والتحرير قد يحبس على خيوط VLCاستبقاء أسطح الرسمإبطال جسر الأحداثالاستئناف ثمّ الإيقافتحرير المقبضانتظار كلّ مالكالثلاثة جميعًا تسبقهخيط الخرج يقرأ سطح الرسمحتى يُهدَم الخرجيجب أن يظلّ مدير الأحداث صالحًاأثناء فصل المستمعينتحرير مقبضٍ قيد التشغيلسلوكٌ غير معرَّفالتحرير لا يفعل إلّا إنقاص عدّاد المراجع. وقد يظلّ مشغّل قائمةٍ منسحبٌ مالكًا لهذا المقبضبعينه بعد عودة تحريرنا — فالهدم لا ينتهي بعودة التحرير، بل ينتهي حين يفرغآخر مالكٍ معدود.
ترتيب تحرير الموارد بحسب الجهات التي ما زالت تستخدمها. تغيير الترتيب قد ينجح في البناء وتكشف خطأه أدوات فحص الذاكرة لاحقًا.

وُثقت القيود الثلاثة في تعليق فوق دالة التحرير. لا يفرضها المترجم، ولذلك يجب مراجعتها عند تغيير هذا المسار.

المؤشّر ليس هويّة

وصلت إلى هذه المشكلة متأخرًا، وكان من المفيد تصميم الحل منذ البداية.

يمكن أن يستبدل Player مشغّل libVLC الداخلي مع بقاء كائن Swift وتدفقات أحداثه. قد تصل بعد الاستبدال callbacks تخص المشغّل القديم. مقارنة المؤشّرات وحدها لا تكفي لمعرفة مصدر الحدث، لأن مخصّص الذاكرة قد يعيد استخدام عنوان كائن حُرر سابقًا.

لذلك أعطي كل طبقة قابلة للاستبدال رقم جيل يزداد مع كل استبدال. يحتفظ الحدث بأرقام الأجيال وقت إنشائه، ويمكن رفضه إذا لم تعد تطابق الحالة الحالية.

لماذا يحمل الحدث خمسة عدّادات بدل مؤشّرخمسة عدّادات تصاعديّة مستقلّة تجري بالتوازي: جلسة الوسيط، والمقبض الأصليّ، وحيازة النداءات، وخرج الفيديو، ومتحكّم «صورة داخل صورة». يتقدّم كلٌّ منها بمُحفِّزه الخاصّ. ويلتقط الحدث قيمها الخمس عند إنشائه؛ وحين يُسلَّم يكون اثنان منها قد تقدّما، فيصير الحدث قابلًا للرفض وإن بدا المؤشّر الذي يحمله صالحًا.خمسة عدّادات، مستقلّة عمدًايُولد الحدث هناويُسلَّم هناالعدّادجلسة الوسيطعند تحميل وسيطٍ جديد2 → 3المقبض الأصليّعند استبدال مشغّل libVLC2حيازة النداءاتعند استيلاء متحكّم على خانة vmem2خرج الفيديوعند فتح خرجٍ جديد1 → 2متحكّم «صورة داخل صورة»عند بناء متحكّم2تقدّم اثنان من الخمسة والحدثُ في الطريق، فصار يصف جلسة وسيطٍ وخرجَ فيديو لم يعوداموجودين. ولم يكن في المؤشّر الذي يحمله ما يدلّ على ذلك — فللمُخصِّص أن يعيدعنوانًا متقاعدًا كما هو.
خمسة أرقام أجيال مستقلة. قد تتغير جلسة الوسائط دون تغيير المقبض؛ ويُرفض الحدث إذا حمل رقم جيل قديمًا في أي منها.

أضفت هذه الأرقام تدريجيًا بعد ظهور عدة أخطاء. وعند مراجعة المكتبة بعد الإصدار 1.0، كانت مجموعة كبيرة من المشكلات تتعلق بأحداث متأخرة تخص حالة سابقة. لو بدأت من جديد، لأضفت أرقام الأجيال منذ اليوم الأول، خصوصًا للكائنات التي تبقى بعد انتهاء الاستدعاء الذي أنشأها.

إصلاح مسارات عرض الفيديو

عند فتح خرج الفيديو، ينسخ libVLC مؤشّرات callbacks وسياقها. مسحها من مشغّل الوسائط لاحقًا لا يمحو النسخة التي يحتفظ بها الخرج. لذلك فإن مسح callbacks ثم تحرير السياق مباشرة قد يؤدي إلى استخدام ذاكرة محررة.

يربط الحل كل سياق بمؤشّر libvlc_media_player_t محدد. يتيح ذلك انتقال المسؤولية بين المتحكمات التي تستخدم المشغّل نفسه دون أن يغيّر خرج فيديو حالة خرج آخر.

عند إيقاف السياق، أمنع بدء عمل عرض جديد، لكنني لا أحرره قبل انتهاء آخر عملية تحرير محسوبة للمشغّل واكتمال callbacks الجارية. الانتظار مدة ثابتة لا يثبت أن هذه العمليات انتهت؛ يجب أن يعكس التحرير عمر الكائن الفعلي.

للعرض أربعة مسارات، تختلف في الجهة التي تملك الكائنات وتتحمل مسؤولية تحريرها:

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

    set_nsobjectNSView / UIView

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

  • PiPVideoViewiOS

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

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

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

    نداءات vmemCVPixelBufferCMSampleBufferAVSampleBufferDisplayLayer

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

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

    set_nsobject‏NSView الخاص بـ VLCPIPViewController

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

أربعة مسارات للعرض تختلف في ملكية الكائنات. في iOS يملك libVLC متحكم صورة داخل صورة، بينما تملك SwiftVLC الطبقة في المسار المباشر.

كان القرار الأصعب متعلقًا بـ macOS. في الإصدارات التي أدعمها، يقص مسار عرض sample buffers العام الصورة بنسبة 1:1 بدل تحجيمها داخل نافذة «صورة داخل صورة». المسار الذي أعطى النتيجة الصحيحة يستخدم إطارًا خاصًا: تحميل PIPViewController من PIP.framework وقت التشغيل، ونقل View الذي يرسم فيه VLC إلى النافذة.

يوفر libVLC متحكمًا لميزة «صورة داخل صورة»، ويستخدمه كلا الغلافين. لكن ملفات بناء VLC تقصره على iOS وtvOS. لا يوجد هذا الصنف في نسخة macOS من المحرّك الذي أوزعه، ولذلك لا أستطيع استخدامه هناك.

جعلت استخدام الإطار الخاص خيارًا يحتاج إلى تفعيل صريح. وجمعت استدعاءات واجهات آبل الخاصة في ملف واحد لتسهيل مراجعتها. هذا التنظيم لا يجعلها مقبولة في متجر التطبيقات؛ ولهذا لا تعمل الميزة بهذا المسار افتراضيًا.

على iOS، تعلن المكتبة أن الميزة غير متاحة في المحاكي. قد يبلغ AVKit في المحاكي أن المتحكم يعمل بينما تبقى نافذة النظام سوداء. لا أريد لاختبار يكتفي بهذه الإشارة أن يسجل نجاحًا دون صورة فعلية.

واحتاج مسار vmem إلى معلومات عن أبعاد الفيديو لم تكن واجهة libVLC توفرها. أضفت دوال إلى واجهة C، ثم احتجت إلى الحفاظ على التوافق مع الأرشيفات التي بُنيت قبل هذه الإضافات.

المصافحة

swiftvlc_libvlc_pip_extensions_version()

تُرجع 0

أرشيفٌ صادرٌ سابقٌ لهذه الرموز

  • الرجوع إلى نداءات الصيغة العامّة
  • لا سبيل إلى إثبات القصّ ونسبة البكسل

تُرجع 3

أرشيفٌ مبنيٌّ من الشجرة المرقوعة

  • نداء الصيغة الموسَّع، والهندسة ملتقَطةٌ دفعةً واحدة
  • المقاس المرمَّز والمرئيّ والقصّ والنسبة تصل مجتمعةً أو لا تصل

ما أضافته كلّ مراجعة من الامتداد

  1. 1تهيئة vmem واعيةٌ بالهندسة، ولقطةٌ للوسيط وطوله تُؤخذ تحت قفل المشغّل
  2. 2لقطة تشغيلٍ بنوعٍ مستقلّ، فلا يكتب أرشيفٌ أحدث خارج مساحةٍ خصّصها عميلٌ أقدم
  3. 3تركيب الطبقات — ببوّابة إصدارٍ فقط، دون بديلٍ ضعيفٍ يُرجَع إليه
دالة إصدار للتحقق من الواجهة، ورموز ضعيفة لإتاحة الربط بأرشيف غير معدّل. تُضاف الترويسة والرموز إلى قوائم VLC، وتتحقق static assertions من مواضع الحقول.

قراءة تنفيذ libVLC

بدأ العمل بقراءة ترويسات libVLC، ثم امتد إلى قراءة تنفيذه وتعديل بعض أجزائه.

متى وصلت

619
  • صدرت مع الإصدار 1.0
  • كُتبت بعده في دفقةٍ واحدة

ما الذي تمسّه

  • خرج الفيديو و«صورة داخل صورة»8
  • واجهة libVLC بلغة C8
  • نواة الدخل والمشغّل6
  • فاكّات التغليف — MP4، TS، HLS4
  • بنية البناء والاختبار3
  • Chromecast والبثّ الخارج2
  • اكتشاف UPnP2
  • avcodec1

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

من أين جاءت

9تعيد إنتاج إيداعاتٍ رسميّة
40 إيداعًا متمايزًا، مُسمّاةً في ترويسات الرقع — واثنتان من التسع تُفصحان عن تكييفٍ للنسخة المثبَّتة
16كُتبت هنا
إصلاحاتٌ أصليّة، وسطحُ واجهةٍ جديد بلغة C، وترميماتُ بناءٍ لا سبب لحملها فوق

أين تقع

56
ملفًّا من VLC عُدِّل
6
ملفّات أُنشئتترويسةٌ عامّة، وثلاث ترويسات هندسةٍ لعازل العيّنات، واختبارا انحدارٍ أُضيفا إلى مجموعتَي VLC نفسها
حُسبت الرقع من التغييرات الفعلية. قد تمس الرقعة أكثر من جزء، لذلك يتجاوز مجموع الأعمدة عددها. يميز صف الملفات بين الملفات المعدّلة والمضافة.

احتوى الإصدار 1.0 على ست رقع. وأُضيفت التسع عشرة الباقية خلال ثمانية أيام بدأت بعد الإصدار باثني عشر يومًا. عندها انتقلت من معالجة سلوك المحرّك في الغلاف إلى إصلاحه داخل VLC.

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

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

وكتبت بعض الإصلاحات بنفسي، منها إصلاح تحرير مزدوج في قارئ البث التكيّفي في VLC.

عند فشل prepareChunk()، تحرر ISegment::toChunk() مصدر البيانات مرتين: تستدعي recycleSource() صراحة، ثم تحذف القطعة، فيحرره ~AbstractChunk() مرة أخرى لأنه يملكه. في هذا المسار، لا يُحتفظ بالمصدر لإعادة استخدامه؛ يصل الاستدعاء الأول إلى delete مباشرة. وعند محاولة التحرير الثانية، يُستخدم جدول الدوال الافتراضية لكائن حُررت ذاكرته بالفعل.

بدأ الخطأ بتغيير من سطر واحد في 2022: نُسخ استدعاء إعادة التدوير من الفرع التالي إلى هذا الفرع. كان الاستدعاء صحيحًا في موضعه الأصلي، لأن createChunk() لم تُرجع قطعة تملك المصدر. أما في الفرع الآخر، فالقطعة موجودة ومسؤولة عن تحريره. السطر نفسه صار هنا تحريرًا مزدوجًا.

عند كتابة المقالة، كان الخطأ ما يزال في فرع VLC الرئيسي. ويصف بلاغ في متتبّع VideoLAN انهيارًا متقطعًا من نوع EXC_BAD_ACCESS على iOS arm64 بعد نحو عشر دقائق من تشغيل HLS، مع مكدس استدعاءات ينتهي عند دالة الهدم.

يفسر مسار التنفيذ ظهور المشكلة بعد نحو عشر دقائق. لا تعمل prepareChunk() إلا للمقاطع المشفرة، ولا تفشل في هذا المسار إلا عند تعذر الحصول على المفتاح. ولأن المفاتيح تُخزن مؤقتًا، قد يظهر الخطأ عند تبديلها أو بعد إخلاء الذاكرة المؤقتة، بدل ظهوره عند بدء التشغيل.

احتاج ظهور المشكلة إلى مقاطع مشفرة، وتعذر الحصول على مفتاح AES-128، وشبكة متذبذبة، وجهاز فعلي، ومرور نحو عشر دقائق. لم تكتشفها اختباراتي. واجهها مطوّر آخر على نسخة المحرّك نفسها وسجلها في المشروع الأصلي. وجدت سببها بقراءة علاقات ملكية الكائنات، ثم أعدت إنتاجها بصورة حتمية. أضفت اختبارًا إلى اختبارات VLC للبث التكيّفي يعدّ مرات التحرير دون تحرير الذاكرة فعلًا، حتى يكشف التحرير المزدوج بفشل واضح بدل السلوك غير المعرّف.

كتبتُ العروض قبل أن أكتب المكتبة

بدأت بتطبيق عرض لكل منصة وبمجموعة اختبارات، اعتمادًا على ملف Matroska ذي الستين ثانية. كتبتها وفق الواجهة التي أردت للمكتبة أن توفرها قبل اكتمال تنفيذها: تبديل المسارات الصوتية، والانتقال داخل الفيديو أثناء «صورة داخل صورة»، وتشغيل العنصر التالي دون فقدان أحداث التحكم.

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

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

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

صممت الأدوات التالية لتسهيل هذا التحقق. توثق الإيداعات المهمة تجربة تعطيل الإصلاح وفشل الاختبار. ويفحص تقرير التغطية الرقع الموجودة فعلًا في النسخة الثنائية التي تختبرها CI، فقد توجد الرقعة في المستودع ولا تكون ضمن المحرّك المرتبط بالاختبارات.

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

أفادني الوكلاء في التنفيذ المتكرر وتشغيل الاختبارات، لكنني بقيت مسؤولًا عن مراجعة ملكية الذاكرة وترتيب الأقفال ومتطلبات AVKit غير الموثقة. كثيرًا ما بدا اقتراح مهلة زمنية معقولًا، بينما كان الحل الصحيح تحديد متى ينتهي استخدام الكائن. قد تعمل المهلة في الاختبار ثم تفشل لاحقًا على شبكة مختلفة. احتجت إلى قراءة شيفرة VLC لفهم هذه الحالات.

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

ما كلّفه ذلك

استغرق الوصول إلى 1.0 أربعة أشهر ونصفًا، وما زالت هناك أعمال صيانة ومشكلات مفتوحة.

أكبر التزامات الصيانة هو الحفاظ على الرقع عند تحديث نسخة VLC. وحتى تثبيت النسخة لا يثبت بيئة البناء كلها. احتجت إلى رقعة لأن إصدارًا أحدث من Autoconf مرر إعدادًا لمعيار اللغة إلى مترجم C يختلف عن إعداد Objective-C. عجز libtool عن تحديد الوسم المناسب، وفشلت أهداف آبل في test/ بعد نجاح ربط libvlccore وlibvlc. كانت المصادر نفسها قد بُنيت بنجاح قبل أسبوعين.

أوزّع المحرّك في أرشيف ساكن، وهذا يضيف التزامات تتعلق بالترخيص. تستخدم libVLC رخصة LGPL 2.1؛ ومن خيارات الامتثال عند الربط الساكن توفير ملفات الكائنات التي تتيح للمستخدم إعادة الربط بنسخة معدّلة من المكتبة. تشرح أسئلة GNU عن الربط الساكن والديناميكي هذا الجانب. لا يحسم اختيار صيغة الحزمة وحده متطلبات توزيع التطبيق.

هناك طلب مفتوح لإصدار xcframework ديناميكي لتسهيل التعامل مع هذه المتطلبات. لم أحسمه عند كتابة المقالة: أتوقع أن يزيد حجم الملفات الثنائية، وما زلت أوازن ذلك مع طريقة التوزيع التي أريد دعمها.

ويضيف الربط الساكن مشكلة هندسية أيضًا: قد تضمّن أكثر من نسخة من المحرّك داخل العملية نفسها.

لماذا يهمّيُوزَّع المحرّك أرشيفًا ساكنًا، والأرشيف الساكن لا هويّة له عند التحميل. وصورتان تربطانه كلتاهما تُنتجان زمنَي تشغيلٍ كاملين لـ libVLC في عمليّةٍ واحدة — سجلَّي إضافات، وخمس عشرة فئة Objective-C بلا مجال أسماء مُعرَّفة مرّتين، إحداهنّ اسمها AoutWrapper وحسب. ولا يفشل الربط في شيء. وإنّما يختار زمن التشغيل واحدةً من كلّ زوج ولا يخبرك أيّها.

الشكل المدعوم

  • التطبيق
  • FeatureAساكن
  • FeatureBساكن
  • MediaCoreديناميّ
  • SwiftVLC
  • libvlc.a

ما يعدّه التكامل المستمرّ

_libvlc_new

الصورة المحمَّلةتُعرّفه
ملفّ التطبيق0
FeatureA0
FeatureB0
MediaCore.framework1

واحدةٌ لا غير، ولا بدّ أن تكون هي.

قد ينجح الربط مع وجود نسختين كاملتين من libVLC في العملية نفسها. يكشف الفحص الصور الثنائية المحمّلة التي تعرّف الرمز نفسه.

وتبقى قيود أخرى. المسار الذي يعرض «صورة داخل صورة» بصورة صحيحة على macOS يستخدم واجهات خاصة، فلا أستطيع إصداره في متجر التطبيقات. وأوزع المحرّك دون رموز تنقيح أو ملفات dSYM، فتظهر الانهيارات داخله بأسماء الدوال دون أرقام الأسطر. كذلك أصبحت اختبارات التأهيل على الأجهزة الفعلية تتضمن ثلاثة وخمسين صفًا، مع أدوات اختبار كبيرة لأن المحاكيات لا تكفي لإثبات صحة هذه الحالات.

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

الربط المباشر حمّلني مسؤولية الترخيص والتوزيع ومشكلات المحرّك، إلى جانب واجهة Swift. ما زلت أختار هذا الطريق، لكن صيانته تستمر بعد إصدار المكتبة.

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

إذا عملت على ربط Swift بمكتبة C، أخبرني عن أكثر ما فاجأك. أرحب بمثال صغير يعيد إنتاج المشكلة، أو بطريقة مختلفة لحلها. وشارك المقالة مع مطوّر يفكر في الربط المباشر؛ ربما تكمل تجربته ما تعلمته هنا.

المواضيع