📱

Promiseを利用してNativeアプリとWebViewをスムーズな通信を実現する

2023/12/19に公開

こんにちは!アルダグラムでエンジニアをしているBingyiです。

本記事は株式会社アルダグラム Advent Calendar 2023 19日目の記事です。

モバイルアプリ開発において、アプリをリリースせずに仕様を更新する必要がある場合、Webコンテンツを用意し、アプリ内のWebViewで表示する方法が一般的です。そのような状況では、WebViewからアプリ側への情報取得というユースケースが生じます。この場合、アプリとWebViewの間でスムーズな通信を確立することが重要です。この記事では、そのための具体的な仕組みについて説明したいと思います。

今回はiOSアプリに焦点を当て、その仕組みを紹介します。この方法はAndroidアプリにも同様に適用できます。また、AndroidでWebViewアプリを構築する際は、こちらの記事を参考にすると良いでしょう。

iOSアプリにおいてWebViewとの通信

まず、iOSアプリでWebViewとどのように通信するかについて紹介したいと思います。

アプリからWebViewへ情報を渡す際には、JavaScriptのスクリプトを直接実行することが可能です。たとえば、WebViewで表示されているページ内のJavaScriptスクリプトにupdateTitleという関数がある場合、アプリ側からこの関数を呼び出すためには、WKWebViewのevaluateJavaScriptメソッドを利用することができます。

let script = "updateTitle('hello world')"
webView.evaluateJavaScript(script, completionHandler: nil)

WebViewからアプリへ情報を渡す際には、まずアプリ側で特定のmessageHandlerを事前に登録します。その後、WebView側からこのmessageHandlerにデータを送信すると、アプリ側でその情報を受け取ることができます。

まず、アプリ側WebViewからbridgeというmessageHandlerを受け取れるように登録します

let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "bridge")//こちらのselfはWKScriptMessageHandlerを実装したViewController
let webView = WKWebView(frame: .zero, configuration: config)

続いて、WebViewからメッセージを受け取った後、アプリ側でどのように処理を行うかを説明します。ここでViewControllerはWKScriptMessageHandlerというプロトコルを実現しており、その中でネイティブ側がWebViewからのデータをどのように処理するかを記述します。

extension ViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "bridge" {
           // WebView側から情報が来たらどのような処理を行う
        }
    }
}

上記のステップの準備が整ったら、WebViewで以下のようなJavaScriptを実行することで、アプリ側では{ eventName: "getRandomTitle" }というデータを受け取ることができます。

window.webkit.messageHandlers.bridge.postMessage({ eventName: "getRandomTitle" })

双方通信に存在する問題

現在WebViewとネイティブアプリ間の通信は可能になりますが、必ずしもスムーズだとは言えません。たとえば、JavaScript側の関数からネイティブ側の情報を取得しようとする際に、postMessageを使用しても、同じメソッドで直接にアプリ側のデーターを取得することができません。


function getRandomTitleFromNative() {
    window.webkit.messageHandlers.bridge.postMessage({ eventName: "getRandomTitle" })
    // ❌ postMessageからデーターが直接に来ないので、同じメソッドでHTMLの更新ができないです
} 

function updateTitleWithData(title) {
    const element = document.querySelector(".center-text");
    element.textContent = title;
}

WebViewを更新したい場合は、アプリ側からJavaScriptに用意された別のメソッドupdateTitleWithDataを再度呼び出す必要があります。

Promiseを利用してアプリ側からのデータを同じメソッドで受け取る

JavaScriptのメソッドでネイティブ側の情報を直接取得できるようにするためには、WebViewからイベントを発火する際の動作を非同期に変更し、ネイティブ側からデータが届いた後に続く処理を行うようにします。

まずJavaScript側アプリにデータ送信するメソッドがPromiseを利用して非同期な形に変更します

