🔖

クロスプラットフォームアプリ開発の技術を比較する

に公開
1

この記事では筆者がネイティブアプリ開発に際して、昨今のクロスプラットフォーム開発について調べた結果をまとめ、共有していこうと思います。

具体的には、以下のように4つの観点をベースにざっくりパターン分けしたものになります。

  • 観点1:プラットフォーム
    • Windows
    • Darwin
    • Linux
  • 観点2:コンパイル方式
    • AOTコンパイル
      • ネイティブコード
      • バイトコード
      • WASM
    • JITコンパイル
    • インタプリタ(コンパイルしない)
  • 観点3:描画方式
    • ネイティブコンポーネント利用
    • グラフィックライブラリによる独自実装
    • グラフィックライブラリ直接利用
    • Webviewによるレンダリング
  • 観点4:相互運用方式
    • メッセージ通信
    • メモリ参照
    • 言語機能利用

皆様に最適な組み合わせを見つけたり、技術選定をする参考としていただければと思います。

プラットフォーム比較

通常にアプリ開発をしていて遭遇するプラットフォームは以下の3種類に分類されると思います。

  • Windows
  • Darwin(macOS、iOS、watchOS...etc)
  • Linux(Android含む)

この中で、大抵障壁になるのがDarwin系(要はApple)です。

Appleはセキュリティポリシーが厳しく、独自のサンドボックス環境内でアプリを動かすには(基本的に)entitlementを付与してApp Storeから認証を受ける必要があります。

(基本的に)の詳細は、Macの場合は許可すれば野良アプリを動かすことができますが、iOSやiPadの場合はジェイルブレイクと呼ばれるライセンス契約に違反する形でしか実行できないことがあります。

また、もう一つ重要点として、Darwin系は(基本的に)JITコンパイルを許可していません。

これについては後述します。

コンパイル方式の比較

クロスプラットフォームアプリケーションのコンパイル方式として、以下の3つがよく挙げられます。

  • AOT
  • インタプリタ方式
  • JIT

AOT(Ahead-Of-Time)

AOTというのは事前コンパイル、つまり従来的なコンパイルのことです。

ただ注意したいのは、AOTは事前コンパイルを行うだけで、コンパイル先がネイティブコードだったり、バイトコードだったり、WASMだったりします。

下記3つ以外にもコンパイル先はありますが、何にコンパイルしているかはしっかり理解しておいた方が良いです。

ネイティブコード

ネイティブコードとは機械語のコードのことで、C++やRustで書いたコードはクロスプラットフォームにネイティブコードに変換できます。

AOTコンパイルでネイティブコードを動かすのが一番速いです。

その点、RustやC++はどのプラットフォームにもAOTコンパイルできるメリットがあります。

バイトコード

バイトコードとは、バイナリ形式ではあるものの機械語ではないコードを指します。

代表的なのはJavaやC#で、ソースをバイトコード(IL)に変換し、バイトコードのランタイム(VM)を別途用意します。

LLVMはVMの一種ですが、LLVMのバイトコードはLLVMがネイティブコードにコンパイルできるので、ソースコード → バイトコード → ネイティブコードへのAOTコンパイルと言えます。

.NETやRust、SwiftなどはLLVMベースです。

また、React NativeはHermesというJavaScriptのAOTコンパイラによってバイトコードを生成し、HermesのVMを同梱することで高速化をしています。

WASM

WASM(WebAssembly)はバイナリ形式のコードで、ある意味でバイトコードと同様に、様々なプラットフォームで動くようにランタイム(例えば、ブラウザやWasmer)が作られています。

あらゆるシステムインターフェースへの対応も進んでいます。(WASI)

将来性という観点では成長著しく、最近発表されたHyperlight Wasmなども考えるとこれからも追っていきたい技術になりますが、サクッと開発を始めたい場合は不向きでしょう。

インタプリタ方式

インタプリタ方式はAOTの逆で、ネイティブコードにコンパイルをせずにコードを実行します。

ですので、必ずランタイムが必要です。

バイトコードの実行も厳密にはインタプリタ方式ですが、ソースコードを直接実行しない分、高速になっています。

ソースコードのインタプリタは、ネイティブコードの実行に比べると実行時に詳細なエラーを出せたり、デバッグがしやすい点が利点です。

一方で、インタプリタ方式は基本的に遅いので、後述するJITという方式に繋がっていきます。

JIT(Just-In-Time)

JITは従来のAOTと違い、コードを実行時にコンパイルをすることを指します。(AOTという言葉は、JITが出始めてからできたのではないかと思います)

高速起動でデバッグがしやすい点はインタプリタ方式と同様で、実行時にコンパイルを行い結果をキャッシュすることで実行自体も高速化されます。

