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

ノート駆動AIコーディング術の提案

ノート駆動AIコーディング術の提案

どうもTAKUYAです。みなさんはAIエージェントを普段のコーディングで活用されていますか。ちょっと面白いワークフローを思いついたのでシェアします。それは、ノート駆動のエージェンティック・コーディング・ワークフローです。最近Claude Codeのプランモードを使っていたら、ターミナル内で生成されたプランを読むのが辛かったんです。それで、じゃあMarkdownノートアプリであるInkdropをプランの保存先バックエンドとして使えば解決するんじゃないかと思って、 試してみました。こちらがそのデモです(英語): こちらがClaude Codeの設定ファイル群です: GitHub - inkdropapp/note-driven-agentic-coding-workflow at devas.lifeComplete Claude Code configuration collection - agents, skills, hooks, commands, rules, MCPs. Battle-tested configs from an Anthropic hackathon w

By Takuya Matsuyama
2025年個人開発活動の振り返り

2025年個人開発活動の振り返り

どうもTAKUYAです。もう1月も半ばに差し掛かっているけど、2025年の自分の活動の振り返りをしたい。去年を一言で言うなら、本厄を満喫した年だった。 厄年とは、人生の節目にあたって、体調不良や災難が起こりやすいと経験的に言われる年齢のこと。数え年で42歳、確かにもう若さに任せた事は出来ないなと痛感した年だった。(ところであなたの国ではこのような年はありますか?) 夏に体調を崩して2~3ヶ月動けなくなった 暖かくなり花粉が飛び出した頃に、持病のアトピーが悪化しだして、まともに生活出来なくなってしまった。酷さで言うと、2019年に脱ステした時と同じぐらい。 脱ステに無事成功したから、この地獄は二度と味わうことはないだろうと高を括っていたが、まさか7年後にまた味わうとは思わなかった。当時の独身時代と違い、妻も子供もいる中で、周りに多大な迷惑をかける事となった。夏の子供との思い出が全く無い。悲しい。 現在はQoLもほとんど元の状態まで復活できた。写真を撮って症状の変化を記録したので、機会があればシェアしたい。食事療法など色々試したが、結局歩くのが一番自分に効いた。それ以来、一日一万歩

By Takuya Matsuyama
書いて、歩け!なぜノートアプリはシンプルで充分なのか

書いて、歩け!なぜノートアプリはシンプルで充分なのか

どうもTAKUYAです。今回はノートやメモから新しい発想を生むための考え方についてシェアします。 自分はシンプルさをウリにした開発者向けのMarkdownアプリInkdropを作っています。なので、どうしても「ノートアプリの作者」としてのポジショントークが含まれてしまいますが、逆に言えば、「ノートアプリを約10年間作り続けてきた人間が、どうやってアイデアを生み出しているのか」 という実際的な体験談として読んでもらえれば幸いです。 結論から言うと、僕は「アプリ上でノート同士を連携させる必要はない。繋げるのはあなたの脳だ」と考えています。本稿では、ノートアプリの機能に溺れずユニークなアイデアを考え出すために僕が実践している事をシェアします。 TL;DR * ノート整理に時間をかけるな。グループ化で充分だ * すごい人はアイデアが「降りてくる」のを待つ * プログラミング × 料理動画 という有機的な掛け合わせ * ノートは「忘れる」ために書く * 歩け! ノート整理に時間をかけるな。グループ化で充分だ 巷ではZettelkastenなどが流行っているようですね。これ

By Takuya Matsuyama
貫禄を捨てて愛嬌で生き延びろ!40代オッサンの生存戦略

貫禄を捨てて愛嬌で生き延びろ!40代オッサンの生存戦略

どうもTAKUYAです。 つい先週(11月19日)に誕生日を迎え、41歳になりました。40代と言うのは若い頃には想像もしなかった年代で、どう生きれば良いのかというイメージがあまり具体的に湧かない、曖昧な年齢ではないでしょうか?自分の父親を想像するも、日中はいつも仕事でいなかったのであまり参考になりません。 自分は個人開発で生計を立てていて20代、30代で積み上げて来たものが上手く実を結んだおかげで今の生活があります。育児にも、いわゆるサラリーマンよりかは柔軟に参加できていて、子供との時間も沢山取れています。ママ友も出来ました(迷惑かけっぱなしですが)。 本記事では、そんなライフスタイルを送る自分が40代で大事にしたいことについて書きたいと思います。タイトルにもある通り、結論から言うとそれは「愛嬌」だと思います。以下、中年男性の愛嬌の重要性について説明します。 TL;DR * 「貫禄が出てきたね」と言われたら注意 * 笑顔を作れ。オッサンがムスッとしてたら普通に怖い * 謙虚に振る舞え。実績を積むと周りが萎縮する * ギャップ萌えを活用しろ 「貫禄が出てきたね」と言わ

By Takuya Matsuyama