WKWebViewの枠を超えて

macCatalystアプリケーション内でネイティブのAppKit WebViewを使用することはほぼ不可能です。この記事では、Craftチームがこの障壁を乗り越え、ユーザーによりよいホワイトボード編集体験を提供した方法を解説します。

執筆者
Craft Author: Peter Wiesner
Peter Wiesner
公開日
8/9/2024
Building Craft
WKWebViewの枠を超えて

iOSおよびMac向けのCraftは強力なネイティブアプリケーションです。アプリチームは高品質なネイティブ体験を迅速かつ効率よく生み出すことができるシニアエキスパートで構成されています。

しかし、ネイティブ体験が現時点で最善の解決策ではない場合もあります。機能が非常に複雑で、ネイティブ実装に多大な投資が必要であり、初期の投資回収見通しが明確でない場合です。

そのような稀なケースでは、既存のウェブ技術をWKWebViewの中で使用することで機能の将来性を検証します。その後、ユーザーが機能を気に入れば、ネイティブ体験に移行できます。

ホワイトボード機能はまさにそのようなケースで、iOSおよびMacアプリケーション内でキャンバス編集を早期に提供するために、tldrawという洗練されたウェブライブラリを使用することを決定しました。

image.pngimage.png

プラットフォームの狭間で身動きが取れないWKWebView

MacでのCraft編集体験の提供にはmacCatalystフレームワークを使用しています。このフレームワークに関する一般的な知見はこちらをご覧ください。

ホワイトボード(および以前のウェブ機能)の実装中に、macCatalystの組み込みWKWebViewに奇妙な問題が発生しました:

  • WebView内のインプット/フォーカス処理が2ステップのプロセスになっていた:WebViewが最初のレスポンダーになってから、インプットフィールドをクリックする必要がありました。これはユーザーが入力を開始するのに任意のインプットフィールドを2回クリックする必要があることを意味していました。WKWebViewにホバージェスチャーを追加して、ホバー時にWebページ上のHTML要素にフォーカスを当てることで解決しました。これでインプット要素への最初のクリックだけで入力開始が可能になりました。
  • テキスト領域または編集可能なdiv内のテキスト選択変更が正しく報告されていなかった:テキストを選択すると、WebViewはネイティブビューを作成して選択のハイライト表示を行い、WebViewのコンテンツの上に配置します。私たちのケースでは、これらのハイライトビューが実際にWebページで選択されているものと常に同期していませんでした。ネイティブ側でテキスト選択オーバーレイを無効にし、ウェブサイト側で正しく実装しました。
  • タッチパッドでのスクロール:タッチパッドでキャンバスをスクロールした際に、ドラッグジェスチャーを終了して離すと、キャンバスが即座に止まり、ネイティブのスクロールビューで慣れている減速がないことに気づきました。
  • 便利なショートカット:commandボタンを押しながらマウスでスクロールするといった基本的なショートカットが、WebViewのズームを全く機能させませんでした。
  • AppleがネイティブサイドからWebKitフレームワークへの2本指タップを右クリックとして送信することをやめた:そのため右クリックのコンテキストメニュー(エクスポートなど)が機能しなくなりました。これはmacOS 14.0と14.2の間のマイナーOSリリースで起きました。WebKit開発者が問題に気づいてそれについて書いたメーリングリストから学びました。

iOSとAppkitのWKWebViewではこのような問題は見られませんでした。そのため自然に、Mac上での現在のソリューションをmacCatalystの実装から離れ、AppkitのWKWebViewに向けて移行しようとしました。

実行時の混乱

macCatalystには、実行時にロードされるMacのみのプラグインバンドルを通じて、Appkitのラッパーフレームワークのコンポーネントにアクセスできるトリックがあります。

AppkitのWKWebViewを作成する最初の試みは、このトリックを使ってバンドル内に作成することでした。多くの試行錯誤の末、Objective-Cランタイムの制限/セキュリティの問題からこのアイデアを断念しました。

Objective-Cクラス(WKWebViewなど)をコードから初期化するとき、ランタイムはクラス名に基づいてメタデータとアロケーション/初期化メソッドを検索します。iOSのWKWebViewクラスの名前はmacOSのものと同じ名前なのです。

