⚙️

Expo ModulesとConfig pluginsでサードパーティのSDKをReact Nativeのアプリに組み込む

2025/02/20に公開

こんにちは!テラーノベルでiOS/Android/Webとフロントエンド周りを担当している @kazutoyoです!

Expoでアプリケーションを開発している際、React NativeのサポートがないサードパーティのSDKを導入したいといった要件がたまにあると思います。

React Nativeでアプリケーションを開発する際、ネイティブSDKの導入が必要になることがあります。
そんなときは、Expo ModulesConfig pluginsを活用することで、CNG(Continuous Native Generation)の恩恵を受けながら、ネイティブコードを直接編集せずにSDKを導入できます。

CNGとは?

Continuous Native Generation (CNG)は、設定値からios/android/ ディレクトリなど、ネイティブのプロジェクトファイルを生成する仕組みです。

これにより、開発者はネイティブ層を意識せずに設定を行い、プロジェクトをクリーンに保つことができます。

より詳細な説明はitomeさんの記事やIchikiさんのスライドがとても参考になります。
https://zenn.dev/woodstock_tech/articles/293a5c1d062ec6
https://speakerdeck.com/ichiki1023/du-expo-nocai-yong-woduan-nian-sitakedo-zai-du-expo-nodao-ru-wojian-tao-siteiruhua

Expo ModulesとConfig pluginsの役割

簡単にまとめると、次のような役割となっています。

  • Expo Modules: React Nativeからネイティブ機能を呼び出すためのモジュール
  • Config plugins: Expo Modulesに対し、Prebuild時にネイティブプロジェクトへの変更を行うプラグイン

今回のケースのようなサードパーティのSDKを導入する際、APIキーなどの情報をネイティブ機能の呼び出し時に必要になると思います。

Config Pluginsを利用することで、 Info.plistAndroidManifest.xml に追加し、Expo Modulesから参照できるようになります。

Expo ModulesとConfig pluginsを使ってみよう!

では実際に既存のExpoアプリケーションに、SugoiSDK という架空のSDKを導入する例を通じて、Expo ModulesとConfig Pluginsの使い方を解説します。

Expo Modulesのプロジェクトの作成

今回は sugoi-sdk-wrapper という名前でExpo Modulesを作成します。
また、今回は既存のアプリケーションに追加する想定のため、 --local を指定しています。

npx create-expo-module sugoi-sdk-wrapper --local

各ステップの入力を行った後、 modules/ 配下にExpo Modulesのプロジェクトが作成されました。

今回はNativeViewを利用しない、Webを対応しないため、 SugoiSdkWrapperView.tsx / SugoiSdkWrapperView.web.tsx / SugoiSdkWrapperModule.web.ts の3ファイルを削除し、それらを参照している index.ts ファイルも修正しましょう。

また、 expo-module.config.jsonplatforms から "web" の指定も削除します。

{
  "platforms": [
    "apple",
    "android"
  ],
  "apple": {
    "modules": [
      "SugoiSdkWrapperModule"
    ]
  },
  "android": {
    "modules": [
      "jp.kazutoyo.sugoisdkwrapper.SugoiSdkWrapperModule"
    ]
  }
}

SDKのセットアップ実装

iOSではAppDelegatedidFinishLaunchingWithOptions、AndroidではApplicationonCreateでSDKのセットアップを行います

それぞれネイティブの実装を追加してみましょう。

iOS

まず最初に .podspec ファイルにSDKの依存設定を追加しましょう。

+  s.dependency 'SugoiSDK'

また、Viewの実装は行わないため SugoiSdkWrapperView.swift は削除します。

そして、 SugoiSdkWrapperAppDelegate.swift というAppDelegateをハンドリングするファイルを追加し、次のように実装します。

import ExpoModulesCore
import SugoiSDK

public class SugoiSdkWrapperAppDelegate: ExpoAppDelegateSubscriber {
  public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    // Info.plistからAPIキーを取得
    guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "SUGOI_SDK_API_KEY") as? String else {
      return false
    }

    // TODO: SDKのセットアップを行う
    print("SugoiSdkWrapperAppDelegate", apiKey)
    
    return true
  }
}

