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

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にはcryptoBufferのような、バイナリデータを扱うためのネイティブモジュールが用意されていません。バイナリデータを処理するとなると、SHA-1やMD5のようなダイジェストハッシュを計算したり、16進数やbase64との間で変換したりするのが一般的な作業です。これらの作業を行うには、spark-md5bufferなどのJavaScriptライブラリを使用する必要があります。cryptoモジュールが必要な場合は、rn-nodeifyやたくさんのpolyfillライブラリをインストールしなければなりませんが、これは最終的にプロジェクトを混乱させ、維持するのが難しくなります。ですから、エンドツーエンドの暗号化をReact Nativeで実装することは、私にとって大きな挑戦でした。

さらに悪いことに、すべてのPolyfillがJavaScriptで書かれているため、非常に遅いのです。私のプロジェクトでなんとか使えるようになった後、あるユーザーから重大なバグレポートを受け取りました:

2 images (1920x1080) not loading on Android
Bug report I added 2 images (1920px width each) to a note. On desktop everything is fine but it does not load on…

このアプリが画像を読み込めないのは、クライアントでのデータの暗号化/復号化が遅すぎるからで、つまりバイナリデータを扱うのに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に渡す様子を示しています。

Fig. Image processing

上図のように、JSアプリはネイティブ関数を呼び出すだけです。データの復号化は、KotlinとSwiftで書かれたreact-native-aes-gcm-cryptoという私のネイティブモジュールで行います。

驚いたことに、私のcryptoモジュールは、iPhone 11 Proでrn-nodeifyreact-native-cryptoよりも50倍速いことを確認しました(私のvlogをご覧ください)

It works pretty well.

ネイティブモジュール間の相互通信

Fig. Image data flow

私のアプリは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は今も新プロジェクトでも使いたいか? — 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は新しいプロジェクトには最適ですが、高速なデータ処理には不十分です。最終的にはネイティブコードを書く必要があります。

参考になれば幸いです。

Inkdrop — Note-taking App with Robust Markdown Editor
Get a low-friction personal note-taking workflow and accomplish more. With your notes well-organized effortlessly, you…

Read more

なぜ体を壊してまで個人開発を頑張るのか?自尊心の欠如や過集中癖と向き合う

なぜ体を壊してまで個人開発を頑張るのか?自尊心の欠如や過集中癖と向き合う

どうもTAKUYAです。最近、個人開発を頑張りすぎて体調を崩してしまいました。アトピーが猛烈に悪化して、QoLが著しく下がってしまいました。まだ療養中ですが、毎日1万歩以上歩いて、徐々に回復しつつあります。 この過ちを繰り返さないためにも、自分は一体何が原因で頑張りすぎてしまうのか?という事について深堀りして考えてみたいと思います。また、個人開発におけるメンタルヘルスはあまり語られていないトピックだと思います。本記事が、同じように仕事を頑張りすぎてしまう人の助けになれば幸いです。 TL;DR * なんとなく続けていたソフト開発が自分を救った * 原体験が歪んだモチベーションを生んでしまった * 親が引くほどの過集中癖がある * 生得的な直せないバグと考えることにする * アプリの成功に関係なく、自分をあるがままに受け入れる * 挫折しないのは、なんだかんだで前向きだから * ユーザさんから「休め!」と叱咤された * 人生は長い。個人開発なんかで死ぬな 自己の原体験について振り返ってみる 個人開発だけで生活するようになって、かれこれ8年ぐらいが経ちます。こう

By Takuya Matsuyama
ユーザサポートの問い合わせを装った攻撃が怖すぎた

ユーザサポートの問い合わせを装った攻撃が怖すぎた

どうもTAKUYAです。個人開発をしていてアプリの知名度が上がってくると、作者個人(あるいはサイト管理人)を狙った攻撃というのをたまに受けます。つい先日も、怖すぎるメールを受け取ったのでシェアします。 件名: Cookie consent prevents platform access Hello, I cannot access use the store. The cookie consent notice keeps appearing and nothing happens once I approve or try to close it, so I’m unable to interact with the website. Please provide guidance on

By Takuya Matsuyama
万年ペーパーの自分が車の運転を楽しめるようになった理由

万年ペーパーの自分が車の運転を楽しめるようになった理由

どうもTAKUYAです。大学の入学前に免許を取って以来ずっとペーパードライバーで、都市生活では出来る限り運転は避ける生活を送っていた。事故を起こせば人を◯してしまう可能性もある代物を日常的に運転するなんて考えられなかった。 そんな自分に転機が訪れたのは、結婚して大阪に戻った事と、子供ができた事、そしてアウトドアに興味を持った事だ。大阪近辺だと箕面とか野勢、神戸、丹波篠山などが日帰りでドライブしやすい距離だ。それで、恐る恐るタイムズのカーシェアで時々ではあるが運転するようになった。 他の車も生きた人間が運転しているという驚き まず運転していて気づいたのは、他の車にも生きた人間が運転していると言う点だ。そんなのは当たり前だろと思うかもしれないが、結構新鮮な発見だった。Grand Theft Autoなどの現代をモチーフにしたゲームをプレイすれば分かるが、NPCの車の動きは鈍臭いのでガンガンぶつかる。プレイヤーの進行を予測した動きなどしないからだ。 しかし現実では相手も事故りたくないので、お互いに動きを読み合い、譲り合って運転する。ルードな運転手もたまにいるものの、どちらかがよっぽ

By Takuya Matsuyama
禅的思考: なぜInkdropはMarkdown独自拡張をしないのか

禅的思考: なぜInkdropはMarkdown独自拡張をしないのか

InkdropはMarkdownのノートアプリですが、Markdownの独自拡張は「絶対にやらない」と決めていて、それがアプリの哲学になっています。 Markdown(厳密にはGitHub-flavored Markdown)の強みは、ソフトウェア業界標準で広く使われてい緩い文書フォーマットという所です。 アプリの独自記法を加えてしまったら、あなたの書いたノートはたちまちそれらと互換性がなくなります。 「独自記法を加えた方が便利な機能が付けられるだろう」と思うかもしれません。もちろん実際Markdownは完璧な書式ではないため、必要な場面はいくつかあります。例えば画像のサイズ指定方法が定まっていない、など。それでも自分は、ノートの可搬性を第一にしてきました。その裏には禅にまつわる哲学があります。 日本の文化は周りの環境と対立するのではなく、溶け込もう、馴染ませよう、共生しようとする傾向があります。窓の借景、枯山水、建築の非対称性、茶室のシンプルさ、侘び寂びなどあらゆるところで見られます。 絵画における「減筆」の手法を例にとって説明します。 これは、描線を最小限に抑えながら絹や紙の

By Takuya Matsuyama