浅谈Hybrid技术的设计与实现javascript
接上文:浅谈Hybrid技术的设计与实现(阅读本文前,建议阅读这个先)前端
上文说了不少关于Hybrid的概要设计,能够算得上大而全,有说明有demo有代码,对于想接触Hybrid的朋友来讲应该有必定帮助,可是对于进阶的朋友可能就不太知足了,他们会想了解其中的每个细节,甚至是一些Native的实现,小钗这里继续抛砖引玉,但愿接下来的内容对各位有必定帮助。java
进入今天的内容以前咱们首先谈谈两个相关技术Ionic与React Native。ios
Ionic是一个基于Cordova的移动开发框架,他的一大优点是有一套配套的前端框架,由于是基于angularJS的,又提供了大量可用UI,可谓大而全,对开发效率颇有帮助;与咱们所说的Hybrid不一样的是,咱们的Native容器是由公司或者我的定制开发,Ionic的Native壳是第三方公司作的平台性产品,优劣不论,可是楼主是毫不会用太多第三方开源的东西的,举个例子来讲,你的项目中若是第三方UI组件越多,那么性能和风险相对会越多,由于你不了解他。另外一方面angular对于H5来讲,尺寸着实过大,最后以逼格来讲,Ionic仍是多适用于外包了。git
与Ionic不一样的是React Native,根据他写出来的View彻底是Native的View,那么这个逼格和体验就高了,小钗在此次Hybrid项目结束后,应该会着力在这方面作研究,这里没实际经验就很少说了。github
文中是我我的的一些开发经验,但愿对各位有用,也但愿各位多多支持讨论,指出文中不足以及提出您的一些建议。web
设计类博客(还有最后一篇便完结)ajax
http://www.cnblogs.com/yexiaochai/p/4921635.html
http://www.cnblogs.com/yexiaochai/p/5524783.html
http://www.cnblogs.com/nildog/p/5536081.html#3440931
文中IOS代码由我如今的同事Nil(http://www.cnblogs.com/nildog/p/5536081.html)提供,感谢Nil对项目的支持。
以前Android代码由明月提供,后续明月也会持续支援咱们Android的实现,感谢明月。
代码地址:https://github.com/yexiaochai/Hybrid
由于IOS不能扫码下载了,你们本身下载下来用模拟器看吧,下面开始今天的内容。
根据以前的知识,H5与Native交互的桥梁为Webview,而“联系”的方式是以url schema的方式作的,在用户安装app后,app能够自定义url schema,而且把自定义的url注册在调度中心, 例如
事实上Native能捕捉webview发出的一切请求,因此就算这里不是这种协议,Native也能捕捉,这个协议的意义在于能够在浏览器中直接打开APP,相关文献为:
又到周末了,咱们一块儿来研究【浏览器如何检测是否安装app】吧
这里盗用一张以前的交互模型图,确实懒得画新的了:
咱们在H5获取Native方法时通常是会构造一个这样的请求,使用iframe发出(设置location会有屡次请求覆盖的问题):
1 requestHybrid({ 2 //建立一个新的webview对话框窗口 3 tagname: 'hybridapi', 4 //请求参数,会被Native使用 5 param: {}, 6 //Native处理成功后回调前端的方法 7 callback: function (data) { 8 } 9 }); 10 //=====> 11 hybridschema://hybridapi?callback=hybrid_1446276509894¶m=%7B%22data1%22%3A1%2C%22data2%22%3A2%7D
多数状况下这种方式没有问题,可是咱们在后续的开发中,为了统一鉴权,将全部的请求所有代理到了Native发出,好比这样:
1 requestHybrid({ 2 tagname: 'post', 3 param: { 4 url: 'http://api.kuai.baidu.com/city/getstartcitys', 5 param1: 'param1', 6 param2: 'param2' 7 }, 8 callback: function(data) { 9 } 10 });
请注意,这里但是POST请求,这里首先考虑的是长度限制,毕竟这个是由iframe的src设置的,虽然各个浏览器不同,但一定会收到长度限制(2k),针对这个问题我咨询了糯米以及携程的Hybrid底层团队,获得了比较零星的回答:
① 移动端通常来讲不会有这么长的请求(这个在理)
② 咱们不支持IOS6了,如今用的JavaScriptCore
上面的答复不太满意,因而我尝试在页面上放一个全局变量(或者文本框)以解决参数过大的问题,而当我尝试解决的时候,产品告诉我:咱们早不支持IOS6了!
若是只用支持chrome用户,那么坚定不支持IE的!抱着这一想法,小钗也就放弃IOS6了
若是不支持IOS6,那么事情彷佛变得好办多了。
在ios7后,Apple新增了一个JavaScriptCore让Native能够与H5更好的交互(Android早就有了),咱们这里主要讨论js如何与Native通讯,这里举一个简单的例子:
PS:楼主对ios不熟,这段代码引至https://hjgitbook.gitbooks.io/ios/content/04-technical-research/04-javascriptcore-note.html
① 首先定义一个js方法,这里注意其中调用了一个没有声明的方法:
function printHello() { //未声明方法 print("Hello, World!"); }
而后,上述未声明方法事实上是Native注入给window对象的:
1 NSString *scriptPath = [[NSBundle mainBundle] pathForResource:@"hello" ofType:@"js"]; 2 NSString *scriptString = [NSString stringWithContentsOfFile:scriptPath encoding:NSUTF8StringEncoding error:nil]; 3 4 JSContext *context = [[JSContext alloc] init]; 5 [context evaluateScript:scriptString]; 6 7 self.context[@"print"] = ^(NSString *text) { 8 NSLog(@"%@", text"); 9 }; 10 11 JSValue *function = self.context[@"printHello"]; 12 [function callWithArguments:@[]];
这个样子,JavaScript就能够调用Native的方法了,这里Native须要注意方法注入的时机,通常是一旦载入页面便须要载入变量,这里的交互模型是:
因而,咱们这里只须要将原来底层的通讯一块改下便可(Android自己就支持相似的实现,这里暂时不予关注):
1 //使用jsCore与native通讯 2 window.requestNative && requestNative(JSON.stringify(params)); 3 return; 4 //兼容ios6 5 var ifr = $('<iframe style="display: none;" src="' + url + '"/>'); 6 $('body').append(ifr); 7 setTimeout(function () { 8 ifr.remove(); 9 ifr = null; 10 }, 1000)
URL Schema与JavaScriptCore的优劣很差说,仍是看具体使用场景吧,不考虑参数问题的话,真正有使用经验的朋友可能会发现url schema方案可能更加适用,由于:
URL Schema方案是监控Webview请求,因此比较通用;而JavaScriptCore的注入是在Webview加载时候注入。
若是页面刷新,这个注入也就丢了须要作特殊处理(糯米接入一个常见BUG就是糯米容器提供的方法不执行)
使用JavaScriptCore的话,页面刷新会致使Hybrid项目瘫痪的问题,咱们IOS同事首先调整了注入方法的时间点,放到了webViewDidFinishLoad中,由于webViewDidFinishLoad的注入在页面js声明以后,因此若是一来就有Hybrid的交互便不会执行,好比:
1 //若是一开始便设置的话,将由于Native没有注入而不执行 2 Hybrid.ui.header.set({ 3 title: '设置右边按钮', 4 });
因此我与Native约定在webViewDidFinishLoad后执行一个我定义的方法,咱们将页面初始化逻辑放到这个事件里面,好比:
1 Hybrid.ready = function() { 2 hybridInit(); 3 }
对比这个方法与以前jQuery的dom ready有点相似,咱们可能会担忧这样会影响页面的渲染速度,这里特别作了一个测试,这段代码对真实逻辑执行确实有必定影响,首次启动在30-90ms之间,第二次没什么影响了,这里也造成了一个一个优化点,只将那种页面一加载结束就须要执行的逻辑放入其中,影响主页面的逻辑可优先执行,若是以为麻烦便直接将页面的载入放到这个方法中便可。
根据咱们的使用过程,发现JavaScriptCore仍是很差用,由于对Native的不熟悉,在js方法注入的时间点一块咱们踩了一些坑,咱们想在webViewDidFinishLoad中注入方法,可是发现有必定概率是页面js已经执行完了才注入,致使Hybrid交互失效。
并且咱们对Native一块声明js方法的生命周期与垃圾回收一块也不熟悉,总担忧埋下什么坑,加之以前30-90ms的延迟,咱们最终是实现了两套方案:
通常状况下仍旧使用URL Schema,若是有不知足的场景,咱们会使用JavaScriptCore,由于底层架构搭建不能耗费太多时间,因此对JavaScriptCore的研究便暂时到此,后续有时间须要对他作深刻研究。
APP会有版本号概念,每一个版本会加一些新的特性或者会改一些BUG,通常的版本号是1.0.0,若是改了BUG打了补丁是1.0.1,有新特性就是1.1.0,若是有大改变的话就2.0.0咯,咱们在实际业务代码中可能会有用到版本号的地方,因此Native须要将当前版本号告诉咱们,通常是采用Native篡改navigator.userAgent写入特殊标志实现,咱们这里是写入了这种标识:
xxx hybrid_1.0.0 xxx
而后咱们会在全局释放一个工具类方法获取当前版本号:
1 var getHybridInfo = function () { 2 var platform_version = {}; 3 var na = navigator.userAgent; 4 na = na.toLowerCase(); 5 var info = na.match(/hybrid_\d\.\d\.\d/); 6 if (info && info[0]) { 7 info = info[0].split('_'); 8 if (info && info.length == 2) { 9 platform_version.platform = info[0]; 10 platform_version.version = info[1]; 11 } 12 } 13 return platform_version; 14 };
因而,咱们在业务开发中便能知道当前是否是处于Native容器中,和获取版本号。
根据以前的共识,咱们的代码只要运行在Native容器中就应该表现的像Hybrid,在浏览器中就应该表现的像H5
上面这句话可能不少朋友以为有点奇怪,这里的界限在于有些方法H5提供了Native也提供了,究竟该用哪一个的问题,好比获取当前位置信息,若是在Native容器中天然走Native获取,若是在浏览器中那就走H5接口。
作一件事情重中之重的就是基础约定,咱们这里作Hybrid架构首先就要作好交互格式约定,这种格式约定的要灵活一点,这个将会在后续扩展中提现他的优点,咱们这里依旧采用相似Ajax的交互规则:
1 requestHybrid({ 2 //建立一个新的webview对话框窗口 3 tagname: 'hybridapi', 4 //请求参数,会被Native使用 5 param: {}, 6 //Native处理成功后回调前端的方法 7 callback: function (data) { 8 } 9 });
tagname是标志此次请求的惟一标识,在接口比较多的状况有可能会有命名空间,好比:tagname: 'ns/api'。
回调的方式都是Native调用H5的js方法,前端须要告诉Native去哪一个对象拿回调方法,另外前端须要与Native约定返回时所带的参数,咱们是这样设计的:
{ data: {}, errno: 0, msg: "success" }
其中每一个错误码须要详细的约定,好比:
{ data: {}, errno: 1, msg: "APP版本太低,请升级APP版本" }
可是真实业务调用的时候却不须要特别去处理响应数据,由于前端应该有统一的地方处理,到具体业务回调时应该只须要使用data数据便可。
通常来讲,H5与Native通讯都只会使用一个方法,咱们以前是H5建立url schema,后面有有了新的方案,是Native释放一个requestNative给H5调用,这里就产生了一个以前没有的问题:
以前Native是没有能力将具体API方法注入给H5,因此咱们使用惟一的方法传递tagname给Native,Native底层会使用相似反射的方式执行他的逻辑,这个tagname能够理解为方法名,而如今Native是有能力为前端注入全部须要的方法了,好比:
意思是以前要根据url schema而后native捕捉请求后,获取tagname字符串,再映射到具体NativeAPI,而如今Native已经有能力将这些Native API创建一个映射函数,注入给H5,因此H5能够直接调用这些方法了,实际的例子是:
1 //全部请求交互收口到一个方法,方法内部再作具体处理 2 requestHybrid({ 3 tagname: 'getAdress', 4 param: { 5 param: 'param' 6 }, 7 callback: function(data){} 8 }); 9 10 //每一个请求交互独立调用Native注入接口 11 hybrid.getAdress({ 12 param: { 13 param: 'param' 14 }, 15 callback: function(data){} 16 });
这里能够各位须要产生一个思考,方案一与方案二到底选哪一个?这个时候就要多考虑框架的扩展性了,一旦有机会“收口”的都要考虑 “收口”,咱们对某一类方法应该有统一的收口的地方,以便处理咱们一些公共的逻辑,好比:
① 前端要对每一个接口调用的次数打点
② 前端要对参数作统一处理
③ 咱们忽然要在底层改变与APP桥接的方式,不能走JavaScriptCore了(咱们就实际遇到了这个问题)
④ 前端要为Native返回的错误码作统一封装
......
一个比较好的交互事实上是这样的,请求的时候要经过一个地方的处理,回调的时候也须要经过一个地方的处理,而这个地方即是咱们能统一把关与控制的地方了,正如咱们对ajax的收口,对settimeout的收口是一个道理:
不管什么系统,一个最重要的功能就是跳转,跳转设计的好坏很大程度决定你的框架好很差,好的跳转设计能够省下业务不少功夫,对迭代扩展也颇有帮助,对于Hybrid来讲,跳转可能会有:
① Native跳H5
② H5跳Native
③ H5跳H5(这里还要份内嵌的场景)
④ H5新开Webview打开H5
......
通常来讲,Native跳H5事实上是用不着咱们关注的,可是有一类Native跳转咱们还不得不关注。
所谓入口类跳转有如下特色:
① 一个入口每每会跳到一个独立的频道
② 每一个独立的入口的实现首页关注不了
③ 频道多是Native的,也多是Hybrid的
几个常见的状况是:
好比糯米的美食或者携程的酒店,美食是Hybrid的,酒店是Native的,而跳转实现是作到Native上的,须要考虑到他的灵活性,也就是我一次点击想去哪就去哪,这个天然须要数据库的支持。
事实上在这类“入口类”跳转模块,每一个模块点击往哪里跳转可能server端会给他一个相似这样的数据结构:
1 //跳Native 2 { 3 topage: 'hotel/index', 4 type: 'native' 5 } 6 //跳转H5 7 { 8 topage: 'https://mdianying.baidu.com', 9 type: 'h5' 10 }
固然,上述只是可能的数据结构,根据以前咱们的实现,更有多是这个样子,直接只是一个URL:
1 requestHybrid({ 2 tagname: 'forward', 3 param: { 4 topage: 'train/index.html', 5 type: 'h5' 6 } 7 }); 8 //=> 9 hybrid://forward?param=%7B%22topage%22%3A%22hotel%2Findex.html%22%2C%22type%22%3A%22h5%22%7D
以这个作法来讲,不管是怎么跳转,仍然能够统一将实现封装到forward的实现中。
若是你使用的是JavaScriptCore,URL Schema依旧要保留以处理这类跳转或者外部浏览器打开APP的需求,有时候当一种方案坑的时候才能体现另外一种的难得。
Native体验好,其中一个缘由就是有过场动画,咱们这里约定了四种基本的动画效果:
//默认动画push 左进 requestHybrid({ tagname: 'forward', param: { topage: 'index2', type: 'native' } }); //右出 requestHybrid({ tagname: 'forward', param: { topage: 'index2', type: 'native', animate: 'pop' } }); //从下往上动画,这种关闭的时候会自动从上向下滑出 requestHybrid({ tagname: 'forward', param: { topage: 'index2', type: 'native', animate: 'present' } });
若是没有动画animate参数便设置为none便可。
由于要保证H5与Native的特性一致,Native的页面路径事实上也是与浏览器一致的,因此咱们只须要保证Native中的back与浏览器中同样,意思是什么都不作......
requestHybrid({ tagname: 'back' });
这里back在webview中会检查history的记录,若是大于1则后退,不然会退回上一步操做。咱们能够看出,back的功能是很单一的,每每不能知足咱们的需求,因此经常使用forward+pop动画当作back使用,而这一作法将引发使人头疼的history错乱问题!!!
forward是很是重要的一个API,在Hybrid中状况又比较复杂,因此这块要花点力气多思考,设计的好很差直接影响业务开发的接受情感。
我以前作框架时会禁止业务开发使用label标签与a标签,这个举动也受到了一些质疑(每每是语义化)
其实并非label标签和a标签很差,而是解决移动端300ms延迟可能会对label标签作特殊处理,容易引发一些莫名其妙的BUG;
而a标签更是破坏单页应用路由的最佳选手,不少同事为a标签添加点击事件(没有设置href)又没有阻止默认事件而致使意想不到的BUG
像这种时候,你与其给人一个个解释要如何作特殊处理,倒不如直接禁止他们使用来得快......
H5跳Native比较简单,只须要与Native同事约定topage的页面便可
1 requestHybrid({ 2 tagname: 'forward', 3 param: { 4 topage: 'index2', 5 type: 'native' 6 } 7 });
若是要带参数的话,便直接写到topage后面的参数上:
topage: 'index2?a=1&b=2',
这个写法显然是有点怪的,由于咱们以前跳转是这样写的:
this.forward('index2', { a: 1, b: 2 });
为了保证业务代码一致,咱们只须要在前端底层封装forward方法便可,这个将生成这种url,根据咱们url schema的约定,这个连接就会进入到Native对应页面:
hybrid://forward?param=%7B%22topage%22%3A%22index2%22%2C%22type%22%3A%22native%22%7D
原本H5跳H5走的是浏览器内部体系,但为加强体验会新开一个Webview作动画,尽量的模拟Native交互,这个代码是这样的:
requestHybrid({ tagname: 'forward', param: { //flight/detail.html?id=111 //hotel/index.html //http:www.baidu.com topage: 'flight/index.html', type: 'h5' } });
若是一个团队前端成体系的话,通常每一个频道的代码是有规则的,通常是频道名/页面名,事实上每一个topage都应该是一个完整的http的连接(若是前端传过去不是完整的,就须要Native补齐),这个也会封装到前端底层造成一个语法糖:
1 this.forward('flight/detail', {id: 1}) 2 //==> 3 requestHybrid({ 4 tagname: 'forward', 5 param: { 6 topage: 'http://domain.com/webapp/flight/detail.html?id=1', 7 type: 'h5' 8 } 9 });
这个是针对线上的场景,而若是读取的是内嵌资源的话就不是这么回事了,好比以前的代码:
1 requestHybrid({ 2 tagname: 'forward', 3 param: { 4 topage: 'flight/detail.html?id=1', 5 type: 'h5' 6 } 7 });
这个是告诉Native去静态资源flight目录找寻detail.html而后载入,这里又涉及到一个问题是:业务到底该怎么写代码?由于不少时候咱们是一套H5代码浏览器与Native两边运行,而我若是在H5想从首页到列表页直接这样写就好了:
this.forward('list', {id: 1})
业务是毫不但愿这个代码要由于接入Hybrid而改变,就算业务开发接受,也会由于跳转选择而致使业务混乱引起各类问题,因此前端框架层要解决这个问题,保证业务最小程度的犯错概率,上面之因此不传完整http的连接给Native,是由于会有静态资源内嵌Native的场景,请看下面的例子:
requestHybrid({ tagname: 'forward', param: { //Native首先检查本地有没有这个文件,若是有就直接读取本地,没有就走http topage: 'flight/index.html', type: 'native' } });
这里为解决快速渲染提出了第一个约定:
跳转时,须要Native去解析URL,判断是否有本地文件,若是有则加载本地文件
举个例子来讲:
http://domain.com/webapp/flight/index.html //解析url得出关键词 //===> flight/index.html 检查本地是否有该文件,有便直接返回
有这个规则的话,就能够最大程度上保证业务代码的一致性,而读取本地文件也能大大提升性能,缓存这块咱们后面再说。
前面说了History错乱的缘由通常来讲是由于使用了forward模拟back回退,这种业务场景常常发生:
① 订单填写页须要去支付页面,因为一些特殊业务需求,须要通过一个中间页作处理,而后再进入真正的支付页,这个时候支付页点击后退事实上是执行的forward的操做(由于点击回退就到中间页了)
② 发布产品的时候会有发布1->发布2->发布预览->完成->产品详情页,这个时候点击产品详情页的后退,咱们也不会但愿他回到发布预览页,而是首页
③ 有可能用户直接由浏览器直接打开APP进入产品详情页,这个时候点击后退是没有任何记录的,固然也是回到首页了。
以上按照业务逻辑的知识的话是正确的,可是以第三种状况来讲,若是回到首页后再次点击后退,而首页的后退又恰好是back操做,那么会回到产品详情页(事实上用户是想退出该频道),而更加不妙的是用户再次点击产品详情的回退又回到了首页,造成了一个死循环!!!
history错乱,暂时没有很好的处理办法,咱们要作的是一旦发现可能会发生history错乱的频道就都不要使用back了,好比上面首页back就写死回到app大首页
固然,有些页面也不是无规律的乱跳的,因此咱们新开一个页面的时候须要让新开页面知道以前是哪一个页面,若是单页应用却是能够写在实例对象上,可是一刷新就丢了,因此比较靠谱的作法也许是带在url上,这个在新开webview的场景下是不可避免的,好比:
//从a页面进入b页面 this.forward('b'); //b页面的实例 this.refer == 'a' //true //由于页面刷新会丢失这个管理,因此咱们将这个关联写在url上 //b的url webapp/project/b.html?refer=a
H5开发中对Header部分的操做是不可避免的,因而咱们抽象出了UIHeader组件处理这种操做,事实上在Hybrid中的Header也应该是一个通用组件,前端作的仅仅是根据约定的格式去调用这个组件便可,可是由于要保证H5与Native调用的一致性,因此要规范化业务代码的使用,通常的使用方法为:
1 //Native以及前端框架会对特殊tagname的标识作默认回调,若是未注册callback,或者点击回调callback无返回则执行默认方法 2 //back前端默认执行History.back,若是不可后退则回到指定URL,Native若是检测到不可后退则返回Naive大首页 3 //home前端默认返回指定URL,Native默认返回大首页 4 this.header.set({ 5 left: [ 6 { 7 //若是出现value字段,则默认不使用icon 8 tagname: 'back', 9 value: '回退', 10 //若是设置了lefticon或者righticon,则显示icon 11 //native会提供经常使用图标icon映射,若是找不到,便会去当前业务频道专用目录获取图标 12 lefticon: 'back', 13 callback: function () { } 14 } 15 ], 16 right: [ 17 { 18 //默认icon为tagname,这里为icon 19 tagname: 'search', 20 callback: function () { } 21 }, 22 //自定义图标 23 { 24 tagname: 'me', 25 //会去hotel频道存储静态header图标资源目录搜寻该图标,没有便使用默认图标 26 icon: 'hotel/me.png', 27 callback: function () { } 28 } 29 ], 30 title: 'title', 31 //显示主标题,子标题的场景 32 title: ['title', 'subtitle'], 33 34 //定制化title 35 title: { 36 value: 'title', 37 //标题右边图标 38 righticon: 'down', //也能够设置lefticon 39 //标题类型,默认为空,设置的话须要特殊处理 40 //type: 'tabs', 41 //点击标题时的回调,默认为空 42 callback: function () { } 43 } 44 });
由于通常来讲左边只有一个返回相关的按钮,因此会提供一个语法糖(在底层依旧会还原为上面的形式):
1 this.header.set({ 2 left: [{ 3 tagname: 'back', 4 callback: function(){} 5 }], 6 title: '', 7 }); 8 //语法糖=> 9 this.header.set({ 10 back: function () { }, 11 title: '' 12 });
header组件上会有不少的图标,而根据以前的约定,tagname与图标是一一对应的,这里就要给出一些基本的映射关系了:
由于H5与native是以tagname做为标识,因此必定不能重复
这些皆须要Native同事实现,若是是新出的图标的话,能够读取线上http的图标,好比这样:
1 Hybrid.ui.header.set({ 2 back: function () { 3 requestHybrid({ 4 tagname: 'back', 5 param: { 6 topage: 'index', 7 type: 'native' 8 } 9 }); 10 }, 11 title: '读取线上资源', 12 right: [ 13 { 14 tagname: 'search', 15 icon: 'http://images2015.cnblogs.com/blog/294743/201511/294743-20151102143118414-1197511976.png', 16 callback: function () { 17 alert('读取线上资源') 18 } 19 } 20 ] 21 });
但若是是经常使用的图标还要去线上取的话,对性能不太好,而这里也引出了一个比较大的话题,静态资源缓存问题,这个咱们在后面点描述。
其实以前我提出过拒绝使用NativeUI的想法,当时最是抵制的就是Header组件,由于若是使用Native的Header的话:
① 咱们的loading将header盖不住
② 每次前端header有什么特殊需求都实现不了,必须等待Native支持(好比Header隐藏之类的)
为了抵制我还提出了一些方案,可是之后面实际项目来讲,事实上是很难摆脱Header组件:
① 断网状况下白屏问题
② js报错假死问题
正如所说,咱们会使用Native的功能一个很大的缘由是为了防止js出错而致使app假死,而通过咱们以前的设计,连back按钮的回调也是咱们定义的,若是js报错的话,这个back的回调可能没注册上去也可能回调报错了,为了处理这个问题,咱们这里须要一个约定:
对header的tagname为back的按钮作了特殊化,相似可能作特殊化的tagname是home、tel
① 若是back按钮没有设置回调则执行webview(浏览器)的history.back
② 若是history为1的话,默认执行退回上一页
③ 若是点击back的时候具备回调则执行回调(JavaScript回调,必须返回true)
④ 若是js回调返回true则Native流程结束,若是300ms没有返回或者返回不为true则跳转到大首页(这个根据业务而定,也可能回到上一页)
这样的话,就算js报错,或者回调报错,也能够保证APP不会陷入假死的状况。
请注意,这样只能避免用户进了某一个页面出不去的状况,并非说页面没BUG!!!
若是这里发生了阻塞主流程的BUG,页面应该要有自动预警与在线更改机制,避免用户&订单流失
这里一旦具备回调可是依旧执行了Native回调的场景就必定是页面有问题,这个时候就应该打点上报日志,日志收集后立刻短信轰炸业务开发人员,这个日志也是有必定要求的:咱们但愿错误日志定位到哪个页面甚至哪个方法出了问题,若是有具体操做路径就更好了,后面的比较难,第一条必定要作到。当错误定位到后,咱们便须要快速解决问题,上线代码,这里涉及Hybrid在线更新一块的逻辑,咱们后面再说。
事实上对H5来讲,请求走Ajax是没有问题的,跨域等问题都有不少解决方案,真正让咱们想用Native代理发出请求的是帐号信息统一(后面又有Native走tcp的场景),请思考如下场景:
Native每每是能够持久化登陆信息的,因此不少主流的Hybrid框架若是是直连(webview直接访问一个url)的话会直接将cookie信息注入给webview,这个时候业务就直接获取了登陆态了,但总有业务可能会产生登出操做,而后换个帐号登陆进来,这个时候webview与Native的帐号就不统一了,没有处理方案的话,这个时候用户就会懵逼了,以为整个APP不可信!
有一种方案是能够绕过这个问题的,就是对登陆登出“收口”(咱们又提到收口一词了哦),限制业务开发登出必须使用APP系统提供的登陆登出,由于通常大公司有统一的passport作鉴权,好比手机百度,就算你在webview中从新登陆了,由于使用的是APP提供的登陆登出,而其余频道应用与你皆是使用的passport鉴权,因此能够用这个方案,可是这个方案对于多数小公司多是不可行的。
第一是不少小公司没那个意识去打造相似passport这种东西,这个也不是前端能推进的事情,就算你实现了整个passport机制,还得保证整个公司其它团队使用你的系统,若是有一个团队不买帐就懵逼了。
没有统一的帐号系统每每有历史包袱的因素,技术债须要及时还清
因此如今不少团队的现状是一个项目都会有本身的登录注册(这个事实上很傻逼),频道之间登陆态共享都没有作到,因此对登录登出作收口便不适用了,可是由于Native是新业务,不存在历史包袱,APP通常又是战略性产品,前端作不到的事情,若是和APP挂钩,每每能够在某些方面完成相似的事情,就咱们如今来讲APP就有本身的一套鉴权机制,虽然不清楚他内部实现(后面需详细了解),可是每一个业务接口对APP都是很友好的,因此请求直接走Native代理发出会是一个很是好的选择。
1 requestHybrid({ 2 //post 3 tagname: 'get', 4 param: { 5 url: 'http://api/demain.com', 6 param: {a: 1, b: 2} 7 }, 8 callback: function (data) { 9 } 10 });
解决了以上问题,事实上只须要Native端新释放一个接口便可,固然这里又会回到以前一个问题,post的参数问题,这个时候可能就须要配置为JavaScriptCore方式通讯,或者将请求参数放在一个全局方法中等待Native调用获取。
业务开发中须要禁止出现登出操做,全部的登出都要走APP惟一页面的惟一登出按钮;若是APP自己未登录,那么能够要求用户进入页面前先登录,也能够在访问到具体须要登录的接口时弹出登录框让用户登录了才能进行后续操做。
由于请求由native发出不会有跨域问题,考虑到安全性,这里会有一个域名白名单,只有白名单的请求才能发出去
像咱们前面说的Header组件与登录框,事实上都算得上Native组件,只不过header是单纯的UI组件,登录框算得上业务组件的,H5会用到NativeUI的场景很少,可是loading这个东西由于要下降页面白屏时间会常常用到。
通常来讲在webview加载html时会有一段时间白屏时间,这个时候便须要Native的loading出场,在页面加载完成后须要在前端框架层将Native loading关闭。
1 var HybridUI = {}; 2 HybridUI.showLoading(); 3 //=> 4 requestHybrid({ 5 tagname: 'showLoading' 6 }); 7 8 HybridUI.showToast({ 9 title: '111', 10 //几秒后自动关闭提示框,-1须要点击才会关闭 11 hidesec: 3, 12 //弹出层关闭时的回调 13 callback: function () { } 14 }); 15 //=> 16 requestHybrid({ 17 tagname: 'showToast', 18 param: { 19 title: '111', 20 hidesec: 3, 21 callback: function () { } 22 } 23 });
这一套UI组件皆须要与前端框架中的组件使用作到一致性,这种业务类组件很少说,这里说一个可能会遇到的问题:
不可避免的,咱们会遇到NativeUI组件与H5通讯的问题,举个简单的例子,咱们为了交互效果,新开了一Native的弹出层组件,大概这个样子:
你们这里不要把它当作单独的View,将它看作一个H5的弹出层,只不过这个弹出层是Native实现的,整个调用方式也许是这样的:
1 requestHybrid({ 2 tagname: 'showCitilist', 3 param: { 4 data: [ 5 {name: '北京'}, {name: '上海'} 6 //...... 7 ] 8 }, 9 callback: function(item) { 10 alert(item.name) 11 } 12 });
这里Native弹出了一个弹出层,装载的是Native的UI,点击某一个城市,执行的是H5的回调,表面逻辑上这个Native的UI应该是基于Webview的,事实上这个NativeUI多是一个单例,其实这个实现还比较简单,由于他的点击交互比较单一,Native能够很容易的将数据得到再回调H5的方法,这里与Header上的点击事件处理一致,比较复杂的是Native新开了一个弹出层而他是一个Webview,装载咱们本身的H5代码,这个便复杂了。
请考虑如下业务场景,此次依旧是使用Native弹出层,可是这里的弹出层是一个Webview组件,里面的内容须要咱们自定义,调用多是这样的:
1 requestHybrid({ 2 tagname: 'showpagelayer', 3 param: { 4 html: '<input id="test" type="text" ><input type="button" id="btn" >', 5 events: { 6 'click #btn': function() { 7 var v = $('#test').val(); 8 //调用父元素方法 9 //parentCallback(v); 10 //关闭当前弹出层 11 //Hybrid.ui.hidepagelayer() 12 } 13 }, 14 } 15 });
这个代码之因此能够这样写,是由于咱们对这个页面展现的Dom结构与事件有控制力,可是若是这个页面若是压根不是我写的,并且上面那种代码的应用场景基本为0,咱们真实的使用场景每每是直接载入一个页面,好比这个例子:
1 requestHybrid({ 2 tagname: 'showpagelayer', 3 param: { 4 src: 'http://domain.com/webapp/common/city.html', 5 } 6 });
若是是以url载入一个页面的话,咱们对页面的控制力就没有了,除非有一个规则让咱们能够对页面的某些方法进行重写,好比依赖一个框架:
一个好的Hybrid平台除了基础实现外,还须要一配套使用前端框架,框架须要最大限度的保证业务代码一致,提高业务的开发效率
咱们这里为了方便你们理解作简单实现便可。首先,咱们约定,这类能够用弹出层打开的页面必定是具有某些“公共”特性的页面,好比:
① 城市列表页
② 经常使用联系人选择页
③ XX类型选择页
切记,这类页面必定是公共业务,不会包含过于业务化的东西,不然是不适用的,那种页面仍是以url传参处理吧。
而后,咱们对这类页面的处理也仅限于回调的处理,不会影响到他们自己的渲染,好比是这样的页面:
1 <input type="text" id="test" > 2 <input type="button" value="父页面通讯" id="btn"> 3 <script src="http://sandbox.runjs.cn/uploads/rs/279/2h5lvbt5/zepto.js" type="text/javascript"></script> 4 <script type="text/javascript"> 5 $('#btn').click(function (){ 6 var val = $('#test').val(); 7 clickAction(val) 8 }); 9 //override 10 function clickAction (val) { 11 alert(val) 12 }; 13 </script>
而咱们真实的调用是这样的:
1 requestHybrid({ 2 tagname: 'showpageview', 3 param: { 4 src: 'http://sandbox.runjs.cn/show/imbacaz7', 5 callbacks: { 6 //请注意,这里的key值 7 clickAction: function(val) { 8 //parentCallback(val); 9 //关闭当前webview,咱们约定这类webview是单例 10 //Hybrid.ui.hidepageview() 11 } 12 } 13 } 14 });
webview载入结束后,咱们会使用咱们本身定义的方法将原来页面的方法重写掉,好比使用JavaScriptCore重写掉。固然,真实的使用场景不会这么简单,具体的业务逻辑就看依赖框架(blade)的实现吧。
PS:这里的实现过于复杂,不太实用,各位暂时仍是保持url跳转通讯吧,这里待研究
前面咱们设置header时,用到了在线静态资源,那里直接是使用的http的资源,咱们在实际业务中由于知道本身的图标在什么位置因此代码多是这样的:
1 { 2 tagname: 'search', 3 //若是当前是机票频道,这个会转化为 http://domain.com/webapp/flight/static/hybrid/icon-search.png 4 icon: './static/hybrid/icon-search.png', 5 callback: function () { 6 alert('读取线上资源') 7 } 8 }
根据以前的规划,Native中若是存在静态资源,也是按频道划分的:
webapp //根目录 ├─flight ├─hotel //酒店频道 │ │ index.html //业务入口html资源,若是不是单页应用会有多个入口 │ │ main.js //业务全部js资源打包 │ │ │ └─static //静态样式资源 │ ├─css │ ├─hybrid //存储业务定制化类Native Header图标 │ └─images ├─libs │ libs.js //框架全部js资源打包 │ └─static //框架静态资源样式文件 ├─css └─images
咱们开始考虑webview读取Native静态资源时候想了几套方案,好比:
icon: 'hotel/icon.png'
这种形式就是业务开发知道Native的hotel有icon.png的静态资源,便直接Native读取了,可是后来我以为这种方案不太好,谁知道哪次更新Native中就没有这个包了呢?那个时候岂不是代码就直接报错了,因此最后咱们决定咱们全部的静态资源必定要过http,由于:
不少业务最初开发的时候都是直接使用浏览器开发或者Native直连url开发,这种时候就能保证全部的静态资源的地址不会错
在正式上线后,咱们可能有一部分公共资源内嵌,这个时候便须要必定机制让Native返回本地文件:
Native会拦截全部的Webview请求,若是发现某个资源请求本地也存在便直接返回
因此这里的症结点是Native如何过滤请求,首先,Native只拦截某些域名的请求,由于咱们本地资源都必定会有一个规则,拿到请求后,咱们会匹配这个规则,好比说,咱们会将这个类型的请求映射到本地:
http://domain.com/webapp/flight/static/hybrid/icon-search.png
//===>>
file ===> flight/static/hybrid/icon-search.png
Native会直接去flight目录寻找是否有这个文件,若是有就直接返回了,可是咱们这里会有一个忧虑点:
这种拦截全部请求的方法再检查文件是否存在是否会很耗时
由于我并不能确定,因而让Native同事作了一个实验,检查100个文件本地是否存在,耗时都在10ms之内。
关于读取Native缓存,咱们也可使用前端构建工具直接以频道为单位生成一个清单,而后Native就只对清单内的请求作处理,可是这里会多一步操做,出错的概率可能增大,考虑的所有拦截的耗损不是很大,因而咱们采用了所有拦截的方案,这里简单说下Native的实现方案,具体各位在代码中去看吧:
这里IOS与Android实现大同小异,这里直接放出代码各位本身去研究吧:
PS:这里是测试时候的代码,最后实现请看git里面的
1 class DogHybirdURLProtocol: NSURLProtocol { 2 3 override class func canInitWithRequest(request: NSURLRequest) -> Bool { 4 if let url = request.URL?.absoluteString { 5 if url.hasPrefix(webAppBaseUrl) { 6 let str = url.stringByReplacingOccurrencesOfString(webAppBaseUrl, withString: "") 7 var tempArray = str.componentsSeparatedByString("?") 8 tempArray = tempArray[0].componentsSeparatedByString(".") 9 if tempArray.count == 2 { 10 let path = MLWebView().LocalResources + tempArray[0] 11 let type = tempArray[1] 12 if let _ = NSBundle.mainBundle().pathForResource(path, ofType: type) { 13 print("文件存在") 14 print("path == \(path)") 15 print("type == \(type)") 16 return true 17 } 18 } 19 } 20 } 21 return false 22 } 23 24 override class func canonicalRequestForRequest(request: NSURLRequest) -> NSURLRequest { 25 return request 26 } 27 28 override func startLoading() { 29 dispatch_async(dispatch_get_main_queue()) { 30 if let url = self.request.URL?.absoluteString { 31 if url.hasPrefix(webAppBaseUrl) { 32 let str = url.stringByReplacingOccurrencesOfString(webAppBaseUrl, withString: "") 33 var tempArray = str.componentsSeparatedByString("?") 34 tempArray = tempArray[0].componentsSeparatedByString(".") 35 if tempArray.count == 2 { 36 let path = MLWebView().LocalResources + tempArray[0] 37 let type = tempArray[1] 38 let client: NSURLProtocolClient = self.client! 39 if let localUrl = NSBundle.mainBundle().pathForResource(path, ofType: type) { 40 var typeString = "" 41 switch type { 42 case "html": 43 typeString = "text/html" 44 break 45 case "js": 46 typeString = "application/javascript" 47 break 48 case "css": 49 typeString = "text/css" 50 break 51 case "jpg": 52 typeString = "image/jpeg" 53 break 54 case "png": 55 typeString = "image/png" 56 break 57 default: 58 break 59 } 60 let fileData = NSData(contentsOfFile: localUrl) 61 let url = NSURL(fileURLWithPath: localUrl) 62 let dataLength = fileData?.length ?? 0 63 let response = NSURLResponse(URL: url, MIMEType: typeString, expectedContentLength: dataLength, textEncodingName: "UTF-8") 64 client.URLProtocol(self, didReceiveResponse: response, cacheStoragePolicy: .NotAllowed) 65 client.URLProtocol(self, didLoadData: fileData!) 66 client.URLProtocolDidFinishLoading(self) 67 } 68 else { 69 print(">>>>> 没找到额 <<<<<") 70 } 71 } 72 } 73 } 74 } 75 } 76 77 override func stopLoading() { 78 79 } 80 81 }
其实这里优势已经很是明显,业务写代码的时候压根不须要考虑文件是否须要本地读取,Native也能够有一个开关轻易的配置哪些频道须要读取本地文件:
以咱们的demo为例,关于业务频道demo的全部静态资源所有走线上,有效减小APP包大小,公共文件或者框架文件在APP中所有走本地,由于核心框架通常比较大,这里能够提高70%以上的载入速度。
有缓存策略就会有更新策略,事实上这里的更新策略涉及到在线发版等功能,这个工做是很是重的,若是说以前的工做前端与Native就能完成的话,那么这个工做会有server端的同事参加,还有可能造成一个功能庞大的发布平台。
最简单的模拟,就是每次Native大版本发布都会有一个版本映射表:
{//业务频道版本号 flight: 1.0.0, hotel: 1.0.0, libs: 1.0.0, static: 1.0.0 }
其中每一个,若是某一天咱们发现了机票频道一个BUG,发布了一个增量包,那么机票的版本就会增长:
//bug修复 flight: 1.0.1 //功能发布 flight: 1.1.0
对于这个版本,后台数据可可能会有这么一个映射:
channel | ver | md5 |
flight | 1.0.0 | 1245355335 |
hotel | 1.0.1 | 455ettdggd |
每个md5值对应着一个实际存在的增量包,在CDN服务器上,每次APP启动,就会检查server端的版本号是否是一致,若是不一致就须要从新拉取zip包,而后更新本地版本号:
这个是比较简单的场景,以一个频道为单位的更新,没有作到粒度更细,安全性方面通常状况咱们也没必要关心有人会篡改你的zip包(好比开发商),在你app流量不大的状况,没人有那么蛋疼,可是咱们要考虑开发人员发布的zip包在某个环节出了问题的状况,通常来讲,咱们的打包程序会根据每一个文件造成一个md5清单,好比这个样子的:
Native拿到后会去检查这个清单全部的文件是否完整,若是不完整就有问题,须要打日志预警放弃此次更新。
咱们这里因为暂时没有Server端的参与,不能作发布系统,因此暂时是将版本信息放到了项目根目录作简单实现,这里还包含三个频道的zip包:
PS:真实场景更复杂更严谨
{ "blade": "1.0.0", "static": "1.0.0", "demo": "1.0.0" }
咱们如今把更新作到了这个页面:
这里的流程是:
1 点击检查更新首先检查Native里面有没有hybrid_ver.json这个文件,没有就去http://yexiaochai.github.io/Hybrid/webapp/hybrid_ver.json下载,完了拿到json串把对应文件所有下载下来解压:
{ "blade": "1.0.0", "static": "1.0.0", "demo": "1.0.0" }
对应规则是:
http://yexiaochai.github.io/Hybrid/webapp/blade.zip http://yexiaochai.github.io/Hybrid/webapp/static.zip http://yexiaochai.github.io/Hybrid/webapp/demo.zip
PS:这里真实状况下其实对应的是md5的压缩包,咱们这里不去纠结。
若是第二次你点击,这个时候本地有hybrid_ver.json文件了,你再去远端获取这个文件,对比三个频道的版本号,这个时候是同样的,因此不会拉取。
若是咱们改动了demo文件中的某个文件,好比加了一个alert什么的,这个时候就从新造成一个zip包,而后你把demo的版本号加大,好比这样:
{ "blade": "1.0.0", "static": "1.0.0", "demo": "1.0.1" }
他就该拉取demo的增量包,再次进入系统的时候便能看到更新了。
与上次文章对比,咱们此次在一些Hybrid设计的细节点把握的更多,但愿此文能对准备接触Hybrid技术的朋友提供一些帮助,关于Hybrid的系列还会有最后一篇实战类文章介绍,有兴趣的朋友持续关注吧,这里是一些效果图:
微博求粉
最后,个人微博粉丝极其少,若是您以为这篇博客对您哪怕有一丝丝的帮助,微博求粉博客求赞!!!