浅谈Hybrid技术的设计与实现第二弹
前言
接上文:浅谈Hybrid技术的设计与实现(阅读本文前,建议阅读这个先)
上文说了很多关于Hybrid的概要设计,可以算得上大而全,有说明有demo有代码,对于想接触Hybrid的朋友来说应该有一定帮助,但是对于进阶的朋友可能就不太满足了,他们会想了解其中的每一个细节,甚至是一些Native的实现,小钗这里继续抛砖引玉,希望接下来的内容对各位有一定帮助。
进入今天的内容之前我们首先谈谈两个相关技术Ionic与React Native。
Ionic是一个基于Cordova的移动开发框架,他的一大优势是有一套配套的前端框架,因为是基于angularJS的,又提供了大量可用UI,可谓大而全,对开发效率很有帮助;与我们所说的Hybrid不同的是,我们的Native容器是由公司或者个人定制开发,Ionic的Native壳是第三方公司做的平台性产品,优劣不论,但是楼主是绝不会用太多第三方开源的东西的,举个例子来说,你的项目中如果第三方UI组件越多,那么性能和风险相对会越多,因为你不了解他。另一方面angular对于H5来说,尺寸着实过大,最后以逼格来说,Ionic还是多适用于外包了。
与Ionic不同的是React Native,根据他写出来的View完全是Native的View,那么这个逼格和体验就高了,小钗在这次Hybrid项目结束后,应该会着力在这方面做研究,这里没实际经验就不多说了。
文中是我个人的一些开发经验,希望对各位有用,也希望各位多多支持讨论,指出文中不足以及提出您的一些建议。
设计类博客(还有最后一篇便完结)
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通信
Url Schema
根据之前的知识,H5与Native交互的桥梁为Webview,而“联系”的方式是以url schema的方式做的,在用户安装app后,app可以自定义url schema,并且把自定义的url注册在调度中心, 例如
- ctrip://wireless 打开携程App
- weixin:// 打开微信
事实上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,那么事情似乎变得好办多了。
JavaScriptCore
在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的研究便暂时到此,后续有时间需要对他做深入研究。
Hybrid版本
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即可。
back
因为要保证H5与Native的特性一致,Native的页面路径事实上也是与浏览器一致的,所以我们只需要保证Native中的back与浏览器中一样,意思是什么都不做......
requestHybrid({ tagname: 'back' });
这里back在webview中会检查history的记录,如果大于1则后退,否则会退回上一步操作。我们可以看出,back的功能是很单一的,往往不能满足我们的需求,所以常常使用forward+pop动画当做back使用,而这一做法将引起令人头疼的history错乱问题!!!
forward
forward是非常重要的一个API,在Hybrid中情况又比较复杂,所以这块要花点力气多思考,设计的好不好直接影响业务开发的接受情感。
我之前做框架时会禁止业务开发使用label标签与a标签,这个举动也受到了一些质疑(往往是语义化)
其实并不是label标签和a标签不好,而是解决移动端300ms延迟可能会对label标签做特殊处理,容易引起一些莫名其妙的BUG;
而a标签更是破坏单页应用路由的最佳选手,很多同事为a标签添加点击事件(没有设置href)又没有阻止默认事件而导致意想不到的BUG
像这种时候,你与其给人一个个解释要如何做特殊处理,倒不如直接禁止他们使用来得快......
H5跳Native
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新开Webview跳H5
本来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错乱
前面说了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
Header组件
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发出不会有跨域问题,考虑到安全性,这里会有一个域名白名单,只有白名单的请求才能发出去
NativeUI
像我们前面说的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通信问题
不可避免的,我们会遇到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代码,这个便复杂了。
Webview通信
请考虑以下业务场景,这次依旧是使用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的系列还会有最后一篇实战类文章介绍,有兴趣的朋友持续关注吧,这里是一些效果图:
微博求粉
最后,我的微博粉丝极其少,如果您觉得这篇博客对您哪怕有一丝丝的帮助,微博求粉博客求赞!!!