獲得 Hybrid 架構的演進之路

獲得 APP 是一個三年多的產品,最初採用純 Native 的方式開發,在 18 年初,咱們開始了 Hybyid 開發技術方案的探索和實踐, 目前獲得 APP 共包含了 ReactNative 和 Webview 兩套 Hybrid 方案。本文從時間維度上,重點回顧一下 Webview Hybrid 方案在獲得 APP 從 0 到 1 的過程,也但願咱們的經歷能夠給一些想落地 Hybrid 方案的團隊一點啓發。前端

Author: jiangqiang

1. 背景和動機

獲得是一個重運營場景的產品,APP 內大部分的功能都會有分享功能。18 年初時,開發一個功能,基本須要三端三我的。部分業務使用了內嵌 Webview 、類瀏覽器式的方案,雖然知足了跨端,但體驗較差。因此最初的目的是但願有一套跨平臺方案,一套代碼能夠三端執行,而且有較好的體驗,這是當時 Hybrid 的架構圖:vue

Hybrid 架構圖 v1

除了 Webview,當時較爲流行的跨平臺方案主要是 ReactNative、Weex,對比了兩個方案,Weex 較爲接近咱們團隊的技術棧,而 RN 當時社區較爲成熟,最終咱們認爲社區更重要一些,因此選擇了 RN。git

在 RN 調研階段,咱們發現 RN 雖然支持三端和動態更新,可是須要配套的基礎設施才能夠實現其動態更新的能力,所以咱們須要一個離線資源的管理系統,可以動態更新客戶端內部的 RN 文件,而咱們在思考和設計這個離線資源管理系統時,發現一樣的思路能夠應用於 Webview,咱們能夠把前端代碼打成離線包,經過離線資源管理系統進行更新,而 Weview 在啓動過程當中,僅須要訪問數據 API 而不須要下載 HTML/JS/CSS 等,也算是變相的增長了離線能力。github

所以,咱們制定了最初的 Roadmap:web

  1. 先開發離線資源管理系統;
  2. 完成以後接入 Web 離線包,由於 Web 離線包開發成本較低,能夠快速的改善現有項目的體驗,快速收益;
  3. 最後在進行 RN 的開發和接入;

2. 離線資源包管理系統-Seeder

作一個技術驅動的項目就像是作一個產品,須要先梳理清楚需求、使用場景等,再想思考技術架構和實現細節。咱們首先爲項目起了個名字,叫 Seeder。(爲何起這個名字,其實沒什麼意義,主要是內部沒有其餘系統叫 Seeder。。。)算法

2.1. 目標

經過梳理,咱們認爲 Seeder 須要達成如下目標:vue-router

  1. 能夠動態更新資源;
  2. 能夠支持非最新版客戶端進行更新;
  3. 支持增量更新;
  4. 支持多頻道發佈;

2.2. 技術選型和架構

明確目標後,咱們要作技術選型和架構,在技術選型上,咱們使用團隊熟悉的 Nodejs+Mongodb 組合,架構圖以下:json

image-20200115161749742

服務端包含 Seeder 和 CDN 兩部分,CDN 部分主要是用來承接資源包的下載。Seeder 則拆分爲 Updater 服務和 Manager 服務:設計模式

  1. Updater 服務:主要是承接處理客戶端的更新請求;
  2. Manager 服務:主要進行資源包及相關配置的管理,包括生成 diff 包等等;

經過合理的拆分,Updater 服務在咱們後續的壓力測試中,2 臺 8C16G 機器能夠穩定承載 6000QPS;數組

2.3. 關鍵實現點 - Package 定義

既然是對資源包進行管理,咱們須要定義資源包的格式和約束。

格式方面,咱們選擇了 tgz 格式,即便用 tar 進行歸檔,用 gzip 進行壓縮的格式,以減小傳輸體積。

文件結構方面,在原有資源目錄結構下的根目錄,增長了一個 info.json 格式的文件,用來描述包的信息。

image-20200114171319102

咱們來看下一 package.json 的結構:

image-20200115105152693

  1. appId:標示包的應用 ID;

  2. version:標示這個包的版本;

  3. depend.containerVersion: 標示這個包依賴的容器版本,目前的容器只有客戶端;

  4. files: 一個數組,記錄全部的文件和路徑及其 MD5;

  5. meta: 擴展信息字段,這裏使用了兩個擴展字段,後面詳細講這兩個字段

    1. type:包的類型
    2. routes:包須要註冊的路由列表

2.4. 關鍵實現點 - 增量更新

增量更新指的是咱們只須要下載一個 Patch 包,安裝 Patch 包以後便可以完成應用的更新,像咱們經常使用的 VSCode 之類的軟件、大部分手機遊戲,都支持增量更新。實現增量更新關鍵點是增量算法,經過調研,最終選擇了支持二進制 diff 算法 bsdiff

確認算法以後就要開始思考增量包的實現方式,由於 bsdiff 是對單個二進制進行 diff,而咱們是一個包。所以有兩種方式:

  1. 基於歸檔壓縮完的 tgz 包進行 diff 和 patch,這種方案的優點是實現成本低,帶來的問題是客戶端必須保留一份底包,而且由 於 Package 在客戶端是下載完先解壓才能執行,這種方案沒法連續 patch 升級(不能增量從 v1.0->v1.1->v1.2,只能 v1.0->v1.1,v1.0->v1.2);
  2. 基於單文件 diff,即增量包其實包含多個 patch 文件,包含了描述 Package 變動信息的文件,這種方案雖然實現會複雜些,可是並無方案 1 的各類問題,所以咱們採用了是單文件 diff 的方案;

下面咱們看下單文件 diff 的方案,先看一個增量包的結構:

image-20200115113015205

相較於普通包,多了一個 update.json 文件,這個文件描述了整個包是若是變化的,基於這個文件和包內的其餘文件,即可以 Patch 到最早的版本,看一下 update.json 的結構:

image-20200115113150991

files 是描述變化的文件。關鍵字段 type, 標示了變動類型,add、delete、move、modify 等,分別表示新增的、須要刪除的、發生目錄和文件名變化的、內容變化的文件。add、delete、move 只涉及到了文件的新增、刪除、變動路徑等操做,而 modify 則是用到了 bsdiff,表示這個文件發生變化,須要增量更新。

經過這種精細化的操做,能夠提升 patch 的效率,同時客戶端無需保留底包,基於解壓完的代碼文件就能夠完成增量更新。

梳理完了增量包結構,還有面臨一個問題,就是增量包的生成時機。一樣有兩種方案:

  1. 請求來了生成增量包,好處就是必定會有增量包,問題是增量包的生成是一個 CPU 密集型操做,沒法支持高併發;
  2. 提早生成增量包,可是隻能提早生成指定版本數量的增量包,但可能存在較老版本沒有增量包可用;

咱們最終採用了提早生成增量包的方案,由於包內容差別越大,增量帶來的收益越小,沒有必要生成全部版本的增量包。咱們在上傳包時,會同時生成歷史 10 個版本的增量包。基於咱們目前的更新頻率,10 個歷史版本目前基本能夠知足需求(後續不知足能夠調整,就是一個配置項)。固然,用戶長時間不打開 APP,可能再次打開,咱們已經更新了十幾個版本,這個時候只能經過全量包來進行更新。

2.5. 架構變化

看一下咱們調整後的架構變化:

image-20200116173706077

3. 應用框架 - Adam

完成了基礎設施的建設以後,客戶端的離線資源也具有的動態更新的能力,但普通的 Web 離線包還有如下的限制:

  1. 每一個 Webview 只能有一個頁面,沒法實現複雜的功能(爲了跟客戶端保持一致的頁面交互體驗,每一個 Webview 只有一個頁面,這樣前進後退、導航條的表現是一致的);
  2. 沒法控制導航條,一些須要定製導航條的功能依賴客戶端;
  3. 沒有體系化的框架,沒法統一處理異常、緩存等;

爲了解決以上問題,咱們決定開發一個應用層的框架。

3.1. 目標和分解

咱們整個團隊最熟悉的技術棧是 Vue,所以 Adam 確定是基於 Vue 作封裝,在設計 Adam 以前,須要咱們先確認目標:

  1. 功能上:一個 Package 能夠做爲一個完整的 Application,可以完整地實現一個功能模塊,包括多頁面的功能等;
  2. 技術上:實現標準化的解決方案,由框架處理緩存、異常頁面等通用邏輯;

對目標進一步作分解:

  1. 須要客戶端將界面所有交給 Webview 處理;
  2. 須要 Router,而且像客戶端同樣,支持棧式管理頁面的路由;
  3. 頁面要實現客戶端相同的前進和後退動效,要支持滑動返回上一個頁面;
  4. 須要抽象緩存和異常頁面等到框架層;

image-20200115160956220

3.2. 架構圖

咱們先看下一下 Adam 的總體架構,以便於咱們後續內容的表述:

image-20200115181521536

每個 Web Package 就是一個應用,每一個 Application 實例對應一個 Global Store 和 vue-stack-router 的實例,對應多個 Page 實例。

每個頁面都由 Page Componets 和 Page Store 組成。其中 Page Store 的生命週期跟頁面保持一致。

3.3. 關鍵實現點 - Router

最初的 Router 方案咱們是選了咱們經常使用的 vue-router,但在實現過程當中,遇到了如下問題:

  1. 實現相似棧式的路由較爲困難。客戶端內的頁面大部分都具備棧式的特色,頁面實例的存活取決因而否在棧中。而 vue-router 中,組件實例的存活則是取決與是否使用了 kee-alive 組件;
  2. 實現兩個頁面間的、相似 Native 的滑動返回較爲困難;
  3. 沒法實現多例頁面。Native 中,A 頁面跳轉 A 頁面,會產生一個新的 A 頁面的實例。vue-router 中,A 頁面跳轉 A 頁面會從新渲染現有的 A 頁面,也就是 A 頁面始終是單例的;

爲了解決這些問題,咱們開發了 vue-stack-router (已開源,具體實現細節,感興趣的能夠直接看 github 代碼,內容較多,這裏不展開),相較於 vue-router,有如下新功能:

  1. 棧式的路由管理;
  2. 路由間數據傳遞;
  3. 支持更細粒度、可定製的路由過渡效果;
  4. 支持預渲染;

基於預渲染模式,咱們實現了手勢滑動返回的功能,即觸發手勢時,預渲染後一個頁面,此時同時存在兩個疊加在一塊兒的頁面,經過 JS 控制兩個頁面的動畫,即可以實習相似 Native 的滑動返回的效果。

3.4. 關鍵實現點 - Store

提到狀態管理工具,共識都是簡單的項目無需使用 Store,複雜項目才能體現出 Store 的價值,其實無非是引入 Store 帶來了成本。咱們分析一下移動端頁面的特色:

  1. 展現爲主
  2. 頁面間耦合性低
  3. 數據流簡單

所以,在移動端頁面,咱們追蹤狀態變化的收益可能不會很高,若是去掉狀態追蹤,Store 能夠變的很精簡, 看一下咱們本身精簡的 Store,原理以下:

class MyStore {
  public name: string = '';
  public updateName(name: string): void {
    this.name = name;
  }
}
const store = Vue.observable(new MyStore());
複製代碼

沒有狀態追蹤,只是最精簡的將狀態抽離到一個類中進行管理。

聊完了 Store 實現,再看看關於 Store 的組織形態,咱們經常使用 Vuex 和 Redux 都是單一組件樹,連 MobX 也有 mobx-state-tree 這種單一組件樹的社區方案。可是結合移動端業務的特色,單一組件樹會有些問題,對多頁面實例的支持,實現比較複雜。再一個,優秀的單一組件樹的組織一般是跟頁面分離的,通過單獨設計的,所以會帶來了額外的心智負擔。

基於以上死牢,最後咱們沒有采用單一組件樹,而是實現了多狀態的 Store 方案:一個頁面對應一個 Store,Store 和頁面的生命週期保持一致的方案。邏輯跟展示分離,頁面間又不耦合,最重要的是簡單;

3.5. 緩存

數據緩存是體驗優化的一大利器,經過先渲染緩存數據,在更新正式數據的方式,咱們能夠馬上展示出一個頁面而無需等待。Adam 實現了三級緩存:

image-20200116171904997

依次從路由數據、內存、LocalStorage 中取。路由數據是什麼呢,一般在客戶端內,頁面跳轉不少都是摘要信息跳往詳情信息的頁面(如列表的 item 跳詳情頁),其實前一個頁面已經包含一部分後續頁面的信息,這個時候能夠將前一個頁面的數據帶到後一個頁面中,後一個頁面即可以渲染出主要信息,提高用戶體驗。

那麼緩存的數據是哪裏來的呢,並不須要開發者手動寫入。咱們知道 View=fn(State),在 Store 方案中咱們已經將頁面的狀態都放到 store 中了,只須要緩存 Store 就能夠了。至於緩存和還原的時機,就是在頁面銷燬時,咱們序列化 Store,等頁面在打開,還原 Store 。

4. 標準化容器

在開發 Adam 的同時,也不斷有同窗反饋,如今接入一個新的 Web Hybrid 業務比較麻煩,須要客戶端配置 webview,並且新業務依賴發版,是否是能夠咱們徹底不依賴客戶端呢?

答案是能夠的。

4.1. 路由協議

咱們在 Package 中增長了包的類型和包的全局路由信息,這樣客戶端在更新到包的信息時,能夠動態註冊路由,也就是全部的 Package 中的路由,都綁定到一個標準化的 webview,webview 啓動後,根據跳轉過來的路由加載對應 Package,已實現動態加載和註冊功能。

4.2. 最終架構

完成了 Adam 和 標準化容器後,咱們看一下最終接架構:

image-20200116173601861

至此咱們能夠將每一個 Package 當作一個獨立的 Application 來更新和迭代。

5. 總結和思考

5.1. 成果

功能方面,咱們接入了講座、電子書、評測、訓練營、獲得大學、活動系統、幫助中心等模塊,接入了 90+的頁面(其中 ReactNative 佔 30+,Web 佔 60+);

image-20200116174052095

效率方面,咱們在一年半內支撐了 49 個功能模考動態更新了 1900 次。測試環境中,動態更新了 1.3 萬次;

性能方面,咱們從性能監控系統中找到兩個未使用和使用 Seeder 的功能進行對比(這個對比不太嚴謹,由於沒有同一個功能前後採用兩種方案的數據,咱們找了兩個功能相近,代碼量相近的兩個項目進行對比)。

普通 Webview 方案

image-20200115115722718

Adam + Seeder 方案:

image-20200115115740709

基本能夠看到,穩定性和效率都有較好的改善。

5.2. 思考

Hybrid 落地過程當中,咱們踩了不少坑,也有不少收貨,簡單談兩點。

第一個,如何評價一個技術方案的好壞?咱們有太多的標準:站在業務角度,是否是能知足需求及低成本的知足潛在的後續需求;站在運維角度,是否是帶來了新的部署運維成本。站在技術角度,咱們甚至能夠掏出一本設計模式大談一番。可是咱們不多有注意到技術方案的用戶體驗,這裏的用戶指的是使用你框架、庫的開發同窗。站在業務開發同窗的角度會發現,提供的方案確實解決了問題,可是使用這個方案過程當中,可能有 30% 工做是不屬於方案部分,可是屬於方案部分必須的,好比方案的入參是 A,開發者須要花大力氣才能獲得 A,才能使用這個方案。因此做爲框架、庫的開發者,要考慮清楚整個方案的使用場景,技術部分是否是能夠覆蓋整個場景,覆蓋不了要怎麼解決,是否須要提供自動化工具等等。

第二個,Hybrid 不是一個端的事情,而是三端一塊兒的事情,而做爲推進方,要儘量的瞭解三端,不瞭解能夠多跟各端同窗溝通交流,不要作成一方推進兩方配合,要讓你們感受是在一塊兒幹一件事情,這樣才能作好。

5.3. 後續的規劃

後續的規劃主要是有兩大方面:

  1. Adam 的多環境多端的支持,覆蓋獲得業務「端」的場景;
  2. Seeder 更加靈活的更新場景,好比支持 Lazy 加載等;
相關文章
相關標籤/搜索