חדשות המוצר

שיפור הפעלת המדיה: מידע מעמיק על PreloadManager של Media3 – חלק 2

משך הקריאה: 9 דקות
לפרופיל של Mayuri Khinvasara Khabya
Mayuri Khinvasara Khabya מהנדס/ת ליחסי מפתחים

ברוכים הבאים לחלק השני בסדרה בת שלושת החלקים שלנו בנושא טעינה מראש של מדיה באמצעות Media3. הסדרה הזו נועדה להדריך אתכם בתהליך של יצירת חוויות מדיה רספונסיביות מאוד עם זמן אחזור נמוך באפליקציות ל-Android.

  • בחלק 1: מבוא לטעינה מראש באמצעות Media3, סקרנו את העקרונות הבסיסיים. הסברנו את ההבדל בין PreloadConfiguration לפלייליסטים פשוטים לבין DefaultPreloadManager, שהוא כלי חזק יותר לממשקי משתמש דינמיים. למדתם איך להטמיע את מחזור החיים הבסיסי של ה-API: הוספת מדיה באמצעות()add, אחזור של MediaSource מוכן באמצעות()getMediaSource, ניהול סדרי עדיפויות באמצעות()setCurrentPlayingIndex ו-()invalidate, ושחרור משאבים באמצעות()remove ו-()release.
  • חלק 2 (הפוסט הזה): בפוסט הזה נסביר על היכולות המתקדמות של DefaultPreloadManager. אנחנו מסבירים איך לקבל תובנות באמצעות PreloadManagerListener, להטמיע שיטות מומלצות שמוכנות לייצור כמו שיתוף רכיבי ליבה עם ExoPlayer, ולשלוט בתבנית של החלון הנפתח כדי לנהל את הזיכרון ביעילות.
  • חלק 3: בחלק האחרון של הסדרה הזו נסביר איך לשלב את PreloadManager עם מטמון דיסק מתמשך, כדי לצמצם את צריכת הנתונים באמצעות ניהול משאבים ולספק חוויה חלקה.

אם אתם חדשים בטעינה מראש ב-Media3, מומלץ מאוד לקרוא את חלק 1 לפני שתמשיכו. אם אתם מוכנים להתקדם מעבר לבסיס, בואו נראה איך אפשר לשפר את ההטמעה של הפעלת המדיה.

האזנה: אחזור ניתוח נתונים באמצעות PreloadManagerListener

כשמפתחי אפליקציות רוצים להשיק תכונה בסביבת הייצור, הם רוצים גם להבין ולתעד את הניתוח שמאחוריה. איך אפשר לוודא שאסטרטגיית הטעינה מראש יעילה בסביבה אמיתית? כדי לענות על השאלה הזו, צריך נתונים על שיעורי ההצלחה, הכישלונות והביצועים. הממשק PreloadManagerListener הוא המנגנון העיקרי לאיסוף הנתונים האלה.

‫PreloadManagerListener מספק שני קריאות חוזרות (callbacks) חיוניות שנותנות תובנות קריטיות לגבי התהליך והסטטוס של הטעינה מראש.

  • ‫onCompleted(MediaItem mediaItem): הקריאה החוזרת הזו מופעלת אחרי השלמה מוצלחת של בקשת טעינה מראש, כפי שהוגדרה על ידי TargetPreloadStatusControl.
  • ‫onError(PreloadException error): פונקציית הקריאה החוזרת הזו יכולה להיות שימושית לניפוי באגים ולמעקב. הוא מופעל כשטעינה מראש נכשלת, ומספק את החריגה המשויכת.

אפשר לרשום listener באמצעות הפעלת method אחת, כמו בדוגמת הקוד הבאה:

val preloadManagerListener = object : PreloadManagerListener {
    override fun onCompleted(mediaItem: MediaItem) {
        // Log success for analytics. 
        Log.d("PreloadAnalytics", "Preload completed for $mediaItem")
    }

    override fun onError( preloadError: PreloadException) {
        // Log the specific error for debugging and monitoring.
        Log.e("PreloadAnalytics", "Preload error ", preloadError)
    }
}

preloadManager.addListener(preloadManagerListener)

הפקת תובנות מהמאזין 

