WebView Javascript Bridge Source Exploration

Posted by Walle on Tue, 17 Sep 2019 12:15:16 +0200

First, let's look at how OC can be used to invoke methods in JS.

Note: Let's take wkwebview as an example. The following code is for wkwebview.

1. First create a button and a WKWebView JavascriptBridge object

UIButton *callbackButton = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    [callbackButton setTitle:@"Native call JS" forState:UIControlStateNormal];
    [callbackButton addTarget:self action:@selector(callJSMethod:) forControlEvents:UIControlEventTouchUpInside];
    ...
    _bridge = [WKWebViewJavascriptBridge bridgeForWebView:webView];

2. Button Message Processing Function

- (void)callJSMethod:(id)sender {
    id data = @{ @"Native call JS Parameter 1": @"Parameter 1" };
    [_bridge callHandler:@"JSMethod1" data:data responseCallback:^(id response) {
        NSLog(@"testJavascriptHandler responded: %@", response);
    }];
}

3. Call the callHandler method in WKWebView JavascriptBridge

- (void)callHandler:(NSString *)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback {
    [_base sendData:data responseCallback:responseCallback handlerName:handlerName];
}

4. The WK WebView JavascriptBridge contains a WebView JavascriptBridgeBase object_base. Continue calling stack trace.

- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName {
    NSMutableDictionary* message = [NSMutableDictionary dictionary];
    
    if (data) {
        message[@"data"] = data;
    }
    
    if (responseCallback) {
        NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
        self.responseCallbacks[callbackId] = [responseCallback copy];
        message[@"callbackId"] = callbackId;
    }
    
    if (handlerName) {
        message[@"handlerName"] = handlerName;
    }
    [self _queueMessage:message];
}

This method is clearly written, which packages the JS function name handlerName, parameter data, and callback method Id into a dictionary object message. CallbackId, one for each callback, unique. Why call backId? Block itself is an object that JS cannot recognize. In fact, it's not very meaningful to pass it on. Just put this block in the responseCallbacks dictionary in the WebView JavascriptBridgeBase object. The key is the callbackId just generated. Then continue to call the following method.

5. Put the message in the queue.

- (void)_queueMessage:(WVJBMessage*)message {
    if (self.startupMessageQueue) {
        [self.startupMessageQueue addObject:message];
    } else {
        [self _dispatchMessage:message];
    }
}

Instead of being queued, the message is distributed directly, and you'll see later why self. startup MessageQueue is nil.

6. Send messages to the Web environment

- (void)_dispatchMessage:(WVJBMessage*)message {
    NSString *messageJSON = [self _serializeMessage:message pretty:NO];
    [self _log:@"SEND" json:messageJSON];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
    messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];
    
    NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
    if ([[NSThread currentThread] isMainThread]) {
        [self _evaluateJavascript:javascriptCommand];

    } else {
        dispatch_sync(dispatch_get_main_queue(), ^{
            [self _evaluateJavascript:javascriptCommand];
        });
    }
}

First, the message of WVJBMessage object is serialized into JSON string, and then the characters in the string are escaped; the command string of JS is generated; and the JS command is executed in the main thread. Why do you want to execute in the main thread? There is a sentence in the Apple document: The WebKit framework is not thread-safe. If you call functions or methods in this framework, you must do so exclusively on the main program thread.

7. Eventually pass the command to WebView for execution

//WebViewJavascriptBridgeBase.m
- (void) _evaluateJavascript:(NSString *)javascriptCommand {
    [self.delegate _evaluateJavascript:javascriptCommand];
}
//WKWebViewJavascriptBridge.ms
- (NSString*) _evaluateJavascript:(NSString*)javascriptCommand {
    [_webView evaluateJavaScript:javascriptCommand completionHandler:nil];
    return NULL;
}

8. The Essence of OC Calling JS Method

When OC calls js, it packages the callback method, method name, and parameters into message JSON, and then calls the following ultimate method. WebView JavascriptBridge. _handleMessageFromObjC (message JSON); this method exists in the WebView JavascriptBridge_js.m file and is injected when the page is loaded. This process is described in the following section.

