React Native の Native Module 作るハンズオン(過去資料)
React社内で Native Module(iOS とか Android で動かすモジュール)を作るハンズオンをやるため作成した資料
この記事の内容は古いです
この記事の内容は React Native 0.65 を対象に書いた資料です。
2023年3月時点では既に古いものとなっており、当時の資料の一つとして残しています。
作る方法
2 つある。
- 既存のプロジェクトに追加する
- create-react-native-library を使い npm パッケージを作る
ハンズオンでは 1 をやる。 練習用のリポジトリはこちら。
作る前に: 繋ぎ込みの仕組みを知る
React Native は、その名の通り React(JavaScript)と Native(Android とか iOS とかのコード)とで処理をやり取りする。
その仕組みが今までとこれからで違っている。それらの仕組みを知っておくことで、今回やる実装への理解が深まる。
今のやり方(以下レガシー RN)
React Native アプリには 3 つのスレッドがある。
- Javascript スレッド(JavaScript VM)
- Shadow スレッド(レイアウト計算)
- Native スレッド(端末のメインスレッド)
そして Javascript スレッドと Native スレッド との間で Bridge というレイヤーが頑張っている。 このレイヤーがお互いの情報を非同期でやり取りするので、特にフロントエンドのパフォーマンスが不安定になる。
用語1: Javascript スレッド
Metro でバンドルされた JavaScript を読むところ。
RN の JavaScript 実行環境(JavaScript VM)は "JavaScriptCore(JSC)" という WebKit のエンジンが使われる。
JSC は元々 iOS のもので、RN はこれを Android に移植して使っている。
Dev 環境で Chrome Debugger を有効にすると、実行環境が JSC ではなく Chrome の V8 に切り替わり、動作が速くなる。
用語2: Shadow スレッド
Native 向けの仮想 DOM みたいなスレッド。
Web のノリでレイアウトをそのまま持っていくことはできないので、Yoga という Flexbox ベースのレイアウトエンジンを使って計算している。
用語3: Native スレッド
端末のメインスレッド。実際の UI(Native UI)を変更できるのはここだけ。
また、今回作成する Native Modules もここにある。
用語4: Bridge
JavaScript スレッドと Native スレッドを繋ぐ箇所。Native Modules が登録されている。情報は JSON を使って、非同期でやり取りする。
最新 RN(これからの)
JSI という Native と直で同期的にやり取りするインターフェースが登場。
それに加えて Fabric, TurboModules などの新技術も登場している。
用語1: JSI
JavaScript と Native Module で直接やり取りするためのインターフェース。Bridge レイヤーを無くす。 JSI 自体は C++ で実装する。
用語2: Fabric
JSI に対応した、Native UI。
用語3: TurboModules
JSI に対応した Native Modules。
遅延読み込み的な仕組みを持っていて、必要な Native Modules を読み込む。
※ JSI, TurboModules については、以下の記事を参考
React Nativeの次世代アーキテクチャTurboModuleとJSIの話 - tomoima525's blog
Native Module を作る(レガシー編)
目標は NativeModuleYobidashi
という iOS/Android で動く Native Module を作り、JavaScript から呼び出すこと。
そのモジュールは 2 つのメソッドを持ち、Native 側で計算した結果を JavaScript に渡せるようになっている。
- 足し算(sum)
- 文字数カウント(getStringLength)
iOS は Objective-C(Swift) で、Android は Java で実装してみる。
iOS(Objective-C)
Objective-C は RN 本体や、macOS/iOS アプリの開発に使われる言語。
言語というか C の拡張で、スティーブ・ジョブスが Apple に持ち込んだ負債。
C でオブジェクト指向をやるために Smalltalk をくっつけており、独特の構文とディレクティブのせいで非常に読みづらい。古くは Mac の前身 NeXTSTEP から、現在では Cocoa という macOS/iOS 向けフレームワークで使われている。
テンプレートの作成
Native Module を作るには、 ヘッダと定義ファイルを作る必要がある。
Xcode で雛形を作れる。
まず、ファイルツリーで右クリックして "New File" を選択。
)
プリセット一覧が表示されるので "Cocoa Touch Class" を選択する。"Cocoa Touch" と言うのは、iOS 向け Cocoa のこと。
"Class:" で設定するクラス名は、Native Module の名前になる。
ここでは "NativeModulesYobidashi" を設定。後はひたすら "Next" で OK。
ダイアログを通して NativeModuleYobidashi
クラスを作った。
NativeModuleYobidashi.h
と NativeModuleYobidashi.m
ができる。
クラスの宣言
最初、NativeModuleYobidashi.h
はこのような状態になっている。
//
// NativeModuleYobidashi.h
// ReactNativeHaTsurai
//
// Created by grgr-dkrk on 2021/01/01.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NativeModuleYobidashi : NSObject<RCTBridgeModule>
@end
NS_ASSUME_NONNULL_END
Native Modules を開発するために、ここでやることが 2 つある。
RCTBridge のインポート
まず最初に、Bridge にあたる <React/RCTBridge.h>
をインポートする。
#import <Foundation/Foundation.h>
#import <React/RCTBridge.h> // ①:ここを追加。
RCTBridge.h
の RCT
は React
の略。Objective-C には namespace がないので、このような大文字プレフィックスを付ける慣習がある。
プロトコル(インターフェース)の指定
次に、NativeModuleYobidashi
クラスを宣言しているところ。
@interface
と書かれているけど、実際はクラス宣言なので注意。
// ②:NSObjectのプロトコルに RCTBridgeModule を指定
@interface NativeModuleYobidashi : NSObject<RCTBridgeModule>
@end
継承元に指定している NSObject
は Cocoa のルートクラス。ルートクラスには、Cocoa のランタイムや、インスタンス生成などの手続きなどが含まれる。
その後ろに指定している <RCTBridgeModule>
はプロトコルと言うもので、Java のインターフェースとほぼ同じ(と言うか、Java のインターフェースの元ネタ)。
Objective-C では @interface
から @end
がクラス宣言の範囲で、この中にメンバやメソッドが含まれることもある。
だから、以下のような意味不明なコードがあっても、@interface
から @end
までがクラスの内容にあたると考えておく。
// その1
@interface FOOPerson : NSObject {
NSString *name;
}
-(void) setName:(NSString *) personName;
-(NSString *) getName;
@end
// その2
@interface HOGPerson : NSObject
@property (nonatomic, copy) NSString *name; // こういう書き方もある
-(void) setName:(NSString *) personName;
-(NSString *) getName;
@end
ちなみに @
で始まるものはコンパイラディレクティブと言って、構文ではなく、コンパイラに出す指示みたいなもの。これからも頻出する。
クラスの定義(実装)
それでは NativeModuleYobidashi
クラスの定義を NativeModuleYobidashi.m
で実装する。以下をコピペ。
//
// NativeModuleYobidashi.m
// ReactNativeHaTsurai
//
// Created by grgr-dkrk on 2021/01/01.
//
#import "NativeModuleYobidashi.h"
@implementation NativeModuleYobidashi
RCT_EXPORT_MODULE(); // ①
// ②
RCT_EXPORT_METHOD(sum:(double)num1 num2:(double)num2 callback:(RCTResponseSenderBlock)callback) {
double result = num1 + num2;
callback(@[@(result)]);
}
// ②
RCT_EXPORT_METHOD(getStringLength:(NSString *)str resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
resolve(@[@([str length])]);
}
@end
@implementation NativeModuleYobidashi
から @end
までが NativeModuleYobidashi
クラスの実装であり、ここで Native Module の登録処理や、実際の処理を書く。
ここでやることも 2 つある。
モジュールを登録する(RCT_EXPORT_MODULE)
まずは RCT_EXPORT_MODULE()
というのを記述する
RCT_EXPORT_MODULE(); // ①
ここは Bridge に直接関わるコードなので、一応読んでおく。
まず RCT_EXPORT_MODULE
というのはマクロで、コンパイル時には以下のコードに置き換えられる。
RCT_EXTERN void RCTRegisterModule(Class);
+(NSString *)moduleName
{
return @ #js_name;
}
+(void)load
{
RCTRegisterModule(self);
}
この load
は、アプリを起動してクラスが読み取られる時に必ず発火するクラスメソッド(クラスから直接実行されるメソッド)。
中では RCTRegisterModule(self)
を実行している。そこでは、Bridge が使う Native Modules のリストに自身を登録している。
/**
* Register the given class as a bridge module. All modules must be registered
* prior to the first bridge initialization.
*/
void RCTRegisterModule(Class);
void RCTRegisterModule(Class moduleClass)
{
RCTWarnNotAllowedForNewArchitecture(
@"RCTRegisterModule()", [NSString stringWithFormat:@"'%@' was registered unexpectedly", moduleClass]);
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
RCTModuleClasses = [NSMutableArray new];
RCTModuleClassesSyncQueue =
dispatch_queue_create("com.facebook.react.ModuleClassesSyncQueue", DISPATCH_QUEUE_CONCURRENT);
});
RCTAssert(
[moduleClass conformsToProtocol:@protocol(RCTBridgeModule)],
@"%@ does not conform to the RCTBridgeModule protocol",
moduleClass);
// Register module
dispatch_barrier_async(RCTModuleClassesSyncQueue, ^{
[RCTModuleClasses addObject:moduleClass];
});
}
あまり深掘りすると脱線してしまうので、大事そうなところだけコメントした。
/**
* Register the given class as a bridge module. All modules must be registered
* prior to the first bridge initialization.
*/
void RCTRegisterModule(Class);
void RCTRegisterModule(Class moduleClass)
{
RCTWarnNotAllowedForNewArchitecture(
@"RCTRegisterModule()", [NSString stringWithFormat:@"'%@' was registered unexpectedly", moduleClass]);
// dispatch_once はインスタンス生成時に一回だけ発火する
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
RCTModuleClasses = [NSMutableArray new]; // 配列生成
RCTModuleClassesSyncQueue =
// ディスパッチキューというのを作成。並列キュー
dispatch_queue_create("com.facebook.react.ModuleClassesSyncQueue", DISPATCH_QUEUE_CONCURRENT);
});
RCTAssert(
[moduleClass conformsToProtocol:@protocol(RCTBridgeModule)],
@"%@ does not conform to the RCTBridgeModule protocol",
moduleClass);
// Register module
// 先程の並列キューに処理を送信。 dispatch_barrier_async によって、前に実行されているタスクの完了を待つ。
dispatch_barrier_async(RCTModuleClassesSyncQueue, ^{
// 中でやっていることは、RCTModuleClasses にクラスを追加しているだけ
[RCTModuleClasses addObject:moduleClass]; // 配列じゃないよ
});
}
ちなみに Objective-C の []
は配列ではなくクラスメソッドの呼び出し構文なので、読み間違いに注意。
メソッドを登録する
Native Modules を登録するだけではダメで、JavaScript が読み込めるメソッドも登録しないといけない。
その役割をこなすのが RCT_EXPORT_METHOD()
マクロ。
登録したいメソッドの数だけ定義できる。
// ②足し算
RCT_EXPORT_METHOD(sum:(double)num1 num2:(double)num2 callback:(RCTResponseSenderBlock)callback) {
double result = num1 + num2;
callback(@[@(result)]);
}
// ②文字数カウント
RCT_EXPORT_METHOD(getStringLength:(NSString *)str resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
resolve(@[@([str length])]);
}
フォーマットはこうなる。引数の部分が絶妙にわかりづらい。第一引数には関数名と、その関数の引数を指定するようになっている。
RCT_EXPORT_METHOD(関数名:(型、クラス名)関数の第一引数名 ...引数nのラベル:(型、クラス名)関数の第n引数名) {
// 処理を書く
}
例えば、sum メソッドの場合はこうなる。
RCT_EXPORT_METHOD(sum:(double)num1 num2:(double)num2 callback:(RCTResponseSenderBlock)callback)
第一引数はメソッド名としてsum:(double)num1
を指定する。要するに sum という、「double 型の num1 を取る関数」を定義しますよ、という意味。
でも、double 型をもう一個引数に取らないといけない。そこで続いて num2:(double)num2
を指定する。これで「num2 という引数も取りますよ」という意味になる。
引数では数値を double 型として扱う。JavaScript とやり取りするには double 型でないといけない。
さて Bridge は非同期で情報をやり取りするので、Native Module の計算結果をすぐに返せない。
だから、コールバックで返す必要がある。
そこで num2 に続いて callback:(RCTResponseSenderBlock)callback
も追加した。
メソッドの中で callback
を呼び出し、計算結果を入れると、JavaScript 側からコールバックで読み込めるようになる。
RCT_EXPORT_METHOD(sum:(double)num1 num2:(double)num2 callback:(RCTResponseSenderBlock)callback) {
double result = num1 + num2;
callback(@[@(result)]); // result の結果が取れる
}
// NativeModuleYobidashi.sum(2, 3, (result) => {console.log(result) })
または、RCTPromiseResolveBlock
とRCTPromiseRejectBlock
クラスをとって、Promise 形式で返させることもできる。
今回は getStringLength メソッドでやってみた。
RCT_EXPORT_METHOD(getStringLength:(NSString *)str resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
resolve(@[@([str length])]) // resolve で文字列の長さを返す;
}
// const strLength = await NativeModuleYobidashi.getStringLength('foo')
iOS 側の Native Module はこれで実装終わり。
yarn iOS
して、エラーなどなくビルドできれば一旦 OK。
iOS(Swift)
Objective-C の RCT_EXTERN_METHOD
マクロを使えば、Swift で書いた Native Modules を作れる。逆に言えば現状は RCT_EXTERN_METHOD
マクロがないと作れない。
そして Swift はマクロが使えないので、Objective-C を通して読み込む必要がある。
XCode からテンプレート & bridging header の作成
まず Xcode の "Cocoa Touch Class" からテンプレートを作成する。Objective-C とやり方は同じだが、Language は "Swift" を選択する。
初回は 「Objective-C の bridging header を設定しますか?」という確認のダイアログが出るので、必ず設定しておく。
bridging header は、Swift と Objective-C を繋ぐファイル。
Swift の方で RCTBridgeModule
を使えるように、Bridging-Header.h で RCTBridgeModule
をインポートしておく。
#import <React/RCTBridgeModule.h>
Swift 側の実装
Swift で、sum と getStringLength を実装する。
import Foundation
@objc(NativeModuleYobidashi)
class NativeModuleYobidashi: NSObject {
// 第一引数には _ を入れる
@objc func sum(_ num1: Double, num2: Double, callback: RCTResponseSenderBlock) -> Void {
var result: Double = num1 + num2
callback([result])
}
@objc func getStringLength(_ str: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void {
resolve([str.count])
}
}
クラスの宣言、メソッド実装時に @objc
というのを入れておく必要がある。
コンパイル時に Objective-C の API が使われることがあり、その互換性を保つためらしい。
あと第一引数は無視しないといけないらしく、_
が入る。
Objective-C で出力
その後 NativeModuleYobidashi.m
を追加して、Swift で書いたクラス、メソッドを出力する。
RCT_EXTERN_MODULE(NativeModuleYobidashi, NSObject)
でクラスを、RCT_EXTERN_METHOD
でメソッドを登録する。
RCT_EXTERN_METHOD
の内容は RCT_EXPORT_METHOD
と全く同じ。
//
// NativeModuleYobidashi.m
// ReactNativeHaTsurai
//
// Created by grgr-dkrk on 2021/01/01.
//
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(NativeModuleYobidashi, NSObject)
RCT_EXTERN_METHOD(sum:(double)num1 num2:(double)num2 callback:(RCTResponseSenderBlock)callback);
RCT_EXTERN_METHOD(getStringLength:(NSString *)str resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);
@end
yarn iOS
して、エラーなどなくビルドできれば一旦 OK。
Android(Java)
Objective-C や Swift が読めるようになった今、もはや Java は綺麗なコードにしか見えないので簡単に実装できるはず。
テンプレートの作成
Android もクラスを作り、それを Native Module として登録する。
クラスの雛形作成は Android Studio でできる。
"MainActivity.java" とか "MainApplication" がある箇所で右クリックし、"New" → "Java Class" を選択。
クラス名は iOS の時と同様 "NativeModuleYobidashi" に設定。
YourProjectName/android/app/src/main/java/com/yourprojectname/NativeModuleYobidashi.java
ができる。
package com.yourprojectname; // ここは自分のプロジェクト名
public class NativeModuleYobidashi {
}
これの 2 行目以降を以下のように書き換える。
package com.yourprojectname; // ここは自分のプロジェクト名
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import java.util.Map;
import java.util.HashMap;
public class NativeModuleYobidashi extends ReactContextBaseJavaModule {
NativeModuleYobidashi(ReactApplicationContext context) {
super(context);
}
}
bridge 関連のモジュールをたくさん読み込んでいる。
ReactApplicationContext はアプリケーションの情報やアクティビティライフサイクルを取得する際に使う。
パッケージ名の指定
NativeModuleYobidashi
クラスは ReactContextBaseJavaModule
を継承している。
public class NativeModuleYobidashi extends ReactContextBaseJavaModule {
NativeModuleYobidashi(ReactApplicationContext context) {
super(context);
}
}
継承元の親を辿っていくと NativeModule
というインターフェースがあり、その中に getName
というメソッドがある。
@NonNull
String getName();
ここが iOS でいう RCT_EXPORT_MODULE
にあたるメソッドで、Android の場合は getName をオーバーライドし、必ずクラス名を返す必要がある。
public class NativeModuleYobidashi extends ReactContextBaseJavaModule {
NativeModuleYobidashi(ReactApplicationContext context) {
super(context);
}
@NotNull
@Override
public String getName() {
return "NativeModuleYobidashi";
}
}
メソッドの登録
iOS では RCT_EXPORT_METHOD
という複雑なマクロを使わされたが、Java では @ReactMethod
アノテーションを付けて、パブリックメソッドを定義するだけで OK。
iOS 同様、数値は double 型で指定する。 Bridge が非同期で動くため、コールバックか Promise が必要なのも同じ。
iOS のメソッドと一貫性を持たせるため sum 関数はコールバックで、getStringLength 関数は Promise で取得できるようにした。
public class NativeModuleYobidashi extends ReactContextBaseJavaModule {
NativeModuleYobidashi(ReactApplicationContext context) {
super(context);
}
@NotNull
@Override
public String getName() {
return "NativeModuleYobidashi";
}
// sum を追加、コールバック式で返す
@ReactMethod
public void sum(double num1, double num2, Callback callback) {
double result = num1 + num2;
callback.invoke(result); // callback.invoke で result を渡す
}
// getStringLength を追加、Promise 式で返す
@ReactMethod
public void getStringLength(String str, Promise promise) {
promise.resolve(str.length()); // これは説明不要
}
}
パッケージの登録
レガシー RN の Android は、アプリの起動時にパッケージから Native Modules を一気に読み込むようになっている。
しかし NativeModuleYobidashi
を読み込むパッケージは登録されていないので、デフォルトでは読み込まれない。
そこでパッケージとなるファイルを作り、それをアプリに登録する。
まず YourProjectName/android/app/src/main/java/com/yourpackgename/MainApplication.java
を開き、23行目あたりにある getPackages
メソッドを見る。
ここに // packages.add(new MyReactNativePackage());
というコメントアウトされた箇所があるので、コメントを解除する。
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage()); ← ここのコメントを外す
return packages;
}
これで MyReactNativePackage
というパッケージを追加するようになったので、これと同じ名前のファイルを作成する。
パスは YourProjectName/android/app/src/main/java/com/yourpackgename/MyReactNativePackage.java
。
package com.yourpackgename; // ここは自分のプロジェクト名
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class MyReactNativePackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
// モジュールリストに NativeModuleYobidashi を登録する
modules.add(new NativeModuleYobidashi(reactContext));
return modules;
}
}
Android の実装はこれで終わり。
yarn android
してビルドエラーがなければ一旦 OK。
JavaScript から呼び出す
最後に JavaScript(TypeScript)から呼び出す。
Native Module 用コンポーネントの作成
ここまでのやり方が正しければ、各 OS で Native Modules が Bridge に読み込まれるはず。
それは、RN の NativeModules
からアクセスできる。
import { NativeModules } from 'react-native'
const App = () => {
const [ans, setAns] = useState(0)
NativeModules.NativeModuleYobidashi.sum(2, 3, (result) => {
setAns(result)
})
// ...
}
しかし NativeModules は {[key: string]: any}
型だから型定義が効かないし、直接 NativeModules を読み込むのもつらいので、大抵は別ファイルに切り出している。
import { NativeModules } from 'react-native'
const { NativeModuleYobidashi } = NativeModules
type NativeModuleYobidashiType = {
sum: (num1: number, num2: number, callback: (result: number) => void) => void
getStringLength: (str: string) => Promise<number>
}
export default NativeModuleYobidashi as NativeModuleYobidashiType
型について
iOS/Android と JavaScript の型には互換性がない。それぞれに対応する型の表がある。
JavaScript はガバガバだからともかく、Native Module の実装で引数の型が合っていないと、ビルドにコケてしまうので注意。
読み込む
モジュールを読み込んで使えば OK。あらかじめ指定した型も効く。
import React, { useEffect, useState } from 'react'
import { StyleSheet, View, Text } from 'react-native'
import NativeModuleYobidashi from './native-module-yobidashi'
const App = () => {
const [ans, setAns] = useState(0)
const [strLength, setStrLength] = useState(0)
NativeModuleYobidashi.sum(2, 3, (result) => {
setAns(result)
})
useEffect(() => {
const fn = async () => {
const _strLength = await NativeModuleYobidashi.getStringLength('foo')
setStrLength(_strLength)
}
fn()
}, [])
return (
<View style={styles.wrapper}>
<Text style={styles.ans}>2 + 3 = {ans}</Text>
<Text style={styles.ans}>"foo".length = {strLength}</Text>
</View>
)
}
const styles = StyleSheet.create({
wrapper: { marginTop: 60 },
ans: { fontSize: 56 },
})
export default App
終わり
iOS で計算できていることを確認。
続いて Android でも計算できていることを確認。
次の学習ステップ
- ネイティブの API にアクセスしてみる
- Swift や Kotlin でも作ってみる
- UI の作り方も学ぶ
- 新しい方の RN も知る