最近在做hybrid相关的工作,项目中用到了EasyJsWebView
,代码量不大,一直想分析一下它的具体实现,抽空写了这篇文章。
1.前言 原生代码+h5页面+甚至React Native(或其他) 的方式开发移动客户端已经成为当前的主流趋势,因此老生常谈的一个问题就是原生代码与js的交互。原生代码中执行js代码,没什么可讲的直接webView执行js代码即可,本文主要由安卓的js调用原生的方式切入,分析iOS端是如何实现类似比较方便的调用的。
2.安卓端(js -> native interface) 对安卓的开发不是很熟,只是列举一个简单的例子讲述这样一种方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public void onCreate (Bundle savedInstanceState) { ... webView.addJavascriptInterface(new Contact(), "contact" ); } private final class Contact { public void showcontacts () { String json = "[{\"name\":\"zxx\", \"amount\":\"9999999\", \"phone\":\"18600012345\"}]" ; webView.loadUrl("javascript:show('" + json + "')" ); } }
1 2 3 4 5 6 7 8 9 10 11 <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例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @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];
1 2 3 4 5 MyJSTest.test(); MyJSTest.testWithParam("ha:ha" ); MyJSTest.testWithTwoParamAndParam2("haha1" , "haha2" ); var str = MyJSTest.testWithRet();
4.2 EasyJsWebView具体实现 4.2.1 EasyJsWebView初始化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 - (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回调实现 代码片段一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 NSMutableString * injection = [[NSMutableString alloc] init]; 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; [webView stringByEvaluatingJavaScriptFromString:js]; [webView stringByEvaluatingJavaScriptFromString:injection]; }
遍历注入的接口的列表key
通过key获取注入类的实例
通过类的实例获取实例方法的列表
依次拼接需要执行js函数的代码
EasyJS对象的加载,执行EasyJS.inject方法
例子:参考Demo调试结果如下
1 2 3 4 5 6 7 8 9 EasyJS.inject ("MyJSTest" , [ "test" , "testWithParam:" , "testWithTwoParam:AndParam2:" , "testWithFuncParam:" , "testWithFuncParam2:" , "testWithRet" ]);
4.2.2.2 EasyJS对象 代码片段一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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
方法的实现。
代码片段二:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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元素上
修改IFRAME
的src
默认会触发webView的回调的执行,因此便有了下面方法shouldStartLoadWithRequest
的拦截。
4.2.2.3 shouldStartLoadWithRequest回调实现 代码片段一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 NSArray *components = [requestString componentsSeparatedByString:@":" ]; NSString * obj = (NSString *)[components objectAtIndex:1 ];NSString * method = [(NSString *)[components objectAtIndex:2 ] stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding ]; NSObject * interface = [javascriptInterfaces objectForKey:obj]; 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的处理过程。
代码片段二:
1 2 3 4 5 6 7 8 9 10 11 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
是如何处理回调方法参数的。
代码片段一:
1 2 3 4 5 6 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字典存储方法实现对象
代码片段二:
1 2 3 4 5 6 if ([@"f" isEqualToString:type]){ EasyJSDataFunction* func = [[EasyJSDataFunction alloc] initWithWebView:(EasyJSWebView *)webView]; func.funcID = argStr; [args addObject:func]; [invoker setArgument:&func atIndex:(j + 2 )]; }
native端拦截到请求,执行方法
代码片段三:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 - (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回调执行语句调试
1 EasyJS.invokeCallback("__cb1462414605044" , true, "blabla%3A %22bla " );
6.存在问题 见如下代码我们分析实现会发现jsObj
全局字典方法区分的key是方法名的拼接,且去处了连接符号:
,因此产生疑问这样可能还是会出现同一个key对应不同的方法。
1 2 3 4 5 6 7 (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
增加一个实现的接口
1 2 3 4 - (void ) testWithTwoParamAndParam2: (NSString *) param { NSLog (@"testWithTwoParamAndParam2 invoked %@" ,param); }
这样就会与以下方法冲突
1 2 3 - (void ) testWithTwoParam: (NSString *) param AndParam2: (NSString *) param2{ NSLog (@"test with param: %@ and param2: %@" , param, param2); }
Demo改成如下调用
1 MyJSTest.testWithTwoParamAndParam2("haha1" , "haha2" );
抛出异常,原因就是js方法全局字典的keytestWithTwoParamAndParam2
所对应的方法被下一个方法覆盖。
1 *** 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一块比较好的实现也欢迎分享大家一起学习,谢谢!!!