Você já sentiu que o JavaScript "pediu arrego" ao tentar processar algo pesado no React Native? Seja manipulando milhares de pixels em tempo real ou processando áudio, o loop do JS muitas vezes não é suficiente.
Neste artigo, vou mostrar como construí o Expo Chroma, um app que remove o fundo verde (Chroma Key) de imagens usando o Expo Modules SDK, C++ e instruções SIMD (ARM NEON) para alcançar uma performance que o JavaScript puro jamais conseguiria.
Repositório do Projeto: mensonones/ExpoChromaApp
O que é SIMD e por que ele importa?
Se você nunca ouviu falar de SIMD (Single Instruction, Multiple Data), pense nele como uma linha de montagem ultra-eficiente.
Historicamente, o conceito surgiu nos anos 70 com supercomputadores (como o lendário Cray-1), mas chegou aos nossos computadores e celulares nos anos 90 e 2000 (com tecnologias como MMX, SSE e AVX na Intel, e NEON na ARM).
Na programação convencional (Escalar), se você quer somar dois vetores de 4 números, você faz 4 somas separadas. Com SIMD, você carrega os dois vetores em registradores especiais e executa uma única instrução que soma todos os números de uma vez. É a base de quase tudo que exige performance hoje: processamento de vídeo, jogos 3D, criptografia e, claro, Inteligência Artificial.
O Coração do Projeto: C++ SIMD (ARM NEON)
O grande diferencial deste projeto é o uso de SIMD (Single Instruction, Multiple Data).
Em um processador comum, para remover o verde de 16 pixels, você faria 16 verificações sequenciais. Com ARM NEON (presente em quase todos os Androids e iPhones), processamos esses 16 pixels (64 bytes) simultaneamente em um único ciclo de CPU.
Por que exatamente 16 pixels?
A matemática é simples e fascinante:
- Os registradores NEON têm 128 bits de largura.
- Cada canal de cor (R, G, B ou A) é um
uint8(8 bits). - 128 / 8 = 16.
Isso significa que o tipo uint8x16_t que usamos no código é, literalmente, um vetor que carrega 16 valores de uma vez. Quando fazemos uma comparação, o hardware compara os 16 valores em paralelo no nível elétrico do silício.
Veja como fica o kernel em C++:
#include <arm_neon.h>
extern "C" void processChromaKey(uint8_t* data, size_t length) {
size_t numPixels = length / 4;
size_t i = 0;
// Definimos o threshold fora do loop para máxima performance
uint8x16_t threshold = vdupq_n_u8(30);
for (; i + 15 < numPixels; i += 16) {
uint8_t* ptr = data + (i * 4);
// Carrega 16 pixels desintercalando os canais (R, G, B, A)
uint8x16x4_t rgba = vld4q_u8(ptr);
// Lógica: Verde > (Vermelho + 30) && Verde > (Azul + 30)
uint8x16_t r_plus_th = vqaddq_u8(rgba.val[0], threshold);
uint8x16_t b_plus_th = vqaddq_u8(rgba.val[2], threshold);
uint8x16_t mask_r = vcgtq_u8(rgba.val[1], r_plus_th);
uint8x16_t mask_b = vcgtq_u8(rgba.val[1], b_plus_th);
uint8x16_t is_green = vandq_u8(mask_r, mask_b);
// Zera o Alpha (torna transparente) onde for verde
rgba.val[3] = vbicq_u8(rgba.val[3], is_green);
// Salva de volta na memória
vst4q_u8(ptr, rgba);
}
// ... fallback para pixels restantes
}
Integrando com Kotlin (Android)
Para que o C++ converse com o Android sem lentidão, usamos o JNI e DirectBuffers. Isso permite que o C++ manipule a memória do Bitmap do Android diretamente, sem criar cópias desnecessárias.
AsyncFunction("processImageBase64") { base64String: String ->
// ... decodificação do bitmap
// Aloca um DirectBuffer para acesso direto do C++
val byteBuffer = ByteBuffer.allocateDirect(mutableBitmap.byteCount)
mutableBitmap.copyPixelsToBuffer(byteBuffer)
// Chama o kernel C++ otimizado
nativeProcessImage(byteBuffer, mutableBitmap.byteCount)
// ... retorna para o JS
}
Resultados e Benchmarks
O benchmark mede o tempo de processamento bruto de um buffer de 8MB (equivalente a uma imagem 2K em RGBA). Isso isola a performance do algoritmo, ignorando custos de decodificação de arquivos ou renderização na tela.
Nos testes realizados em um dispositivo intermediário:
- JS Puro (Hermes): ~320ms (estável entre 321ms e 326ms nas execuções seguintes)
- C++ Nativo (Escalar): ~4ms (caindo para 2ms após o warm-up)
- C++ Nativo (SIMD): ~2ms (caindo para 1ms após o warm-up)
O Fenômeno do "Warm-up": Por que a segunda vez é mais rápida?
Se você rodar o benchmark no app, notará algo curioso: a primeira execução é sempre mais lenta que as seguintes. Por exemplo, o C++ Escalar pode começar em 4ms e cair para 2ms, enquanto o SIMD cai de 2ms para 1ms.
Isso acontece por três motivos técnicos:
- CPU Frequency Scaling: Para economizar bateria, o Android mantém a CPU em "repouso". Quando o código pesado começa, o sistema leva alguns milissegundos para subir a frequência do processador ao máximo.
- CPU Cache: Na primeira vez, os dados saem da RAM (lenta). Nas execuções seguintes, os pixels já estão no Cache L1/L2 do processador, que é ordens de grandeza mais rápido.
- Branch Prediction: O processador "aprende" o padrão dos seus
ifs. Após a primeira volta, ele já sabe prever quais pixels serão transparentes, evitando pausas no fluxo de execução (pipeline stalls).
Isso prova que:
- Mover para o Nativo já elimina o overhead do Garbage Collector e do motor JS, sendo 80x a 160x mais rápido que o Hermes.
- Usar SIMD extrai o máximo de poder do hardware, dobrando a performance do C++ comum ao processar dados em paralelo no nível do silício.
Conclusão
O React Native, através do ecossistema Expo, evoluiu para uma plataforma onde o limite não é mais o JavaScript. Se você precisa de performance extrema, não tenha medo de descer para o C++. O Expo Modules SDK torna essa ponte (ou melhor, esse JSI) muito mais simples de implementar.
O código completo deste projeto está disponível no GitHub: mensonones/ExpoChromaApp
A Arquitetura e Estrutura do Módulo
Para este projeto, criei um Expo Module local localizado em modules/expo-chroma/. Esta é a estrutura recomendada para manter o código nativo organizado:
-
cpp/: Contém o núcleo em C++ com os kernels SIMD. -
android/eios/: Implementações nativas que expõem o C++ para cada plataforma via JNI/Swift. -
src/: A camada TypeScript que define a API consumida pelo React Native.
Utilizamos a JSI (JavaScript Interface) do Expo Modules SDK. Isso permite que o JavaScript chame as funções nativas de forma síncrona e direta, eliminando o overhead da antiga "Bridge" e permitindo a passagem de buffers de memória de forma ultra-eficiente.
Gostou do conteúdo? Deixe um comentário sobre qual tarefa pesada você adoraria ver rodando nativamente no seu app! 🚀
Top comments (1)
Isso ficou irado demais, obrigado por compartilhar meu querido!