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

Tom Wiltzius
Tom Wiltzius

מבוא

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

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

חדש: תאימות ל-V-sync

גיימרים במחשבים עשויים להכיר את המונח הזה, אבל הוא לא נפוץ באינטרנט: מה זה תזמון וידאו?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

במאמר אנימציות יעילות, חזקות ומהירות יותר באמצעות requestAnimationFrame של Paul Lewis מוסבר איך להעביר את העיבוד לקריאות חזרה של requestAnimationFrame במקום למטפלי קלט.

אנימציה ב-CSS

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

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

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

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

שתהיה לכם אנימציה מהנה!

קובצי עזר