不攔截Request!基於WKWebView的API實現Hybrid容器

Mcyboy發表於2019-03-04

在介紹我實現的Hybrid容器之前,建議先了解一下,常用的JavaScript和Native相互通訊的方式到底有多少種?

建議閱讀一下這篇文章:
從零收拾一個hybrid框架(一)– 從選擇JS通訊方案開始

以下,假設你已對JS和Native通訊方式有了基本的瞭解。

常用的三方庫WebViewJavascriptBridge,為了相容UIWebView,繼續採用了假跳轉攔截Request的方式。其實,你也可以不用攔截的方式,而是使用WKWebView自身提供的API。

1.先分析一下攔截Request的方式(如果你已經掌握,可以跳過)

WebViewJavascriptBridge原始碼分析。它載入了本地的ExampleApp.html檔案。ExampleApp.html載入時,執行了一個js方法:setupWebViewJavascriptBridge(callback).

function setupWebViewJavascriptBridge(callback) {
        if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
        if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
        window.WVJBCallbacks = [callback];
        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)
    }
    
    setupWebViewJavascriptBridge(function(bridge) {
		var uniqueId = 1
		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) }
		}

		bridge.registerHandler(`testJavascriptHandler`, function(data, responseCallback) {
			log(`ObjC called testJavascriptHandler with`, data)
			var responseData = { `Javascript Says`:`Right back atcha!` }
			log(`JS responding with`, responseData)
			responseCallback(responseData)
		})

		document.body.appendChild(document.createElement(`br`))

		var callbackButton = document.getElementById(`buttons`).appendChild(document.createElement(`button`))
		callbackButton.innerHTML = `Fire testObjcCallback`
		callbackButton.onclick = function(e) {
			e.preventDefault()
			log(`JS calling handler "testObjcCallback"`)
			bridge.callHandler(`testObjcCallback`, {`foo`: `bar`}, function(response) {
				log(`JS got response`, response)
			})
		}
	})
複製程式碼

剛開始,window物件上的WebViewJavascriptBridgeWVJBCallbacks變數還沒值;然後,定義了WVJBCallbacks陣列,這是一個方法陣列,存放了註冊事件的操作。然後,宣告瞭一個iframe,它的src是一個假地址(這也是為什麼稱呼它叫假跳轉)。簡單的說,html中的iframe就是開啟一個網頁。表現到webView上,就是跳轉了一個新的連結。對於這個假連結,客戶端當然不會做跳轉,而是去注入了JS指令碼檔案。
JS檔案主要用於處理web和native的通訊,是用佇列的方式去接收和傳送事件。注入的JS檔案做了什麼?拋開一大堆的宣告,可以看到如下的呼叫:

    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);
		}
	}
複製程式碼

此處又建立了一個iframe,它的src是特定格式的:https://__wvjb_queue_message__。我們還看到,_callWVJBCallbacks方法中遍歷了WVJBCallbacks陣列,並且傳遞了WebViewJavascriptBridge。回到上文看陣列中的方法,你會發現,這個時候:互動事件被註冊。再回到客戶端,這裡的src也不是跳轉連結,而是web和native的事件互動“觸發器”。

總結:WebViewJavascriptBridge會觸發2類假跳轉,第一類用於客戶端向webView注入js,第二類用於web和native事件互動。原理就是跳轉的過程中攔截了請求,對請求做了特定的處理。

2.用WKWebView的API實現

本文的主題是不攔截Request,如果真的這麼做,從上文的流程,我們可以看出2個問題。

  • 如何注入js檔案?
  • 如何“傳遞互動”事件?

解決問題2:
Apple提供了JavaScriptCore之後,JS在iOS中的使用如魚得水。Webkit中有一個關鍵的類:WKUserContentController。看它的介紹:

A WKUserContentController object provides a way for JavaScript to post
messages to a web view.
The user content controller associated with a web view is specified by its
web view configuration.

