端動態化方案詳細設計

YataoZhang發表於2018-12-29

前言

背景什麼的就不說了,大家都懂!不懂的請百度!既然看到了這篇文章,說明你還是對動態化有自己的訴求噠,那麼希望文章中的內容可以幫到你。


技術選型

技術選型永遠是專案確定之後遇到的第一個難題,市面上可以解決專案問題的選型有很多,到底是時髦驅動開發還是熱鬧驅動開發嘞?其實大家在選型過程中最應該關心的不是技術,而是專案。因為技術應該為專案服務,而不是專案為技術服務,分清楚權重之後,就很清晰了。接下來就是從專案入手,從專案入手需要從三個因素考慮:

  1. 專案因素
  2. 團隊因素
  3. 技術因素

專案因素

專案在不同的階段需要考慮的情況是完全不一樣的。

Alt text

比如專案剛啟動或者在基礎功能鋪設的階段,那麼關心就應該是快速試錯需求快速更新迭代需求變更緊急運營活動頻繁、其他的非功能性需求。在專案的擴張期也就是中期,可能會經歷一次重構,將前期各種臨時的解決方案統一進行升級和整改,以此增加系統的穩定性並且可以足夠應對專案對內的產品類目、功能需求以及對外的市場和產品擴張。在專案的穩定期大多數的專案架構都已經定型,但是並不是那麼的"盡善盡美",會有很多歷史遺留問題讓人頭疼,所以這個時候更需要有一個技術視野和能力很強的人來帶領團隊把專案的技術深度和高度達到一個更高的高度,當然,這個過程中是十分考驗開發人員的能力的。

棋局裡有一句善弈者通盤無妙手,好的架構都是潤物細無聲的,任何需求和功能的變化都可以讓開發人員十分便捷的完成,擁有足夠的靈活性和反脆弱性,當然這裡就不做過多討論了。

團隊因素

選型是針對團隊的考慮比重也是十分重要的,因為專案不是一個人在做,而是一群人。當你選定了某一項技術之後團隊裡肯定存在對這個技術不熟悉的人。所以你需要考慮到團隊成員的學習成本,另外在團隊招納新人的時候也會把該技術的要求新增到新人的技能列表裡,如果你選的技術大部分人都不會甚至不知道那就很尷尬了,專案就會越做越死。

技術因素

經過前兩個因素的綜合考慮之後,就該考慮技術因素了。選定的技術方案或者解決方案技術程度度怎麼樣,是不是已經達到了stable的狀態。

  1. 文件和示例是否齊全?
  2. 遇到問題之後是否可以得到技術維護人員的第一時間解答?
  3. 遇到bug如何修復?

技術方案的穩定性怎麼樣,需要多少人力支援所謂的穩定性這也是需要考慮的地方。另外就是擴充套件性了,當然這個是相對於需求和功能的擴充套件來說的。

最後,把備選的多個技術方案進行三個方面的多維度對比,就會得到一個比較滿意的方案了。


動態化的選型

我們在評審前,找了三端(FE、IOS、安卓)的高工一起討論了選型的問題,經過綜合考慮,我們選擇了Weex和Hybrid兩種方案。具體細節包括但不侷限於技術選型適用場景功能邊界切入點互動協議等方面,在這裡不做贅述。

選定方案之後,我們從上述的三種因素基於團隊當前的專案階段進行綜合對比,具體如下圖。

Alt text

至於為什麼沒有選Weex,原因是我們FE團隊裡的人都不太瞭解Weex(大多是新人),而且深入學習的成本太大(不要告訴我確定專案完全基於某個技術方案開發之後不需要深入學習和掌握,那你不太適合這篇文章)。遇到阻塞性問題怎麼解決我們也不是很有把握,畢竟我們不是阿里系的。而使用Hybrid的話,這些問題就不需要考慮了。

大家都知道,javascript是單執行緒的,即便js引擎底層引入了非阻塞(non-blocking)的機制,也改變不了執行邏輯較多時頁面卡頓的問題(webWorker不在討論範圍內)。所以高階點的Hybrid方案使用了多執行緒以此拆分前端的邏輯和檢視。

