React Native新架構剖析

xiangzhihong發表於2021-12-26

目前 React Native 新架構所依賴的 React 18 已經發了 beta 版,React Native 新架構面向生態庫和核心開發者的文件也正式釋出,React Native 團隊成員 Kevin Gozali 也在最近一次訪談中談到新架構離正式發版還差最後一步延遲初始化,而最後一步工作大約會在 2022 年上半年完成。種種跡象表明,React Native 新架構真的要來了。

前面,RN官方宣佈:Hermes將成為React Native預設的JS引擎。在文章中,我們簡單的介紹了即將釋出的新渲染器 Fabric,那麼我們重點來認識下這個新的渲染器 Fabric 。

一、Fabric

1.1 基本概念

Fabric 是 React Native 新架構的渲染系統,是從老架構的渲染系統演變而來的。核心原理是在 C++ 層統一更多的渲染邏輯,提升與宿主平臺(host platforms)互操作性,即能夠在 UI 執行緒上同步呼叫JavaScript程式碼,渲染效率得到明顯的提高。Fabric研發始於 2018 年,Facebook 內部的很多React Native 應用使用的就是新的渲染器Fabric。

在簡介新渲染器(new renderer)之前,我們先介紹幾個單詞和概念:

  • 宿主平臺(Host platform):React Native 嵌入的平臺,比如 Android、iOS、Windows、macOS。
  • Fabric 渲染器(Fabric Renderer):React Native 執行的 React 框架程式碼,和 React 在 Web 中執行程式碼是同一份。但是,React Native 渲染的是通用平臺檢視(宿主檢視)而不是 DOM 節點(可以認為 DOM 是 Web 的宿主檢視)。

在更換了底層的渲染流程之後,Fabric 渲染器使得渲染宿主檢視變得可行。Fabric 讓 React 與各個平臺直接通訊並管理其宿主檢視例項。Fabric 渲染器存在於 JavaScript 中,並且它呼叫的是由 C++ 程式碼暴露的介面。

1.2 新渲染器的初衷

開發新的渲染架構的初衷是為了更好的提升使用者體驗,而這種新體驗是在老架構上是不可能實現的。主要體現為:

  • 提升宿主檢視(host views)和 React 檢視(React views)的互操作性,渲染器必須有能力同步地測量和渲染 React 介面。在老架構中,React Native 佈局是非同步的,這導致在宿主檢視中渲染巢狀的 React Native 檢視,會有佈局“抖動”的問題。
  • 藉助多優先順序和同步事件的能力,渲染器可以提高使用者互動的優先順序,來確保他們的操作得到及時的處理。
  • React Suspense 的整合,允許開發者在 React 中更合理的組織請求資料程式碼。
  • 允許開發者在 React Native 使用 React Concurrent 中斷渲染功能。
  • 更容易的實現 React Native 的服務端渲染。

除此之外,新的Fabric渲染器在程式碼質量、效能、可擴充套件性方面也是有了質的飛昇。

  • 型別安全:程式碼生成工具(code generation)確保了 JS 和宿主平臺兩方面的型別安全。程式碼生成工具使用 JavaScript 元件宣告作為唯一事實源,生成 C++ 結構體來持有 props 屬性。不會因為 JavaScript 和宿主元件 props 屬性不匹配而出現構建錯誤。
  • 共享 C++ core:渲染器是用 C++ 實現的,其核心 core 在平臺之間是共享的。這增加了一致性並且使得新的平臺能夠更容易採用 React Native。(譯註:例如 VR 新平臺)
  • 更好的宿主平臺互操作性:當宿主元件整合到 React Native 時,同步和執行緒安全的佈局計算提升了使用者體驗(譯註:沒有非同步的【抖動】)。
  • 效能提升:新的渲染系統的實現是跨平臺的,每個平臺都從那些原本只在某個特定平臺的實現的效能優化中得到了更好的使用者體驗。比如拍平檢視層級,原本只是 Android 上的效能優化方案,現在 Android 和 iOS 都直接有了。
  • 一致性:新的渲染系統的實現是跨平臺的,不同平臺之間更容易保持一致。
  • 更快的啟動速度:預設情況下,宿主元件的初始化是懶執行的。
  • JS 和宿主平臺之間的資料序列化更少:React 使用序列化 JSON 在 JavaScript 和宿主平臺之間傳遞資料。新的渲染器用 JSI(JavaScript Interface)直接獲取 JavaScript 資料。

二、渲染流程

2.1 渲染流程

React Native 渲染器通過一系列加工處理,將 React 程式碼渲染到宿主平臺。這一系列加工處理就是渲染流水線(pipeline),它的作用是初始化渲染和 UI 狀態更新。接下來,我們重點介紹一下React Native 渲染流水線,及其在各種場景中的不同之處。

渲染流水線可大致分為三個階段:

  • 渲染:在 JavaScript 中,React 執行那些產品邏輯程式碼建立 React 元素樹(React Element Trees)。然後在 C++ 中,用 React 元素樹建立 React 影子樹(React Shadow Tree)。
  • 提交:在 React 影子樹完全建立後,渲染器會觸發一次提交。這會將 React 元素樹和新建立的 React 影子樹的提升為“下一棵要掛載的樹”。這個過程中也包括了佈局資訊計算。
  • 掛載:React 影子樹有了佈局計算結果後,它會被轉化為一個宿主檢視樹(Host View Tree)。

這裡有幾個名詞需要解釋下:

React元素樹

React 元素樹是通過 JavaScript 中的 React 建立的,該樹由一系類 React 元素組成。一個 React 元素就是一個普通的 JavaScript 物件,它描述了需要在螢幕中展示的內容。一個元素包括屬性 props、樣式 styles、子元素 children。React 元素分為兩類:React 複合元件例項(React Composite Components)和 React 宿主元件(React Host Components)例項,並且它只存在於 JavaScript 中。

React 影子樹

React 影子樹是通過 Fabric 渲染器建立的,樹由一系列 React 影子節點組成。一個 React 影子節點是一個物件,代表一個已經掛載的 React 宿主元件,其包含的屬性 props 來自 JavaScript。它也包括佈局資訊,比如座標系 x、y,寬高 width、height。在新渲染器 Fabric 中,React 影子節點物件只存在於 C++ 中。而在老架構中,它存在於手機執行時的堆疊中,比如 Android 的 JVM。

宿主檢視樹

宿主檢視樹就是一系列的宿主檢視,宿主平臺有 Android 平臺、iOS 平臺等等。在 Android 上,宿主檢視就是 android.view.ViewGroup例項、 android.widget.TextView例項等等。宿主檢視就像積木一樣地構成了宿主檢視樹。每個宿主檢視的大小和座標位置基於的是 LayoutMetrics,而 LayoutMetrics是通過React Native得佈局引擎 Yoga 計算出來的。宿主檢視的樣式和內容資訊,是從 React 影子樹中得到的。

React Native渲染流水線的各個階段可能發生在不同的執行緒中,參考執行緒模型部分。
在這裡插入圖片描述
在React Native中,涉及渲染的操作通常有三種:

  • 初始化渲染
  • React 狀態更新
  • React Native 渲染器的狀態更新

2.2 初始化渲染

2.2.1 渲染階段

假如,有下面一個元件需要執行渲染:

function MyComponent() {
  return (
    <View>
      <Text>Hello, World</Text>
    </View>
  );
}

在上面的例子中,<MyComponent />最終會被React 簡化為最基礎的React 宿主元素。每一次遞迴地呼叫函式元件 MyComponet ,或類元件的 render 方法,直至所有的元件都被呼叫過。最終,得到一棵 React 宿主元件的 React 元素樹。
在這裡插入圖片描述
在這裡,有幾個重要的名詞需要解釋下“

  • React 元件:React 元件就是 JavaScript 函式或者類,描述如何建立 React 元素。
  • React 複合元件:React 元件的 render 方法中,包括其他 React 複合元件和 React 宿主元件。(譯註:複合元件就是開發者宣告的元件)
  • React 宿主元件:React 元件的檢視是通過宿主檢視,比如 <View><Text>實現的。在 Web 中,ReactDOM 的宿主元件就是 <p>標籤、<div>標籤代表的元件。

