メインコンテンツへスキップ
モバイルアプリ開発(Expo / React Native)
React Native
Expo
Swift
iOS
ネイティブモジュール
モバイルアプリ
TypeScript
アーキテクチャ設計
テスト

React Native / Expo × Swift ネイティブモジュール実装ガイド:Expo Modules API と Turbo Native Modules を本番コードで使い分ける

React Native / Expo から Swift の iOS API を呼ぶネイティブモジュールの実装ガイド。Expo Modules API(SDK 57)と Turbo Native Modules の選び方、async/await・Record・エラーコード・イベント・SharedObject、Jest でのテスト、プライバシーマニフェストまでを、公式ドキュメントと実アプリのコードで解説します。

公開日
読了時間
37分
著者
友田 陽大
シェア
目次

Expo と React Native はほとんどの機能をカバーします。それでも実際のアプリ開発では、どこかで「このiOSの機能を呼べるライブラリが無い」という壁に当たります。端末内でのOCR、App Attest による端末の真正性証明、WidgetKit や Live Activity との連携、社内で指定された業務SDKの組み込みなどです。そうなったら、Swift でネイティブモジュールを書いて JS から呼ぶしかありません。

私自身、リリース準備中の自社アプリ MemoryHack AI(教科書を撮影すると AI が一問一答を作る暗記アプリ)で、7本のローカル Expo モジュールを書いています(iOS 側の Swift 実装が6本、Android 側の Kotlin 実装が3本)。この記事では、その実装と、実装中に踏んだ事故を材料に、Expo 公式・React Native 公式・Apple / Swift の一次情報に沿って「どの方式を・どの場面で・どう書くか」を解説します。

本記事の基準バージョン:Expo SDK 57(React Native 0.86 / React 19.2 / expo-modules-core 57.0.x)。実行時の挙動に関する記述は、公式ドキュメントに加えて expo/expo リポジトリの sdk-57 ブランチのソースで確認しています。実例コードの出典である自社アプリは SDK 54 で動いていますが、ここで扱う Module API の DSL は SDK 54 と 57 で同じです。

0. 結論:どの方式を選ぶか

先に判断の表を示します。上から順に検討し、最初に当てはまったものを選びます。

状況選ぶもの理由
既存の Expo SDK / コミュニティのライブラリで足りるまずそれを使う保守を自分で抱えない。ネイティブコードを書かないのが一番安い
Expo アプリで、自分のアプリ専用の機能を Swift で書くExpo Modules API(ローカルモジュール)Swift の DSL だけで書ける。Objective-C++ のつなぎも Codegen も不要。CNG と衝突しない
小さな実験を最短で試すExpo Modules API の inline modulesSDK 56 以降の実験的機能。API が破壊的に変わりうるので本番の土台にはしない
expo に依存しない汎用ライブラリとして公開する/C++ を使うTurbo Native Modules(Codegen)React Native 本体の仕組み。Swift を使う場合は Objective-C++ のアダプタが必要
複数プラットフォームで共通のロジックを C++ で持つC++ Turbo Moduleこの記事の範囲外(React Native 公式 “Pure C++ Modules” を参照)

Expo 公式は React Native チームの推奨を、次のように要約しています。

  • C++ を使うなら Turbo Modules(低レベルの仕組みに触りやすいから)
  • 開発体験を取り、expo パッケージへの依存を許容するなら Expo Modules API

性能面は、選ぶ理由になりません。公式によれば、Expo Modules も Turbo Modules も React Native の JSI(JavaScript Interface) 上で動き、どちらも毎秒数十万回のネイティブ呼び出しをこなせます。呼び出しのオーバーヘッドよりメソッド本体の処理時間のほうが桁違いに大きい、というのが公式の見解です。したがって、設計で気にすべきなのは呼び出しの回数ではなく、1回あたりの処理の重さです。


この記事の実装を、案件として承ります

Expo / React Native アプリの iOS ネイティブ機能(Swift)を、設計から審査対応まで承ります

1. メンタルモデル:「ブリッジ」はもう JSON のメッセージキューではない

古い React Native の解説にある「ブリッジ」は、JS とネイティブが JSON のメッセージを非同期のキューでやり取りする仕組みでした。現在の Expo Modules と Turbo Modules は、どちらも JSI でネイティブの関数を JS のオブジェクトとして直接公開します。Expo SDK 55 以降は New Architecture が常に有効で、無効にはできません(詳しくは Expo本番運用ガイド)。

実装で最初に押さえるべきなのは、「どのスレッドで何が動くか」です。Expo Modules API(iOS)の実行モデルを、公式ドキュメントと SDK 57 のソースから整理すると次の表になります。

定義JS から見た形ネイティブ側の実行場所使いどころ
Function同期で戻り値を返すJS スレッド。終わるまでスクリプトの実行を止める一瞬で終わる読み取り(能力の確認・定数の計算)
AsyncFunction(通常のクロージャ)Promise既定では全モジュールが共有する1本のシリアルキュー(expo.modules.AsyncFunctionQueue、QoS は userInitiated)。.runOnQueue(...) で変更可能I/O・重い計算・メインスレッドが必要な処理
AsyncFunction(async クロージャ)PromiseSwift Concurrency(クロージャは @Sendable)。.runOnQueue は無いOS の async API(App Attest など)を包むとき
View 内の AsyncFunctionref のメソッド既定で UI スレッドfocus() のようなビュー操作
JavaScriptValue を受け取る関数同期のみJS スレッド(他のスレッドから触るとクラッシュ)JS オブジェクトを直接書き換える特殊な用途

表の2行目は、公式ドキュメントには書かれていない実運用上の落とし穴です。SDK 57 のソース(AsyncFunctionDefinition.swift)では、既定キューはファイル内の private let defaultQueue = DispatchQueue(label: "expo.modules.AsyncFunctionQueue", qos: .userInitiated) の1本だけです。これはシリアルキューなので、あるモジュールの数秒かかる OCR が、別のモジュールの AsyncFunction まで待たせます。数百ミリ秒を超えうる処理は、専用キューに移すか async クロージャで書く、と覚えておいてください(§3 で実装します)。

JS と Swift の間で受け渡せる型も確認しておきます(Module API Reference の “Argument types” より)。

Swift 側JS / TS 側補足
Bool / Int 各種 / Double / Stringboolean / number / string配列・辞書・Optional も可
Record に準拠した structオブジェクトフィールドごとに型を持ち、既定値を置ける。戻り値にも使える
Enumerable に準拠した enum文字列(または数値)のユニオン範囲外の値は EnumNoSuchValueException で呼び出し前に弾かれる
Either<A, B> などどちらかの型最大4種類まで
URLstringスキームが無い文字列は file URL として扱われる
DataUint8ArraySDK 50 以降。バイナリを base64 にせず渡せる
CGPoint / CGSize / CGRect / UIColorオブジェクト・配列・色文字列組み込みの Convertible
SharedObject のサブクラスJS のクラスのインスタンスネイティブの状態を JS から参照し続ける(§6)

