Melhorar o desempenho no painel de camadas


O painel de camadas é a estrutura central de um arquivo do Figma. Renovamos a arquitetura desse painel com novas estratégias de computação e cache, acelerando em até 30% a 50% as interações em alguns dos arquivos mais complexos e volumosos.
Compartilhar Melhorar o desempenho no painel de camadas
Ilustrações de Chou Chia Yu
Se você já usou o Figma, provavelmente conhece o painel de camadas, que representa todos os elementos de design em uma lista hierárquica aninhada.
O painel de camadas foi criado há quase sete anos, quando os arquivos eram menores e o Figma tinha menos recursos. Hoje, é comum ver arquivos com dezenas de milhares de camadas. Começamos a observar interações lentas com o painel, principalmente em arquivos muito grandes. Além disso, o desempenho do painel de camadas também podia causar lentidão nas operações do editor, como arrastar ou digitar.
Para que essa parte essencial de nossa UI voltasse a ser rápida e fluida, reconstruímos a arquitetura do painel de camadas do zero.
A abordagem anterior
Os arquivos do Figma são árvores de nós, semelhantes a arquivos HTML. Cada nó tem um conjunto de propriedades (nome, visibilidade etc.) e uma lista de nós filhos. Para renderizar a UI do painel de camadas, construímos um grande objeto JavaScript contendo todos os dados sobre cada nó.
Na arquitetura original, montávamos (e, para acompanhar as mudanças nas camadas, remontávamos) esse objeto por meio de uma única passagem de coleta de dados. Começando de cada nó de nível superior na página atual, reuníamos um conjunto de dados sobre o nó. Em seguida, se o nó estivesse expandido pelo ícone de seta, acessávamos recursivamente seus filhos, coletávamos os mesmos dados sobre eles e repetíamos o processo para os filhos desses filhos. Em arquivos grandes com muitos nós expandidos no painel, isso causava lentidão substancial, já que gerávamos repetidamente os dados para o painel de camadas.
Em última análise, essa arquitetura tinha dois pontos fracos principais:
- Computação excessiva: calculávamos dados para todos os nós expandidos, mesmo que apenas 20 a 30 linhas estivessem realmente visíveis para o usuário em uma tela típica.
- Computação com muita frequência: não usávamos muito o cache de cálculos incrementais. Essencialmente, qualquer alteração no painel de camadas, como a expansão de um nó, exigia um recálculo completo do zero para cada nó expandido.
Cada um desses problemas tinha uma correção diferente.
Correção 1: computação em duas passagens
Para resolver inicialmente o problema da computação excessiva, dividimos a fase de coleta de dados em duas passagens.
Na primeira passagem, buscamos apenas a lista ordenada de IDs de linhas que compõem o painel, deixando todos os outros dados para depois. Mesmo assim, o cálculo para chegar a essa lista de IDs não é trivial. Estas são algumas das partes complexas:
- Os filhos de quadros com layout automático são exibidos em ordem reversa.
- Certos tipos de nós, como widgets e notas do FigJam, não mostram seus filhos no painel.
- Cabeçalhos fixos/roláveis nos quadros de prototipagem dividem os filhos em duas subseções.
- Quadros e componentes de nível superior são fixos.