node.jsやブラウザの実行エンジンであるV8はJIT方式技術の代表ですが、本当に信じられないくらい速いです。

開発時はJIT、プロダクションリリース時はAOTというやり方が取られる場合もあります。

いいことづくめに思えるJITの唯一の弱点は、Appleです。それを最後に説明します。

AppleとJIT

一部の例外を除いて、Appleのコンピュータ上ではJITが使えません。

例外とは、Safariか、macOS上での埋め込みのWKWebViewの場合は、JITが許可されていることです。

つまり、iOS上でJITを動作させる場合は、WebアプリをSafariで開くしかありません。

Electronでアプリを作った場合はJITなし、Tauriの場合はmacOSの場合のみJITありです。

バイトコードの実行であれば、事前にネイティブコードにコンパイルしたランタイムを同梱すればいいので可能です。

当然インタプリタ方式も、事前にコンパイルしたインタプリタを同梱すればいいので可能です。

描画方式の比較

次にプラットフォーム毎に差が出やすく、ネイティブアプリの開発時にも中心となる描画方式の比較をします。

ネイティブコンポーネント

クロスプラットフォームUI実装で多いのはネイティブコンポーネントを利用する方式です。

ネイティブコンポーネントとは、各プラットフォームで事前に用意されているUI部品のことです。

React Nativeで、ネイティブコンポーネントをプラットフォーム毎に調整して、最終的に同じ見た目になるように調整しています。

ただ実際のところ、ピクセル単位で差が出たり、多少は差が出ることを許容しなくてはいけないことがあるのが欠点です。

独自実装UI

この方式は、OpenGLなどのグラフィックライブラリを使って、一からUIを作るやり方です。

Flutterはこの方式で、プラットフォーム毎に差が出ず、ピクセルパーフェクトと言われています。

Flutter Webの場合はWebGLを使っているので、F12で開くと普通のHTMLのようにDOM構造が見られません。

この方式のメリットは、ネイティブコンポーネントの再利用に見られるようなピクセルのズレなどが発生しないことです。

つまり、プラットフォーム固有の機能以外は、いずれかのプラットフォームで確認できていれば、理論上は差が出ないことになります。

グラフィックライブラリ

グラフィックライブラリを直接使う方法もあります。

AppleだとMetal、それ以外はVulkan、Webの場合はWebGLが安定の選択肢と言えるでしょう。(OpenGLはもう仕様が更新されず、WebGLも同様なので、次世代のWebGPUを使うのも手です)

あるいはwgpuのような抽象化を用いるのも手です。

クロスプラットフォームのゲーム開発だとRustのBevyはwgpuを使って実現しています。

こちらもFlutter同様にピクセルパーフェクトと言えます。

Webviewによるレンダリング

忘れてはいけないのがWebviewによるレンダリングです。

こちらも独自実装UIと同様に環境差異は少なく、Webの知識と安定性を再利用できます。

WebGLやWebGPUが使えるのも強みです。

ただし、思うようなパフォーマンスが出ない場合もあります。

相互運用方式の比較

相互運用とは、言語間での連携と言えます。

クロスプラットフォームの界隈では、主に非ネイティブな言語とネイティブな言語を連携指せることを指します。

例えば、RustやC++等のネイティブコードにAOTコンパイルできる言語で作ったモジュールを、JavaScriptなどのネイティブにAOTコンパイルできない言語から呼び出せるようにすることを主に指すでしょう。

なぜわざわざ相互運用をするかというと、JavaScriptやTypeScriptを使いたいからという理由に尽きます。

そうでなければ、すべてネイティブコードにAOTコンパイルすればいいので、考える必要はありません。

ネイティブコードにコンパイルすることで、高速化やネイティブな機能のインターフェースの利用、コンパイルコストの低下という利益があります。

iOSのようなJIT不可の環境では、よりネイティブなコードの割合を高くパッケージ化できる方が嬉しいです。

一方で、相互運用の方式によってはオーバーヘッドが大きく、同様の機能のWebブラウザでの実装よりも速度が遅くなるケースがあると言えるでしょう。

React Nativeの旧方式

過去のReact Nativeは、ネイティブなモジュールとJSONで通信するというなんとも言えない方式を採っていました。

これを非同期ブリッジ(Asynchronous-bridge)と呼ぶようです。

バッチ処理化をし、プロトコルも最適化していたようですが、やはり遅く、制限も多かったようです。

この問題を解決するために、React NativeはJSIという仕組みを導入しました。

React Nativeの新方式

React Nativeは、C++と高速に相互運用するための仕組みとして、JSI(JavaScript/Native Interface)を作りました。

JSIでは、JavaScriptとC++(JSのエンジン側)がメモリ参照を共有し、JavaScriptからC++を呼んだり、C++からJavaScriptを呼べます。

