React Native Land_

// construindo apps mobile melhores

# Código nativo com Turbo Modules

2026-11-12#mug

No post anterior construímos um módulo de bateria com a Expo Modules API. Vamos construir o mesmo com o que o próprio React Native oferece: um Turbo Native Module. Ele lê o nível da bateria e avisa o app quando ele muda.

A diferença começa em onde fica a verdade. Aqui, uma spec em TypeScript descreve o módulo, e o Codegen a transforma em interfaces nativas que o Kotlin e o Objective-C++ precisam implementar. Se não baterem, o build falha.

## A forma

Num app React Native, reduzido ao que importa aqui:

specs/NativeBattery.ts
package.json
ios/RCTNativeBattery.h
ios/RCTNativeBattery.mm
ios/BatteryLevel.swift
android/app/src/main/java/com/nativebattery/NativeBatteryModule.kt
android/app/src/main/java/com/nativebattery/NativeBatteryPackage.kt

## A spec

O nome do arquivo da spec precisa começar com Native. Ela declara uma promise e um evento tipado:

specs/NativeBattery.tsts
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type BatteryEvent = {
  level: number;
};

export interface Spec extends TurboModule {
  getLevel(): Promise<number>;
  readonly onLevelChange: CodegenTypes.EventEmitter<BatteryEvent>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeBattery');

O package.json diz ao Codegen onde estão as specs, em que pacote Java gerar o código e qual classe Objective-C fornece o módulo:

package.jsonjson
{
  "codegenConfig": {
    "name": "NativeBatterySpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": { "javaPackageName": "com.nativebattery" },
    "ios": { "modulesProvider": { "NativeBattery": "RCTNativeBattery" } }
  }
}

## Kotlin

O Codegen escreve uma classe abstrata, NativeBatterySpec, com um método para cada função da spec e um emitOnLevelChange para o evento. O módulo a estende:

android/.../NativeBatteryModule.ktkotlin
package com.nativebattery

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext

class NativeBatteryModule(reactContext: ReactApplicationContext) : NativeBatterySpec(reactContext) {
  private val receiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
      emitOnLevelChange(Arguments.createMap().apply { putDouble("level", levelOf(intent)) })
    }
  }

  override fun getName() = NAME

  override fun initialize() {
    super.initialize()
    reactApplicationContext.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
  }

  override fun invalidate() {
    reactApplicationContext.unregisterReceiver(receiver)
    super.invalidate()
  }

  override fun getLevel(promise: Promise) {
    val intent = reactApplicationContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
    promise.resolve(intent?.let { levelOf(it) } ?: -1.0)
  }

  private fun levelOf(intent: Intent): Double {
    val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
    val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
    return if (level < 0 || scale <= 0) -1.0 else level / scale.toDouble()
  }

  companion object {
    const val NAME = "NativeBattery"
  }
}

Não há um gancho de primeiro listener como no Expo Modules, então o receiver vive tanto quanto o módulo: da primeira vez que o JavaScript o pede até o app recarregar.

Um package entrega o módulo ao React Native quando o JavaScript pede pelo nome dele:

android/.../NativeBatteryPackage.ktkotlin
package com.nativebattery

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider

class NativeBatteryPackage : BaseReactPackage() {
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
    if (name == NativeBatteryModule.NAME) NativeBatteryModule(reactContext) else null

  override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
    mapOf(
      NativeBatteryModule.NAME to ReactModuleInfo(
        name = NativeBatteryModule.NAME,
        className = NativeBatteryModule.NAME,
        canOverrideExistingModule = false,
        needsEagerInit = false,
        isCxxModule = false,
        isTurboModule = true,
      )
    )
  }
}

E o MainApplication.kt o adiciona à lista que o autolinking monta:

MainApplication.ktkotlin
override fun getPackages(): List<ReactPackage> =
  PackageList(this).packages.apply {
    add(NativeBatteryPackage())
  }

## Swift

O Codegen escreve protocolos Objective-C++, que o Swift não consegue adotar. O código da bateria ainda pode ser Swift: uma classe pequena, marcada com @objc para que o Objective-C a enxergue:

ios/BatteryLevel.swiftswift
import UIKit

@objc public class BatteryLevel: NSObject {
  private var observer: NSObjectProtocol?

