hybrid app如何通过js注入实现js与native交互(EasyJSWebView)

前戏

自己从事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的回调里;

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第1张图片
图片发自App

EasyJSWebViewProxyDelegate的关键回调如何实现呢?

核心代码:

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第2张图片
图片发自App


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;

图片发自App


对INJECT_JS 这段js进行深入源码解析

1.EasyJS对象的内容

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第3张图片
图片发自App2.EasyJS的inject方法解析

当js执行这段js,会在window创建 EasyJS对象,EasyJS对象有call  /inject /invokeCallback 三个方法  ,一个__callbacks对象;

我们从下往上看;

解析inject方法:

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第4张图片
图片发自App

window[obj] = {}; 左边的意思是window中增加一个obj key,增加申明一个obj native对象;

var jsObj = window[obj]; 将它赋给jsObj对象

for (var i = 0, l = methods.length; i < l; i++){

//methods是个我们的方法数组

注意for循环中做的事情:

图片发自App

首先简化下结构 function{}(),function{}表示这是个方法,而加上()表示执行此方法,如果不加()就是单单申明方法但不执行;

也就是说这个for循环几次就会调用执行几次fuction()方法的意思;

那么看fuction中具体做什么事:

var method = methods[i]; 表示得到数组中的一个方法

var jsMethod = method.replace(new RegExp(":", "g"), "”); 这就是正则表达式 ,做一些字符替换

好,接着看:

图片发自App

注意左边,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方法啦;

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第5张图片
图片发自App


第一个对象 ,第二个方法 ,第三个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回调方法,

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第6张图片
图片发自App

例子: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代码返回结果值。

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第7张图片
图片发自App

六 分析回调function的处理过程

上面说过js端call方法这样处理function的参数,EasyJS对象中__callbacks对象存储方法实现对象

hybrid app如何通过js注入实现js与native交互(EasyJSWebView)_第8张图片
图片发自App

再看native端拦截到请求,其中的代码

图片发自App


再看EasyJSDataFunction中:


图片发自App

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框架;

将会在不久后输出文章;


我的心愿是:咸鱼翻身!!!不要再让我帅到人人喊打!!!

你可能感兴趣的:(hybrid app如何通过js注入实现js与native交互(EasyJSWebView))