關於非同步非阻塞的區別請參考 asynchronous-vs-non-blocking

使用RAIL模型評估效能

RAIL 是一種以使用者為中心的效能模型。每個網路應用均具有與其生命週期有關的四個不同方面,且這些方面以不同的方式影響著效能:

Alt text

TL;DR

  • 以使用者為中心;最終目標不是讓您的網站在任何特定裝置上都能執行很快,而是使使用者滿意。
  • 立即響應使用者;在 100 毫秒以內確認使用者輸入。
  • 設定動畫或滾動時,在 10 毫秒以內生成幀。
  • 最大程度增加主執行緒的空閒時間。
  • 持續吸引使用者;在 1000 毫秒以內呈現互動內容。

最後基於上述的RAIL模型,我們得到了結論:Hybrid並沒有比Weex的體驗差很多,在可控範圍內。比如小程式的體驗效果

行業契合度

選擇Hybrid之後,我們有對比了行業的契合度。因為我們的專案是一個類似與電商的專案,就是買東西的。所以還是蠻符合的。

Alt text


適用場景

接下來介紹下Hybrid方案在專案功能內的使用場景,目前我們的專案由於是處於初期階段,所以功能較少,主要有以下四類:

Alt text

圖中依次是:首頁、二級頁、詳情頁、單品詳情頁。依據於我們的場景,除了個人中心、訂單列表、收銀臺之外,Hybrid何以適用於專案的其他任何場景。

切入點

因為專案剛開始到目前為止,我們三個端都是各自實現業務邏輯,所以在實現動態化方案的過程中,不能一刀切。時間、人力、專案各種因素也不允許我們這麼做。因此我們選擇了一個切入點,循循漸進得完成我們需求,就像是給一輛高速駕駛的汽車更換地盤一樣。

專案確定之後,我們優先考慮了單品詳情頁作為我們的技術切入點。具體原因如下:

  1. 展示內容居多,沒有複雜互動
  2. 頻繁變化,每個單品都有不同的詳情內容
  3. 互動簡單,適合循循漸進定製Hybrid的各種協議和邏輯

後面我們依次的遷移順序為:單品詳情頁 -> 詳情頁 -> 二級頁 -> 首頁。


整體架構

上面聊了那麼多,沒多少技術的乾貨,現在開始介紹下整體架構的設計。在需求實現的過程中我們經常會發生統一套頁面需求在WAP和Native端上都需要實現,為了解決這種需求帶來的重複工作量的問題,我們在設計時加入對宿主環境相容的考慮。

Alt text

主要分為三層:

  1. 檢視層
  2. 容器
  3. native / OS

檢視層主要負責檢視的展現,包括H5的頁面和模板、業務的框架實現還有內嵌在檢視層的bridge,如果檢視是在APP的webView中,那麼也會包含Native Activities控制元件。至於原生的Native Activities如何設計,後面會講到。

容器就是檢視層的執行環境,可能是移動端瀏覽器,也可能是App的webView。瀏覽器的話這裡暫且不提,webView的話會提供一個Bridge Provider用來將端封裝好的能力輸出給檢視層,一般使用API注入和Schema的方式實現。裡面封裝的都是Native級別的業務API和硬體裝置的API。

最下面的就是Native的OS層,主要提供一切必要的基礎能力,由於我對Native瞭解的並不深入,所以這裡暫不討論。

由於檢視層和容器的層隔離,讓檢視層不需要關心容器的實現,但是它們之間的bridge卻必須得關心這個。以至於bridge如何相容不同的容器(wap瀏覽器、Native App),這是個值得深入考慮的問題。

這是一個簡單的分層架構。其中每一層都有著特定的角色和職能。架構裡的層次是具體工作的高度抽象,它們都是為了實現某種特定的業務請求而存在的。還有一個突出特性是關注點分離,每層都只會處理本層的邏輯。從另一方面說,分層隔離使得層與層之間都是相互獨立的。架構中的每一層都必須符合最少知識原則,正因為這種高度獨立,才使得我們可以很好的相容WAP瀏覽器和Native APP。


