使用 JS 構建跨平臺的原生應用:ListView 元件介紹

發表於2015-12-09

使用 JS 構建跨平臺的原生應用:ListView 元件介紹

背景

滾動列表 幾乎是移動開發中用途最廣的 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 方法中:

在初次見到這種寫法時,心中其實疑惑這裡為什麼不一次性把整個物件 new 出來,而要拆分為兩步?

首先,如前所述,DataSource 的建構函式裡的引數只有四個,並不能直接傳入資料物件。其次,一個頁面中的滾動列表常常要不時修改資料,比如:

  • 邊滾動邊新增列表元素;
  • 展現搜尋結果時,當搜尋條件變化後,列表對應的資料物件需要重置;
  • 對列表中的結果進行篩選、排序等操作。

在這些情況下,資料來源需要做調整,但上述寫法可以使得我們在調整時,無需重新定義 rowHasChanged 等比較函式,只需變化真實的資料物件即可。

展現

基本用法

資料來源確定後,下一個工作就是列表的渲染。在渲染時發揮重要作用的是 renderRow 屬性,它接收資料來源中儲存的資料物件,並通過返回值確定該行該如何進行展現。我們可以對所有行統一進行展現,也可以根據裡面的欄位做出不同的展現。在列表包含 sectionHeader 時,還需要實現 renderSectionHeader 方法。一個簡單的例子如下:

Demo 執行的效果如下:

分頁

除了簡單的渲染之外,另外一個要考慮的問題就是 當資料量很大的時候如何分頁載入 。這種情形分兩種情況考慮:

  1. 資料一次性拿到,邊滾動邊載入
  2. 資料不是一次性拿到,而是有可能分屏取資料

對於第一種情況,在 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 上,它對映為 RCTScrollViewAndroidHorizontalScrollView

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 基礎用法作了簡要介紹,更為細緻的點還是要在實際使用的過程中去發現。

相關文章