つまり、JSONでは不可能な関数渡しや同期処理ができるということです。

関数やクラス自体はグローバルスコープに登録されますが、そこはブラウザのWeb APIと同じです。

また、Lazy Loadで必要な分しか読み込まないため、バンドルサイズも小さいようです。

メモリ参照を共有するので、関数を引数として渡すことも可能です。

さらに、TurboModulesとCodegenという仕組みを使うと、TypeScript/Flowの定義からインターフェースが自動生成され、インターフェースの実装をC++で行えばあっという間に相互運用が完了するようです。あまりにも便利です。

さらに、Java/Objective-C/Swiftもこの仕組みで呼び出せるようです。

仕組み的には、JavaとはJNI、Objective-C/SwiftとはObjective-C Bridgeを使うことでC++と連携し、JSとC++はJSIで連携するようです。おえ🤮

Flutterの方式2つ

Flutterのネイティブモジュールとの相互運用には2つ方式があります。

1つは意外にも原始的でメッセージチャンネルを通した通信です。これはReact Nativeの旧方式に近くオーバーヘッドがあります。

もう1つはFFI(Foreign Function Interface)です。

こちらはJSIに似て、Cを直接バインドできるため、オーバーヘッドが少ないです。

メッセージ通信かメモリ参照

以上の事から、ネイティブモジュールとの相互運用は、メッセージ通信あるいはメモリ参照を利用するパターンがあるようです。

当然、メモリ参照の方が高速で、取っつきやすさとしてはReact NativeのTurboModules+JSI+Codegenなのかなと思います。

WebviewやRustの場合

ElectronやTauriを利用する場合は、単にメインプロセスを担当するnodejsやRustが他の言語との相互運用を担当するため、言語による実装に依存します。

RustやC++をフルにネイティブコードにコンパイルして作る場合も同様ですが、相互運用する意味はあまりないかもしれません。

追記:

ただし、メインプロセスとのIPC(プロセス間通信)はメッセージ通信ですので、高速とは言えません。

WebAPIとしてサポートされているネイティブ機能へのアクセスは高速と言えます。

さいごに

さいごにいくつかの技術をピックアップして、パターンの組み合わせを割り当てたいと思います。

  • React Native
    • モバイル
      • デスクトップ向けのライブラリもあり
    • AOT(バイトコード)
    • ネイティブコンポーネント利用
      • expo-glでGL描画も可
    • JSIを基礎にした高速相互参照
  • Flutter
    • 全プラットフォーム
    • AOT(ネイティブコード)
    • impellerを利用した独自UI
    • FFIによる高速相互参照か、メッセージチャネルによる通信
  • Electron
    • デスクトップのみ
    • Darwin上ではインタプリタ、他はJIT
    • Webviewで描画
    • nodejsの相互運用機能に依存
    • Tauriに比べるとバンドルサイズが大きい
  • Tauri
    • 全プラットフォーム
    • macOS以外のDarwin上ではインタプリタ、他はJIT
    • Webviewで描画
    • 相互運用はRust依存
  • Dioxus
    • 全プラットフォーム
    • macOS以外のDarwin上ではインタプリタ、他はJIT
    • Webviewで描画、一部実験的にWGPUとSkiaで独自実装
    • 基本的に相互運用不要(Rust依存)
  • egui
    • 全プラットフォーム
    • AOT(ネイティブコード)
    • WGPUを利用した独自UI
    • 基本的に相互運用不要(Rust依存)
  • wgpu
    • 全プラットフォーム
    • AOT(ネイティブコード)
    • WebGPU風のAPIでグラフィックライブラリを抽象化
    • 基本的に相互運用不要(Rust依存)
      • 他言語へのバインドもある
  • beby
    • 全プラットフォーム
    • AOT(ネイティブコード)
    • wgpuベース、ゲーム開発向き
    • 基本的に相互運用不要(Rust依存)
  • Web
    • 全プラットフォーム(ブラウザのある環境のみ)
    • JIT
    • HTMLとWebGL(WebGPU)を組み合わせることができる
    • WASMのコンパイラがあれば、何の言語でも組み合わせ可能
    • セキュアなサンドボックス環境でデフォルトでWASMもJITできる反面、利用できるストレージやネイティブ機能制限があり、サーバーも必要

あくまでこれは一部ですので、この記事に書いた内容を元に調べれば無数に選択肢は拡がることが分かると思います。

個人的には、

  • アプリ開発をしたいのかゲーム開発をしたいのか
  • アプリ開発の場合、プラットフォーム固有の機能をどこまでサポートしているか
  • 慣れ親しんだ言語が使えるか
  • Webエンジニアなら、Web資産を再活用できるか

などが選定における重要な観点になると思います。

その上で、是非最適な選択肢を確信を持って選んでいただければと思います。

Discussion