Na segunda passagem, calculamos dados para os nós (nomes, ícones, estado de bloqueio, visibilidade, estado de seleção etc.). Com os IDs da primeira passagem, podemos calcular dados apenas para os nós que importam: ou seja, os nós que estão visíveis na janela.
A técnica de gerenciamento de janelas (windowing) é comum em frameworks de UI: em uma lista rolável, somente os itens visíveis na tela são renderizados. A arquitetura anterior usava gerenciamento de janelas, mas como tudo era calculado em uma única passagem, coletávamos os dados completos para os nós que não estavam visíveis na tela e não eram renderizados.
Com a mudança para uma abordagem de duas passagens, dados como nomes, ícones, status de bloqueio, visibilidade, estado de seleção e outros são calculados apenas para algumas dezenas de linhas, em vez de potencialmente centenas de milhares de linhas.
Correção 2: Cache de dados derivados
Para resolver o segundo problema, a computação com muita frequência, recorremos a uma primitiva de plataforma que desenvolvemos nos últimos anos: propriedades derivadas.
Cada nó do Figma expõe um conjunto de campos, que funcionam como variáveis mutáveis: elas podem ser escritas e lidas pelo código do aplicativo.
No entanto, muitas propriedades dos nós não são campos em si, mas são calculadas a partir de outros campos e propriedades. Como exemplo, considere a posição absoluta de um nó: seu deslocamento relativo ao ponto (0, 0) no centro da tela de trabalho. Os nós não armazenam diretamente esse valor. Em vez disso, eles armazenam sua posição relativa: o deslocamento a partir da posição de seus pais. Isso facilita a movimentação e rotação de grandes árvores de nós de uma só vez.
Mas como calculamos a posição absoluta de um nó para renderização? Podemos derivar essa propriedade por meio de um cálculo como:
Self.AbsolutePosition = Parent.AbsolutePosition + Self.RelativePosition
A primitiva de propriedades derivadas oferece uma maneira formal e automática de declarar essas propriedades e mantê-las atualizadas, semelhante a fórmulas em uma planilha que fazem referência a outras células. Quando usa o sistema, você recebe mais algumas vantagens de graça:
- Cada propriedade derivada sabe de quais campos e propriedades derivadas ela depende. Essas informações são compiladas em uma representação otimizada de um gráfico de dependências.
- Existem várias políticas de cache com diversos equilíbrios entre velocidade e uso de memória.
- Por padrão, as propriedades derivadas somente são calculadas quando lidas, um recurso chamado “lazy” (preguiçoso).
A estrutura em árvore do painel de camadas se encaixa naturalmente nas propriedades derivadas.
Antes, recalculávamos os dados do nó sempre que houvesse uma mudança na árvore. Com as propriedades derivadas, podemos modelar explicitamente a estrutura do painel de camadas e as dependências dentro dele, ou seja, somente as subseções relevantes da árvore precisam ser atualizadas. Também podemos armazenar em cache de forma eficiente os cálculos do painel no nível de subárvore.
Então, na primeira passagem de dados, quando calculamos a lista ordenada de linhas, expressamos as coisas assim (simplificado para clareza):
Self.OrderedChildren =
If(Self.Expanded)
Children.FlatMap(Child => Child.OrderedChildren)
Else
[]A propriedade OrderedChildren de um nó só seria invalidada se seu estado de expansão mudasse ou se o OrderedChildren de qualquer um de seus filhos mudasse.
Como exemplo, imagine um arquivo como este:
Também evitamos alguns cálculos usando o recurso preguiçoso das propriedades derivadas. Por exemplo, OrderedChildren é uma propriedade preguiçosa. Se um nó nunca for expandido, então seu OrderedChildren nunca será solicitado e, portanto, não será calculado.
Imagine se você expandisse o Quadro B para revelar seus filhos. No sistema antigo, começávamos no Quadro A e recalculávamos tudo, incluindo informações sobre todos os 100 filhos do Quadro C. Mas quando expressamos as coisas como propriedades derivadas, apenas informações sobre os Quadros A e B precisam ser calculadas. Assim que chegamos ao Quadro C, podemos parar, porque sabemos que é possível reutilizar os valores antigos.
Uso de memória
A adição de um cache sempre aumenta o uso de memória. Aceitamos pequenos aumentos de memória quando eles levam a melhorias no desempenho. No entanto, usar memória demais aumenta a taxa de erros frustrantes de “falta de memória”.
Esta é uma implementação simples de cache de OrderedChildren:
Frame A: [B, C, D]
Frame B: [C, D]
Frame C: [D]
Frame D: []O custo de memória segue uma soma triangular. A adição 1 + 2 + … + n resulta em n(n + 1)/2.
Cada nível da árvore precisa armazenar em cache os IDs de todos os seus descendentes. Isso cria uma soma triangular, levando a um uso de memória O(n²). Em árvores muito profundas, podemos consumir até dezenas de megabytes, o que não é aceitável.
Uma corda é uma maneira semelhante à árvore de armazenar sequências em pedaços menores. Ela permite que edições e concatenações reutilizem peças existentes, em vez de reconstruir toda a sequência.
Em vez disso, implementamos o cache de OrderedChildren usando uma lista recursiva, semelhante a uma estrutura de dados do tipo corda. Esse recurso maximizou o compartilhamento estrutural, usando ponteiros em vez de cópias na composição. O uso de memória tornou-se estritamente proporcional ao número de nós exclusivos (O(n)), com um pequeno custo adicional de travessias de ponteiro durante as leituras. Assim, reduzimos em até 99% o uso de memória em comparação com o protótipo original.


Impacto no desempenho
Como resultado desse trabalho, observamos melhorias drásticas no desempenho. Interações críticas do painel de camadas, como expandir/recolher linhas ou alternar visibilidade/estado de bloqueio, se tornaram de 30% a 50% mais rápidas em alguns dos maiores e mais complexos arquivos.
Os maiores ganhos de desempenho foram observados no painel de camadas, mas o impacto beneficiou todo o editor do Figma Design. Ao reduzir o trabalho desnecessário, também melhoramos o desempenho geral de renderização, aumentando o FPS e reduzindo os quadros lentos. Como resultado, operações que anteriormente eram lentas, como digitar, arrastar e selecionar cores em alguns arquivos complexos, agora são sempre executadas com fluidez.
Ainda temos um longo caminho a percorrer, e estamos sempre procurando maneiras de melhorar o desempenho em todo o Figma.
Estamos contratando engenheiros!
Saiba mais sobre a vida na Figma e confira nossas vagas em aberto.
Agradecemos a Eli Fitch, Amy Shan, Russell McClellan, Josh Ferrell, Connor Smith, Elynn Lee e Alex Triana por suas contribuições.


