これは2024年12月頃にuapmdのGUIをWebViewで構築していて「難しいな…」と思っていた問題をまとめるつもりで中断してしまったものだけど、同じ問題にハマる人が出てくるなら何かしらの参考になるかもしれないので放流しておこうと思う。書きかけのネタをいろいろ棚卸ししたいし。
WebViewをプラグインあるいはホストのUIに利用したいが、UIリソースをプラグインのリソースの一部として独立して配布することで、特定のUIフレームワークに依存しない、特定のWebViewに依存しないかたちでUI機構を実現したい。が、現実にはまだまだ難しそうだ。
特定のWebView backendに依存してはならない?
現状、C++でMIT / BSD / Apache V2レベルのリベラルなライセンスで利用できるクロスプラットフォームWebViewの実装の選択肢はおよそ以下の3つになると思う(JUCE8などGPL/AGPLライセンスのような拘束性の強いものは含めていない):
(この他にNuiCpp/Nuiなどもあるが、バックエンドはwebview.hの修正版なのでwebview.hに含めることにした。)
この中でバックエンドにGtkWebKitの他にQtを選択できるのはsaucerのみだ。saucerはある程度のC++型に対するシリアライザを(glaze経由で)提供しているので便利だが、多少クセのある制約があり(たとえば固定長配列しかJS Arrayとしてシリアライズできない)、saucerに(特にその型システムの上に)決め打ちでWebView UI interopのビルディングブロックを高く積み上げていくのは不安があるし、他に実用的なバックエンドが出てきたら乗り換えられるようにしておきたい。
プラグインホストはプラグインと異なりGtk3/4(独自にイベントループを独占するGUIフレームワーク)を使用するGtkWebKitを直接利用してはならないということには必ずしもならないが、将来的にHost as a Pluginのような仕組みを提供する可能性があるなら、不必要に依存するのは回避したいところだ。
WebViewインターフェースの機能が足りない
まず、いわゆるプラグインUIをホストするWebViewとプラグインのWeb UI(HTMLコンテンツ上で動作するJavaScript)との間で、WebView実装に依存しないインターフェースを規定する必要があるが、この前提となる足並みを揃えられない。特に次の場面で問題になる(項目名は雑にでっち上げた):
- bind: ホスト側のコードにブラウザ上のJavaScriptコードから呼び出せる関数をバインドする仕組み。プラグインの持つ情報(パラメーターのメタ情報や値など)を取得するために用いられる。
- eval: ホスト側のコードからブラウザ上のJavaScript関数を呼び出す仕組み。プラグインの状態が更新されたときにUIに反映させる等の場面で用いられる。(原則としてJSのスレッドには非同期でdispatchされる)
- return: eval同様JSを実行して、その戻り値を返す(原理的にはbind + eval)
- serialize: C/C++の型をJSONにマッピングする仕組み。evalとreturnで引数と戻り値型を定義するために必要になる。
- resolve: バイナリストリームをブラウザからHTTP(S)あるいはカスタムURIスキームでアクセスできる仕組み。プラグインがもつ波形データなど大量のデータをJSON等にシリアライズせず効率よくUIに渡すために用いられる。
- ファイルベースのUIリソースをロードできるようにする仕組みを含む
- CORSの制限を受けずにリソースをロードできる必要がある
- loop: イベントループ機構(特にメッセージのディスパッチ)を前提とする。start/stop/post (message) の機能が必要。
- この他にwindowing systemとしてsetTitleやminimize/maximize/resize等も必要になる(プラグインフォーマットのAPIでも同様なので、あまり踏み込まない)
プラットフォーム ネイティブのAPIを使えば、これらは概ね実現できるが、細かい挙動の違いがある。現状ではクロスプラットフォームで利用できるAPIでも十分で、プラットフォームAPIに特化した機能が必要になるとまでは言えない状況だと思う。とはいえ、2024年の現状では、これらを実現する仕組みは、クロスプラットフォームのソリューションでも個別のAPIになってしまうことに変わりはない。以下はsaucer, choc, webview.hの比較になる:
| API | webview.h or Nui | saucer | Tracktion/choc |
|---|---|---|---|
| class | webview_t | saucer::smartview<T> | choc::ui::WebView |
| bind | webview_bind() | expose() | bind() |
| eval | webview_eval() | execute() | evaluateJavascript() |
| return | webview_return() | evaluate() | N/A |
| serialize | use JSON String | serializer + glaze backend | choc::value API |
| resolve | N/A | saucer::embedded_files | ”fetchResource” |
| - CORS | ? | works | N/A |
| loop | webview_run() | saucer::application | choc::ui::EventLoop |
表に当てはまらない挙動の違いもそれなりに重要で、たとえばsaucerのreturnに該当するsaucer::evaluate()は、JavaScriptを呼び出してから戻り値が返されるまで呼び出し元の実行をブロックすることになる。この点webview.hでは呼び出しはwebview_eval()だがその結果はwebview_return()がwebview.hから呼び出される形で返ってくるので、ブロックされることはない。JavaScriptはJavaScriptのスレッドで実行する必要があるので、C++コードから同期的に呼び出すことは本質的にできない。できるように見えるAPIがあるとしたら、それはJavaScriptエンジンのスレッドにメッセージを送って、結果が返ってくるまでブロックしているに過ぎない。UIスレッドからそんな呼び出しを実行していたら悲惨なことになりそうだ。
カスタムscheme / URL resolverがCORSに対応するには、WebView実装がこれに対応している必要があるが、Apple WebKitには問題があって、任意のURI schemeを安全なものとして登録するには_registerURLSchemeAsSecureというプライベートメソッドを使わなければならない。saucerはこれに対応している(つまりプライベートメソッドを直接呼び出している)が、chocは対応していない(webview.hは確認していない)。そのため、chocではHTTPサーバも自前で立ててHTTPリソースを解決する仕組みを使うことが想定されている。
chocやsaucerを使うとnon-blocking evalが無く、webview.hを使うとカスタムストリームレスポンスを利用できない。いずれも完全ではないが、それぞれ実装が不可能というほどの課題ではない。
これらを標準化したAPIが求められる。これらのうち、C/C++の型とJavaScriptの型をマッピングする部分を除いては、APIを共通化するのは難しくはない(争いの余地があまり無い)。
C/C++とJavaScriptの型システムの相互運用
前記のWebViewとのインターフェースが何らかのAPIで規定できたとして、プラグインやホストの開発者は、次はそのAPIを前提に、UIとロジック(あるいはモデル)の間でやり取りできる仕組みが必要になる。
関数呼び出しにおける引数は(WebView側から、JS側からのいずれも)、単一引数の文字列あるいはバイトストリームとして受け渡しするのであれば、JSON文字列にするのが最も単純な解決策で、これが不可能である(文字列の受け渡しができない)APIは無いだろう。WebViewホスト側がバイナリストリームを返すというものであれば、HTTPリクエストをカスタムscheme(あるいは任意のURLリクエストハンドラー)から取得するやり方も簡単だ。バイナリストリームをJSONとしてシリアライズする(stringとして渡し、必要があればエスケープする)よりも効率的だ。
引数は必ずしも単一である必要はないが、引数を配列型で渡すのか、サイズはどう渡すのか、std::vectorやstd::rangesを使うのか…などで選択の幅があり、選択肢によっては用途を歪める。たとえばプラグインフォーマットの一部としてWebViewを使う場合のインターフェースを規定したいとしたら、C ABIに基づいている必要があり、ここでC++型を採用するわけにはいかない。
JSON文字列でやり取りする以上の「強い型付け」を求めるなら、JavaScriptとC/C++の間でバインディング機構を利用する必要がある。
まず、原理的に、JavaScriptとWebViewの間でシリアライズ処理を介入させずに利用できる型が存在し、それがあれば利用するのか否かを検討する必要がある。これが利用できるのであれば、コーディングが容易になるだけでなく、パフォーマンス上のメリットも大きい。JavaScriptで利用できるネイティブ型であれば、複数のWebView実装で同様にサポートされている可能性がある。とはいえ、たとえばSharedArrayBufferのサポートなどが全プラットフォームでは期待できそうにないことは想像に難くない。MSはWebView2Feedbackを見る限り、割とわかっている様子だけど、Apple WebKitなんかでは、そんなものはサポートされていない。WebKit, WKUserContentControllerの場合はこうだ:
| JavaScript type | native type | |-|-|-| | bool | (NSNumber) | | number | NSNumber | | string | NSString | | Date? | NSDate | | Array | NSArray | | Object | NSDictionary (key = NSString, value = any of these) | | null | NSNull |
そんなわけで、ここで期待できるのは、せいぜいJSONのサブセットレベルの型システムの相互運用性だ。「サブセット」と書いたのは、JSONではObjectのkeyにstring以外のオブジェクトを指定することも出来るが、WebViewのJavaScript関数をバインドする機構ではそんな柔軟な型はサポートされていないことがあるためだ。たとえばApple WebKitでは(以下略
WebKitベースのバインディング機構は、Appleに限らずGtkWebKitでも同様の可能性が高い(WebKit.frameworkと同様 webkit_user_content_manager_register_script_message_handler_with_reply()のようなAPI構造になっていることまでは確認した)。
この方面で他に将来的に期待できるとしたら、WebViewからWIT (wasm interface types) に基づくバインディングが利用できる可能性がある。WITで定義されたWebView上のページで実装されたコードをネイティブから呼び出す、そしてWebページ上で呼び出せるネイティブのproxyを呼び出す、といった感じで使えればよい。addJavaScriptInterface()の代わりにaddWACInterface()を使う、という塩梅だ。ただ現状WebViewのAPIはそういう使い方ができるようにはなっていない。WebViewのJavaScriptインターフェースを介することでwasmコードにアクセスできるようになる可能性はあるが、それはJSインターフェースで表現できる範囲でのみWITの型を使える、ということになる(WITのほうが型定義が細かい)。
相互運用コードの対象と自動生成の可能性
次に「どのようなAPIバインディングが必要になるか」だが、これは同一アプリケーション間でやり取りが実現していれば足りる問題であり、プラグインフォーマットAPIのように標準化されている必要はない(アプリケーション内部の問題として好き放題できる)。Web UIにプラグインフォーマットの情報を反映させようとすると、プラグインフォーマットのAPIが提供するデータ構造の一部(全てではない)をJavaScriptにマッピングできるようになっている必要がある。
(プラグインフォーマットAPIの全てをJavaScriptで再定義することになると、プラグインフォーマットのAPIだけでも定義するのが大変なのに、APIの定義作業の負担が倍増してしまう。また、一般的にはプラグインフォーマットのAPIは拡張可能な機構に基づいているので、strongly-typedなJavaScript APIを規定するGUI拡張そのものが、他の拡張APIの変更を常に反映しなければならないことになってしまう。これは可能であれば避けたい。プラグインフォーマットAPIの一部をJavaScriptで再定義するとなると、「何を」その一部に含めるのか、という面倒な取捨選択が生じるので、これもたぶん避けたほうが良いやつだ。)
プラグインフォーマットAPIに対するABIの拘束されるバインディングではなく、プラグイン開発フレームワークに対するソースコードレベルでのバインディングで十分であり、かつそのほうが望ましいとなると、何らかのIDLを使い回すのが賢そうな気がする。候補としてはこんな感じだろうか(gRPCはうまく当てはまる気があまりしていない):
- WIT
- WebIDL
- GraphQL
- protobuf and gRPC (if applicable)
いずれも「C++のコード生成はできる」「JavaScriptのコード生成はできる」だと思うが、WebViewのinteropを前提としたコード生成にはなっていないと思う(概ね未確認):
| language | C++ binder | JavaScript binder |
|---|---|---|
| WIT | wit-bindgen | ComponentizeJS |
| WebIDL | 無さそうだがBlinkのアプローチは有用そう | webidl2jsなど |
| GraphQL | microsoft/cppgraphqlgen | GraphQL.jsまたはTypeGraphQL |
| protobuf | protobuf (チュートリアル) | protobufjsなど |
どちらかといえば、自分が使いたい基盤APIの上で動作するコードを自動生成するツールを自作したほうが早そうだ。それであれば、どのようなIDL構文を採用するにせよ、次のステップとしては、この上で利用できる型の限定などのポリシーを策定するべき、ということになる。
もう一つアイディアとして提示できるとしたら、Audio Plugins For Androidで実現している「MIDI 2.0 UMPに命令と引数をシリアライズして転送する方式」で、Android用の実装としては非同期の拡張機能の関数呼び出しをcorrelateできるコードも既に存在するが、今回はプラグインフォーマットのAPIではなくWebViewとJSの内部的なメッセージングなので、よほど期待値が高くなければ採用する理由も無いだろう。ただ実装はprotobufを組み込むよりは軽い。