Impedimento de instabilidade para melhor desempenho de renderização

Tom Wiltzius
Tom Wiltzius

Introdução

Você quer que seu app da Web seja responsivo e suave ao fazer animações, transições e outros pequenos efeitos de interface. Garantir que esses efeitos não tenham instabilidade pode ser a diferença entre uma sensação "nativa" ou uma desajeitada e sem polimento.

Este é o primeiro de uma série de artigos sobre a otimização da performance de renderização no navegador. Para começar, vamos falar sobre por que é difícil criar animações suaves, o que é necessário para isso e algumas práticas recomendadas fáceis. Muitas dessas ideias foram apresentadas originalmente em "Jank Busters", uma palestra que Nat Duca e eu demos no Google I/O (vídeo) este ano.

Introdução à sincronização vertical

Os gamers de PC talvez conheçam esse termo, mas ele não é comum na Web: o que é v-sync?

Considere a tela do seu smartphone: ela é atualizada em um intervalo regular, geralmente (mas nem sempre) cerca de 60 vezes por segundo. A sincronização vertical (V-sync) se refere à prática de gerar novos frames apenas entre as atualizações da tela. Pense nisso como uma disputa entre o processo que grava dados no buffer de tela e o sistema operacional que lê esses dados para colocá-los na tela. Queremos que o conteúdo do frame em buffer mude entre essas atualizações, não durante elas. Caso contrário, o monitor vai mostrar metade de um frame e metade de outro, o que leva ao tearing.

Para ter uma animação suave, é preciso que um novo frame esteja pronto sempre que a tela for atualizada. Isso tem duas grandes implicações: o tempo para a renderização do frame (ou seja, quando o frame precisa estar pronto) e o orçamento do frame (ou seja, quanto tempo o navegador tem para produzir um frame). Você só tem o tempo entre as atualizações da tela para concluir um frame (~16 ms em uma tela de 60 Hz) e quer começar a produzir o próximo frame assim que o último for exibido na tela.

O timing é tudo: requestAnimationFrame

Muitos desenvolvedores Web usam setInterval ou setTimeout a cada 16 milissegundos para criar animações. Isso é um problema por vários motivos (vamos falar mais sobre isso em breve), mas as principais preocupações são:

  • A resolução do timer do JavaScript é de apenas alguns milissegundos
  • Dispositivos diferentes têm taxas de atualização distintas

Lembre-se do problema de tempo para a renderização do frame mencionado acima: você precisa de um frame de animação concluído, finalizado com qualquer JavaScript, manipulação de DOM, layout, pintura etc., para estar pronto antes da próxima atualização de tela. A baixa resolução do timer pode dificultar a conclusão dos frames de animação antes da próxima atualização da tela, mas a variação nas taxas de atualização da tela torna isso impossível com um timer fixo. Não importa qual seja o intervalo do timer, você vai sair lentamente da janela de tempo de um frame e acabar descartando um. Isso aconteceria mesmo se o timer fosse acionado com precisão de milissegundos, o que não vai acontecer (como os desenvolvedores descobriram). A resolução do timer varia dependendo se a máquina está na bateria ou conectada, pode ser afetada por guias em segundo plano que consomem recursos etc. Mesmo que isso seja raro (por exemplo, a cada 16 frames porque você estava atrasado por um milissegundo), você vai notar: vai perder vários frames por segundo. Você também vai gerar frames que nunca serão mostrados, o que desperdiça energia e tempo de CPU que poderiam ser usados para outras coisas no aplicativo.

Telas diferentes têm taxas de atualização diferentes: 60 Hz é comum, mas alguns smartphones têm 59 Hz, alguns laptops caem para 50 Hz no modo de baixo consumo e alguns monitores de computador têm 70 Hz.

Costumamos focar nos quadros por segundo (QPS) ao discutir o desempenho de renderização, mas a variação pode ser um problema ainda maior. Nossos olhos percebem os pequenos travamentos irregulares que uma animação mal cronometrada pode produzir.

A maneira de conseguir frames de animação com o tempo correto é com requestAnimationFrame. Ao usar essa API, você está pedindo ao navegador um frame de animação. Seu callback é chamado quando o navegador está prestes a produzir um novo frame. Isso acontece independentemente da taxa de atualização.

O requestAnimationFrame também tem outras propriedades interessantes:

  • As animações em guias em segundo plano são pausadas, o que economiza recursos do sistema e bateria.
  • Se o sistema não conseguir processar a renderização na taxa de atualização da tela, ele poderá limitar as animações e produzir o callback com menos frequência (por exemplo, 30 vezes por segundo em uma tela de 60 Hz). Embora isso reduza a taxa de frames pela metade, mantém a animação consistente. Como mencionado acima, nossos olhos são muito mais sensíveis à variação do que à taxa de frames. Uma taxa constante de 30 Hz é melhor do que uma de 60 Hz que perde alguns frames por segundo.

requestAnimationFrame já foi discutido em vários lugares. Consulte artigos como este do Creative JS para mais informações, mas é um primeiro passo importante para uma animação suave.

Orçamento de frames

Como queremos um novo frame pronto a cada atualização de tela, há apenas o tempo entre as atualizações para fazer todo o trabalho de criação de um novo frame. Em uma tela de 60 Hz, isso significa que temos cerca de 16 ms para executar todo o JavaScript, realizar o layout, a pintura e tudo o que o navegador precisa fazer para exibir o frame. Isso significa que, se o JavaScript dentro do callback requestAnimationFrame levar mais de 16 ms para ser executado, não será possível produzir um frame a tempo para a sincronização vertical.

16 ms não é muito tempo. Felizmente, as Ferramentas para Desenvolvedores do Chrome podem ajudar a descobrir se você está estourando o orçamento de frames durante o callback requestAnimationFrame.

Ao abrir a linha do tempo do DevTools e gravar essa animação em ação, fica claro que estamos muito acima do orçamento ao animar. Na linha do tempo, mude para "Frames" e confira:

Uma demonstração com layout demais
Uma demonstração com layout demais

Esses callbacks requestAnimationFrame (rAF) estão levando mais de 200 ms. Isos é uma ordem de magnitude muito longa para marcar um frame a cada 16 ms. Ao abrir um desses callbacks rAF longos, é possível ver o que está acontecendo: neste caso, muito layout.

O vídeo de Paul entra em mais detalhes sobre a causa específica do novo layout (ele está lendo scrollTop) e como evitá-lo. Mas o importante aqui é que você pode analisar o callback e investigar o que está demorando tanto.

Uma demonstração atualizada com layout muito reduzido
Uma demonstração atualizada com layout muito reduzido

Observe os tempos para a renderização do frame de 16 ms. Esse espaço em branco nos frames é o espaço que você tem para fazer mais trabalho (ou deixar que o navegador faça o trabalho que precisa fazer em segundo plano). Esse espaço em branco é uma coisa boa.

Outra fonte de instabilidade

A maior causa de problemas ao tentar executar animações com JavaScript é que outras coisas podem atrapalhar o callback rAF e até mesmo impedir que ele seja executado. Mesmo que o callback rAF seja simples e executado em apenas alguns milissegundos, outras atividades (como processar um XHR que acabou de chegar, executar manipuladores de eventos de entrada ou executar atualizações programadas em um timer) podem chegar de repente e ser executadas por qualquer período sem interrupção. Em dispositivos móveis, às vezes o processamento desses eventos pode levar centenas de milissegundos, durante os quais a animação fica completamente travada. Chamamos esses problemas de animação de instabilidade.

Não há uma solução mágica para evitar essas situações, mas existem algumas práticas recomendadas de arquitetura para se preparar para o sucesso:

  • Não faça muito processamento em manipuladores de entrada. Fazer muito JS ou tentar reorganizar a página inteira durante, por exemplo, um gerenciador onscroll é uma causa muito comum de instabilidade terrível.
  • Transfira o máximo de processamento possível (ou seja, tudo que leva muito tempo para ser executado) para o callback rAF ou os Web Workers.
  • Se você enviar trabalho para o callback rAF, tente dividi-lo em partes para processar apenas um pouco em cada frame ou adie o processamento até que uma animação importante termine. Assim, você pode continuar executando callbacks rAF curtos e animar sem problemas.