#if TARGET_OS_IPHONE WK_EXTERN API_AVAILABLE(macos(10.10), ios(8.0))
@interface WKWebView : UIView
#else WK_EXTERN API_AVAILABLE(macos(10.10), ios(8.0))
@interface WKWebView : NSView
#endif

iOSのWKWebViewクラスはコードが実行される前にシステムによってロードされるため、そのメタデータが「WKWebView」という名前のスポットを占有します。セキュリティ上の理由から、後から開発者が手動でアンロードすることはできません。

AppKitバンドルでWKWebView(frame:, configuration:)と書くと、iOSのWebViewが作成され、NSViewヒエラルキーに追加しようとするとクラッシュしました。

これは、macCatalystアプリケーション内でこのようにMacフレームワークを使用する際に注意すべき理由でもあります。Macフレームワークにはxib/nibにWebViewがエンコードされていて、ロード時にクラッシュする可能性があります(iOSのWebViewにはMacの永続プロパティが存在しないため)。

この道は行き詰まりのようです。では……

ランタイムがCraftのもとに来ないなら、CraftがランタイムのもとへGO

コンパニオンアプリの登場

ランタイムにどのWebViewクラスをいつ登録するかをコントロールするために、独立した純粋なAppkitコンパニオンアプリを作成することを決定しました。このコンパニオンアプリは非常にシンプルで、AppkitのWKWebViewを管理するだけです。

Macアプリでホワイトボードをクリックすると:

  1. メインアプリがこのホワイトボードウィンドウのプロキシオブジェクトをメインアプリ内に作成します。これがコンパニオンアプリからの実際のAppkit WKWebViewウィンドウを表します。
  2. Craftアプリがコンパニオンアプリ(CraftWhiteboard)を起動して実行完了を確認します。その後、両アプリが互いに向けたプロセス間通信ブリッジを構築します。同じApp Group内に存在するため、他のアプリは接続できません。
    • メインアプリはWebURLロードリクエスト、システム設定の変更(ライト/ダークモードの優先設定など)、終了リクエストを通信します。
    • コンパニオンアプリはWKWebViewのメッセージハンドラーとそのプロセス状態を通信します。
  3. CraftアプリがIDとともにホワイトボードデータをコンパニオンに送信し、プロキシとWebViewウィンドウでこのホワイトボードセッションを識別します。
  4. コンパニオンがリクエストを登録し、ホワイトボードのウィンドウを作成します。
  5. ユーザーがホワイトボードを編集し、通信が必要な場合はプロキシがJSONメッセージで表示されているAppkit WebViewウィンドウにメッセージを送信できます。
  6. ユーザーがウィンドウを閉じると、コンパニオンアプリが閉じることを報告し、ウィンドウとプロキシの両方が削除されます。
  7. ユーザーがDockからまたはCMD+Qでアプリを閉じると、メインアプリがコンパニオンアプリに終了コマンドを送信します。

ホワイトボードをタップしたときに別アプリが開いてDockで別のCraftロゴと一緒にバウンスするのは、非常に奇妙で混乱を招く体験になるでしょう。

幸いなことに、Dockやアプリスイッチャーにアイコンなしで存在できるアプリエージェントという形式があります。アプリを開いても、ユーザーにはウィンドウしか見えません。

<key>LSUIElement</key>
<true/>

コンパニオンアプリのInfo.plistの関連部分

元々このオプションはデーモンとトップメニューアプリケーション用でした。これらのアプリはウィンドウも管理できるため、通常のアプリケーションの体験を拡張するために使用できます。

豆知識:Quicklookフレームワークもこの技術を使用しており、プレビュー用に開かれたファイルはセキュリティ上の理由から別のプロセスでロードされ、プレビューがメインアプリのホストビューに映し出されます。

過剰なコミュニケーションが鍵

2つのアプリケーションが連携する際、同期を保つことが重要です。また、最後のホワイトボードウィンドウを閉じた後もコンパニオンアプリをメインアプリと一緒に起動したままにして、終了しません。これにより将来のホワイトボードをより速く起動できます。アプリが終了するときにはコンパニオンアプリも終了します。

クラッシュなど、コンパニオンアプリがメインアプリより長く生き残るケースもあり得ますが、次のホワイトボード起動時には再接続できるようにしています。

