😀

WKWebViewを活用したSwiftとJavaScript連携の実装について

2024/12/03に公開

これは株式会社TimeTree Advent Calendar 2024の3日目の記事です。

https://qiita.com/advent-calendar/2024/timetree
こんにちは、TimeTreeでiOSアプリ開発をしていますNeganです。

はじめに

最近のリリースで、TimeTreeのiOSアプリに新しい画面が追加されました。この画面では、公開カレンダーのお得なイベントを簡単に見つけることができます。


おトクなイベント

当初、この画面を完全にネイティブで実装することがチーム内で検討されましたが、工数的なインパクトとリソースの効率化に加え、Webページの方が画面デザインの柔軟な変更が可能である点も考慮し、フロントエンドチームが開発したWebページをWKWebViewを通じて利用する方法をチームで選択しました。これにより、開発工数を大幅に削減しつつ、素早いUIの変更や調整が可能となりました。

しかし、単にWebページをWKWebViewで表示するだけでは仕様を満たすことはできません。Webページ上の操作をトリガーにネイティブアプリの機能・ロジックを呼び出したり、ネイティブアプリ側の情報をWebページに渡して動的に画面を切り替えたりするなど、双方向の連携が必要になります。つまり、SwiftとJavaScript間での通信を行う仕組みを構築する必要がありました。

この記事では、このWKWebViewを利用したネイティブアプリ(Swift)とWeb(JavaScript)間の連携方法について、サンプルコードを交えながら詳しく見ていきたいと思います。

サンプルコード例