[String: Any] を受け取って手で型を確かめるのは、ほとんどの場合で間違いです。Record と Enumerable を使えば、型の検証と変換が呼び出しの手前で自動的に行われ、失敗時は原因付きの例外になります。公式チュートリアルには、次のエラーメッセージが実例として載っています。

Error: FunctionCallException: Calling the 'setTheme' function has failed
→ Caused by: ArgumentCastException: Argument at index '0' couldn't be cast to type Enum<Theme>
→ Caused by: EnumNoSuchValueException: 'not-a-real-theme' is not present in Theme enum, it must be one of: 'light', 'dark', 'system'

1つ注意があります。@Field の実体は参照型(SDK 57 のソースでは public final class Field)です。そのため Record の struct をコピーしても、フィールドの値はコピー元と共有されます。Record は「境界で受け取って読む」「組み立てて返す」ためだけに使い、アプリ内部で値型として持ち回さないのが安全です。


2. 最小構成:ローカルモジュールを作る

アプリ専用のモジュールは、アプリのリポジトリの中にローカルモジュールとして置きます(npm には公開しません)。

npx create-expo-module@latest --local   # modules/<name>/ を作る
npx expo prebuild --clean               # ネイティブプロジェクトを作り直し、自動リンクさせる
npx expo run:ios                        # 自作のネイティブコードは Expo Go では動かない。開発ビルドで確認する

生成される構成は次のとおりです。自動リンクは、既定で ./modules/ 以下を探します(autolinking の nativeModulesDir)。

modules/text-recognition/
├── expo-module.config.json   # どの Swift クラスをモジュールとして登録するか
├── index.ts                  # アプリから import する公開API(ラッパー)
├── src/
│   └── TextRecognitionModule.ts  # requireNativeModule と型宣言だけを置く
└── ios/
    ├── MyAppTextRecognition.podspec  # ← これが無いと、黙ってリンクされない(後述)
    └── TextRecognitionModule.swift
{
  "platforms": ["apple", "android"],
  "apple": { "modules": ["TextRecognitionModule"] },
  "android": { "modules": ["expo.modules.textrecognition.TextRecognitionModule"] }
}

SDK 57 のテンプレートは apple キーを使います。古いテンプレートで作った ios キーも、autolinking が rawConfig.apple ?? rawConfig.ios のように読むため、今も動きます。

2-1. 事故①:podspec が無いモジュールは、エラー無しで存在しないことになる

これは自社アプリで実際に起きた事故です。expo-module.config.json と Swift ファイルがあれば十分だと思い込んで podspec を置かずにいたところ、そのモジュールは一度もリンクされていませんでした。SDK 57 の autolinking のソース(platforms/apple/apple.ts)を読むと、resolveModuleAsync は .podspec が1つも見つからないときnull を返すだけで、エラーは出しません。

困るのは、これが次の JS の書き方と組み合わさったときです。

// ❌ 未リンクと「非対応の端末」が同じ見え方になる
try {
  return requireNativeModule('DeviceCheck');
} catch {
  return null; // シミュレーターや Android と同じ扱いにしたつもり
}

シミュレーターや Android では null になるのが正しい動作なので、テストも画面も正常に見えます。本番の iOS でもずっと null でした。サーバー側でこの値を使う判定が無効になっていたこともあり、誰も気づかないまま約7週間が過ぎました。対策は §9 の「起動時の自己診断」で、仕組みとして防ぎます。

2-2. 事故②:pod の名前が Apple のフレームワークを隠す

podspec を置くときにも罠があります。DEFINES_MODULE = YES の pod に Apple のフレームワークと同じ名前(DeviceCheck、Vision、WidgetKit など)を付けると、ソース中の import DeviceCheck がApple のフレームワークではなくその pod 自身を指すようになります。自社アプリでは MemoryHackDeviceCheck のように、アプリ固有の接頭辞を付けて回避しました。

# ios/MyAppTextRecognition.podspec
Pod::Spec.new do |s|
  s.name           = 'MyAppTextRecognition'   # 'Vision' にすると import Vision が自分自身を指す
  s.version        = '1.0.0'
  s.summary        = 'On-device text recognition (Vision).'
  s.author         = ''
  s.homepage       = 'https://docs.expo.dev/modules/'
  s.platforms      = { :ios => '16.4' }       # SDK 57 テンプレートの値。アプリの最小OSに合わせる
  s.source         = { git: '' }
  s.static_framework = true
  s.dependency 'ExpoModulesCore'
  s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
  s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

3. 実例①:端末内OCR(Vision)— AsyncFunction・Record・Enum・型付き例外

最初の実例は、撮影した教科書のページを端末の中だけで読むモジュールです。Vision は iOS に最初から入っているフレームワークなので、依存ライブラリは増えません。画像も外部へ送らず、API の利用料もかかりません。

自社アプリでこれが必要になった理由は、少し意外なものでした。生成AIに「答えがページのどこにあるか」を座標で返させたところ、その座標が答えの位置を正しく覆えていたのは 88問中0問でした(2026-09-14、日本語の一次資料21ページで計測)。一方、端末の OCR の結果から答えの文字列を探すと、93問中86問で位置を特定でき、その86問ではどの箱も答えをぴったり覆っていました。「位置はモデルに聞かず、端末で測る」。ネイティブモジュールは、こういう判断を実装するための道具です。

3-1. Swift 側

// modules/text-recognition/ios/TextRecognitionModule.swift
import ExpoModulesCore
import UIKit
import Vision

/// JS から渡される読み取りオプション。JS 側で省略したフィールドには既定値が入る
struct ReadPageOptions: Record {
  @Field var level: RecognitionLevel = .accurate
  @Field var languages: [String] = ["ja-JP", "en-US"]
  /// 既定は OFF。理由は本文 3-2 を参照
  @Field var usesLanguageCorrection: Bool = false
}

enum RecognitionLevel: String, Enumerable {
  case fast
  case accurate

  var vision: VNRequestTextRecognitionLevel { self == .fast ? .fast : .accurate }
}

/// 戻り値も Record にすると、JS 側の型と1対1で対応させられる
struct RecognizedCharacter: Record {
  @Field var text: String = ""
  @Field var xmin: Int = 0
  @Field var ymin: Int = 0
  @Field var xmax: Int = 0
  @Field var ymax: Int = 0
}

struct RecognizedLine: Record {
  @Field var characters: [RecognizedCharacter] = []
}

public final class TextRecognitionModule: Module {
  /// Vision は重い。共有の既定キューを塞がないよう、専用のシリアルキューで動かす
  private static let queue = DispatchQueue(label: "app.text-recognition", qos: .userInitiated)

