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 significar 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 de desempenho de renderização no navegador. Para começar, vamos abordar por que a animação suave é difícil e o que precisa acontecer para alcançá-la, além de algumas práticas recomendadas fáceis. Muitas dessas ideias foram apresentadas originalmente em "Jank Busters", uma palestra que Nat Duca e eu demos na Google I/O (vídeo) deste ano.

Introdução à sincronização vertical

Os jogadores de PC podem conhecer esse termo, mas ele é incomum na Web: o que é v-sync?

Pense na tela do seu smartphone: ela é atualizada em um intervalo regular, geralmente (mas nem sempre!) cerca de 60 vezes por segundo. A V-sync (ou sincronização vertical) se refere à prática de gerar novos frames apenas entre as atualizações de tela. Você pode pensar nisso como uma condição de corrida entre o processo que grava dados no buffer da 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, e não durante elas. Caso contrário, o monitor vai mostrar metade de um frame e metade de outro, levando a um rasgo.

Para ter uma animação suave, é necessário que um novo frame esteja pronto sempre que a tela for atualizada. Isso tem duas grandes implicações: o tempo de frame (ou seja, quando o frame precisa estar pronto) e o orçamento de 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 colocado na tela.

O tempo é tudo: requestAnimationFrame

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

  • A resolução do timer do JavaScript é da ordem de vários milissegundos
  • Dispositivos diferentes têm taxas de atualização diferentes

Lembre-se do problema de tempo de 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 perdendo um. Isso aconteceria mesmo que o timer fosse acionado com precisão de milissegundos, o que não acontece (como os desenvolvedores descobriram). A resolução do timer varia dependendo se a máquina está com a bateria ou está conectada. Ela 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 com um milissegundo de diferença), você vai notar que vários frames são descartados 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 fazer outras coisas no app.

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

Tendemos a nos concentrar nos frames por segundo (QPS) ao discutir a performance de renderização, mas a variação pode ser um problema ainda maior. Nossos olhos percebem os pequenos e irregulares entraves na animação que uma animação mal sincronizada pode produzir.

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

requestAnimationFrame também tem outras propriedades interessantes:

  • As animações nas guias em segundo plano são pausadas, preservando os recursos do sistema e a duração da bateria.
  • Se o sistema não conseguir renderizar 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, ele mantém a animação consistente. E, como mencionado acima, nossos olhos estão muito mais sintonizados com a variação do que com a taxa de frames. Uma taxa estável de 30 Hz parece melhor do que 60 Hz, que perde alguns frames por segundo.

O requestAnimationFrame já foi discutido em vários lugares, então consulte artigos como este da creative JS para mais informações, mas é uma primeira etapa importante para uma animação suave.

Orçamento de frames

Como queremos que um novo frame esteja pronto em cada atualização da tela, há apenas o tempo entre as atualizações para fazer todo o trabalho de criar um novo frame. Em uma tela de 60 Hz, isso significa que temos cerca de 16 ms para executar todo o JavaScript, fazer o layout, pintar e tudo o que o navegador precisa fazer para exibir o frame. Isso significa que, se o JavaScript no 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á excedendo o orçamento de frames durante o callback de requestAnimationFrame.

Abrir a linha do tempo das Ferramentas do desenvolvedor e fazer uma gravação dessa animação em ação mostra rapidamente que estamos muito acima do orçamento ao animar. Na linha do tempo, mude para "Frames" e confira:

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

Esses callbacks de requestAnimationFrame (rAF) estão demorando mais de 200 ms. Essa é uma ordem de magnitude muito longa para marcar um frame a cada 16 ms. Abrir um desses callbacks longos do rAF revela o que está acontecendo por dentro: neste caso, muitos layouts.

O vídeo de Paul explica em mais detalhes a causa específica do redimensionamento (que está lendo scrollTop) e como evitá-lo. Mas o ponto aqui é que você pode mergulhar no 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 de frame de 16 ms. Esse espaço em branco nos frames é o espaço disponível para você fazer mais trabalho ou deixar o navegador fazer o trabalho necessário em segundo plano. Esse espaço em branco é uma coisa boa.

Outra origem de instabilidade

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

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

  • Não faça muito processamento nos gerenciadores de entrada. Fazer muito uso de JS ou tentar reorganizar toda a página durante, por exemplo, um manipulador onscroll é uma causa muito comum de instabilidade.
  • Envie o máximo possível de processamento (leia-se: qualquer coisa que leve muito tempo para ser executada) para o callback do rAF ou para Web Workers.
  • Se você enviar trabalho para o callback rAF, tente dividi-lo para processar apenas um pouco de cada frame ou atrasá-lo até que uma animação importante termine. Dessa forma, você pode continuar executando callbacks rAF curtos e animar sem problemas.

Para um tutorial que explica como enviar o processamento para callbacks de requestAnimationFrame em vez de gerenciadores de entrada, consulte o artigo de Paul Lewis Animações mais leves, mais rápidas e eficientes com requestAnimationFrame (em inglês).

Animação CSS

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

Anteriormente, dissemos que não há uma solução simples para evitar a interrupção dos callbacks de rAF, mas você pode usar a animação CSS para evitar completamente a necessidade deles. No 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 pode executá-las mesmo que o JavaScript esteja em execução.

Há uma declaração implícita na seção acima sobre o jank: os navegadores só podem fazer uma coisa por vez. Isso não é estritamente verdadeiro, mas é uma boa suposição de trabalho: em qualquer momento, o navegador pode executar JS, realizar layout ou pintar, mas apenas um de cada vez. Isso pode ser verificado na visualização da linha do tempo das Ferramentas do desenvolvedor. Uma das exceções a essa regra são as animações CSS no 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 seu aplicativo e permitir que as animações sejam executadas sem problemas, mesmo durante a execução do JavaScript.

  // 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. No entanto, se você usar animações CSS, o problema não vai mais ocorrer.

No momento em que este artigo foi escrito, a animação CSS só estava livre de instabilidade no 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.

Encerramento

Resumindo:

  1. Ao animar, é importante produzir frames para cada atualização de tela. A animação com sincronização vertical tem um grande impacto positivo na sensação de um app.
  2. A melhor maneira de usar a animação vsync no Chrome e em outros navegadores modernos é usar a animação 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 do rAF saudáveis, verifique se outros manipuladores de eventos não estão atrapalhando a execução do callback do rAF e mantenha os callbacks do rAF curtos (<15ms).

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

Boa animação!

Referências