2. How to execute the method in JS

1. What did you do when loading the page?

We also take the official example. Load a local HTML page, ExampleApp.html. After loading, the following methods are executed

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    if (webView != _webView) { return; }
    NSURL *url = navigationAction.request.URL;
    __strong typeof(_webViewDelegate) strongDelegate = _webViewDelegate;

    if ([_base isWebViewJavascriptBridgeURL:url]) {
        if ([_base isBridgeLoadedURL:url]) {
            [_base injectJavascriptFile];
        } else if ([_base isQueueMessageURL:url]) {
            [self WKFlushMessageQueue];
        } else {
            [_base logUnkownMessage:url];
        }
        decisionHandler(WKNavigationActionPolicyCancel);
        return;
    }
    
    if (strongDelegate && [strongDelegate respondsToSelector:@selector(webView:decidePolicyForNavigationAction:decisionHandler:)]) {
        [_webViewDelegate webView:webView decidePolicyForNavigationAction:navigationAction decisionHandler:decisionHandler];
    } else {
        decisionHandler(WKNavigationActionPolicyAllow);
    }
}

This method is the first way to load a page, because it determines whether the navigation is allowed or not to be unloaded (that is, whether the page is allowed to be loaded). The URL is not a special jsBridge's URL at the time of the first load, which allows the page to be loaded directly. Let's look at the source code of the page.

2. The content of the loaded page (ExampleApp.html)

