不拦截Request!基于WKWebView的API实现Hybrid容器

3,252 阅读5分钟

在介绍我实现的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,整理出来后第一时间更新。