Shopee在React Native 架構方面的探索

xiangzhihong發表於2022-07-18

1. 背景

React Native(下文簡稱 RN)是混合應用領域流行的跨端開發框架。RN 非常適合靈活多變的電商領域業務,由於 RN 是基於客戶端渲染的技術,所以相較於 H5 頁面,它在使用者體驗方面有一定優勢。

伴隨著 Shopee 業務的飛速發展,我們 App 中的 RN 程式碼量增長得非常快,出現了構建產物體積過大、部署時間太長、不同團隊依賴衝突等問題。為了應對這些痛點,我們探索了去中心化的 RN 架構,並結合該模型自研了系統(Code Push Platform,簡稱 CPP)和客戶端 SDK,覆蓋了多團隊的開發、構建、釋出、執行等一系列 RN 研發週期。經過近三年的迭代,現已接入多款公司級核心 App。

Shopee 商家服務前端團隊打造了多款商家端應用,大部分使用者是商家服務人員,他們對業務系統高可用和問題及時反饋有著很高的要求,從而也推動我們對 React Native 的架構有了更高的要求。

本文會從發展歷史、架構模型、系統設計、遷移方案四個方向逐一介紹我們如何一步步地滿足多團隊在複雜業務中的開發需求。

2. 發展歷程

隨著業務高速發展,我們的 RN bundle 個數飛速增加,App 個數也達到近十個。整個 RN 專案在開發模型、部署模型和架構模型三個維度都發生了變化,從單團隊發展成多團隊,從一個 bundle 發展成多個 bundle,從中心化架構發展成為去中心化,最終發展成為每個團隊的業務程式碼可以獨立地開發、部署、執行。

整個發展歷史分為 4 個階段,分別是單 bundle 集中開發模式、單 bundle 多業務組開發模式、多 bundle 中心化釋出模式、多 bundle 去中心化釋出模式。

image.png

2.1 第一階段:單 bundle 集中開發模式

最初的 RN 整體技術架構相對簡單。由於當時業務形態不算複雜,為了滿足獨立團隊在同一個程式碼倉庫當中的開發流程,整個釋出流程是基於 CDN 的更新發布,並且使用配置檔案記錄 RN bundle 檔案的版本以及下載地址,以此進行資源管理。整個釋出的產物有兩個,一個是 RN 資源包,一個是用於資源版本管理的 JSON 配置檔案。

每次 RN 資源在完成構建後,這兩種構建產物會被放置在靜態資源目錄下。App 在特定的時間節點(例如 App 重啟等)會自動拉取配置檔案檢查資源更新狀態,然後再從 CDN 拉取 RN 靜態資源。在下一次開啟頁面的時候,App 會載入最新的頁面內容。

image.png
隨著業務發展,越來越多業務團隊期望使用 RN 技術棧開發業務,這種情況讓已有架構發生改變,我們自然地產生了“多個業務組多個程式碼倉庫”的想法。

2.2 第二階段:單 bundle 多業務組開發模式

針對上述問題,多業務組的研發解決方案是 host-plugin 這種模式。

host 用於管理公共依賴和通用邏輯,它將 React、React Native、Shopee RN SDK 等通過一個獨立的倉庫管理起來,保證了特殊 RN 依賴的“singleton”(單例模式)條件,避免了部分客戶端元件的重疊依賴,而這種重疊依賴是 RN 官方不允許的。

一個 host 對應著多個 plugin 倉庫,業務程式碼倉庫則是被看作為一個外掛(plugin),以外掛的形式接入主應用當中。業務團隊可以按自己的編碼規範來管理這個倉庫。每個外掛倉庫會被視為 host 專案的 npm 依賴,它的構建是一個集中釋出的流程。所有程式碼都會整合在 host 專案當中執行構建指令碼。這種模式滿足超級 App 的要求。

image.png
與此同時,host-plugin 的模式也帶來了一個“難題”,業務發展使得 RN 產物體積逐漸變大,過大的產物會影響客戶端的解壓效率和 RN 容器載入 JS 時長。

2.3 第三階段:多 bundle 中心化架構模式

針對 RN 產物體積過大的問題,我們利用構建工具將打包產物細分成多個 bundle,這一優化是非常有必要的,我們稱它為“分包”。host 專案對應的是公共包,plugin 專案對應的是業務包。

整個構建發生在 host 專案,專案的模式還是“集中構建”和“集中釋出”。多 bundle 產物將會發布到系統當中,客戶端將拉取熱更新的內容。客戶端會按需載入對應的 bundle,RN 容器單次載入消耗的資源大大減少,解決了效率問題。

image.png
但是它的缺點也很明顯。隨著業務團隊的變大和業務內容的擴張,多 bundle 中心化釋出模式同樣也具備四個弊端:

  • 針對 RN 的執行時,即使分包技術使得產物分離,但是它們還是執行在同一個 JSContext 當中,這種情況可能會導致依賴衝突和環境變數汙染;
  • 在開發除錯的過程中,專案重依賴於 host 專案,每次存在著程式碼變更,需要重新載入很多內容,讓開發除錯不太友好;
  • 在專案構建的過程中,打包速度受到 plugin 個數的影響,特大型應用甚至需要 50 分鐘執行一次構建,過長的構建耗時嚴重影響了釋出效率;
  • 在部署釋出的過程中,host 專案維護者負責整個 App,每個業務組不能獨立釋出,釋出時間會繫結在一起。當出現 live issue 的情況,開發者需要花費大量的溝通成本,且只能整體回滾。

2.4 第四階段:多 bundle 去中心化架構模式

去中心化 React Native 架構模式與網頁的“微前端”或者客戶端的“微應用”的概念類似,滿足了多業務團隊獨立開發部署,能夠在同一個 App 各模組獨立執行。它涵蓋了開發、構建、釋出、執行等多個方面。該模型解決了上面所說的四個弊端,並針對整個研發體系有了全面的升級,優點有:RN 執行時的互不干擾,開發除錯的高效,構建釋出的獨立性。

下文會重點介紹專案的去中心化 RN 架構和系統設計,以及我們是怎樣做到靈活性和穩定性的平衡的。

3. 去中心的 RN 架構模型

簡單來說,去中心化的 RN 釋出模型涉及到四個部分:獨立的 JS 執行時;獨立的開發流程;獨立的構建流程;獨立的釋出流程。在這四個關鍵環節的幫助下,每個團隊按自己的節奏掌控 RN 的研發流程。

3.1 獨立 JS 執行時

獨立執行時(多 JSContext,執行上下文環境)的出現是去中心化架構的最大特色。獨立執行時是對獨立釋出的完美保證,將 RN 執行程式碼按照 plugin 維度進行隔離,它可以有效避免不同業務之間的變數衝突以及依賴衝突問題,即“plugin A”的釋出絕對不會影響到“plugin B”。

它的設計主要包含以下三點:

  • 提前建立 JSContext 且預載入公共包;
  • 進入 plugin 的頁面,SDK 會檢視對應的 JSContext 是否已被例項化。如果已經被例項化,就直接使用,否則從 JSContext Pool 選取一個獨立的上下文,載入執行業務包,各個 plugin 之間執行時是隔離的;
  • 退出業務頁面時,該 JSContext 不會立即銷燬,而是放入一個快取池,使得重複進入該業務可以獲得極致體驗。

image.png
裝置 JSContext 的容器可以是執行緒或者程式。為了避免它頻繁建立和回收,我們要維護快取池且儘可能地複用現有的 JSContext。

這裡我們採用 Least Frequently Recently Used(簡稱 LFRU)的策略。當剛退出的應用被重新開啟,該 JSContext 會被重新啟用。這樣,我們能夠節省 85% 的首屏渲染時長。快取個數管理是可配置的,業務方可以根據應用的規模作為合理的預估。當該 RN 頁面還在使用中,即使超出預估數,該上下文也不會立即被回收,該設計有效地保證頁面的可用性。

image.png

3.2 開發流程

上文提及 RN 專案的除錯效率問題,它會隨著業務程式碼的體量增多,程式碼除錯效能也會隨之下降。每個開發者的效率問題直接影響到大家的“幸福感”。相比之下,RN 去中心化釋出則是針對開發流程做了特定的優化。

隨著獨立執行時環境的出現,RN 進入除錯的時候,客戶端可以做到只載入一個 plugin 到對應的 JSContext 中,其他 plugin 則採用內建 cache。

這樣做有兩個好處:一是保證了服務啟動範圍的最小化,保證了程式碼熱載入的效率;二是確保開發和構建兩種流程的一致性,這樣會讓一些問題在開發階段提前暴露出來,比如 babel 外掛缺失導致的編譯問題。這樣的“去中心化”的開發流程提高了 RN 除錯效率。

image.png

3.3 構建流程

隨著業務發展,某 App 的 RN plugin 數有 4 個,舊構建流程受到 plugin 個數的影響,集中構建時長超過 20 分鐘。而採用去中心化 RN 架構,構建時長不再隨 plugin 個數增長,只和該 plugin 程式碼量有關,穩定在 5 分鐘左右。

新架構也是同樣基於 host-plugin 模型,獨立倉庫的隔離讓每個團隊有自由的發展空間。考慮到在應用內的基礎 Native 依賴是統一的,host 專案僅用來管理統一的公共依賴。專案需要優先將 common bundle 構建完成,系統會記錄公共包中的依賴資訊。當每個 plugin 專案進行構建的時候,構建工具會剔除掉公共包依賴資訊,並完成業務包的構建。每個業務包的構建產物都是獨立地存放於系統當中。系統具備獨立回滾、獨立釋出、獨立灰度的能力。

這樣的好處在於構建任務的最小粒度化,每個 plugin 的構建不會引起整個專案的重新構建,做到真正意義的“按需打包”。

image.png

3.4 釋出流程

RN 的構建和釋出是兩個獨立的流程。這也意味著 bundle 的構建環節和釋出環節完全解耦,釋出時間點也可以由每個業務團隊釋出負責人靈活安排。每個業務組對自己的程式碼質量負責,靈活地把控自己的發版本節奏,不會影響其他團隊線上業務。釋出流程裡面包含了全量釋出、聯合釋出、灰度釋出、回滾等操作,後續章節會詳細介紹如何保證釋出的穩定性。

4. 系統設計

對於複雜的大型專案來說,簡單的熱更新流程已無法滿足多業務組協同合作,我們需要一個功能完善、效能優越、操作友好的熱更新系統來滿足複雜業務的發展。Code Push Platform 由 Node.js 編寫,搭配系統附屬的命令列工具和客戶端 SDK。
image.png
為了滿足該系統在多業務團隊的運作,整個系統從功能角度可以劃分為三大部分,分別是:

  • 多團隊許可權管控;
  • bundle 生命週期管理;
  • 系統效能提升。

其中,系統效能提升功能又細分為:

  • 增量差分;
  • 多場景入口體積優化;
  • 一站式多環境整合。

4.1 多團隊許可權管控

系統除了記錄每次構建操作,更重要的是工作流程的去中心化,每個 plugin 的許可權是隔離的。每個負責人只能在系統內部操作,plugin 1 的負責人只能觸發相關的構建和釋出,沒法看到 plugin 2 的操作情況。系統通過嚴格的許可權管控來規範所有釋出流程,保證了專案的可控性。

image.png
React Native 去中心化釋出的設計目標是節省不同團隊之間的溝通成本。系統會限制他們的構建和釋出的動作,各自的釋出不會互相干擾。

許可權的管理呈樹狀結構,一個 App 對應著一個專案,專案負責人預設是 App 團隊的專案負責人。建立一個全新的外掛等系統操作需要專案負責人審批。一個 App 包含有多個 plugin,每個 plugin 負責人預設是相應的業務團隊負責人,他有許可權分配發布和構建的許可權。

image.png

4.2 bundle 生命週期管理

4.2.1 客戶端版本控制

RN 有別於網頁應用,它對客戶端有著緊密的依賴關係。在客戶端底層依賴沒有變化的情況下,一般情況下開發者可以通過熱更新進行 RN 程式碼的更新。但是遇到重大的更新,例如 React Native 的版本從 59 升級到 63,不僅僅需要 JavaScript 側改動,客戶端也要升級版本且沒法繼續向下相容。從技術層面看,它是難以避免的。這種客戶端無法向下相容的情況,被稱為“斷層”。

系統會提供客戶端版本控制的能力。當重大變更出現時,App 負責人應該在系統上新建一個“斷層資訊”,版本號的範圍是從最低 App 相容版本到最高 App 版本。在這個區間客戶端才能拉取到該斷層的最新 RN 資源。

如下表所示,大於等於 2.5.0 版本的 App 拉取的是 105 版本 RN 包;在 2.0.0 至 2.5.0 版本拉取到 103 版本 RN 包;在 1.0.0 至 2.0.0 版本拉取到 100 版本 RN 包。
image.png
這種措施能夠有效避免潛在風險。而最新的需求只會在最新斷層上線,舊的斷層只做線上問題修復。畢竟是兩套程式碼,程式碼的維護有成本,隨著使用者更新至最新版本,應當逐漸淘汰掉舊斷層。

4.2.2 灰度和回滾

釋出流程裡面包含了全量釋出、灰度釋出、回滾等操作。對於大型需求,全量上線會帶來潛在風險。一般來說,優先針對部分使用者投放新版本,釋出負責人可以根據指定使用者和特定範圍進行灰度釋出,逐步擴大灰度釋出範圍,直至轉到全量。當發現重大 bug 的時候,釋出者可以採用“零構建”的方式進行“秒級”回滾。

去中心化 RN 架構支援每個 plugin 獨立釋出、獨立灰度、獨立回滾,以最小顆粒度的操作來保證質量規避風險。plugin 維度級別的灰度和回滾能夠為不同的業務團隊帶了靈活性,每個業務團隊可以自行釋出版本,控制灰度節奏,處理線上問題。

4.3 系統效能提升

4.3.1 差分增量

App 頻繁更新 RN 資源包會造成對使用者流量的消耗,最有效的方式是利用增量更新來節省流量。RN 資源包涵蓋了編譯後的 JavaScript 產物、圖片、翻譯檔案等靜態資源。它們的前後版本差異即是該版本變更的程式碼或者其他資原始檔。為了讓差分粒度深入到資源包內部,系統專門提供獨立的“差分服務”,採用二進位制差分的方式對構建產物進行差分。

RN 資源包的 diff(差分)操作在服務端完成 ,patch(整合)操作在 App 端完成。在去中心化 RN 架構中,每個 plugin 的差分都是獨立的。plugin 的釋出會自動觸發差分的執行,系統會以 plugin 為維度拉取最近五個版本,Diff Server 則會依次將它們和當前版本進行差分計算。如果計算成功,會將差分結果上傳到 CDN 並反饋給系統,否則繼續重試。整個差分操作是一個非同步的過程,即使出現“差分服務”下線等極端情況,系統會自動降級為全量包,保證系統的可用性。

image.png

4.3.2 多場景入口體積優化

由於 React Native 的構建官方依賴於 metro.js,而它並沒有具備無用程式碼剔除(tree-shaking)的能力。隨著業務程式碼的膨脹,包體積的優化是一個很重要的問題。

例如,ShopeePay 為公司多款核心 App 提供支付業務。ShopeePay plugin 在不同地區、不同 App 之間存在一些頁面級別差異。同一個倉庫含了所有程式碼和資源,但是構建指令碼會將它們都會打包成為一個產物。很明顯,這導致 ShopeePay 的釋出產物包含大量冗餘資源,並非最優,浪費下載流量,同時也影響程式碼的執行效率。

我們採用自研的多場景外掛(babel-plugin-scene),該外掛通過注入的環境變數設定一個場景值,babel 可以根據場景值的差異化載入不同的檔案,並且以預設檔案作為降級兜底。不同場景對應不同的入口檔案,利用這種形式可以有效控制包體積。

image.png

4.3.3 一站式多環境融合

一個正常的研發流程是從 test 環境,到 uat 環境,再到 live環境。Code Push Platform 對接了 App 的 test/uat/live環境,所以 RN 開發者只需要在該系統就可以進行“一站式”的操作,方可滿足一個需求的整個研發週期。

不同環境的包資源流轉,是多環境融合的一大亮點。如果某 RN bundle 在 uat 環境構建,它也不需要重新構建,將 bundle 無縫轉換到 線上 環境進行釋出。它帶來的優勢在於“零構建時長”以及資源包的穩定性,因為 bundle 沒有重新進行構建,所以它的內容已經在 uat 得到了充分的驗證,釋出風險更小。

image.png

5. 舊業務的遷移方案

如何遷移現有業務的 App 是一個非常嚴肅的問題,特別是歷史背景較重的業務,它們可能存在“邏輯耦合”或者“元件耦合”的場景。與此同時,很多相關業務都在需求迭代當中,系統的遷移是不能阻礙需求迭代,所以舊業務“漸進式遷移”方案是非常必要的。