<!doctype html>
<html><head>
    <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0, maximum-scale=1.0">
    <style type='text/css'>
        html { font-family:Helvetica; color:#222; }
        h1 { color:steelblue; font-size:24px; margin-top:24px; }
        button { margin:0 3px 10px; font-size:12px; }
        .logLine { border-bottom:1px solid #ccc; padding:4px 2px; font-family:courier; font-size:11px; }
    </style>
</head><body>
    <h1>WebViewJavascriptBridge Demo</h1>
    <script>
    window.onerror = function(err) {
        log('window.onerror: ' + err)
    }

    function setupWebViewJavascriptBridge(callback) {
        //The first time this method is called, it is false
        if (window.WebViewJavascriptBridge) {
            var result = callback(WebViewJavascriptBridge);
            return result;
        }
        //The first call was also false
        if (window.WVJBCallbacks) {
            var result = window.WVJBCallbacks.push(callback);
            return result;
        }
        //Assign the callback object to the object.
        window.WVJBCallbacks = [callback];
        //This code means to execute the function of loading code in WebView JavascriptBridge_JS.js
        var WVJBIframe = document.createElement('iframe');
        WVJBIframe.style.display = 'none';
        WVJBIframe.src = 'https://__bridge_loaded__';
        document.documentElement.appendChild(WVJBIframe);
        setTimeout(function() {
            document.documentElement.removeChild(WVJBIframe)
        }, 0);
    }

    //SetupWebView JavascriptBridge executes with the parameters passed in, which is a method.
    function callback(bridge) {
        var uniqueId = 1
        //Write operation records into webview
        function log(message, data) {
            var log = document.getElementById('log')
            var el = document.createElement('div')
            el.className = 'logLine'
            el.innerHTML = uniqueId++ + '. ' + message + ':<br/>' + JSON.stringify(data)
            
            if (log.children.length) {
               log.insertBefore(el, log.children[0])
            }else {
                log.appendChild(el)
            }
        }
        //Register the method to be registered in WEB into bridge
        bridge.registerHandler('JSMethod1', function(data, responseCallback) {
            log('OC call JS Method success', data)
            var responseData = { 'JS to OC Callback of call':'Callback value!' }
            log('OC call JS Return value', responseData)
            responseCallback(responseData)
        })
        //Get the button in the web, and then add the click event.
        document.body.appendChild(document.createElement('br'))
        document.getElementById('buttons').onclick = function(e) {
            e.preventDefault()
            var params =  {'JS call OC parameter': 'parameter values'};
            log('JS Call immediately OC Method',params)
            bridge.callHandler('OC Provide methods to JS call',params, function(response) {
                log('JS call OC Return value', response)
            })
        }
    };
    //Drive the initialization of all hander s
    setupWebViewJavascriptBridge(callback);
    </script>
    <input type='button' id='buttons' class='button' value='Click Start JS call OC'></input>
    <div id='log'></div>
</body></html>

Two methods are defined and the first method is called with the second method as the parameter. The first time method one is called, it simply adds an array of WVJBCallbacks to the window object. And put the second function in. Then create an invisible iframe element and set its URL to a special url: https://__bridge_loaded__ . In this way, another request will be made. The first step is to call webView: Decision Policy ForNavigation Action: Decision Handler again. This time it will execute to this branch [_base injection JavascriptFile].

3. This time inject the code in WebView JavascriptBridge_js into JS

- (void)injectJavascriptFile {
    NSString *js = WebViewJavascriptBridge_js();
    [self _evaluateJavascript:js];
    if (self.startupMessageQueue) {
        NSArray* queue = self.startupMessageQueue;
        self.startupMessageQueue = nil;
        for (id queuedMessage in queue) {
            [self _dispatchMessage:queuedMessage];
        }
    }
}

WebView JavascriptBridge_js contains only one method to generate a character. This string is the JS code to be injected (that is, the code to be executed).

NSString * WebViewJavascriptBridge_js() {
    #define __wvjb_js_func__(x) #x
    
    // BEGIN preprocessorJSCode
    static NSString * preprocessorJSCode = @__wvjb_js_func__(
;(function() {
    if (window.WebViewJavascriptBridge) {
        return;
    }

    if (!window.onerror) {
        window.onerror = function(msg, url, line) {
            console.log("WebViewJavascriptBridge: ERROR:" + msg + "@" + url + ":" + line);
        }
    }
    window.WebViewJavascriptBridge = {
        registerHandler: registerHandler,
        callHandler: callHandler,
        disableJavscriptAlertBoxSafetyTimeout: disableJavscriptAlertBoxSafetyTimeout,
        _fetchQueue: _fetchQueue,
        _handleMessageFromObjC: _handleMessageFromObjC
    };

    var messagingIframe;
    var sendMessageQueue = [];
    var messageHandlers = {};
    
    var CUSTOM_PROTOCOL_SCHEME = 'https';
    var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';
    
    var responseCallbacks = {};
    var uniqueId = 1;
    var dispatchMessagesWithTimeoutSafety = true;

    function registerHandler(handlerName, handler) {
        messageHandlers[handlerName] = handler;
    }
    
    function callHandler(handlerName, data, responseCallback) {
        if (arguments.length == 2 && typeof data == 'function') {
            responseCallback = data;
            data = null;
        }
        _doSend({ handlerName:handlerName, data:data }, responseCallback);
    }
    function disableJavscriptAlertBoxSafetyTimeout() {
        dispatchMessagesWithTimeoutSafety = false;
    }
    
    function _doSend(message, responseCallback) {
        if (responseCallback) {
            var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
            responseCallbacks[callbackId] = responseCallback;
            message['callbackId'] = callbackId;
        }
        sendMessageQueue.push(message);
        messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
    }

    function _fetchQueue() {
        var messageQueueString = JSON.stringify(sendMessageQueue);
        sendMessageQueue = [];
        return messageQueueString;
    }

    function _dispatchMessageFromObjC(messageJSON) {
        if (dispatchMessagesWithTimeoutSafety) {
            setTimeout(_doDispatchMessageFromObjC);
        } else {
             _doDispatchMessageFromObjC();
        }
        
        function _doDispatchMessageFromObjC() {
            var message = JSON.parse(messageJSON);
            var messageHandler;
            var responseCallback;

            if (message.responseId) {
                responseCallback = responseCallbacks[message.responseId];
                if (!responseCallback) {
                    return;
                }
                responseCallback(message.responseData);
                delete responseCallbacks[message.responseId];
            } else {
                if (message.callbackId) {
                    var callbackResponseId = message.callbackId;
                    responseCallback = function(responseData) {
                        _doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
                    };
                }
                
                var handler = messageHandlers[message.handlerName];
                if (!handler) {
                    console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
                } else {
                    handler(message.data, responseCallback);
                }
            }
        }
    }
    
    function _handleMessageFromObjC(messageJSON) {
        _dispatchMessageFromObjC(messageJSON);
    }

    messagingIframe = document.createElement('iframe');
    messagingIframe.style.display = 'none';
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
    document.documentElement.appendChild(messagingIframe);

    registerHandler("_disableJavascriptAlertBoxSafetyTimeout", disableJavscriptAlertBoxSafetyTimeout);
    
    setTimeout(_callWVJBCallbacks, 0);
    function _callWVJBCallbacks() {
        var callbacks = window.WVJBCallbacks;
        delete window.WVJBCallbacks;
        for (var i=0; i<callbacks.length; i++) {
            callbacks[i](WebViewJavascriptBridge);
        }
    }
})();
    ); // END preprocessorJSCode

    #undef __wvjb_js_func__
    return preprocessorJSCode;
};

What are the functions of this js code? The window. WebView JavascriptBridge object is created, which is the core of the whole OC and native interaction. This object contains the method _handleMessageFromObjC. That's what we called in Part I 8. This method calls the _doDispatchMessageFromObjC() method. Then we define all kinds of objects and functions. Finally, we see a function call through setTimeout.

    setTimeout(_callWVJBCallbacks, 0);
    function _callWVJBCallbacks() {
        var callbacks = window.WVJBCallbacks;
        delete window.WVJBCallbacks;
        for (var i=0; i<callbacks.length; i++) {
            callbacks[i](WebViewJavascriptBridge);
        }
    }

Remember window.WVJBCallbacks? It is an array of stored callbacks defined in ExampleApp.html. The callback we defined is in it. Call all callback functions in this array and take the WebView JavascriptBridge object as a parameter. The second method defined in ExampleApp.html is then executed (this method contains the js code to be executed by the user page, so it cannot be placed in the framework jsbridge to be placed in the page).
Look at this code in the callback method.

        //Register the method to be registered in WEB into bridge
        bridge.registerHandler('JSMethod1', function(data, responseCallback) {
            log('OC call JS Method success', data)
            var responseData = { 'JS to OC Callback of call':'Callback value!' }
            log('OC call JS Return value', responseData)
            responseCallback(responseData)
        })

The registerHandler of the bridge object is called to register a method name and its corresponding function, and OC calls the method of JS through the method name JSMethod1. Let's go back to WebView JavascriptBridge_js and see how registerHandler is implemented.

    function registerHandler(handlerName, handler) {
        alert(handlerName+'01');
        messageHandlers[handlerName] = handler;
    }

That's to store the function in the object messageHandlers. At this point, the OC calls the JS method, which has been placed in the dictionary, waiting to be called.

4.OC calls JS method

As mentioned in Step 8 of Part I, OC calls JS methods eventually turn into a method defined in JS code to execute WebView JavascriptBridge. _handleMessageFromObjC (message JSON). Let's look at the source code.

function _handleMessageFromObjC(messageJSON) {
    _dispatchMessageFromObjC(messageJSON);
}
//Continue with _dispatchMessageFromObjC
function _dispatchMessageFromObjC(messageJSON) {
        
        if (dispatchMessagesWithTimeoutSafety) {
            setTimeout(_doDispatchMessageFromObjC);
        } else {
             _doDispatchMessageFromObjC();
        }
        
        function _doDispatchMessageFromObjC() {
            var message = JSON.parse(messageJSON);
            var messageHandler;
            var responseCallback;

            if (message.responseId) {
                
                responseCallback = responseCallbacks[message.responseId];
                if (!responseCallback) {
                    return;
                }
                responseCallback(message.responseData);
                delete responseCallbacks[message.responseId];
            } else {
                
                //alert(message.callbackId);        //objc_cb_1 objc_cb_2 objc_cb_3....
                
                //If the callback block is set when OC calls JS, the callbackId is not empty. A responseCallback function is generated here. It is used later when calling the JS method.
                if (message.callbackId) {
                    var callbackResponseId = message.callbackId;
                    
                    //The function defined here is passed to the JS method that OC will call. In the method, the data responseData to be passed to OC is used to make parameter calls. If this callback function is not defined, OC can call the JS method successfully, but the incoming block will not be executed at the time of the call.
                    responseCallback = function(responseData) {
                        //Only one parameter is passed here.
                        _doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
                        
                    };
                }
                
                //The messageHandlers dictionary contains the methods of the JS we are calling.
                var handler = messageHandlers[message.handlerName];
                alert(message.handlerName);
                
                if (!handler) {
                    console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
                } else {
                    //The method of JS is invoked, so OC finally invokes the method of JS. The incoming responseCallback method is called in JS, and the parameter is the data yuxg to be returned to OC.
                    handler(message.data, responseCallback);
                }
            }
        }
    }

5.JS returns value to OC by calling OC callback block.

This handle message Objects are placed in an array, and then changed iframe Of url,Refresh the page.
function _doSend(message, responseCallback) {
    if (responseCallback) {
        var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
        responseCallbacks[callbackId] = responseCallback;
        message['callbackId'] = callbackId;
    }
    //The message object contains the method name of the JS invoked by OC, the Id of the callback block, and the parameters of the block, that is, the data returned.
    //Take this object.
    sendMessageQueue.push(message);
    //Modify the address of iframe to https://_wvjb_queue_message__
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}

JS delivers messages to OC by modifying the SRC (url) of iframed. This allows the webView: decision Policy for Navigation Action: decision Handler method in the WK WebView Javascript Bridge to intercept this message. It will be called inside.
[self WK Flush Message Queue], let's continue with the code

//WKWebViewJavascriptBridge.m
- (void)WKFlushMessageQueue {
    [_webView evaluateJavaScript:[_base webViewJavascriptFetchQueyCommand] completionHandler:^(NSString* result, NSError* error) {
        if (error != nil) {
            NSLog(@"WebViewJavascriptBridge: WARNING: Error when trying to fetch data from WKWebView: %@", error);
        }
        
        [_base flushMessageQueue:result];
    }];
}
//WebViewJavascriptBridgeBase.m
 (void)flushMessageQueue:(NSString *)messageQueueString{
    if (messageQueueString == nil || messageQueueString.length == 0) {
        NSLog(@"WebViewJavascriptBridge: WARNING: ObjC got nil while fetching the message queue JSON from webview. This can happen if the WebViewJavascriptBridge JS is not currently present in the webview, e.g if the webview just loaded a new page.");
        return;
    }
    //JSON strings are deserialized into arrays, where elements are dictionary types.
    id messages = [self _deserializeMessageJSON:messageQueueString];
    for (WVJBMessage* message in messages) {
        if (![message isKindOfClass:[WVJBMessage class]]) {
            NSLog(@"WebViewJavascriptBridge: WARNING: Invalid %@ received: %@", [message class], message);
            continue;
        }
        [self _log:@"RCVD" json:message];
        
        NSString* responseId = message[@"responseId"];
        if (responseId) {
            WVJBResponseCallback responseCallback = _responseCallbacks[responseId];
            //Here OC is invoked to invoke the JS method as the incoming block.
             responseCallback(message[@"responseData"]);
            [self.responseCallbacks removeObjectForKey:responseId];
        } else {
            WVJBResponseCallback responseCallback = NULL;
            NSString* callbackId = message[@"callbackId"];
            if (callbackId) {
                responseCallback = ^(id responseData) {
                    if (responseData == nil) {
                        responseData = [NSNull null];
                    }
                    
                    WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
                    [self _queueMessage:msg];
                };
            } else {
                responseCallback = ^(id ignoreResponseData) {
                    // Do nothing
                };
            }
            
            WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];
            
            if (!handler) {
                NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
                continue;
            }
            
            handler(message[@"data"], responseCallback);
        }
    }
}

Topics: Javascript JSON