JavaScript synchronous native communication to WKWebView
Asked Answered
J

5

33

Is synchronous communication between JavaScript and Swift/Obj-C native code possible using the WKWebView?

These are the approaches I have tried and have failed.

Approach 1: Using script handlers

WKWebView's new way of receiving JS messages is by using the delegate method userContentController:didReceiveScriptMessage: which is invoked from JS by window.webkit.messageHandlers.myMsgHandler.postMessage('What's the meaning of life, native code?') The problem with this approach is that during execution of the native delegate method, JS execution is not blocked, so we can't return a value by immediately invoking webView.evaluateJavaScript("something = 42", completionHandler: nil).

Example (JavaScript)

var something;
function getSomething() {
    window.webkit.messageHandlers.myMsgHandler.postMessage("What's the meaning of life, native code?"); // Execution NOT blocking here :(
    return something;
}
getSomething();    // Returns undefined

Example (Swift)

func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
    webView.evaluateJavaScript("something = 42", completionHandler: nil)
}

Approach 2: Using a custom URL scheme

In JS, redirecting using window.location = "js://webView?hello=world" invokes the native WKNavigationDelegate methods, where the URL query parameters can be extracted. However, unlike the UIWebView, the delegate method is not blocking the JS execution, so immediately invoking evaluateJavaScript to pass a value back to the JS doesn't work here either.

Example (JavaScript)

var something;
function getSomething() {
    window.location = "js://webView?question=meaning" // Execution NOT blocking here either :(
    return something;
}
getSomething(); // Returns undefined

Example (Swift)

func webView(webView: WKWebView, decidePolicyForNavigationAction navigationAction: WKNavigationAction, decisionHandler decisionHandler: (WKNavigationActionPolicy) -> Void) {
    webView.evaluateJavaScript("something = 42", completionHandler: nil)
    decisionHandler(WKNavigationActionPolicy.Allow)
}

Approach 3: Using a custom URL scheme and an IFRAME

This approach only differs in the way that window.location is assigned. Instead of assigning it directly, the src attribute of an empty iframe is used.

Example (JavaScript)

var something;
function getSomething() {
    var iframe = document.createElement("IFRAME");
    iframe.setAttribute("src", "js://webView?hello=world");
    document.documentElement.appendChild(iframe);  // Execution NOT blocking here either :(
    iframe.parentNode.removeChild(iframe);
    iframe = null;
    return something;
}
getSomething();

This nonetheless, is not a solution either, it invokes the same native method as Approach 2, which is not synchronous.

Appendix: How to achieve this with the old UIWebView

Example (JavaScript)

var something;
function getSomething() {
    // window.location = "js://webView?question=meaning" // Execution is NOT blocking if you use this.

    // Execution IS BLOCKING if you use this.
    var iframe = document.createElement("IFRAME");
    iframe.setAttribute("src", "js://webView?question=meaning");
    document.documentElement.appendChild(iframe);
    iframe.parentNode.removeChild(iframe);
    iframe = null;

    return something;
}
getSomething();   // Returns 42

Example (Swift)

