מניעת שיבושים עקב ביצועי רינדור טובים יותר

Tom Wiltzius
Tom Wiltzius

מבוא

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

זהו המאמר הראשון בסדרת מאמרים שעוסקים באופטימיזציה של ביצועי עיבוד בדפדפן. כדי להתחיל, נסביר למה קשה ליצור אנימציה חלקה ומה צריך לקרות כדי להשיג אותה, וגם מספר שיטות מומלצות קלות. רבים מהרעיונות האלה הוצגו במקור ב-Jank Busters הרצאה של נט דוקה ואני ניהלת בהרצאת Google I/O (סרטון) השנה.

חדש: V-sync

יכול להיות שגיימרים במחשבים מכירים את המונח הזה, אבל הוא לא נפוץ באינטרנט: מה זה v-sync?

חשוב לקחת בחשבון את מסך הטלפון: הוא מתרענן במרווחי זמן קבועים, בדרך כלל (אבל לא תמיד!) בערך 60 פעמים בשנייה. V-sync (או סנכרון אנכי) הוא שיטה ליצירת פריימים חדשים רק בין רענון של המסך. אפשר לחשוב על זה כעל מרוץ תהליכים בין התהליך שכותב נתונים למאגר הנתונים הזמני של המסך לבין מערכת ההפעלה שקוראת את הנתונים האלה כדי להציג אותם במסך. אנחנו רוצים שהתוכן של הפריים במאגר הנתונים הזמני ישתנה בין הריענונים האלה ולא במהלכם. אחרת, במסך יוצג מחצית ממסגרת אחת וחצי מתוכן אחר, דבר שיוביל ל"קריעת".

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

התזמון הוא הכול: requestAnimationFrame

מפתחי אתרים רבים משתמשים ב-setInterval או ב-setTimeout כל 16 אלפיות השנייה כדי ליצור אנימציות. זו בעיה מכל מיני סיבות (ועוד נדון כאן בעוד דקה), אבל החששות העיקריים הם:

  • רזולוציית הטיימר מ-JavaScript נמצאת בסדר של כמה אלפיות השנייה בלבד
  • למכשירים שונים יש קצבי רענון שונים

זכרנו את הבעיה של תזמון הפריים שצוינה למעלה: נדרשת מסגרת אנימציה מלאה, שהסתיימה באמצעות JavaScript, תמרון DOM, פריסה, ציור וכו', כדי להיות מוכנים לפני רענון המסך הבא. רזולוציה נמוכה של הטיימר עלולה להקשות על השלמת פריימים של אנימציה לפני רענון המסך הבא, אבל שינויים בקצב הרענון של המסך הופכים את הפעולה הזו לבלתי אפשרית עם טיימר קבוע. ללא קשר למרווח הזמן של הטיימר, הם ייעלמו לאט מחלון הזמנים של פריים ובסוף יתנו פריים. זה יקרה גם אם הטיימר יופעל ברמת דיוק של אלפית השנייה, וזה לא המצב (כפי שהמפתחים גילו). רזולוציית הטיימר משתנה בהתאם לרמת הטעינה של הסוללה לעומת המחשב, עשויה להיות מושפעת מעומס כרטיסיות ברקע וכו'. גם אם מדובר במקרים נדירים (למשל, כל 16 פריימים כי הם היו כבויים באלפיות השנייה), תבחינו במקרים הבאים: נופלים מספר פריימים לשנייה. אתם גם משקיעים מאמצים כדי ליצור פריימים שאף פעם לא יוצגו, וזה מבזבז את הכוח ואת זמן המעבד (CPU) שאתם עשויים להשקיע בביצוע פעולות אחרות באפליקציה שלכם.

למסכים שונים יש קצבי רענון שונים: 60Hz הוא תופעה נפוצה, אבל חלק מהטלפונים הם 59Hz, מחשבים ניידים מסוימים יורדים ל-50Hz במצב עם חשמל נמוך, וחלק מהצגים השולחניים הם 70Hz.

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

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

ל-requestAnimationFrame יש גם מאפיינים נחמדים נוספים:

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

requestAnimationFrame כבר דן בכל המקום, אז כדאי לעיין במאמרים כמו הטקסט הזה מ-Creative JS כדי לקבל מידע נוסף, אבל זהו צעד ראשון וחשוב להטמעת אנימציה.

תקציב מסגרת

מכיוון שאנחנו רוצים ליצור פריים חדש בכל רענון של המסך, נותר רק פרק הזמן בין הרענון כדי לבצע את כל העבודה וליצור פריים חדש. המשמעות של תצוגת 60Hz היא שיש לנו בערך 16 אלפיות השנייה להריץ את כל ה-JavaScript, לבצע פריסה, ציור וכל מה שהדפדפן צריך לעשות כדי להוציא את הפריים. כלומר, אם הרצת ה-JavaScript בתוך הקריאה החוזרת של requestAnimationFrame נמשכת יותר מ-16 אלפיות השנייה, אין כל תקווה לייצר מסגרת בזמן ל-v-sync!

16 אלפיות השנייה הן לא הרבה זמן. למרבה המזל, הכלים למפתחים של Chrome יכולים לעזור לכם לזהות אם אתם מנצלים לרעה את תקציב הפריים שלכם במהלך הקריאה החוזרת של requestAnimationFrame.

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

הדגמה עם פריסה רחבה מדי
הדגמה עם פריסה רחבה מדי