Android

Androidも同様に行っていきます。

SugoiSdkWrapperView.kt ファイルは削除し、build.gradleファイルに依存するSDKを追加します。

そして SugoiSdkWrapperApplicationLifecycleListener.ktSugoiSdkWrapperPackage.kt ファイルを追加し、それぞれ実装します。

SugoiSdkWrapperApplicationLifecycleListener.kt

package jp.kazutoyo.sugoisdkwrapper

import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import expo.modules.BuildConfig
import expo.modules.core.interfaces.ApplicationLifecycleListener
import jp.kazutoyo.sugoisdk

class SugoiSdkWrapperApplicationLifecycleListener: ApplicationLifecycleListener {
    companion object {
        const val TAG = "SugoiSdkWrapperModule"
        const val SUGOI_SDK_API_KEY = "SUGOI_SDK_API_KEY"
    }

    override fun onCreate(application: Application?) {
        super.onCreate(application)

        application?.let { app ->
            // API Keyを取得
            val appInfo = app.packageManager.getApplicationInfo(app.packageName, PackageManager.GET_META_DATA)
            val apiKey = appInfo.metaData.getString(SUGOI_SDK_API_KEY)
            // TODO: セットアップを行う
            Log.d(TAG, "SUGOI_SDK_API_KEY: $apiKey")
        }
    }
}

SugoiSdkWrapperPackage.kt

package jp.kazutoyo.sugoisdkwrapper

import android.content.Context
import expo.modules.core.interfaces.ApplicationLifecycleListener
import expo.modules.core.interfaces.Package

class SugoiSdkWrapperPackage: Package {
    override fun createApplicationLifecycleListeners(context: Context?): List<ApplicationLifecycleListener> {
        return listOf(
            SugoiSdkWrapperApplicationLifecycleListener()
        )
    }
}

このとき、Packageを実装しているファイル名のSuffixは必ず Package.kt する必要があります。
https://stackoverflow.com/a/79375118

Config Pluginsの実装

作成したExpo Modulesのディレクトリに、 plugin/ ディレクトリを作成します。
その配下に tsconfig.json を作成し、次のように記載します。

{
  "extends": "expo-module-scripts/tsconfig.plugin",
  "compilerOptions": {
    "outDir": "build",
    "rootDir": "src"
  },
  "include": ["./src"],
  "exclude": ["**/__mocks__/*", "**/__tests__/*"]
}

また、 plugin/src/index.ts ファイルを作成し、そちらにプラグインを実装していきます。

plugin/src/index.ts の実装

次のように withInfoPlistwithAndroidManifest modsを使い、ファイルに変更を加えます。

import {
  ConfigPlugin,
  AndroidConfig,
  withAndroidManifest,
  withInfoPlist,
} from "@expo/config-plugins";

type Config = {
  apiKey: string;
};

const withSugoiSdkWrapperConfig: ConfigPlugin<{
  ios: Config;
  android: Config;
}> = (config, { ios, android }) => {
  config = withInfoPlist(config, (config) => {
    // Info.plistにAPIキーを追加
    config.modResults["SUGOI_SDK_API_KEY"] = ios.apiKey;
    return config;
  });

  config = withAndroidManifest(config, (config) => {
    const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(
      config.modResults
    );
    // AndroidManifestにAPIキーを追加
    AndroidConfig.Manifest.addMetaDataItemToMainApplication(
      mainApplication,
      "SUGOI_SDK_API_KEY",
      android.apiKey
    );
    return config;
  });
  return config;
};

export default withSugoiSdkWrapperConfig;

pluginのビルド

ルートのpackage.jsonに次のようにビルドスクリプトを追加します。

+  "sugoi-sdk-wrapper:build:plugin": "cd modules/sugoi-sdk-wrapper && expo-module build plugin"

そしてこのスクリプトを実行することで、 modules/sugoi-sdk-wrapper/plugin/build/ にプラグインがビルドされました。