在元素簡化的過程中,每呼叫一個 React 元素,渲染器同時會同步地建立 React 影子節點。這個過程只發生在 React 宿主元件上,不會發生在 React 複合元件上。比如,一個 <View>會建立一個 ViewShadowNode 物件,一個<Text>會建立一個TextShadowNode物件。而我們開發的元件,由於不是基礎元件,因此沒有直接的React 影子節點與之對應,所以<MyComponent>並沒有直接對應的 React 影子節點。

在 React 為兩個 React 元素節點建立一對父子關係的同時,渲染器也會為對應的 React 影子節點建立一樣的父子關係。上面程式碼,各個渲染階段的產物如下圖所示。

在這裡插入圖片描述

2.2.2 提交階段

在 React 影子樹建立完成後,渲染器觸發了一次 React 元素樹的提交。
在這裡插入圖片描述
提交階段(Commit Phase)由兩個操作組成:佈局計算和樹提升。

佈局計算

這一步會計算每個 React 影子節點的位置和大小。在 React Native 中,每一個 React 影子節點的佈局都是通過 Yoga 佈局引擎來計算的。實際的計算需要考慮每一個 React 影子節點的樣式,該樣式來自於 JavaScript 中的 React 元素。計算還需要考慮 React 影子樹的根節點的佈局約束,這決定了最終節點能夠擁有多少可用空間。
在這裡插入圖片描述

樹提升

從新樹到下一棵樹(Tree Promotion,New Tree → Next Tree),這一步會將新的 React 影子樹提升為要掛載的下一棵樹。這次提升代表著新樹擁有了所有要掛載的資訊,並且能夠代表 React 元素樹的最新狀態,下一棵樹會在 UI 執行緒下一個“tick”進行掛載(譯註:tick 是 CUP 的最小時間單元)。

並且,絕大多數佈局計算都是 C++ 中執行,只有某些元件,比如 Text、TextInput 元件等的佈局計算是在宿主平臺執行的。文字的大小和位置在每個宿主平臺都是特別的,需要在宿主平臺層進行計算。為此,Yoga 佈局引擎呼叫了宿主平臺的函式來計算這些元件的佈局。

2.2.3 掛載階段

在這裡插入圖片描述
掛載階段(Mount Phase)會將已經包含佈局計算資料的 React 影子樹,轉換為以畫素形式渲染在螢幕中的宿主檢視樹。

站在更高的抽象層次上,React Native 渲染器為每個 React 影子節點建立了對應的宿主檢視,並且將它們掛載在螢幕上。在上面的例子中,渲染器為<View> 建立了android.view.ViewGroup 例項,為 <Text> 建立了文字內容為“Hello World”的 android.widget.TextView例項 。iOS 也是類似的,建立了一個 UIView 並呼叫 NSLayoutManager 建立文字。然後會為宿主檢視配置來自 React 影子節點上的屬性,這些宿主檢視的大小位置都是通過計算好的佈局資訊配置的。

在這裡插入圖片描述
掛載階段又細分為三個步驟:

  • 樹對比: 這個步驟完全用的是 C++ 計算的,會對比“已經渲染的樹”和”下一棵樹”之間的元素差異。計算的結果是一系列宿主平臺上的原子變更操作,比如 createView, updateView, removeView, deleteView 等等。在這個步驟中,還會將 React 影子樹重構,來避免不必要的宿主檢視建立。
  • 樹提升,從下一棵樹到已渲染樹: 在這個步驟中,會自動將“下一棵樹”提升為“先前渲染的樹”,因此在下一個掛載階段,樹的對比計算用的是正確的樹。
  • 檢視掛載: 這個步驟會在對應的原生檢視上執行原子變更操作,該步驟是發生在原生平臺的 UI 執行緒的。

同時,掛載階段的所有操作都是在 UI 執行緒同步執行的。如果提交階段是在後臺執行緒執行,那麼在掛載階段會在 UI 執行緒的下一個“tick”執行。另外,如果提交階段是在 UI 執行緒執行的,那麼掛載階段也是在 UI 執行緒執行。掛載階段的排程和執行很大程度取決於宿主平臺。例如,當前 Android 和 iOS 掛載層的渲染架構是不一樣的。

2.3 React 狀態更新