אפשר לחבר את פונקציות ה-callback האלה של המאזינים לצינור עיבוד הנתונים של הניתוח. אם תעבירו את האירועים האלה למנוע לניתוח נתונים, תוכלו לקבל תשובות לשאלות חשובות כמו:

  • מהו שיעור ההצלחה של הטעינה מראש? (היחס בין אירועי onCompleted לבין סך הניסיונות לטעינה מראש)
  • באילו רשתות CDN או פורמטים של וידאו יש את שיעורי השגיאות הגבוהים ביותר? (על ידי ניתוח החריגים מ-onError)
  • מהו שיעור השגיאות בטעינה מראש? (היחס בין אירועי onError לבין סך כל ניסיונות הטעינה מראש)

הנתונים האלה יכולים לספק לכם משוב כמותי על אסטרטגיית הטעינה מראש, ולאפשר לכם לבצע בדיקות A/B ולשפר את חוויית המשתמש על סמך נתונים. הנתונים האלה יכולים לעזור לכם לכוונן בצורה חכמה את משך הטעינה מראש, את מספר הסרטונים שאתם רוצים לטעון מראש ואת המאגרים שאתם מקצים.

מעבר לניפוי באגים: שימוש ב-onError כדי להציג ממשק משתמש חלופי במקרה של שגיאה

טעינה מראש שנכשלה היא אינדיקציה חזקה לכך שהמשתמש יחווה בקרוב אירוע של טעינה (buffering). הקריאה החוזרת (callback) של onError מאפשרת לכם להגיב באופן ריאקטיבי. במקום רק לרשום את השגיאה ביומן, אפשר להתאים את ממשק המשתמש. לדוגמה, אם הסרטון הבא לא נטען מראש, האפליקציה יכולה להשבית את ההפעלה האוטומטית בהחלקה הבאה, ולדרוש מהמשתמש להקיש כדי להתחיל את ההפעלה.

בנוסף, אפשר להגדיר אסטרטגיה חכמה יותר לניסיון חוזר על ידי בדיקה של סוג PreloadException. אפליקציה יכולה לבחור להסיר באופן מיידי מקור שנכשל מהמנהל על סמך הודעת השגיאה או קוד סטטוס של ה-HTTP. צריך להסיר את הפריט מזרם ממשק המשתמש בהתאם, כדי שבעיות בטעינה לא יפגעו בחוויית המשתמש. אפשר גם לקבל נתונים מפורטים יותר מ-PreloadException, כמו HttpDataSourceException, כדי לבדוק את השגיאות לעומק. מידע נוסף על פתרון בעיות ב-ExoPlayer

למה צריך לשתף רכיבים עם ExoPlayer?

המחלקות DefaultPreloadManager ו-ExoPlayer מיועדות לעבוד יחד. כדי להבטיח יציבות ויעילות, הם צריכים לשתף כמה רכיבי ליבה. אם הם פועלים עם רכיבים נפרדים ולא מתואמים, זה עלול להשפיע על thread safety ועל נוחות השימוש בטראקים שנטענו מראש בנגן, כי אנחנו צריכים לוודא שהטראקים שנטענו מראש יופעלו בנגן הנכון. בנוסף, הרכיבים הנפרדים עלולים להתחרות על משאבים מוגבלים כמו רוחב פס וזיכרון, מה שעלול לפגוע בביצועים. חלק חשוב ממחזור החיים הוא טיפול בהשלכה המתאימה. סדר ההשלכה המומלץ הוא לשחרר קודם את PreloadManager, ואז את ExoPlayer.

הכלי DefaultPreloadManager.Builder נועד להקל על השיתוף הזה, ויש לו ממשקי API ליצירת מופע של PreloadManager ושל נגן מקושר. נבדוק למה צריך לשתף רכיבים כמו BandwidthMeter,‏ LoadControl,‏ TrackSelector ו-Looper. הייצוג החזותי הזה ממחיש איך הרכיבים האלה פועלים עם ההפעלה ב-ExoPlayer.

preloadManager2.png

מניעת התנגשויות ברוחב הפס באמצעות BandwidthMeter משותף

ה-BandwidthMeter מספק הערכה של רוחב הפס הזמין ברשת על סמך קצבי העברה היסטוריים. אם PreloadManager והנגן משתמשים במופעים נפרדים, הם לא מודעים לפעילות הרשת של כל אחד מהם, מה שעלול להוביל לתרחישי כשל. לדוגמה, נניח שמשתמש צופה בסרטון, החיבור שלו לרשת נחלש, ובמקביל, ה-MediaSource שנטען מראש מתחיל הורדה אגרסיבית של סרטון עתידי. הפעילות של MediaSource לטעינה מראש תצרוך רוחב פס שנדרש לנגן הפעיל, ותגרום לסרטון הנוכחי להיתקע. הפסקת הפעלה היא כשל משמעותי בחוויית המשתמש.