app.plugin.jsファイルの追加

最後に modules/sugoi-sdk-wrapper/app.plugin.js ファイルを追加します。
ファイルでは先程ビルドしたpluginをexportします。

module.exports = require('./plugin/build')

プラグインを利用する

アプリケーションでプラグインを利用し、API Keyを設定できるようにしていましょう。

app.json (app.config.(ts/js))の plugins で次のように定義しましょう。

    "plugins": [
      [
        "./modules/sugoi-sdk-wrapper/app.plugin.js",
        {
          "ios": {
            "apiKey": "sugoi-sdk-no-ios-api-key"
          },
          "android": {
            "apiKey": "sugoi-sdk-no-android-api-key"
          }
        }
      ]
    ],

そして expo prebuild を実行してみましょう。

無事、次のようにInfo.plistとAndroidManifest.xmlにAPIキーが設定されましたね! 🎉

Info.plist

AndroidManifest.xml

動作確認

プラグインで設定されたAPIKeyがExpo Modulesで読み込めるか確認のため、モジュールに getApiKey() といった関数を作成し、React Native側から呼び出して画面上に表示してみましょう。

モジュール側の実装

TSでの関数の定義とiOS/Android側の実装をしていきます。

SugoiSdkWrapperModule.ts

import { NativeModule, requireNativeModule } from 'expo';

declare class SugoiSdkWrapperModule extends NativeModule {
  getApiKey(): string | null;
}

// This call loads the native module object from the JSI.
export default requireNativeModule<SugoiSdkWrapperModule>('SugoiSdkWrapper');

SugoiSdkWrapperModule.swift (iOS)

import ExpoModulesCore

public class SugoiSdkWrapperModule: Module {
  // Each module class must implement the definition function. The definition consists of components
  // that describes the module's functionality and behavior.
  // See https://docs.expo.dev/modules/module-api for more details about available components.
  public func definition() -> ModuleDefinition {
    // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
    // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
    // The module will be accessible from `requireNativeModule('SugoiSdkWrapper')` in JavaScript.
    Name("SugoiSdkWrapper")

    // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
    Function("getApiKey") {
      return Bundle.main.object(forInfoDictionaryKey: "SUGOI_SDK_API_KEY") as? String
    }
  }
}

SugoiSdkWrapperModule.kt (Android)

package jp.kazutoyo.sugoisdkwrapper

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.content.pm.PackageManager

class SugoiSdkWrapperModule : Module() {
  // Each module class must implement the definition function. The definition consists of components
  // that describes the module's functionality and behavior.
  // See https://docs.expo.dev/modules/module-api for more details about available components.
  override fun definition() = ModuleDefinition {
    // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
    // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
    // The module will be accessible from `requireNativeModule('SugoiSdkWrapper')` in JavaScript.
    Name("SugoiSdkWrapper")

    Function("getApiKey") {
      val applicationInfo = appContext?.reactContext?.packageManager?.getApplicationInfo(appContext?.reactContext?.packageName.toString(), PackageManager.GET_META_DATA)

      return@Function applicationInfo?.metaData?.getString("SUGOI_SDK_API_KEY")
    }
  }
}

React Native側の実装

モジュールを利用し、 getAPIKey() メソッドを呼び出して値を画面上に表示します。

import { StyleSheet, View, Text } from 'react-native';
import SugoiSdkWrapper from '@/modules/sugoi-sdk-wrapper'

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>API Key: {SugoiSdkWrapper.getApiKey()}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 32,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 8,
  },
  text: {
    fontSize: 16,
    color: 'black',
  }
});

結果

iOS/Androidともに正しく値が取得できていますね! 🎉

iOS Android

まとめ

Expo ModulesとConfig Pluginsを使うことで、ネイティブプロジェクトへの変更を最小限に抑えつつ、サードパーティSDKを導入できます。

CNGの恩恵を最大限に活かし、より良いReact Native開発ライフを送りましょう!

テラーノベル テックブログ

Discussion