  public func definition() -> ModuleDefinition {
    Name("TextRecognition")

    // 能力の確認だけなら同期でよい。呼び出し側は「そもそも試すか」をこれで決める
    Function("supportedLanguages") { (level: RecognitionLevel) -> [String] in
      Self.supportedLanguages(level: level)
    }

    AsyncFunction("readPage") { (url: URL, options: ReadPageOptions) throws -> [RecognizedLine] in
      // 信頼境界:ローカルファイルだけを受け付ける。任意の URL を取りに行くモジュールは、リクエスト偽造の踏み台になる
      guard url.isFileURL else { throw NotAFileURLException() }
      guard let image = UIImage(contentsOfFile: url.path), let cgImage = image.cgImage else {
        throw ImageUnreadableException()
      }

      // OS によって使える言語が違う。バージョンで決め打ちせず、実際に使えるかを問い合わせる
      let available = Set(Self.supportedLanguages(level: options.level))
      let languages = options.languages.filter(available.contains)
      guard !languages.isEmpty else { throw LanguageUnavailableException(options.languages) }

      let request = VNRecognizeTextRequest()
      request.recognitionLevel = options.level.vision
      request.usesLanguageCorrection = options.usesLanguageCorrection
      request.recognitionLanguages = languages

      let handler = VNImageRequestHandler(
        cgImage: cgImage,
        orientation: CGImagePropertyOrientation(image.imageOrientation)
      )
      do {
        try handler.perform([request])
      } catch {
        throw RecognitionFailedException().causedBy(error)
      }

      return (request.results ?? []).compactMap { observation in
        guard let candidate = observation.topCandidates(1).first else { return nil }
        let line = RecognizedLine()
        line.characters = PageGeometry.characters(of: candidate)
        return line
      }
    }
    .runOnQueue(Self.queue)
  }

  private static func supportedLanguages(level: RecognitionLevel) -> [String] {
    let request = VNRecognizeTextRequest()
    request.recognitionLevel = level.vision
    return (try? request.supportedRecognitionLanguages()) ?? []
  }
}

座標の変換は、ブリッジから切り離した純粋な型に置きます。こうしておくと、ここだけを XCTest でテストできます(§9)。

// modules/text-recognition/ios/PageGeometry.swift
import UIKit
import Vision

/// Vision の正規化座標(原点は左下、0...1)を、アプリで使う座標(原点は左上、0...1000)に変換する
enum PageGeometry {
  static let space: CGFloat = 1000

  static func characters(of candidate: VNRecognizedText) -> [RecognizedCharacter] {
    let string = candidate.string
    return string.indices.compactMap { index in
      let range = index..<string.index(after: index)
      guard let rect = try? candidate.boundingBox(for: range)?.boundingBox,
            let box = box(from: rect) else { return nil }
      // @Field は参照型(final class)のラッパーなので、let のまま値を入れられる
      let character = RecognizedCharacter()
      character.text = String(string[range])
      (character.xmin, character.ymin, character.xmax, character.ymax) = box
      return character
    }
  }

  /// 箱として成り立たないものは nil。推測で埋めない
  static func box(from rect: CGRect) -> (Int, Int, Int, Int)? {
    // Int(_:) は有限でない値でトラップ(クラッシュ)する。写真からの値は信用しない
    guard rect.minX.isFinite, rect.maxX.isFinite, rect.minY.isFinite, rect.maxY.isFinite,
          rect.width > 0, rect.height > 0 else { return nil }
    // Y軸を反転するのはここ1か所だけ。2か所にあると、上下が逆でも範囲チェックは通ってしまう
    return (
      coordinate(rect.minX, .down), coordinate(1 - rect.maxY, .down),
      coordinate(rect.maxX, .up), coordinate(1 - rect.minY, .up)
    )
  }

  /// 丸めは常に外側へ。内側に丸めると、隠したい文字の端がはみ出して見える
  static func coordinate(_ value: CGFloat, _ rule: FloatingPointRoundingRule) -> Int {
    Int(min(max((value * space).rounded(rule), 0), space))
  }
}

extension CGImagePropertyOrientation {
  /// 名前どうしで対応させる。rawValue は2つの enum で番号が違う(.right は 3 と 6)
  init(_ orientation: UIImage.Orientation) {
    switch orientation {
    case .up: self = .up
    case .upMirrored: self = .upMirrored
    case .down: self = .down
    case .downMirrored: self = .downMirrored
    case .left: self = .left
    case .leftMirrored: self = .leftMirrored
    case .right: self = .right
    case .rightMirrored: self = .rightMirrored
    @unknown default: self = .up
    }
  }
}
// modules/text-recognition/ios/TextRecognitionExceptions.swift
import ExpoModulesCore

// code は、override しなければクラス名から自動で作られる。
// ただし略語を含む名前は崩れる:NotAFileURLException → "ERR_NOT_AFILE_UR_L"(SDK 57 の規則で計算)。
// JS 側で分岐に使う code は override して固定し、クラス名を変えても契約が壊れないようにする
final class NotAFileURLException: Exception, @unchecked Sendable {
  override var code: String { "ERR_TEXT_RECOGNITION_NOT_A_FILE_URL" }
  override var reason: String { "Only local file URLs are accepted." }
}

final class ImageUnreadableException: Exception, @unchecked Sendable {
  override var reason: String { "The image could not be opened." }
}

final class LanguageUnavailableException: GenericException<[String]>, @unchecked Sendable {
  override var code: String { "ERR_TEXT_RECOGNITION_LANGUAGE_UNAVAILABLE" }
  override var reason: String { "None of the requested languages is available on this OS: \(param)" }
}

final class RecognitionFailedException: Exception, @unchecked Sendable {
  override var reason: String { "Text recognition failed for this image." }
}

3-2. 公式ドキュメントだけでは気づけない4つのこと

  1. 向き(orientation)の変換は rawValue で行ってはいけない。UIImage.Orientation と CGImagePropertyOrientation は同じ8方向を表しますが、番号の振り方が違います。縦持ちで撮った写真は UIImage.Orientation.right(rawValue 3)で、EXIF では 6 です。rawValue で変換すると、90度ずれた画像の上で座標を測ることになります。
  2. 幅も高さも 0 の箱が返ってくる。Vision は空白文字に対して、ページの左下の隅にある大きさ 0 の矩形を返すことがあります。自社の計測(50ページ、34,021文字)では、そのうち211文字がこれでした。大きさ 0 の箱をそのまま使うと、隠す範囲が「答え」から「ページの隅」まで伸びてしまいます。
  3. 言語補正を切るかどうかは用途で決まる。Apple のドキュメントには、usesLanguageCorrection を false にすると「性能は上がるが精度は下がる」とあります。一方で、原文と照合する用途では、補正が古い表記を「正しい」表記に書き換えてしまいます(「あつた」を「あった」に直すなど)。そうなると、書き換えられた引用を「原文どおり」と誤って判定します。自社の計測では、補正を切ってもページ全体の一致度は 0.991 以上(中央値 0.997)で、支払う代償はほとんどありませんでした。
  4. ja-JP をどの OS で使えるかはバージョンで決め打ちしない。手元の検証では iOS 16 以降でしたが、実装では supportedRecognitionLanguages()(iOS 15 以降)を呼び、実際に使える言語を調べます。英語だけの認識器で日本語のページを読むと、ラテン文字がまばらに返るだけです。それを「このページには文字が無い」と解釈すると、判定を誤ります。

