# 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 でのテスト、プライバシーマニフェストまでを、公式ドキュメントと実アプリのコードで解説します。

- 公開日: 2026-09-25
- 著者: 友田 陽大
- タグ: React Native, Expo, Swift, iOS, ネイティブモジュール, モバイルアプリ, TypeScript, アーキテクチャ設計, テスト
- URL: https://tomodahinata.com/blog/react-native-expo-swift-native-module-bridge-guide
- カテゴリ: モバイルアプリ開発（Expo / React Native）
- 総合ガイド: https://tomodahinata.com/blog/expo-production-guide-router-eas-cng-ota

## 要点

- 新しく書くなら Expo Modules API（Swift の DSL）が第一候補。C++ を使う場合や、ライブラリを `expo` に依存させたくない場合は Turbo Native Modules を選ぶ。React Native チームの推奨を Expo 公式が要約した基準です。
- `AsyncFunction` には2つの実行経路がある。通常のクロージャは、全モジュールが共有する1本のシリアルキューで動く（SDK 57 のソースで確認）。重い処理は専用キューへ移すか、`async` クロージャにする。`async` クロージャでは `.runOnQueue` は使えない。
- エラーは `Exception` のサブクラスで投げ、`code` を JS との契約にする。`Exception` 以外の Swift のエラーは、JS 側ではすべて `ERR_UNEXPECTED` にまとめられる。
- podspec が無いローカルモジュールは、自動リンクの対象から何のエラーも出ずに外れる（SDK 57 の autolinking ソースで確認）。JS 側の try/catch が握りつぶすと『非対応端末』と区別できず、自社アプリでは約7週間気づかなかった。
- App Group の UserDefaults をウィジェットと共有するなら、プライバシーマニフェストの理由コードは `1C8F.1`。`CA92.1` はアプリ自身だけが読み書きする場合の理由で、これだけでは足りない。

---

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

私自身、リリース準備中の自社アプリ [MemoryHack AI](/labs/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 modules** | SDK 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回あたりの処理の重さ**です。

---

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

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

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

| 定義 | JS から見た形 | ネイティブ側の実行場所 | 使いどころ |
| --- | --- | --- | --- |
| `Function` | 同期で戻り値を返す | **JS スレッド**。終わるまでスクリプトの実行を止める | 一瞬で終わる読み取り（能力の確認・定数の計算） |
| `AsyncFunction`（通常のクロージャ） | Promise | 既定では**全モジュールが共有する1本のシリアルキュー**（`expo.modules.AsyncFunctionQueue`、QoS は `userInitiated`）。`.runOnQueue(...)` で変更可能 | I/O・重い計算・メインスレッドが必要な処理 |
| `AsyncFunction`（`async` クロージャ） | Promise | **Swift Concurrency**（クロージャは `@Sendable`）。`.runOnQueue` は**無い** | OS の async API（App Attest など）を包むとき |
| `View` 内の `AsyncFunction` | ref のメソッド | 既定で 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` / `String` | `boolean` / `number` / `string` | 配列・辞書・Optional も可 |
| `Record` に準拠した struct | オブジェクト | **フィールドごとに型を持ち、既定値を置ける**。戻り値にも使える |
| `Enumerable` に準拠した enum | 文字列（または数値）のユニオン | 範囲外の値は `EnumNoSuchValueException` で**呼び出し前に**弾かれる |
| `Either<A, B>` など | どちらかの型 | 最大4種類まで |
| `URL` | `string` | **スキームが無い文字列は file URL として扱われる** |
| `Data` | `Uint8Array` | SDK 50 以降。バイナリを base64 にせず渡せる |
| `CGPoint` / `CGSize` / `CGRect` / `UIColor` | オブジェクト・配列・色文字列 | 組み込みの Convertible |
| `SharedObject` のサブクラス | JS のクラスのインスタンス | ネイティブの状態を JS から参照し続ける（§6） |

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

```text
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 には公開しません）。

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

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

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

```json
{
  "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 の書き方と組み合わさったときです。

```ts
// ❌ 未リンクと「非対応の端末」が同じ見え方になる
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` のように、**アプリ固有の接頭辞**を付けて回避しました。

```ruby
# 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 側

```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）。

```swift
// 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
    }
  }
}
```

```swift
// 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 で検証できます。

```ts
// 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');
```

```ts
// 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` のクロージャでそのまま包めます。

```swift
// 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種類に分けます。

```ts
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 の状態を読むときは、**メインアクターへ明示的に移ります**。

```swift
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行目）。

```swift
// 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` で購読します。

```ts
// 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 で書き直すと次のようになります。

```swift
// 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` のように**非同期に開く**場合は、「開いている途中で画面を離れた」ケースまで含めて、自分で解放を書きます。

```ts
// 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 を設定する** → **アプリのコードを書く** → **生成されたインターフェースに沿ってネイティブ側を実装する**。

```ts
// 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');
```

```json
{
  "codegenConfig": {
    "name": "NativeLocalStorageSpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": { "javaPackageName": "com.nativelocalstorage" },
    "ios": { "modulesProvider": { "NativeLocalStorage": "RCTNativeLocalStorage" } }
  }
}
```

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

```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) } }
}
```

```objc
// 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 API | Turbo 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` / `sendEvent` | Promise の 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` を一度開いて確認してください。

```ts
// 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` に登録します。

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

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

```json
{ "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 で分けた**境界のファイルだけ**をモックします。

```ts
// 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 ビルドで、モジュールがリンクされているか**の確認です。ユニットテストでは、原理的にこれを検出できません。

```ts
// 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 側に返していないか（関連：[モバイルアプリのセキュリティ実装ガイド](/blog/mobile-app-security-owasp-masvs-secure-storage-guide)） |
| **プライバシー** | 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` / `EnumNoSuchValueException` | JS から渡した値が 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_UNEXPECTED` | `Exception` ではない 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の組み込み）の追加は、設計から実装・審査対応まで一緒に進められます。下のフォームからご相談ください。