func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
    webView.stringByEvaluatingJavaScriptFromString("something = 42")    
}
Jointure answered 10/11, 2014 at 19:38 Comment(10)
Wouldn't a better approach (in terms of UX) be to take advantage of the asynchronous nature? E.g. In your posted message to webkit, supply information about where the 'result' of the action should be posted from webkit. E.g. a gotSomething() javascript method that could be called by WebKit using stringByEvaluatingJavaScriptFromString?Jopa
Imagine you would like to ask the native code if an app is installed. If you're using synchronous calls you'd could do something as simple as var isTwitterInstalled = isInstalled('twitter');. If you've only got asynchronous calls, you'd have to split that into two functions; one that you call from JavaScript and the other that you call from the native code, passing in the result.Jointure
True, but the advantage is that if it takes any time at all to execute the code you're trying to run in iOS-world, your JavaScript will not be waiting at all for it, which makes the UI more responsive. In this way it's similar to Node.Jopa
The issue I've run into here is that it appears to be impossible to call into JavaScript and use the result of that call *in response to a UI/framework request. E.g., printing or copying. My attempts to synchronize through the completion handler have all resulted in deadlock.Electrodynamic
I notice your example code is using a local variable, but your evaluated JS might not be running in the same scope. Have you tried this with a variable on window?Farthermost
@SteveJohnson, I think that the var something; is actually superfluous because the injection from the native code creates something as a global when it assigns it its value.Jointure
@Jointure var something shadows the global. It's not superfluous, it's a bug.Farthermost
@SteveJohnson please correct me if I'm wrong, but if the line with var something; is not inside any function, i.e. it's in the global scope, it creates a global variable called something (equivalent to window.something = undefined;). Then, the native code assigns 42 to the global variable window.something (and if the global didn't exist, it would create it first, then assign it its value). So whether or not we create declare something beforehand, it's created by the native code, right?Jointure
@Jointure You're probably right. I usually work in Browserify, so I'm used to assuming all module-level code is wrapped in a function.Farthermost
instead of using window.location or iframe, can i simple use ajax?Maddux
T
10

No I don't believe it is possible due to the multi-process architecture of WKWebView. WKWebView runs in the same process as your application but it communicates with WebKit which runs in its own process (Introducing the Modern WebKit API). The JavaScript code will be running in the WebKit process. So essentially you are asking to have synchronous communication between two different processes which goes against their design.

Tumid answered 3/12, 2014 at 12:34 Comment(2)
It's really a shame for a shiny new API like the WKWebView to lack a feature that Android has had for years.Jointure
it makes no sense at all. why can't that postMessage method return a promise?Westbound
S
7

I found a hack for doing synchronous communication but haven't tried it yet: https://mcmap.net/q/427457/-wkwebview-complex-communication-between-javascript-amp-native-code

Edit: Basically you can use the the JS prompt() to carry your payload from the js side to the native side. In the native WKWebView will have to intercept the prompt call and decide if it is a normal call or if it is a jsbridge call. Then you can return your result as a callback to the prompt call. Because the prompt call is implemented in such a way that it waits for user input your javascript-native communication will be synchronous. The downside is that you can only communicate trough strings.

Savdeep answered 18/4, 2018 at 12:54 Comment(2)
Well it looks OK now.Weidner
I upvoted for the creativity in finding the most ugly hack, not because it's a solution one would actually be able to use in a real app :)Mckean
X
4

I also investigated this issue, and failed as you. To workaround, you have to pass a JavaScript function as a callback. Native function needs to evaluate the callback function to return result. Actually, this is the JavaScript way, because JavaScript never wait. Blocking JavaScript thread may cause ANR, it's very bad.

I have created a project named XWebView which can establish a bridge between native and JavaScript. It offers binding styled API for calling native from JavaScript and vice versa. There is a sample app.

Xerarch answered 28/4, 2015 at 16:57 Comment(4)
How do you pass a JavaScript function as a callback to WKWebView? From what I can see, only serializable values (string, number, boolean) are allowed.Cockatoo
@VidarS.Ramdal, For those non-serializable objects which can not be passed by value, they will be passed by "reference". The "reference" is a special tiny (serializable) object which represents the original non-serializable object. Operations applied on the reference object will be translated to javascript expression and evaluated by XWebView.Xerarch
Well, then I guess my question is how you operate on that anonymous object. How can you call an anonymous JavaScript function from Swift?Cockatoo
Good question. Any object passed by reference (whether anonymous or not) will be retained under the namespace of initiated plugin object. It will be released while its reference is released on native side.Xerarch
C
1

It's possible to synchronously wait for the result of evaluateJavaScript by polling the current RunLoop's acceptInput method. What this does is allow your UI thread to respond to input while you wait for the the Javascript to finish.

Please read warnings before you blindly paste this into your code

//
//  WKWebView.swift
//
//  Created by Andrew Rondeau on 7/18/21.
//

import Cocoa
import WebKit

extension WKWebView {
    func evaluateJavaScript(_ javaScriptString: String) throws -> Any? {

        var result: Any? = nil
        var error: Error? = nil
        
        var waiting = true
        
        self.evaluateJavaScript(javaScriptString) { (r, e) in
            result = r
            error = e
            waiting = false
        }
        
        while waiting {
            RunLoop.current.acceptInput(forMode: RunLoop.Mode.default, before: Date.distantFuture)
        }
        
        if let error = error {
            throw error
        }
        
        return result
    }
}

What happens is that, while the Javascript is executing, the thread calls RunLoop.current.acceptInput until waiting is false. This allows your application's UI to be responsive.

Some warnings:

  • Buttons, ect, on your UI will still respond. If you don't want someone to push a button while your Javascript is running, you should probably disable interacting with your UI. (This is especially the case if you're calling out to another server in Javascript.)
  • The multi-process nature of calling evaluateJavaScript may be slower than you expect. If you're calling code that is "instant," things may still slow down if you make repeated calls into Javascript in a tight loop.
  • I've only tested this on the Main UI thread. I don't know how this code will work on a background thread. If there are problems, investigate using a NSCondition.
  • I've only tested this on macOS. I do not know if this works on iOS.
Caisson answered 19/7, 2021 at 0:49 Comment(0)
O
0

I was facing a similar issue, i resolved it by storing promise callbacks.

The js that you load in your web view via WKUserContentController::addUserScript

var webClient = {
    id: 1,
    handlers: {},
};

webClient.onMessageReceive = (handle, error, data) => {
    if (error && webClient.handlers[handle].reject) {
        webClient.handlers[handle].reject(data);
    } else if (webClient.handlers[handle].resolve){
        webClient.handlers[handle].resolve(data);
    }

    delete webClient.handlers[handle];
};

webClient.sendMessage = (data) => {
    return new Promise((resolve, reject) => {
       const handle = 'm' + webClient.id++;
       webClient.handlers[handle] = { resolve, reject };
       window.webkit.messageHandlers.<message_handler_name>.postMessage({data: data, id: handle});
   });
}

Perform Js Request like

webClient.sendMessage(<request_data>).then((response) => {
...
}).catch((reason) => {
...
});

Receive request in userContentController :didReceiveScriptMessage

Call evaluateJavaScript with webClient.onMessageReceive(handle, error, response_data).

Orissa answered 2/4, 2019 at 6:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.