Detalhes de renderização de RenderNG: LayoutNG

Ian kilpatrick
Ian Kilpatrick
Koji isshi
Koji Ishi

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".

Mostra a árvore conforme descrito no texto a seguir.

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.

O modelo conceitual descrito anteriormente.

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.

A á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.

Comparar uma imagem com largura fixa e com porcentagem de largura.
Um elemento fixo de largura/altura não se importa se o tamanho disponível fornecido a ele aumenta. No entanto, largura/altura com base em porcentagem sim. O available-size é representado no objeto Parent Constraints e, como parte do algoritmo de diferenciação, realizará essa otimização.

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".

O vídeo e a demonstração mostram um bug de história no Chrome 92 e versões anteriores. Isso foi corrigido no Chrome 93.

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.

Uma árvore demonstrando os problemas descritos no texto anterior.
Dependendo das informações de resultado de layout anteriores, isso resultará em layouts não idempotentes

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.

Dois conjuntos de caixas. O primeiro mostra o tamanho intrínseco das caixas na passagem de medição, e o segundo no layout têm a mesma altura.

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.

Os layouts de uma, duas e três passagens explicados na legenda.
Na imagem acima, temos três elementos <div>. Um layout simples de uma única passagem (como o layout de blocos) visita três nós de layout (complexidade O(n). No entanto, para um layout de duas passagens (como flexível ou grade), isso pode resultar em O(2n) visitas neste exemplo.
Gráfico mostrando o aumento exponencial no tempo de layout.
Esta imagem e esta demonstração mostram um layout exponencial com o layout de grade. Isso foi corrigido no Chrome 93 como resultado da migração da grade para a nova arquitetura.

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