3-3. TypeScript 側:失敗の種類を型で表す

JS 側は2つのファイルに分けます。ネイティブとの境界だけを置くファイルと、アプリが使う公開 API(ラッパー)です(SRP)。前者だけをモックすれば、ラッパーのロジックを Jest で検証できます。

// modules/text-recognition/src/TextRecognitionModule.ts
import { NativeModule, requireOptionalNativeModule } from 'expo';

export type RecognitionLevel = 'fast' | 'accurate';

export interface ReadPageOptions {
  readonly level?: RecognitionLevel;
  readonly languages?: readonly string[];
  readonly usesLanguageCorrection?: boolean;
}

export interface RecognizedCharacter {
  readonly text: string;
  readonly xmin: number;
  readonly ymin: number;
  readonly xmax: number;
  readonly ymax: number;
}

export interface RecognizedLine {
  readonly characters: readonly RecognizedCharacter[];
}

declare class TextRecognitionNativeModule extends NativeModule {
  supportedLanguages(level: RecognitionLevel): string[];
  readPage(uri: string, options: ReadPageOptions): Promise<RecognizedLine[]>;
}

/** 見つからなければ null(Android 未実装・Web・未リンク)。例外は投げない */
export default requireOptionalNativeModule<TextRecognitionNativeModule>('TextRecognition');
// modules/text-recognition/index.ts
import TextRecognition, { type ReadPageOptions, type RecognizedLine } from './src/TextRecognitionModule';

export type { ReadPageOptions, RecognizedCharacter, RecognizedLine } from './src/TextRecognitionModule';

/** Swift 側の LanguageUnavailableException が override している code。綴りは契約テストで固定する */
export const LANGUAGE_UNAVAILABLE = 'ERR_TEXT_RECOGNITION_LANGUAGE_UNAVAILABLE';

export type ReadPageResult =
  | { readonly status: 'read'; readonly lines: readonly RecognizedLine[]; readonly text: string }
  | { readonly status: 'unavailable'; readonly reason: 'module-missing' | 'language-missing' }
  | { readonly status: 'failed'; readonly code: string };

function errorCode(error: unknown): string {
  return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
    ? error.code
    : 'ERR_UNKNOWN';
}

export async function readPage(uri: string, options: ReadPageOptions = {}): Promise<ReadPageResult> {
  if (TextRecognition === null) return { status: 'unavailable', reason: 'module-missing' };
  try {
    const lines = await TextRecognition.readPage(uri, options);
    // 文字列は「箱を持つ文字」から組み立てる。照合に使う文字列と、マスクを描く箱が食い違わない
    const text = lines.map((line) => line.characters.map((c) => c.text).join('')).join('\n');
    return { status: 'read', lines, text };
  } catch (error) {
    const code = errorCode(error);
    return code === LANGUAGE_UNAVAILABLE
      ? { status: 'unavailable', reason: 'language-missing' }
      : { status: 'failed', code };
  }
}

ポイントは、「読めなかった」を1種類の null にまとめないことです。画面の側では read 以外を同じように扱う(何も表示せず、何も主張しない)としても、可観測性の側では module-missing と failed を区別できなければなりません。§2-1 の事故は、この区別が無かったために起きました。逆に、写真を読めた結果として文字が0件だった(read で空)場合は、呼び出し側が「このページには文字が無い」と判断してよい、唯一のケースです。


4. 実例②:Swift Concurrency で OS の async API を包む(App Attest)

2つ目は、Apple の App Attest(DCAppAttestService)です。サーバーが発行したチャレンジに対して、本物の Apple 端末上の本物のこのアプリであることを Secure Enclave の鍵で証明します。OS 側の API が async なので、async throws のクロージャでそのまま包めます。

// modules/app-attest/ios/AppAttestModule.swift
import CryptoKit
import DeviceCheck
import ExpoModulesCore

public final class AppAttestModule: Module {
  public func definition() -> ModuleDefinition {
    Name("AppAttest")

    // シミュレーターでは常に false
    Function("isSupported") { () -> Bool in
      DCAppAttestService.shared.isSupported
    }

    // JS に返すのは鍵の ID だけ。秘密鍵は Secure Enclave から出ず、取り出す API も存在しない
    AsyncFunction("generateKey") { () async throws -> String in
      guard DCAppAttestService.shared.isSupported else { throw AppAttestUnsupportedException() }
      return try await DCAppAttestService.shared.generateKey()
    }

    AsyncFunction("generateAssertion") { (keyId: String, challengeBase64: String) async throws -> String in
      guard let challenge = Data(base64Encoded: challengeBase64) else {
        throw AppAttestInvalidChallengeException()
      }
      do {
        // ハッシュはネイティブ側で取る。何に署名したかについて、クライアントとサーバーの認識がずれない
        let assertion = try await DCAppAttestService.shared.generateAssertion(
          keyId, clientDataHash: Data(SHA256.hash(data: challenge))
        )
        return assertion.base64EncodedString()
      } catch let error as DCError where error.code == .invalidKey {
        // 見分けたい失敗だけを Exception に変換する。それ以外の DCError は ERR_UNEXPECTED のまま
        throw AppAttestInvalidKeyException()
      }
    }
  }
}

final class AppAttestUnsupportedException: Exception, @unchecked Sendable {
  override var reason: String { "App Attest is not supported on this device." }
}

final class AppAttestInvalidChallengeException: Exception, @unchecked Sendable {
  override var reason: String { "Challenge was not valid base64." }
}

/// 鍵 ID は Keychain に残るが、鍵そのものは再インストールや機種変更で失われる
final class AppAttestInvalidKeyException: Exception, @unchecked Sendable {
  // JS がこの綴りで分岐する。クラス名から自動生成させず、明示して固定する
  override var code: String { "ERR_APP_ATTEST_INVALID_KEY" }
  override var reason: String { "The App Attest key is no longer held by this device." }
}

4-1. エラーコードが JS に届くまで(SDK 57 のソースで確認)

  • Exception の code は、override しなければクラス名から作られます。末尾の Exception / Error を外してスネークケースにし、大文字にして ERR_ を付けます(errorCodeFromString)。
  • 関数の中で投げた Exception は、FunctionCallException に包まれてから JS に届きます。ただし FunctionCallException の code は原因となった例外の code をそのまま返す実装なので、JS からは ERR_APP_ATTEST_INVALID_KEY が見えます。
  • Exception ではないエラー(DCError や NSError など)は UnexpectedException に包まれ、JS からは一律に ERR_UNEXPECTED に見えます。見分けたい失敗は、必ず Swift の中で Exception に変換してから投げてください。

