React NativeExpoMetroJSITurboModulesFabricCodegenHermesMobile Development

React Native + Expo + Metro + ネイティブ実装の完全解説

Sloth255
Sloth255
·9 min read·1,828 words

React Native を使い始めると「なんとなく動いている」感覚になりがちです。この記事では、その裏側で何が起きているかを Metro・JSI・Codegen・Expo の仕組みから、Expo SDK でカバーできない領域のネイティブ実装まで、一気通貫で解説します。


1. React Native の基本思想

React Native の核心は「JS で書いた UI ロジックを、ネイティブの UI コンポーネントに変換する」という点にあります。ReactDOM が <div> を DOM に変換するのと同様に、React Native は <View><Text> を iOS なら UIView・Android なら android.view.View へとマッピングします。

つまり WebView で HTML を描画しているのではなく、本物のネイティブ UI を生成しています。ここが Cordova や Ionic との決定的な違いです。


2. アーキテクチャの進化:Bridge から JSI へ

React Native は現在、2つのアーキテクチャが共存しています。

flowchart LR
  subgraph OLD["旧アーキテクチャ(Bridge)"]
    direction TB
    J1["JS Thread<br/>React ロジック"] -->|"JSON serialize"| BR["Bridge<br/>非同期キュー ⚠"]
    BR -->|"JSON deserialize"| N1["Native Thread<br/>UIKit / Android View"]
    SH["Shadow Thread<br/>Yoga layout"] --> N1
  end
  subgraph NEW["新アーキテクチャ(JSI)"]
    direction TB
    HM["Hermes Engine<br/>バイトコード実行"] -->|"C++ 直接参照"| JSI["JSI<br/>Host Objects"]
    JSI --> FAB["Fabric<br/>同期 Renderer"]
    JSI --> TM["TurboModules<br/>遅延初期化"]
    FAB --> N2["Native Layer"]
    TM --> N2
  end

旧アーキテクチャ(Bridge)の問題

旧来の React Native は JS スレッドとネイティブスレッドが Bridge という非同期の単一ボトルネックを介して通信していました。すべてのやり取りが JSON 文字列にシリアライズ→送信→デシリアライズというラウンドトリップを踏むため、アニメーションやジェスチャーなど頻度の高い操作でパフォーマンスが落ちやすい構造でした。

新アーキテクチャ(JSI + Hermes)の革新

JSI(JavaScript Interface)は C++ で実装された薄いバインディング層で、JS エンジンからネイティブオブジェクトを直接同期参照できるようになります。これにより以下が実現しました。

  • Fabric — UIツリーの構築・更新を同期的に行える新しいレンダラー
  • TurboModules — ネイティブモジュールを必要になるまでロードしない遅延初期化
  • Hermes — JS をバイトコードに事前コンパイルし、起動時間を大幅に短縮する Facebook 製エンジン

3. Codegen:型安全なネイティブ通信の要

Codegen は新アーキテクチャの中で最も見落とされがちな仕組みです。JSI と TurboModules が「通信できる」ようにする一方で、Codegen は「正しく通信できる」ことをビルド時に保証します。

flowchart TD
  SPEC["JS Spec ファイル<br/>TypeScript / Flow で型定義"]

  subgraph BUILD["ビルド時"]
    CG["Codegen<br/>pod install / Gradle で実行"]
  end

  subgraph OUT["生成される成果物"]
    CPP["C++ 抽象クラス<br/>TurboModule 基底インターフェース"]
    DESC["Component Descriptor<br/>Fabric ネイティブコンポーネント用"]
    JSTYPE["JS 型定義<br/>.d.ts 相当"]
  end

  subgraph NATIVE["ネイティブ実装"]
    IOS["iOS<br/>Swift / Obj-C で実装"]
    AND["Android<br/>Kotlin / Java で実装"]
  end

  RUNTIME["JSI 経由<br/>型安全・同期呼び出し"]

  SPEC --> CG
  CG --> CPP & DESC & JSTYPE
  CPP --> IOS & AND
  DESC --> IOS & AND
  IOS --> RUNTIME
  AND --> RUNTIME

旧アーキテクチャでは、JS とネイティブのインターフェースはほぼ「紳士協定」でした。JS から NativeModules.MyModule.doSomething(42) を呼んでも、ネイティブ側が String を期待していればランタイムクラッシュが起きるまで気づきません。

Codegen はこの問題をビルド時に解決します。開発者は TypeScript または Flow で「このネイティブモジュールはこういう型を受け取る」という Spec(仕様書)を書くだけです。pod install(iOS)や Gradle ビルド(Android)のタイミングに Codegen が走り、C++ の抽象クラスiOS と Android 向けのネイティブ雛形コードJS 側の型定義を自動生成します。


4. Metro Bundler のパイプライン

Metro は React Native 専用の JavaScript バンドラーです。webpack と同じ役割を担いますが、モバイル開発に最適化されています。

flowchart LR
  SRC[".js / .ts / .tsx<br/>ソースファイル"]
  RES["Resolution<br/>import パス解決"]
  TRA["Transformation<br/>Babel でES変換"]
  SER["Serialization<br/>バンドル生成"]
  DEV["端末へ配信<br/>HTTP :8081"]
  HMR["HMR<br/>差分モジュールのみ再送"]
  CACHE["FSキャッシュ<br/>2回目以降高速"]

  SRC --> RES --> TRA --> SER --> DEV
  TRA -.->|"ファイル変更検知"| HMR
  HMR -.->|"状態を保持したまま反映"| DEV
  TRA <-.->|"変換済みをキャッシュ"| CACHE

Metro の処理は3フェーズからなります。

  • Resolution — Node.js ライクなモジュール解決を行い、foo.ios.ts / foo.android.ts のようなプラットフォーム別ファイルも自動選択します
  • Transformation — Babel が JSX・TypeScript・最新 ES を変換し、結果をファイルシステムキャッシュに保存するため2回目以降の起動が高速になります
  • Serialization — 最終バンドルを生成し、HTTP サーバー経由で端末に配信します

特筆すべきは HMR(Hot Module Replacement) です。変更されたモジュールのグラフだけを差分更新するため、React コンポーネントの状態を保持したままコード変更を即座に反映できます。本番ビルド時は --minify と Hermes バイトコード変換が組み合わさり、ファイルサイズと起動時間の両方を削減します。


5. Expo のレイヤー構造と開発ツール

flowchart TD
  APP["あなたのアプリ<br/>App.tsx / screens / hooks"]
  SDK["Expo SDK<br/>expo-camera / expo-location / expo-router …"]
  EMC["expo-modules-core<br/>Swift / Kotlin DSL で型安全な記述"]
  RN["React Native Core<br/>View / Text / Animated / StyleSheet"]
  IOS["iOS<br/>UIKit / AVFoundation / CoreLocation"]
  AND["Android<br/>View / Camera2 / LocationManager"]
  GO["Expo Go<br/>QR スキャンで即起動"]
  EASB["EAS Build<br/>クラウドで .ipa / .apk 生成"]
  EASU["EAS Update<br/>OTA で JS バンドルのみ配信"]
  STORE["App Store / Google Play"]

  APP --> SDK
  EMC --> SDK
  SDK --> RN
  RN --> IOS & AND
  GO -->|"Dev 時にホスト"| APP
  EASB --> STORE
  EASU -->|"App Store 審査なし"| APP

Expo SDK は、カメラ・位置情報・通知・ファイルシステムなどのネイティブ機能を、バージョン管理されたパッケージとして提供します。自前でネイティブコードを書かずに済む最大の価値です。

expo-modules-core は、Swift や Kotlin の DSL を使ってネイティブモジュールを宣言的に書ける新しい仕組みで、Codegen と統合されています。旧来の NativeModules ブリッジより型安全で記述量も少なく、Expo のサードパーティ SDK はほぼすべてこれを使って書かれています。

EAS(Expo Application Services) は Build・Update・Submit・Workflows など複数サービスの総称です。本記事ではその中でも EAS BuildEAS Update に絞って扱います。EAS Build はクラウド上でネイティブバイナリをビルドするため、Mac なしで iOS ビルドが可能になります。EAS Update は JS バンドルやアセットを OTA(Over The Air)配信し、ネイティブコードを変えない範囲の修正を迅速に届けられます。


