React?Native?的動態(tài)列表方案探索詳解
背景
時至2022,精細(xì)化運營已經(jīng)成為了各大App廠商的強需求,阿里的 DinamicX、Tangram 大家應(yīng)該都很熟悉了,很多App廠商也自研了一些類似框架,基于DSL的動態(tài)化方案雖然有性能上的一些優(yōu)勢,但是畢竟不是圖靈完備,一些需要邏輯動態(tài)下發(fā)的需求實現(xiàn)成本偏高,或由于DSL本身限制無法實現(xiàn),針對這個問題我們使用RN進行了一下探索嘗試, 利用我們已經(jīng)相對完善的RN基建,結(jié)合客戶端列表能力低成本的實現(xiàn)了一套的動態(tài)化能力,同時兼顧一定的性能體驗。
基于 ReactNative 的動態(tài)列表方案簡單來說就是將 ReactNative 容器內(nèi)嵌在 RecyclerView 的 ViewHolder 中,由于頁面主體框架還是由 Native 開發(fā)和渲染,所以首屏加載速度得到了保證,局部的RN實現(xiàn)也使頁面獲得動態(tài)化的能力,從而在性能、”完備邏輯執(zhí)行“的動態(tài)化能力之間取得了一個平衡點,根據(jù)我們使用經(jīng)驗對幾種動態(tài)化方案排序如下:
- 整體性能體驗排序: 純 Native > 基于DSL動態(tài)化方案 >= ReactNative 動態(tài)列表方案 > 純 ReactNative 頁面 > H5
- 動態(tài)能力排序: H5 = 純 ReactNative 頁面 > ReactNative 動態(tài)列表方案 > 基于DSL動態(tài)化方案 > 純 Native
- 實現(xiàn)能力排序: 純 Native >= RN 動態(tài)列表方案 = 純 ReactNative 頁面 > H5 > 基于DSL的動態(tài)化方案
從以上排序中可以看出 ReactNative 動態(tài)列表方案整體處于中等或中等偏上的一個位置,在實現(xiàn)能力上遠(yuǎn)勝余基于 DSL 動態(tài)方案,和 Native 能力基本對等,可以實現(xiàn)一些復(fù)雜的UI交互效果,并且相比于純 RN 實現(xiàn)的頁面首屏速度會有非常大的優(yōu)勢,另外不需要對頁面整體框架進行更改就能比較方便的嵌入,在開發(fā)維護成本上 RN 動態(tài)列表方案相比各種基于DSL的動態(tài)化方案會有比較明顯的優(yōu)勢,不需要額外的開發(fā)組件管理平臺,排查問題時也不用去讀難懂的 dsl,最重要的是 RN 具有圖靈完備的能力,所以綜合來看使用 RN 內(nèi)嵌到 Native RecyclerView 來實現(xiàn) Native 頁面部分動態(tài)化的方式算是一種性價比相對較高的方式了,值得一試。
技術(shù)方案介紹
這里從 Android 視角分享下我們這套方案實現(xiàn)的一些技術(shù)細(xì)節(jié)、原理以及遇到的問題。首先我們常用的一些術(shù)語:
moduleName
是 RN 離線包的唯一 key,相當(dāng)于離線包的名字;componentName
是 RN 中 registerComponent 的 component,對應(yīng)一個 RN 實現(xiàn)的業(yè)務(wù)的執(zhí)行入口;- 卡片指云音樂首頁中每個 viewholder 內(nèi)部的展示內(nèi)容,展示的 UI 樣式是卡片樣式;
- RN 引擎指以 RN Bridge 為主的整個 JS 離線包運行時環(huán)境。
整體方案架構(gòu)如下:
從圖中可以看出整體方案采用數(shù)據(jù)驅(qū)動的方式,服務(wù)端通過數(shù)據(jù)中攜帶的類型、component、moduleName等字段來唯一指定是否是使用 RN 來渲染,執(zhí)行 RN 離線包中的哪個 component 邏輯
整體方案上有幾個細(xì)節(jié)點:
- 采用數(shù)據(jù)驅(qū)動的方式,接入頁面無須關(guān)注具體展示數(shù)據(jù),只需要將數(shù)據(jù)透傳到 RN 的 JS 側(cè)即可
- 由于 RN 需要將離線包加載后才能執(zhí)行 JS 生成客戶端視圖,在 RecyclerView 綁定數(shù)據(jù)時才開始加載 RN 的離線包勢必會拖慢整個模塊的展示,所以這里我們做了整個離線包的預(yù)加載
- 首頁列表中每個 ViewHolder 的展示元素我們叫做一個卡片,目前采取的策略是多個卡片放在一個 RN 的離線包中,通過同一個 RN 容器來分別展示,避免多個容器消耗過多的資源。
下面從數(shù)據(jù)流角度拆解整個方案,整體方案可以分為服務(wù)端數(shù)據(jù)定義和下發(fā),容器數(shù)據(jù)透傳,JS側(cè)數(shù)據(jù)解析三個主要步驟:
- 服務(wù)端數(shù)據(jù)定義和下發(fā)
由于是服務(wù)端接口驅(qū)動 RecyclerView 中內(nèi)容展示,接口下發(fā)數(shù)據(jù)中需要有type字段標(biāo)識使用RN還是Native展示,可以服用Native展示樣式標(biāo)記字段,由于RN中具體展示的樣式和運行哪些 JS 代碼直接相關(guān),所以服務(wù)端下發(fā)的數(shù)據(jù)中需要帶上對應(yīng)的 moduleName 和 componentName,整體數(shù)據(jù)結(jié)構(gòu)定義如下:
[ { "type":"rn", "rnInfo":{ "moduleName":"bizDiscovery", "component":"hotSong", "otherInfo":{ } }, "data":{ "songInfo":{ } } }, { "type":"dragonball", "data":{ "showInfo":{ } } }]
獲取到數(shù)據(jù)之后只需要按照 RecyclerView 正常的使用方法將數(shù)據(jù)和不同的 ViewHolder 綁定即可
- 容器數(shù)據(jù)透傳
RN 容器直接直接內(nèi)嵌在 ViewHolder 中,在 viewHolder 中只需要定義承載 RN JS 渲染視圖的 ViewGroup container,RN Bridge 創(chuàng)建好 ReactRootView 后將創(chuàng)建好的 ReactRootView 調(diào)用 add 方法添加到 container 中即可,數(shù)據(jù)傳遞是透傳的方式通過 RN 的 initialProperty 傳入到 JS 側(cè),在 JS 側(cè)解析和使用,數(shù)據(jù)傳遞代碼如下:
mReactRootView?.startReactApplication(reactInstanceManager, componentName, initialProperties)
這里面需要注意的點是,由于所有使用RN展示的卡片都是對應(yīng)的相同的 RecyclerView type 即相同的 ViewHolder,所以在 RecyclerView 復(fù)用時可能會出現(xiàn)兩種情況:
1. 只有一個 RN 卡片,上下滑動 RecyclerView 時發(fā)生復(fù)用,這時基本不用處理,
2. 存在兩種不同類型的 RN 卡片,復(fù)用時會運行完全不同的離線包代碼,這種情況會導(dǎo)致 JS 側(cè)重新執(zhí)行渲染邏輯生成全新的視圖,上下滾動時如果每次都出現(xiàn) JS 側(cè)重新渲染,會極大的影響滑動時性能,造成滑動卡頓掉幀,針對這種問題我們對 RN 的 ReactRootView 也做了緩存,
整體架構(gòu)如下:
從圖中可以看到 ViewHolder 中的 container 和 RN 的 ReactRootView 是一對多的關(guān)系,RN 的 ReactRootView 在第一次初始化完成后還是掛在 RN 管理的虛擬視圖樹中,在 RecyclerView 滑動切換不同的展示類型時只需要從 ViewHolder 的 container 中移除不展示的ReactRootView 再重新 add 需要展示的 ReactRootView,不需要 JS 側(cè)重新執(zhí)行,重新 add ReactRootView 之后還需要將當(dāng)前的數(shù)據(jù)再傳入 JS 側(cè)以適配相同樣式的卡片展示不同數(shù)據(jù)的需求。這里面的原理是一般情況下我們一個 RN Bridge 只會創(chuàng)建一個 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 的渲染邏輯也可以正常執(zhí)行,這里一個 ReactRootView 即對應(yīng) JS 實現(xiàn)中的一個 Component,我們在運行 RN 業(yè)務(wù)代碼會看到 startApplication 的實現(xiàn)在 ReactRootView 中,startApplication 傳入的參數(shù)就是 Component,對應(yīng)代碼如下:
public class ReactRootView extends FrameLayout implements RootView, ReactRoot { public void startReactApplication( ReactInstanceManager reactInstanceManager, String moduleName, @Nullable Bundle initialProperties, @Nullable String initialUITemplate) { ... } }
到此客戶端側(cè)的重點實現(xiàn)基本完成了,接下來就是JS側(cè)。
- JS 側(cè)寫法變化
JS 側(cè)的對于卡片開發(fā)的寫法和正常的 RN 開發(fā)基本相同,唯一的區(qū)別是需要同時注冊多個 component,客戶端每個業(yè)務(wù)卡片啟動時只需要啟動對應(yīng)的 Component 即可,代碼示例如下:
AppRegistry.registerComponent('hotTopic', () => EStyleTheme(HotTopic)); AppRegistry.registerComponent('musicCalendar', () => EStyleTheme(MusicCalendar)); AppRegistry.registerComponent('newSong', () => EStyleTheme(NewSong));
- JS 和 Native 通信
至此整個渲染流程都已經(jīng)介紹完成,卡片已經(jīng)可以正常展示,不過既然RN具有圖靈完備的能力,勢必會有一些用戶交互導(dǎo)致的UI變化,比如點擊卡片上的 ”叉“ 的不感興趣操作,點擊后需要通知客戶端彈出客戶端的不感興趣組件,多個卡片對應(yīng)同一個 JS 引擎,JS 和 Native 的通信通道也是復(fù)用的,怎么決定由哪個卡片來彈出呢,我們的做法是在卡片第一次渲染時就使用時間戳的哈希值生成唯一的 key,將這個 key 作為 Native 側(cè)和 JS 側(cè)區(qū)分不同業(yè)務(wù)的唯一標(biāo)識,和具體展示的業(yè)務(wù)卡片關(guān)聯(lián)起來在雙側(cè)都存儲起來,這樣后續(xù)每次通信時雙側(cè)就可以通過 key 來確認(rèn)通信的對象,確保不會導(dǎo)致通信混亂。
- RN 引擎預(yù)熱
在整個 RN 的執(zhí)行周期中離線包加載一般也會消耗比較多的時間,所以為了盡可能的提升性能,我們還對頁面卡片對應(yīng)的整個離線包進行了預(yù)熱,即提前將離線包加載到內(nèi)存中并準(zhǔn)備好業(yè)務(wù)邏輯的運行時環(huán)境,預(yù)熱只需要創(chuàng)建好 ReactInstanceManager 并調(diào)用createReactContextInBackground() 即可,調(diào)用后整個離線包會被交給 JS 引擎進行預(yù)處理,代碼如下:
ReactInstanceManager.builder() .setApplication(ApplicationWrapper.getInstance()) .setJSMainModulePath("index.android") .addPackage(MainReactPackage()) ... .build() .createReactContextInBackground()
這里還需要注意的一個點是代碼調(diào)試能力,采用內(nèi)嵌的方式如果原來頁面已經(jīng)有搖一搖這種手勢, RN 原生的調(diào)試菜單會無法呼出,這里需要增加額外的交互方式來解決,我們在卡片上增加了一個懸浮按鈕。
到此整體框架就都已介紹完畢,在框架之外內(nèi)存占用和合理的異常處理也是需要考慮的重點。
內(nèi)存
在整體技術(shù)實現(xiàn)之外,我們另外關(guān)注的一個重點就是內(nèi)存占用,我們對以RN Bridge為核心的RN容器內(nèi)存占用進行了統(tǒng)計,使用Profiler工具獲取數(shù)據(jù)如下:
無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個以內(nèi)RN容器的情況整體內(nèi)存并沒有增加很多,內(nèi)存占用整體在可控狀態(tài),由于此方案采用了一個 RN Bridge 對應(yīng)多個卡片的方式,所以相當(dāng)于只新增一個Bridge,對內(nèi)存影響較小,實際線上運行也沒有新增 OOM 問題。
異常處理
- 出現(xiàn)異常如何處理
不管是 JS 寫法原因還是 ReactNative 本身的穩(wěn)定性原因,總有一定概率會有異常出現(xiàn),這時需要合理的邏輯處理保證功能和用戶體驗不會受到比較大的影響,我們當(dāng)前的處理策略是異常監(jiān)聽還是使用 NativeExceptionHandler 來監(jiān)聽 SoftException 和 FatalException,異常時在統(tǒng)一的回調(diào)中通知上層業(yè)務(wù)(recyclerView 層),然后根據(jù)具體的業(yè)務(wù)情況,由業(yè)務(wù)層統(tǒng)一消除或者重建 RN 容器,保證體驗不受影響或者影響較小,以云音樂首頁使用場景為例目前卡片總 PV 約 1 億,錯誤率不到萬分之一,整體運行情況穩(wěn)定,無相關(guān)用戶反饋。
- RN版本升級導(dǎo)致和數(shù)據(jù)不兼容如何處理
RN 使用離線包策略,為保證用戶能正常獲取到離線包和保證離線包能快速高效的更新,我們采取了兜底包集成、更新信息服務(wù)端接口搭車等策略,不過受限于用戶的機型地區(qū)、網(wǎng)絡(luò)狀態(tài)等原因還是存在一定概率的更新不成功,對于這種情況我們將當(dāng)前 RN 離線包支持的卡片信息保存在離線包的配置文件中,通過離線包獲取的接口暴露給業(yè)務(wù)方,業(yè)務(wù)在運行離線包前可以根據(jù)配置信息對網(wǎng)絡(luò)請求結(jié)果進行過濾,保證新版數(shù)據(jù)匹配舊版的離線包時不會導(dǎo)致異常。
未來規(guī)劃
短期內(nèi)我們希望將 RN 動態(tài)列表方案結(jié)合我們已有的 RN 低代碼能力,實現(xiàn)首頁運營動態(tài)搭建發(fā)布,另一方面主要在性能提升,我們目前還是使用的 RN 0.60.5 版本,JS 的執(zhí)行效率和當(dāng)前版本的多線程框架是我們的最大的瓶頸,之后我們會在新架構(gòu)上進行更多的嘗試。
以上就是React Native 的動態(tài)列表方案探索詳解的詳細(xì)內(nèi)容,更多關(guān)于React Native 動態(tài)列表的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
react中使用better-scroll滾動插件的實現(xiàn)示例
滾動在很多地方都可以使用,本文主要介紹了react中使用better-scroll滾動插件的實現(xiàn)示例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-07-07react antd checkbox實現(xiàn)全選、多選功能
目前好像只有table組件有實現(xiàn)表格數(shù)據(jù)的全選功能,如果說對于list,card,collapse等其他組件來說,需要自己結(jié)合checkbox來手動實現(xiàn)全選功能,這篇文章主要介紹了react antd checkbox實現(xiàn)全選、多選功能,需要的朋友可以參考下2024-07-07在react項目中使用antd的form組件,動態(tài)設(shè)置input框的值
這篇文章主要介紹了在react項目中使用antd的form組件,動態(tài)設(shè)置input框的值,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-10-10react-router v4如何使用history控制路由跳轉(zhuǎn)詳解
這篇文章主要給大家介紹了關(guān)于react-router v4如何使用history控制路由跳轉(zhuǎn)的相關(guān)資料,文中通過示例代碼介紹的的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2018-01-01React網(wǎng)絡(luò)請求發(fā)起方法詳細(xì)介紹
在編程開發(fā)中,網(wǎng)絡(luò)數(shù)據(jù)請求是必不可少的,這篇文章主要介紹了React網(wǎng)絡(luò)請求發(fā)起方法,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)吧2022-09-09React-native橋接Android原生開發(fā)詳解
本篇文章主要介紹了React-native橋接Android原生開發(fā)詳解,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-01-01