שמי איאן קילפטריק, מנהל הנדסה בצוות הפריסה של 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();
}
תיקון לבעיה מהסוג הזה בדרך כלל גורם לנסיגה משמעותית בביצועים (ראו 'ביטול תוקף יתר' בהמשך), וקשה מאוד לבצע אותו בצורה נכונה.
כיום (כפי שמתואר למעלה) יש לנו אובייקט אילוצים של הורה שלא ניתן לשינוי, שמתאר את כל הקלט מפריסת ההורה אל הצאצא. אנחנו מאחסנים את הקטע הזה עם המקטע שלא ניתן לשינוי. לכן, יש לנו מקום מרכזי שבו אנחנו משווים בין שני מקורות הקלט האלה כדי לקבוע אם צריך לבצע עוד סבב של פריסה בנכס הצאצא. הלוגיקה של ההשוואה הזו מורכבת, אבל היא מוגדרת היטב. כדי לנפות באגים בבעיות מהסוג הזה של אי-תוקף חלקי, בדרך כלל צריך לבדוק באופן ידני את שני מקורות הקלט ולהחליט מה השתנה במקור הקלט כך שנדרש עוד סבב של פריסת ה-layout.
תיקונים לקוד ההבדל הזה הם בדרך כלל פשוטים, וקלים לבדיקה יחידה בגלל הפשטות של יצירת אובייקטים בלתי תלויים אלה.
קוד ההשוואה לדוגמה שלמעלה הוא:
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 יש לנו מבני נתונים מפורשים של קלט ופלט, והגישה למצב הקודם אסורה, צמצמנו באופן נרחב את סוג הבאג הזה ממערכת הפריסה.
ביטול יתר של פריטים וביצועים
זוהי הקטגוריה ההפוכה לקטגוריה 'לא בוצעה ביטול הסכמה' של באגים. לרוב, כשאנחנו מתקנים באג של ביטול לא מלא, אנחנו גורמים לירידה חדה בביצועים.
לעיתים קרובות נאלצנו לקבל החלטות קשות שבהן העדפנו את הנכונות על פני הביצועים. בקטע הבא נסביר בפירוט איך הפכנו את הבעיות האלה בביצועים לפחות משמעותיות.
עליית הפריסות עם שני מעברים ותופעת 'צוקים בביצועים'
פריסות גמישות ופיריסות רשתות סימנו שינוי ביכולת להביע את עצמכם באמצעות פריסות באינטרנט. עם זאת, האלגוריתם הזה היה שונה באופן מהותי מהאלגוריתם של פריסת הבלוק שקדמו לו.
בפריסה של בלוקים (בכמעט כל המקרים), המנוע צריך לבצע פריסה של כל הצאצאים רק פעם אחת. זה מעולה לביצועים, אבל בסופו של דבר הוא לא יבטא את המידה הרצויה של מפתחי האתרים.
לדוגמה, לרוב רוצים שהגודל של כל הצאצאים יתרחב לגודל של הגדול ביותר. כדי לתמוך בכך, בפריסת ההורה (גמיש או רשת) יבצע מעבר מידה כדי לקבוע את הגודל של כל אחד מהילדים, ולאחר מכן כרטיס פריסה כדי לאפשר לכל הילדים להתאים לגודל הזה. זוהי ברירת המחדל גם בפריסה של גמישות וגם בפריסה של רשת.
פריסות שני המעבר האלה היו מקובלות בהתחלה מבחינת ביצועים, כי אנשים בדרך כלל לא הוצבו בקידומות עמוקות. עם זאת, ככל שהצלחנו לפתח תוכן מורכב יותר, הבחנו בבעיות משמעותיות בביצועים. אם לא שומרים את התוצאה של שלב המדידה במטמון, עץ הפריסה יתבצע בין המצב measure לבין המצב הסופי layout.
בעבר, כדי להתמודד עם ירידה חדה כזו בביצועים, ניסינו להוסיף מטמון ספציפי מאוד לפריסת Flex ולפריסת רשת. הפתרון הזה עבד (והגענו רחוק מאוד עם Flex), אבל היינו צריכים להתמודד כל הזמן עם באגים של ביטול תוקף מוגזם או מוגבל מדי.
LayoutNG מאפשר לנו ליצור מבני נתונים מפורשים גם לקלט וגם לפלט של הפריסה, ונוסף על כך יצרנו מטמון של מעברי המדידה והפריסה. כך המורכבות חוזרת ל-O(n), וכתוצאה מכך מתקבלים ביצועים לינאריים צפויים למפתחי אתרים. אם יקרה מקרה שבו פריסת האתר תתבצע בשלוש חזרות, פשוט נסנכרן גם את החזרה הזו במטמון. כך נוכל להציג בעתיד מצבי פריסה מתקדמים יותר בבטחה. זוהי דוגמה לאופן שבו RenderingNG פותח את האפשרות להרחבה בכל התחומים. במקרים מסוימים, פריסת רשת עשויה לדרוש פריסות של שלושה מעברים, אבל זה נדיר מאוד כרגע.
גילינו שכשמפתחים נתקלים בבעיות בביצועים ספציפית עם הפריסה, בדרך כלל הסיבה לכך היא באג זמן פריסה מעריכית ולא התפוקה הגולמית של שלב הפריסה של צינור עיבוד הנתונים. אם שינוי מצטבר קטן (רכיב אחד שמשנה מאפיין CSS אחד) גורם לזמן פריסה של 50-100 אלפיות השנייה, סביר להניח שמדובר באג בפריסה מעריכית.
לסיכום
נושא הפריסה הוא נושא מורכב מאוד, ולא התייחסנו לכל סוגי הפרטים המעניינים, כמו אופטימיזציה של פריסה בתוך שורה (הסבר מפורט על אופן הפעולה של כל מערכת המשנה של הטקסט והפריסה בתוך שורה). גם הרעיונות שצוינו כאן הם רק קצה המזלג, וחלק גדול מהפרטים לא הוזכר. עם זאת, אני מקווה שראינו איך שיפור שיטתי של הארכיטקטורה של המערכת יכול להוביל לרווחים עצומים בטווח הארוך.
עם זאת, ברור לנו שעדיין יש לנו הרבה עבודה לפנינו. אנחנו מודעים לבעיות מסוגים שונים (גם בנושא ביצועים וגם בנושא תקינות) שאנחנו פועלים כדי לפתור, ואנחנו שמחים על תכונות פריסה חדשות שיתווספו ל-CSS. אנחנו מאמינים שהארכיטקטורה של LayoutNG מאפשרת לפתור את הבעיות האלה בצורה בטוחה ופשוטה.
תמונה אחת (אתם יודעים איזו מהן!) של Una Kravets.