📑

WKWebViewのJSとNativeの連携で通常の関数呼び出しのようにNative処理の結果を受け取る

2021/08/28に公開

WKWebViewでJavaScriptからNative側を呼び出す方法は検索すると出てきますが、ネイティブ側の処理結果を同じコンテキストで受け取る方法の情報が少なかったのでメモしておきます。

要点

  • WKScriptMessageHandlerを使った方法ではJavaScript側からの呼び出しに対して同じコンテキストで戻り値が返せない
  • JavaScriptのprompt()関数をWKUIDelegateプロトコルで上書きすると通常の関数呼び出しのように同じコンテキストで処理結果を返すことができる

実現したいこと

JavaScript上で次のようなコードを記述したい。

var result = nativeMethod() // nativeMethodはネイティブ側(Swift)で用意した処理
alert(result)               // 後続の処理でネイティブの処理結果(戻り値)を使う

nativeMethod() の処理結果を同期的に取得する必要があるので、JavaScript側はnativeMethod処理が終わるのを待っている必要があります。ここでは諸事情でPromiseasync/awaitを使用しないことを想定しています。

JavaScript側からNative側を呼び出す一般的な方法(webkitのmessageHandlersとevaluateJavaScriptを組み合わせた非同期処理)

WKWebViewでJavaScriptからNative側を呼び出す方法を検索するとwebkitmessageHandlersを使った実装例が出てきますが、この方法は戻りを返すことができません。WKWebViewevaluateJavaScript を使ってNative側からJavaScriptコードを実行することで結果を返すことができます。結果的にJavaScritpからの呼び出しと戻り値が別々のコンテキスト(非同期処理)になります。

Native側(Swift)のコードの例です。

var webView: WKWebView!
let handlerName = "nativeBridge" // JavaScript側に公開されるメッセージハンドラの名前

// WKWebViewを初期化するコード
private func setupWebView() {

    let userContentController: WKUserContentController = WKUserContentController()
    userContentController.add(self, name: handlerName) // メッセージハンドラの名前をセット

    let configuration: WKWebViewConfiguration = WKWebViewConfiguration()
    configuration.userContentController = userContentController // メッセージハンドラをセットしたuserContentControllerを代入

    // configurationを使ってWKWebViewを生成する
    webView = WKWebView(frame: .zero, configuration: configuration)
}

// JavaScriptからの呼び出しを処理するコード
extension ViewController: WKScriptMessageHandler {
    // JS -> Native の呼び出し
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if(message.name == handlerName) {
            print(message.body)
            evalJavaScript()
        }
    }

    // Native -> JS の呼び出し
    private func evalJavaScript() {
        let message = "asynchronus message."
        let executeScript: String = "callFromNative(\"\(message)\");" // callFromNative() というコードをevalする
        webView.evaluateJavaScript(executeScript, completionHandler: { (object, error) -> Void in
            if let object = object {
                print(object)
            }
            if let error = error {
                print(error)
            }
        })
    }

}

Native側を呼び出して、処理の結果を取得するJavaScript側のコード例です。

// Nativeを実行する関数
function callAsynchronusNativeBridge() {
    var object = {
        "foo": "bar"
    }
    webkit.messageHandlers.nativeBridge.postMessage(object); // nativeBridgeという名前空間を使ってSwift側を呼び出す
}

// Nativeから呼び出される関数
function callFromNative(value) {
    var element = document.getElementById("asynchronusResult");
    element.value = value;
}

callAsynchronusNativeBridgecallFromNativeは異なるコンテキストで実行されるのでNative側の呼び出しと戻り値の取得(処理)が非同期になります。

JavaScriptのprompt()関数をWKUIDelegateプロトコルで上書きして処理の結果を返す(同期処理)

prompt()関数とは下記のようにWebページでテキスト入力のポップアップを表示する関数です。

JavaScriptでは以下のように使います。

var result = prompt("any message") // ポップアップを表示してユーザーの入力を待つ
alert(result)                   // resultには入力されたテキストが入っている

この関数はWKWebView上のコンテンツで実行しても通常は上記のようなポップアップが表示されません。WKWebViewではprompt()関数の呼び出しのハンドリングはNative側に委ねられています。そのためにWKUIDelegaterunJavaScriptTextInputPanelWithPromptで始まるメソッドが用意されています。

optional func webView(_ webView: WKWebView,
runJavaScriptTextInputPanelWithPrompt prompt: String,
          defaultText: String?,
     initiatedByFrame frame: WKFrameInfo,
    completionHandler: @escaping (String?) -> Void)

https://developer.apple.com/documentation/webkit/wkuidelegate/1538086-webview

この関数はJavaScriptでprompt()を呼び出すと実行され、completionHandlerStringをセットして文字列を返します。JavaScript側はNative側がデータを返すまで処理を待っています。

実際のコードの例は次の通りです、ここではJSONを使った JS -> Native でのやりとりを想定した実装にしました。

Native側のコードです。

extension ViewController: WKUIDelegate {
    // JSでprompt()が呼び出されたときに実行される関数
    func webView(_ webView: WKWebView,
                 runJavaScriptTextInputPanelWithPrompt prompt: String,
                 defaultText: String?,
                 initiatedByFrame frame: WKFrameInfo,
                 completionHandler: @escaping (String?) -> Void) {


        // decode json stringify text.
        let jsonText = prompt.data(using: .utf8)!
        let json = try! JSONDecoder().decode([String : String].self, from: jsonText)
        // json["foo"] --> bar

        // return json stringify text.
        let dict = ["body" : "synchronus message."]
        let data = try! JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted)
        let text = String(data: data, encoding: String.Encoding.utf8) ?? ""

        // completeHandler を呼び出して戻り値を返す
        completionHandler(text)
    }
}

JavaScript側のコードです。

// JS -> Native (Synchronus)
function callSynchronusNativeBridge() {
    var object = {
        "foo": "bar"
    }
    var result = prompt(JSON.stringify(object)) // Native側でWKUIDelegateの関数が呼び出される

    // resultにはNative側のcompleteHandlerでセットした値が入っている

    var element = document.getElementById("synchronusResult");
    element.value = JSON.parse(result).body
}

上記のコードではcallSynchronusNativeBridgeの中でprompt()を呼び出してNative側を呼び出しています。Native側ではWKUIDelegateの関数が呼び出されるので処理の結果をcompletionHandlerにセットします。

completionHandlerにセットされた値がprompt()関数の戻り値として返却されます。

サンプルコード

簡単に試せるサンプルコードをGitHubに置いておきます。
https://github.com/yorifuji/WKWebViewNativeBridge

Discussion