4-2. なぜ「鍵が無効」と「一時的な失敗」を分けるのか(冪等性)

JS 側は結果を3種類に分けます。

export type AssertionResult =
  | { readonly status: 'signed'; readonly assertion: string }
  | { readonly status: 'invalid_key' } // このときだけ鍵を作り直す
  | { readonly status: 'failed' };     // 一時的な失敗。鍵はそのまま使い続ける

一時的な失敗まで invalid_key と扱うと、失敗のたびに鍵を作り直し、サーバー側で端末と鍵の結び付けを付け替えることになります。逆に、本当に失われた鍵を failed と扱うと、再インストール後の端末は使えない鍵を出し続けて拒否され続けます。どちらの誤りも、実害のある障害になります。Apple のドキュメントも、鍵 ID が無ければ鍵は使えないのですぐにアプリかサーバーに保存するようにと書いています。「起動のたびに鍵を作る」実装は誤りです。

4-3. async クロージャから UIKit に触るとき

async クロージャ(ConcurrentFunctionDefinition)には .runOnQueue がありません(SDK 57 のソースでは、runOnQueue を持つのは通常のクロージャ用の定義だけです)。UIKit の状態を読むときは、メインアクターへ明示的に移ります。

AsyncFunction("isAppActive") { () async -> Bool in
  await MainActor.run { UIApplication.shared.applicationState == .active }
}

SDK 57 では、async クロージャの型が @Sendable です。Swift 6 の言語モードでは、Sendable でない値をクロージャに取り込むとコンパイルエラーになります。モジュールが可変の状態を持つなら、その状態は1つのキューか actor の中に閉じ込めてください。


5. 実例③:ネイティブから JS へのイベント(Events / OnStartObserving)

3つ目は、ネイティブ側から JS へ知らせる向きの通信です。ここでは、通信が高価(モバイル回線・テザリング)か、省データモードかを購読し、同期キューの送信方針を切り替える例を作ります。実装の前に、expo-network など既存のライブラリで足りないかを先に確認してください(§0 の表の1行目)。

// modules/network-quality/ios/NetworkQualityModule.swift
import ExpoModulesCore
import Network

public final class NetworkQualityModule: Module {
  private var monitor: NWPathMonitor?
  private let queue = DispatchQueue(label: "app.network-quality")

  public func definition() -> ModuleDefinition {
    Name("NetworkQuality")
    Events("onChange")

    // 最初のリスナーが付いたときに監視を始める。誰も聞いていない間は電池を使わない
    OnStartObserving("onChange") {
      let monitor = NWPathMonitor() // cancel 後に再利用せず、開始のたびに作る
      monitor.pathUpdateHandler = { [weak self] path in
        self?.sendEvent("onChange", [
          "isConnected": path.status == .satisfied,
          "isExpensive": path.isExpensive,     // モバイル回線・テザリング
          "isConstrained": path.isConstrained, // 省データモード
        ])
      }
      monitor.start(queue: queue)
      self.monitor = monitor
    }

    // 最後のリスナーが外れたら止める
    OnStopObserving("onChange") {
      monitor?.cancel()
      monitor = nil
    }

    OnDestroy {
      monitor?.cancel()
    }
  }
}

sendEvent はどのスレッドから呼んでも問題ありません。SDK 57 のソースでは、イベントは runtime.schedule で JS スレッドに渡されてから配信されます。

TS 側は、型付きのイベントマップを宣言し、expo の useEvent で購読します。

// modules/network-quality/index.ts
import { EventEmitter, NativeModule, requireOptionalNativeModule, useEvent } from 'expo';

export interface NetworkQuality {
  readonly isConnected: boolean;
  readonly isExpensive: boolean;
  readonly isConstrained: boolean;
}

type Events = { onChange: (quality: NetworkQuality) => void };

declare class NetworkQualityModule extends NativeModule<Events> {}

/**
 * モジュールが無い環境(Android 未実装・Web・未リンク)では、何も発火しない emitter を使う。
 * こうすると useEvent を常に呼べるので、フックを条件付きで呼ぶ必要がない(rules-of-hooks を守れる)
 */
const emitter =
  requireOptionalNativeModule<NetworkQualityModule>('NetworkQuality') ?? new EventEmitter<Events>();

/** 何も分からないときは「制約なし」とみなす。送信を止める側に倒すと、Android や Web で同期が止まる */
const UNKNOWN: NetworkQuality = { isConnected: true, isExpensive: false, isConstrained: false };

export function useNetworkQuality(): NetworkQuality {
  return useEvent(emitter, 'onChange', UNKNOWN);
}

useEvent は、最初のレンダーでリスナーを登録し、アンマウント時に外します。最後のリスナーが外れると、Swift 側の OnStopObserving が呼ばれて監視が止まります。

使う側では、たとえば省データモードのときに大きな画像のアップロードだけを後回しにし、テキストの同期は止めない、といった細かい方針を実装できます。


6. 状態を持つネイティブ資源:SharedObject

1回の呼び出しで完結しない資源(デコード済みの画像、開いた PDF、長く使うセッションなど)は、呼び出しのたびに開き直すと、I/O とメモリの両方を無駄にします。Expo 公式は、この目的のために SharedObject(ネイティブのインスタンス1つを、JS から参照し続ける仕組み)を用意しています。

自社アプリの PDF ページ書き出しモジュールは、現状 renderPage(uri, pageIndex, maxWidth) が呼ばれるたびに PDF を開き直しています。教科書の1章分、数百枚のサムネイルを作ると、同じ PDF を数百回開くことになります。SharedObject で書き直すと次のようになります。

// modules/pdf-pages/ios/PdfPagesModule.swift(SharedObject 版)
import ExpoModulesCore
import PDFKit

final class PdfDocumentObject: SharedObject {
  let document: PDFDocument
  let pageCount: Int // 不変の値として持つ。JS スレッドから読まれても競合しない

  init(document: PDFDocument) {
    self.document = document
    self.pageCount = document.pageCount
    super.init()
  }
}

public final class PdfPagesModule: Module {
  /// 専用のシリアルキュー。PDFDocument を複数スレッドから同時に触らず、他のモジュールも待たせない
  private static let renderQueue = DispatchQueue(label: "app.pdf-pages", qos: .userInitiated)

  public func definition() -> ModuleDefinition {
    Name("PdfPages")

    // ファイルを開くのは1回だけ
    AsyncFunction("openAsync") { (url: URL) throws -> PdfDocumentObject in
      guard url.isFileURL, let document = PDFDocument(url: url) else { throw PdfUnreadableException() }
      return PdfDocumentObject(document: document)
    }
    .runOnQueue(Self.renderQueue)

    Class("PdfDocument", PdfDocumentObject.self) {
      Property("pageCount") { (pdf: PdfDocumentObject) -> Int in pdf.pageCount }

      AsyncFunction("renderPageAsync") { (pdf: PdfDocumentObject, pageIndex: Int, maxWidth: Double) throws -> String in
        // 描画処理(PdfPageRenderer)は、元の実装を関数に切り出したもの。中身は本文の下で説明する
        try PdfPageRenderer.render(pdf.document, pageIndex: pageIndex, maxWidth: maxWidth)
      }
      .runOnQueue(Self.renderQueue)
    }
  }
}

