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

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, פריסה, רינדור (painting) וכו', שיהיה מוכן לפני שמתרחש הרענון הבא של המסך. רזולוציית טיימר נמוכה יכולה להקשות על השלמת הפריימים של האנימציה לפני הרענון הבא של המסך, אבל שינויים בקצב הרענון של המסך הופכים את זה לבלתי אפשרי עם טיימר קבוע. לא משנה מה מרווח הזמן של הטיימר, לאט לאט תחרגו מחלון הזמן של הפריים ובסופו של דבר תפספסו פריים. זה יקרה גם אם הטיימר יופעל בדיוק של אלפית השנייה, מה שלא יקרה (כפי שמפתחים גילו) – הרזולוציה של הטיימר משתנה בהתאם לשאלה אם המכשיר פועל על סוללה או מחובר לחשמל, יכולה להיות מושפעת מכרטיסיות ברקע שצורכות משאבים וכו'. גם אם זה נדיר (למשל, כל 16 פריימים כי פספסתם באלפית השנייה), תבחינו בכך: תאבדו כמה פריימים בשנייה. בנוסף, האפליקציה תבצע את העבודה ליצירת פריימים שלא יוצגו אף פעם, וזה מבזבז אנרגיה וזמן מעבד (CPU) שאפשר להשקיע בדברים אחרים באפליקציה.

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

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

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

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

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

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

תקציב הפריימים

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

‫16 אלפיות השנייה זה לא הרבה זמן. למרבה המזל, הכלים למפתחים ב-Chrome יכולים לעזור לכם לבדוק אם אתם חורגים מתקציב הפריימים במהלך הקריאה החוזרת (callback) של requestAnimationFrame.

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

הדגמה עם יותר מדי פריסה
הדגמה עם יותר מדי פריסה

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

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

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

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

מקור אחר לבעיות בביצועים

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

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

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

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

אנימציית CSS

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

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

בקטע שלמעלה על קפיצות בתצוגה יש הצהרה משתמעת: דפדפנים יכולים לעשות רק דבר אחד בכל פעם. זה לא מדויק, אבל זו הנחת עבודה טובה: בכל זמן נתון, הדפדפן יכול להריץ 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 אלפיות השנייה, וזה גורם לבעיות בביצועים. אבל אם נפעיל את האנימציה הזו באמצעות אנימציות CSS, הבעיה לא תתרחש יותר.

(חשוב לזכור שבזמן כתיבת המאמר הזה, אנימציית CSS פועלת בצורה חלקה רק ב-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. הדרך הכי טובה להציג אנימציה מסונכרנת עם קצב הרענון של המסך ב-Chrome ובדפדפנים מודרניים אחרים היא להשתמש באנימציית CSS. אם אתם צריכים יותר גמישות ממה שאנימציית CSS מספקת, השיטה הכי טובה היא אנימציה שמבוססת על requestAnimationFrame.
  3. כדי שהאנימציות של rAF יפעלו בצורה תקינה, חשוב לוודא שגורמים מטפלים אחרים באירועים לא מפריעים להפעלת הקריאה החוזרת (callback) של rAF, ושהקריאות החוזרות של rAF קצרות (פחות מ-15 אלפיות השנייה).

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

בהצלחה!

קובצי עזר