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 上的效果如下:
下面我們來看實際對應的 Native 控件的情況。RN 中的 ScrollView 對應到 Native 的 RCTScrollView,自動把子組件包含在一個 ViewGroup 中(因為Android 的 ScrollView 只能有一個直接子控件),如下圖中的紅色框內(nèi):
其實,RN 中的 ScrollView 有一個?removeClippedSubviews?屬性,表示如果子 View 超出可視區(qū)域,是否自動移除,雖然默認是 true。但是也需要子 View 的?overflow: 'hidden'屬性配合。所以,給子組件的?style?添加如下屬性即可。 {"T" + i};const styles = StyleSheet.create({ child: { ... overflow: 'hidden', },});
得到的效果是,在使用上完全沒有區(qū)別,而我們來看一下界面的 Tree View,如下圖:
同時,我們來看一下 iOS 平臺上的表現(xiàn),與 Android 上類似:
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 所花的時間,如下圖:
通過前面的分析,我們可以得到的結(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)了,再來更新。