Sou Ian Kilpatrick, chefe de engenharia na equipe de layout do Blink e Koji Ishii. Antes de trabalhar na equipe do Blink, eu era engenheiro de front-end (antes de o Google assumir o papel de "engenheiro front-end"), desenvolvendo recursos nos apps Documentos Google, Drive e Gmail. Depois de cerca de cinco anos nessa função, apostei alto na mudança para a equipe do Blink, aprendi código C++ no trabalho e tentei aproveitar a base de código extremamente complexa da Blink. Ainda hoje, entendo apenas uma pequena parte disso. Agradeço pelo tempo que me deu durante esse período. Fiquei aliviado com o fato de que muitos "engenheiros de front-end de recuperação" fizeram a transição para ser um "engenheiro de navegadores" antes de mim.
Minha experiência anterior me orientou pessoalmente na equipe da Blink. Como engenheiro de front-end, eu constantemente me deparou com inconsistências do navegador, problemas de desempenho, bugs de renderização e recursos ausentes. O LayoutNG foi uma oportunidade para ajudar a corrigir sistematicamente esses problemas no sistema de layout da Blink e representa a soma dos esforços de muitos engenheiros ao longo dos anos.
Nesta postagem, vou explicar como uma grande mudança de arquitetura como essa pode reduzir e mitigar vários tipos de bugs e problemas de desempenho.
Uma visão de 900 metros das arquiteturas do mecanismo de layout
Antes, a árvore de layout do Blink era o que chamarei de "árvore mutável".
Cada objeto na árvore de layout continha informações de input, como o tamanho disponível imposto por um pai, a posição de todos os pontos flutuantes e informações de output, por exemplo, a largura e a altura finais do objeto ou a posição x e y dele.
Esses objetos eram mantidos entre as renderizações. Quando ocorria uma mudança de estilo, marcamos o objeto como sujo, assim como todos os pais na árvore. Quando a fase de layout do pipeline de renderização era executada, limpamos a árvore, percorremos os objetos sujos e executamos o layout para que eles fiquem limpos.
Descobrimos que essa arquitetura resultou em muitas classes de problemas, que vamos descrever abaixo. Mas, primeiro, vamos voltar e considerar quais são as entradas e saídas do layout.
A execução do layout em um nó nessa árvore usa conceitualmente o "Estilo mais DOM", e qualquer restrição pai do sistema de layout pai (grade, bloco ou flex), executa o algoritmo de restrição de layout e produz um resultado.
Nossa nova arquitetura formaliza esse modelo conceitual. Ainda temos a árvore de layout, mas ela é usada principalmente para manter as entradas e saídas do layout. Para a saída, geramos um objeto imutável completamente novo chamado árvore de fragmentos.
Abordamos a árvore de fragmentos imutáveis anteriormente, descrevendo como ela foi projetada para reutilizar grandes partes da árvore anterior para layouts incrementais.
Além disso, armazenamos o objeto de restrições pai que gerou esse fragmento. Usamos isso como uma chave de cache. Falaremos mais sobre isso abaixo.
O algoritmo de layout in-line (texto) também é reescrito para corresponder à nova arquitetura imutável. Ela não apenas produz a representação de lista simples imutável para o layout inline, mas também oferece armazenamento em cache no nível do parágrafo para reformulação mais rápida, forma por parágrafo para aplicar recursos de fonte a elementos e palavras, um novo algoritmo bidirecional Unicode usando ICU, muitas correções de correção e muito mais.
Tipos de bugs de layout
Em termos gerais, os bugs de layout se enquadram em quatro categorias diferentes, cada uma com uma causa raiz.
Correção
Quando pensamos sobre bugs no sistema de renderização, normalmente pensamos em correção. Por exemplo: "O navegador A tem um comportamento X, enquanto o navegador B tem um comportamento Y" ou "Os navegadores A e B estão corrompidos". Antes, era nisso que gastávamos muito tempo e, nesse processo, trabalhávamos constantemente com o sistema. Um modo de falha comum era aplicar uma correção muito direcionada a um bug, mas descobrimos, semanas depois, que causamos uma regressão em outra parte (aparentemente não relacionada) do sistema.
Conforme descrito em postagens anteriores, esse é um sinal de sistema muito frágil. Especificamente para o layout, não tínhamos um contrato claro entre nenhuma classe, fazendo com que os engenheiros do navegador dependam do estado que não deveriam ou interpretem incorretamente algum valor de outra parte do sistema.
Por exemplo, em um momento, tínhamos uma cadeia de aproximadamente 10 bugs ao longo de mais de um ano, relacionados ao layout flexível. Cada correção causava um problema de correção ou desempenho em parte do sistema, levando a mais um bug.
Agora que o LayoutNG define claramente o contrato entre todos os componentes do sistema de layout, descobrimos que podemos aplicar as alterações com muito mais confiança. Também nos beneficiamos muito do excelente projeto Web Platform Tests (WPT), que permite que várias partes contribuam para um pacote comum de testes da Web.
Hoje descobrimos que, se lançarmos uma regressão real no nosso canal estável, ela normalmente não tem testes associados no repositório WPT e não é resultado de uma interpretação errada dos contratos de componentes. Além disso, como parte da nossa política de correção de bugs, sempre adicionamos um novo teste de WPT, ajudando a garantir que nenhum navegador cometa o mesmo erro novamente.
Invalidação
Se você já teve um bug misterioso em que redimensionar a janela do navegador ou alternar uma propriedade CSS magicamente faz o bug desaparecer, você encontrou um problema de subinvalidação. Efetivamente, uma parte da árvore mutável foi considerada limpa, mas devido a algumas mudanças nas restrições das mães, ela não representava a saída correta.
Isso é muito comum com os modos de layout de duas passagens (percursos da árvore de layout duas vezes para determinar o estado final do layout) descritos abaixo. Antes, o código seria semelhante a este:
if (/* some very complicated statement */) {
child->ForceLayout();
}
Uma correção para esse tipo de bug normalmente seria:
if (/* some very complicated statement */ ||
/* another very complicated statement */) {
child->ForceLayout();
}
Uma correção para esse tipo de problema normalmente causava uma severa regressão de desempenho (consulte a invalidação excessiva abaixo) e foi muito delicada para ser corrigida.
Hoje, como descrito acima, temos um objeto de restrições pai imutável que descreve todas as entradas do layout pai para o filho. Armazenamos isso com o fragmento imutável resultante. Por isso, temos um local centralizado em que diferenciamos essas duas entradas para determinar se o filho precisa que outra passagem de layout seja realizada. Essa lógica de diferenciação é complicada, mas bem contida. Depurar essa classe de problemas de subinvalidação normalmente resulta na inspeção manual das duas entradas e em decidir o que mudou na entrada para que outra transmissão de layout seja necessária.
As correções nesse código de diferenciação costumam ser simples e facilmente testáveis por unidade, devido à simplicidade da criação desses objetos independentes.
O código de diferenciação do exemplo acima é:
if (width.IsPercent()) {
if (old_constraints.WidthPercentageSize()
!= new_constraints.WidthPercentageSize())
return kNeedsLayout;
}
if (height.IsPercent()) {
if (old_constraints.HeightPercentageSize()
!= new_constraints.HeightPercentageSize())
return kNeedsLayout;
}
Histerese
Essa classe de bugs é semelhante à subinvalidação. Essencialmente, no sistema anterior, era muito difícil garantir que o layout fosse idempotente, ou seja, a nova execução do layout com as mesmas entradas resulta na mesma saída.
No exemplo abaixo, estamos simplesmente trocando uma propriedade CSS entre dois valores. No entanto, isso resulta em um retângulo "crescimento infinito".
Com nossa árvore mutável anterior, foi muito fácil introduzir bugs como essa. Se o código cometeu o erro de ler o tamanho ou a posição de um objeto no momento ou no estágio incorreto, como não "limpamos" o tamanho ou a posição anterior, adicionaríamos imediatamente um bug de história sutil. Esses bugs normalmente não aparecem nos testes, já que a maioria dos testes se concentra em um único layout e renderização. Ainda mais preocupante, sabíamos que parte dessa história era necessária para que alguns modos de layout funcionassem corretamente. Tínhamos bugs em que realizamos uma otimização para remover uma passagem de layout, mas introduzimos um "bug", já que o modo de layout exigia duas transmissões para chegar ao resultado correto.
Com o LayoutNG, como temos estruturas de dados de entrada e saída explícitas, e o acesso ao estado anterior não é permitido, mitigamos amplamente essa classe de bug do sistema de layout.
Excesso de invalidação e desempenho
Isso é o oposto direto da classe de subinvalidação de bugs. Muitas vezes, ao corrigir um bug de sub-invalidação, acionamos um aborrecimento no desempenho.
Muitas vezes, tivemos que fazer escolhas difíceis favorecendo a correção em vez do desempenho. Na próxima seção, vamos nos aprofundar em como mitigamos esses tipos de problemas de desempenho.
Aumento dos layouts de passagem dupla e penhascos de desempenho
O layout flexível e em grade representaram uma mudança na expressividade dos layouts na Web. No entanto, esses algoritmos eram fundamentalmente diferentes do algoritmo de layout em blocos que antecedeu a criação.
O layout de blocos (em quase todos os casos) exige que o mecanismo execute o layout em todos os filhos exatamente uma vez. Isso é ótimo para o desempenho, mas acaba não sendo tão expressivo quanto os desenvolvedores da Web querem.
Por exemplo, você geralmente quer que o tamanho de todos os filhos se expanda para o tamanho do maior. Para oferecer suporte a isso, o layout pai (flexível ou grade) executa uma passagem de medição para determinar o tamanho de cada filho e, em seguida, uma transmissão de layout para estender todos os filhos até esse tamanho. Esse comportamento é o padrão para o layout flexível e em grade.
Esses layouts de duas passagens eram inicialmente aceitáveis em termos de desempenho, porque as pessoas normalmente não os aninhavam profundamente. No entanto, começamos a observar problemas significativos de desempenho à medida que conteúdos mais complexos surgiram. Se você não armazenar o resultado da fase de medição em cache, a árvore de layout vai alternar entre os estados measure e layout final.
Anteriormente, tentávamos adicionar caches muito específicos ao layout flexível e em grade para combater esse tipo de agravamento de desempenho. Isso funcionou (e fomos muito longe com o Flex), mas lutamos constantemente com bugs de invalidação e de invalidação.
O LayoutNG permite criar estruturas de dados explícitas para a entrada e a saída do layout. Além disso, criamos caches das passagens de medição e layout. Isso traz a complexidade de volta para o O(n), resultando em um desempenho previsivelmente linear para desenvolvedores da Web. Se um layout estiver fazendo um layout de três etapas, simplesmente armazenaremos essa passagem em cache também. Isso pode abrir oportunidades para introduzir com segurança modos de layout mais avançados no futuro, um exemplo de como o RenderingNG desbloqueia a extensibilidade (link em inglês) em toda a placa. Em alguns casos, o layout de grade pode exigir layouts de três etapas, mas é extremamente raro no momento.
Descobrimos que, quando os desenvolvedores encontram problemas de desempenho especificamente com o layout, isso geralmente ocorre devido a um bug de tempo de layout exponencial, e não à capacidade bruta do estágio de layout do pipeline. Se uma pequena mudança incremental (um elemento que muda uma única propriedade CSS) resulta em um layout de 50 a 100 ms, isso é provavelmente um bug de layout exponencial.
Resumo
O layout é uma área extremamente complexa, e não abordamos todos os detalhes interessantes, como otimizações de layout inline (na verdade, como todo o subsistema inline e de texto funciona), e até os conceitos sobre os quais falamos aqui são apenas superficiais e abordamos muitos detalhes. No entanto, esperamos mostrar como a melhoria sistemática da arquitetura de um sistema pode levar a ganhos maiores a longo prazo.
Dito isso, sabemos que ainda temos muito trabalho pela frente. Estamos cientes das classes de problemas (desempenho e correção) que estamos trabalhando para resolver e estamos empolgados com os novos recursos de layout que chegarão ao CSS. Acreditamos que a arquitetura do LayoutNG torna a resolução desses problemas segura e tratável.
Uma imagem (você sabe qual!) de Una Kravets