基於 React Native 的動態列表方案探索

語言: CN / TW / HK
題圖

圖片來自:http://unsplash.com

背景

時至2022,精細化運營已經成為了各大App廠商的強需求,阿里的 DinamicX、Tangram 大家應該都很熟悉了,很多App廠商也自研了一些類似框架,基於DSL的動態化方案雖然有效能上的一些優勢,但是畢竟不是圖靈完備,一些需要邏輯動態下發的需求實現成本偏高,或由於DSL本身限制無法實現,針對這個問題我們使用RN進行了一下探索嘗試, 利用我們已經相對完善的RN基建,結合客戶端列表能力低成本的實現了一套的動態化能力,同時兼顧一定的效能體驗。

基於 ReactNative 的動態列表方案簡單來說就是將 ReactNative 容器內嵌在 RecyclerView 的 ViewHolder 中,由於頁面主體框架還是由 Native 開發和渲染,所以首屏載入速度得到了保證,區域性的RN實現也使頁面獲得動態化的能力,從而在效能、”完備邏輯執行“的動態化能力之間取得了一個平衡點,根據我們使用經驗對幾種動態化方案排序如下:

  • 整體效能體驗排序:純 Native > 基於DSL動態化方案 >= ReactNative 動態列表方案 > 純 ReactNative 頁面 > H5

  • 動態能力排序:H5 = 純 ReactNative 頁面 > ReactNative 動態列表方案 > 基於DSL動態化方案 > 純 Native

  • 實現能力排序:純 Native >= RN 動態列表方案 = 純 ReactNative 頁面 > H5 > 基於DSL的動態化方案

從以上排序中可以看出 ReactNative 動態列表方案整體處於中等或中等偏上的一個位置,在實現能力上遠勝餘基於 DSL 動態方案,和 Native 能力基本對等,可以實現一些複雜的UI互動效果,並且相比於純 RN 實現的頁面首屏速度會有非常大的優勢,另外不需要對頁面整體框架進行更改就能比較方便的嵌入,在開發維護成本上 RN 動態列表方案相比各種基於DSL的動態化方案會有比較明顯的優勢,不需要額外的開發元件管理平臺,排查問題時也不用去讀難懂的 dsl,最重要的是 RN 具有圖靈完備的能力,所以綜合來看使用 RN 內嵌到 Native RecyclerView 來實現 Native 頁面部分動態化的方式算是一種價效比相對較高的方式了,值得一試。

技術方案介紹

這裡從 Android 視角分享下我們這套方案實現的一些技術細節、原理以及遇到的問題。首先我們常用的一些術語:

  1. moduleName 是 RN 離線包的唯一 key,相當於離線包的名字;
  2. componentName 是 RN 中 registerComponent 的 component,對應一個 RN 實現的業務的執行入口;
  3. 卡片指雲音樂首頁中每個 viewholder 內部的展示內容,展示的 UI 樣式是卡片樣式;

  4. RN 引擎指以 RN Bridge 為主的整個 JS 離線包執行時環境。

整體方案架構如下:

整體方案設計

從圖中可以看出整體方案採用資料驅動的方式,服務端通過資料中攜帶的型別、component、moduleName等欄位來唯一指定是否是使用 RN 來渲染,執行 RN 離線包中的哪個 component 邏輯

整體方案上有幾個細節點:

  1. 採用資料驅動的方式,接入頁面無須關注具體展示資料,只需要將資料透傳到 RN 的 JS 側即可

  2. 由於 RN 需要將離線包載入後才能執行 JS 生成客戶端檢視,在 RecyclerView 繫結資料時才開始載入 RN 的離線包勢必會拖慢整個模組的展示,所以這裡我們做了整個離線包的預載入

  3. 首頁列表中每個 ViewHolder 的展示元素我們叫做一個卡片,目前採取的策略是多個卡片放在一個 RN 的離線包中,通過同一個 RN 容器來分別展示,避免多個容器消耗過多的資源。

下面從資料流角度拆解整個方案,整體方案可以分為服務端資料定義和下發,容器資料透傳,JS側資料解析三個主要步驟:

  1. 服務端資料定義和下發

由於是服務端介面驅動 RecyclerView 中內容展示,介面下發資料中需要有type欄位標識使用RN還是Native展示,可以服用Native展示樣式標記欄位,由於RN中具體展示的樣式和執行哪些  JS 程式碼直接相關,所以服務端下發的資料中需要帶上對應的 moduleName 和 componentName,整體資料結構定義如下:

[
{
"type":"rn",
"rnInfo":{
"moduleName":"bizDiscovery",
"component":"hotSong",
"otherInfo":{

}
},
"data":{
"songInfo":{

}
}
},
{
"type":"dragonball",
"data":{
"showInfo":{

}
}
}
]

獲取到資料之後只需要按照 RecyclerView 正常的使用方法將資料和不同的 ViewHolder 繫結即可

  1. 容器資料透傳

RN 容器直接直接內嵌在 ViewHolder 中,在 viewHolder 中只需要定義承載 RN JS 渲染檢視的 ViewGroup container,RN Bridge 建立好 ReactRootView 後將建立好的 ReactRootView 呼叫 add 方法新增到 container 中即可,資料傳遞是透傳的方式通過 RN 的 initialProperty 傳入到 JS 側,在  JS 側解析和使用,資料傳遞程式碼如下:

mReactRootView?.startReactApplication(reactInstanceManager, componentName, initialProperties)

這裡面需要注意的點是,由於所有使用RN展示的卡片都是對應的相同的 RecyclerView type 即相同的 ViewHolder,所以在 RecyclerView 複用時可能會出現兩種情況:1. 只有一個 RN 卡片,上下滑動 RecyclerView 時發生複用,這時基本不用處理,2. 存在兩種不同型別的 RN 卡片,複用時會執行完全不同的離線包程式碼,這種情況會導致 JS 側重新執行渲染邏輯生成全新的檢視,上下滾動時如果每次都出現 JS 側重新渲染,會極大的影響滑動時效能,造成滑動卡頓掉幀,針對這種問題我們對 RN 的 ReactRootView 也做了快取,整體架構如下:

複用設計

從圖中可以看到 ViewHolder 中的 container 和 RN 的 ReactRootView 是一對多的關係,RN 的 ReactRootView 在第一次初始化完成後還是掛在 RN 管理的虛擬檢視樹中,在 RecyclerView 滑動切換不同的展示型別時只需要從 ViewHolder 的 container 中移除不展示的ReactRootView 再重新 add 需要展示的 ReactRootView,不需要 JS 側重新執行,重新 add ReactRootView 之後還需要將當前的資料再傳入 JS 側以適配相同樣式的卡片展示不同資料的需求。這裡面的原理是一般情況下我們一個 RN Bridge 只會建立一個 ReactRootView,但是檢視 RN 原始碼,RN 其實支援一個 RN Bridge 繫結多個 RootView 的能力,程式碼如下:

  public void addRootNode(ReactShadowNode node) {
mThreadAsserter.assertNow();
int tag = node.getReactTag();
mTagsToCSSNodes.put(tag, node);
mRootTags.put(tag, true);
}

一個 ReactRootView 即一棵檢視樹,RN在更新客戶端檢視時都會遍歷所有的 ReactRootView,程式碼如下:

  protected void updateViewHierarchy() {
....
try {
for (int i = 0; i < mShadowNodeRegistry.getRootNodeCount(); i++) {
int tag = mShadowNodeRegistry.getRootTag(i);
ReactShadowNode cssRoot = mShadowNodeRegistry.getNode(tag);

if (cssRoot.getWidthMeasureSpec() != null && cssRoot.getHeightMeasureSpec() != null) {
...
try {
notifyOnBeforeLayoutRecursive(cssRoot);
} finally {
Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE);
}

calculateRootLayout(cssRoot);
...
try {
applyUpdatesRecursive(cssRoot, 0f, 0f);
} finally {
}
...
}
}
} finally {
Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE);
}
}

所以即使使用多個 ReactRootView RN 的渲染邏輯也可以正常執行,這裡一個 ReactRootView 即對應 JS 實現中的一個 Component,我們在執行 RN 業務程式碼會看到 startApplication 的實現在 ReactRootView 中,startApplication 傳入的引數就是 Component,對應程式碼如下:

public class ReactRootView extends FrameLayout implements RootView, ReactRoot {
public void startReactApplication(
ReactInstanceManager reactInstanceManager,
String moduleName,
@Nullable Bundle initialProperties,
@Nullable String initialUITemplate) {
...
}
}

到此客戶端側的重點實現基本完成了,接下來就是JS側。

  1. JS 側寫法變化

JS 側的對於卡片開發的寫法和正常的 RN 開發基本相同,唯一的區別是需要同時註冊多個 component,客戶端每個業務卡片啟動時只需要啟動對應的 Component 即可,程式碼示例如下:

AppRegistry.registerComponent('hotTopic', () => EStyleTheme(HotTopic));
AppRegistry.registerComponent('musicCalendar', () => EStyleTheme(MusicCalendar));
AppRegistry.registerComponent('newSong', () => EStyleTheme(NewSong));
  1. JS 和 Native 通訊

