Expo Modulesでアプリにネイティブコードを追加する方法
このチュートリアルでは、Expo Modulesを使ってReact Nativeアプリにネイティブ機能を追加する方法を説明します。例として「オーディオルート検出モジュール(どこに音声出力が送られているかを判定するモジュール)」を作成し、イベントリスナーの扱い、テスト、配布までの流れをカバーします。
主なポイント:
- Expoのエコシステムは多くのライブラリを提供しますが、既存のものが要件に合わない場合はカスタムのネイティブ実装が必要になります。
- Expo Modulesを使えば、Expoの利点を維持しつつアプリ固有のネイティブ機能を追加できます。
- シミュレータ/エミュレータではオーディオルートの変化を試せる範囲が限定的なので、実機での検証が推奨されます。
事前準備: 新規Expoプロジェクト
新しいExpoアプリを作成します。既存プロジェクトがあればそれを使って構いませんが、このチュートリアルでは新規プロジェクトを作ります。例ではnpmを使用しますが、任意のパッケージマネージャで進めてください。
npx create-expo-app@latest expo-custom-local-module-example
Expo Dev Client
カスタムネイティブコードを含むため、このプロジェクトはExpo Goでは動作しません。開発用ビルドを作成するためにexpo-dev-clientをインストールします。expo-dev-clientはデバッグツールを含むExpo Goに似たアプリですが、自分のネイティブモジュールが組み込まれます。
npx expo install expo-dev-client
カスタムモジュールのセットアップ
Expo Modules APIはJSI上の抽象レイヤーで、可能な限りプラットフォーム間で一貫したAPIを公開します。ローカルなExpo Moduleを作成していきます。
npx create-expo-module@latest
--localフラグは、そのモジュールをプロジェクト内に格納し、npmに公開する独立パッケージとしてではなくアプリ固有のモジュールとして作成します。プロジェクトルートにmodulesフォルダが作成されます。
想定されるフォルダ構成:
.
rest of your project root
modules/
└── expo-audio-route/
├── android/
├── ios/
├── src/
├── expo-module.config.js
└── index.ts
ネイティブの実装はios/とandroid/に入ります。これらはアプリのネイティブフォルダとは異なり、バージョン管理に含める必要があります(詳細は後述)。
生成ファイルのクリーンアップ
今回のモジュールは「ビュー」を公開する必要がないため、生成されたビュー関連ファイルを削除しておきます:
rm modules/expo-audio-route/src/ExpoAudioRouteView.tsx
rm modules/expo-audio-route/src/ExpoAudioRouteView.web.tsx
rm modules/expo-audio-route/src/ExpoAudioRouteModule.web.ts
rm modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteView.kt
rm modules/expo-audio-route/ios/ExpoAudioRouteView.swift
API設計(JS側の契約)
ネイティブコードに入る前に、JavaScript側でどのAPIを公開するか決めます。ネイティブ側はこの契約を満たすように実装します。
このAudio Routeモジュールで欲しいものは次の2点です。
-
現在のオーディオルートを取得する仕組み
- JS側から1つの関数を呼び出すだけで、現在の出力先を知れるようにする。
- 少なくとも次の値を扱う:
wiredHeadset
bluetooth
speaker
unknown(それ以外のフォールバック)
-
ルート変更を監視する仕組み
- ヘッドホンを抜いたりBluetooth機器を接続したときに、ネイティブからJSへ通知できるようにする。
TypeScript型の定義
カスタムモジュール内のsrcディレクトリにあるExpoAudioRoute.types.tsとExpoAudioRouteModule.tsを編集します。これらはネイティブとJSの間の契約(イベント名とペイロード、戻り値の型)を定義します。
パス:
modules/
└── expo-audio-route/
├── ios/
├── src/
│ ├── ExpoAudioRoute.types.ts
│ ├── ExpoAudioRouteModule.ts
│ └── .. other files
├── expo-module.config.js
└── index.ts
ExpoAudioRoute.types.tsの内容例:
export type ExpoAudioRouteModuleEvents = {
onAudioRouteChange: (params: RouteChangeEvent) => void;
};
export type RouteChangeEvent = {
route: AudioRoute;
};
export type AudioRoute =
| "speaker"
| "wiredHeadset"
| "bluetooth"
| "unknown";
続いて、ネイティブモジュールを型として公開するExpoAudioRouteModule.ts:
import { NativeModule, requireNativeModule } from "expo";
import { AudioRoute, ExpoAudioRouteModuleEvents } from "./ExpoAudioRoute.types";
declare class ExpoAudioRouteModule extends NativeModule<ExpoAudioRouteModuleEvents> {
getCurrentRouteAsync(): Promise<AudioRoute>;
}
export default requireNativeModule<ExpoAudioRouteModule>("ExpoAudioRoute");
この定義により、JS側からはgetCurrentRouteAsync()を呼べ、イベント名とペイロードの型情報も得られます。
イベントの扱いについて
addListenerやremoveAllListenersをTypeScript内で明示的に定義していなくても、NativeModule<TEvents>を継承することでExpoが自動的に以下のメソッドを提供します:
addListener(eventName, callback) - ネイティブ側のOnStartObservingブロックを最初のリスナー追加時に実行するトリガーになります。
removeAllListeners(eventName) または subscription.remove() - 最後のリスナーが削除されたときにネイティブ側のOnStopObservingを呼びます。
これにより、TypeScriptの型宣言(NativeModule<ExpoAudioRouteModuleEvents>)だけでイベント名とペイロード形状を型安全に扱えます。
index.tsの更新
ビューをエクスポートしていた箇所を削除し、モジュールだけをエクスポートするようにします:
export { default } from "./src/ExpoAudioRouteModule";
export * from "./src/ExpoAudioRoute.types";
ネイティブコードの実装(概要)
ios/とandroid/のExpoAudioRouteModuleファイルを開き実装していきます。生成テンプレートにはビュー用のプレースホルダが入っていますが今回は不要です。
iOS用のテンプレート例(生成直後):
import ExpoModulesCore
public class ExpoAudioRouteModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
}
}
Android用のテンプレート例(生成直後):
package expo.modules.audioroute
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoAudioRouteModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
}
}
Expo Modulesでよく使うビルディングブロック
Events("onAudioRouteChange") - JSへ送るイベント名を宣言
AsyncFunction("getCurrentRouteAsync") - JSから呼べる非同期関数を定義
OnStartObserving("onAudioRouteChange") - 最初のリスナーが追加されたときに実行されるブロック
OnStopObserving("onAudioRouteChange") - 最後のリスナーが削除されたときに実行されるブロック
イベント名をパラメータとして渡すことで、複数イベントを持つモジュールでも各ブロックの意図が明確になります。
ネイティブ実装(パート1): 現在のオーディオルートを取得
まずは現在のルートを単純に問い合わせる関数から実装します。これはルート変更の監視を行わず、呼ばれた時に値を返すだけです。
インポート
iOSではAVFoundationのAVAudioSessionを使って現在のオーディオルートにアクセスします。AndroidではAudioManagerで接続デバイスや状態を照会します。
iOS側のインポート例:
import ExpoModulesCore
import AVFoundation
Android側のインポート例:
package expo.modules.audioroute
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
Android: AudioManagerの初期化
AndroidではAudioManagerの参照が必要です。モジュール内にprivateプロパティを追加し、OnCreateでappContextから初期化します。
Android側の例(モジュール定義の中に追加):
class ExpoAudioRouteModule : Module() {
private var audioManager: AudioManager? = null
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
OnCreate {
audioManager = appContext.reactContext?.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
}
}
現在のルートを判定するヘルパー
両プラットフォームで、ネイティブの情報を正規化して次のいずれかの文字列を返すcurrentRoute()のようなヘルパーを実装します:
"wiredHeadset"
"bluetooth"
"speaker"
"unknown"
このヘルパーをAsyncFunction("getCurrentRouteAsync")でラップして、JSからExpoAudioRoute.getCurrentRouteAsync()として呼べるようにします。
ネイティブ実装(パート2): ルート変更を監視してイベントを送る
次に、ルート変更を監視してJSへイベントを送る実装を行います。Expo ModulesのEvents、OnStartObserving、OnStopObservingを使います。
- iOS:
AVAudioSession.routeChangeNotificationを監視し、変更時にonAudioRouteChangeイベントを送る。
- Android:
AudioDeviceCallbackを実装してシステムに登録し、変更時にonAudioRouteChangeイベントを送る。
OnStartObserving("onAudioRouteChange")でネイティブの監視を開始し、OnStopObserving("onAudioRouteChange")で監視を解除します。これにより、JS側のリスナー数に応じてネイティブ側の監視を効率的に管理できます。
テストと配布
- オーディオルートのテストは実機で行ってください。シミュレータ/エミュレータではテストが難しい場合が多いです。
- 開発中は
expo-dev-clientを使った開発ビルドでネイティブモジュールを組み込んだアプリを動かします。
ios/とandroid/内のネイティブコードはプロジェクト固有の実装であるため、バージョン管理に含めることを推奨します。
- モジュールをアプリ専用に保つ場合は
--localで作成したままにし、他アプリでも使いたければパッケージとして切り出してnpmに公開できます。
参考
- 深掘りしたい場合はExpo Modules APIのドキュメントを参照してください(公式ドキュメントを参照)。
このチュートリアルの要点は、JS側で明確な契約(関数とイベント)を設計し、それを満たす形でiOS/AndroidのネイティブAPIをラップすることです。Expo Modulesはそのためのビルディングブロックを提供しており、開発者は同じ設計方針で両プラットフォームに対応できます。