---
title: "ما كلّفه فعلًا تغليف libVLC"
description: "بدأ SwiftVLC مشغّلَ فيديو لتطبيق IPTV وانتهى بخمسٍ وعشرين رقعة على شيفرة VLC نفسها. ملاحظات في الربط مع C، وعزل Swift 6، والمواضع التي يكفّ فيها الغلاف عن كونه غلافًا."
author: "Omar Albeik"
date: 2026-08-03
type: essay
topics: [software, engineering]
language: ar
reading_time_minutes: 12
canonical_url: https://albeik.dev/ar/blog/what-wrapping-libvlc-actually-cost
translation_url: https://albeik.dev/en/blog/what-wrapping-libvlc-actually-cost
source_url: https://albeik.dev/ar/blog/what-wrapping-libvlc-actually-cost.md
---

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

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

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

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

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

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

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

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

```swift
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 بطريقتين
اثنتين لا ثالث لهما.

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

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

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

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

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

وثمّة طريقة ثالثة، وهي محظورة في هذه الشيفرة: تهريب المؤشّر عبر
`Int(bitPattern:)` والعودة به. إنها تُترجَم، وتُسكِت التحذير، وتُتلف الشيئين
اللذين جعلا التحذير جديرًا بالوجود: النوع والقصد. وبعد ستة أشهر لن يستطيع قارئ
تلك الدالّة أن يميّز مؤشّرًا حيًّا من عددٍ صحيح، ويكون المترجم قد أُقنع بالكفّ
عن المساعدة. والقاعدة تستحق التنصيص عليها في
[وثيقة البنية](https://github.com/harflabs/SwiftVLC/blob/main/ARCHITECTURE.md)
تحديدًا لأنّ الاختصار
سهل المنال في الواحدة بعد منتصف الليل.

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

تعبر الأحداث ثلاث طبقات: نداءات C التي تُطلَق على خيوط libVLC نفسها، ثم مذيعٌ
متعدّد المستهلكين، ثم خصائص `@Observable` على المُمثِّل الرئيسي. والطبقة الوسطى
نوعٌ عامّ صغير،
[`Broadcaster<Element: Sendable>`](https://github.com/harflabs/SwiftVLC/blob/main/Sources/SwiftVLC/Core/Broadcaster.swift)،
تتشاركه أحداثُ المشغّل،
وقيودُ السجل، ونداءاتُ الحوارات، واكتشافُ العارضات، ونيّةُ التشغيل.

<Figure
  lockDirection
  caption="يعبر الحدث حدَّي عزلٍ قبل أن تراه SwiftUI. والحدّ الثاني هو موضع التصميم: يعمل المذيع على أيّ خيطٍ صادف أن يكون libVLC عليه، وينال كلُّ مشتركٍ تدفّقه الخاص لا تدفّقًا مشتركًا."
>
  <EventPath locale="ar" />
</Figure>

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

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

<Figure
  lockDirection
  caption="قفلان، وخيطان، وترتيبان متعاكسان في الحيازة. ولا يفعل أيٌّ من الطرفين شيئًا غريبًا — أحدهما يوصّل حدثًا والآخر يلغي مهمّة — ولهذا بالضبط تسهل كتابة الدورة ويصعب رؤيتها."
>
  <LockCycle locale="ar" />
</Figure>

ويحمل النوع نفسه تمييزًا يبدو إفراطًا في الهندسة إلى أن يتوقف عن كونه كذلك.
فـ`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 إليه.

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

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

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

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

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

يضمّ [`scripts/patches/`](https://github.com/harflabs/SwiftVLC/tree/main/scripts/patches)
خمسًا وعشرين رقعة مرتّبة تُطبَّق على مراجعة VLC مثبّتة
عند البناء، مع بيان تحقّق بالبصمات.

<Figure
  caption="أين تقع الرقع الخمس والعشرون، محسوبةً بتحليل كل فرقٍ لا بقراءة عنوانه — وتُحسب الرقعة مرةً لكل منطقة تعدّلها، ولذلك يتجاوز مجموع الأعمدة خمسًا وعشرين. أما المصدر فمن ترويسة كل رقعة: تسعٌ تعيد إنتاج إيداعٍ رسميّ بلا تغيير، والستّ عشرة الباقية كُتبت هنا. وهي تغيّر معًا 4606 أسطر في 56 ملفًا من VLC."
>
  <PatchMap locale="ar" />
</Figure>

بعضها نقلٌ لإصلاحاتٍ رسمية وصلت بعد التثبيت. [الرقعة 12](https://github.com/harflabs/SwiftVLC/blob/main/scripts/patches/0012-player-timer-pause-clock.patch)
هي سلسلة مؤقّت المشغّل: على المراجعة المثبّتة يستكمل المؤقّت
الاستقراء بعد نقطة الإيقاف، فيظلّ مشغّلٌ متوقّف يبلّغ عن زمنٍ يتقدّم. ويظهر ذلك
مباشرةً في `Player.currentTime`، ومن القراءة نفسها يُضبط مرجعُ زمن «صورة داخل
صورة»، فيكون العَرَض المرئي فيديو متوقّفًا وشريط تقدّمٍ يزحف. وتحمل الرقعة معها
إعادتَي هيكلةٍ رسميّتين تعتمد عليهما، حرفيًا، بدل تكييف الإصلاحين الحقيقيين
يدويًا على الشكل الأقدم — فإعادةُ أساسٍ يمكنك اشتقاقها من جديد خيرٌ من إعادة
كتابةٍ عليك التحقّق منها من جديد.

وبعضها أصيل. تصلح
[الرقعة 8](https://github.com/harflabs/SwiftVLC/blob/main/scripts/patches/0008-adaptive-segment-double-recycle.patch)
استخدامًا بعد التحرير حين يتعذّر تحضير قطعة مقطعٍ
مشفَّر: تحرّر `ISegment::toChunk()` مصدرَ القطعة مرتين على مسار فشل
`prepareChunk()`، مرةً صراحةً عبر `recycleSource()`، ومرةً حين تحذف القطعة، لأنّ
`~AbstractChunk()` تحرّر المصدر الذي تملكه القطعة. ولقطع المقاطع لا تكون
إعادةُ التدوير قابلةً للتخزين فتمضي مباشرةً إلى `delete`، فيتوزّع التحرير الثاني
عبر جدول دوالٍّ افتراضية فوق ذاكرة محرَّرة. وهي تتكرّر تحت AddressSanitizer،
وموضع الانهيار يطابق
[بلاغًا قائمًا في VLC](https://code.videolan.org/videolan/vlc/-/issues/29845)
عن `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 كبيرة، فالنصيحة الصادقة أن ترصد ميزانيةً لشيفرتها
لا لترويساتها. فسطح الواجهة عطلةُ نهاية أسبوعٍ لكل وحدة، ويكتب نفسه في أغلبه.
أما ما يكلّف شهورًا فهو ذلك الجزء الذي تكون فيه افتراضاتُ المحرّك وضماناتُ لغتك
صحيحةً معًا ومتنافيةً معًا، ولا يمكن تغيير إلا أحدهما بتحرير مستودعك أنت.
