חדשות על המוצר

שיפור הפעלת המדיה: מבט מעמיק על 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 מספק שתי פונקציות קריאה חוזרת (callback) חיוניות שנותנות תובנות קריטיות לגבי התהליך והסטטוס של הטעינה מראש.

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

אפשר לרשום מאזין באמצעות הפעלת 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 Playback.

preloadManager2.png

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

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

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

preloadManagerBuilder.setBandwidthMeter(customBandwidthMeter)

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

  • ‫LoadControl: הרכיב הזה קובע את מדיניות אגירת הנתונים, למשל כמה נתונים לאגור לפני שמתחילים את ההפעלה ומתי להתחיל או להפסיק לטעון עוד נתונים. שיתוף של LoadControl מבטיח שצריכת הזיכרון של הנגן ושל 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 כשיוצרים את הנגן. אפשר להריץ שאילתה על ה-Looper של ה-thread שממנו צריך לגשת לנגן באמצעות Player.getApplicationLooper. כששומרים על Looper משותף בין הנגן ל-PreloadManager, כל הפעולות באובייקטים המשותפים האלה של המדיה מועברות לתור ההודעות של שרשור אחד. כך אפשר לצמצם את מספר הבאגים שקשורים לפעולות בו-זמניות.

כל האינטראקציות בין PreloadManager לבין הנגן עם מקורות מדיה לטעינה או לטעינה מראש צריכות להתבצע באותו שרשור הפעלה. שיתוף ה-Looper הוא חובה כדי לשמור על בטיחות ה-thread, ולכן צריך לשתף את 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 אחד כדי ליצור גם את המופעים של המנהל וגם את המופעים של הנגן, כדי לוודא שרכיבים חשובים משותפים.
  • מטמיעים את תבנית החלון הנפתח על ידי ניהול פעיל של הקריאות add()‎ ו-remove()‎ כדי למנוע OutOfMemoryError.
  • כדאי להשתמש ב-TargetPreloadStatusControl כדי ליצור אסטרטגיית טעינה מראש חכמה ומדורגת, שמאזנת בין הביצועים לבין צריכת המשאבים.

מה השלב הבא בחלק 3: שמירה במטמון עם מדיה שנטענה מראש

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

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

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

כותב/ת המסמך:
להמשך קריאה