JSBridge的思考

2,841 阅读7分钟

前言

最近在做一个web与原生交互的需求,需求背景是这样子的,提供一个SDK里面包含一个webview用于加载业务h5,原生这边赋予webview选择相片、相机、刷脸、关闭原生界面的能力。虽然这个功能逻辑都是“熟悉的配方”,但还是有不少坑。

webview执行JS阻塞

项目一开始使用的桥接框架是以前项目用的桥接框架,但这个项目里面有一功能点跟旧项目不一样,旧项目只涉及到单图片的选择和上传而新项目需要支持多图片选择和上传,因为以前单图片选择上传整个过程响应较快,所以没关注执行JS时卡住了主线程,但这次项目是多图片选择上传而且h5多了ocr识别,导致整个处理相对耗时,原生这边执行JS一个回调将多张图片数据回传给h5处理,实例代码如下

[UIWebView stringByEvaluatingJavaScriptFromString:jsstring]

这个方法是一个同步方法,他会阻塞到JS方法执行结束才会返回,这时整个UI就会卡住。一开始的解决方案是通过原生这边异步派发队列解决同步的问题,但这又是一个坑,会致webview出现偶现的crash,这个稍后再详讲。原生这边不通,那就从JavaScript这一边着手,熟悉JavaScript的同学都知道,setTimeout方法能够实现异步,如果代码中设定了一个 setTimeout,那么浏览器便会在合适的时间,将代码插入任务队列,如果这个时间设为 0,就代表立即插入队列,但不是立即执行,仍然要等待前面代码执行完毕,所以 setTimeout 并不能保证执行的时间,是否及时执行取决于 JavaScript 线程是拥挤还是空闲,但它能够解决我们执行JS代码导致的同步问题,在我们原生调用JS回调之前用setTimeout做一层包装,相当于调用setTimeout方法,一调用就即刻返回,不阻塞线程,实例代码如下:

function asyncallback(callback,params) {if(typeof callback == 'function'){setTimeout(function () {callback(params);},0);}}

Why no WebViewJavascriptBridge

当给出第一版SDK给h5同事联调的时候,h5同事反馈了几个意见:
1、桥接依赖于协议定制和iframe,数据传输透明,存在安全隐患;
2、调用方式过于硬编码,调用时需要匹对填入方法名和参数,希望我这边设计出类似微信web api;
3、webview出现偶现的crash;
4、希望支持命名空间;
有人会问为什么不用业界更加成熟桥接框架WebViewJavascriptBridge,我们通过读源码可知WebViewJavascriptBridge底层还是依赖于协议定制和iframe,并不支持命名空间,而且crash还是会出现(网友反馈)。 综合上次的意见,我们需要重新设计我们的桥接框架,原框架的两端交互依赖iframe发请求、拦截请求来进行交互,iOS还有另外一个方案来实现两端交互:JavaScriptCore,想深入了解JavaScriptCore可以看这篇文章,而且通过JavaScriptCore设计的js api的代码风格可以做到微信web api的效果。JavaScriptCore框架是一个苹果在iOS7引入的框架,该框架让 Objective-C 和 JavaScript 代码直接的交互变得更加的简单方便,而JavaScriptCore是苹果Safari浏览器的JavaScript引擎。通过JavaScriptCore,我们可以以写原生代码的方式写JavaScript,最终JavaScriptCore都会将我们的原生代码顺滑、安全转化为JavaScript层的实现。我们以这个JavaScriptCore框架为基础设计我们的桥接组件XDMicroJSBridge。

XDMicroJSBridge简概

关键类

JSContext: JSContext是JavaScript的执行环境;
JSValue: JSValue代表一个JavaScript实体,一个JSValue可以表示很多JavaScript原始类型例如boolean、 integers、doubles甚至包括对象和函数;

实现原理

先在原生注册对应的暴露给h5使用js API函数名,通过[JSContext currentArguments]捕获方法的参数,参数的类型是JSValue,JSValue提供一系列方法将值转换成合适的Objective-C值或对象,方便这边原生处理,通过block包装原生调用方法(相机、相册等),将block注入JSContext当中,命名空间的实现是往JSContext注入一个空实现的类,需要赋予命名空间的方法则将对应包装的block注入到这个空实现的类中。想了解具体实现点击github.com/caixindong/…。实例代码如下:

