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

أساسيات تشغيل الفيديو: AVPlayerViewController مقابل AVPlayerLayer
بشكل عام، عند الرغبة في تشغيل مقطع فيديو، نقوم عادةً بالحصول على رابط URL للفيديو ثم نعرضه ببساطة باستخدام AVPlayerViewController.
let videoURL = URL (string: "Sample-Video-Url" )
let player = AVPlayer (url: videoURL!)
let playerViewController = AVPlayerViewController ()
playerViewController.player = player
self .present(playerViewController, animated: true ) {
playerViewController.player.play()
}
يبدو الأمر بسيطًا ومباشرًا، أليس كذلك؟ لكن العيب الرئيسي في هذا التنفيذ هو عدم إمكانية تخصيصه. وهذا غالبًا ما يكون مطلبًا يوميًا إذا كنت تعمل في شركة تطوير منتجات رائدة.
كبديل، يمكننا استخدام AVPlayerLayer الذي يؤدي وظيفة مشابهة، لكنه يتيح لنا تخصيص العرض والعناصر الأخرى بشكل كامل.
let videoURL = URL (string: "Sample-Video-Url" )
let player = AVPlayer (url: videoURL!)
let playerLayer = AVPlayerLayer (player: player)
playerLayer.frame = self .view.bounds
self .view.layer.addSublayer(playerLayer)
player.play()
ولكن ماذا لو أردت دمج مقاطع فيديو متعددة، على غرار قصص إنستغرام؟ هنا، سيتعين علينا التعمق أكثر في التفاصيل التقنية.
تحديد المشكلة: متطلبات قصص الفيديو المتسلسلة
دعني أشاركك حالة استخدام من واقع عملي في شركة Swiggy. كنا نرغب في عرض مقاطع فيديو متعددة، حيث يُعرض كل فيديو عددًا معينًا من المرات. بالإضافة إلى ذلك، كان يجب أن تتمتع الميزة بخصائص شبيهة بقصص إنستغرام:
- يجب أن يتم تشغيل الفيديو الثاني تلقائيًا بعد انتهاء الفيديو الأول بسلاسة، وهكذا دواليك.
- يجب أن ينتقل التطبيق إلى الفيديو المقابل (السابق أو التالي) عندما ينقر المستخدم على الجانب الأيسر أو الأيمن من الشاشة.
إذا كنت تتساءل عن دور التخزين المؤقت هنا، فلا تقلق، سنتطرق إليه بالتفصيل بعد قليل.
دمج مقاطع فيديو متعددة في عرض واحد
الخطوة الأولى هي فهم كيفية إضافة مقاطع فيديو متعددة إلى عرض واحد. يمكننا تحقيق ذلك بإنشاء طبقة AVPlayerLayer واحدة وتعيين الفيديو الأول لها. عندما ينتهي الفيديو الأول، نقوم بتعيين الفيديو التالي لنفس طبقة AVPlayerLayer.
func addPlayer (player: AVPlayer) {
player.currentItem?.seek(to: CMTime .zero, completionHandler: nil )
playerViewModel?.player = player
playerView.playerLayer.player = player
}
للانتقال إلى الفيديو السابق أو التالي، يمكننا اتباع الخطوات التالية:
- إضافة إيماءة النقر (
tap gesture) على العرض. - إذا كان موقع النقر
xأقل من نصف عرض الشاشة، يتم تعيين الفيديو السابق، وإلا يتم تعيين الفيديو التالي.
@objc func didTapSnap ( _ sender: UITapGestureRecognizer) {
let touchLocation = sender.location(ofTouch: 0 , in : view)
if touchLocation.x < view.frame.width/ 2 {
changePlayer(forward: false )
} else {
fillupLastPlayedSnap()
changePlayer(forward: true )
}
}
وهكذا، أصبح لدينا الآن ميزة قصص الفيديو الشبيهة بإنستغرام. لكن مهمتنا لم تنته بعد! حان الوقت للحديث عن التخزين المؤقت.
أهمية التخزين المؤقت للفيديو: توفير البيانات وتحسين التجربة
لا نرغب في أن يقوم التطبيق بتنزيل الفيديو من البداية في كل مرة ينتقل فيها المستخدم من فيديو إلى آخر. كذلك، إذا تم عرض الفيديو مرة أخرى في جلسة لاحقة، فلا داعي لإجراء استدعاء آخر للخادم. إذا تمكنا من تخزين الفيديو مؤقتًا، فسيتم توفير بيانات الإنترنت للمستخدم، وسيقل الحمل على الخادم، وستتحسن تجربة المستخدم (UX) بشكل كبير لأنه لن يضطر إلى الانتظار طويلاً لتحميل الفيديو. كـمطورين جيدين، يجب أن يكون تقليل استهلاك بيانات الإنترنت للمستخدم أولوية قصوى.