フォロー状態のトグルボタンを題材にしたサンプルコードを交えて流れを見ていきましょう。
WKWebView側はローカルに立ち上げたWebページ(React App) のURL(http://localhost:3000/)を読み込みます。

画面は以下の通りです。

未フォロー状態 フォロー状態

フォロートグルボタンを押下すると、フォロー状態・アンフォロー状態を切り替えることができます。

サンプルコードの詳細は下記をご参照ください。
https://github.com/misyobun/advent-calendar-2024/

SwiftとJavaScriptの連携の仕組み

SwiftとJavaScriptを連携させるためには、WKWebViewを介した双方向通信の仕組みを構築する必要があります。基本的な流れとしては以下の2つがあります。

  1. Swift → JavaScript
    Swift側からJavaScriptの関数を呼び出し、Webページの動作を操作する方法です。今回のサンプルコードでは、フォロー完了時/フォロー失敗にPromiseを用いて非同期にJavaScriptへ通知を送る場面が該当します。

  2. JavaScript → Swift
    JavaScript側からネイティブコード(Swift)にメッセージを送り、処理を依頼する方法です。この記事の例では、ユーザーがフォロー/フォロー解除ボタンを押した際に、JavaScriptがSwiftにフォロー/フォロー解除のメッセージを送ります。

この双方向通信を円滑に行うには、WKUserContentController を中心とした仕組みを正しく理解し、実装する必要があります。それではSwift側のコードを順に見ていきましょう。

WKWebViewの初期設定

まずは、WKWebViewの基本的なセットアップから始めます。WKWebViewは、Webコンテンツをアプリ内で表示するための強力なツールであり、WKWebViewConfigurationを通じて細かい設定を行うことができます。この設定では、Webコンテンツの挙動やJavaScriptとの連携方法を指定します。

特に重要なのが、JavaScriptコードの注入を可能にするWKUserContentControllerの設定です。このコントローラーを使用することで、SwiftとJavaScript間のメッセージ通信を構築したり、Webページの動作をカスタマイズしたりすることができます。

以下のサンプルコードでは、SwiftからJavaScriptの機能を追加するために、window.followToggleExampleオブジェクトを定義しています。このオブジェクトは、JavaScriptからSwiftにメッセージを送るためのエントリーポイントとなります。また、SwiftからJavaScriptへのメッセージ送信やログの受信も可能にしています。

// WebViewの設定
let config = WKWebViewConfiguration()
let contentController = WKUserContentController()

// JavaScriptコードを注入
let jsCode = """
window.followToggleExample = {
  follow: (publicCalendarId) => {
    return new Promise((resolve, reject) => {
      window.__resolveFollowToggleCalendar = resolve;
      window.__rejectFollowToggleCalendar = reject;
      window.webkit.messageHandlers.follow.postMessage(publicCalendarId);
    });
  },
  isFollowing: (options = {}) => {
    return window.webkit.messageHandlers.isFollowing.postMessage(options);
  }
};
"""
let userScript = WKUserScript(source: jsCode, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
contentController.addUserScript(userScript)

// Swift -> JavaScript のメッセージハンドラーを追加
contentController.add(self, name: "follow")
contentController.addScriptMessageHandler(LeakAvoidingScriptMessageHandlerWithReply(self), contentWorld: .page, name: "isFollowing")
config.userContentController = contentController

// JavaScript側のConsole.logを受ける
config.userContentController.add(self, name: "logging")

// override console.log
let _override = WKUserScript(source: "var console = { log: function(msg){window.webkit.messageHandlers.logging.postMessage(msg) }};", injectionTime: .atDocumentStart, forMainFrameOnly: true)
config.userContentController.addUserScript(_override)
webView = WKWebView(frame: self.view.bounds, configuration: config)

1. JavaScriptコードの注入

上記のコードでは、WKUserScriptを使用して、window.followToggleExampleというJavaScriptオブジェクトをWebページに注入しています。このオブジェクトを通じて、以下の機能が実現されます:

  • follow メソッド:Swiftに対してフォローリクエストを送信します。Promiseで成功・失敗を通知します。(成功時にはresolve、失敗時にはrejectを呼び出します)
  • isFollowing メソッド:現在のフォロー状態をSwiftに問い合わせ、結果を返します。

2. Swift -> JavaScriptの連携

contentController.addUserScript(userScript) を使用して、JavaScriptコードをWebページの読み込み時に注入します。この設定によって、Webページ内でSwiftが定義したオブジェクトやメソッドが利用可能になります。

さらに、contentController.add(_:name:)でメッセージハンドラーを登録することで、JavaScriptからSwiftにメッセージを送信できるようにしています。

3. JavaScriptのログ受信

console.logの出力をSwift側でキャッチするために、override console.log を実装しています。この機能は、Webページのデバッグやログ確認に役立ちます。

4. evaluateJavaScriptの利用準備

このセットアップを通じて、SwiftからJavaScriptコードを実行するevaluateJavaScriptも利用可能になります。たとえば、Swift側から動的にJavaScript関数を呼び出し、Webページの動作を制御することができます。

JavaScriptからSwiftへのメッセージハンドリング

次に、JavaScriptから送られてくるメッセージを受け取り、それに応じた処理をSwift側で実行する流れを見ていきましょう。

以下のコードでは、followという名前のメッセージを受信した際に、受け取ったsampleIDをもとにフォロー処理を実行しています。

// JavaScriptからのメッセージを受信
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    if message.name == "follow", let sampleID = message.body as? String {
        print("フォロー要求を受信: \(sampleID)")
        Task {
            do {
                // フォロー/アンフォロー処理を実行
                try await handleFollowToggle()
                await sendJavaScriptMessage("window.__resolveFollowToggleCalendar()")
            } catch {
                await sendJavaScriptMessage("window.__rejectFollowToggleCalendar()")
            }
        }
    } else if message.name == "logging" {
        print("WebView: \(message.body) ")
    }
}

// JavaScriptメッセージ送信を簡略化
private func sendJavaScriptMessage(_ script: String) async {
    await MainActor.run {
        self.webView.evaluateJavaScript(script) { _, error in
            if let error = error {
                print("JavaScript評価エラー: \(error)")
            }
        }
    }
}

以下の通りevaluateJavaScriptを用いてJavaScriptのPromiseを解決しています。

  • フォロートグル処理の成功時
    • window.__resolveFollowToggleCalendar()
  • フォロートグル処理の失敗時
    • window.__rejectFollowToggleCalendar()

双方向通信の実現

双方向通信をさらに拡張するには、WKScriptMessageHandlerWithReplyを活用します。通常のWKScriptMessageHandlerでは片方向通信(JavaScript→Swift)しかできませんが、この機能を使うことで、JavaScriptからSwiftに問い合わせを行い、それを受けたSwift側から何かしらの情報(今回のサンプルコードでは、フォロー状態を表現する isFollowed)をJavaScript側へ非同期で渡すことが可能です。

func userContentController(
    _ userContentController: WKUserContentController, 
    didReceive scriptMessage: WKScriptMessage, 
    replyHandler: @escaping (Any?, String?) -> Void
)
  • scriptMessage: JavaScriptから送られてきたメッセージ。
  • replyHandler: 処理結果をJavaScriptに返信するためのクロージャ。

サンプルコードでは、JavaScriptが現在のフォロー状態を問い合わせるisFollowing関数を実装する場合、以下のコードのように処理を記述しています。

func userContentController(_ userContentController: WKUserContentController, didReceive scriptMessage: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) {
    if scriptMessage.name == "isFollowing" {
        replyHandler(self.isFollowed, nil)
    } else {
        replyHandler(nil, "Invalid message")
    }
}

ここで、replyHandlerを用いてJavaScript側に結果を返しています。これにより、非同期通信がシンプルかつスムーズに行えるようになります。

Swiftから注入されたオブジェクトをJavaScript側で利用する

JavaScript側では、Swiftから注入されたwindow.followToggleExampleオブジェクトを活用します。このオブジェクトを通じて、Swiftにリクエストを送ります。

再掲となりますが下記のコード箇所です。

// JavaScriptコードを注入
let jsCode = """
window.followToggleExample = {
  follow: function(publicCalendarId) {
    return new Promise((resolve, reject) => {
      window.__resolveFollowCalendar = resolve;
      window.__rejectFollowCalendar = reject;
      window.webkit.messageHandlers.follow.postMessage(publicCalendarId);
    });
  },
  isFollowing: function(options) {
    return window.webkit.messageHandlers.isFollowing.postMessage(options);
  }
};
"""
declare global {
  interface Window {
    followToggleExample?: {
      follow: (publicCalendarId: string) => Promise<void>;
      isFollowing: () => boolean;
    };
    __resolveFollowCalendar?: () => void;
    __rejectFollowCalendar?: () => void;
  }
}

注入された window.followToggleExample を用いて、フォロー機能を実装します。以下のJavaScriptコードでは、フォローボタン/フォロー解除ボタンを押すとfollow関数を呼び出し、フォローメッセージをSwift側に送信しています。

const onFollowToggleButtonClick = async () => {
    try {
      await window.followToggleExample?.follow("sample-id");
      const following = await window.followToggleExample?.isFollowing();
      setIsFollowed(!!following);
    } catch (e) {
      setIsFollowed(false);
    }
};

このコードの流れを順に説明します。

  1. フォローメッセージの送信: window.followToggleExample?.follow("sample-id")を呼び出し、sample-idをSwift側に送信します。

  2. フォロー状態の取得: フォロー処理が完了した後、isFollowing関数を呼び出して現在のフォロー状態を取得します。
    このとき、Swift側では以下のコードが実行されます。

if scriptMessage.name == "isFollowing" {
    replyHandler(self.isFollowed, nil)
}
  1. コンポーネントの再描画: 取得したフォロー状態に基づいて、コンポーネントの状態を更新します。
    setIsFollowed(!!following) によって、ボタンの表示が「フォローする」から「フォロー解除する」に切り替わります。

フォロー状態に応じてボタンのデザインとテキストを切り替えるReactコンポーネントを作成します。

const FollowToggleButton: React.FC<FollowToggleButtonProps> = ({ isFollowed, onClick }) => {
  const buttonStyles = isFollowed
    ? "bg-red-500 hover:bg-red-600"
    : "bg-blue-400 hover:bg-blue-600";

  return (
    <button
      onClick={onClick}
      className={`${buttonStyles} text-white px-6 py-2 rounded-full transition duration-300`}
    >
      {isFollowed ? "フォロー解除する" : "フォローする"}
    </button>
  );
};

まとめ

この記事では、SwiftとJavaScriptの双方向通信を利用して、iOSアプリで動的なWebページと連携する方法を紹介しました。
ポイントは以下の通りです。

  • WKWebViewの設定方法
  • JavaScriptからSwiftにメッセージを送る方法
  • 双方向通信を実現するWKScriptMessageHandlerWithReplyの活用

最後に

このように、SwiftとJavaScriptの連携を活用することで、Webの柔軟性をネイティブアプリ側で最大限に活用することができます。ぜひ、実際のプロジェクトで試してみてください。

TimeTree Tech Blog

Discussion