Para um ótimo tutorial sobre como enviar o processamento para callbacks requestAnimationFrame em vez de gerenciadores de entrada, consulte o artigo de Paul Lewis Animações mais leves, mais rápidas e mais eficientes com requestAnimationFrame.

Animação CSS

O que é melhor do que um JS leve nos callbacks de evento e rAF? Sem JS.

Anteriormente, dissemos que não há uma solução mágica para evitar a interrupção dos callbacks rAF, mas é possível usar a animação CSS para evitar totalmente a necessidade deles. No Google Chrome para Android em particular (e outros navegadores estão trabalhando em recursos semelhantes), as animações CSS têm a propriedade muito desejável de que o navegador geralmente pode executá-las mesmo que o JavaScript esteja em execução.

Há uma declaração implícita na seção acima sobre instabilidade: os navegadores só podem fazer uma coisa por vez. Isso não é estritamente verdadeiro, mas é uma boa suposição de trabalho: a qualquer momento, o navegador pode estar executando JS, realizando layout ou pintura, mas apenas um de cada vez. Isso pode ser verificado na visualização da linha do tempo do DevTools. Uma das exceções a essa regra são as animações CSS no Google Chrome para Android (e, em breve, no Chrome para computador, mas ainda não).

Sempre que possível, use uma animação CSS para simplificar o aplicativo e permitir que as animações sejam executadas sem problemas, mesmo enquanto o JavaScript é executado.

  // see http://paulirish.com/2011/requestanimationframe-for-smart-animating/ for info on rAF polyfills
  rAF = window.requestAnimationFrame;

  var degrees = 0;
  function update(timestamp) {
    document.querySelector('#foo').style.webkitTransform = "rotate(" + degrees + "deg)";
    console.log('updated to degrees ' + degrees);
    degrees = degrees + 1;
    rAF(update);
  }
  rAF(update);

Se você clicar no botão, o JavaScript será executado por 180 ms, causando instabilidade. Mas se, em vez disso, usarmos animações CSS, o jank não vai mais ocorrer.

(No momento em que este artigo foi escrito, a animação CSS só é livre de instabilidade no Google Chrome para Android, não no Chrome para computador.)

  /* tools like Modernizr (http://modernizr.com/) can help with CSS polyfills */
  #foo {
    +animation-duration: 3s;
    +animation-timing-function: linear;
    +animation-animation-iteration-count: infinite;
    +animation-animation-name: rotate;
  }

  @+keyframes: rotate; {
    from {
      +transform: rotate(0deg);
    }
    to {
      +transform: rotate(360deg);
    }
  }

Para mais informações sobre o uso de animações CSS, consulte artigos como este no MDN.

Conclusão

Em resumo:

  1. Ao animar, é importante produzir frames para cada atualização de tela. A animação com Vsync tem um impacto positivo enorme na forma como um app é percebido.
  2. A melhor maneira de conseguir uma animação com vsync no Chrome e em outros navegadores mais recentes é usar a animação em CSS. Quando você precisa de mais flexibilidade do que a animação CSS oferece, a melhor técnica é a animação baseada em requestAnimationFrame.
  3. Para manter as animações rAF íntegras e funcionando, verifique se outros manipuladores de eventos não estão atrapalhando a execução do callback rAF e mantenha os callbacks rAF curtos (<15 ms).

Por fim, a animação com vsync não se aplica apenas a animações simples de interface, mas também a animações Canvas2D, WebGL e até mesmo à rolagem em páginas estáticas. No próximo artigo desta série, vamos analisar o desempenho de rolagem com esses conceitos em mente.

Divirta-se com as animações!

Referências