تحميل مقاطع الفيديو بشكل غير متزامن باستخدام loadValuesAsynchronously
أول أداة يمكننا استخدامها لتحميل مقاطع الفيديو هي دالة loadValuesAsynchronously. وفقًا لوثائق Apple، تقوم هذه الدالة بإخبار الأصل (asset) بتحميل قيم جميع المفاتيح المحددة (أسماء الخصائص) التي لم يتم تحميلها بعد. الميزة هنا هي أنها تحفظ الفيديو حتى يتم عرضه. لذلك، لن يتم تنزيل الفيديو من البداية في كل مرة ينتقل فيها المستخدم إلى فيديو سابق؛ بل ستقوم بتنزيل الجزء الذي لم يتم عرضه سابقًا فقط.
لننظر إلى مثال: لنفترض أن لدينا Video_1 بطول 15 ثانية، وشاهد المستخدم 10 ثوانٍ من هذا الفيديو قبل الانتقال إلى Video_2. الآن، إذا عاد المستخدم إلى Video_1 مرة أخرى بالنقر إلى اليسار، فإن loadValuesAsynchronously ستحتفظ بالـ 10 ثوانٍ التي تم مشاهدتها وستقوم فقط بتنزيل الـ 5 ثوانٍ المتبقية (غير المشاهدة).
func asynchronouslyLoadURLAssets ( _ newAsset: AVURLAsset) {
DispatchQueue .main.async {
newAsset.loadValuesAsynchronously(forKeys: self .assetKeysRequiredToPlay) {
for key in self .assetKeysRequiredToPlay {
var error: NSError?
if newAsset.statusOfValue(forKey: key, error: &error) == .failed {
self .delegate?.playerDidFailToPlay(message: "Can't use this AVAsset because one of it's keys failed to load" )
return
}
}
if !newAsset.isPlayable || newAsset.hasProtectedContent {
self .delegate?.playerDidFailToPlay(message: "Can't use this AVAsset because it isn't playable or has protected content" )
return
}
let currentItem = AVPlayerItem (asset: newAsset)
let currentPlayer = AVPlayer (playerItem: currentItem)
self .delegate?.playerDidSuccesToPlay(playerDetail: currentPlayer)
}
}
يمكنك العثور على مزيد من التفاصيل حول loadValuesAsynchronously عبر هذا الرابط.
التحذير هنا هو أن هذه الطريقة تحتفظ ببيانات الفيديو لتلك الجلسة فقط. إذا أغلق المستخدم التطبيق وعاد إليه، سيتعين تنزيل الفيديو مرة أخرى. فما هي الخيارات الأخرى المتاحة لدينا؟
حفظ مقاطع الفيديو على الجهاز: التخزين المؤقت الدائم باستخدام AVAssetExportSession
هنا يأتي دور التخزين المؤقت للفيديو الفعلي! عندما يتم عرض الفيديو بالكامل، يمكننا تصديره وحفظه على جهاز المستخدم. عندما يظهر الفيديو مرة أخرى في جلسته التالية، يمكننا استرداد الفيديو من الجهاز وتحميله ببساطة.
AVAssetExportSession
وفقًا لوثائق Apple:
كائن يقوم بتحويل محتويات كائن مصدر (
asset source) لإنشاء مخرج بالشكل الموصوف بواسطة إعداد مسبق للتصدير محدد.
هذا يعني أن AVAssetExportSession يعمل كمُصدِّر، يمكننا من خلاله حفظ الملف على جهاز المستخدم. يجب علينا توفير رابط URL الإخراج ونوع ملف الإخراج.
let exporter = AVAssetExportSession (asset: avUrlAsset, presetName: AVAssetExportPresetHighestQuality )
exporter?.outputURL = outputURL
exporter?.outputFileType = AVFileType .mp4
exporter?.exportAsynchronously(completionHandler: {
print (exporter?.status.rawValue)
print (exporter?.error)
})
يمكنك العثور على مزيد من التفاصيل حول AVAssetExportSession عبر هذا الرابط.
الآن، الشيء الوحيد المتبقي هو جلب البيانات من ذاكرة التخزين المؤقت وتحميل الفيديو. قبل التحميل، تحقق مما إذا كان الفيديو موجودًا في ذاكرة التخزين المؤقت. ثم احصل على رابط URL المحلي هذا وقم بتمريره إلى loadValuesAsynchronously.
if let cacheUrl = FindCachedVideoURL (forVideoId: videoId) {
let cacheAsset = AVURLAsset (url: cacheUrl)
asynchronouslyLoadURLAssets(cacheAsset)
} else {
asynchronouslyLoadURLAssets(newAsset)
}
سيساعد التخزين المؤقت في تقليل استهلاك بيانات المستخدم بشكل كبير، بالإضافة إلى تخفيف الحمل على الخادم (أحيانًا يصل إلى تيرابايت من البيانات).
حالات استخدام إضافية للتخزين المؤقت
ما هي حالات الاستخدام الأخرى التي يمكننا معالجتها باستخدام التخزين المؤقت؟ فيما يلي أمثلة على طرق يمكنك من خلالها استخدام التخزين المؤقت هنا:
ضمان التخزين الأمثل
قبل حفظ الفيديو على الجهاز، يجب عليك التحقق مما إذا كانت هناك مساحة تخزين كافية متاحة على الجهاز للقيام بذلك.
func isStorageAvailable () -> Bool {
let fileURL = URL (fileURLWithPath: NSHomeDirectory () as String )
do {
let values = try fileURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeTotalCapacityKey])
guard let totalSpace = values.volumeTotalCapacity, let freeSpace = values.volumeAvailableCapacityForImportantUsage else {
return false
}
if freeSpace > minimumSpaceRequired {
return true
} else {
// Capacity is unavailable
return false
}
} catch { /* Handle error */ }
return false
}
إزالة مقاطع الفيديو القديمة
يمكنك تعيين طابع زمني (timestamp) لكل فيديو بحيث يمكنك تنظيف مقاطع الفيديو القديمة من ذاكرة الجهاز بعد عدد معين من الأيام.
func cleanExpiredVideos () {
let currentTimeStamp = Date ().timeIntervalSince1970
var expiredKeys: [ String ] = []
for videoData in videosDict where currentTimeStamp - videoData.value.timeStamp >= expiryTime {
// video is expired. delete
if let _ = popupVideosDict[videoData.key] {
expiredKeys.append(videoData.key)
}
}
for key in expiredKeys {
if let _ = popupVideosDict[key] {
popupVideosDict.removeValue(forKey: key)
deleteVideo( ForVideoId : key)
}
}
}
الحفاظ على عدد محدود من مقاطع الفيديو
يمكنك التأكد من حفظ عدد محدود فقط من مقاطع الفيديو في الملف في أي وقت، لنقل 10 مقاطع. عندما يأتي الفيديو الحادي عشر، يمكنك حذف الفيديو الأقل مشاهدة واستبداله بالجديد. سيساعدك هذا أيضًا على عدم استهلاك الكثير من ذاكرة جهاز المستخدم.
func removeVideoIfMaxNumberOfVideosReached () {
if popupVideosDict. count >= maxVideosAllowed {
// remove the least recently used video
let sortedDict = popupVideosDict.keysSortedByValue { (v1, v2) -> Bool in
v1.timeStamp < v2.timeStamp
}
guard let videoId = sortedDict.first else {
return
}
popupVideosDict.removeValue(forKey: videoId)
deleteVideo( ForVideoId : videoId)
}
}
قياس التأثير
لا تنسَ إضافة سجلات (logs) حتى تتمكن من قياس تأثير ميزتك. لقد استخدمت حدث سجل New Relic Log Event مخصصًا للقيام بذلك:
static func findCachedVideoURL (forVideoId id: String) -> URL? {
let nsDocumentDirectory = FileManager . SearchPathDirectory .documentDirectory
let nsUserDomainMask = FileManager . SearchPathDomainMask .userDomainMask
let paths = NSSearchPathForDirectoriesInDomains (nsDocumentDirectory, nsUserDomainMask, true )
if let dirPath = paths.first {
let fileURL = URL (fileURLWithPath: dirPath).appendingPathComponent(folderPath).appendingPathComponent(id + ".mp4" )
let filePath = fileURL.path
let fileManager = FileManager . default
if fileManager.fileExists(atPath: filePath) {
NewRelicService .sendCustomEvent(with: NewRelicEventType .statusCodes, eventName: NewRelicEventName .videoCacheHit, attributes: [
NewRelicAttributeKey .videoSize: fileURL.fileSizeString
])
return fileURL
} else {
return nil
}
}
return nil
}
لتحويل حجم الملف إلى تنسيق قابل للقراءة، أقوم بجلب حجم الملف وتحويله إلى ميغابايت (MBs).
extension URL {
var attributes: [ FileAttributeKey : Any ]? {
do {
return try FileManager . default .attributesOfItem(atPath: path)
} catch let error as NSError {
print ( "FileAttribute error: \(error)" )
}
return nil
}
var fileSize: UInt64 {
return attributes?[.size] as ? UInt64 ?? UInt64 ( 0 )
}
var fileSizeString: String {
return ByteCountFormatter .string(fromByteCount: Int64 (fileSize), countStyle: .file)
}
}
هذه هي كيفية قياس تأثيرك:

إجمالي البيانات المحفوظة = عدد الطلبات × حجم الفيديو = 2.4 ميغابايت × 20.3 ألف ≈ 49 غيغابايت. هذا مجرد بيانات أسبوعين. تخيل الحساب للسنة بأكملها! وهذا الرقم سيستمر في الزيادة بشكل كبير بمرور الوقت. هذا كل شيء! لقد قمت الآن ببناء آلية التخزين المؤقت الخاصة بك.

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