קוראים לי איאן קילפטריק ואני מנהל מהנדסים בצוות הפריסה של Blink, יחד עם קוג'י אישי. לפני שהתחלתי לעבוד בצוות Blink, הייתי מהנדס חזית (front-end) (לפני ש-Google יצרה את התפקיד 'מהנדס חזית'), ועסקתי בפיתוח תכונות ב-Google Docs, ב-Drive וב-Gmail. אחרי כחמש שנים בתפקיד הזה, החלטתי להמר ולעבור לצוות Blink. למדתי את השפה C++ בעבודה, וניסיתי להתקדם בבסיס הקוד המורכב מאוד של Blink. גם היום, אני מבינה רק חלק קטן יחסית ממנו. תודה על הזמן שהקדשת לי בתקופה הזו. העובדה ש'מהנדסי חזית משוחזרים' רבים עברו לפניי למהנדסי דפדפנים נתנה לי נחמה.
הניסיון הקודם שלי עזר לי מאוד כשהצטרפתי לצוות Blink. בתור מהנדס חזית, נתקלתי כל הזמן בחוסר עקביות בדפדפנים, בבעיות בביצועים, בבאגים ברינדור ובתכונות חסרות. LayoutNG הייתה הזדמנות בשבילי לעזור לפתור את הבעיות האלה באופן שיטתי במערכת הפריסה של Blink, והיא מייצגת את סך המאמצים של מהנדסים רבים לאורך השנים.
בפוסט הזה אסביר איך שינוי משמעותי בארכיטקטורה כמו זה יכול לצמצם ולצמצם את הסיכוי לסוגי באגים שונים ולבעיות בביצועים.
תצוגה רחבה של ארכיטקטורות של מנועי פריסה
בעבר, עץ הפריסה של Blink היה מה שאקרא לו 'עץ שניתן לשינוי'.
כל אובייקט בעץ הפריסה הכיל מידע ממקור קלט, כמו הגודל הזמין שהוגדר על ידי האב, המיקום של כל רכיב צף ומידע ממקור פלט, למשל, הרוחב והגובה הסופיים של האובייקט או המיקום שלו ב-x וב-y.
האובייקטים האלה נשמרו בין הרנדרים. כשהתרחש שינוי בסגנון, סימנו את האובייקט הזה כ'לא עדכני', וכך גם את כל ההורים שלו בעץ. כשהשלב של הפריסה צבר נתונים בצינור עיבוד הנתונים לעיבוד, היינו מנקים את העץ, עוברים על כל האובייקטים המלוכלכים ומריצים את הפריסה כדי להעביר אותם למצב נקי.
גילינו שהארכיטקטורה הזו גרמה לבעיות רבות, שנסביר בהמשך. אבל קודם, נרחיב על הקלט והפלט של הפריסה.
כשמפעילים את הפריסה בצומת בעץ הזה, מבחינה מושגית, המערכת מקבלת את 'הסגנון ו-DOM' ואת כל האילוצים של ההורה ממערכת הפריסה של ההורה (grid, block או flex), מפעילה את האלגוריתם של אילוצי הפריסה ומפיקה תוצאה.
הארכיטקטורה החדשה שלנו ממסדת את המודל המושגי הזה. עץ הפריסה עדיין קיים, אבל אנחנו משתמשים בו בעיקר כדי לשמור את הקלט והפלט של הפריסה. כפלט, אנחנו יוצרים אובייקט חדש לגמרי ולא ניתן לשינוי שנקרא עץ הפאזל.
תיארתי את העץ של הקטעים הבלתי משתנים, והסברתי איך הוא תוכנן לשימוש חוזר בחלקים גדולים מהעץ הקודם לצורך פריסות מצטברות.
בנוסף, אנחנו שומרים את אובייקט האילוצים של ההורה שיצר את הקטע הזה. אנחנו משתמשים בו כמפתח מטמון, ונרחיב על כך בהמשך.
גם האלגוריתם של הפריסה בתוך הטקסט נכתב מחדש כדי להתאים לארכיטקטורה החדשה שאינה ניתנת לשינוי. הוא לא רק יוצר ייצוג של רשימה רגילה ואישית לפריסה בתוך שורה, אלא כולל גם אחסון במטמון ברמת הפסקה כדי לבצע פריסה מחדש מהר יותר, עיצוב לכל פסקה כדי להחיל תכונות גופן על רכיבים ומילים, אלגוריתם דו-כיווני חדש של Unicode באמצעות ICU, הרבה תיקוני תקינות ועוד.
סוגי באגים בפריסה
באופן כללי, באגים בפריסה נכללים באחת מארבע קטגוריות שונות, לכל אחת מהן סיבות שונות.
תקינות
כשאנחנו חושבים על באגים במערכת הרינדור, אנחנו בדרך כלל חושבים על תקינות. לדוגמה: "בדפדפן א' יש התנהגות X, ובדפדפן ב' יש התנהגות Y", או "שני הדפדפנים, א' ו-ב', שבורים". בעבר הקדשנו לכך הרבה זמן, ובמהלך התהליך נאלצנו להתמודד כל הזמן עם המערכת. דפוס נפוץ של כשל היה החלת תיקון ממוקד מאוד לבאג אחד, אבל גילינו כמה שבועות לאחר מכן שגרמנו לנסיגה (רגרסיה) בחלק אחר של המערכת (שנראה לא קשור).
כפי שמתואר בפוסטים קודמים, זהו סימן למערכת מאוד פגיעה. בנוגע לפריסה באופן ספציפי, לא היה לנו חוזה ברור בין הכיתות, מה שגרם למהנדסי הדפדפנים להסתמך על מצב שהם לא צריכים להסתמך עליו, או לפרש באופן שגוי ערך כלשהו מחלק אחר של המערכת.
לדוגמה, בשלב מסוים היו לנו כ-10 באגים לאורך יותר משנה, שקשורים לפריסה גמישה. כל תיקון גרם לבעיה בתקינות או בביצועים בחלק מהמערכת, ועל כן נוצר באג נוסף.
עכשיו, כשהמערכת LayoutNG מגדירה בבירור את ההסכם בין כל הרכיבים במערכת הפריסה, אנחנו יכולים להחיל שינויים בביטחון הרבה יותר גבוה. אנחנו גם נהנים מאוד מהפרויקט המעולה Web Platform Tests (WPT), שמאפשר למספר גורמים לתרום לחבילת בדיקות אינטרנט משותפת.
היום אנחנו מגלים שאם אנחנו משיקים נסיגה אמיתית בערוץ היציב שלנו, בדרך כלל אין לה בדיקות משויכות במאגר WPT והיא לא נובעת מאי-הבנה של חוזי הרכיבים. בנוסף, כחלק ממדיניות תיקון הבאגים שלנו, אנחנו תמיד מוסיפים בדיקת WPT חדשה כדי לוודא שאף דפדפן לא יעשה שוב את אותה טעות.
ביטול תוקף חלקי
אם נתקלתם פעם בבאג מסתורי שבו שינוי הגודל של חלון הדפדפן או החלפת מצב של נכס CSS מבטלים את הבאג באופן קסום, כנראה נתקלתם בבעיה של ביטול לא מלא. למעשה, חלק מהעץ שניתן לשינוי נחשב נקי, אבל בגלל שינוי כלשהו באילוצים של ההורה, הוא לא ייצג את הפלט הנכון.
המצב הזה נפוץ מאוד במצבי הפריסה שמתוארים בהמשך, שבהם מתבצע טיול בשני מעברים (במהלך טיול פעמיים בעץ הפריסה כדי לקבוע את מצב הפריסה הסופי). בעבר, הקוד שלנו נראה כך:
if (/* some very complicated statement */) {
child->ForceLayout();
}
תיקון לבאג מהסוג הזה יהיה בדרך כלל:
if (/* some very complicated statement */ ||
/* another very complicated statement */) {
child->ForceLayout();
}
תיקון לבעיה מהסוג הזה בדרך כלל גורם לנסיגה משמעותית בביצועים (ראו 'ביטול תוקף יתר' בהמשך), וקשה מאוד לבצע אותו בצורה נכונה.
כיום (כפי שמתואר למעלה), יש לנו אובייקט של אילוצים הורה שלא ניתן לשינוי, שמתאר את כל הקלט מהפריסה של ההורה לצאצא. אנחנו מאחסנים את המידע הזה עם הפלח הבלתי ניתן לשינוי שנוצר. לכן, יש לנו מקום מרכזי שבו אנחנו diff בין שני מקורות הקלט האלה כדי לקבוע אם צריך לבצע עוד סבב של פריסה בנכס הצאצא. הלוגיקה של ההשוואה הזו מורכבת, אבל היא מוגדרת היטב. כדי לנפות באגים בבעיות מהסוג הזה של אי-תוקף חלקי, בדרך כלל צריך לבדוק באופן ידני את שני מקורות הקלט ולהחליט מה השתנה במקור הקלט כך שנדרש עוד סבב של פריסת האתר.
התיקונים לקוד ההשוואה הזה הם בדרך כלל פשוטים, וקל לבצע בדיקות יחידה שלהם בגלל הפשטות של יצירת העצמים העצמאיים האלה.
קוד ההשוואה לדוגמה שלמעלה הוא:
if (width.IsPercent()) {
if (old_constraints.WidthPercentageSize()
!= new_constraints.WidthPercentageSize())
return kNeedsLayout;
}
if (height.IsPercent()) {
if (old_constraints.HeightPercentageSize()
!= new_constraints.HeightPercentageSize())
return kNeedsLayout;
}
היסטירציה
סוג הבאגים הזה דומה לביטול לא מספק. בעיקרון, במערכת הקודמת היה קשה מאוד לוודא שהפריסה היא חד-פעמית (idempotent), כלומר הפעלה חוזרת של הפריסה עם אותם נתוני קלט מניבה את אותו פלט.
בדוגמה הבאה אנחנו פשוט מחליפים בין שני ערכים של מאפיין CSS. עם זאת, התוצאה היא מלבן 'שצומח ללא הגבלה'.
בעץ הקודם שאפשר לשנות, היה קל מאוד להכניס באגים כאלה. אם בקוד הייתה טעות בקריאת הגודל או המיקום של אובייקט בזמן או בשלב שגויים (למשל, כי לא "ניקינו" את הגודל או המיקום הקודמים), היינו מוסיפים מיד באג היסטריזיה עדין. בדרך כלל הבאגים האלה לא מופיעים בבדיקות, כי רוב הבדיקות מתמקדות בפריסה ובעיבוד (render) יחידים. מה שעוד יותר מדאיג הוא שחלק מההייסטרזיס הזה נדרש כדי שחלק ממצבי הפריסה יפעלו כמו שצריך. היו לנו באגים שבהם ביצענו אופטימיזציה כדי להסיר סבב פריסה, אבל הכנסנו "באג" כי צורך בשני סבבים של מצב הפריסה כדי לקבל את הפלט הנכון.
ב-LayoutNG, מאחר שיש לנו מבני נתונים מפורשים של קלט ופלט, ואי אפשר לגשת למצב הקודם, הצלחנו לצמצם באופן משמעותי את מספר הבאגים מהסוג הזה במערכת הפריסה.
ביטול יתר של פריטים וביצועים
זוהי הקטגוריה ההפוכה לקטגוריה 'לא בוצעה ביטול הסכמה' של באגים. לעיתים קרובות, כשאנחנו מתקנים באג של ביטול לא מלא, אנחנו גורמים לירידה חדה בביצועים.
לעיתים קרובות נאלצנו לקבל החלטות קשות שבהן העדפנו את הנכונות על פני הביצועים. בקטע הבא נסביר בפירוט איך הפחתנו את הבעיות האלה שקשורות לביצועים.
עליית הפריסות בשני שלבים וירידה חדה בביצועים
פריסות גמישות ופיריסות רשתות סימנו שינוי ביכולת להביע את עצמכם באמצעות פריסות באינטרנט. עם זאת, האלגוריתם הזה היה שונה באופן מהותי מהאלגוריתם של פריסת הבלוק שקדמו לו.
בפריסה של בלוקים (בכמעט כל המקרים), המנוע צריך לבצע פריסה של כל הצאצאים רק פעם אחת. הפתרון הזה מצוין מבחינת הביצועים, אבל בסופו של דבר הוא לא מאפשר להביע את עצמכם כמו שמפתחי אתרים רוצים.
לדוגמה, לרוב רוצים שהגודל של כל הצאצאים יתרחב לגודל של הגדול ביותר. כדי לתמוך בכך, פריסת ההורה (flex או grid) תבצע שלב מדידה כדי לקבוע את הגודל של כל אחד מהצאצאים, ולאחר מכן שלב פריסה כדי למתוח את כל הצאצאים לגודל הזה. זוהי ברירת המחדל גם בפריסה של גמישות וגם בפריסה של רשת.
בהתחלה, הביצועים של הפריסות האלה בשני מעברים היו בטווח הקביל, כי בדרך כלל אנשים לא הטמיעו אותן לעומק. עם זאת, כשהתחיל להופיע תוכן מורכב יותר, התחלנו לראות בעיות משמעותיות בביצועים. אם לא שומרים את התוצאה של שלב המדידה במטמון, עץ הפריסה יתבצע בין המצב measure לבין המצב הסופי layout.
בעבר, כדי להתמודד עם ירידה חדה כזו בביצועים, ניסינו להוסיף מטמון ספציפי מאוד לפריסת Flex ולפריסת רשת. השיטה הזו עבדה (והגענו רחוק מאוד עם Flex), אבל היינו צריכים להתמודד כל הזמן עם באגים של ביטול תוקף מוגזם או מוגבל מדי.
LayoutNG מאפשר לנו ליצור מבני נתונים מפורשים גם לקלט וגם לפלט של הפריסה, ונוסף על כך יצרנו מטמון של מעברי המדידה והפריסה. כך המורכבות חוזרת ל-O(n), וכתוצאה מכך מפתחי האתרים יכולים לחזות את הביצועים באופן לינארי. אם יקרה מקרה שבו פריסת האתר תתבצע בשלוש חזרות, פשוט נסנכרן גם את החזרה הזו במטמון. כך נוכל להציג בעתיד מצבי פריסה מתקדמים יותר בבטחה. זוהי דוגמה לאופן שבו RenderingNG פותח את האפשרות להרחבה בכל התחומים. במקרים מסוימים, פריסת רשת עשויה לדרוש פריסות של שלושה מעברים, אבל זה נדיר מאוד כרגע.
גילינו שכאשר מפתחים נתקלים בבעיות בביצועים שקשורות במיוחד לפריסה, בדרך כלל הסיבה לכך היא באג בזמן הפריסה האקספוננציאלי ולא הקצב ברוטו של שלב הפריסה בצינור עיבוד הנתונים. אם שינוי מצטבר קטן (רכיב אחד שמשנה מאפיין CSS אחד) גורם לזמן פריסה של 50-100 אלפיות השנייה, סביר להניח שמדובר באג בפריסה מעריכית.
לסיכום
נושא הפריסה הוא נושא מורכב מאוד, ולא התייחסנו לכל סוגי הפרטים המעניינים, כמו אופטימיזציה של פריסה בתוך שורה (הסבר מפורט על אופן הפעולה של כל מערכת המשנה של הטקסט והפריסה בתוך שורה). גם הרעיונות שצוינו כאן הם רק קצה המזלג, וחלק גדול מהפרטים לא הוזכר. עם זאת, אנחנו מקווים שהראינו איך שיפור שיטתי של הארכיטקטורה של מערכת יכול להוביל לרווח גדול בטווח הארוך.
עם זאת, ברור לנו שעדיין יש לנו הרבה עבודה לפנינו. אנחנו מודעים לבעיות מסוגים שונים (גם בנושא ביצועים וגם בנושא תקינות) שאנחנו פועלים כדי לפתור, ואנחנו שמחים על תכונות פריסה חדשות שיתווספו ל-CSS. אנחנו מאמינים שהארכיטקטורה של LayoutNG מאפשרת לפתור את הבעיות האלה בצורה בטוחה ופשוטה.
תמונה אחת (אתם יודעים איזו!) של Una Kravets.