כשמשתפים BandwidthMeter יחיד, האובייקט TrackSelector יכול לבחור את הטראקים באיכות הכי גבוהה בהתאם לתנאי הרשת הנוכחיים ולמצב המאגר, במהלך טעינה מראש או הפעלה. לאחר מכן, המערכת יכולה לקבל החלטות חכמות כדי להגן על סשן ההפעלה הפעיל ולהבטיח חוויה חלקה.

preloadManagerBuilder.setBandwidthMeter(customBandwidthMeter)

הבטחת עקביות עם רכיבי LoadControl,‏ TrackSelector ו-Renderer המשותפים של ExoPlayer

  • ‫LoadControl: הרכיב הזה קובע את מדיניות אגירת הנתונים, למשל כמה נתונים לאגור לפני שמתחילים את ההפעלה ומתי להתחיל או להפסיק לטעון עוד נתונים. שיתוף LoadControl מבטיח שצריכת הזיכרון של player ו-PreloadManager תתבסס על אסטרטגיית אחסון במאגר (באפרינג) מתואמת אחת, גם עבור מדיה שנטענה מראש וגם עבור מדיה שמופעלת באופן פעיל, וכך נמנעת תחרות על משאבים. כדי להבטיח עקביות, תצטרכו להקצות בצורה חכמה את גודל המאגר בהתאם למספר הפריטים שאתם טוענים מראש ולמשך הזמן של הטעינה. במקרים של מחלוקת, הנגן ייתן עדיפות להפעלה של הפריט הנוכחי שמוצג במסך. כשמשתמשים ב-LoadControl משותף, מנהל הטעינה מראש ימשיך לטעון מראש כל עוד מספר הבייטים של מאגר הנתונים הזמני שהוקצה לטעינה מראש לא הגיע למגבלה העליונה, והוא לא ימתין עד שהטעינה להפעלה תסתיים.

הערה: השיתוף של LoadControl בגרסה העדכנית של Media3 (1.8) מבטיח שאפשר יהיה לשתף את ה-Allocator שלו בצורה נכונה עם PreloadManager ועם הנגן. התכונה שמאפשרת להשתמש ב-LoadControl כדי לשלוט ביעילות בטעינה מראש תהיה זמינה בגרסה הבאה של Media3‏ (1.9).

preloadManagerBuilder.setLoadControl(customLoadControl)

  • ‫TrackSelector: הרכיב הזה אחראי לבחירת הטראקים (לדוגמה, סרטון ברזולוציה מסוימת, אודיו בשפה ספציפית) לטעינה ולהפעלה. השיתוף מבטיח שהטראקים שנבחרו במהלך הטעינה מראש הם אותם טראקים שהנגן ישתמש בהם. כך אפשר להימנע ממצב שבו טראק של וידאו באיכות 480p נטען מראש, אבל הנגן מיד מבטל אותו ומאחזר טראק באיכות 720p בזמן ההפעלה.< br /> מנהל הטעינה מראש לא אמור לשתף את אותו מופע של TrackSelector עם הנגן. במקום זאת, צריך להשתמש במופע אחר של TrackSelector אבל באותה הטמעה. לכן הגדרנו את TrackSelectorFactory במקום TrackSelector ב-DefaultPreloadManager.Builder.

preloadManagerBuilder.setTrackSelectorFactory(customTrackSelectorFactory)

  • רכיב עיבוד (Renderer): הרכיב הזה אחראי להבנת היכולות של הנגן בלי ליצור את רכיבי העיבוד המלאים. הוא בודק את תוכנית האב הזו כדי לראות באילו פורמטים של וידאו, אודיו וטקסט הנגן הסופי יתמוך. כך המערכת יכולה לבחור ולהוריד רק את טראק המדיה התואם, ולא לבזבז רוחב פס על תוכן שהנגן לא יכול להפעיל.

preloadManagerBuilder.setRenderersFactory(customRenderersFactory)

מידע נוסף על רכיבי ExoPlayer

כלל הזהב: כלי נפוץ להפעלת לולאה ששולט בכל ההפעלות