6. Expo が対応できない領域とワークフローの選択

Expo SDK でカバーできない主な領域は次の通りです。

  • Bluetooth LE / NFC — プラットフォーム固有のペリフェラル通信
  • 独自決済・生体認証 SDK — ベンダー提供の .framework / .aar の直接組み込み
  • リアルタイム音声・動画処理 — WebRTC や独自コーデックの低レイヤー操作
  • 社内ネイティブライブラリ — 既存アプリから切り出した Swift / Kotlin 資産
  • カスタムカメラビュー — OpenGL / Metal を使った独自レンダリング
flowchart TD
  START["必要なネイティブ機能は?"]

  START --> Q1{"Expo SDK / 公開ライブラリで<br/>カバーできる?"}
  Q1 -->|"Yes"| MANAGED["Managed Workflow<br/>ネイティブコード不要<br/>npx expo start だけで完結"]
  Q1 -->|"No"| Q2{"Config Plugin で<br/>設定変更だけで済む?<br/>(Info.plist / AndroidManifest 追記など)"}
  Q2 -->|"Yes"| PLUGIN["Config Plugin 作成<br/>app.json に追記<br/>prebuild で自動適用"]
  Q2 -->|"No"| Q3{"独自ネイティブコードが<br/>必要な規模は?"}
  Q3 -->|"軽量<br/>(既存SDKのラップ)"| BARE["Bare Workflow<br/>npx expo prebuild<br/>→ ios/ android/ を直接管理"]
  Q3 -->|"本格的<br/>(独自モジュール / UI)"| MODULE{"何を作る?"}
  MODULE -->|"ロジック系<br/>Bluetooth / 決済 / 暗号"| TM["TurboModule<br/>+ Codegen"]
  MODULE -->|"UI コンポーネント系<br/>カスタムカメラビュー等"| FAB["Fabric Component<br/>+ Codegen"]
  BARE --> TM & FAB
  PLUGIN --> BARE

軽微な設定変更なら Config Plugin で吸収できます。しかしコードを書かなければならない場合は npx expo prebuildios/android/ ディレクトリを生成し、Bare Workflow に移行するのが現実的な起点です。


7. アーキテクチャ全体像:JSI + Codegen + ネイティブ実装

flowchart TB
  subgraph JS["JS レイヤー(Hermes)"]
    APP["App コード / React コンポーネント"]
    SPEC["JS Spec<br/>(TypeScript + TurboModuleRegistry)"]
  end

  subgraph CODEGEN["Codegen(ビルド時自動生成)"]
    GEN_CPP["C++ 抽象クラス<br/>TurboModule / ComponentDescriptor"]
    GEN_SHADOW["ShadowNode / Props 定義"]
  end

  subgraph JSI_LAYER["JSI レイヤー(C++)"]
    JSI["JSI Host Object"]
    FABRIC["Fabric Renderer"]
  end

  subgraph NATIVE_IOS["iOS ネイティブ実装"]
    SWIFT["Swift / Obj-C 実装クラス<br/>(C++ 抽象クラスを継承)"]
    SDK_IOS["社内SDK / 外部.framework<br/>e.g. CoreBluetooth / Stripe"]
    UIVIEW["UIView サブクラス<br/>(カスタム UI コンポーネント)"]
  end

  subgraph NATIVE_AND["Android ネイティブ実装"]
    KOTLIN["Kotlin / Java 実装クラス<br/>(C++ 抽象クラスを継承)"]
    SDK_AND["社内SDK / 外部.aar<br/>e.g. BluetoothGatt / Stripe"]
    ANDROIDVIEW["View サブクラス<br/>(カスタム UI コンポーネント)"]
  end

  SPEC -->|"型定義を読み込む"| CODEGEN
  GEN_CPP --> JSI
  GEN_SHADOW --> FABRIC
  APP -->|"同期 / 非同期呼び出し"| JSI
  JSI --> SWIFT & KOTLIN
  FABRIC --> UIVIEW & ANDROIDVIEW
  SWIFT --> SDK_IOS
  KOTLIN --> SDK_AND

