React Native製アプリをJSIモジュールを使って50倍高速化した話

React Native製アプリをJSIモジュールを使って50倍高速化した話
本記事は2月26日に書いた英語記事のDeepL機械翻訳です。
人々がアプリを十分に楽しんで使うためには、アプリからできる限り摩擦を取り除くことが重要です。
React Nativeは、JavaScriptとReactを使って、マルチプラットフォームのモバイルアプリを素早く構築できるフレームワークです。そのおかげで、私はInkdropというアプリのモバイル版を作ることができました。エンドツーエンドの暗号化でデバイス間で同期するMarkdownノートアプリです。このアーキテクチャのおかげで、私はこのアプリを素早く構築することができ、ElectronJSで構築したデスクトップ版のコードベースの多くを再利用することで簡単に維持することができました。これは素晴らしいことです。React Nativeには大いに助けられましたが、特に画像を扱う際のパフォーマンスの低さには苦労しました。最悪の場合、7MBの画像をダウンロード、復号化、そして表示するのに40秒かかっていました(ネットワークやデバイスに依存します)。新バージョンでは数秒で済みます。これにはとても満足しています。
こんにちは、TAKUYAです。今回は、私がReact Nativeアプリのパフォーマンスを大幅に改善するために行ったことを紹介したいと思います。
まとめ
- React NativeはNodeJSほど速くない
- パフォーマンスが必要な場合は、JavaScriptベースのPolyfillを使用しない
- React NativeでNULL文字を含むバイナリ文字列は扱えない
- Polyfillをネイティブモジュールに置き換えた
- JSI native modules を C++で書いた
- React Nativeを新しいプロジェクトでも使いたいか? — Yes.
パフォーマンスが必要な場合は、JavaScriptベースのPolyfillを使用しない
しかし、RNはバイナリのサポートがまだ不足しています。NodeJSとは異なり、RNにはcrypto
やBuffer
のような、バイナリデータを扱うためのネイティブモジュールが用意されていません。バイナリデータを処理するとなると、SHA-1やMD5のようなダイジェストハッシュを計算したり、16進数やbase64との間で変換したりするのが一般的な作業です。これらの作業を行うには、spark-md5やbufferなどのJavaScriptライブラリを使用する必要があります。crypto
モジュールが必要な場合は、rn-nodeifyやたくさんのpolyfillライブラリをインストールしなければなりませんが、これは最終的にプロジェクトを混乱させ、維持するのが難しくなります。ですから、エンドツーエンドの暗号化をReact Nativeで実装することは、私にとって大きな挑戦でした。
さらに悪いことに、すべてのPolyfillがJavaScriptで書かれているため、非常に遅いのです。私のプロジェクトでなんとか使えるようになった後、あるユーザーから重大なバグレポートを受け取りました:
このアプリが画像を読み込めないのは、クライアントでのデータの暗号化/復号化が遅すぎるからで、つまりバイナリデータを扱うのにJSベースのライブラリに頼るのは良くないということです。そこで、それらの作業をネイティブ言語で実装することになりました。
NULL 文字問題
React Nativeでは、プラットフォーム固有のAPIを使用したり、Objective-C、Swift、Java、Kotlin、C++の既存のライブラリを再利用したりできるように、ネイティブモジュールを作ることができます。ネイティブコードであっても、この厄介な問題のために、バイナリデータを扱うのはそれほど簡単ではありません:
これは、JSCが文字列を常にNULLで終端するUTF-8として扱うためです。なぜ古いバージョンのRNでは問題がなかったのかはわかりませんが。
この問題のために、react-native-sqlite-2というモジュールではblobデータを格納するために、\0
文字をエスケープしています:
function escapeBlob(data) { if (typeof data === 'string') { return data .replace(/\u0002/g, '\u0002\u0002') .replace(/\u0001/g, '\u0001\u0002') .replace(/\u0000/g, '\u0001\u0001') } else { return data }}
これにより、ほとんどの場合は問題なく動作しますが、当然ながらパフォーマンスにもある程度影響します。しかし、ネイティブ言語でバイナリデータを処理したい場合は、このようにするか、base64に変換するしかありません。
残念ながら、コミュニティはそれを解決することに興味がないようです。
Polyfillsをネイティブモジュールに置き換えた
すべてをJSで行うのではなく、ネイティブコード(Java,Kotlin,Objective-C,Swift,C++)をいくつか書いてパフォーマンスを向上させました。次の図は、アプリが画像データを処理してWebViewに渡す様子を示しています。

上図のように、JSアプリはネイティブ関数を呼び出すだけです。データの復号化は、KotlinとSwiftで書かれたreact-native-aes-gcm-cryptoという私のネイティブモジュールで行います。
驚いたことに、私のcryptoモジュールは、iPhone 11 Proでrn-nodeify
やreact-native-crypto
よりも50倍速いことを確認しました(私のvlogをご覧ください)
It works pretty well.
ネイティブモジュール間の相互通信

