Sunstone Apps
← Voltar para o blog

Performance em React Native

Medindo latência percebida em React Native quando métricas de frame não bastam

Métricas de frame dizem quando o renderer está pressionado. Nem sempre dizem por que o jogador toca uma célula e espera o tabuleiro se mover.

Publicado: 7 min de leitura
Imagem de prévia OpenGraph deste artigo. Medindo latência percebida em React Native quando métricas de frame não bastam

A métrica que parece mais científica nem sempre é a que explica o bug.

Encontramos isso ao testar um renderer Skia em Daily Sudoku: Offline Puzzle. Estatísticas de frame do Android, contagem de jank e percentis de renderização eram sinais úteis de fundo, mas não explicavam aquilo que dava para sentir manualmente: em alguns dispositivos, a tela reagia tarde depois do toque.

Então paramos de começar pelos gráficos de frame e mudamos a pergunta.

Em vez de perguntar “quantos frames tiveram jank?”, passamos a perguntar:

Para esta ação exata do usuário, quanto tempo o app leva para sair do início do handler, atualizar estado e chegar ao paint?

Por que métricas de frame não bastaram

Métricas de frame têm valor quando renderização já é a suspeita. Elas podem mostrar frames perdidos, comandos de draw lentos ou uma UI thread pressionada.

Mas nem sempre são um bom primeiro benchmark para latência percebida.

O jogador não sente um percentil. Ele sente o atraso entre tocar e ver a primeira resposta visual. Se a tela fica parada por meio segundo, uma métrica agregada aparentemente limpa não salva a interação.

No nosso tabuleiro Sudoku, a parte confusa era que os dispositivos não batiam com a intuição. Um emulador podia parecer rápido enquanto um telefone real mais fraco parecia lento. Um aparelho forte parecia melhor, mas ainda não instantâneo em trocas de tema.

Isso mostrou que o benchmark precisava seguir a ação, não o pipeline de frames.

O probe interno

Adicionamos um pequeno probe de observabilidade atrás de config:

observability:
  latencyProbe:
    enabled: true
    prefix: "[SUDOKU-OBSERVER-LATENCY]"

O app registra três estágios para interações selecionadas:

  • start: o handler da ação começou.
  • state: o estado do app ou do React terminou de atualizar.
  • paint: o segundo requestAnimationFrame depois da atualização de estado.

Essa divisão importa:

  • start -> state aponta custo de lógica da ação, reducers, persistência ou mutação do jogo.
  • state -> paint aponta renderização, reconciliação, trabalho no canvas, propagação de tema ou pressão na UI thread.
  • start -> paint é a latência interna de resposta que melhor serviu para comparar dispositivos.

Isso não é igual a uma câmera de alta velocidade medindo latência física do dedo até o pixel. Não inclui amostragem de toque do hardware, despacho de input do sistema operacional, timing do display ou evidência em vídeo. Mas é reproduzível, barato de rodar em aparelhos reais e próximo o suficiente para mostrar onde o próprio app está gastando tempo.

O que medimos

Instrumentamos ações que o jogador repete o bastante para julgar:

  • Selecionar uma célula do Sudoku.
  • Pressionar um número ou nota.
  • Mudar o tema do app.

Depois coletamos a saída do logcat em cada aparelho:

adb logcat | rg "SUDOKU-OBSERVER-LATENCY"

A regra era consistência: mesmo build, mesma flag de config, mesma tela, mesmo conjunto de ações. Se um aparelho não produzisse um probe completo para uma ação, marcávamos a amostra como fraca em vez de fingir que ela cabia na comparação.

O resultado útil

O primeiro achado útil não foi sutil: as atualizações de estado eram rápidas.

No Redmi Note 7, que parecia lento, a seleção de célula normalmente concluía estado por volta de 1.5ms, mas o paint chegava em cerca de 467ms de mediana e 621ms de p95. Trocas de tema eram piores: por volta de 1063ms de mediana até o paint.

No POCO X6 Pro, mais rápido, a seleção de célula ficava perto de 215ms de mediana até o paint e a troca de tema perto de 442ms. Melhor, mas ainda não instantâneo.

Essa distinção mudou o próximo movimento. Se estado é rápido e paint atrasa, otimizar regra de jogo ou handler de input é quase trabalho ocupado. A investigação deve olhar para renderização, reconciliação React, desenho no Skia, propagação de tema e pressão de UI específica do dispositivo.

Uma rodada posterior de otimização

O mesmo probe ficou mais útil quando paramos de tratar “o tabuleiro” como uma coisa só.