ポイントは Spec → Codegen → ネイティブ実装 という一方向の依存関係です。JS 側で型定義を書くと、Codegen がビルド時に C++ の抽象クラスを生成し、Swift / Kotlin の実装クラスはそれを継承するだけでよい。型の整合性はコンパイラが保証し、ランタイムクラッシュの原因だった「紳士協定」が消えます。


8. TurboModule の実装フロー(Bluetooth の例)

注: 以下のコードは React Native 公式の TurboModule / Codegen / Custom Events ガイドをもとにした簡略例です。公式サンプルの転載ではなく、Bluetooth を題材に再構成しています。

① JS Spec を書く

// src/specs/NativeBluetoothModule.ts
import type { TurboModule, CodegenTypes } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type ScanResult = {
  id: string;
  name: string;
  rssi: number;
};

export interface Spec extends TurboModule {
  startScan(serviceUUIDs: string[]): void;
  stopScan(): void;
  connect(deviceId: string): Promise<boolean>;
  readonly onDeviceFound: CodegenTypes.EventEmitter<ScanResult>;
}

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

② Codegen が生成するもの(自動)

flowchart LR
  SPEC["NativeBluetoothModule.ts<br/>(開発者が書く)"]

  subgraph AUTO["自動生成されるコード"]
    CPP["C++ glue code<br/>JSI / Codegen 生成物"]
    IOS_H["iOS glue code<br/>Obj-C++ / header 生成物"]
    AND_J["Android glue code<br/>JNI / Spec クラス生成物"]
  end

  subgraph IMPL["開発者が実装する"]
    SWIFT_IMPL["NativeBluetoothModule.swift<br/>生成された Spec を実装し CoreBluetooth を呼ぶ"]
    KOTLIN_IMPL["NativeBluetoothModule.kt<br/>生成された Spec を実装し BluetoothGatt を呼ぶ"]
  end

  SPEC --> CPP
  SPEC --> IOS_H
  SPEC --> AND_J
  CPP --> SWIFT_IMPL & KOTLIN_IMPL
  IOS_H --> SWIFT_IMPL
  AND_J --> KOTLIN_IMPL

③ iOS 実装(Swift)

// ios/NativeBluetoothModule.swift
import CoreBluetooth

@objc(NativeBluetoothModule)
class NativeBluetoothModule: NativeBluetoothModuleSpec, CBCentralManagerDelegate {
  private var centralManager: CBCentralManager!

  override func startScan(_ serviceUUIDs: [String]) {
    let uuids = serviceUUIDs.map { CBUUID(string: $0) }
    centralManager.scanForPeripherals(withServices: uuids)
  }

  func centralManager(_ central: CBCentralManager,
                      didDiscover peripheral: CBPeripheral,
                      advertisementData: [String: Any], rssi: NSNumber) {
    emitOnDeviceFound([
      "id": peripheral.identifier.uuidString,
      "name": peripheral.name ?? "",
      "rssi": rssi
    ])
  }
}

④ Android 実装(Kotlin)

// android/src/main/java/NativeBluetoothModule.kt
class NativeBluetoothModule(reactContext: ReactApplicationContext)
    : NativeBluetoothModuleSpec(reactContext), BluetoothGatt.Callback {

  private val adapter = BluetoothAdapter.getDefaultAdapter()

  override fun startScan(serviceUUIDs: ReadableArray) {
    val filters = serviceUUIDs.toArrayList().map { uuid ->
      ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(uuid as String)).build()
    }
    adapter.bluetoothLeScanner.startScan(filters, ScanSettings.Builder().build(), scanCallback)
  }

  private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
      val params = Arguments.createMap().apply {
        putString("id", result.device.address)
        putString("name", result.device.name ?: "")
        putInt("rssi", result.rssi)
      }
      emitOnDeviceFound(params)
    }
  }
}

9. Fabric Component の実装フロー(カスタムカメラビューの例)

TurboModule がロジック担当なら、Fabric Component はネイティブ UI を JSX に埋め込むための仕組みです。OpenGL / Metal を使った独自描画や、既存ネイティブアプリから流用した View をそのまま使う場合に必要になります。

注: 以下も理解用の最小例です。Codegen の import パスや型名は React Native のバージョン差分を受けるため、実装時は利用中バージョンの公式ドキュメントで確認してください。

