Pular para o conteúdo
← Voltar para o blog
React Native Skia

Como desenhar um tabuleiro de Sudoku inteiro em um SkPicture (e por que o Canvas declarativo travava)

9 min read

Escrito por: Jonathan Reis em

O app Sudoku tinha dois renderers de tabuleiro: nativo (React Native views) e Skia declarativo (<Canvas> com 81 componentes). Em aparelhos modestos, o reconciler do Skia declarativo virava o gargalo de frame time. A solução não foi otimizar o reconciler; foi tirá-lo do caminho com PictureRecorder.

Imagem de prévia OpenGraph deste artigo. Como desenhar um tabuleiro de Sudoku inteiro em um SkPicture (e por que o Canvas declarativo travava)

O tabuleiro do Daily Sudoku: Offline Puzzle renderiza em uma <Canvas> da @shopify/react-native-skia. Cada uma das 81 células é um punhado de nós declarativos (<Rect>, <SkiaText>, <RoundedRect>). Funciona e fica bonito. Em aparelhos antigos, trava.

Não trava por causa do desenho em si. O Skia desenha rápido. O problema é que, entre o toque na célula e a tinta na tela, o React reconciler reconcilia 81 componentes, cada um com props, cada um re-renderizando quando a seleção muda. Em um Redmi Note 7, isso é dezenas de milissegundos por frame perdidos com JavaScript que não desenha nada.

A pergunta que fizemos foi: dá para desenhar o tabuleiro inteiro sem criar 81 componentes React? A resposta é sim, e o caminho é o PictureRecorder.

O problema: reconciler no caminho crítico

O renderer Skia declarativo atual faz assim:

<Canvas>
  <SkiaCells cellSize={cellSize} cells={cells} colors={colors} />
  <SkiaSelectionHighlights ... />
  <SkiaValues ... />
  <SkiaNotes ... />
  <SkiaWrongBorders ... />
  <SkiaAnimationLayer overlays={overlays} ... />
  <SkiaGridLines ... />
</Canvas>

Cada subcomponente mapeia 81 células em nós Skia. SkiaSelectionHighlights é o pior: quando você seleciona uma célula, todo o array de 81 é re-mapeado porque o selectedIndex mudou e o useMemo que filtra foi invalidado. O Skia em si é rápido, mas o overhead de reconciliação de 81 nós React, com props animadas e useDerivedValue para cada overlay de animação, soma.

Em um iPhone moderno isso é invisível. Em um Android modesto, cada mudança de seleção soa como um micro-stutter. Animar uma onda de unidade completa (highlights por distância de Manhattan em até 27 células) piora.

A descoberta: useDrawCallback não existe mais

A primeira ideia foi usar o useDrawCallback da Skia, o API imperativo clássico que todo tutorial de 2022 mostra. O problema: @shopify/react-native-skia 2.6.7 não tem mais useDrawCallback. Nem SkiaView. Esses nomes existem em versões antigas e em tutoriais antigos, mas a API moderna moveu para <Canvas> + nós declarativos como camada principal.

Procurar por useDrawCallback nos types da lib retorna vazio. O que existe é:

  • Canvas (componente React) com ref do tipo CanvasRef.
  • CanvasRef.redraw() força re-paint.
  • Skia.PictureRecorder() retorna um recorder imperativo.
  • recorder.beginRecording(bounds) retorna um SkCanvas onde você desenha com drawRect, drawLine, drawText, drawRRect — puro, sem React.
  • recorder.finishRecordingAsPicture() retorna um SkPicture.
  • <Picture picture={pictureRef.current} /> renderiza o picture dentro do <Canvas>.

Ou seja: o modo imperativo existe, mas você o alcança gravando um SkPicture e passando-o para o <Canvas> declarativo. Você não substitui o <Canvas> por um SkiaView; você substitui os 81 componentes React por um único <Picture>.

A arquitetura: estado fora do React tree

O renderer imperativo guarda tudo em refs:

const stateRef = useRef<BoardState>({
  cells: [],
  overlays: [],
  mistakeHiddenIndexes: new Set(),
  selectedIndex: null,
  // ...
});

const [picture, setPicture] = useState<SkPicture | null>(null);
const canvasRef = useRef<CanvasRef | null>(null);

Quando as props mudam (seleção, board, notes, animationPresets), um useEffect regrava o picture:

const recordPicture = useCallback(() => {
  const recorder = Skia.PictureRecorder();
  const canvas = recorder.beginRecording(bounds);
  drawBoard(canvas, stateRef.current, performance.now(), paints, fonts, colors);
  setPicture(recorder.finishRecordingAsPicture());
}, [colors, fonts]);

React não re-renderiza 81 células. Re-renderiza apenas o componente top-level, que lê o estado picture e renderiza <Picture picture={picture} />. O setPicture é o que faz o snapshot do picture chegar à view nativa — uma mutação de useRef não dispararia o re-render do <Picture>.

O drawBoard é uma função que recebe um SkCanvas e desenha tudo na ordem certa: fundos, highlights, notes, valores, bordas erradas, overlays de animação, grid lines. Sem JSX, sem reconciler.

O loop de animação imperativo

Animações são onde o ganho real aparece. No renderer declarativo, cada overlay de animação é um <SkiaAnimationRect> com useSharedValue + withSequence + withTiming. Cada unidade completada pode criar 27 overlays simultâneos, cada um com seu próprio useEffect e useAnimatedStyle. O Reanimated é bom, mas 27 instâncias simultâneas de withSequence tem custo.

No renderer imperativo, um único loop de requestAnimationFrame regrava o picture a cada frame:

