Jestem Ian Kilpatrick i kierownik zespołu technicznego w zespole Blink. Przed rozpoczęciem pracy w zespole Blink byłem inżynierem interfejsu (zanim Google pełnił rolę „inżyniera interfejsu użytkownika”), tworząc funkcje w Dokumentach Google, Dysku i Gmailu. Po około 5 latach pełniłam tę rolę i przeniosłam się do zespołu Blink, skutecznie nauczyłam się C++ w pracy i próbowałam lepiej poznać niesamowicie złożoną bazę kodu Blink. Do dzisiaj wiem tylko niewielką część. Dziękuję za poświęcony mi czas. Pocieszył mnie fakt, że wielu „inżynierów zajmujących się przywracaniem danych” przeszło na „inżyniera przeglądarki” przed mną.
Moje wcześniejsze doświadczenie pomogło mi osobiście w zespole Blink. Jako inżynier frontendu stale napotykałem(-am) niespójności w przeglądarce, problemy z wydajnością, błędy renderowania i brakujące funkcje. Układ LayoutNG dał mi możliwość systematycznego rozwiązywania takich problemów w systemie układu Blink. Jest to efekt sumy wysiłków wielu inżynierów na przestrzeni lat.
W tym poście wyjaśnię, jak duża zmiana architektury może ograniczyć i wyeliminować różnego rodzaju błędy oraz problemy z wydajnością.
Widok z 30 tys. metrów na architektury układów
Wcześniej drzewo układu Blink było „zmiennym drzewem”.
Każdy obiekt w drzewie układu zawierał informacje wejściowe, np. dostępny rozmiar nałożony przez obiekt nadrzędny, pozycję każdej liczby zmiennoprzecinkowej oraz informacje output, na przykład końcową szerokość i wysokość obiektu lub jego pozycję na osi x i y.
Obiekty te były przechowywane pomiędzy renderowaniem. Po zmianie stylu oznaczaliśmy obiekt jako brudny, a także wszystkie jego elementy nadrzędne w drzewie. Po uruchomieniu fazy układu potoku renderowania oczyściliśmy drzewo, przejęliśmy wszystkie zanieczyszczone obiekty, a następnie uruchomiliśmy układ, aby przywrócić je do stanu czystego.
Zauważyliśmy, że taka architektura prowadzi do wielu klas problemów, które omówimy poniżej. Najpierw jednak cofnijmy się i zastanówmy, jakie są dane wejściowe i wyjściowe układu.
Uruchomienie układu w węźle w tym drzewie powoduje, że koncepcyjnie korzysta się ze stylu „Styl plus DOM” i wszelkich ograniczeń nadrzędnych z nadrzędnego systemu układu (siatka, blok lub elastyczny). Uruchamia algorytm ograniczeń układu i otrzymuje wynik.
Nasza nowa architektura nadaje ten model koncepcyjny. Nadal mamy drzewo układu, ale używamy go głównie do przechowywania danych wejściowych i wyjściowych układu. Na potrzeby danych wyjściowych generujemy zupełnie nowy, niezmienny obiekt o nazwie drzewo fragmentów.
Omówiliśmy już niezmienne drzewo fragmentów, w którym opisaliśmy, jak powinno wykorzystywać duże fragmenty poprzedniego drzewa na potrzeby układów przyrostowych.
Dodatkowo przechowujemy nadrzędny obiekt ograniczeń, który wygenerował ten fragment. Używamy go jako klucza pamięci podręcznej, który omówimy bardziej szczegółowo poniżej.
Algorytm układu wbudowanego (tekstowego) także zostaje przepisany, aby pasował do nowej, stałej architektury. Nie tylko zapewnia stałą reprezentację płaskiej listy dla układu wbudowanego, ale udostępnia też buforowanie na poziomie akapitu, które przyspiesza przekazywanie układu, uszczególnienie na poziomie akapitu, aby stosować cechy czcionek do różnych elementów i słów, nowy dwukierunkowy algorytm Unicode korzystający z ICU, dużo poprawek poprawek i nie tylko.
Rodzaje błędów układu
Ogólnie błędy w układach można podzielić na 4 kategorie, z których każda ma inne przyczyny.
Poprawność
Gdy myślimy o błędach w systemie renderowania, zwykle zwracamy uwagę na poprawność, np. „Przeglądarka A działa X, a przeglądarka B – działa Y”, lub „Przeglądarki A i B działają nieprawidłowo”. Wcześniej poświęcaliśmy na to wiele czasu, ale cały czas walczyliśmy z systemem. Częstym błędem było wprowadzenie bardzo ukierunkowanej poprawki w jednym błędzie, ale kilka tygodni później powodowało regresję w innej (pozornie niepowiązanej) części systemu.
Jak pisaliśmy w poprzednich postach, jest to oznaka bardzo łamliwego systemu. Szczególnie jeśli chodzi o układ, nie mieliśmy ujednoliconej umowy między klasami, co sprawiało, że inżynierowie przeglądarek musieli polegać na stanie, który nie powinien, lub błędnie zinterpretować jakąś wartość z innej części systemu.
Na przykład przez ponad rok mieliśmy łańcuch około 10 błędów związanych z elastycznym układem. Każda z nich powodowała problem z poprawnością lub wydajnością części systemu, co prowadziło do kolejnego błędu.
Teraz gdy w LayoutNG zostanie sprecyzowany umowa między wszystkimi komponentami systemu, ustaliliśmy, że zmiany można wprowadzać z większą pewnością. Bardzo cenimy sobie również znakomity projekt Web Platform Tests (WPT), który umożliwia udział wielu stronom we wspólnym zestawie testów internetowych.
Obecnie okazuje się, że jeśli wprowadzimy prawdziwą regresję na kanale stabilnym, zwykle nie ma ona powiązanych testów w repozytorium WPT i nie wynika z niewłaściwego zrozumienia składowych umów. Ponadto w ramach zasad dotyczących naprawiania błędów zawsze dodajemy nowy test WPT, dzięki czemu żadna przeglądarka nie powtarza tego samego błędu.
Zbyt duża liczba unieważnień
Jeśli kiedykolwiek zdarzyło Ci się napotkać tajemniczy błąd polegający na tym, że zmiana rozmiaru okna przeglądarki lub magiczne przełączenie właściwości CSS powoduje jego usunięcie, oznacza to, że mamy problem z nieprawidłową unieważnieniem. W efekcie część zmiennego drzewa została uznana za oczyszczoną, ale ze względu na pewną zmianę w ograniczeniach nadrzędnych nie było to prawidłowe dane wyjściowe.
Jest to bardzo częste w przypadku dwuprzebiegowych trybów układu (dwukrotnego przejścia po drzewie układu w celu określenia ostatecznego stanu układu) opisanych poniżej. Wcześniej nasz kod wyglądałby tak:
if (/* some very complicated statement */) {
child->ForceLayout();
}
Rozwiązaniem tego typu błędu zwykle jest:
if (/* some very complicated statement */ ||
/* another very complicated statement */) {
child->ForceLayout();
}
Rozwiązanie tego typu problemów zwykle powodowało poważną regresję wydajności (patrz informacje o nadmiarowych unieważnieniach poniżej), a jej naprawienie było bardzo trudne.
Obecnie (jak opisano powyżej) mamy stały obiekt ograniczeń nadrzędnych, który opisuje wszystkie dane wejściowe z układu nadrzędnego do jednostki podrzędnej. Zapisujemy to w wyniku niezmiennym fragmentu. Z tego względu znajdujemy się w jednym miejscu, w którym różnicujemy te 2 dane wejściowe, aby określić, czy dziecko potrzebuje innego układu. Ta odmienna logika jest skomplikowana, ale dobrze kontrolowana. Debugowanie tej klasy problemów z niedostatecznie unieważnianiem skutkuje zwykle ręczną kontrolą 2 wejść i decydowaniem, co się w nich zmieniło, tak aby konieczne było ponowne przekazywanie układu.
Poprawki w tym różniącym się kodzie są zwykle proste i dzięki łatwemu tworzeniu tych niezależnych obiektów można łatwo testować jednostki.
Różniący się kod w tym przykładzie to:
if (width.IsPercent()) {
if (old_constraints.WidthPercentageSize()
!= new_constraints.WidthPercentageSize())
return kNeedsLayout;
}
if (height.IsPercent()) {
if (old_constraints.HeightPercentageSize()
!= new_constraints.HeightPercentageSize())
return kNeedsLayout;
}
Histereza
Ta klasa błędów przypomina niedostateczne unieważnianie. Zasadniczo w poprzednim systemie bardzo trudno było zadbać o idempotentność układu. Oznacza to, że ponowne uruchomienie układu z tymi samymi danymi wejściowymi dało taki sam wynik.
W poniższym przykładzie po prostu przełączamy między dwiema wartościami właściwość CSS. Da to jednak prostokąt „nieskończenie rosnący”.
Przy poprzednim zmiennym drzewie wprowadzenie takich błędów było niezwykle proste. Jeśli kod błędnie odczyta rozmiar lub położenie obiektu w niewłaściwym momencie lub etapie (np. nie „wyczyściliśmy” poprzedniego rozmiaru lub położenia), natychmiast dodamy subtelny błąd histerezy. Takie błędy zwykle nie pojawiają się podczas testowania, ponieważ większość testów skupia się na pojedynczym układzie i renderowaniu. Co bardziej niepokojące, zdaliśmy sobie sprawę, że ta histeza jest potrzebna do prawidłowego działania niektórych trybów układu. Mieliśmy błędy, które wymagały optymalizacji, by usunąć kartę układu, ale wprowadzenie błędu, ponieważ tryb układu wymagał dwukrotnego przejścia w celu uzyskania poprawnych danych wyjściowych.
Ze względu na to, że mamy jawne struktury danych wejściowych i wyjściowych, a dostęp do poprzedniego stanu jest niedozwolony, znacznie ograniczyliśmy tę klasę błędu z systemu układów.
Nadmierne unieważnianie i skuteczność
To jest bezpośrednie przeciwieństwo błędów, które nie zawsze są w stanie nieprawidłowe. Naprawienie błędu polegającego na unieważnieniu treści często powodowało spadek wydajności.
Często musieliśmy dokonywać trudnych wyborów i zamiast skuteczności reklam musieliśmy stawiać na poprawność. W następnej sekcji dowiemy się, jak radziliśmy sobie z tego typu problemami z wydajnością.
Układ dwuprzejazdowy i klify związane z wydajnością
Elastyczność i układ siatki odzwierciedlają zmianę ekspresji układów w internecie. Algorytmy te różniły się jednak zasadniczo od stosowanego wcześniej algorytmu układu blokowego.
Układ blokowy (w prawie wszystkich przypadkach) wymaga, by wyszukiwarka wykonała układ na wszystkich jego elementach podrzędnych dokładnie raz. Jest to świetne rozwiązanie w zakresie wydajności, ale niestety nie musi być tak ekspresywne, jak tego oczekują programiści.
Na przykład często chcesz, aby rozmiar wszystkich elementów podrzędnych rozwijał się do największego rozmiaru. Aby to obsługiwać, układ nadrzędny (fleks lub siatka) przeprowadza przekazywanie pomiaru, aby określić, jak duże jest każde z elementów podrzędnych, a następnie przekazuje układ, aby rozciągnąć wszystkie elementy podrzędne do tego rozmiaru. To zachowanie jest domyślne w przypadku układu elastycznego i siatkowego.
Taki układ dwuprzebiegowy był początkowo akceptowalny pod względem wydajności, ponieważ klienci zwykle nie umieszczali ich głęboko. Jednak w miarę powstawania bardziej złożonych treści zauważyliśmy jednak poważne problemy z wydajnością. Jeśli nie zapiszesz w pamięci podręcznej wyniku fazy pomiaru, drzewo układu zacznie migać między stanem measure a końcowym stanem układu.
Wcześniej staraliśmy się dodawać bardzo konkretne pamięci podręczne do elastycznego układu siatki, aby walczyć z tego typu klifami wydajności. To pomogło (i doszliśmy bardzo daleko z Flex), ale stale zmagaliśmy się z błędami dotyczącymi unieważniania.
LayoutNG pozwala nam tworzyć wyraźne struktury danych dla danych wejściowych i wyjściowych w układzie. Do tego mamy pamięci podręczne dla kart pomiarów i układów. W ten sposób złożoność wróci do O(n), co daje przewidywalną liniową wydajność programistów stron internetowych. Jeśli zdarzy się już, że układ ma układ trójprzebiegowy, zapisujemy go w pamięci podręcznej. Może to stworzyć możliwość bezpiecznego wprowadzenia bardziej zaawansowanych trybów układu w przyszłości. Jest to przykład tego, jak RenderingNG zasadniczo zwiększa rozszerzalność. W niektórych przypadkach układ siatki może wymagać układów trzyprzejściowych, ale obecnie robi się to niezwykle rzadko.
Odkrywamy, że problemy z wydajnością w układzie przez programistów mają zwykle związek z błędem związanym z wykładniczym czasem układu, a nie z pełnej przepustowości etapu układu potoku. Jeśli mała zmiana przyrostowa (jeden element zmienia jedną właściwość CSS) skutkuje układem o długości 50–100 ms, jest to prawdopodobnie błąd układu wykładniczego.
W skrócie
Układ to niezwykle złożony obszar, więc nie omówiliśmy m.in. optymalizacji układu w tekście (faktycznie jak działa cały podsystem w tekście i w tekście), a nawet omówione tu koncepcje to tylko drobiazg, a wiele szczegółów dominuje. Mamy jednak nadzieję, że pokazaliśmy, jak systematyczne ulepszanie architektury systemu może w dłuższej perspektywie prowadzić do nadzwyczajnych korzyści.
Zdajemy sobie jednak sprawę, że czeka nas jeszcze wiele pracy. Zdajemy sobie sprawę z klas problemów (zarówno dotyczących skuteczności, jak i poprawności), nad którymi pracujemy, i cieszymy się, że wprowadzimy nowe funkcje układu w CSS. Jesteśmy przekonani, że architektura LayoutNG pozwala na bezpieczne i łatwe rozwiązywanie tych problemów.
Jeden obraz (wicie, który!) autorstwa Una Kravets.