檢視層設計

UI的本質是什麼?是將從伺服器獲取資料狀態(state),經過一定的操作使之展示出來。我們可以用一個數學表示式來表現它們的關係:UI = f(state)。state是通過bridge或非同步化介面獲取的資料,UI就是使用者看到的介面,對於Hybrid模式的來說,真正關心的就是f這個函式到底如何實現。

我們可以簡單的把f往大了想,把它理解成為一個web容器,也就是Web Container。至於Container裡怎麼做?請看下圖:

Alt text

前文我們說過了,Hybrid中可以將檢視層中的檢視(View)與邏輯(Service)分開達到體驗提升的目的。在Native App中一般是將一個頁面拆成這兩部分放在兩個不同WebView中,一個WebView放View部分,一個WebView放Service部分*(也就是說,每個頁面都需要2個WebView)*。

他們之間經過各自的Bridge對即將傳送或剛接收到的資料進行包裝,然後再經過封裝在bridge中的tunnel進行資料互動,完成後續操作。不過,在wap瀏覽器中完全不需要考慮這些,該怎麼做就這麼做。但是也會出現一個相容問題,檢視層和容器之間通過bridge互動,也就是說在wap瀏覽器(wap瀏覽器也是容器)中也需要存在bridge,不過這個bridge提供的是瀏覽器的能力。

bridge中存在一個叫做tunnel的東西,主要負責傳遞Service和View之間的資料和事件。在不同的宿主環境中,tunnel的組成也不同,在Native App中,tunnel是一個IPC的實現。在瀏覽器中,tunnel是一個釋出訂閱事件機制的實現。

在Service中會遇到資料本地儲存的問題。資料的儲存和獲取統一通過bridge將操作內容傳送給Native,然後Native根據不同的操作內容進行處理,完畢之後再通過處理完畢之後的資料傳送給Bridge,進而bridge再行通知Service。

data的傳遞

Service包含檢視層中除了檢視渲染之外的其他任何邏輯。它把獲取到的state經過framework的API處理之後會生成一個檢視元資訊(View Metadata),檢視元資訊是對將要渲染檢視的簡單描述,通過它我們可以預想到檢視長什麼樣子。之後framework會把檢視元資訊通過bridge中的tunnel傳送給View。

注意: tunnel傳送資料的過程是非同步的。比如小程式中的setData()方法。

View只包含檢視層中的頁面渲染。渲染物件主要包括兩個:html以及需要展示的Native Activities控制元件。當Service傳送過來的檢視元資訊中包含Native級別控制元件時,bridge會把該部分的檢視後設資料傳送給Native。Native收到之後,就會根據後設資料在檢視層的WebView上展示原生控制元件*(注意:原生元件是Cover在WebView上的)*。當Service傳送過來的資料為html的檢視後設資料時,會先根據檢視後設資料進行DOM Diff,然後根據生成的Patch物件來進行頁面的渲染。

event的傳遞

View渲染完成之後,就會等待使用者操作。View會將使用者操作的事件區別對待:html的事件和Native控制元件事件。先說Native的事件,Native的事件WebView把控不了,需要Native在封裝業務原生控制元件時多做注意,對控制元件可能遇到的事件做統一梳理。原生控制元件會通過Native框架把事件源和事件引數進行序列化,然後Native框架再將序列化後的事件資料通過bridge傳送給Service。

如果是html的事件,View這邊中bridge會通過js獲取事件源和事件引數,然後統一進行序列化。然後在通過tunnel將序列化後的事件資料傳送給Service。

資料的請求

Web Container中請求的資料主要分為兩類:

  • 靜態資源請求
  • 業務資料請求(非同步化介面)

靜態資源請求會直接通過webView對外發起請求,這裡不做贅述。

