淺談Hybrid技術(shù)的設(shè)計(jì)與實(shí)現(xiàn)
前言
隨著移動(dòng)浪潮的興起,各種APP層出不窮,極速的業(yè)務(wù)擴(kuò)展提升了團(tuán)隊(duì)對(duì)開發(fā)效率的要求,這個(gè)時(shí)候使用IOS&Andriod開發(fā)一個(gè)APP似乎成本有點(diǎn)過高了,而H5的低成本、高效率、跨平臺(tái)等特性馬上被利用起來形成了一種新的開發(fā)模式:Hybrid APP。
作為一種混合開發(fā)的模式,Hybrid APP底層依賴于Native提供的容器(UIWebview),上層使用Html&Css&JS做業(yè)務(wù)開發(fā),底層透明化、上層多多樣化,這種場(chǎng)景非常有利于前端介入,非常適合業(yè)務(wù)快速迭代,于是Hybrid火啦。
本來我覺得這種開發(fā)模式既然大家都知道了,那么Hybrid就沒有什么探討的價(jià)值了,但令我詫異的是依舊有很多人對(duì)Hybrid這種模式感到陌生,這種情況在二線城市很常見,所以我這里嘗試從另一個(gè)方面向各位介紹Hybrid,期望對(duì)各位正確的技術(shù)選型有所幫助。
Hybrid發(fā)家史
最初攜程的應(yīng)用全部是Native的,H5站點(diǎn)只占其流量很小的一部分,當(dāng)時(shí)Native有200人紅紅火火,而H5開僅有5人左右在打醬油,后面 無線團(tuán)隊(duì)來了一個(gè)執(zhí)行力十分強(qiáng)的服務(wù)器端出身的leader,他為了了解前端開發(fā),居然親手使用jQuery Mobile開發(fā)了第一版程序,雖然很快方案便被推翻,但是H5團(tuán)隊(duì)開始發(fā)力,在短時(shí)間內(nèi)已經(jīng)趕上了Native的業(yè)務(wù)進(jìn)度:
突然有一天andriod同事跑過來告訴我們andriod中有一個(gè)方法最大樹限制,可能一些頁面需要我們內(nèi)嵌H5的頁面,于是Native與H5 框架團(tuán)隊(duì)牽頭做了第一個(gè)Hybrid項(xiàng)目,攜程第一次出現(xiàn)了一套代碼兼容三端的情況。這個(gè)開發(fā)效率杠杠的,團(tuán)隊(duì)嘗到了甜頭,于是乎后續(xù)的頻道基本都開始了 Hybrid開發(fā),到我離開時(shí),整個(gè)機(jī)制已經(jīng)十分成熟了,而前端也有幾百人了。
場(chǎng)景重現(xiàn)
狼廠有三大大流量APP,手機(jī)百度、百度地圖、糯米APP,最近接入糯米的時(shí)候,發(fā)現(xiàn)他們也在做Hybrid平臺(tái)化相關(guān)的推廣,將靜態(tài)資源打包至Native中,Native提供js調(diào)用原生應(yīng)用的能力,從產(chǎn)品化和工程化來說做的很不錯(cuò),但是有兩個(gè)瑕疵:
① 資源全部打包至Naive中APP尺寸會(huì)增大,就算以增量機(jī)制也避免不了APP的膨脹,因?yàn)楝F(xiàn)在接入的頻道較少一個(gè)頻道500K沒有感覺,一旦平臺(tái)化后主APP尺寸會(huì)急劇增大
② 糯米前端框架團(tuán)隊(duì)封裝了Native端的能力,但是沒有提供配套的前端框架,這個(gè)解決方案是不完整的。很多業(yè)務(wù)已經(jīng)有H5站點(diǎn)了,為了接入還得單獨(dú)開發(fā)一 套程序;而就算是新業(yè)務(wù)接入,又會(huì)面臨嵌入資源必須是靜態(tài)資源的限制,做出來的項(xiàng)目沒有SEO,如果關(guān)注SEO的話還是需要再開發(fā),從工程角度來說是有問 題的。
但從產(chǎn)品可接入度與產(chǎn)品化來說,糯米Hybrid化的大方向是很樂觀的,也確實(shí)取得了一些成績(jī),在短時(shí)間就有很多頻道接入了,隨著推廣進(jìn)行,明年可 能會(huì)形成一個(gè)大型的Hybrid平臺(tái)。但是因?yàn)槲乙步?jīng)歷過推廣框架,當(dāng)聽到他們忽悠我說性能會(huì)提高70%,與Native體驗(yàn)基本一致時(shí),不知為何我居然 笑了......
總結(jié)
如果讀了上面幾個(gè)故事你依舊不知道為何要使用Hybrid技術(shù)的話,我這里再做一個(gè)總結(jié)吧:
Hybrid開發(fā)效率高、跨平臺(tái)、底層本
Hybrid從業(yè)務(wù)開發(fā)上講,沒有版本問題,有BUG能及時(shí)修復(fù)
Hybrid是有缺點(diǎn)的,Hybrid體驗(yàn)就肯定比不上Native,所以使用有其場(chǎng)景,但是對(duì)于需要快速試錯(cuò)、快速占領(lǐng)市場(chǎng)的團(tuán)隊(duì)來說,Hybrid一定是不二的選擇,團(tuán)隊(duì)生存下來后還是需要做體驗(yàn)更好的原生APP。
好了,上面扯了那么多沒用的東西,今天的目的其實(shí)是為大家介紹Hybrid的一些設(shè)計(jì)知識(shí),如果你認(rèn)真閱讀此文,可能在以下方面對(duì)你有所幫助:
① Hybrid中Native與前端各自的工作是什么
② Hybrid的交互接口如何設(shè)計(jì)
③ Hybrid的Header如何設(shè)計(jì)
④ Hybrid的如何設(shè)計(jì)目錄結(jié)構(gòu)以及增量機(jī)制如何實(shí)現(xiàn)
⑤ 資源緩存策略,白屏問題......
文中是我個(gè)人的一些開發(fā)經(jīng)驗(yàn),希望對(duì)各位有用,也希望各位多多支持討論,指出文中不足以及提出您的一些建議。
然后文中Andriod相關(guān)代碼由我的同事明月提供,這里特別感謝明月同學(xué)對(duì)我的支持,這里掃描二維碼可以下載APP進(jìn)行測(cè)試:
Andriod APP二維碼:
代碼地址:
https://github.com/yexiaochai/hybrid
#p#
Native與前端分工
在做Hybrid架構(gòu)設(shè)計(jì)之前需要分清Native與前端的界限,首先Native提供的是一宿主環(huán)境,要合理的利用Native提供的能力,要實(shí)現(xiàn)通用的Hybrid平臺(tái)架構(gòu),站在前端視角,我認(rèn)為需要考慮以下核心設(shè)計(jì)問題。
交互設(shè)計(jì)
Hybrid架構(gòu)設(shè)計(jì)第一個(gè)要考慮的問題是如何設(shè)計(jì)與前端的交互,如果這塊設(shè)計(jì)的不好會(huì)對(duì)后續(xù)開發(fā)、前端框架維護(hù)造成深遠(yuǎn)的影響,并且這種影響往往是不可逆的,所以這里需要前端與Native好好配合,提供通用的接口,比如:
① NativeUI組件,header組件、消息類組件
② 通訊錄、系統(tǒng)、設(shè)備信息讀取接口
③ H5與Native的互相跳轉(zhuǎn),比如H5如何跳到一個(gè)Native頁面,H5如何新開Webview做動(dòng)畫跳到另一個(gè)H5頁面
資源訪問機(jī)制
Native首先需要考慮如何訪問H5資源,做到既能以file的方式訪問Native內(nèi)部資源,又能使用url的方式訪問線上資源;需要提供前端 資源增量替換機(jī)制,以擺脫APP迭代發(fā)版問題,避免用戶升級(jí)APP。這里就會(huì)涉及到靜態(tài)資源在APP中的存放策略,更新策略的設(shè)計(jì),復(fù)雜的話還會(huì)涉及到服 務(wù)器端的支持。
賬號(hào)信息設(shè)計(jì)
賬號(hào)系統(tǒng)是重要并且無法避免的,Native需要設(shè)計(jì)良好安全的身份驗(yàn)證機(jī)制,保證這塊對(duì)業(yè)務(wù)開發(fā)者足夠透明,打通賬戶信息。
Hybrid開發(fā)調(diào)試
功能設(shè)計(jì)完并不是結(jié)束,Native與前端需要商量出一套可開發(fā)調(diào)試的模型,不然很多業(yè)務(wù)開發(fā)的工作將難以繼續(xù),這個(gè)很多文章已經(jīng)接受過了,本文不贅述。
至于Native還會(huì)關(guān)注的一些通訊設(shè)計(jì)、并發(fā)設(shè)計(jì)、異常處理、日志監(jiān)控以及安全模塊因?yàn)椴皇俏疑婕暗念I(lǐng)域便不予關(guān)注了(事實(shí)上是想關(guān)注不得其門),而前端要做的事情就是封裝Native提供的各種能力,整體架構(gòu)是這樣的:
真實(shí)業(yè)務(wù)開發(fā)時(shí),Native除了會(huì)關(guān)注登錄模塊之外還會(huì)封裝支付等重要模塊,這里視業(yè)務(wù)而定。
Hybrid交互設(shè)計(jì)
Hybrid的交互無非是Native調(diào)用前端頁面的JS方法,或者前端頁面通過JS調(diào)用Native提供的接口,兩者交互的橋梁皆Webview:
app自身可以自定義url schema,并且把自定義的url注冊(cè)在調(diào)度中心, 例如
-
ctrip://wireless 打開攜程App
-
weixin:// 打開微信
我們JS與Native通信一般就是創(chuàng)建這類URL被Native捕獲處理,后續(xù)也出現(xiàn)了其它前端調(diào)用Native的方式,但可以做底層封裝使其透明化,所以重點(diǎn)以及是如何進(jìn)行前端與Native的交互設(shè)計(jì)。
#p#
JS to Native
Native在每個(gè)版本會(huì)提供一些API,前端會(huì)有一個(gè)對(duì)應(yīng)的框架團(tuán)隊(duì)對(duì)其進(jìn)行封裝,釋放業(yè)務(wù)接口。比如糯米對(duì)外的接口是這樣的:
- BNJS.http.get();//向業(yè)務(wù)服務(wù)器拿請(qǐng)求據(jù)【1.0】 1.3版本接口有擴(kuò)展
- BNJS.http.post();//向業(yè)務(wù)服務(wù)器提交數(shù)據(jù)【1.0】
- BNJS.http.sign();//計(jì)算簽名【1.0】
- BNJS.http.getNA();//向NA服務(wù)器拿請(qǐng)求據(jù)【1.0】 1.3版本接口有擴(kuò)展
- BNJS.http.postNA();//向NA服務(wù)器提交數(shù)據(jù)【1.0】
- BNJS.http.getCatgData();//從Native本地獲取篩選數(shù)據(jù)【1.1】
- BNJSReady(function(){
- BNJS.http.post({
- url : 'http://cp01-testing-tuan02.cp01.baidu.com:8087/naserver/user/feedback',
- params : {
- msg : '測(cè)試post',
- contact : '18721687903'
- },
- onSuccess : function(res){
- alert('發(fā)送post請(qǐng)求成功!');
- },
- onFail : function(res){
- alert('發(fā)送post請(qǐng)求失?。?);
- }
- });
- });
前端框架定義了一個(gè)全局變量BNJS作為Native與前端交互的對(duì)象,只要引入了糯米提供的這個(gè)JS庫,并且在糯米封裝的Webview容器中, 前端便獲得了調(diào)用Native的能力,我揣測(cè)糯米這種設(shè)計(jì)是因?yàn)檫@樣便于第三方團(tuán)隊(duì)的接入使用,手機(jī)百度有一款輕應(yīng)用框架也走的這種路線:
clouda.mbaas.account //釋放了clouda全局變量
這樣做有一個(gè)前提是,Native本身已經(jīng)十分穩(wěn)定了,很少新增功能了,否則在直連情況下就會(huì)面臨一個(gè)尷尬,因?yàn)閣eb站點(diǎn)永遠(yuǎn)保持最新的,就會(huì)在一些低版本容器中調(diào)用了沒有提供的Native能力而報(bào)錯(cuò)。
API式交互
手白、糯米底層如何做我們無從得知,但我們發(fā)現(xiàn)調(diào)用Native API接口的方式和我們使用AJAX調(diào)用服務(wù)器端提供的接口是及其相似的:
這里類似的微薄開放平臺(tái)的接口是這樣定義的:
粉絲服務(wù)(新手接入指南) |
||
---|---|---|
讀取接口 |
接收用戶私信、關(guān)注、取消關(guān)注、@等消息接口 |
|
寫入接口 |
向用戶回復(fù)私信消息接口 |
|
生成帶參數(shù)的二維碼接口 |
我們要做的就是通過一種方式創(chuàng)建ajax請(qǐng)求即可:
https://api.weibo.com/2/statuses/public_timeline.json
所以我在實(shí)際設(shè)計(jì)Hybrid交互模型時(shí),是以接口為單位進(jìn)行設(shè)計(jì)的,比如獲取通訊錄的總體交互是:
格式約定
交互的第一步是設(shè)計(jì)數(shù)據(jù)格式,這里分為請(qǐng)求數(shù)據(jù)格式與響應(yīng)數(shù)據(jù)格式,參考ajax的請(qǐng)求模型大概是:
$.ajax(options) ⇒ XMLHttpRequest
type (默認(rèn)值:"GET") HTTP的請(qǐng)求方法(“GET”, “POST”, or other)。
url (默認(rèn)值:當(dāng)前url) 請(qǐng)求的url地址。
data (默認(rèn)值:none) 請(qǐng)求中包含的數(shù)據(jù),對(duì)于GET請(qǐng)求來說,這是包含查詢字符串的url地址,如果是包含的是object的話,$.param會(huì)將其轉(zhuǎn)化成string。
所以我這邊與Native約定的請(qǐng)求模型是:
- requestHybrid({
- //創(chuàng)建一個(gè)新的webview對(duì)話框窗口
- tagname: 'hybridapi',
- //請(qǐng)求參數(shù),會(huì)被Native使用
- param: {},
- //Native處理成功后回調(diào)前端的方法
- callback: function (data) {
- }
- });
這個(gè)方法執(zhí)行會(huì)形成一個(gè)URL,比如:
hybridschema://hybridapi?callback=hybrid_1446276509894¶m=%7B%22data1%22%3A1%2C%22data2%22%3A2%7D
這里提一點(diǎn),APP安裝后會(huì)在手機(jī)上注冊(cè)一個(gè)schema,比如淘寶是taobao://,Native會(huì)有一個(gè)進(jìn)程監(jiān)控Webview發(fā)出的所有 schema://請(qǐng)求,然后分發(fā)到“控制器”hybridapi處理程序,Native控制器處理時(shí)會(huì)需要param提供的參數(shù)(encode過),處 理結(jié)束后將攜帶數(shù)據(jù)獲取Webview window對(duì)象中的callback(hybrid_1446276509894)調(diào)用之
數(shù)據(jù)返回的格式約定是:
{
data: {},
errno: 0,
msg: "success"
}
真實(shí)的數(shù)據(jù)在data對(duì)象中,如果errno不為0的話,便需要提示msg,這里舉個(gè)例子如果錯(cuò)誤碼1代表該接口需要升級(jí)app才能使用的話:
{
data: {},
errno: 1,
msg: "APP版本過低,請(qǐng)升級(jí)APP版本"
}
代碼實(shí)現(xiàn)
這里給一個(gè)簡(jiǎn)單的代碼實(shí)現(xiàn),真實(shí)代碼在APP中會(huì)有所變化:
- window.Hybrid = window.Hybrid || {};
- var bridgePostMsg = function (url) {
- if ($.os.ios) {
- window.location = url;
- } else {
- var ifr = $('<iframe style="display: none;" src="' + url + '"/>');
- $('body').append(ifr);
- setTimeout(function () {
- ifr.remove();
- }, 1000)
- }
- };
- var _getHybridUrl = function (params) {
- var k, paramStr = '', url = 'scheme://';
- url += params.tagname + '?t=' + new Date().getTime(); //時(shí)間戳,防止url不起效
- if (params.callback) {
- url += '&callback=' + params.callback;
- delete params.callback;
- }
- if (params.param) {
- paramStr = typeof params.param == 'object' ? JSON.stringify(params.param) : params.param;
- url += '¶m=' + encodeURIComponent(paramStr);
- }
- return url;
- };
- var requestHybrid = function (params) {
- //生成唯一執(zhí)行函數(shù),執(zhí)行后銷毀
- var tt = (new Date().getTime());
- var t = 'hybrid_' + tt;
- var tmpFn;
- //處理有回調(diào)的情況
- if (params.callback) {
- tmpFn = params.callback;
- params.callback = t;
- window.Hybrid[t] = function (data) {
- tmpFn(data);
- delete window.Hybrid[t];
- }
- }
- bridgePostMsg(_getHybridUrl(params));
- };
- //獲取版本信息,約定APP的navigator.userAgent版本包含版本信息:scheme/xx.xx.xx
- var getHybridInfo = function () {
- var platform_version = {};
- var na = navigator.userAgent;
- var info = na.match(/scheme\/\d\.\d\.\d/);
- if (info && info[0]) {
- info = info[0].split('/');
- if (info && info.length == 2) {
- platform_version.platform = info[0];
- platform_version.version = info[1];
- }
- }
- return platform_version;
- };
因?yàn)镹ative對(duì)于H5來是底層,框架&底層一般來說是不會(huì)關(guān)注業(yè)務(wù)實(shí)現(xiàn)的,所以真實(shí)業(yè)務(wù)中Native調(diào)用H5場(chǎng)景較少,這里不予關(guān)注了。
#p#
常用交互API
良好的交互設(shè)計(jì)是成功的第一步,在真實(shí)業(yè)務(wù)開發(fā)中有一些API一定會(huì)用到。
跳轉(zhuǎn)
跳轉(zhuǎn)是Hybrid必用API之一,對(duì)前端來說有以下跳轉(zhuǎn):
① 頁面內(nèi)跳轉(zhuǎn),與Hybrid無關(guān)
② H5跳轉(zhuǎn)Native界面
③ H5新開Webview跳轉(zhuǎn)H5頁面,一般為做頁面動(dòng)畫切換
如果要使用動(dòng)畫,按業(yè)務(wù)來說有向前與向后兩種,forward&back,所以約定如下,首先是H5跳Native某一個(gè)頁面
- //H5跳Native頁面
- //=>baidubus://forward?t=1446297487682¶m=%7B%22topage%22%3A%22home%22%2C%22type%22%3A%22h2n%22%2C%22data2%22%3A2%7D
- requestHybrid({
- tagname: 'forward',
- param: {
- //要去到的頁面
- topage: 'home',
- //跳轉(zhuǎn)方式,H5跳Native
- type: 'native',
- //其它參數(shù)
- data2: 2
- }
- });
比如攜程H5頁面要去到酒店Native某一個(gè)頁面可以這樣:
- //=>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
- requestHybrid({
- tagname: 'forward',
- param: {
- //要去到的頁面
- topage: 'hotel/detail',
- //跳轉(zhuǎn)方式,H5跳Native
- type: 'native',
- //其它參數(shù)
- id: 20151031
- }
- });
比如H5新開Webview的方式跳轉(zhuǎn)H5頁面便可以這樣:
- requestHybrid({
- tagname: 'forward',
- param: {
- //要去到的頁面,首先找到hotel頻道,然后定位到detail模塊
- topage: 'hotel/detail ',
- //跳轉(zhuǎn)方式,H5新開Webview跳轉(zhuǎn),最后裝載H5頁面
- type: 'webview',
- //其它參數(shù)
- id: 20151031
- }
- });
back與forward一致,我們甚至?xí)衋nimattype參數(shù)決定切換頁面時(shí)的動(dòng)畫效果,真實(shí)使用時(shí)可能會(huì)封裝全局方法略去tagname的細(xì)節(jié),這時(shí)就和糯米對(duì)外釋放的接口差不多了。
Header 組件的設(shè)計(jì)
最初我其實(shí)是抵制使用Native提供的UI組件的,尤其是Header,因?yàn)槠脚_(tái)化后,Native每次改動(dòng)都很慎重并且響應(yīng)很慢,但是出于兩點(diǎn)核心因素考慮,我基本放棄了抵抗:
① 其它主流容器都是這么做的,比如微信、手機(jī)百度、攜程
② 沒有header一旦網(wǎng)絡(luò)出錯(cuò)出現(xiàn)白屏,APP將陷入假死狀態(tài),這是不可接受的,而一般的解決方案都太業(yè)務(wù)了
PS:Native吊起Native時(shí),如果300ms沒有響應(yīng)需要出loading組件,避免白屏
因?yàn)镠5站點(diǎn)本來就有Header組件,站在前端框架層來說,需要確保業(yè)務(wù)的代碼是一致的,所有的差異需要在框架層做到透明化,簡(jiǎn)單來說Header的設(shè)計(jì)需要遵循:
① H5 header組件與Native提供的header組件使用調(diào)用層接口一致
② 前端框架層根據(jù)環(huán)境判斷選擇應(yīng)該使用H5的header組件抑或Native的header組件
一般來說header組件需要完成以下功能:
① header左側(cè)與右側(cè)可配置,顯示為文字或者圖標(biāo)(這里要求header實(shí)現(xiàn)主流圖標(biāo),并且也可由業(yè)務(wù)控制圖標(biāo)),并需要控制其點(diǎn)擊回調(diào)
② header的title可設(shè)置為單標(biāo)題或者主標(biāo)題、子標(biāo)題類型,并且可配置lefticon與righticon(icon居中)
③ 滿足一些特殊配置,比如標(biāo)簽類header
所以,站在前端業(yè)務(wù)方來說,header的使用方式為(其中tagname是不允許重復(fù)的):
- //Native以及前端框架會(huì)對(duì)特殊tagname的標(biāo)識(shí)做默認(rèn)回調(diào),如果未注冊(cè)callback,或者點(diǎn)擊回調(diào)callback無返回則執(zhí)行默認(rèn)方法
- // back前端默認(rèn)執(zhí)行History.back,如果不可后退則回到指定URL,Native如果檢測(cè)到不可后退則返回Naive大首頁
- // home前端默認(rèn)返回指定URL,Native默認(rèn)返回大首頁
- this.header.set({
- left: [
- {
- //如果出現(xiàn)value字段,則默認(rèn)不使用icon
- tagname: 'back',
- value: '回退',
- //如果設(shè)置了lefticon或者righticon,則顯示icon
- //native會(huì)提供常用圖標(biāo)icon映射,如果找不到,便會(huì)去當(dāng)前業(yè)務(wù)頻道專用目錄獲取圖標(biāo)
- lefticon: 'back',
- callback: function () { }
- }
- ],
- right: [
- {
- //默認(rèn)icon為tagname,這里為icon
- tagname: 'search',
- callback: function () { }
- },
- //自定義圖標(biāo)
- {
- tagname: 'me',
- //會(huì)去hotel頻道存儲(chǔ)靜態(tài)header圖標(biāo)資源目錄搜尋該圖標(biāo),沒有便使用默認(rèn)圖標(biāo)
- icon: 'hotel/me.png',
- callback: function () { }
- }
- ],
- title: 'title',
- //顯示主標(biāo)題,子標(biāo)題的場(chǎng)景
- title: ['title', 'subtitle'],
- //定制化title
- title: {
- value: 'title',
- //標(biāo)題右邊圖標(biāo)
- righticon: 'down', //也可以設(shè)置lefticon
- //標(biāo)題類型,默認(rèn)為空,設(shè)置的話需要特殊處理
- //type: 'tabs',
- //點(diǎn)擊標(biāo)題時(shí)的回調(diào),默認(rèn)為空
- callback: function () { }
- }
- });
因?yàn)镠eader左邊一般來說只有一個(gè)按鈕,所以其對(duì)象可以使用這種形式:
- this.header.set({
- back: function () { },
- title: ''
- });
- //語法糖=>
- this.header.set({
- left: [{
- tagname: 'back',
- callback: function(){}
- }],
- title: '',
- });
為完成Native端的實(shí)現(xiàn),這里會(huì)新增兩個(gè)接口,向Native注冊(cè)事件,以及注銷事件:
- var registerHybridCallback = function (ns, name, callback) {
- if(!window.Hybrid[ns]) window.Hybrid[ns] = {};
- window.Hybrid[ns][name] = callback;
- };
- var unRegisterHybridCallback = function (ns) {
- if(!window.Hybrid[ns]) return;
- delete window.Hybrid[ns];
- };
Native Header組件的實(shí)現(xiàn):
- define([], function () {
- 'use strict';
- return _.inherit({
- propertys: function () {
- this.left = [];
- this.right = [];
- this.title = {};
- this.view = null;
- this.hybridEventFlag = 'Header_Event';
- },
- //全部更新
- set: function (opts) {
- if (!opts) return;
- var left = [];
- var right = [];
- var title = {};
- var tmp = {};
- //語法糖適配
- if (opts.back) {
- tmp = { tagname: 'back' };
- if (typeof opts.back == 'string') tmp.value = opts.back;
- else if (typeof opts.back == 'function') tmp.callback = opts.back;
- else if (typeof opts.back == 'object') _.extend(tmp, opts.back);
- left.push(tmp);
- } else {
- if (opts.left) left = opts.left;
- }
- //右邊按鈕必須保持?jǐn)?shù)據(jù)一致性
- if (typeof opts.right == 'object' && opts.right.length) right = opts.right
- if (typeof opts.title == 'string') {
- title.title = opts.title;
- } else if (_.isArray(opts.title) && opts.title.length > 1) {
- title.title = opts.title[0];
- title.subtitle = opts.title[1];
- } else if (typeof opts.title == 'object') {
- _.extend(title, opts.title);
- }
- this.left = left;
- this.right = right;
- this.title = title;
- this.view = opts.view;
- this.registerEvents();
- _.requestHybrid({
- tagname: 'updateheader',
- param: {
- left: this.left,
- right: this.right,
- title: this.title
- }
- });
- },
- //注冊(cè)事件,將事件存于本地
- registerEvents: function () {
- _.unRegisterHybridCallback(this.hybridEventFlag);
- this._addEvent(this.left);
- this._addEvent(this.right);
- this._addEvent(this.title);
- },
- _addEvent: function (data) {
- if (!_.isArray(data)) data = [data];
- var i, len, tmp, fn, tagname;
- var t = 'header_' + (new Date().getTime());
- for (i = 0, len = data.length; i < len; i++) {
- tmp = data[i];
- tagname = tmp.tagname || '';
- if (tmp.callback) {
- fn = $.proxy(tmp.callback, this.view);
- tmp.callback = t;
- _.registerHeaderCallback(this.hybridEventFlag, t + '_' + tagname, fn);
- }
- }
- },
- //顯示header
- show: function () {
- _.requestHybrid({
- tagname: 'showheader'
- });
- },
- //隱藏header
- hide: function () {
- _.requestHybrid({
- tagname: 'hideheader',
- param: {
- animate: true
- }
- });
- },
- //只更新title,不重置事件,不對(duì)header其它地方造成變化,僅僅最簡(jiǎn)單的header能如此操作
- update: function (title) {
- _.requestHybrid({
- tagname: 'updateheadertitle',
- param: {
- title: 'aaaaa'
- }
- });
- },
- initialize: function () {
- this.propertys();
- }
- });
- });
- Native Header組件的封裝
#p#
請(qǐng)求類
雖然get類請(qǐng)求可以用jsonp的方式繞過跨域問題,但是post請(qǐng)求卻是真正的攔路虎,為了安全性服務(wù)器設(shè)置cors會(huì)僅僅針對(duì)幾個(gè)域 名,Hybrid內(nèi)嵌靜態(tài)資源是通過file的方式讀取,這種場(chǎng)景使用cors就不好使了,所以每個(gè)請(qǐng)求需要經(jīng)過Native做一層代理發(fā)出去。
這個(gè)使用場(chǎng)景與Header組件一致,前端框架層必須做到對(duì)業(yè)務(wù)透明化,業(yè)務(wù)事實(shí)上不必關(guān)心這個(gè)請(qǐng)求是由瀏覽器發(fā)出還是由Native發(fā)出:
1 HybridGet = function (url, param, callback) {
2 };
3 HybridPost = function (url, param, callback) {
4 };
真實(shí)的業(yè)務(wù)場(chǎng)景,會(huì)將之封裝到數(shù)據(jù)請(qǐng)求模塊,在底層做適配,在H5站點(diǎn)下使用ajax請(qǐng)求,在Native內(nèi)嵌時(shí)使用代理發(fā)出,與Native的約定為:
- requestHybrid({
- tagname: 'ajax',
- param: {
- url: 'hotel/detail',
- param: {},
- //默認(rèn)為get
- type: 'post'
- },
- //響應(yīng)后的回調(diào)
- callback: function (data) { }
- });
常用NativeUI組件
最后,Native會(huì)提供幾個(gè)常用的Native級(jí)別的UI,比如loading加載層,比如toast消息框:
- var HybridUI = {};
- HybridUI.showLoading();
- //=>
- requestHybrid({
- tagname: 'showLoading'
- });
- HybridUI.showToast({
- title: '111',
- //幾秒后自動(dòng)關(guān)閉提示框,-1需要點(diǎn)擊才會(huì)關(guān)閉
- hidesec: 3,
- //彈出層關(guān)閉時(shí)的回調(diào)
- callback: function () { }
- });
- //=>
- requestHybrid({
- tagname: 'showToast',
- param: {
- title: '111',
- hidesec: 3,
- callback: function () { }
- }
- });
Native UI與前端UI不容易打通,所以在真實(shí)業(yè)務(wù)開發(fā)過程中,一般只會(huì)使用幾個(gè)關(guān)鍵的Native UI。
賬號(hào)系統(tǒng)的設(shè)計(jì)
根據(jù)上面的設(shè)計(jì),我們約定在Hybrid中請(qǐng)求有兩種發(fā)出方式:
① 如果是webview訪問線上站點(diǎn)的話,直接使用傳統(tǒng)ajax發(fā)出
② 如果是file的形式讀取Native本地資源的話,請(qǐng)求由Native代理發(fā)出
因?yàn)殪o態(tài)html資源沒有鑒權(quán)的問題,真正的權(quán)限驗(yàn)證需要請(qǐng)求服務(wù)器api響應(yīng)通過錯(cuò)誤碼才能獲得,這是動(dòng)態(tài)語言與靜態(tài)語言做入口頁面的一個(gè)很大的區(qū)別。
以網(wǎng)頁的方式訪問,賬號(hào)登錄與否由是否帶有秘鑰cookie決定(這時(shí)并不能保證秘鑰的有效性),因?yàn)镹ative不關(guān)注業(yè)務(wù)實(shí)現(xiàn),而每次載入都有可能是登錄成功跳回來的結(jié)果,所以每次載入后都需要關(guān)注秘鑰cookie變化,以做到登錄態(tài)數(shù)據(jù)一致性。
以file的方式訪問內(nèi)嵌資源的話,因?yàn)锳PI請(qǐng)求控制方為Native,所以鑒權(quán)的工作完全由Native完成,接口訪問如果沒有登錄便彈出 Native級(jí)別登錄框引導(dǎo)登錄即可,每次訪問webview將賬號(hào)信息種入到webview中,這里有個(gè)矛盾點(diǎn)是Native種入webview的時(shí) 機(jī),因?yàn)橛锌赡苁蔷W(wǎng)頁注銷的情況,所以這里的邏輯是:
① webview載入結(jié)束
② Native檢測(cè)webview是否包含賬號(hào)cookie信息
③ 如果不包含則種入cookie,如果包含則檢測(cè)與Native賬號(hào)信息是否相同,不同則替換自身
④ 如果檢測(cè)到跳到了注銷賬戶的頁面,則需要清理自身賬號(hào)信息
如果登錄不統(tǒng)一會(huì)就會(huì)出現(xiàn)上述復(fù)雜的邏輯,所以真實(shí)情況下我們會(huì)對(duì)登錄接口收口。
簡(jiǎn)單化賬號(hào)接口
平臺(tái)層面覺得上述操作過于復(fù)雜,便強(qiáng)制要求在Hybrid容器中只能使用Native接口進(jìn)行登錄和登出,前端框架在底層做適配,保證上層業(yè)務(wù)的透明,這樣情況會(huì)簡(jiǎn)單很多:
① 使用Native代理做請(qǐng)求接口,如果沒有登錄直接Native層喚起登錄框
② 直連方式使用ajax請(qǐng)求接口,如果沒有登錄則在底層喚起登錄框(需要前端框架支持)
簡(jiǎn)單的登錄登出接口實(shí)現(xiàn):
- /*
- 無論成功與否皆會(huì)關(guān)閉登錄框
- 參數(shù)包括:
- success 登錄成功的回調(diào)
- error 登錄失敗的回調(diào)
- url 如果沒有設(shè)置success,或者success執(zhí)行后沒有返回true,則默認(rèn)跳往此url
- */
- HybridUI.Login = function (opts) {
- };
- //=>
- requestHybrid({
- tagname: 'login',
- param: {
- success: function () { },
- error: function () { },
- url: '...'
- }
- });
- //與登錄接口一致,參數(shù)一致
- HybridUI.logout = function () {
- };
賬號(hào)信息獲取
在實(shí)際的業(yè)務(wù)開發(fā)中,判斷用戶是否登錄、獲取用戶基本信息的需求比比皆是,所以這里必須保證Hybrid開發(fā)模式與H5開發(fā)模式保持統(tǒng)一,否則需要在業(yè)務(wù)代碼中做很多無謂的判斷,我們?cè)谇岸丝蚣軙?huì)封裝一個(gè)User模塊,主要接口包括:
1 var User = {};
2 User.isLogin = function () { };
3 User.getInfo = function () { };
這個(gè)代碼的底層實(shí)現(xiàn)分為前端實(shí)現(xiàn),Native實(shí)現(xiàn),首先是前端的做法是:
當(dāng)前端頁面載入后,會(huì)做一次異步請(qǐng)求,請(qǐng)求用戶相關(guān)數(shù)據(jù),如果是登錄狀態(tài)便能獲取數(shù)據(jù)存于localstorage中,這里一定不能存取敏感信息
前端使用localstorage的話需要考慮極端情況下使用內(nèi)存變量的方式替換localstorage的實(shí)現(xiàn),否則會(huì)出現(xiàn)不可使用的情況,而后續(xù)的訪問皆是使用localstorage中的數(shù)據(jù)做判斷依據(jù),以下情況需要清理localstorage的賬號(hào)數(shù)據(jù):
① 系統(tǒng)登出
② 訪問接口提示需要登錄
③ 調(diào)用登錄接口
這種模式多用于單頁應(yīng)用,非單頁應(yīng)用一般會(huì)在每次刷新頁面先清空賬號(hào)信息再異步拉取,但是如果當(dāng)前頁面馬上就需要判斷用戶登錄數(shù)據(jù)的話,便不可靠了;處于Hybrid容器中時(shí),因?yàn)镹ative本身就保存了用戶信息,封裝的接口直接由Native獲取即可,這塊比較靠譜。
#p#
Hybrid的資源
目錄結(jié)構(gòu)
Hybrid技術(shù)既然是將靜態(tài)資源存于Native,那么就需要目錄設(shè)計(jì),經(jīng)過之前的經(jīng)驗(yàn),目錄結(jié)構(gòu)一般以2層目錄劃分:
如果我們有兩個(gè)頻道酒店與機(jī)票,那么目錄結(jié)構(gòu)是這樣的:
- webapp //根目錄
- ├─flight
- ├─hotel //酒店頻道
- │ │ index.html //業(yè)務(wù)入口html資源,如果不是單頁應(yīng)用會(huì)有多個(gè)入口
- │ │ main.js //業(yè)務(wù)所有js資源打包
- │ │
- │ └─static //靜態(tài)樣式資源
- │ ├─css
- │ ├─hybrid //存儲(chǔ)業(yè)務(wù)定制化類Native Header圖標(biāo)
- │ └─images
- ├─libs
- │ libs.js //框架所有js資源打包
- │
- └─static
- ├─css
- └─images
最初設(shè)計(jì)的forward跳轉(zhuǎn)中的topage參數(shù)規(guī)則是:頻道/具體頁面=>channel/page,其余資源會(huì)由index.html這個(gè)入口文件帶出。
增量機(jī)制
真實(shí)的增量機(jī)制需要服務(wù)器端的配合,我這里只能簡(jiǎn)單描述,Native端會(huì)有維護(hù)一個(gè)版本映射表:
{
flight: 1.0.0,
hotel: 1.0.0,
libs: 1.0.0,
static: 1.0.0
}
這個(gè)映射表是每次大版本APP發(fā)布時(shí)由服務(wù)器端生成的,如果酒店頻道需要在線做增量發(fā)布的話,會(huì)打包一個(gè)與線上一致的文件目錄,走發(fā)布平臺(tái)發(fā)布,會(huì)在數(shù)據(jù)庫中形成一條記錄:
channel |
ver |
md5 |
flight |
1.0.0 |
1245355335 |
hotel |
1.0.1 |
455ettdggd |
當(dāng)APP啟動(dòng)時(shí),APP會(huì)讀取版本信息,這里發(fā)現(xiàn)hotel的本地版本號(hào)比線上的小,便會(huì)下載md5對(duì)應(yīng)的zip文件,然后解壓之并且替換整個(gè) hotel文件,本次增量結(jié)束,因?yàn)樗械陌姹疚募粫?huì)重復(fù),APP回滾時(shí)可用回到任意想去的版本,也可以對(duì)任意版本做BUG修復(fù)。
結(jié)語
github上代碼會(huì)持續(xù)更新,現(xiàn)在界面反正不太好看,大家多多包涵吧,這里是一些效果圖:
Hybrid方案是快速迭代項(xiàng)目,快速占領(lǐng)市場(chǎng)的神器,希望此文能對(duì)準(zhǔn)備接觸Hybrid技術(shù)的朋友提供一些幫助,并且再次感謝明月同學(xué)的配合。