אפשר לציין במפורש את ה-thread שדרכו אפשר לגשת למכונה של ExoPlayer, על ידי העברת Looper כשיוצרים את הנגן. אפשר להשתמש ב-Player.getApplicationLooper כדי לשלוח שאילתה ל-Looper של השרשור שממנו צריך לגשת לנגן. השימוש ב-Looper משותף בין הנגן לבין PreloadManager, ולכן מובטח שכל הפעולות על אובייקטים משותפים של מדיה יסודרו בסדר עוקב בתור ההודעות של השרשור היחיד. כך אפשר לצמצם את מספר הבאגים שקשורים לפעולות בו-זמניות.

כל האינטראקציות בין PreloadManager לבין הנגן עם מקורות מדיה לטעינה או לטעינה מראש צריכות להתבצע באותו שרשור הפעלה. חובה לשתף את Looper כדי להבטיח את thread safety, ולכן צריך לשתף את PlaybackLooper בין PreloadManager לבין הנגן.

ה-PreloadManager מכין ברקע אובייקט MediaSource עם שמירת מצב. כשקוד ממשק המשתמש קורא ל-player.setMediaSource(mediaSource), אתם מבצעים העברה של האובייקט המורכב הזה עם המצב שלו מה-MediaSource שנטען מראש לנגן. במקרה כזה, כל PreloadMediaSource מועבר מהמנהל לנגן. כל האינטראקציות וההעברות האלה צריכות להתרחש באותו PlaybackLooper.

אם PreloadManager ו-ExoPlayer פעלו על שרשורים שונים, יכול להיות שנוצר מרוץ תהליכים. ה-thread של PreloadManager יכול לשנות את המצב הפנימי של MediaSource (למשל, לכתוב נתונים חדשים במאגר נתונים זמני) בדיוק ברגע שבו ה-thread של הנגן מנסה לקרוא ממנו. התוצאה היא התנהגות בלתי צפויה, IllegalStateException שקשה לנפות באגים.

preloadManagerBuilder.setPreloadLooper(playbackLooper)

בואו נראה איך אפשר לשתף את כל הרכיבים שלמעלה בין ExoPlayer לבין DefaultPreloadManager בהגדרה עצמה.

val preloadManagerBuilder =
DefaultPreloadManager.Builder(context, targetPreloadStatusControl)

// Optional - Share components between ExoPlayer and DefaultPreloadManager
preloadManagerBuilder
     .setBandwidthMeter(customBandwidthMeter)
     .setLoadControl(customLoadControl)
     .setMediaSourceFactory(customMediaSourceFactory)
     .setTrackSelectorFactory(customTrackSelectorFactory)
     .setRenderersFactory(customRenderersFactory)
     .setPreloadLooper(playbackLooper)

val preloadManager = val preloadManagerBuilder.build()

טיפ: אם אתם משתמשים ברכיבי ברירת המחדל ב-ExoPlayer, כמו DefaultLoadControl וכו', אתם לא צריכים לשתף אותם באופן מפורש עם DefaultPreloadManager. כשיוצרים את מופע ExoPlayer באמצעות buildExoPlayer של DefaultPreloadManager.Builder, הרכיבים האלה מקושרים זה לזה באופן אוטומטי, אם משתמשים בהטמעות ברירת המחדל עם הגדרות ברירת המחדל. אבל אם אתם משתמשים ברכיבים בהתאמה אישית או בהגדרות אישיות, אתם צריכים להודיע עליהם באופן מפורש ל-DefaultPreloadManager באמצעות ממשקי ה-API שלמעלה.

טעינה מראש שמוכנה לייצור: תבנית החלון הנע

בפיד דינמי, המשתמש יכול לגלול בכמות תוכן כמעט אינסופית. אם מוסיפים סרטונים ל-DefaultPreloadManager באופן רציף בלי אסטרטגיית הסרה מתאימה, בסופו של דבר תתרחש שגיאת OutOfMemoryError. כל MediaSource שנטען מראש מכיל SampleQueue, שמקצה מאגרי זיכרון. כשהאובייקטים האלה מצטברים, הם יכולים לנצל את כל שטח ה-heap של האפליקציה. הפתרון הוא אלגוריתם שאולי אתם כבר מכירים, שנקרא "חלון הזזה". תבנית החלון הנע שומרת בזיכרון קבוצה קטנה ונוחה לניהול של פריטים שסמוכים באופן לוגי למיקום הנוכחי של המשתמש בפיד. כשהמשתמש גולל, "החלון" הזה של הפריטים המנוהלים זז איתו, מוסיף פריטים חדשים שמופיעים בתצוגה ומסיר פריטים שכבר לא מוצגים.

