第19章 Expo Modulesでネイティブ機能を自作する(発展)
標準モジュールで足りないときに、SwiftとKotlinを少しだけ書いて、JSから呼べる機能を自分で足します。
第11章で「標準モジュールにない機能を使いたい時」の話に少し触れました。本章ではその続きとして、自分でネイティブ機能を書いてJSから呼び出すための仕組み、Expo Modulesを扱います。
Web開発者の感覚で言えば、「ブラウザに無いAPIを、自分でPolyfillならぬネイティブ実装で足す」という体験です。SwiftやKotlinを少しだけ書く必要が出てきますが、最近のExpoは「最小限のネイティブコード+TypeScriptの型」で済むようになっており、敷居がかなり下がっています。

Expo Modulesは実行時のAPI呼び出しをTypeScriptとネイティブの間で橋渡しします。Config Pluginは別の経路で、prebuild時にネイティブ設定を書き換えます。
いつExpo Modulesを書くのか
順番をもう一度整理しておきます。やりたいことが出てきたら、次の優先度で検討します。
- Expo標準モジュールがあるかを確認(
expo-camera、expo-locationなど) - サードパーティライブラリがあるかをReact Native Directoryで検索
- それでも無ければExpo Modulesで自作
世の中の多くのケースは2までで解決します。3が必要になるのは、独自SDKの組み込み(社内ライブラリ、特定のハードウェア連携など)、ベンダーが提供するiOS/AndroidのSDKをラップするケースが中心です。
「ちょっとした拡張」程度なら自作で十分価値があります。例として、特定の権限ステータスをアプリ内ロジックに合わせて返すヘルパーモジュールや、社内SDKをそのままJSから叩けるようにするラッパーなどです。
本章では学習目的で、expo-hapticsが実際にやっているような「iOS/AndroidのHaptic APIを直接叩いてJSから呼び出す」モジュールを自作してみます。実用上はexpo-hapticsをそのまま使えば十分なケースですが、ネイティブAPIをExpo Modules経由でJSに繋ぐ感覚を掴むのには手頃な題材です。
モジュールを生成する
Expoには専用のジェネレータがあります。ローカルで動くExpo Modulesを作るのが一番手軽です。
npx create-expo-module@latest --local my-haptics
--localを付けると、別パッケージとしてではなく、現在のExpoプロジェクトのmodules/my-haptics/配下にコードが生成されます。npmに公開する予定がなければこの形で十分です。
生成されるディレクトリは次のようになっています。
modules/my-haptics/
├── android/
│ └── src/main/java/expo/modules/myhaptics/
│ └── MyHapticsModule.kt
├── ios/
│ └── MyHapticsModule.swift
├── src/
│ ├── MyHapticsModule.ts # ネイティブ呼び出しの宣言
│ ├── MyHaptics.types.ts # 共有する型
│ └── index.ts
├── expo-module.config.json
└── package.json
iOS側がSwift、Android側がKotlinの単一ファイル、JS側はTypeScriptで型と呼び出しを書く構成です。Bridgeを意識する必要はありません(本書はNew Architecture前提)。
iOSの実装
iOSのモジュールはSwiftで書きます。Expo Modules APIにはDSL(ドメイン特化言語、特定領域に特化した小さな記法のこと)風の記法が用意されていて、関数や定数の宣言が宣言的に書けるのが特徴です。
// modules/my-haptics/ios/MyHapticsModule.swift
import ExpoModulesCore
import UIKit
public class MyHapticsModule: Module {
public func definition() -> ModuleDefinition {
Name("MyHaptics")
Function("impact") { (style: String) -> Void in
let mappedStyle: UIImpactFeedbackGenerator.FeedbackStyle =
switch style {
case "light": .light
case "medium": .medium
case "heavy": .heavy
default: .medium
}
let generator = UIImpactFeedbackGenerator(style: mappedStyle)
generator.prepare()
generator.impactOccurred()
}
AsyncFunction("notificationFeedback") { (type: String) in
let generator = UINotificationFeedbackGenerator()
let mappedType: UINotificationFeedbackGenerator.FeedbackType =
switch type {
case "success": .success
case "warning": .warning
case "error": .error
default: .success
}
generator.notificationOccurred(mappedType)
}
}
}
ポイントを整理します。
definition()の中身はDSLで、Name、Function、AsyncFunction、Property、Constantsなどの宣言を並べるFunctionは同期、AsyncFunctionはJS側にPromiseを返す- 引数と戻り値はExpo Modules APIが自動で変換する(
String、Int、Bool、[String: Any]など)
UIKitのUIImpactFeedbackGeneratorをそのまま呼んでいるだけで、特別なBridge登録もいりません。Swift/Objective-Cの世界でできることはだいたい同じ手数で繋げます。
Androidの実装
Android側はKotlinで書きます。iOSと同じくDSLが用意されていて、Function/AsyncFunctionが並びます。
// modules/my-haptics/android/src/main/java/expo/modules/myhaptics/MyHapticsModule.kt
package expo.modules.myhaptics
import android.os.Build
import android.view.HapticFeedbackConstants
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class MyHapticsModule : Module() {
override fun definition() = ModuleDefinition {
Name("MyHaptics")
Function("impact") { style: String ->
val activity = appContext.currentActivity ?: return@Function
val view = activity.window.decorView
val constant = when (style) {
"light" -> HapticFeedbackConstants.KEYBOARD_TAP
"heavy" -> if (Build.VERSION.SDK_INT >= 30)
HapticFeedbackConstants.CONFIRM
else HapticFeedbackConstants.LONG_PRESS
else -> HapticFeedbackConstants.VIRTUAL_KEY
}
view.performHapticFeedback(constant)
}
}
}
appContext.currentActivityで現在のActivityを参照するのが定番のパターンです。Activityがない状態(バックグラウンド処理中など)ではnullになるので、?: return@Functionで早期リターンしておきます。
iOSとAndroidで使えるAPIの粒度や名前が違うので、JS側の引数("light"、"medium"、"heavy")からそれぞれの最適なAPIにマッピングするのが、こうしたラッパーモジュールの実装の中心になります。
JavaScript側の橋渡し
ネイティブ側でName("MyHaptics")と宣言したモジュールは、TypeScript側からrequireNativeModuleで取り出せます。
// modules/my-haptics/src/MyHapticsModule.ts
import { requireNativeModule } from "expo-modules-core";
type MyHapticsModule = {
impact: (style: "light" | "medium" | "heavy") => void;
notificationFeedback: (
type: "success" | "warning" | "error",
) => Promise<void>;
};
export default requireNativeModule<MyHapticsModule>("MyHaptics");
利用側はただのモジュールimportです。
// 使う側
import MyHaptics from "@/modules/my-haptics";
function FeedbackButton() {
return (
<Pressable onPress={() => MyHaptics.impact("medium")}>
<Text>振動</Text>
</Pressable>
);
}
ここまで来ると、Webでnavigator.vibrate(50)を呼ぶのとほぼ同じ感覚で、iOS/AndroidのHapticsを叩けるようになります。
ビルドして反映する
ローカルExpo Modulesは、ネイティブコードを変更するたびにDevelopment Buildを焼き直す必要があります。expo prebuildしてネイティブプロジェクトに反映してから、EASまたはローカルでビルドします。
npx expo prebuild
eas build --profile development --platform ios
eas build --profile development --platform android
「JS側だけ変えた場合」はリロードで反映されますが、Swift/Kotlinを触ったらビルドが必要、という切り分けです。
開発中はnpx expo run:ios/npx expo run:androidを使えばローカルマシンでネイティブビルドが走り、シミュレータ/エミュレータで即試せます。Xcode/Android Studioのフルセットアップは要りますが、「変更→確認」のサイクルが速いので、ネイティブを書く期間はローカルビルドのほうが快適です。
Config Pluginsという選択肢
ネイティブのJVMやSwiftコードを書かず、Info.plistやAndroidManifest.xmlを書き換えるだけで済むケースもあります。たとえば、特定のSDKが要求する初期化コードをAppDelegateに足す、Androidのpermissionを動的に追加する、などです。
その用途にはConfig Pluginsを使います。app.jsonのplugins配列に書いた関数が、expo prebuild時にネイティブプロジェクトを書き換える仕組みです。
Info.plistやAndroidManifest.xmlの編集はwithInfoPlist/withAndroidManifestで扱えます。AppDelegate(iOSアプリの起動エントリ)を書き換えたい場合はwithAppDelegateまたはwithDangerousModを使い、ファイルを文字列として加工します。「prebuildで生成されるネイティブコードに直接手を入れる」ではなく、Config Pluginを介して書き換える形にしておくと、Continuous Native Generation(CNG)の流儀から外れずに済みます。
// plugins/with-custom-permission.js
const { withInfoPlist, withAndroidManifest } = require("expo/config-plugins");
module.exports = function withCustomPermission(config) {
config = withInfoPlist(config, (cfg) => {
cfg.modResults.NSMotionUsageDescription =
"歩数の計測のためにモーションデータを使用します";
return cfg;
});
config = withAndroidManifest(config, (cfg) => {
// 必要に応じて Manifest を編集
return cfg;
});
return config;
};
{
"expo": {
"plugins": ["./plugins/with-custom-permission.js"]
}
}
設定ファイルの編集だけで済むなら、Expo Modulesを書くより圧倒的に短く済みます。「Info.plistに1行足したいだけ」のようなケースは、まずConfig Pluginsで解決できないか考える価値があります。
Expo ModulesとConfig Pluginsを組み合わせて、実際にサードパーティSDKをアプリへ組み込んだ手順はExpo ModulesとConfig pluginsでサードパーティのSDKをReact Nativeのアプリに組み込むにまとめてあります。本章で扱った書き方が、現実の要件でどう噛み合うかの例として読めます。
エコシステムを活用する
自作する前に、世の中に同じようなものがないか調べる癖をつけましょう。コミュニティが書いたExpo Modules/Config Pluginsは、expo-XXXまたはreact-native-XXXの名前でGitHubに大量にあります。
頼れるリソースを2つ挙げておきます。
- React Native Directory(https://reactnative.directory/): フィルタが充実していて、New Architecture対応や利用率での絞り込みができる
- Expo公式ドキュメントの
Modules APIセクション: DSLの全API一覧、最新の書き方、よくあるパターンが揃う
最新仕様の確認には、第20章で触れたContext7のような公式ドキュメント参照ツールも有効です。「expo modules api viewmanager」のような検索でDSLの最新の正式名と例を引けます。
自作モジュールとAIエージェント
ここまで読んで「Swift/Kotlinちょっと書ける気がしない」と感じた人にこそ、Coding Agentの出番だと考えています。Expo Modulesは
- DSLが宣言的で、Agentが扱いやすい
- iOS/Androidの対応コードがほぼ並列に書ける
- TypeScriptの型を書くと、ネイティブ側のシグネチャもそれに合わせやすい
という性質があり、AIに「expo-haptics相当の薄いモジュールを作って」と依頼すると、Swift/Kotlin/TypeScriptの3点セットを一度に出せます。型と動作確認は人がやる前提ですが、最初の構造を組む時間が大幅に短縮されます。
expo/skillsにはモジュール開発のベストプラクティスをまとめたスキルがあるので、それを読み込ませた上で依頼するとさらに精度が上がります。
第19章は以上です。ネイティブの世界に少し降りる体験ができたかと思います。次章では、本書を貫いてきたAIエージェント活用を、Skills・Rozenite・agent-deviceといったツール群の形で総ざらいします。基礎と実践を走り終えた今の頭で読むと、それぞれのツールが効く場面のイメージが具体的になっていると思います。