EasyJsWebView 源码分析

最近在做hybrid相关的工作,项目中用到了EasyJsWebView,代码量不大,一直想分析一下它的具体实现,抽空写了这篇文章。

1.前言

原生代码+h5页面+甚至React Native(或其他) 的方式开发移动客户端已经成为当前的主流趋势,因此老生常谈的一个问题就是原生代码与js的交互。原生代码中执行js代码,没什么可讲的直接webView执行js代码即可,本文主要由安卓的js调用原生的方式切入,分析iOS端是如何实现类似比较方便的调用的。

2.安卓端(js -> native interface)

对安卓的开发不是很熟,只是列举一个简单的例子讲述这样一种方式。

  • native端
public void onCreate(Bundle savedInstanceState) {
    ...
    // 添加一个对象, 让JS可以访问该对象的方法, 该对象中可以调用JS中的方法
    webView.addJavascriptInterface(new Contact(), "contact");
}

private final class Contact {
    //Html调用此方法传递数据
    public void showcontacts() {
        String json = "[{\"name\":\"zxx\", \"amount\":\"9999999\", \"phone\":\"18600012345\"}]"; 
        // 调用JS中的方法
        webView.loadUrl("javascript:show('" + json + "')");
    }
}
  • h5端
<html>
<body onload="javascript:contact.showcontacts()">
   <table border="0" width="100%" id="personTable" cellspacing="0">
        <tr>
            <td width="30%">姓名</td>
            <td width="30%" align="center">存款</td>
            <td align="center">电话</td>
        </tr>
    </table>
</body>
</html>

当h5页面加载时,onload方法执行,对应的native端中的Contact类中的showcontacts方法被执行。因此核心思想就是通过webView将native原生的类与自定义的js对象关联,js就可以直接通过这个js对象调用它的实例方法。

3.iOS端(js -> native interface)

上述安卓的js调用native的方式是如此简单明了,不禁想如果iOS端也有如此实现的话,这样同时即保证安卓,iOS,h5的统一性也能让开发者只用关心交互的接口即可。因此便引出了EasyJSWebView的第三方的框架(基于说明2设计),下面从该框架的使用出发,分析框架的具体实现。

说明:

  • 1.iOS端虽然也可以通过JSContext注入全局的方法但是达不到与安卓端统一
  • 2.iOS端可以通过拦截h5请求的url,通过url的格式区分类或方法,但是这样不够直观,也达不到与安卓端统一

4.EasyJsWebView

4.1 EasyJsWebView使用

本文直接列举EasyJsWebView Github README例子

  • native端
@interface MyJSInterface : NSObject

- (void) test;
- (void) testWithParam: (NSString*) param;
- (void) testWithTwoParam: (NSString*) param AndParam2: (NSString*) param2;

- (NSString*) testWithRet;

@end

// 注入
MyJSInterface* interface = [MyJSInterface new];
[self.myWebView addJavascriptInterfaces:interface WithName:@"MyJSTest"];
[interface release];

  • js端
MyJSTest.test();
MyJSTest.testWithParam("ha:ha");
MyJSTest.testWithTwoParamAndParam2("haha1", "haha2");

var str = MyJSTest.testWithRet();

4.2 EasyJsWebView具体实现

4.2.1 EasyJsWebView初始化

- (id)init{
    self = [super init];
    if (self) {
        [self initEasyJS];
    }
    return self;
}

- (void) initEasyJS{
    self.proxyDelegate = [[EasyJSWebViewProxyDelegate alloc] init];
    self.delegate = self.proxyDelegate;
}

- (void) setDelegate:(id<UIWebViewDelegate>)delegate{
    if (delegate != self.proxyDelegate){
        self.proxyDelegate.realDelegate = delegate;
    }else{
        [super setDelegate:delegate]; 
    }
}

初始化设置webView的delegate,实际的webView的回调的在EasyJSWebViewProxyDelegate中实现,因此我们主要关注EasyJSWebViewProxyDelegate中的webView的回调的实现即可。

4.2.2 EasyJSWebViewProxyDelegate webView回调实现

4.2.2.1 webViewDidStartLoad回调实现

代码片段一:

NSMutableString* injection = [[NSMutableString alloc] init];
    