// src/specs/NativeCameraViewSpec.ts
import type { ViewProps } from 'react-native';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { DirectEventHandler, Float } from 'react-native/Libraries/Types/CodegenTypes';

type FrameCapturedEvent = Readonly<{
  uri: string;
}>;

interface NativeProps extends ViewProps {
  zoom?: Float;          // Codegen が Float → CGFloat / float に変換
  torchEnabled?: boolean;
  onFrameCaptured?: DirectEventHandler<FrameCapturedEvent>;
}

export default codegenNativeComponent<NativeProps>('CameraView');

Codegen はこの Spec から ShadowNodeComponentDescriptorProps の C++ 定義を生成します。iOS では RCTViewComponentView を継承した CameraViewComponentView を実装し、Android では ReactViewGroup を継承します。JS 側からは単純に <CameraView zoom={2.0} torchEnabled /> として使えます。


10. 社内 SDK / 外部 .framework.aar の組み込み

iOS:CocoaPods でローカル参照

# ios/Podfile
pod 'CompanyPaymentSDK', :path => '../vendor/ios/CompanyPaymentSDK'
# または xcframework の場合
pod 'CompanyPaymentSDK', :podspec => '../vendor/ios/CompanyPaymentSDK.podspec'

Podfile に追加したあと pod install を実行すると、以降は通常の Swift クラスとして import CompanyPaymentSDK できます。TurboModule の実装クラスからそのまま呼び出すだけです。

Android:ローカル Maven / .aar 参照

// android/app/build.gradle
dependencies {
  implementation files('../vendor/android/company-payment-sdk.aar')
  // または社内 Maven リポジトリ
  implementation 'com.company:payment-sdk:1.4.0'
}

11. 実際の開発ループ:起動から動作確認まで

flowchart TD
  INIT["npx create-expo-app MyApp<br/>--template blank-typescript"]

  subgraph START["npx expo start"]
    METRO["Metro Bundler 起動<br/>port 8081"]
    QR["QR コード + ショートカット表示<br/>i = iOS  a = Android  w = Web"]
  end

  subgraph DEVICE["接続先を選ぶ"]
    SIM["iOS Simulator<br/>Xcode 付属 (Mac のみ)"]
    EMU["Android Emulator<br/>Android Studio AVD"]
    GO["実機 + Expo Go<br/>LAN 経由 or Tunnel"]
  end

  subgraph NATIVE_BUILD["初回のみ ネイティブビルド"]
    POD["pod install<br/>iOS CocoaPods"]
    GRADLE["Gradle build<br/>Android"]
    CODEGEN_RUN["Codegen 実行<br/>Spec → ネイティブ雛形"]
  end

  BUNDLE["JS バンドルをデバイスへ転送<br/>Metro HTTP サーバー経由"]
  RENDER["画面表示"]

  INIT --> START
  METRO --> QR
  QR --> SIM & EMU & GO
  SIM --> NATIVE_BUILD
  EMU --> NATIVE_BUILD
  NATIVE_BUILD --> BUNDLE
  GO --> BUNDLE
  BUNDLE --> RENDER

npx expo start を叩くと Metro が起動し、ターミナルに QR コードとキーボードショートカットが現れます。

  • i キー → iOS Simulator(Xcode がインストールされた Mac 上で動作)
  • a キー → Android Emulator(Android Studio の AVD Manager で作成済みの仮想デバイス)
  • 実機の場合は Expo Go アプリで QR をスキャン。LAN 内ならほぼ瞬時に繋がります

npx expo run:iosnpx expo run:android のように明示的にネイティブビルドを走らせると、初回は pod install(iOS)や Gradle(Android)が実行され、その中で Codegen も動いてネイティブ雛形コードが生成されます。2回目以降は JS バンドルの差分だけ転送されるので速い。


12. Bare Workflow での変更ループ