A primeira versão Skia do tabuleiro ficou mais lenta que a baseline React Native no Redmi Note 7 porque cada seleção ainda propagava pela tela de jogo e reconstruía estado visual demais. Um renderer Skia de superfície única ajudou, mas não bastou sozinho: seleção de célula saiu de cerca de 384ms no renderer React Native para 210ms no primeiro spike Skia.

Então o probe forçou a pergunta arquitetural que estávamos evitando:

Quais partes devem reagir à seleção e quais partes devem permanecer estáticas?

Movemos o estado da célula selecionada para fora do GameScreen, usando uma store local consumida só pelo tabuleiro, controles e teclado numérico. Isso removeu renders do GameScreen durante o protocolo de 20 seleções e derrubou a mediana no Note 7 para 163ms.

Na rodada seguinte, separamos a superfície Skia em camadas estáveis:

  • Células, valores, notas estáticas, bordas de erro e linhas do grid deixaram de depender da seleção.
  • Highlights de seleção foram para uma camada própria.
  • Highlights de notas foram separados do texto estático das notas.

No mesmo protocolo do Note 7, a mediana caiu para 149ms. Ainda não chegou na meta de resposta imediata (<100ms), mas passou o corte técnico que definimos para continuar o spike. Mais importante: mostrou onde não valia continuar insistindo. Regras de jogo e estado da tela já não eram a causa principal.

O que isso mudou

O probe mudou a conversa de debug de vaga para acionável.

Antes do probe:

  • “Parece lento nesse celular.”
  • “O emulador parece normal.”
  • “As métricas de frame são difíceis de interpretar.”

Depois do probe:

  • “A seleção de célula no Note 7 pinta em aproximadamente meio segundo.”
  • “Troca de tema passa de um segundo no Note 7.”
  • “Estado não é o gargalo.”
  • “Investigue state -> paint, não start -> state.”

Esse é um ciclo de debug melhor porque aponta para responsabilidade, não para sensação.

Lições

Use métricas de frame, Perfetto e ferramentas de GPU quando elas responderem a próxima pergunta. Mas não comece por elas se a pergunta de produto é latência de resposta.

Em apps React Native, um pequeno probe interno pode ser a forma mais rápida de separar latência de lógica e latência visual:

  • Meça ações nomeadas do usuário, não atividade genérica do app.
  • Registre estágios que mapeiam para responsabilidades de engenharia.
  • Mantenha o probe atrás de config.
  • Rode o mesmo conjunto de ações em todos os dispositivos-alvo.
  • Trate probes incompletos como dados incompletos.

O objetivo não é substituir profiling profundo. É parar de perfilar a coisa errada primeiro.

Como escolher quais ações merecem probes

Nem toda interação precisa de instrumentação. As melhores candidatas são ações que o usuário repete muito e julga na hora: selecionar uma célula, apertar um número, abrir um modal, aplicar um tema ou disparar uma animação que deveria parecer imediata.

Para essas ações, meça em volta do momento de produto, não em volta do detalhe de implementação. Um probe de seleção de célula deve começar quando o handler recebe o toque e terminar quando o estado selecionado aparece no paint. Se o probe começa depois, pode esconder atraso de input. Se termina cedo demais, pode esconder trabalho de renderização.

Como ler os números

A mediana mostra a sensação normal. P90 mostra com que frequência o app fica perceptivelmente atrasado. O máximo ajuda a encontrar outliers, mas não deve ser o único fator de decisão. Uma amostra ruim isolada pode vir de trabalho em background do device; um P90 consistentemente alto significa que a interação não é confiável.

A comparação mais útil é antes/depois no mesmo aparelho com o mesmo roteiro. Números entre devices ajudam a entender risco de produto, mas nem sempre ajudam a julgar se uma mudança de código melhorou. Mantenha o protocolo estável: mesmo tabuleiro, mesma ordem de interação, mesmos intervalos, mesmo tipo de build.

O que fazer depois que o probe encontra latência

Um probe não corrige nada. Ele estreita a busca. Depois de confirmar uma interação lenta, inspecione o que renderizou de novo, qual estado mudou, que animação começou e se persistência ou analytics está bloqueando o caminho.

É aí que pequenas mudanças arquiteturais ficam mais fáceis de justificar. Tirar estado de seleção de uma tela pai, isolar um renderer ou memoizar um componente denso deve vir de pressão medida, não de hábito. O probe dá ao time permissão para fazer uma mudança chata e direcionada em vez de reescrever a tela.

Conclusão

Trabalho de latência percebida melhora quando a medição acompanha a ação do usuário. Um probe pequeno não substitui julgamento, mas mostra qual atraso é real, repetível e merece correção antes de uma reescrita começar a parecer tentadora.