הקריאות החוזרות (callback) של requestAnimationFrame (rAF) נמשכות יותר מ-200 אלפיות השנייה. סדר הגודל הזה ארוך מדי מכדי לסמן פריים כל 16 אלפיות השנייה. פתיחת אחת מהקריאות החוזרות הארוכות של rAF חושפת מה קורה בפנים: במקרה הזה, יש הרבה פריסה.

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

הדגמה מעודכנת עם פריסה מצומצם מאוד
הדגמה מעודכנת עם פריסה מצומצם יותר

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

מקור אחר של ג'נק

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

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

  • לא צריך להשקיע הרבה עיבוד ב-handlers של קלט! הרבה שימוש ב-JS או ניסיון לשנות את הסדר של כל הדף במהלך, למשל handler של onscroll הוא סיבה נפוצה מאוד למהירות תגובה איומה.
  • דחיפו כמה שיותר עיבוד (קריאה: כל מה שייקח זמן רב לפעול) לקריאה החוזרת של rAF או ל-Web Workers ככל האפשר.
  • אם דוחפים את העבודה לתוך הקריאה החוזרת (callback) של rAF, נסו לחלק אותה למקטעים כך שעיבוד הפריים הוא טיפה קטנה בלבד או שתעכבו אותו עד אחרי שאנימציה חשובה תסתיים – כך תוכלו להמשיך להריץ קריאות חוזרות קצרות של rAF וליצור אנימציה בצורה חלקה.

למדריך מעולה שמסביר איך לדחוף קריאות חוזרות (callbacks) של requestAnimationFrame במקום את ה-handlers של הקלט, אפשר לעיין במאמר של פול לואיס Leaner, Meaner, Faster Animations עם requestAnimationFrame.

אנימציית CSS

מה טוב יותר מ-JS קל באירוע ובקריאות חוזרות (callbacks) של rAF? אין JS.

קודם לכן אמרנו שאין פתרון קסם שבעזרתו אפשר למנוע הפרעות בקריאות החוזרות של rAF, אבל אפשר להשתמש באנימציה של CSS כדי להימנע לגמרי מהצורך לבצע אותן. באופן ספציפי ב-Chrome ל-Android (ודפדפנים אחרים עובדים על תכונות דומות), לאנימציות של CSS יש מאפיין רצוי מאוד, שבמקרים רבים הדפדפן יכול להריץ אותן גם אם JavaScript פועל.

בקטע שלמעלה יש הצהרה מרומזת לגבי jank: דפדפנים יכולים לעשות רק דבר אחד בכל פעם. זה לא נכון לחלוטין, אבל זו הנחה טובה שיש לפעול לפיה: בכל זמן נתון הדפדפן יכול להריץ JS, לבצע פריסה או ציור, אבל רק אחד בכל פעם. אפשר לבדוק זאת בתצוגת ציר הזמן בכלי הפיתוח. יוצא הדופן לכלל הזה הוא אנימציות CSS ב-Chrome ל-Android (ובקרוב גם ב-Chrome למחשב, אבל עדיין לא).

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

  // see http://paulirish.com/2011/requestanimationframe-for-smart-animating/ for info on rAF polyfills
  rAF = window.requestAnimationFrame;

  var degrees = 0;
  function update(timestamp) {
    document.querySelector('#foo').style.webkitTransform = "rotate(" + degrees + "deg)";
    console.log('updated to degrees ' + degrees);
    degrees = degrees + 1;
    rAF(update);
  }
  rAF(update);

אם לוחצים על הלחצן, ה-JavaScript פועל במשך 180 אלפיות השנייה וגורם לעומס (jank). אבל אם במקום זאת נגיע לאנימציה הזו באמצעות אנימציות של CSS, ה-jank כבר לא מתרחש.

(חשוב לזכור שנכון למועד הכתיבה, אנימציית CSS זמינה רק ללא בעיות בממשק (jank) ב-Chrome ל-Android ולא ב-Chrome למחשב.)

  /* tools like Modernizr (http://modernizr.com/) can help with CSS polyfills */
  #foo {
    +animation-duration: 3s;
    +animation-timing-function: linear;
    +animation-animation-iteration-count: infinite;
    +animation-animation-name: rotate;
  }

  @+keyframes: rotate; {
    from {
      +transform: rotate(0deg);
    }
    to {
      +transform: rotate(360deg);
    }
  }

מידע נוסף על השימוש באנימציות של CSS זמין במאמרים כמו האנימציה הזו ב-MDN.

סיכום

הקצרה היא:

  1. כשיוצרים אנימציה, חשוב ליצור פריימים לכל רענון מסך. לאנימציה של Vsync יש השפעה חיובית מאוד על התחושה באפליקציה.
  2. הדרך הטובה ביותר להציג אנימציית vsync ב-Chrome ובדפדפנים מודרניים אחרים היא כדי להשתמש באנימציה של CSS. כשצריך יותר גמישות מאנימציה של CSS מספקת, השיטה הטובה ביותר היא אנימציה המבוססת על requestAnimationFrame.
  3. כדי לשמור על אנימציות של rAF תקינות ושמחות, חשוב לוודא שמטפלים באירועים אחרים לא מפריעות לביצוע הקריאה החוזרת של ה-RAF, ומשמרים קריאות חוזרות (callbacks) של rAF קצר (פחות מ-15 אלפיות שנייה).

לבסוף, אנימציית vsync לא חלה רק על אנימציות פשוטות בממשק המשתמש – היא חלה על אנימציית Canvas2D, אנימציית WebGL ואפילו גלילה בדפים סטטיים. במאמר הבא בסדרה הזו נתעמק בביצועי הגלילה מתוך מחשבה על המושגים האלה.

אנימציה שמחה!

קובצי עזר