const animationLoop = useCallback(() => {
  recordPicture();
  const overlays = animationOverlaysRef.current;
  if (overlays.length > 0) {
    const elapsed = performance.now() - start;
    const maxEnd = Math.max(...overlays.map((o) => o.delayMs + o.durationMs));
    if (elapsed < maxEnd + 32) {
      animationFrameRef.current = requestAnimationFrame(animationLoop);
    } else {
      animationOverlaysRef.current = [];
      animationFrameRef.current = null;
    }
  } else {
    animationFrameRef.current = null;
  }
}, [recordPicture]);

O drawBoard recebe performance.now() e interpola alphas com easeOutCubic. O shake do erro é uma função de elapsed em ms. Nenhum useSharedValue, nenhum withSequence, nenhum useAnimatedStyle. Um único requestAnimationFrame para todas as animações ativas.

Isso é a parte que importa para aparelhos modestos: o custo por frame é constante, não escala com o número de overlays. 27 overlays de unidade completada custam o mesmo que 1, porque você está redesenhando o picture inteiro de qualquer forma.

O detalhe que não é óbvio: useRef não dispara re-render do <Picture>

Aqui está a armadilha que custou tempo e causou um bug de toque deslocado em produção. A primeira versão guardava o SkPicture em pictureRef.current (mutação de useRef) e renderizava <Picture picture={pictureRef.current} />. O board ficava stale — desenhado com o picture antigo. O toque selecionava a célula certa no hitbox, mas o highlight visual aparecia na célula do picture antigo. Parecia que o toque não correspondia ao dedo.

O problema: o React não observa mutações de refs. O <Picture> declarativo nunca re-renderizava para pegar o novo SkPicture. A correção foi trocar pictureRef.current = nextPicture por setPicture(nextPicture)useState dispara o re-render e o <Picture> recebe o novo picture a cada frame.

Se você tentar canvasRef.current?.redraw() sem trocar o picture via state, o redraw() re-pinta o picture que já está no <Picture>, não o novo que está no ref. O redraw() sozinho não propaga um SkPicture novo; ele só repinta o existente. O state é o canal.

PaintCache: não crie SkPaint a cada frame

Outro detalhe: Skia.Paint() cria um objeto nativo. Fazer isso para 81 células por frame, a 60fps, é 4.860 alocações por segundo. A solução é um cache simples:

class PaintCache {
  private fillCache = new Map<string, SkPaint>();
  private strokeCache = new Map<string, SkPaint>();
  private textCache = new Map<string, SkPaint>();

  fill(color: string, alpha = 1): SkPaint {
    const key = `fill:${color}:${alpha}`;
    const cached = this.fillCache.get(key);
    if (cached) return cached;
    const paint = Skia.Paint();
    paint.setAntiAlias(true);
    paint.setColor(Skia.Color(color));
    if (alpha !== 1) paint.setAlphaf(alpha);
    this.fillCache.set(key, paint);
    return paint;
  }
  // ...
}

O cache é por (cor, alpha). Para o tabuleiro de Sudoku, são cerca de 20 combinações únicas. O cache estabiliza em algumas centenas de bytes e nunca cresce além disso.

Como testar no device

O ponto de comparação é o cenário que gera mais jank:

  1. Preencher uma linha/coluna/bloco inteiro rapidamente. Dispara unitCompleted com onda de highlights por distância de Manhattan. No renderer declarativo, cada célula da unidade é um <SkiaAnimationRect> com useSharedValue. No imperativo, é um alpha interpolado no draw.
  2. Errar em sequência. Cada erro cria um overlay de mistake com shake horizontal do dígito. Vários erros rápidos empilham overlays.
  3. Board reveal ao iniciar jogo novo. Reveal sequencial de todas as 81 células com fade-in.
  4. Auto-complete assist. Ondas de preenchimento automático.

A comparação deve olhar frame time consistente (sem picos) em aparelhos modestos. No iPhone moderno, os três renderers são indistinguíveis; o ganho aparece onde o reconciler era o gargalo.

Quando usar Skia imperativo vs declarativo

Skia declarativo (<Canvas> com nós React) é a escolha certa para a maioria dos casos. É mais legível, mantém o poder do React (props, hooks, context), e o overhead do reconciler é invisível até alguns dezenas de nós.

Skia imperativo (PictureRecorder + <Picture>) vale a pena quando:

  • Você tem centenas de objetos visuais que mudam a cada frame.
  • O reconciler domina o frame time (meça antes de migrar; não chute).
  • Você precisa de animações coordenadas entre muitos elementos (ondas, partículas, grids).

Para um tabuleiro de Sudoku com 81 células que muda a cada toque, a fronteira é tênue. Em aparelhos modernos, o declarativo é suficiente. Em aparelhos modestos, o imperativo elimina o overhead e estabiliza o frame time. A escolha virou uma config no app, não uma decisão irreversível.

O que ficou

O renderer canvas-imperative é uma terceira opção no app, selecionável nas Configurações. Convive com canvas (Skia declarativo) e native (React Native views). O default continua canvas. Quem quiser testar o imperativo em um aparelho antigo pode trocar; quem não quiser, não percebe diferença.

O aprendizado que vale para outros projetos: @shopify/react-native-skia 2.6.7 não tem useDrawCallback, mas tem PictureRecorder + <Picture>, e esse par é o caminho para desenho imperativo puro sem abandonar o <Canvas>. Mas o SkPicture que você passa para o <Picture> tem que vir de useState, não de useRef — o React não observa mutações de refs, e o board fica stale sem que ninguém perceba até um tester dizer “o toque não corresponde ao dedo”.