React Native中ScrollView性能探究

1 基本使用

ScrollView是 ReactNative(后面簡稱:RN) 中最常見的組件之一。理解 ScrollView 的原理,有利于寫出高性能的 RN 應用。

ScrollView 的基本使用也非常簡單,如下:

...

它和 View 組件一樣,可以包含一個或者多個子組件。對子組件的布局可以是垂直或者水平的,通過屬性?horizontal=true/false?來控制。甚至還默認支持“下拉”刷新操作。另外還有一個特別贊的特性,超出屏幕的 View 會自動被移除,從而節(jié)省資源和提高繪制效率。我們來看如下一個例子:

class ScrollViewTest extends Component { render() { let children = []; for (var i = 0; i < 20; i++) { children.push( {"T" + i} ); } return ( {children} ); }}

在 Android 上的效果如下:

如圖,我們在 ScrollView 中添加了 20 個子組件,但是我們的屏幕任意時刻最多只能顯示 5 個子項目。

下面我們來看實際對應的 Native 控件的情況。RN 中的 ScrollView 對應到 Native 的 RCTScrollView,自動把子組件包含在一個 ViewGroup 中(因為Android 的 ScrollView 只能有一個直接子控件),如下圖中的紅色框內(nèi):

注意到,我們在 JS 中添加了 20 個子組件,但是在 RCTViewGroup 中只有在屏幕上顯示的 5 個子控件,在屏幕外的組件,也會自動添加到 View 樹中,這與 Native 的 ScrollView 表現(xiàn)一致。

其實,RN 中的 ScrollView 有一個?removeClippedSubviews?屬性,表示如果子 View 超出可視區(qū)域,是否自動移除,雖然默認是 true。但是也需要子 View 的?overflow: 'hidden'屬性配合。所以,給子組件的?style?添加如下屬性即可。 {"T" + i};const styles = StyleSheet.create({ child: { ... overflow: 'hidden', },});

得到的效果是,在使用上完全沒有區(qū)別,而我們來看一下界面的 Tree View,如下圖:

可見,屏幕外的子 View,就被自動從 View 樹中移除了。

同時,我們來看一下 iOS 平臺上的表現(xiàn),與 Android 上類似:

這印證了我們前面的結(jié)論,RN 自動優(yōu)化了 Native 平臺 ScrollView,在這個層面,我們可以說 RN 比 Native 的性能還要高。

2 性能研究

通過上面的實例,我們可以看到,ScrollView 應該是非常高效的,它使用簡單,并且還能按需構建 View 樹,高效渲染,有點類似 Native 平臺上的 ListView 了,是我心目完美 ScrollView 該有的樣子。

但是,之前看到騰訊的 TAT.ronnie 一篇文章探索 react native 首屏渲染最佳實踐,文中提到的優(yōu)化方法,主要就是針對 ScrollView 的。作者認為,在 ScrollView 中,即使不可見(例如,超出屏幕)的組件還是會繪制的。為了優(yōu)化 ScrollView 的繪制性能,不可見的組件,應該在 JS 中避免添加到 ScrollView 中。

顯然,這與我們前面觀察到的結(jié)論是矛盾的。但是,作者的通過那樣處理,確實優(yōu)化了顯示性能,這是怎么回事呢?為了驗證,我們也和文中一樣,使用?componentDidMount()?和?componentWillMount()?的時間差衡量顯示速度。在 Android 上,測試 ScrollView 的子組件數(shù)量分別為 10,100,1000 的時候,顯示的時間,以及 APP 所占用的內(nèi)存:子組件數(shù)量加載時間(ms)占用內(nèi)存(MB)繪制時間*(ms)1030919.714.666100117021.915.0161000946126.515.025

* 注,這里的繪制時間,是在 Tree View 中獲得的 Draw 時間。

從加載時間看,時間隨著子組件的數(shù)量線性增加,占用內(nèi)存也有類似趨勢,說明 TAT.ronnie 的改進方法確實是有效的。另外我們也注意到,隨著子組件的數(shù)量增加,Draw 的時間并沒有明顯的變化,其實 Measure 和 Layout 時間也沒有明顯的變化。

說明 ScrollView 雖然有?removeClippedSubviews?屬性,也確實在 View Hierarchy 中去掉了不可見的 View。但是組件的加載時間消耗資源還是隨著子組件的數(shù)量成正比。

3 原因分析

來看一下 RN 中 ScrollView 的相關的源碼,主要分析 Android 平臺的代碼,iOS 類似,就不贅述了。// ScrollView.jsvar AndroidScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps); var AndroidHorizontalScrollView = requireNativeComponent( 'AndroidHorizontalScrollView', ScrollView, nativeOnlyProps);var ScrollView = React.createClass({ render: function() { var contentContainer = {this.props.children} ; var ScrollViewClass; if (Platform.OS === 'ios') { ... } else if (Platform.OS === 'android') { if (this.props.horizontal) { ScrollViewClass = AndroidHorizontalScrollView; } else { ScrollViewClass = AndroidScrollView; } } // 為了簡單,忽略有下拉刷新的情況 return ( {contentContainer} ); }});

JS 部分的代碼邏輯很簡單。首先把 ScrollView 所有子組件包裝在一個 View?contentContainer?中,并繼承設置了?removeClippedSubviews?屬性。根據(jù) ScrollView 是否是水平方向,決定是用?RCTScrollView?或者?AndroidHorizontalScrollView?Native 組件來包含 contentContainer。

所以,我們先來看?RCTScrollView?本地組件對應的代碼(AndroidHorizontalScrollView 原理也類似)。JS 中的 RCTScrollView 組件由?com.facebook.react.views.scroll.ReactScrollViewManager?提供,具體的 View 的實現(xiàn)是?com.facebook.react.views.scroll.ReactScrollView。

其中 ReactScrollViewManager 是最基礎的 ViewManager 的實現(xiàn),導出了一些屬性和事件。ReactScrollView 則繼承于?android.widget.ScrollView,并實現(xiàn)了?ReactClippingViewGroup?接口。關于 Scroll 事件相關的代碼我們先忽略,我主要關心 View 繪制相關的代碼。主要在下面這段代碼:@Overridepublicvoid updateClippingRect() { if (!mRemoveClippedSubviews) { return; } ... View contentView = getChildAt(0); if (contentView instanceof ReactClippingViewGroup) { ((ReactClippingViewGroup) contentView).updateClippingRect(); }}

可見,如果不開啟?mRemoveClippedSubviews,它就和普通的 ScrollView 一樣,否者,它就會調(diào)用了它的第一個(也是唯一的一個)子 View 的?updateClippingRect()?方法。從上面的 JS 中我們可以看到,它的第一個子元素應該就是一個 View 組件,對應的 Native 的控件就是?ReactViewGroup。 ReactViewGroup 是 RN for Android 中最基礎的控件,它直接繼承于?android.view.ViewGroup:public class ReactViewGroup extends ViewGroup implements ReactInterceptingViewGroup, ReactClippingViewGroup, ReactPointerEventsView, ReactHitSlopView { private boolean mRemoveClippedSubviews = false; // 用來保存所有子 View 的數(shù)組,包括可見和不可見的 private@NullableView[] mAllChildren = null; private int mAllChildrenCount; // 當前 ReactViewGroup 于父 View 相交矩陣, // 也就是它自己在父 View 中可見區(qū)域 private@NullableRect mClippingRect; ...}

在 ReactViewGroup 中實現(xiàn)?removeClippedSubviews?的功能也非常直接,需要更新界面 Layout 的時候,遍歷所有的子 View,看子 View 是否在?mClippingRect?區(qū)域內(nèi),如果在,就通過?addViewInLayout()?方法添加此 View,否者就通過?removeViewsInLayout()?方法移除它。

到這了,我們就可以解釋前面的矛盾了。雖然在 ScrollView 的 View Hierarchy 中,會自動移除不顯示的 View,但是實際上還是創(chuàng)建了所有的子 View,所以所占內(nèi)存和加載時間會線性增加。

關于創(chuàng)建所有子 View,我這里可以多分析一下。我們知道在 Android 中,創(chuàng)建 View 的代價是很大的。特別是在 ScrollView 中,所有的子 View 都是同時創(chuàng)建的。如果 ScrollView 中子 View 的數(shù)量很多,這樣的代價累加起來,對 APP 造成的延遲和卡頓是相當可觀的。例如前面的測試中有 1000 個子組件,加載時間竟然長達 9.5 秒。我們用Method Tracing看一下創(chuàng)建一個子 View 所花的時間,如下圖:

這里只是簡單的創(chuàng)建一個 TextView 就消耗了大約 25ms 的時間。當然 Tracing 過程本身會拖慢 APP 運行,但是不影響我們的結(jié)論。所以 Android 中列表類的控件,都內(nèi)部支持對 View 的復用,盡量避免創(chuàng)建 View。

通過前面的分析,我們可以得到的結(jié)論是:RN 中的 ScrollView 并不像我們想象的那樣高性能。

4 ListView

在這里提到 ListView,是因為 RN 中的 ListView 就是基于 ScrollView 的,但是有一些優(yōu)化。這里簡要介紹一些 ListView 的原理。

ListView 其實是對 ScrollView 的一個封裝,對應到 Native 平臺,和 ScrollView 的表現(xiàn)一模一樣。但是 ListView 在顯示列表內(nèi)容的時候,會根據(jù)滑動距離,逐步向 ScrollView 中添加子組件(通過調(diào)用?renderRow()?方法)。注意到 ListView 有?initialListSize?屬性,表示第一次加載的時候添加多少個子項,默認是 10,還有?pageSize?屬性,表示每次需要添加的時候,增加多少個子項,默認是 1。

通過上面的分析我們可以看到,ListView 在第一次加載的時候,不論你的列表有多大,默認最多加載 initialListSize 個子項,所以能保證啟動速度,如果還沒有充滿,或者在向下滑動過程中,再組件添加子項。這樣的操作似乎比較合理,但是注意到,整個操作中,會逐漸向 ListView 中添加子項,新出現(xiàn)的子項,都是通過創(chuàng)建新的 View,而完全沒有復用的過程。所以,如果在應用中,ListView 中的子項數(shù)量特別多,ListView 往下滑動的過程中,內(nèi)存會逐漸上漲的。

值得一提的是,ListView 提供了?renderScrollComponent,可以使用其他 Scroll 組件來替換 ScrollView,并且?RecyclerViewBackedScrollView?組件來作為備選。看到這個名字我很欣喜,說明它支持子項的回收復用(Recycler)。首先,看到 iOS 的實現(xiàn)?RecyclerViewBackedScrollView.ios.js,其實它就是 ScrollView,并沒有實現(xiàn)所謂的復用,失望了一半。繼續(xù)看 Android 的實現(xiàn),它實際上是對應 Native 的?com.facebook.react.views.recyclerview.AndroidRecyclerViewBackedScrollView,它繼承與 Android 的?RecyclerView。看到這里,如果使用這種方法,我直觀感覺 RN 的 ListView 性能在 Android 上表現(xiàn)應該會比 iOS 好。

我們繼續(xù)來看它是怎么實現(xiàn)回收復用的,AndroidRecyclerViewBackedScrollView 內(nèi)部實現(xiàn)了一個 RecyclerView.Adapter,如下:static class ReactListAdapter extends Adapter { private final List mViews = new ArrayList<>(); public void addView(View child, int index) { mViews.add(index, child); ... } public void removeViewAt(int index) { View child = mViews.get(index); if (child != null) { mViews.remove(index); ... } }@Overridepublic ConcreteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return new ConcreteViewHolder(new RecyclableWrapperViewGroup(parent.getContext())); }@Overridepublic void onBindViewHolder(ConcreteViewHolder holder, int position) { RecyclableWrapperViewGroup vg = (RecyclableWrapperViewGroup) holder.itemView; View row = mViews.get(position); if (row.getParent() != vg) { vg.addView(row, 0); } }@Overridepublic void onViewRecycled(ConcreteViewHolder holder) { super.onViewRecycled(holder); ((RecyclableWrapperViewGroup) holder.itemView).removeAllViews(); } }

注意到這里有一個?mViews,用來保存所有的子 View,綁定 View 的時候只是簡單用一個空的 View(RecyclableWrapperViewGroup)包了一下。這樣一來,RecyclerView 完全沒有什么起到復用的作用呀!測試一下,確實也是這樣,性能問題還是很嚴重。

這里我們也可以得到一個結(jié)論:RN 中的 ListView 也不是我們想象的 ListView 該有的性能。

5 改進方案

通過前面的分析,我們已經(jīng)知道了 RN 中的 ScrollView 或者 ListView 的性能瓶頸了,同時也有了改進的思路。下面針對各種情況分析:

如果要優(yōu)化首次加載速度,也就是啟動速度:可以參考 TAT.ronnie的文章中的方法,根據(jù)實際情況,最小化 ScrollView 或者 ListView 初始子項數(shù)量;

優(yōu)化內(nèi)存:因為 ScrollView/ListView 會保存所有子View在內(nèi)存中,因為我們沒法刪掉子項,但是我們可以盡量減少每個子項所占的內(nèi)存。例如這個項目?react-native-sglistview,它在子項不可見的時候,就把它退化成一個最基本的View;

終極解決方案:要真正達到高性能,就需要盡量少的創(chuàng)建View,要想辦法真正重復利用已經(jīng)創(chuàng)建的子項。目前只有一些想法,待我實現(xiàn)了,再來更新。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,808評論 25 708
  • 簡短說明 收錄一些好用的RN第三方組件,以方便日常的使用,大家有什么推薦的也可以跟我說,我加進去。如有冒犯,可以聯(lián)...
    以德扶人閱讀 43,686評論 44 214
  • JVM所管理的內(nèi)存分為以下幾個運行時數(shù)據(jù)區(qū):程序計數(shù)器、Java虛擬機棧、本地方法棧、Java堆、方法區(qū)。 程序計...
    峰峰小閱讀 388評論 0 5
  • 青春,沒有輪廓。 青春,充斥苦澀。 青春年代,譜寫一生最絢麗多彩的曲子。 青春,沒有假期,卻一直瘋狂。 青春是一部...
    若熙若熙閱讀 226評論 3 1
  • 該腳本仿照dubbo官方給出的測試項目中的start.sh改寫的,依次是:設置java home,設置path,設...
    ltjxwxz閱讀 1,926評論 0 0