EasyJsWebView 源码分析

作者: Arnold134777 | 来源:发表于2016-05-05 17:01 被阅读1680次

    最近在做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一块比较好的实现也欢迎分享大家一起学习,谢谢!!!

    相关文章

      网友评论

      • 440e45207ed3:写的太冗余了,明明一段代码搞定的东西
        Arnold134777:@440e45207ed3
        愿闻其详
      • Jabir_Zhang:求助:我将easyJS的UIWebview改成了WKWebView,现在遇到了注入的方法如果是直接有return返回值的JS那边拿不到,打印出来的值是undefine,怎么解决
      • 我是认真的団:好难啊,我是培训了5个月iOS刚出来,是不是功力远远不够接触这个啊
      • 998e4a3fbbdc:楼主 如何解决网页跳转 对象不能注入的问题?
        Aa刘德健:请问解决了吗,我也遇到了
      • 998e4a3fbbdc:楼主 我现在的需求是给H5界面传输参数 已经商定了params这个方法 需要修改 框架里面的东西么?
        Arnold134777:@雷大腚 这个应该不存在啊,跳转到的另一个页面,也注入这个接口协议就行了,不明白你的意思
        998e4a3fbbdc:@Arnold134777 恩是的不需要 可是这个网页一旦跳转参数就不能传了 你有解决方案么
        Arnold134777:@雷大腚 应该不需要
      • L_Sovereign:EasyWebVIew中oc调用js传值也是stringByEvaluatingJavaScriptFromString这个方法么
        Arnold134777:@Sovereign 是的

      本文标题:EasyJsWebView 源码分析

      本文链接:https://www.haomeiwen.com/subject/yjvdrttx.html