除了靜態資源請求之外的非同步化介面請求我們會通過Native進行代理,讓Native幫我們傳送請求,而不是使用XMLHttpRequest物件進行請求。


Bridge設計

bridge層位於檢視層和Native之間,負責連結雙方,一個好的bridge設計,可以讓我們在開發的過程中事半功倍。

我們對Bridge的關注點:

  1. 位於 js執行環境宿主環境之間,負責連結雙方
  2. 相容宿主環境*(wap、app)*差異性
  3. 適配不同業務線提供的橋連*(注入API、schema協議)*能力
  4. 根據業務線單獨配置橋連能力
  5. 編譯階段 解決宿主環境相容能力

js執行環境可能是wap瀏覽器,也可能是Native中的WebView。宿主環境也可能是瀏覽器和Native App。對接的業務方提供的橋連能力各不相同,統一套方案需要對接至少三種不同的功能需求平臺。而這些問題就是需要在Bridge中解決的。

上一節提到過『除了靜態資源請求之外的非同步化介面請求我們會通過Native進行代理,讓Native幫我們傳送請求』。至於為什麼要這樣做主要原因為:

  1. 介面鑑權問題
  2. 對資料進行更新粒度的控制

先說第一個鑑權問題,常規的做法是App使用者登入後,將使用者的認證標識存在在webView的cookies中,然後WebView裡的業務程式碼傳送AJAX請求時就會將cookies攜帶到伺服器完成使用者鑑權。這種情況下如果伺服器端校驗使用者token失敗的話是無法第一時間讓APP跳轉到登入視窗的。另外在WebView中傳送了一個退出登入的非同步介面請求,這時APP也需要同步退出登入。很顯然,最好的辦法就是讓APP幫我們代為傳送非同步化介面請求。這樣我們還可以利用上APP的持久化快取能力來儲存介面資料。

整體架構流程

Alt text

宿主環境差異性

在瀏覽器和Native APP的差異性方面,我們總結了以下5點:

  1. 檢視控制元件
  2. 資料儲存
  3. 非同步化介面請求
  4. 頁面路由
  5. 頁面歷史管理

我們會在有差異的功能上封裝統一的API,以此減少FE開發人員在開發過程中的相容問題。

這裡僅以非同步化介面請求舉例,我們封裝一個統一request方法。開發人員不需要關心自己寫的程式碼將要在哪個平臺上執行。藉助WebPack和Rollup等工具的tree shaking功能,我們可以很好的完成差異化編譯。

// tools.js
import Axios from 'Axios';
import bridgeRequest from '@/bridge/request.js';

export default {
	request: process.env.TARGET === 'app' ? bridgeRequest : Axios
}

// main.js
import {request} from 'tools';

request.get('http://www.test.com/test', {a: 1}).then(data => {
	console.log('this is test data -> ', data);
});
複製程式碼

在編譯時我們只需要指定target就可以做差異化編譯了:

# 編譯為app版本
$ npm run build --TARGET=app
# 編譯為wap瀏覽器版本
$ npm run build --TARGET=browser
複製程式碼

橋連能力注入

我們定製一個Bridge的標準介面,用來規範各種操作,比如Native的調起彈出層控制元件。業務方根據自己往WebView注入的API或schema協議,填寫一個配置Json檔案,然後注入到bridge中,該檔案中宣告瞭alert操作要訪問的協議或方法以及引數名稱。這樣bridge在呼叫alert方法的時候就會根據json完成指定操作。

業務方只需要根據Bridge定義好的標準介面,注入自己的schema協議即可。

// system.schema.json
export default {
	alert: {
		schema: 'xxxx',
		params: {}
	},
	request: {
		schema: 'xxxx',
		params: {}
	}
}

// interactive,js
import schema from '@/schemas/system.schema.json';
// 注入業務方自己的alert schema
interactive.injectSchema(schema);
export default {
	alert(options) {
		return interactive.api.alert(options)
	}
}

// main.js
import {bridge} from '@/bridge/index.js';
import {alert} from '@/bridge/interactive.js';