至此整個渲染流程都已經介紹完成,卡片已經可以正常展示,不過既然RN具有圖靈完備的能力,勢必會有一些使用者互動導致的UI變化,比如點選卡片上的 ”叉“ 的不感興趣操作,點選後需要通知客戶端彈出客戶端的不感興趣元件,多個卡片對應同一個 JS 引擎,JS 和 Native 的通訊通道也是複用的,怎麼決定由哪個卡片來彈出呢,我們的做法是在卡片第一次渲染時就使用時間戳的雜湊值生成唯一的 key,將這個 key 作為 Native 側和 JS 側區分不同業務的唯一標識,和具體展示的業務卡片關聯起來在雙側都儲存起來,這樣後續每次通訊時雙側就可以通過 key 來確認通訊的物件,確保不會導致通訊混亂。

  1. RN 引擎預熱

在整個 RN 的執行週期中離線包載入一般也會消耗比較多的時間,所以為了儘可能的提升效能,我們還對頁面卡片對應的整個離線包進行了預熱,即提前將離線包載入到記憶體中並準備好業務邏輯的執行時環境,預熱只需要建立好 ReactInstanceManager 並呼叫createReactContextInBackground() 即可,呼叫後整個離線包會被交給 JS 引擎進行預處理,程式碼如下:

ReactInstanceManager.builder()
.setApplication(ApplicationWrapper.getInstance())
.setJSMainModulePath("index.android")
.addPackage(MainReactPackage())
...
.build()
.createReactContextInBackground()

這裡還需要注意的一個點是程式碼除錯能力,採用內嵌的方式如果原來頁面已經有搖一搖這種手勢, RN 原生的除錯選單會無法撥出,這裡需要增加額外的互動方式來解決,我們在卡片上增加了一個懸浮按鈕。

到此整體框架就都已介紹完畢,在框架之外記憶體佔用和合理的異常處理也是需要考慮的重點。

記憶體

在整體技術實現之外,我們另外關注的一個重點就是記憶體佔用,我們對以RN Bridge為核心的RN容器記憶體佔用進行了統計,使用Profiler工具獲取資料如下:

無RN容器(native/java) 1 RN容器(native/java) 2 RN容器(native/java) 3 RN容器(native/java) 5 RN容器(native/java)
紅米k30pro 6G 148/54.6 154/56 157/55.7 153/56.7 208/59.8
谷歌Pixel 2XL 4G 137.8/60 163/73 176/83 186/91 196/101
紅米k30 8G 118/52 143/56 136/55 138/56 142/60

整體看來在5個以內RN容器的情況整體記憶體並沒有增加很多,記憶體佔用整體在可控狀態,由於此方案採用了一個 RN Bridge 對應多個卡片的方式,所以相當於只新增一個Bridge,對記憶體影響較小,實際線上執行也沒有新增 OOM 問題。

異常處理

  1. 出現異常如何處理

不管是 JS 寫法原因還是 ReactNative 本身的穩定性原因,總有一定概率會有異常出現,這時需要合理的邏輯處理保證功能和使用者體驗不會受到比較大的影響,我們當前的處理策略是異常監聽還是使用 NativeExceptionHandler 來監聽 SoftException 和 FatalException,異常時在統一的回撥中通知上層業務(recyclerView 層),然後根據具體的業務情況,由業務層統一消除或者重建 RN 容器,保證體驗不受影響或者影響較小,以雲音樂首頁使用場景為例目前卡片總 PV 約 1 億,錯誤率不到萬分之一,整體執行情況穩定,無相關使用者反饋。

  1. RN版本升級導致和資料不相容如何處理

RN 使用離線包策略,為保證使用者能正常獲取到離線包和保證離線包能快速高效的更新,我們採取了兜底包整合、更新資訊服務端介面搭車等策略,不過受限於使用者的機型地區、網路狀態等原因還是存在一定概率的更新不成功,對於這種情況我們將當前 RN 離線包支援的卡片資訊儲存在離線包的配置檔案中,通過離線包獲取的介面暴露給業務方,業務在執行離線包前可以根據配置資訊對網路請求結果進行過濾,保證新版資料匹配舊版的離線包時不會導致異常。

未來規劃

短期內我們希望將 RN 動態列表方案結合我們已有的 RN 低程式碼能力,實現首頁運營動態搭建釋出,另一方面主要在效能提升,我們目前還是使用的 RN 0.60.5 版本,JS 的執行效率和當前版本的多執行緒框架是我們的最大的瓶頸,之後我們會在新架構上進行更多的嘗試。

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!