- (void)registerAction:(NSString *)action handler:(XDMCJSBHandle)handler {
    if (action && handler) {
        __weak typeof(self) weakSelf = self;
        _context[_nameSpace][action] = ^{
            NSLog(@"action is %@",action);
            __strong typeof(weakSelf) strongSelf = weakSelf;
            strongSelf.webThread = [NSThread currentThread];
            NSLog(@"webThread is %@",[NSThread currentThread]);
            NSArray *args = [JSContext currentArguments];
            JSValue *last = (JSValue *)[args lastObject];
            XDMCJSBCallback ncallback = nil;
            NSMutableArray *trueArgs = [NSMutableArray arrayWithArray:args];
            if ([last isObject] && [[last toDictionary] isEqualToDictionary:@{}]) {
                [trueArgs removeLastObject];
                ncallback = ^(NSDictionary *params){
                    [strongSelf performSelector:@selector(_callJSMethodWithArgs:) onThread:strongSelf.webThread withObject:@[last, params] waitUntilDone:NO];
                };
            }
            NSMutableArray *trueOCArgs = [NSMutableArray array];
            for (JSValue *value in trueArgs) {
                if ([value isObject]) {
                    [trueOCArgs addObject:[value toDictionary]];
                } else if ([value isString]) {
                    [trueOCArgs addObject:[value toString]];
                } else if ([value isNull]) {
                    [trueOCArgs addObject:[NSNull null]];
                } else if ([value isBoolean]) {
                    [trueOCArgs addObject:[NSNumber numberWithBool:[value toBool]]];
                }
            }
            handler([trueOCArgs copy], ncallback);
        };
    }
}

实现难点

JSValue提供了JavaScript原始类型boolean、integers、doubles、对象转化方法,但没有提供函数的转化方法,因为JS函数参数一般都会包含回调,回调是function对象,所以这一块转化是很有必要的,由代码可见我这边是通过一个oc的block保存了函数回调的信息。

webthread crash

对于crash问题,经过我多次调试发现,在web与原生交互多次后再触发下一次交互会发现野指针crash,频次不定,crash栈定位到webview的webthread。两种实现方案都会出现这个问题。总所周知,JavaScript是以单线程的方式运行的,所以webview底层会维护一个线程用于处理JavaScript的交互,网上很多例子和教程在webview执行js代码的时候都会派发到主线程,可是webthread有时候并不在主线程,这是有隐患的,如果是频次低的交互可能不会触发这个bug,当频次高时,就例如我这个项目,h5内有很多表单需要上传选择图片这种跨端操作,就可能会触发webthread crash。网上资料和官方文档并没有对这个crash做具体的解释,我猜测可能是底层线程通信派发出现问题,所以正确的做法应该是webview内JavaScript的执行和回调应始终在一个线程,以防止线程切换导致偶现crash。那怎么获取webthread,获取webthread的时机应该是JavaScript的执行环境初始化完成之后,所以可以在包装原生调用方法的block捕获这个webthread,因为h5触发原生封装的js api后会跑进封装原生方法block,这时候上下文已经初始化完成,而且也是在webview维护的webthread内。实例代码如下:

- (void)registerAction:(NSString *)action handler:(XDMCJSBHandle)handler {
    if (action && handler) {
        __weak typeof(self) weakSelf = self;
        _context[_nameSpace][action] = ^{
            NSLog(@"action is %@",action);
            __strong typeof(weakSelf) strongSelf = weakSelf;
            strongSelf.webThread = [NSThread currentThread];
            NSLog(@"webThread is %@",[NSThread currentThread]);
}

然后在这个线程执行js相关逻辑代码,这样修改后,crash没再出现,实例代码如下:

[self performSelector:@selector(_callJSMethodWithArgs:) onThread:strongSelf.webThread withObject:@[callback, params] waitUntilDone:NO];

最终框架实现效果

相比其他桥接框架,XDMicroJSBridge更加轻量(代码量不到100行),支持命名空间,原生专注原生代码,web专注JavaScript,维护一致的web thread。

初始化Bridge

#import "XDMicroJSBridge.h"
@property (nonatomic, strong) UIWebView *webview;
@property (nonatomic, strong) XDMicroJSBridge *bridge;
@property (nonatomic, copy) XDMCJSBCallback callback;
self.bridge = [XDMicroJSBridge bridgeForWebView:_webview];

注册JS方法

__weak typeof(self) weakself = self;
[_bridge registerAction:@"camerapicker" handler:^(NSArray *params, XDMCJSBCallback callback) {
        dispatch_async(dispatch_get_main_queue(), ^{
            //if your javaScript method has callback, you should register this call like this.
            if (callback) {
                weakself.callback = callback;
            }
            UIImagePickerController *cameraVC = [[UIImagePickerController alloc] init];
            cameraVC.delegate = weakself;
            cameraVC.sourceType = UIImagePickerControllerSourceTypeCamera;
            [weakself presentViewController:cameraVC animated:YES completion:nil];
        });
    }];

h5调用原生注册的JS方法

<script>
    function clickcamera() {
        XDMCBridge.camerapicker(function (response) {
            var photos = response['photos'];
            var insert = document.getElementById('insert');
            for(var i = 0; i < photos.length; i++) {
                var img = new Image(100,100);
                img.src = photos[i];
                insert.appendChild(img);
            }
        });
    }
</script>

想了解更多iOS终端相关知识可以前往终端杂谈