Como desenhar um tabuleiro de Sudoku inteiro em um SkPicture (e por que o Canvas declarativo travava)
9 min readEscrito 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.

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) comrefdo tipoCanvasRef.CanvasRef.redraw()força re-paint.Skia.PictureRecorder()retorna um recorder imperativo.recorder.beginRecording(bounds)retorna umSkCanvasonde você desenha comdrawRect,drawLine,drawText,drawRRect— puro, sem React.recorder.finishRecordingAsPicture()retorna umSkPicture.<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:
- Preencher uma linha/coluna/bloco inteiro rapidamente. Dispara
unitCompletedcom onda de highlights por distância de Manhattan. No renderer declarativo, cada célula da unidade é um<SkiaAnimationRect>comuseSharedValue. No imperativo, é um alpha interpolado no draw. - Errar em sequência. Cada erro cria um overlay de
mistakecom shake horizontal do dígito. Vários erros rápidos empilham overlays. - Board reveal ao iniciar jogo novo. Reveal sequencial de todas as 81 células com fade-in.
- 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”.