  @objc public static var current: Float {
    UIDevice.current.isBatteryMonitoringEnabled = true
    return UIDevice.current.batteryLevel
  }

  @objc public func start(_ onChange: @escaping (Float) -> Void) {
    UIDevice.current.isBatteryMonitoringEnabled = true
    observer = NotificationCenter.default.addObserver(
      forName: UIDevice.batteryLevelDidChangeNotification,
      object: nil,
      queue: .main
    ) { _ in
      onChange(UIDevice.current.batteryLevel)
    }
  }

  @objc public func stop() {
    if let observer {
      NotificationCenter.default.removeObserver(observer)
    }
    observer = nil
  }
}

## Objective-C++

O módulo em si é uma classe Objective-C++ fina. O header adota o protocolo gerado e estende a classe base gerada, que traz o emitOnLevelChange:

ios/RCTNativeBattery.hobjective-c
#import <NativeBatterySpec/NativeBatterySpec.h>

NS_ASSUME_NONNULL_BEGIN

@interface RCTNativeBattery : NativeBatterySpecBase <NativeBatterySpec>
@end

NS_ASSUME_NONNULL_END

A implementação repassa para o Swift. UIDevice pertence à thread principal, então as duas chamadas vão para ela; no simulador o nível é -1, já que ele não tem bateria:

ios/RCTNativeBattery.mmobjective-cpp
#import "RCTNativeBattery.h"
#import "BatteryApp-Swift.h"

@implementation RCTNativeBattery {
  BatteryLevel *_battery;
}

+ (NSString *)moduleName {
  return @"NativeBattery";
}

- (instancetype)init {
  if (self = [super init]) {
    _battery = [BatteryLevel new];
    __weak RCTNativeBattery *weakSelf = self;
    dispatch_async(dispatch_get_main_queue(), ^{
      [weakSelf->_battery start:^(float level) {
        [weakSelf emitOnLevelChange:@{@"level": @(level)}];
      }];
    });
  }
  return self;
}

- (void)dealloc {
  [_battery stop];
}

- (void)getLevel:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
  dispatch_async(dispatch_get_main_queue(), ^{
    resolve(@(BatteryLevel.current));
  });
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<facebook::react::NativeBatterySpecJSI>(params);
}

@end

BatteryApp-Swift.h é o header que o Xcode escreve para o código Swift do app; o nome dele é o do seu target.

## TypeScript

A spec já é o módulo tipado, então o hook a importa diretamente. Um evento é uma função que recebe um listener e devolve uma inscrição:

useBatteryLevel.tsts
import { useEffect, useState } from 'react';
import NativeBattery from './specs/NativeBattery';

export function useBatteryLevel(): number | null {
  const [level, setLevel] = useState<number | null>(null);

  useEffect(() => {
    let active = true;
    NativeBattery.getLevel().then((value) => {
      if (active) setLevel(value);
    });
    const subscription = NativeBattery.onLevelChange((event) => setLevel(event.level));
    return () => {
      active = false;
      subscription.remove();
    };
  }, []);

  return level;
}

O badge é o mesmo de antes:

BatteryBadge.tsxtsx
import { Text } from 'react-native';
import { useBatteryLevel } from './useBatteryLevel';

export function BatteryBadge() {
  const level = useBatteryLevel();
  if (level === null || level < 0) return <Text>Bateria: desconhecida</Text>;
  return <Text>Bateria: {Math.round(level * 100)}%</Text>;
}

## Rode

O Codegen roda quando os pods são instalados e quando o Gradle compila, então uma mudança na spec pede os dois de novo:

cd ios && bundle exec pod install && cd ..
npx react-native run-ios
npx react-native run-android

## Qual escolher?

  • A spec é o contrato: o Codegen confere o lado nativo contra ela no build, e o módulo já vem tipado no JavaScript, sem declaração sua.
  • Turbo Modules não pedem dependência além do React Native, e podem ser escritos uma vez em C++ para as duas plataformas.
  • Expo Modules pedem menos código: sem package, sem registro, sem Objective-C++, Swift do começo ao fim, e ganchos para o primeiro e o último listener.

A documentação do React Native sobre Turbo Native Modules vai além, incluindo módulos escritos em C++.