//inject the javascript interface
for(id key in self.javascriptInterfaces) {
    NSObject* interface = [self.javascriptInterfaces objectForKey:key];
    
    [injection appendString:@"EasyJS.inject(\""];
    [injection appendString:key];
    [injection appendString:@"\", ["];
    
    unsigned int mc = 0;
    Class cls = object_getClass(interface);
    Method * mlist = class_copyMethodList(cls, &mc);
    for (int i = 0; i < mc; i++){
        [injection appendString:@"\""];
        [injection appendString:[NSString stringWithUTF8String:sel_getName(method_getName(mlist[i]))]];
        [injection appendString:@"\""];
        
        if (i != mc - 1){
            [injection appendString:@", "];
        }
    }
    
    free(mlist);
    
    [injection appendString:@"]);"];
    
    
    NSString* js = INJECT_JS;
    //inject the basic functions first
    [webView stringByEvaluatingJavaScriptFromString:js];
    //inject the function interface
    [webView stringByEvaluatingJavaScriptFromString:injection];
}
  • 遍历注入的接口的列表key
  • 通过key获取注入类的实例
  • 通过类的实例获取实例方法的列表
  • 依次拼接需要执行js函数的代码
  • EasyJS对象的加载,执行EasyJS.inject方法

例子:参考Demo调试结果如下

EasyJS.inject("MyJSTest", 
[
    "test",
    "testWithParam:", 
    "testWithTwoParam:AndParam2:", 
    "testWithFuncParam:",
    "testWithFuncParam2:", 
    "testWithRet"
]);

4.2.2.2 EasyJS对象

代码片段一:

inject: function (obj, methods){
    window[obj] = {};
    var jsObj = window[obj];
    
    for (var i = 0, l = methods.length; i < l; i++){
        (function (){
            var method = methods[i];
            var jsMethod = method.replace(new RegExp(":", "g"), "");
            jsObj[jsMethod] = function (){
                return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));
            };
        })();
    }
}

遍历注入的类的实例方法的列表,通过一个全局的window[obj]的字典维护对应方法的具体实现。下面我们具体看看EasyJS.call方法的实现。

代码片段二:

call: function (obj, functionName, args){
    var formattedArgs = [];
    for (var i = 0, l = args.length; i < l; i++){
        if (typeof args[i] == "function"){
            formattedArgs.push("f");
            var cbID = "__cb" + (+new Date);
            EasyJS.__callbacks[cbID] = args[i];
            formattedArgs.push(cbID);
        }else{
            formattedArgs.push("s");
            formattedArgs.push(encodeURIComponent(args[i]));
        }
    }
    
    var argStr = (formattedArgs.length > 0 ? ":" + encodeURIComponent(formattedArgs.join(":")) : "");
    alert(argStr);
    var iframe = document.createElement("IFRAME");
    iframe.setAttribute("src", "easy-js:" + obj + ":" + encodeURIComponent(functionName) + argStr);
    document.documentElement.appendChild(iframe);
    iframe.parentNode.removeChild(iframe);
    iframe = null;
    
    var ret = EasyJS.retValue;
    EasyJS.retValue = undefined;
    
    if (ret){
        return decodeURIComponent(ret);
    }
},

这段代码做了三件事:

  • 1.分别针对参数function类型与其他类型区分处理
  • 2.创建一个IFRAME标签元素,设置src
  • 3.将新建的IFRAME添加到root元素上

修改IFRAMEsrc默认会触发webView的回调的执行,因此便有了下面方法shouldStartLoadWithRequest的拦截。

4.2.2.3 shouldStartLoadWithRequest回调实现

代码片段一:

NSArray *components = [requestString componentsSeparatedByString:@":"];
//NSLog(@"req: %@", requestString);
    
NSString* obj = (NSString*)[components objectAtIndex:1];
NSString* method = [(NSString*)[components objectAtIndex:2]
                    stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
    
NSObject* interface = [javascriptInterfaces objectForKey:obj];
    
// execute the interfacing method
SEL selector = NSSelectorFromString(method);
NSMethodSignature* sig = [[interface class] instanceMethodSignatureForSelector:selector];
NSInvocation* invoker = [NSInvocation invocationWithMethodSignature:sig];
invoker.selector = selector;
invoker.target = interface;
    
NSMutableArray* args = [[NSMutableArray alloc] init];
    
if ([components count] > 3){
    NSString *argsAsString = [(NSString*)[components objectAtIndex:3]
                              stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
    
    NSArray* formattedArgs = [argsAsString componentsSeparatedByString:@":"];
    for (int i = 0, j = 0, l = [formattedArgs count]; i < l; i+=2, j++){
        NSString* type = ((NSString*) [formattedArgs objectAtIndex:i]);
        NSString* argStr = ((NSString*) [formattedArgs objectAtIndex:i + 1]);
        
        if ([@"f" isEqualToString:type]){
            EasyJSDataFunction* func = [[EasyJSDataFunction alloc] initWithWebView:(EasyJSWebView *)webView];
            func.funcID = argStr;
            [args addObject:func];
            [invoker setArgument:&func atIndex:(j + 2)];
        }else if ([@"s" isEqualToString:type]){
            NSString* arg = [argStr stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
            [args addObject:arg];
            [invoker setArgument:&arg atIndex:(j + 2)];
        }
    }
}
[invoker invoke];
  • 1.拆分拦截到的requestString拆分为obj,method,formattedArgs三个部分
  • 2.获取类实例方法的签名,新建一个NSInvocation实例,指定实例与方法
  • 3.invoker设置参数,然后执行invoke,注意参数中function类型的区分,以下5中会分析回调function的处理过程。

代码片段二:

if ([sig methodReturnLength] > 0){
    NSString* retValue;
    [invoker getReturnValue:&retValue];
    
    if (retValue == NULL || retValue == nil){
        [webView stringByEvaluatingJavaScriptFromString:@"EasyJS.retValue=null;"];
    }else{
        retValue = (NSString *)CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(NULL,(CFStringRef) retValue, NULL, (CFStringRef)@"!*'();:@&=+$,/?%#[]", kCFStringEncodingUTF8));
        [webView stringByEvaluatingJavaScriptFromString:[@"" stringByAppendingFormat:@"EasyJS.retValue=\"%@\";", retValue]];
    }
}

获取invoker执行的结果通过webView执行js代码返回结果值。

5.EasyJSDataFunction 与 invokeCallback

以下主要分析EasyJsWebView是如何处理回调方法参数的。

代码片段一:

if (typeof args[i] == "function"){
    formattedArgs.push("f");
    var cbID = "__cb" + (+new Date);
    EasyJS.__callbacks[cbID] = args[i];
    formattedArgs.push(cbID);
}

js端call方法这样处理function参数,EasyJS对象一个全局的__callbacks字典存储方法实现对象

代码片段二:

if ([@"f" isEqualToString:type]){
    EasyJSDataFunction* func = [[EasyJSDataFunction alloc] initWithWebView:(EasyJSWebView *)webView];
    func.funcID = argStr;
    [args addObject:func];
    [invoker setArgument:&func atIndex:(j + 2)];
}

native端拦截到请求,执行方法

代码片段三:

- (NSString*) executeWithParams: (NSArray*) params{
    NSMutableString* injection = [[NSMutableString alloc] init];
    
    [injection appendFormat:@"EasyJS.invokeCallback(\"%@\", %@", self.funcID, self.removeAfterExecute ? @"true" : @"false"];
    
    if (params){
        for (int i = 0, l = params.count; i < l; i++){
            NSString* arg = [params objectAtIndex:i];
            NSString* encodedArg = (NSString*) CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef)arg, NULL, (CFStringRef) @"!*'();:@&=+$,/?%#[]", kCFStringEncodingUTF8);
            
            [injection appendFormat:@", \"%@\"", encodedArg];
        }
    }
    
    [injection appendString:@");"];
    
    if (self.webView){
        return [self.webView stringByEvaluatingJavaScriptFromString:injection];
    }else{
        return nil;
    }
}

回调方法执行,将回调方法执行参数解析封装js函数字符串,注意前两个参数第一个表示js函数的唯一ID方便js端找到该函数对象,第二个表示第一次回调完成是否移除该回调执行的函数对象的bool值,然后webView主动执行,这样就完成个整个的回调过程。

例子:Demo回调执行语句调试

EasyJS.invokeCallback("__cb1462414605044", true, "blabla%3A%22bla");

6.存在问题

见如下代码我们分析实现会发现jsObj全局字典方法区分的key是方法名的拼接,且去处了连接符号:,因此产生疑问这样可能还是会出现同一个key对应不同的方法。

(function (){
    var method = methods[i];
    var jsMethod = method.replace(new RegExp(":", "g"), "");
    jsObj[jsMethod] = function (){
        return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));
    };
})();

鉴于以上的疑问我改了一下Demo工程,MyJSInterface增加一个实现的接口

- (void) testWithTwoParamAndParam2: (NSString*) param
{
    NSLog(@"testWithTwoParamAndParam2 invoked %@",param);
}

这样就会与以下方法冲突

- (void) testWithTwoParam: (NSString*) param AndParam2: (NSString*) param2{
    NSLog(@"test with param: %@ and param2: %@", param, param2);
}

Demo改成如下调用

MyJSTest.testWithTwoParamAndParam2("haha1", "haha2");

抛出异常,原因就是js方法全局字典的keytestWithTwoParamAndParam2所对应的方法被下一个方法覆盖。

*** WebKit discarded an uncaught exception in the webView:decidePolicyForNavigationAction:request:frame:decisionListener: delegate: <NSInvalidArgumentException> -[NSInvocation setArgument:atIndex:]: index (3) out of bounds [-1, 2]

解决:

  • 1.可以尽量避免重名问题
  • 2.也可以替换分隔符号":"用其他特殊字符替换

本文结,本人还在不断学习积累中,如果对文章有疑问或者错误的描述欢迎提出。
或者你有hybrid iOS一块比较好的实现也欢迎分享大家一起学习,谢谢!!!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容