概要
ネイティブコードを React Native アプリに統合するには手間がかかります。Expo モジュールはその手助けをしますが、利用時に主に次の 2 点が障壁になっていました:
- パッケージのボイラープレート: モジュールを standalone なパッケージとして作成し、そのセットアップが完了してからネイティブコードを書き始める必要がある。
- 複数のインターフェース: Swift/Kotlin 側の定義と一致する TypeScript モジュールインターフェースを手動で維持する必要がある。
SDK 56 ではこれらの課題に取り組み、inline-modules と expo-type-information パッケージを導入しました。これにより新しいモジュールの作成がより簡単かつ高速になります。
インラインモジュールと型生成の利用
インラインモジュールの主な考え方はオーバーヘッドを最小化することです。Swift と Kotlin のモジュールをプロジェクト構成内(他のアプリファイルのそば)に直接書けるようになりました。カスタムのネイティブビューが必要な場合は、App.tsx の隣に NativeView.kt や NativeView.swift を作成してビューを実装できます。
インラインモジュールの設定
インラインモジュールの設定は非常に簡単です: アプリの設定ファイルで watchedDirectories を指定します — これはインラインモジュールを含むプロジェクト内のディレクトリのリストです。次に npx expo prebuild を実行してネイティブプロジェクトを同期すれば準備完了です。
{ "expo": { "experiments": { "inlineModules": { "watchedDirectories": ["app"] } } } }
watchedDirectories に "app" を追加すると、app ディレクトリもしくはそのサブディレクトリ(例: app/nested/ や app/nested/directory/)の任意の場所に Swift と Kotlin ファイルを作成できます。例えば app/nested/InlineModule.swift を開いてモジュールを記述できます。
internal import ExpoModulesCore
class InlineModule: Module {
public func definition() -> ModuleDefinition {
Constant("Hello") { return "Hello iOS inline modules!" }
}
}
作成後は JavaScript 側で requireNativeModule('InlineModule') でアクセスできます。モジュール内にビューがあれば requireNativeView('InlineModule') でインポートできます。
型生成の追加
インラインモジュールはモジュール作成の大部分のボイラープレートを取り除きますが、型生成を組み合わせることで利用インターフェースがさらに簡単になります。ネイティブモジュールを書いたら、TypeScript インターフェースがあると型チェックやオートコンプリートが使えて便利です。
この部分は expo-type-information パッケージが担当し、Swift モジュールを解析して対応する TypeScript 型を自動生成します。パッケージには強力な CLI ツールが付属しています。
CLI ツール
CLI にはインラインモジュール向けのコマンド inline-modules-interface が含まれています。このコマンドはプロジェクト内のすべての Swift インラインモジュールを検出し、各モジュールに対して 2 つの TypeScript ファイルを生成します。コマンド実行後、Swift ファイルの隣に一対のファイルが生成されます: InlineModule.generated.ts と InlineModule.tsx。
生成されるファイル
-
生成ファイル([ModuleName].generated.ts): モジュールに関するすべての型情報を含みます(関数、定数、クラス、ビューなどの DSL 宣言や、enum / record struct の変換可能な Swift 構造)。このファイルはコマンド実行時に上書きされます。
/Automatically generated by expo-type-information./
import { ViewProps } from 'react-native';
import { NativeModule } from 'expo';
export declare class InlineModuleNativeModuleType extends NativeModule {
readonly Hello: string;
}
-
安定ファイル([ModuleName].tsx): モジュールインターフェースを再エクスポートし、メインビューが存在する場合はそのデフォルトエクスポートを提供します。このファイルは編集可能で、一度変更すれば上書きされません。
// File hash: 8dfc86f5416afbe08cc1ee581c850fc9cec446479211d85501d9a5e2d24cc534
import { InlineModuleNativeModuleType } from './InlineModule.generated';
import { requireNativeModule, requireNativeView } from 'expo';
const InlineModule: InlineModuleNativeModuleType = requireNativeModule<InlineModuleNativeModuleType>('InlineModule');
export const Hello: string = InlineModule.Hello;
TypeScript モジュールインターフェースをこのように分割することで、自動生成された不完全な出力を手動で調整しつつ、ネイティブ側を更新した際にコアの宣言マッピングを再生成できるようになります。
制限事項
- ファイル名: インラインモジュールの名前は、定義されているファイル名と完全に一致していなければなりません。さらに、モジュール名はグローバルに一意である必要があります(名前はグローバルオブジェクトからモジュールを取得する識別子として使われるため)。そのため、同一プロジェクト内に app/InlineView.swift と src/InlineView.swift の両方を置くことはできません。
- 言語とプラットフォームのサポート: 型生成は現在 Swift モジュールかつ macOS 上でのみ動作します。
未解決の型
時としてネイティブモジュール宣言の型を解決できない場合があります。これは主に SourceKitten の制限と、提供されたファイルのみを解析しフルコンパイルプロセスを行わない設計によるものです。Swift 型が解決できない場合、TypeScript では unknown 型として生成されます。よくあるケース:
-
ネストした宣言(例: DSL 内の Class): SourceKitten は深くネストしたクロージャの解析に制限があり、Class 内の宣言の型を見つけにくくなります。そのためクラスメソッドの戻り型が正しく解決されないことがあります(引数は正しく解決される場合が多い)。例えば ExpoBlob モジュール:
import Foundation
import ExpoModulesCore
public class ExpoBlob: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlob")
Class(Blob.self) {
Constructor { (blobParts: [EitherOfThree<String, Blob, TypedArray>]?, options: BlobOptions?) in
let endings = options?.endings ?? .transparent
let blobPartsProcessed = processBlobParts(blobParts, endings: endings)
return Blob(blobParts: blobPartsProcessed, options: options ?? BlobOptions())
}
Property("size") { (blob: Blob) in blob.size }
Property("type") { (blob: Blob) in blob.type }
Function("slice") { (blob: Blob, start: Int?, end: Int?, contentType: String?) in
let blobSize = blob.size
let safeStart = start ?? 0
let safeEnd = end ?? blobSize
let relativeStart = safeStart < 0 ? max(blobSize + safeStart, 0) : min(safeStart, blobSize)
let relativeEnd = safeEnd < 0 ? max(blobSize + safeEnd, 0) : min(safeEnd, blobSize)
return blob.slice(start: relativeStart, end: relativeEnd, contentType: contentType ?? "")
}
AsyncFunction("text") { (blob: Blob) async -> String in await blob.text() }
AsyncFunction("bytes") { (blob: Blob) async -> Data in
let bytes = await blob.bytes()
return Data(bytes)
}
}
}
}
生成結果の一部例:
import { ViewProps } from 'react-native';
import { NativeModule } from 'expo';
export type Data = unknown;
export type TypedArray = unknown;
export type BlobOptions = { type: string; endings: EndingType; };
export enum EndingType { transparent = 'transparent', native = 'native' }
export enum BlobPart { string = 'string', blob = 'blob', data = 'data' }
export declare class Blob {
slice(
blob: Blob,
start: number | undefined,
end: number | undefined,
contentType: string | undefined
): unknown ;
text(blob: Blob): Promise<string>;
bytes(blob: Blob): Promise<Data>;
readonly size: unknown ;
readonly type: unknown ;
constructor(
blobParts: (string | Blob | TypedArray)[] | undefined,
options: BlobOptions | undefined
);
}
export declare class ExpoBlobNativeModuleType extends NativeModule { Blob: typeof Blob; }
この例では slice の戻り型や type / size が解決されていません。多くの場合は Swift 側でクロージャの戻り型を明示的に注釈することで修正できます。
- インポートされた宣言: 我々は提供されたファイルのみを解析し、import を無視するため、外部からの関数や型が戻り値に影響する場合に解決できないことがあります。
- 戻り値の型: return キーワードを省略し、クロージャへ明示的な注釈を付けていないと戻り型の推論が難しくなります。これを避けるには DSL 宣言に注釈を付けるか、明示的に return を入れてください。
型推論を改善するために、CLI の --type-inference PREPROCESS_AND_INFERENCE オプションを試すことができます。デフォルトでは無効ですが、稀に失敗するケースがあるためです。モジュールによっては有効化で改善することがあります。
Deep dive(仕組みの詳細)
以下ではインラインモジュールと expo-type-information が内部でどのように動作するかをもう少し詳しく説明します。
インラインモジュールの挙動
app/nested/InlineModule.swift にモジュールを作成したとします。まずは app 設定で watchedDirectories を設定します(ここでは app フォルダのみを例にします)。
{ "expo": { "experiments": { "inlineModules": { "watchedDirectories": ["app"] } } } }
Prebuild
アプリ設定更新後、ネイティブプロジェクトを更新するために npx expo prebuild を実行します。これにより主に 2 つの処理が行われます:
-
Xcode プロジェクトを更新して app フォルダを file system synchronized group にします。これによりフォルダ内(およびサブフォルダ内)のすべてのファイルが Xcode エディタに表示され、iOS ビルドに自動的に含まれるようになります。
- file system synchronized group
-
iOS と Android のプロジェクトプロパティを watchedDirectories で更新します。Android 側ではこれによりファイルが Android Studio に追加されます。これらのプロパティは後の autolinking ステップでも使用されます。
{ "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", "expo.inlineModules.watchedDirectories": "["app"]" }
...
expo.inlineModules.watchedDirectories=["app"]
Android プロジェクト
Android 側では Gradle の設定フェーズ中にプロジェクトが更新されます。これは Android Studio の "Sync Project with Gradle Files" ボタンを手動で押すか、npx expo run:android のようにビルド前に自動的に実行されます。このフェーズで watchedDirectories をミラーするフォルダ構造が作成され、watchedDirectories 内の Kotlin ファイルへのシンボリックリンクが作られます。
このミラーフォルダ構造を作ることで:
- ネイティブファイルがコンパイルされる: インラインモジュールは Android Studio から見えるようになり、Android ビルド時にコンパイルされる。
- 他のファイルは無視される: ミラー内には JavaScript / TypeScript ファイルは含まれないため、Android Studio からはインデックスされない。
Autolinking
すべての Expo モジュールはグローバルオブジェクト上に存在し、ネイティブモジュールプロバイダを通じて公開されます。iOS と Android のビルド時にモジュールプロバイダクラスが生成され、通常の Expo モジュールとインラインモジュールの参照を含みます。JavaScript で requireNativeModule('InlineModule') を呼ぶと、このグローバルオブジェクトへのアクセスをラップしているだけです。
型生成の仕組み
型生成の基本アイデアはシンプルです: ネイティブモジュールの宣言は構造化されているため、ネイティブコードから直接 TypeScript インターフェースを生成できます。expo-type-information は以下の 4 つの主要コンポーネントで構成されます:
- Swift パーサ
- モジュール型の抽象化層
- TypeScript コードジェネレータ
- CLI ツール
これらが連携してモジュールの TypeScript インターフェース作成を自動化します。
型システム
DSL でモジュールを定義するとき、関数、定数、クラス、ビューなどのネイティブインターフェースを提供します。これらは Swift の型システムで厳密に型付けされています。一方で TypeScript 側は JavaScript オブジェクトを扱う TypeScript の型システム上で動作し、Swift / Kotlin とは根本的に異なり、厳密な 1 対 1 の対応は存在しません。したがって変換(conversions)を考慮する必要があります。
(ここで説明は続きますが、上記がインラインモジュールと expo-type-information の主要なポイントとその内部的な流れになります。)