JS 側で受け取ったインスタンスは、使い終わったら release() で解放します。同期的に作れるインスタンスなら、expo-modules-core の useReleasingSharedObject がアンマウント時に解放してくれます。openAsync のように非同期に開く場合は、「開いている途中で画面を離れた」ケースまで含めて、自分で解放を書きます。

// modules/pdf-pages/index.ts
import { NativeModule, requireOptionalNativeModule, SharedObject } from 'expo';
import { useEffect, useState } from 'react';

declare class PdfDocument extends SharedObject {
  readonly pageCount: number;
  renderPageAsync(pageIndex: number, maxWidth: number): Promise<string>;
}

declare class PdfPagesModule extends NativeModule {
  openAsync(uri: string): Promise<PdfDocument>;
}

const PdfPages = requireOptionalNativeModule<PdfPagesModule>('PdfPages');

/** uri が変わるか、画面を離れたら解放する。開き終わる前に離れた場合も、ネイティブの PDF を残さない */
export function usePdfDocument(uri: string | null): PdfDocument | null {
  const [document, setDocument] = useState<PdfDocument | null>(null);

  useEffect(() => {
    if (uri === null || PdfPages === null) return;
    let disposed = false;
    let opened: PdfDocument | null = null;

    PdfPages.openAsync(uri).then(
      (doc) => {
        if (disposed) {
          doc.release(); // 間に合わなかった分は、受け取った直後に解放する
          return;
        }
        opened = doc;
        setDocument(doc);
      },
      () => setDocument(null),
    );

    return () => {
      disposed = true;
      opened?.release();
      setDocument(null);
    };
  }, [uri]);

  return document;
}

ネイティブ側で後片付けが必要なら、sharedObjectDidRelease() を override して書きます。PdfPageRenderer.render の中身は、自社アプリの実装をそのまま使えます。具体的には、拡大はしない。JPEG には透明が無いので、白で塗ってから描く。ファイル名に幅を入れて、同じ入力なら同じファイルになる(冪等)ようにする。atomic で書き込む。OS が消しても作り直せるので、Caches 以下に置く。


7. React Native 公式の方法:Turbo Native Modules を Swift で書く

expo に依存しない汎用ライブラリを作る場合や、Expo を使っていないアプリでは、React Native 本体の Turbo Native Modules を使います。手順は公式どおり4段階です。型付きの仕様(spec)を書く → Codegen を設定する → アプリのコードを書く → 生成されたインターフェースに沿ってネイティブ側を実装する。

// specs/NativeLocalStorage.ts — ファイル名とモジュール名は "Native" で始める(公式ルール)
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  setItem(value: string, key: string): void;
  getItem(key: string): string | null;
  removeItem(key: string): void;
  clear(): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeLocalStorage');
{
  "codegenConfig": {
    "name": "NativeLocalStorageSpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": { "javaPackageName": "com.nativelocalstorage" },
    "ios": { "modulesProvider": { "NativeLocalStorage": "RCTNativeLocalStorage" } }
  }
}

Swift で書く場合、公式はアダプタパターンを採ります。React Native の中核は C++ で書かれていて、Swift と C++ の相互運用が十分ではないため、Objective-C++ の薄い層が Swift の実装に処理を転送する形になります。

// NativeLocalStorage.swift — 実際の処理はすべて Swift に置く
@objcMembers public class NativeLocalStorage: NSObject {
  let userDefaults = UserDefaults(suiteName: "local-storage")

  public func getItem(for key: String) -> String? { userDefaults?.string(forKey: key) }
  public func setItem(for key: String, value: String) { userDefaults?.set(value, forKey: key) }
  public func removeItem(for key: String) { userDefaults?.removeObject(forKey: key) }
  public func clear() { userDefaults?.dictionaryRepresentation().keys.forEach { removeItem(for: $0) } }
}
// RCTNativeLocalStorage.mm — Codegen が生成したプロトコルを満たし、Swift に転送するだけ
#import "RCTNativeLocalStorage.h"
#import "SampleApp-Swift.h" // Xcode が生成する Swift の公開ヘッダー("SampleApp" の部分はアプリ名)

@implementation RCTNativeLocalStorage {
  NativeLocalStorage *storage;
}

- (id)init {
  if (self = [super init]) { storage = [NativeLocalStorage new]; }
  return self;
}

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

- (NSString *_Nullable)getItem:(NSString *)key { return [storage getItemFor:key]; }
- (void)setItem:(NSString *)value key:(NSString *)key { [storage setItemFor:key value:value]; }
- (void)removeItem:(NSString *)key { [storage removeItemFor:key]; }
- (void)clear { [storage clear]; }

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

比べると、違いがはっきりします。

観点Expo Modules APITurbo Native Modules(Swift)
書く言語Swift(DSL)のみTS の spec + Objective-C++ のアダプタ + Swift
型の源泉Swift 側の Record / Enumerable(TS の型は手書き。SDK 56 以降は expo-type-information で Swift から生成もできる。macOS のみ)TS の spec から Codegen がネイティブのインターフェースを生成
エラー・イベントException の code、Events / sendEventPromise の reject、spec 内の CodegenTypes.EventEmitter と emitOnXxx
CNG(prebuild --clean)との相性良い(modules/ 以下は生成物ではない)公式手順はアプリの Xcode プロジェクトを直接編集する(ブリッジングヘッダーなど)。CNG と両立させるには、ライブラリに切り出すか config plugin が必要
expo への依存必要不要
向いている場面Expo アプリ専用の機能汎用ライブラリ、C++ を使う、Expo を使わないアプリ

Expo のアプリで、アプリ専用の機能を書くなら、Turbo Native Modules を選ぶ理由はほとんどありません。CNG の利点(ios/ を生成物として毎回作り直せる)を手放すことになるからです。


8. iOS の設定とライフサイクル:エンタイトルメント・AppDelegate・プライバシーマニフェスト

ネイティブモジュールは、Swift のコードだけでは完結しないことが多いです。エンタイトルメント(Capability)、Info.plist、AppDelegate、プライバシーマニフェストも、コードと同じく設定ファイルとして宣言します。ios/ を手で編集しても、次の prebuild --clean で消えてしまいます。

8-1. App Group とプライバシーマニフェスト(見落としやすい理由コード)

ホーム画面のウィジェットとデータを共有するには、App Group と UserDefaults(suiteName:) を使います。ここで見落としやすいのが、UserDefaults は Apple の「Required reason API」に含まれるという点です。App Store Connect は、2024年5月1日以降、理由が宣言されていないアプリを受け付けません。

さらに、どの理由コードを宣言するかが重要です。Apple のドキュメントでは、次のように定義されています。