接下來,我們繼續看 React 狀態更新時,渲染流水線的各個階段的情況。假設,在初始化渲染時渲染的是如下元件。

function MyComponent() {
  return (
    <View>
      <View
        style={{ backgroundColor: 'red', height: 20, width: 20 }}
      />
      <View
        style={{ backgroundColor: 'blue', height: 20, width: 20 }}
      />
    </View>
  );
}

通過初始化渲染部分學的知識,我們可以得到如下的三棵樹:
在這裡插入圖片描述
可以看到,節點 3 對應的宿主檢視背景是 紅的,而 節點 4 對應的宿主檢視背景是 藍的。假設 JavaScript 的產品邏輯是,將第一個內嵌的<View>的背景顏色由紅色改為黃色。新的 React 元素樹看起來大概是這樣的。

<View>
  <View
    style={{ backgroundColor: 'yellow', height: 20, width: 20 }}
  />
  <View
    style={{ backgroundColor: 'blue', height: 20, width: 20 }}
  />
</View>

此時,我們或許會有一個疑問:React Native 是如何處理這個更新的呢?

從概念上講,當發生狀態更新時,為了更新已經掛載的宿主檢視,渲染器需要直接更新 React 元素樹。但是為了執行緒的安全,React 元素樹和 React 影子樹都必須是不可變的(immutable)。這意味著 React 並不能直接改變當前的 React 元素樹和 React 影子樹,而是必須為每棵樹建立一個包含新屬性、新樣式和新子節點的新副本。

2.3.1 渲染階段

在這裡插入圖片描述
React 要建立了一個包含新狀態的新的 React 元素樹,它就要複製所有變更的 React 元素和 React 影子節點。複製後,再提交新的 React 元素樹。

React Native 渲染器利用結構共享的方式,將不可變特性的開銷變得最小。為了更新 React 元素的新狀態,從該元素到根元素路徑上的所有元素都需要複製。但 React 只會複製有新屬性、新樣式或新子元素的 React 元素,任何沒有因狀態更新發生變動的 React 元素都不會複製,而是由新樹和舊樹共享。

在上面的例子中,React 建立新樹使用了下面這些操作:

  1. CloneNode(Node 3, {backgroundColor: 'yellow'}) → Node 3'
  2. CloneNode(Node 2) → Node 2'
  3. AppendChild(Node 2', Node 3')
  4. AppendChild(Node 2', Node 4)
  5. CloneNode(Node 1) → Node 1'
  6. AppendChild(Node 1', Node 2')

操作完成後,節點 1'(Node 1') 就是新的 React 元素樹的根節點,我們用 T 代表“先前渲染的樹”,用 T' 代表“新樹”。
在這裡插入圖片描述
注意,節點 4 在 T and T' 之間是共享的。結構共享提升了效能並減少了記憶體的使用。

2.3.2 提交階段

