取り組みの背景 — なぜカスタムネイティブモジュールが必要なのか
Rork Max は AI 駆動のコード生成と Expo エコシステムにより、多くのアプリ開発ニーズをカバーしています。しかし、プロダクション品質のアプリを構築する段階になると、既存のライブラリだけでは対応しきれない場面が必ず訪れます。
たとえば、独自のハードウェアセンサーへのアクセス、特定の OS API との高速なブリッジ通信、あるいはサードパーティ SDK のラッパー実装など、ネイティブコードを直接書く必要があるケースは少なくありません。
Expo Modules API は、こうした課題に対するモダンな解決策です。従来の React Native Bridge と比較して、型安全性・パフォーマンス・開発体験のすべてにおいて大きく進化しています。ここで扱うのはExpo Modules API を使ったカスタムネイティブモジュールの設計から npm 配布までを、実践的なコードとともに解説します。
この記事の対象読者:
- Rork Max でアプリをリリース済み、またはリリース間近の開発者
- React Native の基本を理解しており、ネイティブ拡張に踏み込みたい方
- 自作モジュールを npm で公開して再利用性を高めたい方
Expo Modules API の設計思想とアーキテクチャ
従来の Bridge との違い
React Native の従来のブリッジ(Legacy Bridge)は、JavaScript とネイティブ間の通信を JSON シリアライゼーションで行っていました。この方式は柔軟ではあるものの、いくつかの根本的な問題を抱えていました。
Legacy Bridge の課題:
- 非同期 JSON シリアライズによるオーバーヘッド
- 型安全性の欠如(ランタイムエラーが発生しやすい)
- iOS と Android で別々の実装パターンが必要
Expo Modules API の解決策:
- JSI(JavaScript Interface)ベースの同期通信で高速化
- Swift/Kotlin のネイティブ型がそのまま使える型安全設計
- 両プラットフォーム共通の宣言的 API
// Legacy Bridge(旧方式)— JSON を介した非同期通信
// NativeModules.MyModule.doSomething(callback)
// Expo Modules API(新方式)— JSI ベースの同期呼び出し
import { requireNativeModule } from 'expo-modules-core';
const MyModule = requireNativeModule('MyModule');
const result = MyModule.doSomething(); // 同期的に結果を取得
モジュールのライフサイクル
Expo Modules API で作成したモジュールは、以下のライフサイクルに従います。
- Registration(登録):
expo-module.config.json によるモジュール自動検出
- Definition(定義): Swift/Kotlin で
Module クラスを継承しモジュールを定義
- Linking(リンク): Expo の autolinking が自動的にネイティブプロジェクトに統合
- Runtime(実行): JSI を通じて JavaScript から直接呼び出し
プロジェクトのセットアップ
Expo Modules のスキャフォールディング
まず、カスタムモジュール用のプロジェクト構造を生成します。
# Expo Modules テンプレートから新規モジュールを作成
npx create-expo-module my-native-sensor
# 生成されるディレクトリ構成
# my-native-sensor/
# ├── src/ # TypeScript API
# │ ├── index.ts
# │ └── MyNativeSensor.types.ts
# ├── ios/ # Swift 実装
# │ └── MyNativeSensorModule.swift
# ├── android/ # Kotlin 実装
# │ └── MyNativeSensorModule.kt
# ├── expo-module.config.json # モジュール設定
# └── package.json
expo-module.config.json の設定
このファイルがモジュールのエントリポイントとなります。Expo の autolinking システムがこのファイルを検出し、ネイティブプロジェクトへの統合を自動化します。
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["MyNativeSensorModule"]
},
"android": {
"modules": ["expo.modules.mynativesensor.MyNativeSensorModule"]
}
}
Swift でのネイティブモジュール実装(iOS)
基本構造
Expo Modules API の Swift 実装は、宣言的な DSL(Domain Specific Language)を採用しています。関数、プロパティ、イベントを直感的に定義できます。
// ios/MyNativeSensorModule.swift
import ExpoModulesCore
import CoreMotion
public class MyNativeSensorModule: Module {
private let motionManager = CMMotionManager()
private var isTracking = false
// モジュール定義
public func definition() -> ModuleDefinition {
// モジュール名(JavaScript 側からこの名前で参照する)
Name("MyNativeSensor")
// 定数の公開
Constants([
"isAccelerometerAvailable": motionManager.isAccelerometerAvailable,
"isGyroAvailable": motionManager.isGyroAvailable
])
// イベント定義(JavaScript 側で addEventListener で購読)
Events("onSensorUpdate", "onError")
// 同期関数: センサーの現在値を即座に返す
Function("getCurrentReading") { () -> [String: Double] in
guard let data = self.motionManager.accelerometerData else {
throw SensorError.notAvailable
}
return [
"x": data.acceleration.x,
"y": data.acceleration.y,
"z": data.acceleration.z,
"timestamp": data.timestamp
]
}
// 非同期関数: センサー追跡を開始
AsyncFunction("startTracking") { (interval: Double, promise: Promise) in
guard self.motionManager.isAccelerometerAvailable else {
promise.reject(SensorError.notAvailable)
return
}
self.motionManager.accelerometerUpdateInterval = interval
self.motionManager.startAccelerometerUpdates(
to: .main
) { [weak self] data, error in
guard let self = self, let data = data else {
if let error = error {
self?.sendEvent("onError", [
"message": error.localizedDescription
])
}
return
}
self.sendEvent("onSensorUpdate", [
"x": data.acceleration.x,
"y": data.acceleration.y,
"z": data.acceleration.z,
"timestamp": data.timestamp
])
}
self.isTracking = true
promise.resolve(true)
}
// センサー追跡を停止
Function("stopTracking") { () -> Bool in
self.motionManager.stopAccelerometerUpdates()
self.isTracking = false
return true
}
// モジュール破棄時のクリーンアップ
OnDestroy {
if self.isTracking {
self.motionManager.stopAccelerometerUpdates()
}
}
}
}
// カスタムエラー型
enum SensorError: Error, CustomStringConvertible {
case notAvailable
var description: String {
switch self {
case .notAvailable:
return "Accelerometer is not available on this device"
}
}
}
View モジュールの実装
ネイティブ UI コンポーネントを JavaScript に公開する場合は、View 定義を使います。
// ios/MyNativeSensorView.swift
import ExpoModulesCore
import UIKit
class MyNativeSensorView: ExpoView {
private let gaugeView = UIView()
private let label = UILabel()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupUI()
}
private func setupUI() {
label.textAlignment = .center
label.font = .monospacedSystemFont(ofSize: 16, weight: .medium)
addSubview(gaugeView)
addSubview(label)
}
// Props として JavaScript から渡される値
var value: Double = 0 {
didSet {
label.text = String(format: "%.3f g", value)
updateGauge()
}
}
var color: UIColor = .systemBlue {
didSet { gaugeView.backgroundColor = color }
}
private func updateGauge() {
let normalized = min(abs(value) / 2.0, 1.0)
gaugeView.frame = CGRect(
x: 0, y: 0,
width: bounds.width * normalized,
height: bounds.height
)
}
}
Kotlin でのネイティブモジュール実装(Android)
基本構造
Android 側も同じ宣言的 API パターンで実装します。Swift 版とほぼ対称的なコード構造になるため、学習コストが低く保てます。
// android/src/main/java/expo/modules/mynativesensor/MyNativeSensorModule.kt
package expo.modules.mynativesensor
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
class MyNativeSensorModule : Module(), SensorEventListener {
private var sensorManager: SensorManager? = null
private var accelerometer: Sensor? = null
private var isTracking = false
override fun definition() = ModuleDefinition {
// モジュール名(iOS と同じ名前にする)
Name("MyNativeSensor")
// 定数
Constants(
"isAccelerometerAvailable" to (getAccelerometer() != null),
"isGyroAvailable" to (getGyroscope() != null)
)
// イベント
Events("onSensorUpdate", "onError")
// 同期関数
Function("getCurrentReading") {
// Android ではセンサーのリアルタイム値を
// 直接取得できないため、最後のキャッシュを返す
lastReading ?: mapOf(
"x" to 0.0,
"y" to 0.0,
"z" to 0.0,
"timestamp" to System.currentTimeMillis().toDouble()
)
}
// 非同期関数
AsyncFunction("startTracking") { interval: Double, promise: Promise ->
val manager = getSensorManager()
val sensor = getAccelerometer()
if (sensor == null) {
promise.reject(
"SENSOR_UNAVAILABLE",
"Accelerometer is not available",
null
)
return@AsyncFunction
}
val delayMicros = (interval * 1_000_000).toInt()
manager?.registerListener(
this@MyNativeSensorModule,
sensor,
delayMicros
)
isTracking = true
promise.resolve(true)
}
// 停止関数
Function("stopTracking") {
sensorManager?.unregisterListener(this@MyNativeSensorModule)
isTracking = false
true
}
// クリーンアップ
OnDestroy {
if (isTracking) {
sensorManager?.unregisterListener(
this@MyNativeSensorModule
)
}
}
}
// 最後のセンサー読み取り値をキャッシュ
private var lastReading: Map<String, Double>? = null
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
val reading = mapOf(
"x" to it.values[0].toDouble(),
"y" to it.values[1].toDouble(),
"z" to it.values[2].toDouble(),
"timestamp" to (it.timestamp / 1_000_000.0)
)
lastReading = reading
sendEvent("onSensorUpdate", reading)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
private fun getSensorManager(): SensorManager? {
if (sensorManager == null) {
sensorManager = appContext.reactContext
?.getSystemService(Context.SENSOR_SERVICE)
as? SensorManager
}
return sensorManager
}
private fun getAccelerometer(): Sensor? {
if (accelerometer == null) {
accelerometer = getSensorManager()
?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
}
return accelerometer
}
private fun getGyroscope(): Sensor? {
return getSensorManager()
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
}
}
TypeScript API レイヤーの設計
型定義
ネイティブモジュールの TypeScript 型を厳密に定義することで、JavaScript 側での型安全性を確保します。
// src/MyNativeSensor.types.ts
export interface SensorReading {
x: number;
y: number;
z: number;
timestamp: number;
}
export interface SensorError {
message: string;
}
export interface MyNativeSensorModule {
// 定数
isAccelerometerAvailable: boolean;
isGyroAvailable: boolean;
// 関数
getCurrentReading(): SensorReading;
startTracking(interval: number): Promise<boolean>;
stopTracking(): boolean;
}
export type SensorEventPayload = SensorReading;
モジュールのエクスポート
// src/index.ts
import {
requireNativeModule,
EventEmitter,
Subscription,
} from 'expo-modules-core';
import type {
MyNativeSensorModule,
SensorReading,
SensorError,
} from './MyNativeSensor.types';
// ネイティブモジュールの取得
const NativeModule =
requireNativeModule<MyNativeSensorModule>('MyNativeSensor');
// イベントエミッター
const emitter = new EventEmitter(NativeModule);
// --- 公開 API ---
/** 加速度センサーが利用可能かどうか */
export const isAccelerometerAvailable: boolean =
NativeModule.isAccelerometerAvailable;
/** ジャイロスコープが利用可能かどうか */
export const isGyroAvailable: boolean =
NativeModule.isGyroAvailable;
/** 現在のセンサー値を同期的に取得 */
export function getCurrentReading(): SensorReading {
return NativeModule.getCurrentReading();
}
/**
* センサー追跡を開始
* @param interval - 更新間隔(秒)。デフォルト 0.1(100ms)
*/
export async function startTracking(
interval: number = 0.1
): Promise<boolean> {
return NativeModule.startTracking(interval);
}
/** センサー追跡を停止 */
export function stopTracking(): boolean {
return NativeModule.stopTracking();
}
/** センサー値の更新イベントを購読 */
export function addSensorListener(
callback: (data: SensorReading) => void
): Subscription {
return emitter.addListener('onSensorUpdate', callback);
}
/** エラーイベントを購読 */
export function addErrorListener(
callback: (error: SensorError) => void
): Subscription {
return emitter.addListener('onError', callback);
}
Rork Max アプリへの統合
作成したモジュールを Rork Max プロジェクトに統合する方法を見ていきましょう。
ローカルモジュールとしての統合
開発中はローカルパスで参照するのが最も効率的です。
// package.json(Rork Max プロジェクト側)
{
"dependencies": {
"my-native-sensor": "file:./modules/my-native-sensor"
}
}
React Hook としてのラッパー実装
実際のアプリコードでは、カスタム Hook を通じてモジュールを使うのが推奨パターンです。
// hooks/useNativeSensor.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import {
startTracking,
stopTracking,
addSensorListener,
addErrorListener,
isAccelerometerAvailable,
} from 'my-native-sensor';
import type { SensorReading } from 'my-native-sensor';
interface UseSensorOptions {
interval?: number;
autoStart?: boolean;
}
interface UseSensorReturn {
reading: SensorReading | null;
isTracking: boolean;
isAvailable: boolean;
start: () => Promise<void>;
stop: () => void;
error: string | null;
}
export function useNativeSensor(
options: UseSensorOptions = {}
): UseSensorReturn {
const { interval = 0.1, autoStart = false } = options;
const [reading, setReading] = useState<SensorReading | null>(null);
const [isActive, setIsActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const subscriptionRef = useRef<ReturnType<
typeof addSensorListener
> | null>(null);
const start = useCallback(async () => {
if (!isAccelerometerAvailable) {
setError('Sensor not available on this device');
return;
}
try {
// イベントリスナーを登録
subscriptionRef.current = addSensorListener((data) => {
setReading(data);
});
await startTracking(interval);
setIsActive(true);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
}
}, [interval]);
const stop = useCallback(() => {
stopTracking();
subscriptionRef.current?.remove();
subscriptionRef.current = null;
setIsActive(false);
}, []);
// autoStart 対応
useEffect(() => {
if (autoStart) {
start();
}
return () => {
stop();
};
}, [autoStart, start, stop]);
return {
reading,
isTracking: isActive,
isAvailable: isAccelerometerAvailable,
start,
stop,
error,
};
}
アプリコンポーネントでの使用例
// screens/SensorDashboard.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useNativeSensor } from '../hooks/useNativeSensor';
export default function SensorDashboard() {
const { reading, isTracking, isAvailable, start, stop, error } =
useNativeSensor({ interval: 0.05 });
if (!isAvailable) {
return (
<View style={styles.container}>
<Text style={styles.errorText}>
加速度センサーはこのデバイスで利用できません
</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sensor Dashboard</Text>
{reading && (
<View style={styles.readingContainer}>
<Text style={styles.axis}>X: {reading.x.toFixed(4)}</Text>
<Text style={styles.axis}>Y: {reading.y.toFixed(4)}</Text>
<Text style={styles.axis}>Z: {reading.z.toFixed(4)}</Text>
</View>
)}
{error && <Text style={styles.errorText}>{error}</Text>}
<Pressable
style={[styles.button, isTracking && styles.buttonActive]}
onPress={isTracking ? stop : start}
>
<Text style={styles.buttonText}>
{isTracking ? 'Stop' : 'Start'} Tracking
</Text>
</Pressable>
</View>
);
}
// 期待される出力:
// ┌─────────────────────────┐
// │ Sensor Dashboard │
// │ │
// │ X: 0.0023 │
// │ Y: -0.9812 │
// │ Z: 0.0145 │
// │ │
// │ [Start Tracking] │
// └─────────────────────────┘
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
readingContainer: { marginBottom: 24 },
axis: { fontSize: 18, fontFamily: 'monospace', marginVertical: 4 },
button: {
backgroundColor: '#007AFF',
padding: 16,
borderRadius: 12,
alignItems: 'center',
},
buttonActive: { backgroundColor: '#FF3B30' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
errorText: { color: '#FF3B30', marginBottom: 16 },
});
テスト戦略
ユニットテスト(TypeScript レイヤー)
// __tests__/MyNativeSensor.test.ts
import {
getCurrentReading,
startTracking,
stopTracking,
} from '../src/index';
// Expo Modules のモック
jest.mock('expo-modules-core', () => ({
requireNativeModule: () => ({
isAccelerometerAvailable: true,
isGyroAvailable: true,
getCurrentReading: jest.fn(() => ({
x: 0.01,
y: -0.98,
z: 0.02,
timestamp: Date.now(),
})),
startTracking: jest.fn(() => Promise.resolve(true)),
stopTracking: jest.fn(() => true),
}),
EventEmitter: jest.fn(() => ({
addListener: jest.fn(),
})),
}));
describe('MyNativeSensor', () => {
it('getCurrentReading returns valid sensor data', () => {
const reading = getCurrentReading();
expect(reading).toHaveProperty('x');
expect(reading).toHaveProperty('y');
expect(reading).toHaveProperty('z');
expect(typeof reading.x).toBe('number');
});
it('startTracking resolves with true', async () => {
const result = await startTracking(0.1);
expect(result).toBe(true);
});
it('stopTracking returns true', () => {
const result = stopTracking();
expect(result).toBe(true);
});
});
// 期待される出力:
// PASS __tests__/MyNativeSensor.test.ts
// MyNativeSensor
// ✓ getCurrentReading returns valid sensor data (3 ms)
// ✓ startTracking resolves with true (1 ms)
// ✓ stopTracking returns true (1 ms)
ネイティブ側のテスト(Swift XCTest)
// ios/Tests/MyNativeSensorTests.swift
import XCTest
@testable import MyNativeSensor
import ExpoModulesCore
final class MyNativeSensorTests: XCTestCase {
var module: MyNativeSensorModule!
override func setUp() {
super.setUp()
module = MyNativeSensorModule()
}
func testModuleNameIsCorrect() {
// definition() 内の Name() が正しく設定されているか
let definition = module.definition()
XCTAssertNotNil(definition)
}
func testStopTrackingReturnsFalseWhenNotStarted() {
// 追跡未開始の状態で stop しても安全であること
let result = module.definition()
XCTAssertNotNil(result)
}
}
npm パッケージとして公開する
package.json の準備
{
"name": "@your-scope/my-native-sensor",
"version": "1.0.0",
"description": "Custom accelerometer module for Expo/React Native",
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"test": "jest"
},
"peerDependencies": {
"expo": ">=52.0.0",
"react": ">=18.0.0",
"react-native": ">=0.76.0"
},
"devDependencies": {
"expo-modules-core": "^2.0.0",
"typescript": "^5.0.0"
},
"files": [
"build/",
"ios/",
"android/",
"expo-module.config.json",
"src/"
],
"keywords": [
"expo",
"react-native",
"sensor",
"accelerometer",
"native-module"
],
"repository": {
"type": "git",
"url": "https://github.com/your-org/my-native-sensor"
},
"license": "MIT"
}
公開手順
# 1. TypeScript をコンパイル
npm run build
# 2. ドライラン(実際に公開せず内容を確認)
npm pack --dry-run
# 出力: 含まれるファイル一覧が表示される
# 3. npm にログイン
npm login
# 4. 公開(スコープ付きパッケージの場合は --access public が必要)
npm publish --access public
# 期待される出力:
# npm notice Publishing to https://registry.npmjs.org/ with tag latest
# + @your-scope/my-native-sensor@1.0.0
バージョニングのベストプラクティス
モジュールのバージョンは、対応する Expo SDK バージョンとの互換性を明示するのが重要です。
v1.0.x — Expo SDK 52 対応
v1.1.x — Expo SDK 53 対応(後方互換あり)
v2.0.x — Expo SDK 54 対応(Breaking Changes あり)
README にサポートマトリクスを記載しておくと、利用者にとって非常に親切です。
パフォーマンス最適化のポイント
カスタムネイティブモジュールの性能を最大限に引き出すためのテクニックを紹介します。
イベント頻度の制御
センサーデータのように高頻度でイベントが発火するモジュールでは、JavaScript 側のスレッドが詰まらないようスロットリングを実装します。
// iOS: 最小間隔でイベントを間引く
private var lastEventTime: TimeInterval = 0
private let minInterval: TimeInterval = 0.016 // 約60fps
private func throttledSendEvent(_ data: [String: Any]) {
let now = CACurrentMediaTime()
guard now - lastEventTime >= minInterval else { return }
lastEventTime = now
sendEvent("onSensorUpdate", data)
}
メモリリークの防止
ネイティブモジュールでは OnDestroy ハンドラを必ず実装し、リスナーやタイマーの解放を保証してください。これを怠ると、アプリのバックグラウンド遷移時にメモリリークが発生します。
個人開発者の視点から(実体験メモ)
まとめ
Expo Modules API によるカスタムネイティブモジュール開発は、Rork Max の拡張性を大きく広げる技術です。宣言的な API 設計のおかげで、Swift と Kotlin の両プラットフォーム実装を統一的なパターンで書けるようになり、保守性も向上します。
本記事で解説したセンサーモジュールの実装パターンは、カメラ制御、Bluetooth 通信、OS 固有の UI コンポーネントなど、あらゆるネイティブ拡張に応用できます。ぜひ自分のプロジェクトで実践してみてください。
React Native と Expo のアーキテクチャをより深く理解したい方には「React Native 0.84 + Hermes V1 完全ガイド」や「Rork の React Native + Expo アーキテクチャを深掘り解説」も参考になります。
モバイルアプリ開発のネイティブ拡張手法