React Native 中 ScrollView 效能探究
1 基本使用
ScrollView 是 React Native(後面簡稱:RN) 中最常見的元件之一。理解 ScrollView 的原理,有利於寫出高效能的 RN 應用。
ScrollView 的基本使用也非常簡單,如下:
<ScrollView>
<Child1 />
<Child2 />
...
</ScrollView>
它和 View 元件一樣,可以包含一個或者多個子元件。對子元件的佈局可以是垂直或者水平的,通過屬性 horizontal=true/false
來控制。甚至還預設支援“下拉”重新整理操作。另外還有一個特別讚的特性,超出螢幕的
View 會自動被移除,從而節省資源和提高繪製效率。我們來看如下一個例子:
class ScrollViewTest extends Component { render() { let children = []; for (var i = 0; i < 20; i++) { children.push( <View key={"key_" + i} style={styles.child}> <Text>{"T" + i}</Text> </View>); } return ( <ScrollView style={styles.scrollView}> {children} </ScrollView> ); } }
在 Android 上的效果如下:如圖,我們在 ScrollView 中添加了 20 個子元件,但是我們的螢幕任意時刻最多隻能顯示 5 個子專案。
下面我們來看實際對應的 Native 控制元件的情況。RN 中的 ScrollView 對應到 Native 的 RCTScrollView,自動把子元件包含在一個 ViewGroup 中(因為Android 的 ScrollView 只能有一個直接子控制元件),如下圖中的紅色框內:
注意到,我們在 JS 中添加了 20 個子元件,但是在 RCTViewGroup 中只有在螢幕上顯示的 5 個子控制元件,在螢幕外的元件,也會自動新增到 View 樹中,這與 Native 的 ScrollView 表現一致。
其實,RN 中的 ScrollView 有一個 removeClippedSubviews
屬性,表示如果子 View 超出可視區域,是否自動移除,雖然預設是
true。但是也需要子 View 的 overflow: 'hidden'
屬性配合。所以,給子元件的 style
新增如下屬性即可。
<View key={"key_" + i} style={styles.child}>
<Text>{"T" + i}</Text>
</View>;
const styles = StyleSheet.create({
child: {
...
overflow: 'hidden',
},
});
得到的效果是,在使用上完全沒有區別,而我們來看一下介面的 Tree View,如下圖:
可見,螢幕外的子 View,就被自動從 View 樹中移除了。
同時,我們來看一下 iOS 平臺上的表現,與 Android 上類似:
這印證了我們前面的結論,RN 自動優化了 Native 平臺 ScrollView,在這個層面,我們可以說 RN 比 Native 的效能還要高。
2 效能研究
通過上面的例項,我們可以看到,ScrollView 應該是非常高效的,它使用簡單,並且還能按需構建 View 樹,高效渲染,有點類似 Native 平臺上的 ListView 了,是我心目完美 ScrollView 該有的樣子。
但是,之前看到騰訊的 TAT.ronnie 一篇文章 探索 react native 首屏渲染最佳實踐,文中提到的優化方法,主要就是針對 ScrollView 的。作者認為,在 ScrollView 中,即使不可見(例如,超出螢幕)的元件還是會繪製的。為了優化 ScrollView 的繪製效能,不可見的元件,應該在 JS 中避免新增到 ScrollView 中。
顯然,這與我們前面觀察到的結論是矛盾的。但是,作者的通過那樣處理,確實優化了顯示效能,這是怎麼回事呢?為了驗證,我們也和文中一樣,使用 componentDidMount()
和 componentWillMount()
的時間差衡量顯示速度。在
Android 上,測試 ScrollView 的子元件數量分別為 10,100,1000 的時候,顯示的時間,以及 APP 所佔用的記憶體:
子元件數量 | 載入時間(ms) | 佔用記憶體(MB) | 繪製時間*(ms) |
---|---|---|---|
10 | 309 | 19.7 | 14.666 |
100 | 1170 | 21.9 | 15.016 |
1000 | 9461 | 26.5 | 15.025 |
* 注,這裡的繪製時間,是在 Tree View 中獲得的 Draw 時間。
從載入時間看,時間隨著子元件的數量線性增加,佔用記憶體也有類似趨勢,說明 TAT.ronnie 的改進方法確實是有效的。另外我們也注意到,隨著子元件的數量增加,Draw 的時間並沒有明顯的變化,其實 Measure 和 Layout 時間也沒有明顯的變化。
說明 ScrollView 雖然有 removeClippedSubviews
屬性,也確實在 View Hierarchy 中去掉了不可見的
View。但是元件的載入時間消耗資源還是隨著子元件的數量成正比。
3 原因分析
來看一下 RN 中 ScrollView 的相關的原始碼,主要分析 Android 平臺的程式碼,iOS 類似,就不贅述了。
// ScrollView.js
var AndroidScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
var AndroidHorizontalScrollView = requireNativeComponent(
'AndroidHorizontalScrollView',
ScrollView,
nativeOnlyProps
);
var ScrollView = React.createClass({
render: function() {
var contentContainer =
<View
...
removeClippedSubviews={this.props.removeClippedSubviews}
collapsable={false}>
{this.props.children}
</View>;
var ScrollViewClass;
if (Platform.OS === 'ios') {
...
} else if (Platform.OS === 'android') {
if (this.props.horizontal) {
ScrollViewClass = AndroidHorizontalScrollView;
} else {
ScrollViewClass = AndroidScrollView;
}
}
// 為了簡單,忽略有下拉重新整理的情況
return (
<ScrollViewClass ...>
{contentContainer}
</ScrollViewClass>
);
}
});
JS 部分的程式碼邏輯很簡單。首先把 ScrollView 所有子元件包裝在一個 View contentContainer
中,並繼承設定了 removeClippedSubviews
屬性。根據
ScrollView 是否是水平方向,決定是用 RCTScrollView
或者 AndroidHorizontalScrollView
Native
元件來包含 contentContainer。
所以,我們先來看 RCTScrollView
本地元件對應的程式碼(AndroidHorizontalScrollView 原理也類似)。JS
中的 RCTScrollView 元件由 com.facebook.react.views.scroll.ReactScrollViewManager
提供,具體的
View 的實現是 com.facebook.react.views.scroll.ReactScrollView
。
其中 ReactScrollViewManager 是最基礎的 ViewManager 的實現,匯出了一些屬性和事件。ReactScrollView 則繼承於 android.widget.ScrollView
,並實現了 ReactClippingViewGroup
介面。關於
Scroll 事件相關的程式碼我們先忽略,我主要關心 View 繪製相關的程式碼。主要在下面這段程式碼:
@Override
public void updateClippingRect() {
if (!mRemoveClippedSubviews) {
return;
}
...
View contentView = getChildAt(0);
if (contentView instanceof ReactClippingViewGroup) {
((ReactClippingViewGroup) contentView).updateClippingRect();
}
}
可見,如果不開啟 mRemoveClippedSubviews
,它就和普通的 ScrollView 一樣,否者,它就會呼叫了它的第一個(也是唯一的一個)子
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 的陣列,包括可見和不可見的
private @Nullable View[] mAllChildren = null;
private int mAllChildrenCount;
// 當前 ReactViewGroup 於父 View 相交矩陣,
// 也就是它自己在父 View 中可見區域
private @Nullable Rect mClippingRect;
...
}
在 ReactViewGroup 中實現 removeClippedSubviews
的功能也非常直接,需要更新介面 Layout 的時候,遍歷所有的子
View,看子 View 是否在 mClippingRect
區域內,如果在,就通過 addViewInLayout()
方法新增此
View,否者就通過 removeViewsInLayout()
方法移除它。
到這了,我們就可以解釋前面的矛盾了。雖然在 ScrollView 的 View Hierarchy 中,會自動移除不顯示的 View,但是實際上還是建立了所有的子 View,所以所佔記憶體和載入時間會線性增加。
關於建立所有子 View,我這裡可以多分析一下。我們知道在 Android 中,建立 View 的代價是很大的。特別是在 ScrollView 中,所有的子 View 都是同時建立的。如果 ScrollView 中子 View 的數量很多,這樣的代價累加起來,對 APP 造成的延遲和卡頓是相當可觀的。例如前面的測試中有 1000 個子元件,載入時間竟然長達 9.5 秒。我們用Method Tracing 看一下建立一個子 View 所花的時間,如下圖:
這裡只是簡單的建立一個 TextView 就消耗了大約 25ms 的時間。當然 Tracing 過程本身會拖慢 APP 執行,但是不影響我們的結論。所以 Android 中列表類的控制元件,都內部支援對 View 的複用,儘量避免建立 View。
通過前面的分析,我們可以得到的結論是:RN 中的 ScrollView 並不像我們想象的那樣高效能。
4 ListView
在這裡提到 ListView,是因為 RN 中的 ListView 就是基於 ScrollView 的,但是有一些優化。這裡簡要介紹一些 ListView 的原理。
ListView 其實是對 ScrollView 的一個封裝,對應到 Native 平臺,和 ScrollView 的表現一模一樣。但是 ListView 在顯示列表內容的時候,會根據滑動距離,逐步向 ScrollView 中新增子元件(通過呼叫 renderRow()
方法)。注意到
ListView 有 initialListSize
屬性,表示第一次載入的時候新增多少個子項,預設是 10,還有 pageSize
屬性,表示每次需要新增的時候,增加多少個子項,預設是
1。
通過上面的分析我們可以看到,ListView 在第一次載入的時候,不論你的列表有多大,預設最多載入 initialListSize 個子項,所以能保證啟動速度,如果還沒有充滿,或者在向下滑動過程中,再元件新增子項。這樣的操作似乎比較合理,但是注意到,整個操作中,會逐漸向 ListView 中新增子項,新出現的子項,都是通過建立新的 View,而完全沒有複用的過程。所以,如果在應用中,ListView 中的子項數量特別多,ListView 往下滑動的過程中,記憶體會逐漸上漲的。
值得一提的是,ListView 提供了 renderScrollComponent
,可以使用其他 Scroll 元件來替換 ScrollView,並且 RecyclerViewBackedScrollView
元件來作為備選。看到這個名字我很欣喜,說明它支援子項的回收複用(Recycler)。首先,看到
iOS 的實現 RecyclerViewBackedScrollView.ios.js
,其實它就是 ScrollView,並沒有實現所謂的複用,失望了一半。繼續看
Android 的實現,它實際上是對應 Native 的 com.facebook.react.views.recyclerview.AndroidRecyclerViewBackedScrollView
,它繼承與
Android 的 RecyclerView
。看到這裡,如果使用這種方法,我直觀感覺 RN 的 ListView 效能在 Android
上表現應該會比 iOS 好。
我們繼續來看它是怎麼實現回收複用的,AndroidRecyclerViewBackedScrollView 內部實現了一個 RecyclerView.Adapter,如下:
static class ReactListAdapter extends Adapter<ConcreteViewHolder> {
private final List<View> 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);
...
}
}
@Override
public ConcreteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new ConcreteViewHolder(new RecyclableWrapperViewGroup(parent.getContext()));
}
@Override
public void onBindViewHolder(ConcreteViewHolder holder, int position) {
RecyclableWrapperViewGroup vg = (RecyclableWrapperViewGroup) holder.itemView;
View row = mViews.get(position);
if (row.getParent() != vg) {
vg.addView(row, 0);
}
}
@Override
public void onViewRecycled(ConcreteViewHolder holder) {
super.onViewRecycled(holder);
((RecyclableWrapperViewGroup) holder.itemView).removeAllViews();
}
}
注意到這裡有一個 mViews
,用來儲存所有的子 View,繫結 View 的時候只是簡單用一個空的 View(RecyclableWrapperViewGroup)包了一下。這樣一來,RecyclerView
完全沒有什麼起到複用的作用呀!測試一下,確實也是這樣,效能問題還是很嚴重。
這裡我們也可以得到一個結論:RN 中的 ListView 也不是我們想象的 ListView 該有的效能。
5 改進方案
通過前面的分析,我們已經知道了 RN 中的 ScrollView 或者 ListView 的效能瓶頸了,同時也有了改進的思路。下面針對各種情況分析:
- 如果要優化首次載入速度,也就是啟動速度:可以參考 TAT.ronnie 的文章中的方法,根據實際情況,最小化 ScrollView 或者 ListView 初始子項數量;
- 優化記憶體:因為 ScrollView/ListView 會儲存所有子 View 在記憶體中,因為我們沒法刪掉子項,但是我們可以儘量減少每個子項所佔的記憶體。例如這個專案 react-native-sglistview,它在子項不可見的時候,就把它退化成一個最基本的 View;
- 終極解決方案:要真正達到高效能,就需要儘量少的建立 View,要想辦法真正重複利用已經建立的子項。目前只有一些想法,待我實現了,再來更新。