在這裡插入圖片描述
在 React 建立完新的 React 元素樹和 React 影子樹後,需要提交它們,也會涉及以下幾個步驟:

  • 佈局計算: 狀態更新時的佈局計算,和初始化渲染的佈局計算類似。一個重要的不同之處是佈局計算可能會導致共享的 React 影子節點被複制。這是因為,如果共享的 React 影子節點的父節點引起了佈局改變,共享的 React 影子節點的佈局也可能發生改變。
  • 樹提升: 和初始化渲染的樹提升類似。
  • 樹對比: 這個步驟會計算“先前渲染的樹”(T)和“下一棵樹”(T')的區別。計算的結果是原生檢視的變更操作。

在上面的例子中,這些操作包括:UpdateView('Node 3', {backgroundColor: 'yellow'})

2.3.3 掛載階段

在這裡插入圖片描述

  • 樹提升:在這個步驟中,會自動將“下一棵樹”提升為“先前渲染的樹”,因此在下一個掛載階段,樹的對比計算用的是正確的樹。
  • 檢視掛載:這個步驟會在對應的原生檢視上執行原子變更操作。在上面的例子中,只有 檢視 3(View 3) 的背景顏色會更新,變為黃色。

在這裡插入圖片描述

2.4 渲染器狀態更新

對於影子樹中的大多數資訊而言,React 是唯一所有方也是唯一事實源。並且所有來源於 React 的資料都是單向流動的。

但有一個例外。這個例外是一種非常重要的機制:C++ 元件可以擁有狀態,且該狀態可以不直接暴露給 JavaScript,這時候 JavaScript (或 React)就不是唯一事實源了。通常,只有複雜的宿主元件才會用到 C++ 狀態,絕大多數宿主元件都不需要此功能。

例如,ScrollView 使用這種機制讓渲染器知道當前的偏移量是多少。偏移量的更新是宿主平臺的觸發,具體地說是 ScrollView 元件。這些偏移量資訊在 React Native 的 measure 等 API 中有用到。因為偏移量資料是由 C++ 狀態持有的,所以源於宿主平臺更新,不影響 React 元素樹。

從概念上講,C++ 狀態更新類似於我們前面提到的 React 狀態更新,但有兩點不同:

  • 因為不涉及 React,所以跳過了“渲染階段”(Render phase)。
  • 更新可以源自和發生在任何執行緒,包括主執行緒。

在這裡插入圖片描述
提交階段(Commit Phase):在執行 C++ 狀態更新時,會有一段程式碼把影子節點 (N) 的 C++ 狀態設定為值 S。React Native 渲染器會反覆嘗試獲取 N 的最新提交版本,並使用新狀態 S 複製它 ,並將新的影子節點 N' 提交給影子樹。如果 React 在此期間執行了另一次提交,或者其他 C++ 狀態有了更新,本次 C++ 狀態提交失敗。這時渲染器將多次重試 C++ 狀態更新,直到提交成功,這可以防止真實源的衝突和競爭。

在這裡插入圖片描述
掛載階段(Mount Phase)實際上與 React 狀態更新的掛載階段相同。渲染器仍然需要重新計算佈局、執行樹對比等操作。

三、跨平臺實現

在上一代 React Native 渲染器中,React 影子樹、佈局邏輯、檢視拍平演算法是在各個平臺單獨實現的。當前的渲染器的設計上採用的是跨平臺的解決方案,共享了核心的 C++ 實現。而Fabric渲染器直接使用 C++ core 渲染實現了跨平臺共享。

使用 C++ 作為核心渲染系統有以下幾個優點。

  • 單一實現降低了開發和維護成本。
  • 提升了建立 React 影子樹的效能,同時在 Android 上,也因為不再使用 JNI for Yoga,降低了 Yoga 渲染引擎的開銷,佈局計算的效能也有所提升。
  • 每個 React 影子節點在 C++ 中佔用的記憶體,比在 Kotlin 或 Swift 中佔用的要小。

同時,React Native 團隊還使用了強制不可變的 C++ 特性,來確保併發訪問時共享資源即便不加鎖保護,也不會有問題。但在 Android 端還有兩種例外,渲染器依然會有 JNI 的開銷:

  • 複雜檢視,比如 Text、TextInput 等,依然會使用 JNI 來傳輸屬性 props。
  • 在掛載階段依然會使用 JNI 來傳送變更操作。

React Native 團隊在探索使用 ByteBuffer 序列化資料這種新的機制,來替換 ReadableMap,減少 JNI 的開銷,目標是將 JNI 的開銷減少 35~50%。

渲染器提供了 C++ 與兩邊通訊的 API:

  • 與 React 通訊
  • 與宿主平臺通訊

關於 React 與渲染器的通訊,包括 渲染(render) React 樹和監聽 事件(event),比如 onLayout、onKeyPress、touch 等。而React Native 渲染器與宿主平臺的通訊,包括在螢幕上 掛載(mount) 宿主檢視,包括 create、insert、update、delete 宿主檢視,和監聽使用者在宿主平臺產生的 事件(event)。

在這裡插入圖片描述

四、檢視拍平

檢視拍平(View Flattening)是 React Native 渲染器避免佈局巢狀太深的優化手段。React API 在設計上希望通過組合的方式,實現元件宣告和重用,這為更簡單的開發提供了一個很好的模型。但是在實現中,API 的這些特性會導致一些 React 元素會巢狀地很深,而其中大部分 React 元素節點只會影響檢視佈局,並不會在螢幕中渲染任何內容。這就是所謂的 “只參與佈局” 型別節點。

從概念上講,React 元素樹的節點數量和螢幕上的檢視數量應該是 1:1 的關係。但是,渲染一個很深的“只參與佈局”的 React 元素會導致效能變慢。假如,有一個應用,應用中擁有外邊距 ContainerComponent的容器元件,容器元件的子元件是 TitleComponent 標題元件,標題元件包括一個圖片和一行文字。React 程式碼示例如下:

function MyComponent() {
  return (
    <View>                          // ReactAppComponent
      <View style={{margin: 10}} /> // ContainerComponent
        <View style={{margin: 10}}> // TitleComponent
          <Image {...} />
          <Text {...}>This is a title</Text>
        </View>
      </View>
    </View>
  );
}

React Native 在渲染時,會生成以下三棵樹:
在這裡插入圖片描述
在檢視 2 和檢視 3 是“只參與佈局”的檢視,因為它們在螢幕上渲染只是為了提供 10 畫素的外邊距。

為了提升 React 元素樹中“只參與佈局”型別的效能,渲染器實現了一種檢視拍平的機制來合併或拍平這類節點,減少螢幕中宿主檢視的層級深度。該演算法考慮到了如下屬性,比如 margin、padding、backgroundColor和opacity等等。

檢視拍平演算法是渲染器的對比(diffing)階段的一部分,這樣設計的好處是我們不需要額外的 CUP 耗時,來拍平 React 元素樹中“只參與佈局”的檢視。此外,作為 C++ 核心的一部分,檢視拍平演算法預設是全平臺共用的。

在前面的例子中,檢視 2 和檢視 3 會作為“對比演算法”(diffing algorithm)的一部分被拍平,而它們的樣式結果會被合併到檢視 1 中。

在這裡插入圖片描述
不過,雖然這種優化讓渲染器少建立和渲染兩個宿主檢視,但從使用者的角度看螢幕內容沒有任何區別。

五、執行緒模型

React Native 渲染器是執行緒安全的。從更高的視角看,在框架內部執行緒安全是通過不可變的資料結果保障的,其使用的是 C++ 的 const correctness 特性。這意味著,在渲染器中 React 的每次更新都會重新建立或複製新物件,而不是更新原有的資料結構。這是框架把執行緒安全和同步 API 暴露給 React 的前提。

在React Native中,渲染器使用三個不同的執行緒:

  • UI 執行緒:唯一可以操作宿主檢視的執行緒。
  • JavaScript 執行緒:這是執行 React 渲染階段的地方。
  • 後臺執行緒:專門用於佈局的執行緒。

下圖描述了React Native渲染的完整流程:
在這裡插入圖片描述

5.1 渲染場景

在後臺執行緒中渲染

這是最常見的場景,大多數的渲染流水線發生在 JavaScript 執行緒和後臺執行緒。
在這裡插入圖片描述

在主執行緒中渲染

當 UI 執行緒上有高優先順序事件時,渲染器能夠在 UI 執行緒上同步執行所有渲染流水線。

在這裡插入圖片描述

預設或連續事件中斷

在這個場景中,UI 執行緒的低優先順序事件中斷了渲染步驟。React 和 React Native 渲染器能夠中斷渲染步驟,並把它的狀態和一個在 UI 執行緒執行的低優先順序事件合併。在這個例子中渲染過程會繼續在後臺執行緒中執行。
在這裡插入圖片描述

不相干的事件中斷

渲染步驟是可中斷的。在這個場景中, UI 執行緒的高優先順序事件中斷了渲染步驟。React 和渲染器是能夠打斷渲染步驟的,並把它的狀態和 UI 執行緒執行的高優先順序事件合併。在 UI 執行緒渲染步驟是同步執行的。
在這裡插入圖片描述

來自 JavaScript 執行緒的後臺執行緒批量更新

在後臺執行緒將更新分派給 UI 執行緒之前,它會檢查是否有新的更新來自 JavaScript。這樣,當渲染器知道新的狀態要到來時,它就不會直接渲染舊的狀態。
在這裡插入圖片描述

C++ 狀態更新

更新來自 UI 執行緒,並會跳過渲染步驟。
在這裡插入圖片描述

相關文章