5.1 邏輯耦合

如果兩個以上 plugin 存在邏輯依賴關係,使用者必須同時載入到最新的 plugin。考慮到熱更新失敗的可能性,邏輯耦合就是多個 plugin 隱藏著一種約束關係。例如,訂單業務和購買業務存在一定的邏輯耦合關係,釋出負責人針對流量極大的超級 App,不可能逐個釋出 plugin。在極端的狀態下,使用者可能會先載入到 plugin A,新版本的 plugin A 和舊版本的 plugin B 是不相容的,這樣會帶來嚴重後果。遇到這種情況,有兩種解決方案:

  • 方案一:plugin 間邏輯解耦,保證每個 plugin 的獨立性。
  • 方案二:系統提供了聯合釋出,在 Native 側保證多個 plugin 能夠同時載入到最新。

方案一是最理想化的狀態,但是在業務場景細分的情況下,專案結構很難做到絕對獨立。

針對老業務可以考慮方案二,系統提供了 module 的概念,一個 module 對應著兩個以上的 plugin。它們存在著一個繫結的關係。在同一個下載任務裡面,客戶端 SDK 以“事務”形式,保證多個 plugin 能夠同時下載完成並投入使用。聯合釋出這個能力在系統層面,有效規避這種錯誤的可能性。

image.png

5.2 元件耦合

如果說聯合釋出是針對在 plugin 維度的“邏輯耦合”相容方案,“元件耦合”則是更細粒度的元件級別的耦合關係。也就是說,一個頁面中存在多個元件來自不同的團隊,例如商品詳情頁等頁面有評價功能元件。這種“一個頁面存在著 JSContext 相互巢狀”的情景存在於電商業務當中。

針對這種“元件耦合”情況,有兩種解決方案:

  • 方案一:巢狀元件抽離成為一個獨立倉庫,供第三方 plugin 使用。
  • 方案二:使用“同屏渲染”的能力實現“多 Context 巢狀”。

方案一是最理想的解決方案。但是考慮到遷移成本,我們也提供了方案二(一種“同屏渲染”巢狀元件)來支援這種場景,它類似一種 Native 元件。在多個 JSContext 的情況下,通過 plugin 名和頁面名將所需要的內容巢狀到另一個頁面當中。

如下圖所示,plugin A 會巢狀 plugin B 的內容,A 和 B 也可以實現在同一個螢幕進行渲染。從 Web 的方向理解,這種情況有點像 “iframe” 的場景,支援多個頁面的巢狀。它非常易於 RN 開發者的理解,客戶端 SDK 能夠動態載入目標 bundle 並將它渲染在合適的位置。

image.png

5.3 漸進式遷移

對於現有的 App,因為業務沒法暫停迭代,我們難以一次性完成整體遷移。因此,我們提供了“漸進式遷移”方案。考慮到歷史背景,該方案不會一次性把所有 plugin 都遷移,而是逐步拆分,再遷移接入到新發布系統。

遷移的步驟如下圖所示:

  • 優先將獨立的業務遷移到 Code Push Platform,它們享用一個獨立的 JSContext;
  • 所有“待拆分程式碼”共用一個獨立的JSContext;
  • 將“待拆分程式碼”繼續拆分成幾個獨立 plugin,獨立使用 JSContext,其他內容則保持步驟二的狀態。

隨著版本迭代,重複第二和第三步驟,直至歷史業務全部拆分完畢。這樣我們可以達到一個最優的目標,即是真正意義的“獨立構建”和“獨立釋出”。

image.png

6. 總結

該系統的目標在於滿足所有 App 的多團隊研發協作效率問題,去中心化 RN 釋出模型考慮到“獨立執行時”、“獨立開發”、“獨立構建”、“獨立釋出”四大方面,保障了每個 plugin 執行的獨立性。最終目標在於支撐 Shopee 的多個 RN 團隊在不同 App 平臺根據自己節奏自由釋出且高效運作。

系統設計涉及到“多團隊許可權管控”、“客戶端版本控制”、“灰度和回滾”、“增量差分”、“多入口包體積優化”、 “一站式多環境融合”,加速了整個研發流程,真正做到了“靈活性”和“穩定性”的兼得。

相關文章