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

Inkdrop v6 Canary版リリースしました — 新Markdownエディタやその他新機能盛り沢山

Inkdrop v6 Canary版リリースしました — 新Markdownエディタやその他新機能盛り沢山

Inkdrop v6.0.0 Canary版リリースしました — 新Markdownエディタやその他新機能盛り沢山 こんにちはTAKUYAです。 v6.0.0 の最初の Canary バージョンをリリースしました 😆✨ v6では、アプリのコア機能の改善がたくさん盛り込まれています! * リリースノート(英語): https://forum.inkdrop.app/t/inkdrop-desktop-v6-0-0-canary-1/5339 CodeMirror 6 ベースの新しいエディタ フローティングツールバー v5ではツールバーがエディタの上部に固定されており、使っていないときもスペースを占有していました。 v6では、テキストを選択したときだけ表示されるフローティングツールバーに変わりました。 GitHub Alerts 構文のサポート Alerts の構文が正しい色と左ボーダーでハイライトされるようになりました。 ネストされたアラートや引用にも対応しています。 また、アラートタイプの入力を支援する補完機能も追加されました。 スラッシュコマンド 空行で /

By Takuya Matsuyama
AIのお陰で最近辛かった個人開発がまた楽しくなった

AIのお陰で最近辛かった個人開発がまた楽しくなった

AIのお陰で最近辛かった個人開発がまた楽しくなった こんにちは、TAKUYAです。日本語ではお久しぶりです。僕はInkdropというプレーンテキストのMarkdownノートアプリを、デスクトップとモバイル向けにマルチプラットフォームで提供するSaaSとして、かれこれ9年にわたり開発運営しています。 最近、その開発にClaude Codeを導入しました。エージェンティックコーディングを可能にするCLIのAIツールです。 最初の試行は失敗に終わったものの、徐々に自分のワークフローに馴染ませることができました。そして先日、アプリ開発がまた「楽しい」と感じられるようになったのです。これは予想外でした。 本稿では、自分がエージェンティック・コーディングをワークフローに取り入れた方法と、それが個人開発への視点をどう変えたかを共有します。 * 翻訳元記事(英語): Agentic coding made programming fun again 自分のアプリに技術的負債が山ほどあった ご想像のとおり、9年も続くサービスをメンテするのは本当に大変です。 初期の頃は新機能の追加も簡単で

By Takuya Matsuyama
個人開発を7年以上続けて分かった技術選択のコツ

個人開発を7年以上続けて分かった技術選択のコツ

個人開発を7年以上続けて分かった技術選択のコツ InkdropというMarkdownノートアプリを作り続けて7年になる。 お陰さまでその売上でずっと生活できている。 これまで個人開発でどう継続していくかについて「ユーザの退会理由をあれこれ考えない」とか「アプリの売上目標を立てるのをやめました」とか、ビジネス面あるいはメンタル面からいろいろ書いてきた。 今回は、技術面にフォーカスして、どう継続して開発していくかについてシェアしたい。 TL;DR * 最初はとにかく最速でリリースする事を最優先する * 迷ったら「ときめく方」を選べ * 程よいところで切り上げて開発を進める * 使っているモジュールがdeprecatedされるなんてザラだと覚悟する * 古いから悪いとは限らない * シンプルにしていく * 老舗から継続の秘訣を学ぶ * 運ゲー要素は排除しきれない 最初はとにかく最速でリリースする事を目標に技術選定する 開発計画とビジネス計画は切っても切り離せない。 コーディングに傾倒するあまり完璧主義に陥って結局リリース出来ないまま頓挫してしまう個人開発者は多い

By Takuya Matsuyama
子育て中の個人開発者の一日

子育て中の個人開発者の一日

子育て中の個人開発者の一日 どうもTAKUYAです。 久しぶりに生活まわりの事を書きたい。自分はInkdropというMarkdownノートアプリを売って生きている。 子供も無事順調に成長しており、あと数ヶ月で3歳になるというところで、イヤイヤ期もやっと終わりが見えてきた。 生活パターンもなんとなく定着しつつあるので、ここで一旦どんなルーティンなのか書き出してみる。ちなみに当方今年で40歳。 平日の1日の流れ * 06:30 妻と子供起床、朝食 * 07:10–30 俺起床、朝食 * 07:40 布団を畳んで子供を着替えさせる。妻はその間に化粧や通勤の準備 * 08:00 ストレッチと軽い筋トレ(腕立て50回、スクワット100回) * 08:10 妻と子供を見送る。15分前後瞑想 * 08:30 散歩 * 09:00 作業開始(カフェまたは家) * 11:00 昼飯 * 12:00 ダラダラする * 12:30 作業再開(だいたい家)

By Takuya Matsuyama