// view層準備完畢
bridge.on('ready', () => {
	alert('這是一個alert!').then(data => {
		console.log(data.state ? '確定' : '取消');
	}).catch(e => {
		console.log('調起alert失敗');
	});
})
複製程式碼

Native層設計

由於我本身不是Native的開發人員所以這裡就列一張Native的架構圖,具體的你們自己看吧。

Alt text

注意: 這張圖是我這個FE畫的,被安卓的大佬吐槽說畫的結構不清晰。你們將就著看吧!

到這裡,我們就把架構裡最主要的三層:檢視層、Bridge和Native層介紹完了。下面開始介紹功能設計,主要包括三個方面:原生元件互動、路由系統(統跳協議)、資源包的快取與更新。


原生元件互動

原生元件與webView中用javascript實現的元件是不一樣的。它們是由Native直接在WebView之上渲染的原生控制元件,無法受到javascript影響,只會受到Native的控制和影響。對於WebView中的javascirpt程式碼來說就是:超乎三界之外,不在五行之中

為什麼不可以全部使用WebView中的js元件哪?那就是WebView中前端元件的影響面過小,就跟唐朝末年的朝廷一樣,政令不出長安城。比如Alert提示在顯示狀態下,不可以做其他互動操作,只能點選Alert的確定和取消按鈕。還有Header上左側按鈕的後退以及點選右側Icon返回APP首頁的操作等,這樣的例子還可以往下舉很多。所以遇到這種情況,就必須請原生的Native控制元件出馬控場了。

我們這裡梳理了一下可以用到的Native級別控制元件:

  • Header
  • Footer TabBar
  • Alert Tip Confirm
  • Dialog
  • SelectBar

Alt text

『部分』原生元件的載入時機

那些總是需要在檢視裡第一時間展示(Header、TabBar等)的原生元件必須區別對待。不能在WebView載入完之後再去渲染那些原生控制元件,因為這樣會出現因需渲染原生控制元件而對WebView重新計算大小導致Service中資料錯誤以及頁面閃爍的問題,從而影響使用者體驗。

最好的方法就是把這類原生元件的檢視後設資料單獨放在一個控制版本管理的json檔案(下文有寫到)中,而不是放在包含bundle內容的zip包中。這樣Native就可以根據json檔案中的檢視元資訊提前渲染好原生控制元件,然後載入WebView並執行javascript程式碼。


路由系統

在設計整個路由系統之前我們有個前提條件,那就是每個檢視頁面都是獨立的一個WebView(其實包含兩個,一個存放View邏輯,一個存放Service邏輯),而不是在同一個WebView中載入渲染多個頁面。因為只有這樣才可以完美的模擬原生應用的頁面跳轉的各種操作。這個一定要注意,如果你不注意你就不會理解下文到底在說什麼!

我們遇到的場景有以下幾種:

跳轉場景:

  1. Native to Native
  2. Native to WebView
  3. WebView to Native
  4. WebView to WebView

載入場景:

  1. 同頁面載入(重定向)
  2. 跨頁面載入

存在的問題:

  • 同時存在的WebView最大數量
  • 檢視之間的引數傳遞
  • 檢視歷史棧管理

最後我們商定的WebView可以同時存在的數量為9個,和微信小程式一樣。當頁面棧已經達到9個的時再開啟新頁面就會無法開啟新頁面。頁面之間的引數傳遞統一使用querystring格式。歷史棧的管理由Native統一實現。

歷史棧的管理

我們維護一個歷史棧的目的就是讓Native中的檢視可以像瀏覽器的歷史一樣,進行前進和後退。唯一的不同是,瀏覽器的歷史存的是URL字串,而我們的歷史棧存的是檢視物件。每次Native APP開啟都會重新從頭記錄,只會記錄APP執行期間的歷史,APP關閉後歷史棧清空。

Alt text

逐級訪問

正常的操作路徑訪問,會將每一級的檢視存放在歷史棧中。最多存入9級,超過9級則無法載入新頁面。

