http://www.cnblogs.com/yexiaochai/p/5813248.html
http://www.cnblogs.com/nildog/p/5536081.html
Hybrid开发效率高、跨平台、低层本
Hybrid从业务开发上讲,没有版本问题,有BUG能及时修复
Hybrid是有缺点的,Hybrid体验就肯定比不上Native,所以使用有其场景,但是对于需要快速试错、快速占领市场的团队来说,Hybrid一定是不二的选择,团队生存下来后还是需要做体验更好的原生APP。
① Hybrid中Native与前端各自的工作是什么
② Hybrid的交互接口如何设计
③ Hybrid的Header如何设计
④ Hybrid的如何设计目录结构以及增量机制如何实现
⑤ 资源缓存策略,白屏问题......
静态资源打包至Native中,Native提供js调用原生应用的能力,从产品化和工程化来说做的很不错,但是有两个瑕疵:
① 资源全部打包至Naive中APP尺寸会增大,就算以增量机制也避免不了APP的膨胀,因为现在接入的频道较少一个频道500K没有感觉,一旦平台化后主APP尺寸会急剧增大
② 糯米前端框架团队封装了Native端的能力,但是没有提供配套的前端框架,这个解决方案是不完整的。很多业务已经有H5站点了,为了接入还得单独开发一套程序;而就算是新业务接入,又会面临嵌入资源必须是静态资源的限制,做出来的项目没有SEO,如果关注SEO的话还是需要再开发,从工程角度来说是有问题的。
Native与前端分工
首先Native提供的是一宿主环境,要合理的利用Native提供的能力,要实现通用的Hybrid平台架构,站在前端视角,我认为需要考虑以下核心设计问题。
交互设计
Hybrid架构设计第一个要考虑的问题是如何设计与前端的交互,如果这块设计的不好会对后续开发、前端框架维护造成深远的影响,并且这种影响往往是不可逆的,所以这里需要前端与Native好好配合,提供通用的接口,比如:
① NativeUI组件,header组件、消息类组件
② 通讯录、系统、设备信息读取接口
③ H5与Native的互相跳转,比如H5如何跳到一个Native页面,H5如何新开Webview做动画跳到另一个H5页面
资源访问机制
Native首先需要考虑如何访问H5资源,做到既能以file的方式访问Native内部资源,又能使用url的方式访问线上资源;需要提供前端资源增量替换机制,以摆脱APP迭代发版问题,避免用户升级APP。这里就会涉及到静态资源在APP中的存放策略,更新策略的设计,复杂的话还会涉及到服务器端的支持。
账号信息设计
账号系统是重要并且无法避免的,Native需要设计良好安全的身份验证机制,保证这块对业务开发者足够透明,打通账户信息。
Hybrid开发调试
功能设计完并不是结束,Native与前端需要商量出一套可开发调试的模型,不然很多业务开发的工作将难以继续,这个很多文章已经接受过了,本文不赘述。
至于Native还会关注的一些通讯设计、并发设计、异常处理、日志监控以及安全模块因为不是我涉及的领域便不予关注了(事实上是想关注不得其门),而前端要做的事情就是封装Native提供的各种能力,整体架构是这样的:
真实业务开发时,Native除了会关注登录模块之外还会封装支付等重要模块,这里视业务而定。
Hybrid交互设计
Hybrid的交互无非是Native调用前端页面的JS方法,或者前端页面通过JS调用Native提供的接口,两者交互的桥梁皆Webview:
app自身可以自定义url schema,并且把自定义的url注册在调度中心, 例如
- ctrip://wireless 打开携程App
- weixin:// 打开微信
我们JS与Native通信一般就是创建这类URL被Native捕获处理,后续也出现了其它前端调用Native的方式,但可以做底层封装使其透明化,所以重点以及是如何进行前端与Native的交互设计。
JS to Native
Native在每个版本会提供一些API,前端会有一个对应的框架团队对其进行封装,释放业务接口。
前端框架定义了一个全局变量BNJS作为Native与前端交互的对象,只要引入了糯米提供的这个JS库,并且在糯米封装的Webview容器中,前端便获得了调用Native的能力,我揣测糯米这种设计是因为这样便于第三方团队的接入使用,手机百度有一款轻应用框架也走的这种路线:
clouda.mbaas.account //释放了clouda全局变量
API式交互
手白、糯米底层如何做我们无从得知,但我们发现调用Native API接口的方式和我们使用AJAX调用服务器端提供的接口是及其相似的:
这里类似的微薄开放平台的接口是这样定义的:
粉丝服务(新手接入指南) | ||
---|---|---|
读取接口 | 接收消息 | 接收用户私信、关注、取消关注、@等消息接口 |
写入接口 | 发送消息 | 向用户回复私信消息接口 |
生成带参数的二维码 | 生成带参数的二维码接口 |
我们要做的就是通过一种方式创建ajax请求即可:
https://api.weibo.com/2/statuses/public_timeline.json
所以我在实际设计Hybrid交互模型时,是以接口为单位进行设计的,比如获取通讯录的总体交互是:
格式约定
所以我这边与Native约定的请求模型是:
requestHybrid({ //创建一个新的webview对话框窗口 tagname: 'hybridapi', //请求参数,会被Native使用 param: {}, //Native处理成功后回调前端的方法 callback: function (data) { } });
这个方法执行会形成一个URL,比如:
hybridschema://hybridapi?callback=hybrid_1446276509894¶m=%7B%22data1%22%3A1%2C%22data2%22%3A2%7D
这里提一点,APP安装后会在手机上注册一个schema,比如淘宝是taobao://,Native会有一个进程监控Webview发出的所有schema://请求,然后分发到“控制器”hybridapi处理程序,Native控制器处理时会需要param提供的参数(encode过),处理结束后将携带数据获取Webview window对象中的callback(hybrid_1446276509894)调用之
数据返回的格式约定是:
{ data: {}, errno: 0, msg: "success" }
真实的数据在data对象中,如果errno不为0的话,便需要提示msg,这里举个例子如果错误码1代表该接口需要升级app才能使用的话:
{ data: {}, errno: 1, msg: "APP版本过低,请升级APP版本" }
代码实现
这里给一个简单的代码实现,真实代码在APP中会有所变化:
1 window.Hybrid = window.Hybrid || {}; 2 var bridgePostMsg = function (url) { 3 if ($.os.ios) { 4 window.location = url; 5 } else { 6 var ifr = $('<iframe style="display: none;" src="' + url + '"/>'); 7 $('body').append(ifr); 8 setTimeout(function () { 9 ifr.remove(); 10 }, 1000) 11 } 12 }; 13 var _getHybridUrl = function (params) { 14 var k, paramStr = '', url = 'scheme://'; 15 url += params.tagname + '?t=' + new Date().getTime(); //时间戳,防止url不起效 16 if (params.callback) { 17 url += '&callback=' + params.callback; 18 delete params.callback; 19 } 20 if (params.param) { 21 paramStr = typeof params.param == 'object' ? JSON.stringify(params.param) : params.param; 22 url += '¶m=' + encodeURIComponent(paramStr); 23 } 24 return url; 25 }; 26 var requestHybrid = function (params) { 27 //生成唯一执行函数,执行后销毁 28 var tt = (new Date().getTime()); 29 var t = 'hybrid_' + tt; 30 var tmpFn; 31 32 //处理有回调的情况 33 if (params.callback) { 34 tmpFn = params.callback; 35 params.callback = t; 36 window.Hybrid[t] = function (data) { 37 tmpFn(data); 38 delete window.Hybrid[t]; 39 } 40 } 41 bridgePostMsg(_getHybridUrl(params)); 42 }; 43 //获取版本信息,约定APP的navigator.userAgent版本包含版本信息:scheme/xx.xx.xx 44 var getHybridInfo = function () { 45 var platform_version = {}; 46 var na = navigator.userAgent; 47 var info = na.match(/scheme\/\d\.\d\.\d/); 48 49 if (info && info[0]) { 50 info = info[0].split('/'); 51 if (info && info.length == 2) { 52 platform_version.platform = info[0]; 53 platform_version.version = info[1]; 54 } 55 } 56 return platform_version; 57 };
因为Native对于H5来是底层,框架&底层一般来说是不会关注业务实现的,所以真实业务中Native调用H5场景较少,这里不予关注了。
常用交互API
良好的交互设计是成功的第一步,在真实业务开发中有一些API一定会用到。
跳转
跳转是Hybrid必用API之一,对前端来说有以下跳转:
① 页面内跳转,与Hybrid无关
② H5跳转Native界面
③ H5新开Webview跳转H5页面,一般为做页面动画切换
如果要使用动画,按业务来说有向前与向后两种,forward&back,所以约定如下,首先是H5跳Native某一个页面
1 //H5跳Native页面 2 //=>baidubus://forward?t=1446297487682¶m=%7B%22topage%22%3A%22home%22%2C%22type%22%3A%22h2n%22%2C%22data2%22%3A2%7D 3 requestHybrid({ 4 tagname: 'forward', 5 param: { 6 //要去到的页面 7 topage: 'home', 8 //跳转方式,H5跳Native 9 type: 'native', 10 //其它参数 11 data2: 2 12 } 13 });
比如携程H5页面要去到酒店Native某一个页面可以这样:
1 //=>schema://forward?t=1446297653344¶m=%7B%22topage%22%3A%22hotel%2Fdetail%20%20%22%2C%22type%22%3A%22h2n%22%2C%22id%22%3A20151031%7D 2 requestHybrid({ 3 tagname: 'forward', 4 param: { 5 //要去到的页面 6 topage: 'hotel/detail', 7 //跳转方式,H5跳Native 8 type: 'native', 9 //其它参数 10 id: 20151031 11 } 12 });
比如H5新开Webview的方式跳转H5页面便可以这样:
1 requestHybrid({ 2 tagname: 'forward', 3 param: { 4 //要去到的页面,首先找到hotel频道,然后定位到detail模块 5 topage: 'hotel/detail ', 6 //跳转方式,H5新开Webview跳转,最后装载H5页面 7 type: 'webview', 8 //其它参数 9 id: 20151031 10 } 11 });
back与forward一致,我们甚至会有animattype参数决定切换页面时的动画效果,真实使用时可能会封装全局方法略去tagname的细节,这时就和糯米对外释放的接口差不多了。
Header 组件的设计
最初我其实是抵制使用Native提供的UI组件的,尤其是Header,因为平台化后,Native每次改动都很慎重并且响应很慢,但是出于两点核心因素考虑,我基本放弃了抵抗:
① 其它主流容器都是这么做的,比如微信、手机百度、携程
② 没有header一旦网络出错出现白屏,APP将陷入假死状态,这是不可接受的,而一般的解决方案都太业务了
PS:Native吊起Native时,如果300ms没有响应需要出loading组件,避免白屏
因为H5站点本来就有Header组件,站在前端框架层来说,需要确保业务的代码是一致的,所有的差异需要在框架层做到透明化,简单来说Header的设计需要遵循:
① H5 header组件与Native提供的header组件使用调用层接口一致
② 前端框架层根据环境判断选择应该使用H5的header组件抑或Native的header组件
一般来说header组件需要完成以下功能:
① header左侧与右侧可配置,显示为文字或者图标(这里要求header实现主流图标,并且也可由业务控制图标),并需要控制其点击回调
② header的title可设置为单标题或者主标题、子标题类型,并且可配置lefticon与righticon(icon居中)
③ 满足一些特殊配置,比如标签类header
所以,站在前端业务方来说,header的使用方式为(其中tagname是不允许重复的):
//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 });
为完成Native端的实现,这里会新增两个接口,向Native注册事件,以及注销事件:
1 var registerHybridCallback = function (ns, name, callback) { 2 if(!window.Hybrid[ns]) window.Hybrid[ns] = {}; 3 window.Hybrid[ns][name] = callback; 4 }; 5 6 var unRegisterHybridCallback = function (ns) { 7 if(!window.Hybrid[ns]) return; 8 delete window.Hybrid[ns]; 9 };
请求类
虽然get类请求可以用jsonp的方式绕过跨域问题,但是post请求却是真正的拦路虎,为了安全性服务器设置cors会仅仅针对几个域名,Hybrid内嵌静态资源是通过file的方式读取,这种场景使用cors就不好使了,所以每个请求需要经过Native做一层代理发出去。
这个使用场景与Header组件一致,前端框架层必须做到对业务透明化,业务事实上不必关心这个请求是由浏览器发出还是由Native发出:
1 HybridGet = function (url, param, callback) { 2 }; 3 HybridPost = function (url, param, callback) { 4 };
真实的业务场景,会将之封装到数据请求模块,在底层做适配,在H5站点下使用ajax请求,在Native内嵌时使用代理发出,与Native的约定为:
1 requestHybrid({ 2 tagname: 'ajax', 3 param: { 4 url: 'hotel/detail', 5 param: {}, 6 //默认为get 7 type: 'post' 8 }, 9 //响应后的回调 10 callback: function (data) { } 11 });
常用NativeUI组件
最后,Native会提供几个常用的Native级别的UI,比如loading加载层,比如toast消息框:
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 });
Native UI与前端UI不容易打通,所以在真实业务开发过程中,一般只会使用几个关键的Native UI。
账号系统的设计
根据上面的设计,我们约定在Hybrid中请求有两种发出方式:
① 如果是webview访问线上站点的话,直接使用传统ajax发出
② 如果是file的形式读取Native本地资源的话,请求由Native代理发出
因为静态html资源没有鉴权的问题,真正的权限验证需要请求服务器api响应通过错误码才能获得,这是动态语言与静态语言做入口页面的一个很大的区别。
以网页的方式访问,账号登录与否由是否带有秘钥cookie决定(这时并不能保证秘钥的有效性),因为Native不关注业务实现,而每次载入都有可能是登录成功跳回来的结果,所以每次载入后都需要关注秘钥cookie变化,以做到登录态数据一致性。
以file的方式访问内嵌资源的话,因为API请求控制方为Native,所以鉴权的工作完全由Native完成,接口访问如果没有登录便弹出Native级别登录框引导登录即可,每次访问webview将账号信息种入到webview中,这里有个矛盾点是Native种入webview的时机,因为有可能是网页注销的情况,所以这里的逻辑是:
① webview载入结束
② Native检测webview是否包含账号cookie信息
③ 如果不包含则种入cookie,如果包含则检测与Native账号信息是否相同,不同则替换自身
④ 如果检测到跳到了注销账户的页面,则需要清理自身账号信息
如果登录不统一会就会出现上述复杂的逻辑,所以真实情况下我们会对登录接口收口。
简单化账号接口
平台层面觉得上述操作过于复杂,便强制要求在Hybrid容器中只能使用Native接口进行登录和登出,前端框架在底层做适配,保证上层业务的透明,这样情况会简单很多:
① 使用Native代理做请求接口,如果没有登录直接Native层唤起登录框
② 直连方式使用ajax请求接口,如果没有登录则在底层唤起登录框(需要前端框架支持)
简单的登录登出接口实现:
1 /* 2 无论成功与否皆会关闭登录框 3 参数包括: 4 success 登录成功的回调 5 error 登录失败的回调 6 url 如果没有设置success,或者success执行后没有返回true,则默认跳往此url 7 */ 8 HybridUI.Login = function (opts) { 9 }; 10 //=> 11 requestHybrid({ 12 tagname: 'login', 13 param: { 14 success: function () { }, 15 error: function () { }, 16 url: '...' 17 } 18 }); 19 //与登录接口一致,参数一致 20 HybridUI.logout = function () {
账号信息获取
在实际的业务开发中,判断用户是否登录、获取用户基本信息的需求比比皆是,所以这里必须保证Hybrid开发模式与H5开发模式保持统一,否则需要在业务代码中做很多无谓的判断,我们在前端框架会封装一个User模块,主要接口包括:
1 var User = {}; 2 User.isLogin = function () { }; 3 User.getInfo = function () { };
这个代码的底层实现分为前端实现,Native实现,首先是前端的做法是:
当前端页面载入后,会做一次异步请求,请求用户相关数据,如果是登录状态便能获取数据存于localstorage中,这里一定不能存取敏感信息
前端使用localstorage的话需要考虑极端情况下使用内存变量的方式替换localstorage的实现,否则会出现不可使用的情况,而后续的访问皆是使用localstorage中的数据做判断依据,以下情况需要清理localstorage的账号数据:
① 系统登出
② 访问接口提示需要登录
③ 调用登录接口
这种模式多用于单页应用,非单页应用一般会在每次刷新页面先清空账号信息再异步拉取,但是如果当前页面马上就需要判断用户登录数据的话,便不可靠了;处于Hybrid容器中时,因为Native本身就保存了用户信息,封装的接口直接由Native获取即可,这块比较靠谱。
Hybrid的资源
目录结构
Hybrid技术既然是将静态资源存于Native,那么就需要目录设计,经过之前的经验,目录结构一般以2层目录划分:
如果我们有两个频道酒店与机票,那么目录结构是这样的:
1 webapp //根目录 2 ├─flight 3 ├─hotel //酒店频道 4 │ │ index.html //业务入口html资源,如果不是单页应用会有多个入口 5 │ │ main.js //业务所有js资源打包 6 │ │ 7 │ └─static //静态样式资源 8 │ ├─css 9 │ ├─hybrid //存储业务定制化类Native Header图标 10 │ └─images 11 ├─libs 12 │ libs.js //框架所有js资源打包 13 │ 14 └─static 15 ├─css 16 └─images
最初设计的forward跳转中的topage参数规则是:频道/具体页面=>channel/page,其余资源会由index.html这个入口文件带出。
增量机制
真实的增量机制需要服务器端的配合,我这里只能简单描述,Native端会有维护一个版本映射表:
{ flight: 1.0.0, hotel: 1.0.0, libs: 1.0.0, static: 1.0.0 }
这个映射表是每次大版本APP发布时由服务器端生成的,如果酒店频道需要在线做增量发布的话,会打包一个与线上一致的文件目录,走发布平台发布,会在数据库中形成一条记录:
channel | ver | md5 |
flight | 1.0.0 | 1245355335 |
hotel | 1.0.1 | 455ettdggd |
当APP启动时,APP会读取版本信息,这里发现hotel的本地版本号比线上的小,便会下载md5对应的zip文件,然后解压之并且替换整个hotel文件,本次增量结束,因为所有的版本文件不会重复,APP回滚时可用回到任意想去的版本,也可以对任意版本做BUG修复。
H5与Native通信
Url Schema
根据之前的知识,H5与Native交互的桥梁为Webview,而“联系”的方式是以url schema的方式做的,在用户安装app后,app可以自定义url schema,并且把自定义的url注册在调度中心, 例如
- ctrip://wireless 打开携程App
- weixin:// 打开微信
事实上Native能捕捉webview发出的一切请求,所以就算这里不是这种协议,Native也能捕捉,这个协议的意义在于可以在浏览器中直接打开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接口。
交互格式约定
请求格式
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的
事实上在这类“入口类”跳转模块,每个模块点击往哪里跳转可能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交互
这里为解决快速渲染提出了第一个约定:
跳转时,需要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调用的一致性,所以要规范化业务代码的使用
图标
header组件上会有很多的图标,而根据之前的约定,tagname与图标是一一对应的,这里就要给出一些基本的映射关系了:
因为H5与native是以tagname作为标识,所以一定不能重复
但如果是常用的图标还要去线上取的话,对性能不太好,而这里也引出了一个比较大的话题,静态资源缓存问题
防止假死
其实之前我提出过拒绝使用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鉴权,所以可以用这个方案,但是这个方案对于多数小公司可能是不可行的。
解决了以上问题,事实上只需要Native端新释放一个接口即可,当然这里又会回到之前一个问题,post的参数问题,这个时候可能就需要配置为JavaScriptCore方式通信,或者将请求参数放在一个全局方法中等待Native调用获取。
业务开发中需要禁止出现登出操作,所有的登出都要走APP唯一页面的唯一登出按钮;如果APP本身未登陆,那么可以要求用户进入页面前先登陆,也可以在访问到具体需要登陆的接口时弹出登陆框让用户登陆了才能进行后续操作。
因为请求由native发出不会有跨域问题,考虑到安全性,这里会有一个域名白名单,只有白名单的请求才能发出去
NativeUI通信问题
不可避免的,我们会遇到NativeUI组件与H5通信的问题,举个简单的例子,我们为了交互效果,新开了一Native的弹出层组件,大概这个样子:
家这里不要把它当做单独的View,将它看做一个H5的弹出层,只不过这个弹出层是Native实现的,整个调用方式也许是这样的:
Webview通信
请考虑以下业务场景,这次依旧是使用Native弹出层,但是这里的弹出层是一个Webview组件,里面的内容需要我们自定义,调用可能是这样的:
这个代码之所以可以这样写,是因为我们对这个页面展示的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的资源,我们在实际业务中因为知道自己的图标在什么位置所以代码可能是这样的:
根据之前的规划,Native中如果存在静态资源,也是按频道划分的:
如何读取缓存
我们开始考虑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的实现方案,具体各位在代码中去看吧:
实现方案
以我们的demo为例,关于业务频道demo的所有静态资源全部走线上,有效减少APP包大小,公共文件或者框架文件在APP中全部走本地,因为核心框架一般比较大,这里可以提升70%以上的载入速度。
增量更新
有缓存策略就会有更新策略,事实上这里的更新策略涉及到在线发版等功能,这个工作是非常重的,如果说之前的工作前端与Native就能完成的话,那么这个工作会有server端的同事参加,还有可能形成一个功能庞大的发布平台。
这个是比较简单的场景,以一个频道为单位的更新,没有做到粒度更细,安全性方面一般情况我们也不必关心有人会篡改你的zip包(比如开发商),在你app流量不大的情况,没人有那么蛋疼,但是我们要考虑开发人员发布的zip包在某个环节出了问题的情况,一般来说,我们的打包程序会根据每个文件形成一个md5清单,比如这个样子的:
Native拿到后会去检查这个清单所有的文件是否完整,如果不完整就有问题,需要打日志预警放弃这次更新。
边界问题
在我们使用Hybrid技术前要注意一个边界问题,什么项目适合Hybrid什么项目不适合,这个要搞清楚,适合Hybrid的项目为:
① 有60%以上的业务为H5
② 对更新(开发效率)有一定要求的APP
不适合使用Hybrid技术的项目有以下特点:
① 只有20%不到的业务使用H5做
② 交互效果要求较高(动画多)
任何技术都有适用的场景,千万不要妄想推翻已有APP的业务用H5去替代,最后会证明那是自讨苦吃,当然如果仅仅想在APP里面嵌入新的实验性业务,这个是没问题的。