WKWebview 中 JS 获得原生代码回调的几种方式

4,922 阅读4分钟

在 WKWebview 的使用过程中,与 JS 的交互是最经典的一个话题。常规的 JS 方法调用原生、原生调用 JS 方法,在 WKWebview 的 API 中都有支持,这里就不讨论了。

本文讨论如何在 JS 方法中获得原生方法的返回数据,目前可行的有 3 种典型方式:

  • 基于 window.prompt 方法的同步返回方式
  • 基于 postMessage 发送回调标记模式
  • 基于 postMessage 的 Promise 处理模式

基于 window.prompt 方法的同步返回方式

在 JS 中调用 window.prompt 会弹出一个输入提示框,输入内容后会将结果同步返回给方法。在 WKWebview 的 WKUIDelegate 代理方法中实现 runJavaScriptTextInputPanelWithPrompt 方法,可以拦截到输入的内容,并通过回调方法进行返回。

// JS
let msg = window.prompt("msg", "msgPlaceholder")

// Swift
func webView(_ webView: WKWebView,
    runJavaScriptTextInputPanelWithPrompt prompt: String,
    defaultText: String?,
    initiatedByFrame frame: WKFrameInfo,
    completionHandler: @escaping (String?) -> Void) {
    completionHandler("result")
}

这个方法看似很好,但是直接破坏了 window.prompt 的原始语义,对于需要采用标准的交互模式来使用的场景就被破坏了,尽管可以通过 prompt 传递的信息来区分使用场景,但是对于规范的使用要求非常高,很容易就覆盖了正常行为。

基于 postMessage 发送回调标记模式

JS 调用原生使用的是这个方法: window.webkit.messageHandlers..postMessage

postMessage 发送后就收不到回调了,剩下就是原生代码独自执行,但是仍然有办法可以把数据传会给 JS。

基本思路就是 JS 在发送 postMessage 时,顺便带上一个标记,原生收到这个标记,获取 JS 端需要的数据,再调用 JS 端已有的一个方法将数据带过去,通过标记匹配到 JS 端能响应的回调方法,在回调方法里接受数据。

例如下面的示例,在 JS 端保存一个回调方法的对象 nativeHandlers,通过一个 ID (即回调标记)映射所有的回调方法,在发送 postMessage 时保存回调方法,并给原生回调 ID,原生响应时调用 nativeCallback JS 方法将回调 ID 与原生信息一同带回,JS 即能响应此回调。

// JS
class NativeInteraction {
    constructor() {
        this.nativeHandlers = {}
        this.getNativeInfo = this.getNativeInfo.bind(this)
        this.nativeCallback = this.nativeCallback.bind(this)
    }

    getNativeInfo(handler) {
        let uuid = "123"
        this.nativeHandlers[uuid] = handler
        window.webkit.messageHandlers.nativeHandler.postMessage(`${uuid}`);
    }

    nativeCallback(uuid, info) {
        let handler = this.nativeHandlers[uuid]
        if (handler) {
            handler(info)
            delete this.nativeHandlers[uuid]
        }
    }
}
NativeInteraction.shared = new NativeInteraction()

function jsTest() {
    NativeInteraction.shared.getNativeInfo((info) => {
        console.log(info)
    })
}

// Swift
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    switch message.name {
    case "nativeHandler":
        guard let uuid = message.body as? String else { return }
        let info = "native info"
        let script = """
        NativeInteraction.shared.nativeCallback("\(uuid)", "\(info)")
        """
        self.wkwebview.evaluateJavaScript(script)
    default:
        return
    }
}

基于 postMessage 的 Promise 处理模式

postMessage 方法返回的是一个 Promise,在 iOS 14 以前的版本中,Promise 的 resolve 方法是无法获得返回值的:

let result = window.webkit.messageHandlers.nativeHandler.postMessage(null);
result
    .then((value) => {
        console.log("promise result:", value) // value 始终为 null
    })
    .catch((error) => {
        console.log("error:", error)
    })

不过即使这样,也是能发挥一些小小的用处的。在原生端处理消息的代理方法中执行 evaluateJavaScript 方法,可以将数据设置到 JS 中的某个属性中,并且能在 resolve 中获取到:

// JS
let nativeValue = null
let result = window.webkit.messageHandlers.nativeHandler.postMessage(null);
result
    .then((value) => {
        console.log("promise result:", value) // value 始终为 null
        console.log(nativeValue) // 
    })
    .catch((error) => {
        console.log("error:", error)
    })

// Swift
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    switch message.name {
    case "nativeHandler":
        let result = ""
        self.wkwebview.evaluateJavaScript("nativeValue = \(result)")
    default:
        return
    }
}

evaluateJavaScript 方法会先执行完成,然后才是执行 .then 中的 resolve 方法,如果对于回调的要求不高可以采用这个方法。如果这个方法的最大弊端有 2 点:

  • 需要使用全局变量
  • 无法在原生端异步拿一些数据再返回,didReceive 方法执行完后 resolve 方法就要执行了。

在 iOS 14 中,苹果终于大发慈悲地顺手(tuo le hen jiu)解决了这个问题,提供了一个新的方法监听 JS 发送的消息:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void)

这其中的关键就是 replyHandler 发挥的作用,通过它就可以把数据传递给 JS,并在 resolve 中获得:

// Swift
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) {
    switch message.name {
    case "nativeHandler":
        let result = ""
        replyHandler("result", nil)
    default:
        return
    }
}

// JS
window.webkit.messageHandlers.nativeHandler.postMessage(null)
    .then((value) => {
        console.log("promise result:", value) // value 现在有值了
    })
    .catch((error) => {
        console.log("error:", error)
    })

根据你的使用场景,可以在以上方法的基础上做些修改,例如第 2 种方法可以将回调集合换成 Promise 的集合,效果相同。总体上思路都不外乎这 3 种。

题图:Zoltan Tasi / unsplash