最近在做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元素上
修改IFRAME
的src
默认会触发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一块比较好的实现也欢迎分享大家一起学习,谢谢!!!