flowchart TD
  subgraph CHANGE["変更の種類で分岐"]
    JS_ONLY["JS のみ変更<br/>コンポーネント・ロジック・スタイル"]
    NATIVE_CHANGE["ネイティブコード変更<br/>Swift / Kotlin / Spec / Podfile"]
  end

  subgraph JS_LOOP["JS 変更ループ(高速)"]
    METRO_HMR["Metro HMR<br/>差分バンドルを転送<br/>数百ms"]
    SIM_JS["エミュレーターに即反映<br/>アプリ状態を保持"]
  end

  subgraph NATIVE_LOOP["ネイティブ変更ループ(低速)"]
    CODEGEN_RUN["Codegen 再実行<br/>pod install (iOS)<br/>Gradle sync (Android)"]
    REBUILD["ネイティブ再ビルド<br/>npx expo run:ios<br/>npx expo run:android"]
    SIM_NATIVE["エミュレーター / 実機で確認"]
  end

  JS_ONLY --> METRO_HMR --> SIM_JS
  NATIVE_CHANGE --> CODEGEN_RUN --> REBUILD --> SIM_NATIVE

  SIM_JS -->|"問題なし"| DONE["動作確認 OK"]
  SIM_NATIVE -->|"問題なし"| DONE
  SIM_JS -->|"バグ発見"| DEBUG_JS["Hermes Debugger<br/>React DevTools"]
  SIM_NATIVE -->|"ネイティブクラッシュ"| DEBUG_NATIVE["Xcode / Android Studio<br/>ネイティブデバッガ"]
  DEBUG_JS --> JS_ONLY
  DEBUG_NATIVE --> NATIVE_CHANGE

JS だけの変更は Metro HMR が数百ミリ秒で差分を送ります。状態も保持されます。

ネイティブコードを変えた場合は再ビルドが必要です。iOS のビルド時間は初回で2〜5分、差分なら30秒〜1分ほどが目安です。Android は Gradle のキャッシュが効けば同程度、初回はさらに時間がかかります。

この非対称性が Bare Workflow での開発効率の鍵です。ネイティブの境界面(Spec)を先に固めてしまい、実装の大半を JS 側に寄せる設計にすることで、重いネイティブビルドの頻度を下げられます。


13. デバッグ:JS とネイティブの境界を跨ぐ調査

flowchart TD
  SYMPTOM["症状を観察"]

  SYMPTOM --> Q1{"クラッシュの種類"}

  Q1 -->|"JS エラー<br/>LogBox 赤画面"| JS_ERR["Hermes Debugger で調査<br/>ブレークポイント・スタックトレース<br/>Source Map で TS 行番号"]
  Q1 -->|"ネイティブクラッシュ<br/>アプリが落ちる・フリーズ"| NAT_ERR["クラッシュログを確認"]
  Q1 -->|"動作はするが<br/>結果がおかしい"| BORDER["JS↔Native 境界を疑う"]

  NAT_ERR --> IOS_CRASH["iOS: Xcode の<br/>Debug Navigator<br/>+ lldb アタッチ"]
  NAT_ERR --> AND_CRASH["Android: Android Studio<br/>Logcat でスタックトレース<br/>+ Native Debugger"]

  BORDER --> LOG_IOS["iOS: NSLog / os_log<br/>npx react-native log-ios"]
  BORDER --> LOG_AND["Android: Log.d<br/>npx react-native log-android"]
  BORDER --> JS_LOG["JS 側: console.log で<br/>TurboModule 戻り値を確認"]

  IOS_CRASH --> INSTRUMENTS["Instruments<br/>Time Profiler / Allocations<br/>メモリリーク・CPU 特定"]
  AND_CRASH --> PROFILER["Android Studio<br/>Memory / CPU Profiler"]

  JS_ERR --> FIXED["修正 → Metro HMR で即反映"]
  INSTRUMENTS --> FIXED2["修正 → npx expo run:ios で確認"]
  PROFILER --> FIXED3["修正 → npx expo run:android で確認"]
  LOG_IOS & LOG_AND & JS_LOG --> BORDER2["原因箇所を特定<br/>→ Spec / 実装を修正"]

JS エラー:Hermes Debugger

Cmd+D → "Open Debugger" で Chrome DevTools が開きます。Metro が Source Map を持っているので、バンドル済みコードではなく元の TypeScript の行番号でブレークポイントが貼れます。TurboModule への呼び出しで例外が起きた場合も、コールスタックが JS 側まで追えます。

ネイティブクラッシュ:Xcode / Android Studio のデバッガをアタッチ

# iOS: 起動済みのシミュレーターに lldb をアタッチ
npx expo run:ios --configuration Debug
# → Xcode が自動で開いてデバッガがアタッチされる

# Android
npx expo run:android
# → 「Run > Attach Debugger to Android Process」でアタッチ

シミュレーターではなく実機でのみ再現するクラッシュ(Bluetooth・カメラ・センサー系は特に)は、USB 接続した実機に同じ手順でデバッガをアタッチできます。

境界調査:ログで挟み込む

JS↔Native の境界でデータが化ける場合は、両側からログで挟むのが最速です。

// Swift 側
os_log("BluetoothModule.connect called: %{public}@", deviceId)
// → npx react-native log-ios でフィルタして確認
// Kotlin 側
Log.d("BluetoothModule", "connect called: $deviceId")
// → npx react-native log-android でフィルタして確認
// JS 側
const result = await BluetoothModule.connect(deviceId);
console.log('connect result:', result); // Metro に出力される

React DevTools

npx react-devtools
# → ポート 8097 でデバイスの接続を待機

コンポーネントツリーを視覚的に確認でき、props や state をインスペクタで直接書き換えてリアルタイムに動作確認できます。


14. エミュレーターと実機の使い分け

確認したい内容 iOS Simulator Android Emulator 実機
UI・レイアウト 十分 十分 最終確認
JS ロジック・API 十分 十分 不要
Bluetooth / NFC 不可 不可 必須
カメラ / マイク 部分的 不可 必須
Push 通知 部分的 部分的 推奨
パフォーマンス 参考程度 参考程度 必須
生体認証 Face ID シミュ可 指紋シミュ可 推奨

ネイティブモジュールを伴う開発では「エミュレーターで JS の動作を固め、実機でネイティブ機能を検証する」という2段階運用が現実的です。特に Bluetooth や NFC はエミュレーターで一切テストできないため、実機の台数・OS バージョンのカバレッジが品質に直結します。


まとめ

技術スタックの全体像

レイヤー 技術 役割
バンドラー Metro ソースのトランスパイル・HMR・プラットフォーム分岐
JS エンジン Hermes 起動高速化のバイトコード実行
JS↔Native 橋 JSI C++ 経由の同期バインディング
コード生成 Codegen Spec から型安全なネイティブ雛形を自動生成
UI レンダラー Fabric JSI 上で動く同期的 UI レンダリング
ネイティブ API TurboModules Codegen 生成クラスを継承した遅延初期化モジュール
開発基盤 Expo / EAS SDK・クラウドビルド・OTA アップデート

ネイティブ実装を伴う開発の全体フロー

① JS Spec を書く(TypeScript で型定義)
       ↓
② pod install / gradle build → Codegen が C++ 雛形を自動生成
       ↓
③ Swift / Kotlin で実装(社内 SDK・外部ライブラリを呼ぶ)
       ↓
④ npx expo run:ios / run:android でネイティブビルド&エミュレーター確認
       ↓
⑤ JS 側の呼び出しコードを書く → Metro HMR で高速イテレーション
       ↓
⑥ JS エラー → Hermes Debugger
   ネイティブクラッシュ → Xcode / Android Studio デバッガ
   境界の不整合 → log-ios / log-android + console.log で挟み込み
       ↓
⑦ 実機で Bluetooth / カメラ / センサー系を検証
       ↓
⑧ EAS Build でストア向けバイナリ生成

Bridge から JSI + Codegen への移行で React Native は「動くがもろい」から「型安全でパフォーマンスが高い」へと大きく変わりました。Expo の外に出た瞬間に「ビルドが重い」「2言語で同じロジックを書く」というコストが生まれますが、Codegen が Spec を共通の真実の源泉にすることで、型のずれによるランタイムクラッシュという最大のリスクは設計段階で取り除かれています。

参考 URL 一覧

注: 本文は以下の資料をもとに筆者が要約・再構成したものです。文中の説明やコード例は理解しやすさのために簡略化しており、参考 URL からの直接引用ではありません。実装時は利用中バージョンに対応する公式ドキュメントを優先してください。

参照した URL

React Native 公式

Expo 公式