重複開啟頁面

當最新的單品頁新開啟一個二級頁時,即便這個二級頁已經開啟過,歷史管理器也會在棧的頂部新開啟一個二級頁。注意,兩個二級頁是完全獨立的。不存在檢視提升。

重定向

在最新的單品頁重定向為二級頁時,和上面的重複開啟頁面情況類似,都是將當前頁重定向為二級頁並渲染。注意,這兩個二級頁是完全獨立的。不存在檢視提升。

後退

當點選Header左側的後退按鈕(一級一級後退)或者通過Hybrid Router API(可以多級後退)進行後退操作時,就是消費當前的歷史管理棧。


資源包的快取與更新

當所有的步驟都已經就緒之後,就該到這一步了,bundle資源的快取和更新。這裡我們引入的分包載入的機制,而且分包的級別是以頁面為緯度的,而不是功能。其實就是小程式的那套分包載入機制。

為了實現這套機制,我們拋棄了WebView的快取,和Native同學一起開發並建立起了這套快取機制。並且只快取bundle資源(一個一個的zip包)。我們規定每個業務只有一個入口zip包,所有的子包zip都必須依賴入口zip包中的subConf.json進行更新和載入。

Alt text

只有在Native每次更新入口包是才會顯示loading,除此之外都會顯示入口包中攜帶的骨架圖html。

Alt text

APP每次開啟的時候都是先去伺服器獲取conf.json,conf.json中內容如下:

{
	"version": "v1.0.1",
	// 此內容僅為示例
	"skeletonURL": "https://pan.baidu.com/nt-static/hybrid/app.skeleton_v1.0.1.d3a938346f1ab825.html",
	// 需要下載的入口zip包 命名方式 {version}.{md5}.zip
	"zip": "https://issue.pcs.baidu.com/packages/bybrid/v1.0.1.d3a938346f1ab825.zip",
	// zip包的md5,根據此md5判斷該zip包是否需要更新
	"md5": "d3a938346f1ab825",
	// 簽名校驗
	"signature": "342876ba19d34aba92f7536e42992a45",
	// 需要提前渲染的原生控制元件
	"header": {
		"title": "this is a title"
	},
	"tabBar": {
		{
			"text": "log",
			"icon": ""
		},
		{
			"text": "home",
			"icon": ""
		}
	}
}
複製程式碼

入口zip包解壓完畢之後的目錄結構為:

$ tree ./v1.0.1.d3a938346f1ab825

./v1.0.1.d3a938346f1ab825
├── app.bundle.css //樣式檔案
├── app.bundle.js // js邏輯檔案
├── app.index.html // 入口html
├── app.skeleton_v1.0.1.d3a938346f1ab825.html // 骨架圖,和conf.json中的skeletonURL一致
└── subConf.json // 子包的載入及校驗配置
複製程式碼

subConf.json中的內容:

{
	// 和conf.json一致
	"signature": "342876ba19d34aba92f7536e42992a45",
	// 子包入口
	"subRoutes": [
		{
			// 入口的路由
			"routes": ["/go/to/path/1", "/go/to/path/2"],
			// 骨架圖URL
			"skeletonURL": {
				"/go/to/path/1": "https://pan.baidu.com/nt-static/hybrid/app.skeleton_v1.0.1.bfa31a2ae5f55a7f.html"
			},
			"zip": "https://issue.pcs.baidu.com/packages/bybrid/v1.0.1.bfa31a2ae5f55a7f.zip"
			"md5": "bfa31a2ae5f55a7f"
		},
		{
			"routes": ["/go/to/path/3"],
			"skeletonURL": {}
			"zip": "https://issue.pcs.baidu.com/packages/bybrid/v1.0.1.7af5f492a74499e7.zip",
			"md5": "7af5f492a74499e7"
		}
	]
}
複製程式碼

最後

本文主要介紹了Hybrid的整體架構的三層和功能設計的三個點,基本涵蓋了端動態化方向的全部要點。希望本文可以幫到你。

相關文章