slidingwindow.png

הטמעה של תבנית החלון הנע

חשוב להבין ש-PreloadManager לא מספק שיטת setWindowSize()‎ מובנית. חלון הזזה הוא תבנית עיצוב שהמפתחים אחראים להטמיע באמצעות השיטות הפרימיטיביות add()‎ ו-remove()‎. הלוגיקה של האפליקציה צריכה לקשר אירועים בממשק המשתמש, כמו גלילה או שינוי דף, לקריאות האלה ל-API. אם רוצים הפניה לקוד, אפשר לעיין בדוגמה של socialite שבה מיושם דפוס של חלון נע. הדוגמה כוללת גם PreloadManagerWrapper שמדמה חלון נע.

אל תשכחו להוסיף את preloadManager.remove(mediaItem)‎ להטמעה שלכם כשהפריט כבר לא צפוי להופיע בקרוב בצפייה של המשתמש. אם לא מסירים פריטים שכבר לא קרובים למשתמש, זו הסיבה העיקרית לבעיות בזיכרון בהטמעות של טעינה מראש. הקריאה ל-remove() מוודאת שהמשאבים משוחררים, וכך עוזרת לשמור על השימוש בזיכרון של האפליקציה במסגרת מוגבלת ויציבה.

שיפור אסטרטגיית טעינה מראש לפי קטגוריות באמצעות TargetPreloadStatusControl

עכשיו, אחרי שהגדרנו מה לטעון מראש (הפריטים בחלון שלנו), אנחנו יכולים להחיל אסטרטגיה מוגדרת היטב לגבי כמות הטעינה מראש של כל פריט. כבר ראינו איך להשיג את רמת הפירוט הזו באמצעות ההגדרה TargetPreloadStatusControl בחלק 1.

חשוב לזכור: לפריט במיקום ‎+/- 1 יכולה להיות סבירות גבוהה יותר להפעלה מאשר לפריט במיקום ‎+/- 4. אתם יכולים להקצות יותר משאבים (רשת, מעבד, זיכרון) לפריטים שהמשתמשים צפויים לצפות בהם בהמשך. כך נוצרת אסטרטגיית טעינה מראש שמבוססת על קרבה, והיא המפתח לאיזון בין הפעלה מיידית לבין שימוש יעיל במשאבים.

כדי להחליט על אסטרטגיית משך הטעינה מראש, אתם יכולים להשתמש בנתוני ניתוח דרך PreloadManagerListener, כפי שמתואר בקטעים הקודמים.

סיכום והשלבים הבאים

עכשיו יש לכם את הידע המתקדם שנדרש כדי ליצור פידים מהירים, יציבים וחסכוניים במשאבים של מדיה באמצעות DefaultPreloadManager של Media3.

בואו נסכם את המסקנות העיקריות:

  • כדאי להשתמש ב-PreloadManagerListener כדי לאסוף תובנות אנליטיות וליישם טיפול יעיל בשגיאות.
  • כדי לוודא שרכיבים חשובים משותפים, תמיד צריך להשתמש ב-DefaultPreloadManager.Builder יחיד כדי ליצור את מופעי הנגן והמנהל.
  • כדי למנוע OutOfMemoryError, מטמיעים את תבנית החלון הנפתח על ידי ניהול פעיל של הקריאות add()‎ ו-remove()‎.
  • כדאי להשתמש ב-TargetPreloadStatusControl כדי ליצור אסטרטגיית טעינה מראש חכמה ומדורגת, שמאזנת בין הביצועים לבין צריכת המשאבים.

מה השלב הבא בחלק 3: שמירת מדיה בזיכרון המטמון באמצעות טעינה מראש

טעינה מראש של נתונים לזיכרון משפרת את הביצועים באופן מיידי, אבל יכולות להיות לה גם חסרונות. אחרי שהאפליקציה נסגרת או שהמדיה שנטענה מראש מוסרת מהמנהל, הנתונים נעלמים. כדי להשיג רמת אופטימיזציה עקבית יותר, אפשר לשלב טעינה מראש עם שמירה במטמון הדיסק. התכונה הזו נמצאת בשלבי פיתוח פעילים ותהיה זמינה בקרוב, בעוד כמה חודשים.

רוצים לשלוח לנו משוב? נשמח לשמוע ממך.

כדאי להמשיך לעקוב, ובינתיים אתם יכולים להגביר את מהירות ההפעלה של הסרטונים. 🚀

נכתב על ידי:
להמשך קריאה