2つのアプリの同期を保つために:

  • コンパニオンアプリがメインアプリにプロセスの開始/終了メッセージを送信します。
  • 開始メッセージにはプロセスIDも含まれ、メインアプリがアプリを起動する必要があるかどうか、または再接続が必要かどうかを確認できます。
  • メインアプリがコンパニオンアプリに「ウィンドウを閉じる」または「終了」コマンドを送信し、メインアプリと並んで終了させます(App Storeガイドラインへの準拠にも必要です)。
ccefeb3d-bfee-4e9a-8234-499e6266dc2f.png

Appkitへの最初の一歩

幸いなことに、上記のアプローチですべてのWKWebViewの問題が解決できました。純粋なAppkitアプリケーションを扱うのは今回が初めてだったため、そのプロセスで多くのことを学びました:

  • NSWindowが少なくとも1つ必要:必要なエージェントアプリになるよう通常のMacアプリを修正しましたが、このことは少なくとも1つのウィンドウがアプリで生きている必要がありました。最後のウィンドウを閉じると、コンパニオンアプリがクラッシュしました。最後に閉じたウィンドウへの強参照を常に持つことで解決しました。
  • 初期Storyboardを保持しておくのが有用:私たちはすべてをコードで構築する傾向があり、コンパニオンアプリは非常にシンプルなビューヒエラルキー(フルスクリーンWebView)を持っていたため、初期のMain.storyboardを削除し、UIApplicationとルートビューコントローラーを手動で作成すれば容量の節約になると思いました。Storyboardを使ったデフォルトのアプリセットアップが、アプリケーションの基本的なキーコマンドメニュー(トップメニューバーに表示されない場合でも)を構築する役割を担っていることが分かりました。この変更により、WebView内でのデフォルトのCMD+C/V/Xサポートが失われました。最終的にこのファイルを元に戻しました。
  • 「スペース」キーはWKWebViewコンテンツのスクリーン高さ分ジャンプするために予約されている:リリース前の社内フィードバックで、スペース+マウスの組み合わせでホワイトボードをグラブして移動しようとすると、奇妙なエラー音が聞こえるという報告がありました。デフォルトでスペースキーはWkWebViewのコンテンツをビューの高さ分ジャンプするために予約されていることが分かりました。キャンバスはすでにフルハイトだったため、スクロールできる次のページがなく、システムがエラー音を鳴らしました。インジェクトされたスクリプトでJavaScript側からこのデフォルト動作を無効化する必要がありました。
  • ウィンドウのドラッグ:ホワイトボードウィンドウのデフォルトタイトル領域を無効にしてWebViewをフルスクリーンにしました。これによりWebViewがウィンドウのドラッグイベントを奪ってしまいました。WebViewのhitTestメソッドをオーバーライドして、トップのドラッグ領域を無視させることで解決しました。
  • WKWebViewのファイル選択ダイアログ:AppkitのWKWebViewでは、Webページがファイルをインポートしたいときにユーザーにファイルダイアログを表示できるよう、Mac専用のデリゲートメソッドの実装が必要です。私たちのケースでは、ユーザーがホワイトボードに画像や動画を追加しようとするときにこれが発生します。iOS/macCatalystでは自動的に処理されます。これに対応するために、以下のメソッドを実装する必要がありました:
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { //... }

次のホワイトボードでお会いしましょう!

Screenshot 2024-07-25 at 14.09.00.pngこのアプローチにより、ユーザーにフルネイティブなAppkit WebView体験を提供できました。最初に挙げたWKWebViewの問題を修正するだけでなく、このソリューションは以下の点でも優れています:

  • ホワイトボード編集に独立したスペースを与え、ユーザーがホワイトボード編集に集中できるようにします。
  • ドキュメントを新しいタブで開くことなく、複数のホワイトボードを同時に開けるようにします。
  • CraftアプリとWhiteboardアプリのパフォーマンスを分離し、互いの実行に影響を与えません。また、それぞれ独自の割り当てメモリを持ちます。

今回共有したかったCraftのハッカリーはここまでです。ぜひホワイトボードを試してみてください。質問があればpeter@craft.doまでご連絡ください!

ブログをもっと読む