function sendMessageToNative(data) {
    return new Promise((resolve, reject) => {
        // ここでNative側の返事を待つようにする
        window.handleNativeResponse = (response) => {
            resolve(response);
        };
        // postMessageで情報をNativeに渡す
        window.webkit.messageHandlers.bridge.postMessage(data);
    });
}

既存のJavaScriptメソッドにおいてsendMessageToNativeを利用する形に変更します

async function updateTitleWithDataFromNative() {
    const title = await sendMessageToNative({ eventName: "getRandomTitle" });
	  // Native側受け取ったデーターを使ってコンテンツを更新する
    const element = document.querySelector(".center-text");
    element.textContent = title;
}

アプリ側がgetRandomTitleのイベントに対して、window.handleNativeResponseを使ってレスポンスをWebViewに返す

extension ViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "bridge",
           let body = message.body as? [String: Any],
           let eventNameString = body["eventName"] as? String,
           let event = BridgeEvent(rawValue: eventNameString) {
            switch event {
            case .getRandomTitle:
                let randomTitle = getRandomTitle()
                let script = "window.handleNativeResponse('\(randomTitle)')"
                webView.evaluateJavaScript(script)
            }
        }
    }

    private func getRandomTitle() -> String {
        return "Title: \(UUID().uuidString)"
    }
}

この方法でWebViewはアプリ側からのデータをメソッド内で受け取るようになりました。しかし、ここには別の問題が存在します。アプリ側がWebViewからの異なるイベントすべてに対して同じ場所でレスポンスを処理しているため、複数のイベントがアプリ側から同時に発信された場合、どのレスポンスがどのメソッドに対応するのか判別が困難になります。

アプリ側でレスポンスを適切に処理するためには、JavaScriptからアプリへイベントを送信する際に、毎回requestIdを付ける必要があります。この要件に沿って、JavaScript側で既存のsendMessageToNativeメソッドを以下のように更新します。

let requestIdCounter = 0;

// requestIdを生成します
function generateRequestId() {
    return `request_${requestIdCounter++}`;
}

// 発信したrequestのrequestIdをkeyとして一時的にrequestを保存
const pendingRequests = new Map();

function sendMessageToNative(detail) {
    return new Promise((resolve, reject) => {
        const requestId = generateRequestId();
        pendingRequests.set(requestId, resolve);
        window.handleNativeResponse = (response, requestId) => {
                         // 保存したrequestを取り出す
            const handler = pendingRequests.get(requestId);
            if (handler) {
                handler(response);
                pendingRequests.delete(requestId);
            }
        };
        // requestIdもアプリに送信する
        const messageWithRequestId = Object.assign(Object.assign({}, detail), { requestId });
        window.webkit.messageHandlers.bridge.postMessage(messageWithRequestId);
    });
}

アプリ側がWebViewにデータを返す際には、WebViewから受け取ったデータに含まれるrequestIdも一緒にWebViewへ返す必要があります。

extension ViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "bridge",
           let body = message.body as? [String: Any],
           let requestId = body["requestId"] as? String,
           let eventNameString = body["eventName"] as? String,
           let event = BridgeEvent(rawValue: eventNameString) {
            switch event {
            case .getRandomTitle:
                let randomTitle = getRandomTitle()
                
								// requestIdもhandleNativeResponseに渡します
                let script = "window.handleNativeResponse('\(randomTitle)','\(requestId)')"
                webView.evaluateJavaScript(script)
            }
        }
    }
    
    private func getRandomTitle() -> String {
        return "Title: \(UUID().uuidString)"
    }
}

このような形式にすることで、JavaScript側ではアプリの情報を取得する際に非同期関数を実行するような感覚でコードを記述できるようになります。

最後に

WebViewとアプリ間の双方向通信において、Promiseを使用し、アプリ側からのデータを非同期に処理する仕組みを共有しました。また、この記事に関連するサンプルプロジェクトも公開していますので、興味のある方はぜひチェックしてみてください。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion