背景
滾動列表 幾乎是移動開發中用途最廣的 UI 元件,其重要性不言而喻。由於平臺差異性,React Native 中的滾動列表元件 ListView 並沒有直接對映為 Android 中的 ListView 或 iOS 中的 UITableView,而是在 ScrollView 的基礎上使用 JS 做了一次封裝。這樣,滾動體驗部分由 Native 負責,而 React 部分則專注於元件何時渲染、如何渲染等問題。
ListView
的基本設計原則是 “資料和展現相隔離” ,如下圖所示。給我們帶來的好處就是,我們只需關注資料的組織方式,任何對資料的操作都會自動渲染出對應的展現。
資料
ListView
在建立時需要繫結資料來源,類似於 Android 中的 Adaptor。資料來源內部儲存了展現需要的初始資料 _dataBlob ,它是一個純粹的物件或陣列。列表可以帶 SectionHeader
(即列表中某一段的標題部分)也可以不帶,本質上相同。資料來源預設的格式有三個維度:
- 第一個維度是 sectionId ,標識屬於哪一段, 可以手動指定或隱式地使用陣列索引或物件的 key 值;
- 第二個維度是 rowId ,標識某個資料段下的某一個行,同樣可以手動指定或隱式地使用陣列索引或物件的 key 值;
- 第三個維度是具體的資料物件,根據實際的需要而定。
需要注意的是,上面只是 預設的資料格式,如果它不符合實際的需求, 完全可以使用自定義的資料結構 。唯一的區別就是需要額外指定給 ListView 資料來源中哪些是 id,哪些是 rowData。
DataSource 的建構函式接收以下幾個引數:
rowHasChanged
: 用於在資料變化的時候,計算出變化的部分,在更新時只渲染髒資料;sectionHeaderHasChanged
: 同理,在列表帶分段標題時需要實現;getRowData/getSectionHeaderData
: 如果遵循預設的資料來源格式,這兩個方法就沒有必要實現,用內部預設的即可;而當資料來源格式是自定義時,需要手動實現這兩個方法。
如文件中一般介紹的那樣,DataSource 的初始化一般在 getInitialState 方法中:
1 2 3 4 5 6 |
getInitialState: function() { var ds = new ListView.DataSource({rowHasChanged: (r1, r2) => r1 !== r2}); return { dataSource: ds.cloneWithRows(['row 1', 'row 2']), }; } |
在初次見到這種寫法時,心中其實疑惑這裡為什麼不一次性把整個物件 new 出來,而要拆分為兩步?
首先,如前所述,DataSource 的建構函式裡的引數只有四個,並不能直接傳入資料物件。其次,一個頁面中的滾動列表常常要不時修改資料,比如:
- 邊滾動邊新增列表元素;
- 展現搜尋結果時,當搜尋條件變化後,列表對應的資料物件需要重置;
- 對列表中的結果進行篩選、排序等操作。
在這些情況下,資料來源需要做調整,但上述寫法可以使得我們在調整時,無需重新定義 rowHasChanged 等比較函式,只需變化真實的資料物件即可。
展現
基本用法
資料來源確定後,下一個工作就是列表的渲染。在渲染時發揮重要作用的是 renderRow
屬性,它接收資料來源中儲存的資料物件,並通過返回值確定該行該如何進行展現。我們可以對所有行統一進行展現,也可以根據裡面的欄位做出不同的展現。在列表包含 sectionHeader 時,還需要實現 renderSectionHeader
方法。一個簡單的例子如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
var postDemo = React.createClass({ getInitialState: function() { var ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2, sectionHeaderHasChanged: (s1, s2) => s1 !== s2 }); var dataBlob = {...} return { dataSource: ds.cloneWithRowsAndSections(dataBlob) } }, renderRow: function(rowData, sectionId, rowId) { return (<View style={styles.row}> <Image source={{uri: 'http:' + rowData.pic}} style={styles.image} /> <View style={styles.rightSection}> <Text style={styles.title}>{rowData.title}</Text> <Text style={styles.priceInfo}>當前價: ¥{rowData.price}</Text> </View> </View>); }, renderSectionHeader: function(sectionData, sectionId) { return (<View> <Text style={styles.sectionHeaderStyle}>{sectionId}</Text> </View>); }, render: function() { return ( <ListView style={styles.listview} dataSource={this.state.dataSource} renderSectionHeader={this.renderSectionHeader} renderRow={this.renderRow}/> ); } }); |
Demo 執行的效果如下:
分頁
除了簡單的渲染之外,另外一個要考慮的問題就是 當資料量很大的時候如何分頁載入 。這種情形分兩種情況考慮:
- 資料一次性拿到,邊滾動邊載入
- 資料不是一次性拿到,而是有可能分屏取資料
對於第一種情況,在 ListView 內部其實已經做了分頁的處理:
- ListView 內部通過
curRenderedRowsCount
狀態儲存已渲染的行數; - 初始狀態下,要載入的資料條數等於
initialListSize
(預設為 10 條); - 在滾動時檢測當前滾動的位置和最底部的距離,如果小於
scrollRenderAheadDistance
(預設為 1000),就更新curRenderedRowsCount
,在它原有值基礎上加pageSize
個(預設為 1 條); - 由於屬性變化,觸發了 ListView 重新的 render 。在渲染過程中,
curRenderedRowsCount
起到截斷資料的作用,React 的 diff 演算法使得只有新加入的資料才會渲染到了介面上。
整個過程類似於 Web 端懶載入機制,即 每次在和底部的距離達到一個閾值時,載入接下來的 pageSize 個資料 。
對於第二種情況,ListView 提供了相關的屬性:
onEndReachedThreshold
,在滾動即將到達底部時觸發;onEndReached
,在已經到達底部時觸發;
我們可以在這兩個方法中呼叫介面去拿資料,取到資料後再更新資料來源。
多列
很多頁面中的列表並非單列的,如手淘搜尋結果頁裡,商品分兩列並排展示。乍一看似乎要做出不少調整,但實際上只通過佈局即可達到相關效果。ListView 並沒有強制要求一個 rowData 在展示時一定要佔滿一行,在多列的情況下,我們適時調整每個 rowData 佔據的寬度即可。
由於 React Native 使用 Flexbox 進行佈局,在實現多列時,主要用到的是 flexWrap:wrap
屬性:它的效果類似於 float,即水平地排列每一項,當放不下時進行折行處理。在設定每行檢視佔據一半寬度後就達到了兩列的效果,多列的類似。
具體示例可以參考ListViewGridLayoutExample。
滾動
ListView 只是整合了資料和展現,但實際滾動的功能還是由 ScrollView
全權負責。ScrollView 實現完全和平臺相關:在 iOS 上,它對映為 RCTScrollView
;在 Android 上,它對映為 RCTScrollView
和 AndroidHorizontalScrollView
。
React Native 讓不同端上的技術融合在了一起,同時也給開發人員提出了更高的要求。以 ScrollView 為例,大量的屬性其實原封不動對映給了 UIScrollView,這就意味著如果想再深入地研究下去,必須對客戶端相關技術有足夠了解。無論是前端還是客戶端,跳出自己熟悉的那片領域也許才是更進一步的關鍵。
談到滾動,有一點不得不說的就是 列表的無限載入,這牽涉到滾動的效能。
Github 上的這個 issue對此展開了熱烈的討論。其中有人就提到,資料量很大情況下,ListView 在載入時所佔用的 CPU 和記憶體會大大增加,滾動到最後就導致了應用 crash。
為此,ListView 中新新增了一個實驗性的屬性: removeClippedSubviews ,它能在滾動時及時刪掉列表中處於視窗的之外的行,以此達到降低記憶體消耗的目的。不幸的是,即使設定了這個屬性,程式雖然各項佔用減少了不少,但還是沒避免崩潰的命運。處於好奇,我也在最新版的 ListView 基礎上做了簡單嘗試,不斷載入一個無限大的列表,但並沒有出現崩潰的情況:
- 即使載入了 3000、4000 行,Android 真機、iOS 真機和 iOS 模擬器上都沒有崩潰;
- Android 上明顯感到資料載入有 階段性的延時 ,即滾動一定程度後,再次滾動資料始終載入不出來或要等一段時間才載入出來,體驗較差;iOS 相比要流暢的多;
但不崩潰並非最終的目的,很多 React Native 使用者都在試圖改進 ListView 的效能表現,相比於直接使用 Native 端的元件,ListView 效能還是差強人意,有很大優化空間。
總結
ListView 並沒有創造出新的東西,它只是集各家所長,很好地將 React 的檢視渲染和 Native 端很成熟的滾動機制融合在了一起,使用起來和其他元件無差,靜態地定義展現、動態地組織資料,是給人帶來的直觀感受。本文僅對 ListView 基礎用法作了簡要介紹,更為細緻的點還是要在實際使用的過程中去發現。