私のアプリはMarkdownを使ったノートアプリなので、画像はWebViewでレンダリングする必要があります。画像ファイルを読み込んで直接WebViewに渡すために、JavaとObjective-Cでアプリ固有のネイティブコードを書きました。React Nativeブリッジを介してJSからWebViewにBase64エンコードされた画像データを渡すのは冗長で時間がかかるからです。そのために、JavaとObjective-Cで書かれたRNのコアモジュールを掘り下げ、既存のRNのビューインスタンスを取得して、iOSのRCTBridge
を介して別のネイティブモジュールから制御する方法を見つけました。
#import <React/RCTUIManager.h>
RCT_EXPORT_METHOD(runJS:(NSString* __nonnull)js inView:(NSNumber* __nonnull)reactTag withResolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { RCTUnsafeExecuteOnMainQueueSync(^{ RCTUIManager* uiManager = [self.bridge moduleForClass:[RCTUIManager class]]; RNCWebView* webView = (RNCWebView*)[uiManager viewForReactTag:reactTag]; if (webView) { [webView injectJavaScript:js]; } resolve(@"OK"); });}
Androidでは ReactContext
を使って以下のようにします:
@ReactMethodpublic void injectJavaScript(int reactTag, String js) { UIManagerModule uiManagerModule = this.reactContext.getNativeModule(UIManagerModule.class); WebView webView = (WebView) uiManagerModule.resolveView(reactTag); webView.post(new Runnable() { @Override public void run() { webView.evaluateJavascript(js, null); } });}
reactTag
というビューごとに割り当てられるIDは、ReactのRef経由で取得できます:
import { WebView } from 'react-native-webview'
const YourComponent = (props) => { const webViewRef = useRef() useEffect(() => { const { current: webView } = webViewRef if (webView) { console.log(webView.webViewRef.current._nativeTag) } }, [])
return ( <WebView ref={webViewRef} ... /> )}
Read my post for more detail.

RNブリッジを使わずにファイルシステムから直接WebViewに読み込むことで、アプリは瞬時に画像を表示することができます。
JSIモジュールをC++で書いた
私のアプリをさらに高速化するために、base64のエンコード/デコードとmd5の計算を行うネイティブモジュールを作成しました。これらは、JSI(JavaScript Interface)を使って実装されています。JSIは、React NativeのJSコードとネイティブコードの間の新しいTranslation layerです。
JSIを使用することで、JavaScriptはC++ホストオブジェクトへの参照を保持し、そのオブジェクトに対してメソッドを呼び出すことができます。つまり、先に述べたNULL文字の問題を最終的に回避することができるのです。データをArrayBuffer
として渡すことで、NULL
文字をエスケープすることなくバイナリデータを扱うことができます。
#include <iostream>#include <sstream>
using namespace facebook;
// Returns false if the passed value is not a string or an ArrayBuffer.bool valueToString(jsi::Runtime& runtime, const jsi::Value& value, std::string* str) { if (value.isString()) { *str = value.asString(runtime).utf8(runtime); return true; }
if (value.isObject()) { auto obj = value.asObject(runtime); if (!obj.isArrayBuffer(runtime)) { return false; } auto buf = obj.getArrayBuffer(runtime); *str = std::string((char*)buf.data(runtime), buf.size(runtime)); return true; }
return false;}
C++でもArrayBuffer
オブジェクトを作ることができます:
std::string str = "foo";jsi::Function arrayBufferCtor = runtime.global().getPropertyAsFunction(runtime, "ArrayBuffer");jsi::Object o = arrayBufferCtor.callAsConstructor(runtime, (int)str.length()).getObject(runtime);jsi::ArrayBuffer buf = o.getArrayBuffer(runtime);memcpy(buf.data(runtime), str.c_str(), str.size());
return o;
公式の包括的なドキュメントがなく、JSIを使ったネイティブモジュールもまだ少ないので、JSIの使い方を学ぶのは大変でした。私のJSIネイティブモジュールを例に挙げてみましょう:
- react-native-quick-md5: iOSで10倍高速、Androidで8x高速なMD5モジュール
- react-native-quick-base64: iOSで4倍高速なBase64モジュール
React Nativeは今も新プロジェクトでも使いたいか? — Yes.
さて、私のプロジェクトでは、良いパフォーマンスを得るために、Java、Kotlin、Objective-C、Swift、そしてC++まで書くことになりました。誰もがそうしたいわけではないでしょう。もちろん、とても大変でしたが。ですから、バイナリデータを集中的に扱う必要があるアプリの場合は、最初からネイティブ言語での実装を検討することをお勧めします。
しかし、ほとんどの場合、質の良いアプリを作るにはRNがちょうど良いと思います。なぜなら、アプリを素早く構築できることは、ビジネスを立ち上げるための鍵であり、それがReact Nativeの主な利点だと思うからです。DHHは著書の中でこう言っています。
It’s a Problem When It’s a Problem
Create a great app and then worry about what to do once it’s wildly successful.
— DHH, “Getting Real”
私はElectronとReact Nativeを使って、5つのプラットフォームで動作するノートアプリを作ることに成功しました。これは、モバイルアプリとデスクトップアプリの間で、JSのコードベースを大量に再利用することができたからです。あなたのプロジェクトにまだ顕在化していない問題を心配するのではなく、痒いところに手が届くようなアプリを作ることに集中すべきです。
React Nativeコミュニティは、NodeJSとは異なり、バイナリデータ用の強固なAPIを提供することに注力していません。彼らは、代わりにフロントエンドフレームワークとしての改善に取り組んでいます。そして、プラットフォームに依存する機能など、他のものは拡張機能に頼ることになるでしょう。
nodejs-mobileというプロジェクトがあり、NodeJSをアプリに統合することができます。趣味のプロジェクトでは楽しいかもしれませんが、寿命を重視する自分のアプリにこんなマイナーなフレームワークに頼るのは怖いですね。実際、このライブラリは最近は活動していないようです。
一言で言うと、React Nativeは新しいプロジェクトには最適ですが、高速なデータ処理には不十分です。最終的にはネイティブコードを書く必要があります。
参考になれば幸いです。