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 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回あたりの処理の重さです。
この記事の実装を、案件として承ります
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 クロージャ) | 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 を使えば、型の検証と変換が呼び出しの手前で自動的に行われ、失敗時は原因付きの例外になります。公式チュートリアルには、次のエラーメッセージが実例として載っています。
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つのこと
- 向き(orientation)の変換は rawValue で行ってはいけない。
UIImage.OrientationとCGImagePropertyOrientationは同じ8方向を表しますが、番号の振り方が違います。縦持ちで撮った写真はUIImage.Orientation.right(rawValue 3)で、EXIF では 6 です。rawValue で変換すると、90度ずれた画像の上で座標を測ることになります。 - 幅も高さも 0 の箱が返ってくる。Vision は空白文字に対して、ページの左下の隅にある大きさ 0 の矩形を返すことがあります。自社の計測(50ページ、34,021文字)では、そのうち211文字がこれでした。大きさ 0 の箱をそのまま使うと、隠す範囲が「答え」から「ページの隅」まで伸びてしまいます。
- 言語補正を切るかどうかは用途で決まる。Apple のドキュメントには、
usesLanguageCorrectionを false にすると「性能は上がるが精度は下がる」とあります。一方で、原文と照合する用途では、補正が古い表記を「正しい」表記に書き換えてしまいます(「あつた」を「あった」に直すなど)。そうなると、書き換えられた引用を「原文どおり」と誤って判定します。自社の計測では、補正を切ってもページ全体の一致度は 0.991 以上(中央値 0.997)で、支払う代償はほとんどありませんでした。 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 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 を一度開いて確認してください。
// 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 / 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 とネイティブの境界の設計です。
- 方式:Expo アプリ専用なら Expo Modules API。
expoに依存しない汎用ライブラリや C++ なら Turbo Native Modules。 - スレッド:
Functionは JS スレッド、通常のAsyncFunctionは共有のシリアルキュー、asyncは Swift Concurrency。重い処理は専用キューへ。 - 型:
Record/Enumerableで、検証を呼び出しの手前に置く。 - 失敗:
Exceptionのcodeを契約にし、JS 側で「未リンク・非対応・一時的な失敗・恒久的な失敗」を区別する。 - 検証:純粋なロジックは XCTest、ラッパーは Jest、コードの綴りは契約テスト、リンク漏れは起動時の自己診断で確認する。
「ここだけはネイティブで書くしかない」という部分がある Expo / React Native アプリの開発や、既存アプリへの iOS ネイティブ機能(OCR、App Attest、ウィジェット、Live Activity、業務SDKの組み込み)の追加は、設計から実装・審査対応まで一緒に進められます。下のフォームからご相談ください。