Detalhes de renderização de RenderNG: LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

Sou Ian Kilpatrick, líder de engenharia da equipe de layout do Blink, junto com Koji Ishii. Antes de trabalhar na equipe do Blink, Eu era engenheiro de front-end (antes de o Google assumir o papel de "engenheiro de front-end"), criando recursos nos apps Documentos Google, Drive e Gmail. Depois de cerca de cinco anos nessa função, arrisquei muito mudar para a equipe da Blink, a aprender C++ no trabalho com eficiência, e estamos tentando aproveitar a complexa base de código do Blink. Até hoje, entendo apenas uma parte relativamente pequena dele. Agradeço pelo tempo que me deu durante esse período. Fiquei tranquilo com o fato de muitos "recuperar engenheiros de front-end" fizeram a transição para se tornar um "engenheiro de navegadores" antes de mim.

Minha experiência anterior me orientou pessoalmente na equipe do Blink. Como engenheiro de front-end, sempre me deparava com inconsistências de navegador, problemas de desempenho, bugs de renderização e recursos ausentes. O LayoutNG foi uma oportunidade para eu ajudar a corrigir sistematicamente esses problemas no sistema de layout do Blink, e representa a soma do trabalho de vários engenheiros esforços 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 geral das arquiteturas de mecanismos de layout

Anteriormente, a árvore de layout do Blink era o que vou chamar 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 pelo pai, a posição dos pontos flutuantes e as informações de saída 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 ocorreu uma mudança de estilo, marcamos esse objeto como sujo e, da mesma forma, todos os seus pais na árvore. Quando a fase de layout do pipeline de renderização foi executada, Depois, limparíamos a árvore, percorreríamos os objetos sujos e executaríamos o layout para que eles tivessem um estado limpo.

Descobrimos que essa arquitetura resultou em muitas classes de problemas, que vamos descrever a seguir. Mas, primeiro, vamos voltar e considerar quais são as entradas e saídas do layout.

A execução do layout em um nó dessa árvore pega conceitualmente o "Style mais DOM", e quaisquer restrições pai do sistema de layout pai (grade, bloco ou flexível), executa o algoritmo de restrição de layout e produz um resultado.

O modelo conceitual descrito anteriormente.

A 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 immutable completamente novo chamado immutable.

A árvore de fragmentos.

Abordei árvore de fragmentos imutável anteriormente, descrevendo como ele foi projetado para reutilizar grandes porções da árvore anterior para layouts incrementais.

Além disso, armazenamos o objeto de restrições pai que gerou esse fragmento. Ela é usada como uma chave de cache, que será abordada em mais detalhes abaixo.

O algoritmo de layout em linha (texto) também foi reescrito para corresponder à nova arquitetura imutável. Ela não apenas produz representação de lista fixa imutável para layout inline, mas também apresenta armazenamento em cache em nível de parágrafo para um novo layout mais rápido, forma por parágrafo para aplicar recursos de fonte em elementos e palavras um novo algoritmo bidirecional Unicode usando ICU, muitas correções de correções e muito mais.

Tipos de bugs de layout

De modo geral, os bugs de layout se enquadram em quatro categorias: cada um com diferentes causas raiz.

Correção

Quando pensamos em bugs no sistema de renderização, normalmente pensamos na 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, gastávamos boa parte do nosso tempo, e, no processo, brigamos constantemente com o sistema. Um modo de falha comum era aplicar uma correção bem direcionada para um bug, mas descobrir, semanas depois, que causamos uma regressão em outra parte (parecidamente não relacionada) do sistema.

Como descrito em postagens anteriores, isso é um sinal de um sistema muito frágil. Especificamente para o layout, não tínhamos um contrato limpo entre as classes, fazendo com que os engenheiros de navegadores dependam do estado que não deveriam, ou interpretam 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, relacionadas ao layout flexível. Cada correção causava um problema de correção ou desempenho em parte do sistema, levando a outro bug.

Agora que o LayoutNG define claramente o contrato entre todos os componentes no 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), permitindo 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 Stable, normalmente não tem testes associados no repositório WPT, e não resulta de um mal-entendido dos contratos dos componentes. Além disso, como parte da nossa política de correção de bugs, sempre adicionamos um novo teste 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 do CSS faz o bug desaparecer em um passe de mágica, você se depara com um problema de subinvalidação. Efetivamente, uma parte da árvore mutável foi considerada limpa, mas devido a alguma mudança nas restrições do pai, ele não representa a saída correta.

Isso é muito comum no uso da (percorrer a árvore de layout duas vezes para determinar o estado final) descritos abaixo. Antes, nosso código teria esta aparência:

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();
}

A correção para esse tipo de problema normalmente causa uma grave regressão de desempenho, (veja invalidação excessiva abaixo) e foi muito delicado para ser corrigido.

Hoje, conforme descrito acima, temos um objeto de restrições pai imutável que descreve todas as entradas do layout pai para o filho. Isso é armazenado com o fragmento imutável resultante. Por isso, temos um local centralizado em que diferenciamos essas duas entradas para determinar se as filhas precisam ter outra transmissão de layout realizada. Essa lógica de diferenciação é complicada, mas bem contida. A depuração dessa classe de problemas de subinvalidação normalmente resulta na inspeção manual das duas entradas. e decidir o que mudou na entrada para que outra passagem de layout seja necessária.

As correções para essa diferenciação de código costumam ser simples, e podem ser facilmente testáveis por unidade, devido à simplicidade de criação desses objetos independentes.

Comparando uma imagem com largura fixa e porcentagem de largura.
Um elemento de largura/altura fixa não importa se o tamanho disponível atribuído a ele aumenta, ao contrário de largura/altura baseada em porcentagem. 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. 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 resultou na mesma saída.

No exemplo abaixo, estamos simplesmente alternando uma propriedade CSS entre dois valores. No entanto, isso resulta em um "crescimento infinito" retângulo.

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

Com nossa árvore mutável anterior, foi incrivelmente fácil introduzir bugs como este. Se o código cometeu o erro de ler o tamanho ou a posição de um objeto no momento ou cenário incorreto (como não "limpamos" o tamanho ou a posição anterior, por exemplo), adicionaríamos imediatamente um bug de histórico sutil. Esses bugs normalmente não aparecem nos testes, já que a maioria dos testes se concentra em um único layout e renderização. O mais preocupante é que sabíamos que parte dessa hetese era necessária para que alguns modos de layout funcionassem corretamente. Havia bugs em que usávamos uma otimização para remover uma passagem de layout, mas também apresentar um bug já que o modo de layout exigia duas transmissões para chegar à saída correta.

Uma árvore demonstrando os problemas descritos no texto anterior.
Dependendo das informações anteriores de resultado do layout, resulta 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 fosse permitido, mitigamos amplamente essa classe de bug no sistema de layout.

Invalidação excessiva e desempenho

Isso é o oposto direto da classe de bugs da subinvalidação. Muitas vezes, ao corrigir um bug de subinvalidação, isso causava um abismo de 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.

Ascensão das duas passagens e falésias de desempenho

Os layouts 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 bloco que veio antes deles.

O layout em bloco (em quase todos os casos) exige apenas 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 Web querem.

Por exemplo: muitas vezes você quer que o tamanho de todos os filhos seja expandido para o tamanho do maior. Para isso, o layout pai (flexível ou grade) vai realizar uma passagem de medição para determinar o tamanho de cada um dos filhos, em seguida, uma passagem de layout para esticar todos os filhos até esse tamanho. Esse é o comportamento padrão para os layouts flexível e em grade.

Dois conjuntos de caixas, o primeiro mostra o tamanho intrínseco das caixas na passagem de medição, o segundo no layout, todos com altura igual.

Inicialmente, esses layouts de duas passagens eram aceitáveis em termos de desempenho, já que as pessoas normalmente não os aninhavam profundamente. No entanto, começamos a ver problemas de desempenho significativos à medida que conteúdos mais complexos surgiam. Se você não armazenar em cache o resultado da fase de medição, a árvore de layout se alternará entre o estado de measure e o estado final de layout.

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

Anteriormente, tentávamos adicionar caches muito específicos ao layout flexível e de grade para combater esse tipo de abismo de desempenho. Isso funcionou (e chegamos muito longe com o Flex), mas estavam sempre lutando contra bugs de invalidação e mais.

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 de volta a complexidade para O(n), resultando em desempenho previsivelmente linear para desenvolvedores Web. Se houver um caso em que um layout faça o layout de três etapas, simplesmente armazenaremos a transmissão em cache. Isso pode abrir oportunidades para introduzir com segurança modos de layout mais avançados com segurança no futuro. Um exemplo de como o RenderingNG desbloqueia a extensibilidade em toda a linha. Em alguns casos, o layout de grade pode exigir layouts de três passagens, mas é extremamente raro no momento.

Descobrimos que, quando os desenvolvedores têm 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 altera uma única propriedade CSS) resultar em um layout de 50 a 100 ms, provavelmente é um bug de layout exponencial.

Resumo

O layout é uma área extremamente complexa, e não cobrimos todos os detalhes interessantes, como otimizações de layout inline. (realmente como funciona todo o subsistema in-line e de texto), e até os conceitos que abordamos aqui são apenas superficiais, e muitos detalhes. No entanto, esperamos que tenhamos mostrado como a melhoria sistemática da arquitetura de um sistema pode gerar grandes ganhos 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 estão animados com os novos recursos de layout que serão lançados no CSS. Acreditamos que a arquitetura do LayoutNG torna a solução desses problemas segura e fácil.

Uma imagem (você sabe qual!) de Una Kravets.