コンテンツにスキップ
Webフロントエンドエンジニアのための React Native 実践入門
Esc
navigateopen⌘Jpreview
このページの内容

第19章 Expo Modulesでネイティブ機能を自作する(発展)

標準モジュールで足りないときに、SwiftとKotlinを少しだけ書いて、JSから呼べる機能を自分で足します。

第11章で「標準モジュールにない機能を使いたい時」の話に少し触れました。本章ではその続きとして、自分でネイティブ機能を書いてJSから呼び出すための仕組み、Expo Modulesを扱います。

Web開発者の感覚で言えば、「ブラウザに無いAPIを、自分でPolyfillならぬネイティブ実装で足す」という体験です。SwiftやKotlinを少しだけ書く必要が出てきますが、最近のExpoは「最小限のネイティブコード+TypeScriptの型」で済むようになっており、敷居がかなり下がっています。

TypeScriptからExpo Modules APIを通ってSwiftとKotlinのネイティブAPIを呼ぶ構造

Expo Modulesは実行時のAPI呼び出しをTypeScriptとネイティブの間で橋渡しします。Config Pluginは別の経路で、prebuild時にネイティブ設定を書き換えます。

いつExpo Modulesを書くのか

順番をもう一度整理しておきます。やりたいことが出てきたら、次の優先度で検討します。

  1. Expo標準モジュールがあるかを確認(expo-cameraexpo-locationなど)
  2. サードパーティライブラリがあるかをReact Native Directoryで検索
  3. それでも無ければ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で、NameFunctionAsyncFunctionPropertyConstantsなどの宣言を並べる
  • Functionは同期、AsyncFunctionはJS側にPromiseを返す
  • 引数と戻り値はExpo Modules APIが自動で変換する(StringIntBool[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.plistAndroidManifest.xmlを書き換えるだけで済むケースもあります。たとえば、特定のSDKが要求する初期化コードをAppDelegateに足す、Androidのpermissionを動的に追加する、などです。

その用途にはConfig Pluginsを使います。app.jsonplugins配列に書いた関数が、expo prebuild時にネイティブプロジェクトを書き換える仕組みです。

Info.plistAndroidManifest.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といったツール群の形で総ざらいします。基礎と実践を走り終えた今の頭で読むと、それぞれのツールが効く場面のイメージが具体的になっていると思います。

最終更新 2026年7月24日