取り組みの背景 — なぜカスタムネイティブモジュールが必要なのか
Rork Max は AI 駆動のコード生成と Expo エコシステムにより、多くのアプリ開発ニーズをカバーしています。しかし、プロダクション品質のアプリを構築する段階になると、既存のライブラリだけでは対応しきれない場面が必ず訪れます。
たとえば、独自のハードウェアセンサーへのアクセス、特定の OS API との高速なブリッジ通信、あるいはサードパーティ SDK のラッパー実装など、ネイティブコードを直接書く必要があるケースは少なくありません。
Expo Modules API は、こうした課題に対するモダンな解決策です。従来の React Native Bridge と比較して、型安全性・パフォーマンス・開発体験のすべてにおいて大きく進化しています。
加速度センサーを読むだけの小さなモジュールを題材に、Swift と Kotlin の実装、TypeScript レイヤーの設計、テスト、そして 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 ハンドラを必ず実装し、リスナーやタイマーの解放を保証してください。ただし OnDestroy が発火するのはモジュールが解放される直前だけです。アプリがバックグラウンドに回った瞬間には呼ばれません。この点は誤解しやすいので、次の節で詳しく扱います。
個人開発者の視点から(実体験メモ)
ここまでのコードは、そのままビルドすれば動きます。ただ私が実機に載せて最初に突きつけられたのは、動くことと消耗しないことは別だという当たり前の事実でした。
個人開発ではレビュアーがいません。バッテリーの減りも、スクロールのわずかな引っかかりも、自分で気づくまで誰も教えてくれない。
以下は expo-modules-core 57.0.12 のソースを読み直しながら整理した、自分のモジュールで実際に踏んだ三つの落とし穴です。
OnDestroy はバックグラウンド遷移では呼ばれない
OnDestroy の実体は EventListener(.moduleDestroy, closure) です。ソースのコメントにも「called when the module is about to be deallocated」と明記されています。発火するのはモジュールが解放される直前であって、ホームボタンでアプリを背面に送った瞬間ではありません。
私自身、停止処理を OnDestroy にだけ書いていました。結果として、アプリを背面に回したあとも加速度計が回り続けていました。気づいたきっかけは、実装の不具合ではなくバッテリーの減り方です。
バックグラウンド遷移には専用のフックが用意されています。
タイミング iOS(Swift) Android(Kotlin)
背面に回った OnAppEntersBackgroundOnActivityEntersBackground
前面に戻った OnAppEntersForegroundOnActivityEntersForeground
解放される直前 OnDestroyOnDestroy
// definition() の中に追加する
OnAppEntersBackground {
if self .isTracking {
self .motionManager. stopAccelerometerUpdates ()
}
}
OnAppEntersForeground {
if self .isTracking {
// 中断前の interval を保持しておき、同じ設定で再開する
self . resumeAccelerometerUpdates ()
}
}
OnDestroy は最後の保険として残します。役割が違うので、どちらか一方では足りません。
センサーのコールバックをメインキューに載せない
前半のサンプルでは startAccelerometerUpdates(to: .main) と書きました。素直で読みやすい反面、accelerometerUpdateInterval を 0.01 秒に設定すると、毎秒 100 回のクロージャがメインスレッドのキューに積まれます。
同じスレッドで React Native の UI 更新も走ります。スクロール中に指の追従が鈍る感触が出たら、まずここを疑ってください。
センサー用のキューを分けて、UI に触れる処理だけを明示的にメインへ戻す形が安全です。
private let sensorQueue: OperationQueue = {
let queue = OperationQueue ()
queue.name = "net.rorklab.sensor"
queue.qualityOfService = .userInitiated
queue.maxConcurrentOperationCount = 1
return queue
}()
// startAccelerometerUpdates(to: sensorQueue) { ... } のように渡す
前掲のスロットリングと組み合わせると、間引かれたあとのイベントだけがブリッジを越えます。送出頻度そのものが下がるため、効き方が分かりやすい対策です。
購読者がいない間はハードウェアを止める
Events("onSensorUpdate", "onError") を宣言しただけでは、JavaScript 側にリスナーが一つも無くても sendEvent は呼ばれ続けます。センサーは回りっぱなしです。
expo-modules-core には、最初のリスナーが追加された時と全てのリスナーが外れた時に呼ばれるフックがあります。
public func OnStartObserving ( _ event: String ? = nil , _ closure: @escaping () -> Void ) -> EventObservingDefinition
public func OnStopObserving ( _ event: String ? = nil , _ closure: @escaping () -> Void ) -> EventObservingDefinition
第 1 引数にイベント名を渡せば、購読対象ごとに分けられます。Kotlin 側も ObjectDefinitionBuilder に同名の関数が用意されているため、同じ考え方をそのまま持ち込めます。
OnStartObserving ( "onSensorUpdate" ) {
self . startAccelerometerUpdatesIfNeeded ()
}
OnStopObserving ( "onSensorUpdate" ) {
self .motionManager. stopAccelerometerUpdates ()
}
こうしておくと、画面を離れて subscription.remove() が走った時点でハードウェアが止まります。startTracking と stopTracking を JavaScript から明示的に呼ばせる設計に比べ、停止し忘れが構造的に起きません。
一つ注意があります。OnStartObserving を入れたなら、AsyncFunction("startTracking") 側で二重に起動しないよう、起動処理は一箇所にまとめてください。私は両方から呼んでしまい、stopAccelerometerUpdates() を一度呼んでも止まらない状態を作りました。
まとめ
Expo Modules API によるカスタムネイティブモジュール開発は、Rork Max の拡張性を大きく広げる技術です。宣言的な API 設計のおかげで、Swift と Kotlin の両プラットフォーム実装を統一的なパターンで書けるようになり、保守性も向上します。
本記事で解説したセンサーモジュールの実装パターンは、カメラ制御、Bluetooth 通信、OS 固有の UI コンポーネントなど、あらゆるネイティブ拡張に応用できます。ぜひ自分のプロジェクトで実践してみてください。
モジュールが載る土台のほうを先に押さえたい場合は「RorkのReact Native + Expoアーキテクチャを深掘り解説 」が参考になります。
モジュールを公開したあとも、実機のバッテリー消費とプロファイラの数字は定期的に見返しています。手元で動いた形がそのまま最適とは限らない、というのが Expo Modules API に向き合って一番学んだことでした。
まずは Function を一つだけ持つ最小のモジュールを作り、npx expo run:ios で実機に載せてみてください。そこから先は、必要になった API を一つずつ足していくだけです。
お読みいただきありがとうございました。