理由コードApple の定義(要約)
CA92.1アプリ自身だけが読み書きする情報へのアクセス。他のアプリが書いた情報を読むことや、他のアプリが読める情報を書くことは含まない
1C8F.1同じ App Group に属するアプリ・アプリ拡張・App Clip だけが読み書きする情報へのアクセス

ウィジェットと共有するなら 1C8F.1 が必要です。CA92.1 は「他のアプリが読める情報を書く」ことを明示的に除外しているので、CA92.1 だけでは App Group での共有をカバーできません。ネイティブプロジェクトを生成したときに CA92.1 だけが入っているケースは珍しくないので、生成された PrivacyInfo.xcprivacy を一度開いて確認してください。

// app.config.ts(抜粋)
const APP_GROUP = 'group.com.example.app';

export default (): ExpoConfig => ({
  // ...
  ios: {
    bundleIdentifier: 'com.example.app',
    entitlements: { 'com.apple.security.application-groups': [APP_GROUP] },
    privacyManifests: {
      NSPrivacyAccessedAPITypes: [
        {
          NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults',
          // CA92.1: アプリ自身の設定 / 1C8F.1: ウィジェットと共有する App Group
          NSPrivacyAccessedAPITypeReasons: ['CA92.1', '1C8F.1'],
        },
      ],
    },
  },
});

Expo 公式によれば、宣言が足りないビルドを提出すると、数分以内に Apple からメールが届きます。TestFlight の外部テスト用に早めに提出して確認するのが確実です。

8-2. AppDelegate のイベントを受け取る

URL での起動やリモート通知など、AppDelegate に届くイベントを受け取りたい場合も、AppDelegate.swift を手で書き換える必要はありません。ExpoAppDelegateSubscriber を継承したクラスを作り、expo-module.config.json に登録します。

// modules/app-lifecycle/ios/AppLifecycleDelegate.swift
import ExpoModulesCore

public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
  public func applicationDidEnterBackground(_ application: UIApplication) {
    // 例:送信待ちのキューを永続化する
  }
}
{ "apple": { "appDelegateSubscribers": ["AppLifecycleDelegate"] } }

戻り値を返すデリゲートメソッドを複数のサブスクライバーが実装した場合、ExpoAppDelegate が結果をまとめます。たとえば didFinishLaunchingWithOptions は、1つでも true を返せば true になります。didReceiveRemoteNotification の完了ハンドラーは、1つでも failed があれば failed、そうでなく newData が1つでもあれば newData、それ以外は noData です。サブスクライバーは Swift のクラスでなければならず、Objective-C のクラスは使えません。


9. テスト戦略:Swift は Jest で動かない前提で、3層に分ける

層何を守るか手段
純粋な Swift のロジック座標変換・丸め・入力の検証PageGeometry のようにブリッジから切り離し、XCTest / Swift Testing で検証する
JS のラッパー失敗の分類・既定値・プラットフォーム分岐ネイティブとの境界のファイルをモックして Jest で検証する
Swift と TS の約束エラーコードの綴りSwift のソースを読み、TS が使う文字列と突き合わせる契約テスト

ラッパーのテストは、§3-3 で分けた境界のファイルだけをモックします。

// modules/text-recognition/__tests__/readPage.test.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const mockReadPage = jest.fn();

jest.mock('../src/TextRecognitionModule', () => ({
  __esModule: true,
  default: { readPage: (...args: unknown[]) => mockReadPage(...args), supportedLanguages: jest.fn() },
}));

// jest.mock の後に import する
import { LANGUAGE_UNAVAILABLE, readPage } from '..';

beforeEach(() => mockReadPage.mockReset());

test('文字の箱からページの文字列を組み立てる', async () => {
  mockReadPage.mockResolvedValue([
    { characters: [{ text: '条', xmin: 0, ymin: 0, xmax: 10, ymax: 10 }] },
  ]);
  await expect(readPage('file:///page.jpg')).resolves.toMatchObject({ status: 'read', text: '条' });
});

test('言語が無いことと、読み取りの失敗を区別する', async () => {
  mockReadPage.mockRejectedValueOnce(Object.assign(new Error('x'), { code: LANGUAGE_UNAVAILABLE }));
  await expect(readPage('file:///page.jpg')).resolves.toEqual({
    status: 'unavailable',
    reason: 'language-missing',
  });

  mockReadPage.mockRejectedValueOnce(Object.assign(new Error('x'), { code: 'ERR_UNEXPECTED' }));
  await expect(readPage('file:///page.jpg')).resolves.toEqual({ status: 'failed', code: 'ERR_UNEXPECTED' });
});

test('Swift 側は TS と同じ綴りの code を投げる(契約テスト)', () => {
  const swift = readFileSync(join(__dirname, '..', 'ios', 'TextRecognitionExceptions.swift'), 'utf8');
  // 綴りがずれると「言語が無い」がすべて「失敗」に化け、しかも誰も気づかない
  expect(swift).toContain(`"${LANGUAGE_UNAVAILABLE}"`);
});

Expo 公式は、配布するモジュール向けに mocks/ ディレクトリの仕組みも用意しています。npx expo-modules-test-core generate-ts-mocks を使うと、Swift の実装からモックを自動生成できます(SourceKitten が必要)。アプリ内のローカルモジュールなら、上のように境界のファイルをモックするほうが、テストの意図がはっきりします。

9-1. 起動時の自己診断:§2-1 の事故を仕組みで防ぐ

最後の層は、本物の iOS ビルドで、モジュールがリンクされているかの確認です。ユニットテストでは、原理的にこれを検出できません。

// src/native-modules-health.ts
import { requireOptionalNativeModule } from 'expo';
import { Platform } from 'react-native';

/** iOS のビルドに必ず含まれているべきモジュール。追加したらここにも書く */
const REQUIRED_ON_IOS = ['TextRecognition', 'AppAttest', 'NetworkQuality', 'PdfPages'] as const;

export function findMissingNativeModules(): string[] {
  if (Platform.OS !== 'ios') return [];
  return REQUIRED_ON_IOS.filter((name) => requireOptionalNativeModule(name) === null);
}

開発ビルドでは、起動時に findMissingNativeModules() が空でなければ throw して、すぐに気づけるようにします。本番では、欠けているモジュールの名前を1回だけ Sentry などに送ります。E2E のスモークテストにも同じ関数を入れておけば、「podspec を置き忘れたビルド」がストアに出る前に止まります。


10. 本番運用チェックリスト

