DEV Community

Emerson Vieira
Emerson Vieira

Posted on

React Native em Alta Performance: Expo Modules, C++ e SIMD (ARM NEON)

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. 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/ e ios/: 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 (2)

Collapse
 
wlsf profile image
Willian Frantz

Isso ficou irado demais, obrigado por compartilhar meu querido!

Collapse
 
mensonones profile image
Emerson Vieira

valeu pela moral, Frantz! Eu que agradeco por ter tirado um tempo para ler e comentar! Valeu dms!