簡單的說,我們可以通過註冊事件的方式實現WebView的JS和Native互動。(程式碼示例我就不提供了,看API文件)

解決問題1:
Webkit還提供了一個類:WKUserScript。它只提供一個公有方法,用於注入script檔案。有2種可選時機,一種是頁面載入完成,一種是頁面開始載入。

其實到這裡,這個方案的初步輪廓已經完成了。簡單的說就是用WKWebView的API。但是,還有優化的地方。

優化

我們在WebViewJavascriptBridge的ExampleApp.html檔案中可以看到,每一個互動的事件都需要單獨的註冊。能否用一個事件去處理呢?這是完全可以的。我們知道,事件互動時,會傳遞資料,我們可以:把需要呼叫的方法名作為引數傳遞給客戶端,客戶端用NSMethodSignature類生成函式簽名,最後通過runtime去呼叫對應的方法。

例如:在注入的js檔案中,我們可以這麼做:

//  ...其他處理

function _on(event, callback) {
    //...略
    _event_hook_map[event] = callback;
}

function _handleMessageFromApp(message) {
    //...略
    switch(message) {
        case `event`: {//...}
        case `init` : {
            var ret = _event_hook_map[xxx];
        }
    }
}

function _setDefaultEventHandlers() {
    _on(`sys:init`,function(ses){
        if (window.RbJSBridge._hasInit) {
            console.log(`hasInit, no need to init again`);
            return;
        }else{
            console.log(`init event`);
        }

        window.RbJSBridge._hasInit = true;

        // bridge ready
        var readyEvent = doc.createEvent(`Events`);
        readyEvent.initEvent(`RbJSBridge`);
        doc.dispatchEvent(readyEvent);
    });
}

var doc = document;
_setDefaultEventHandlers();
複製程式碼

1.webView載入時,注入js,js會呼叫_setDefaultEventHandlers();方法。_event_hook_map中存放了註冊事件的方法,但是它要等待頁面載入成功才能註冊。

2.當客戶端頁面載入成功後,客戶端在webView的didFinish代理方法中主動呼叫_handleMessageFromApp()方法,告訴web開始註冊事件。

當客戶端收到web的互動事件時,我們需要做的是把方法名“翻譯”成函式。這裡需要一套統一的規則。即我們規定(根據自身需要,兩端人員制定):呼叫的所有客戶端方法都只有2個引數,一是字典引數,而是block回撥。然後,將其生成方法,如下:

+ (id)ur_performSelectorWithTargetName:(NSString *)targetName selector:(SEL)aSelector withObjects:(NSArray *)objects {
    URWebWidgetManager *manager = [URWebWidgetManager shareInstance];
    id realInstance = [manager.widgets objectForKey:targetName];
    if ([realInstance respondsToSelector:aSelector]) {
        NSMethodSignature *signature = [realInstance methodSignatureForSelector:aSelector];
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
        [invocation setTarget:realInstance];
        [invocation setSelector:aSelector];
        
        NSUInteger i = 1;
        
        for (id object in objects) {
            id tempObject = object;
            [invocation setArgument:&tempObject atIndex:++i];
        }
        [invocation invoke];    //方法被執行
        
        if ([signature methodReturnLength]) {
            id data;
            [invocation getReturnValue:&data];
            return data;
        }
    }
    return nil;
}
複製程式碼

這樣,我們就不需要每次都在html和客戶端中註冊互動事件。而是前端規定好呼叫的客戶端方法名,客戶端提供對應的實現就好了。

最後

不攔截Request只是一種實現方式,我並沒有去檢測這麼做會不會比攔截的效能更高。可是,我覺得提供唯一的事件註冊,並把這個工作放在注入的js檔案中去做,可以極大的減少兩端開發人員要做的事情。web端呼叫客戶端時,提供方法名和引數即可。客戶端只要實現對應的方法名函式即可。
目前,基於這種方案實現的H5容器已經在我們公司的線上產品中使用。我還沒有單獨整理出demo,整理出來後第一時間更新。

相關文章