前戏
自己从事iOS开发多年,单在电商领域,从GMall全球购再到河狸家app,已经开发了多款电商app,也想将自己的一些经验分享给大家,希望能够帮助到正在面临困难的团队及同行业的技术朋友们。
一 如何架构一款hybrid app
以下是做hybrid相关工作的总结:
为什么要hybrid?
首先要看场景:假如是客户端更新实时性要求高的,必然要考虑前端工作好一点,这是基础的要求,否则就将计算尽量集中到云端,譬如一些游戏3D、GPU运算要求高的;
并且电商平台都存在很多运营活动,那么h5页面与native页面的交互则十分频繁且紧密,需要更新的频率很快。
那么从普遍的技术选择角度:有react-native或者cordova、以及基于webview的H5+native等
此时自己造个轮子呢,还是利用成熟的开源进行组合,这就要团队技术leader根据团队现状的不同去选择不同的技术架构;
之所以需要hybrid还得看它的优势:
而它有什么优势呢?
1 从开发效率角度:开发快,
2.从是否利于版本更新频繁角度:更新版本快
3.从人力成本角度: 解决开发人力成本
4. 从可移植性角度:跨平台, 方便迁移or内嵌到其他项目
5.对业务帮助角度:对营销策略有利,便于维护
那么如何架构一款Hybrid的app?
1 JSBridge (runtime机制, JavaScriptCore(iOS,但与安卓不统一))
2 Native与H5 API ( Header组件, 其他native组件, 路由, Device API, 网络请求)
3 调试
4 资源 ( 打包, 缓存, 增量更新, 资源访问机制)
接下来我们聊一聊如何建立交互协议;
二 协议方案与问题:
首先不同语言之间如何建立良好的通讯协议是个问题,那么iOS native实现与js交互有几种方案呢?
1.通过UIWebview的webView:shouldStartLoadWithRequest:navigationType:截取请求的URL,再通过特殊字符串,重定向;
这种方式的不好维护,蛋疼啊,字符串无限叠加啊;且不直观;(这也是河狸家最早的交互设计,带来很多坑点)
2.XMLHttpRequest bridge
JS 端使用 XMLHttpRequest 发起了一个请求:execXhr.open('HEAD', "/!gap_exec?" + (+new Date()), true); ,请求的地址是 /!gap_exec;
并把请求的数据放在了请求的 header 里面,见这句代码:execXhr.setRequestHeader('cmds', iOSExec.nativeFetchMessages());。
而在 Objective-C 端使用一个 NSURLProtocol 的子类来检查每个请求,如果地址是/!gap_exec的话,则认为是 Cordova 通信的请求,直接拦截,拦截后就可以通过分析请求的数据,分发到不同的插件类;
3.可以通过JavaScriptCore框架的JSContext注入方法来实现;
但这里还需要结合业务一起去看,一般平台都有iOS与安卓两端,如何能够让iOS与安卓统一一套协议,这样极力减少开发与沟通成本,这成架构设计中的一个问题。
那么我们来看看安卓非常好用的一种方式,从而诞生了第三种方案:
建立一个jsbrige,js中注入native对象与方法,js通过window直接操作native对象、及方法;
沿着这个思路,iOS是否能实现呢?一个好的设想!
很棒的是,果然是有办法的,但是需要做一些改造!!!
• 需要制定一套oc与js双方协议,校验协议,让oc与js能够识别对象/方法/参数;
• oc与js通信使用webViewstringByEvaluatingJavaScriptFromString:js,将oc的对象注入给js,并暴露方法/参数给js,注入oc对象;
• iframe bridge
• 在JS端创建一个透明的iframe,设置这个 ifame 的 src 为自定义的协议,而 ifame 的 src 更改时,UIWebView会先回调其 delegate 的webView:shouldStartLoadWithRequest:navigationType:将js信息抛给oc,是一个json串(包含注入对象,方法,参数);
• 符合协议的通信信息抛给oc的解析器去做事件分发;
通过调研与寻找,发现了EasyJSWebView类库,确实是个好东西,接下来就对这个类库以河狸家app工程为例做下核心源码分析;(请结合demo代码)
三 EasyJSWebView核心类介绍:
首先初始化EasyJSWebView,并设置EasyJSWebViewProxyDelegate为delegate,核心环节都在delegate的回调里;
EasyJSWebViewProxyDelegate的关键回调如何实现呢?
核心代码:
oc跟js通信由于采用的stringByEvaluatingJavaScriptFromString方法可以将javascript嵌入html页面中;
而stringByEvaluatingJavaScriptFromString需要在js加载完成以后才可调用,
则需要在webViewDidFinishLoad: 回调中调用注入方法injectJavascript();
(注意此处:原demo有个bug,injectJavascript()是放在webViewDidStartLoad:(UIWebView *)webView方法中执行的,会造成h5内部跳转新页面后就注入无效了;河狸家app做了下改造,将它放在webViewDidFinishLoad回调中执行的;)
(后续会以demo形式开源,但这里会造成js注入延迟,如果html想要在一开始加载就要对native做一些事,这里就会出现问题,也是这个设计的缺点之一);
四 如何实现注入
那到底注入过程是怎么执行的呢?
在上图中的96-121行代码利用到了runtime运行时机制:
这部分做的事情就是将对象,方法组成的数组拼接字符串,“\”是html中的转义需要,最后结果是这样的一个结构 EasyJS.inject(\对象,[方法数组])
河狸家为例子就是EasyJS.inject("HLJJavaScript", ["jsShowSomething:", "loginBlock", "resetPasswordBlock", "setShare:"]);
但是要看Objective-C 跟 JS 通信,会先调用,stringByEvaluatingJavaScriptFromString执行js;
对INJECT_JS 这段js进行深入源码解析
1.EasyJS对象的内容
当js执行这段js,会在window创建 EasyJS对象,EasyJS对象有call /inject /invokeCallback 三个方法 ,一个__callbacks对象;
我们从下往上看;
解析inject方法:
window[obj] = {}; 左边的意思是window中增加一个obj key,增加申明一个obj native对象;
var jsObj = window[obj]; 将它赋给jsObj对象
for (var i = 0, l = methods.length; i < l; i++){
//methods是个我们的方法数组
注意for循环中做的事情:
首先简化下结构 function{}(),function{}表示这是个方法,而加上()表示执行此方法,如果不加()就是单单申明方法但不执行;
也就是说这个for循环几次就会调用执行几次fuction()方法的意思;
那么看fuction中具体做什么事:
var method = methods[i]; 表示得到数组中的一个方法
var jsMethod = method.replace(new RegExp(":", "g"), "”); 这就是正则表达式 ,做一些字符替换
好,接着看:
注意左边,jsObj[jsMethod],而jsObj是咱们上面创建的一个对象,还记得吧。对象是个空的,还没有任何值;
[jsMethod] 表示往jsObj对象里添加设置方法,方法名由jsMethod来决定,是个动态创建方法;
那么观察整个函数inject: function() 无非就创建了一个对象,并且往对象中添加了方法;
好,此时我们又看到:
return EasyJS.call(obj, method, Array.prototype.slice.call(arguments));
3. EasyJS的call方法解析
EasyJS.call,是EasyJS底下的call方法啦;
第一个对象 ,第二个方法 ,第三个Array.prototype.slice.call(arguments);
解释下Array.prototype.slice.call(arguments);
例子:
var a={length:2,0:'first',1:'second'};
Array.prototype.slice.call(a);
//得到结果: ["first", "second"]
Array.prototype.slice.call(arguments)能将具有length属性的对象转成数组;
其实就是把key 0和1 的值"first", "second"取出来,拼装成一个数组;
也就是说Array.prototype.slice.call(arguments) 这个就是个数组;
Array.prototype:就是Array的原型,Array.prototype.slice这句就是访问Array的内置方法,因为Array是类名,而不是对象名,所以不能直接用Array.slice ,而要用Array.prototype.slice
解释下函数体:
第一部分:
var formattedArgs = [];//申明创建一个数组
第二部分:
for (var i = 0, l = args.length; i < l; i++) //循环遍历数组
if (typeof args[i] == "function"){如果数组元素的类型是方法,也就是说args[i]类型是个方法的话就进入
formattedArgs.push("f”);//formattedArgs是个数组,push表示添加到数组中相当于我们的add
var cbID = "__cb" + (+new Date);//就是获取一个时间戳
EasyJS.__callbacks[cbID] = args[i];
formattedArgs.push(cbID);
我们来看EasyJS.__callbacks[cbID] //__callbacks这玩意不就是EasyJs对象顶部的声明的一个对象嘛?
之前里面没有任何属性与方法
那么EasyJS.__callbacks[cbID] = args[i];这个的意思就是往__callbacks对象中添加名叫cbID的属性与方法;值为args[i];
而刚刚if判断args[i]类型是方法,也就是说往__callbacks中增加了方法
第三部分
var argStr = (formattedArgs.length > 0 ? ":" + encodeURIComponent(formattedArgs.join(":")) : "");
formattedArgs.join(":") 首先这是个数组 调用数组的join()方法
返回值是个字符串,也就是说这个方法就是将formattedArgs的每个元素用:分隔开拼接成一个字符串
encodeURIComponent() 函数可把字符串作为 URI 组件进行编码。
返回例如:%2C%2F%3F%3A%40%26%3D%2B%24%23
也就是argStr是一个字符串;
第四部分:
iframe bridge
在 JS 端创建一个透明的 iframe,设置这个 ifame 的 src 为自定义的协议,而 ifame 的 src 更改时,UIWebView 会先回调其 delegate 的webView:shouldStartLoadWithRequest:navigationType:方法如下
var iframe = document.createElement("IFRAME”);//就是创建iframe对象,iframe是html中标签,
iframe.setAttribute("src", "easy-js:" + obj + ":" + encodeURIComponent(functionName) + argStr);
"easy-js:" + obj + ":" + encodeURIComponent(functionName)+argStr //设置src
document.documentElement.appendChild(iframe);//标签是要加到html的页面上的,html会加载这个js,只要加载js就会触发我们webview的should 和webfinsh等等代理方法
iframe.parentNode.removeChild(iframe);// 使用完之后记得删除
iframe = null;
五 shouldStartLoadWithRequest函数解析
当执行了js,ifame 的 src 更改时,UIWebView 会先回调shouldStartLoadWithRequest回调方法,
例子:easy-js:HLJBindJavaScript:setShare%3A:s%3A%257B%2522iconUrl
获取request string,截取成四个部分,协议头部easy-js:开头,obj为对象,method为方法,formattedArgs
必须是easy-js:协议开头,
获取类实例方法的签名,新建一个NSInvocation实例,指定实例与方法
invoker设置参数,然后执行invoke,注意参数中function类型的区分。
获取invoker执行的结果通过webView执行js代码返回结果值。
六 分析回调function的处理过程
上面说过js端call方法这样处理function的参数,EasyJS对象中__callbacks对象存储方法实现对象
再看native端拦截到请求,其中的代码
再看EasyJSDataFunction中:
return [self.webViewstringByEvaluatingJavaScriptFromString:injection];
将回调方法执行参数解析封装js函数字符串;
注意:
第一个参数表示js函数的唯一ID,方便js端找到该函数对象
第二个表示第一次回调完成是否移除该回调执行的函数对象的bool值,然后webView主动执行,这样就完成个整个的回调过程。
七 最后总结:
那么这个注入流程就清晰啦!!!
这个easy的核心也在EasyJSWebViewProxyDelegate;
NSString* js =INJECT_JS;//INJECT_JS是一段js代码
[webViewstringByEvaluatingJavaScriptFromString:js];
当webview加载你想要访问的url的时候,等资源加载结束了webViewDidFinishLoad;才会去注入,这里确实也存在注入延迟的问题,就是说js一旦要做一些一开始加载就想回调app的事情就做不了了,这也是弊端之一;
好,抛开这个问题不看 我们继续;
等资源加载结束了webViewDidFinishLoad 就会开始注入对象了
于是调注入方法- (void) injectJavascript:(UIWebView *)webView{}
[webViewstringByEvaluatingJavaScriptFromString:injection];
我们是通过桥接调用执行window.EasyJS = {}
这样window就有了EasyJS对象;
EasyJS对象有是call inject invokeCallback 三个方法 一个__callbacks对象;
以河狸家injection为例 :EasyJS.inject("HLJJavaScript", ["jsShowSomething:", "loginBlock", "resetPassBlock", "setShare:"]);
js执行这个代码:因为已经存在EasyJS对象,EasyJS可直接调用注入inject;
那么HLJBindJavaScript对象就是native接收和处理js回调oc所要做的事情;
暴露给js方法有jsShowSomething:", "loginBlock", "resetPassBlock", "setShare:”;
于是当h5想要让native做些事情时,如设置分享,就可以执行:
window.HLJJavaScript.setShare(json串)
就可以很开心滴实现交互啦!!!
八 期待拓展
当我们利用此协议实现了js与oc交互之后,其实可以帮助我们做很多很多事情;
如何利用运行时机制+组件化设计搭建app框架;
将会在不久后输出文章;
我的心愿是:咸鱼翻身!!!不要再让我帅到人人喊打!!!