観点確認すること
性能重い処理を共有の既定キューに置いていないか(専用キューか async にする)。同期の Function で I/O をしていないか。バイナリを base64 ではなく Data ↔ Uint8Array で渡しているか。呼び出しを細かく分けすぎていないか
型安全[String: Any] ではなく Record / Enumerable を使っているか。引数が3つを超える関数は Record にまとめたか(引数の順番を取り違えにくくなる)
回復性未リンク・非対応・一時的な失敗・恒久的な失敗を、JS 側で区別しているか。どの失敗でもアプリ全体が落ちないか
冪等性再試行したときに、副作用(鍵の作り直し、ファイルの書き出し、サーバーでの付け替え)が重複しないか
可観測性Exception の code を固定し、ログやエラー監視のタグに使っているか。起動時の自己診断で未リンクを検出しているか
セキュリティ受け付ける入力を絞っているか(file URL のみ、など)。何でも実行できる汎用関数を公開していないか。秘密鍵やトークンを JS 側に返していないか(関連:モバイルアプリのセキュリティ実装ガイド)
プライバシーRequired reason API(UserDefaults、ファイルのタイムスタンプ、起動からの経過時間、ディスク容量など)の理由コードを、用途に合わせて宣言しているか
配布ネイティブを変えたら新しいバイナリを出す。OTA は JS とアセットだけ。runtimeVersion のポリシーを fingerprint にして、互換性の無い OTA が誤配信されるのを防ぐ

11. よくあるエラーと対処

症状原因対処
Cannot find native module 'X'prebuild・pod install をしていない/podspec が無い/Expo Go で実行しているnpx expo prebuild --clean → development build で起動する。ios/*.podspec があるか確認する
import Vision なのに Vision の型が見つからないpod の名前が Apple のフレームワーク名と同じpod の名前にアプリ固有の接頭辞を付ける
ArgumentCastException / EnumNoSuchValueExceptionJS から渡した値が Swift 側の型と合っていないTS の型を Swift の Record / Enumerable に合わせる(SDK 56 以降は型生成も検討する)
別モジュールの Promise がなかなか返ってこない重い処理が、全モジュールで共有の既定キューを占有している.runOnQueue(専用キュー) にするか、async クロージャで書く
UI の更新で紫色の警告が出る、またはクラッシュするメインスレッド以外から UIKit に触っている.runOnQueue(.main)、または await MainActor.run { … }
OCR の座標が90度ずれる、上下が反転する向きを rawValue で変換している/Y軸の原点の違い名前どうしで対応させる。Y軸の反転は1か所にまとめる
JS 側のエラーコードがすべて ERR_UNEXPECTEDException ではない Swift のエラーを、そのまま投げている見分けたいエラーを Exception のサブクラスに変換し、code を固定する

まとめ:ネイティブモジュールは「境界の設計」

Swift のコードを書くこと自体は、Expo Modules API のおかげで難しくなくなりました。本番で差が出るのは、JS とネイティブの境界の設計です。

  1. 方式:Expo アプリ専用なら Expo Modules API。expo に依存しない汎用ライブラリや C++ なら Turbo Native Modules。
  2. スレッド:Function は JS スレッド、通常の AsyncFunction は共有のシリアルキュー、async は Swift Concurrency。重い処理は専用キューへ。
  3. 型:Record / Enumerable で、検証を呼び出しの手前に置く。
  4. 失敗:Exception の code を契約にし、JS 側で「未リンク・非対応・一時的な失敗・恒久的な失敗」を区別する。
  5. 検証:純粋なロジックは XCTest、ラッパーは Jest、コードの綴りは契約テスト、リンク漏れは起動時の自己診断で確認する。

「ここだけはネイティブで書くしかない」という部分がある Expo / React Native アプリの開発や、既存アプリへの iOS ネイティブ機能(OCR、App Attest、ウィジェット、Live Activity、業務SDKの組み込み)の追加は、設計から実装・審査対応まで一緒に進められます。下のフォームからご相談ください。

よくある質問

Expo Modules API と Turbo Native Modules はどちらを選べばいいですか?
Expo のアプリで、そのアプリのためだけに書くなら Expo Modules API が第一候補です。Swift の DSL だけで書け、Objective-C++ のつなぎ(グルーコード)も Codegen の設定も要らず、CNG(prebuild)とも衝突しません。Expo 公式は React Native チームの推奨を『C++ を使うなら Turbo Modules、expo パッケージへの依存を許容して開発体験を取るなら Expo Modules API』と要約しています。expo に依存させたくない汎用ライブラリを公開する場合は、Turbo Native Modules を選びます。
Expo Go で自作のネイティブモジュールは動きますか?
動きません。Expo Go に入っているのは、アプリ本体に組み込み済みのネイティブコードだけです。自作の Swift コードを使うには、npx expo prebuild でネイティブプロジェクトを生成し、development build(expo-dev-client)か npx expo run:ios でアプリを作り直す必要があります。JS の変更は、その後も Metro からすぐに反映されます。
Swift の async/await はそのまま使えますか?
使えます。AsyncFunction に async throws のクロージャを渡すと、Swift Concurrency で実行され、JS 側には Promise として返ります。ただし .runOnQueue(.main) は通常のクロージャ用の API なので、async クロージャから UIKit に触るときは await MainActor.run { … } などでメインアクターへ明示的に移ります。
ネイティブ側のエラーを JS 側で種類ごとに見分けるには?
Exception を継承したクラスで throw します。code は既定でクラス名から作られます(AppAttestInvalidKeyException なら ERR_APP_ATTEST_INVALID_KEY)。code を override して固定すれば、クラス名を変えても契約は壊れません。Exception ではない Swift のエラー(DCError など)は、JS 側ではすべて ERR_UNEXPECTED になるため、見分けたいエラーは Swift 側で Exception に変換してから投げます。
ネイティブモジュールを追加したら OTA 更新(EAS Update)で配信できますか?
できません。OTA で配信できるのは JS とアセットだけです。Swift のコード、podspec、エンタイトルメント、Info.plist を変えたら、ストアに新しいバイナリを提出する必要があります。runtimeVersion のポリシーに fingerprint を使えば、ネイティブ層が変わったときに runtime が自動で切り替わり、互換性の無い端末へ OTA が誤配信されるのを防げます。
Swift のコードは Jest でテストできますか?
できません。Jest は Node で動くため、Swift は実行されません。テストは3層に分けます。①座標変換のような純粋な Swift のロジックは、ブリッジから切り離して XCTest / Swift Testing で検証する。②JS のラッパーは、ネイティブ呼び出しの部分をモックして Jest で検証する(Expo 公式にもモックの仕組みがあります)。③Swift が投げるエラーコードの綴りは、Swift のソースを読んで突き合わせる契約テストで固定する。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Expo / React Native アプリの iOS ネイティブ機能(Swift)を、設計から審査対応まで承ります

既存のライブラリでは届かない iOS の機能を、Swift のネイティブモジュールとして実装します。端末内OCR(Vision)、App Attest による端末の真正性証明、WidgetKit・Live Activity との連携、業務SDKの組み込みまで対応します。自社アプリで Expo のローカルモジュールを7本(Swift / Kotlin)書いてきた経験から、スレッドの設計、JS との型とエラーコードの契約、プライバシーマニフェストまで含めて、OTA では直せないネイティブ層を最初から壊れない形でつくります。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい