【騰訊Bugly干貨分享】跨平臺 ListView 性能優化

ElmoNivison 8年前發布 | 20K 次閱讀 ListView 前端技術

Qunar 的 RN 之路

回到我們今天的主題,今天的主題主要是講 Native,對于 RN,其實在做 RN 之前我們一直都在用 Hybrid。對于 Hybrid,可能在當時我們覺得沒法達到與客戶端體驗一致的效果。所以看到 Native 創新的架構出來以后,我們團隊把很多的時間放在這個上面去做一些研究。

在2016年3月份的時候,當時 RN 的版本是 0.22,我們第一個承載業務的版本上線了。

第一個業務我們做的是在酒店的客戶端首頁進去的首頁,因為這個頁面本身于酒店來說,其實還挺重要的。但在當時,要把這個頁面改成更適合于運營的一個方向。而且之前的和現在RN的代碼冗余比較大,正好考慮重構,所以把它共享出來,有一些風險,新的技術用了這么重要的一個頁面上面,所以我們當時也做了一些熱修復,或者說熱替換的這樣一個方式。

到現在為止,去哪兒旅行中酒店業務總共大約有18個頁面采用了 RN 的方案來做。這就是當前 Qunar 在 RN 上面的一些數據。

RN 的 ListView 是如何做的

剛才說我是2011年開始做 iOS,在當時作為一個 iOS 程序員可以用一句話概括:

當時所有的 APP 都是在使用 TableView 來做主要的頁面設計。

對于2016年,如果你是一個 RN 開發的話,會產生一個疑問:

如果你學會 ListView,是不是就學會開發RN了呢?

我覺得還不完全是,大家可能也看過很多 RN 性能相關的文章,都提到了 ListView 的 性能問題 ,我們需要了解這些問題產生的原因,才能更好的去優化并使用 RN。

1. RN 如何實現的 ListView?

我們先了解一下 RN 到底如何來實現 ListView 的。

首先RN的 ListView 其實是基于 RN 的 RCTScrollView 來實現的。它也實現了類似 UIKit 中通過 DataSource 來控制數據,以及是否要做一些界面的刷新。

它還有一個很重要的特性,是從 RN 的 RCTView 里面繼承的一個特性

當 removeClippedSubviews 等于 true ,listview進行滑動的時候,RN會把界面上已經移到頁面之外的從你的父視圖上面移出去,他所有在外面外的子視圖都會做 removeFromSuperView,他調的方法就是 updateClippedSubviews 。

更直觀一點看,我使用到了新的 XCode 的 View Memory Graph Hierarchy 工具,當你在屏幕上,大家可以明顯的看到,這個View會有一個 RCTView 會引用它。當這個 View 被移出屏幕之外,再觀察他的內存引用時,它就只被 RCTUIManager 引用了。

RN 為什么沒有去把這個 View 釋放掉,而是被 RCTUIManager 來持有?RN 為了能夠保持一定的 UI 上的性能,他用 UImanager 來管理所有的 UI 元素,只要創建過的,還有可能被顯示在界面上的東西,他都用這個 UImanager 來去管理,從而在進行 Dom Diff 時能夠減少 View 的創建和銷毀。

2. ListView 多做了什么?

然后,我們再來看看 ListView 本身比 RCTScrollView 多做的哪些東西,首先 ListView 包含兩個屬性 —- initialListSize 和 pageSize , initialListSize 決定了第一屏加載item的數量, pageSize 則是當你需要加載更多的時候,每次需要載入多少的item,這樣做的主要目的在盡量減少你手機加載第一屏時所需要的時間。

還有就是它還實現了從JS端實現了 Section Header,Header,Footer 的封裝,以及實現了監聽 onScroll 事件,隨著 View 的滾動動態的添加 row view。

3. 相對于 TableView 少了點什么?

那么ListView相當于UITableView少了一點什么呢?

怎么沒有提到復用?

在ListView官網上面找了一個ListView的例子,這個例子有一行,我用紅色的框標出來,他用了一個叫 RecyclerViewBackedScrollView ,如果大家對Android有一點了解的話, RecyclerView 在Android上是在列表上面用來做重用的一個控件。

4. RecyclerViewBackedScrollView 是什么?

那么 RecyclerViewBackedScrollView 是如何實現的呢?我們需要去看下他的源碼。

我們先看一下 iOS 的 JS,JS里面只有一行代碼

module.exports = require('ScrollView');

里面什么都沒做, RecyclerViewBackedScrollView 和 ScrollView 完全是一個東西,我覺得好像 RN 只是埋了一個坑期望社區在社區的演進中解決。

我又看了一下Android的,Android里面的代碼做了什么事情呢?

就是它確實引入了一個原生的 RecycleView 來去做布局,那么再深入來看一下,Android在 Native代碼中是怎么來做的,我們我們重點看 onCreateViewHolder 和 onBindViewHolder 的實現

在 onBindViewHolder 他做的一件事情,傳入 item 的 Position,從 mViews 中獲得這個row的view對象

我們再看一下 mViews 是什么東西,他是一個數組,他的元素都是在 addView 時加入到對應的 index 上的,而 index 就是 item 的 Position,說明他只是把實體的 row 通過 index 緩存起來了而已,并沒有實現復用。

解決方案

基于RN的復用問題,在去哪兒我們做了兩個方向的嘗試。

前端的同學覺得我們可以改進 RN 中 ListView 的 JS 實現,通過在 onScroll 事件中將被移除出去的 Cell Dom 元素通過 JS 把他們移動到需要復用的位置上

而客戶端的同學認為通過把 UITableView bridge 到 RN 中可以解決這個問題。

1. 用JS寫一套Cell的重用邏輯

先說說前端的想法,我們實現完了之后,它實現的方式是說,也是基于 RN 的 ScrollView,我們也監聽 OnScroll(),哪些 View 可以補上來?

他往上滑的時候,我們需要把上面的 cellComponent 挪下來,挪到上面去用。但是這個方式最終的效果并不是特別好。

缺點

問題在于,如果我們所有的 Cell 都是一樣高的,里面的元素不是很多的情況下,性能還相對好一些,我們每次 OnScroll 的時候,他處理的Cell比較少。如果你希望有一個界面滾動能夠達到流暢的話,所有的處理都需要在 16ms 內完成,但是這又造成了 onScroll 都要去刷新頁面,導致這樣的交互會非常非常多,導致你從 JS,到 native 的 bridge 要頻繁的通訊,JS 中的很多處理方式都是異步的,使得這個方案的效果沒有達到很好的預期。

我們再看看客戶端同學想出來的辦法,Bridge 一個 UITableView 到 JS 環境中。

2.Bridge 一個 UITableView

在RN中我們要 bridge 一個 RN 的 View 組件,我們需要實現 RCTComponent 這個 protocol,這里有兩個很重要的方法

- (void)insertReactSubview:(id<RCTComponent>)subview atIndex:(NSInteger)atIndex;

  • (void)removeReactSubview:(id<RCTComponent>)subview;</code></pre>

    這兩個方法是 RN 做 Dom Diff 的關鍵

    什么是Dom Diff呢

    在界面發生變化前,界面存在一個 Dom Tree,發生業務變化之后是另外一個 Dom tree,Tree中的每個元素都有自己的引用值,Diff 其實就是找出兩個 Tree 的差異點來確定需要進行更新的節點。最終確定一個需要插入和刪除的 View 的列表,并通知相應的 Dom 節點來處理。

    但是RN的UI處理方式和原生對UI處理完全不一樣,我們如何 Bridge 一個 TableView 呢,我們想到了一個方法。

    我們創建一些 VirtualView,他只是遵從了 RCTComponent 協議,他其實并不是一個真正的 View,我把它形成一個組件,把它 Bridge 到 JS,這就使得,你在寫 JSX 的時候,就可以直接用 VirtualView 來去做布局了。在RN里面做布局的時候我們用VirtualView來做布局。但是最終在 insertReactSubview 時,我們把這些 VirtualView 當做數據去處理,通過 VirtualView 和RealView 的對應關系,把它轉化成一個真實的 View 對象添加到 TableView 中去。

    用這個圖來說,更清晰一些。

    首先我們寫的是一個 JSX,React 把它轉化成 Dom Tree,在進行 Dom Diff 后,React 會調用 insertReactSubview 傳入 VirtualView,我們通過 VirtualView 生成 Tree Data,

    通過 VirtualView 和 RealView 的對應關系,我們創建 RealView 去真正的添加到原生的 View 上。

    但是這里又產生另外一個問題,大家會自定義一個 cell 的一個對象來去做的。這個對象,能夠接收你特定的數據,對這個 cell 重新去 set 一些控件的值,然后把界面更新。

    但是在JS里面我們并沒有辦法這樣做,在 RN 中,我們不可能動態的去往 Native 里面去加一個類。

    那么我們是如何做到,在復用的時候對于 Cell 上面的子View能夠去設置更新他的數據?

    我們在所有子 view 上面我們也加上了 tag 屬性,在更新數據的時候我們通過 tag 找到更新的子 view上面的 view 對他做數據的更新的。所以并不是只有Cell有這樣的tag,包括子 view 也會有這樣的 tag,這樣就做到了可以獲取到對應 tag 的子 view 并對子 view 的數據進行更新。

    最后,為了客戶端的同學在使用這個 TableView 時更好上手一些,我們把幾乎整套的 TableViewDataSource 方法,全部照搬到了 RN 中,所以我們在創建這個 ListView 的時候我們需要去設置很多的回調方法,這樣做也是為了能夠更快的做一些界面的遷移工作。

    缺點

    前面說了這個東西怎么來做的,我們來說一下這個東西的缺點,或者說他的限制,首先既然它需要做映射,我們肯定需要做一個 Virtualview 到 NativeView,大多數的 cell 里面如果做展示來用的話,Label 和 Image 基本上能夠滿足大多數的需求了。所以我們現在只是做了 Label 和 Image 的對應工作,但在RN的一些官方控件,在這個 view 里面都是沒法直接使用的。

    還有一個缺點就是說,因為我們是按照 TableView 的邏輯去做的,這個邏輯其實在 Android 上可能不適用,因為 Android 的 ListView 實現跟iOS完全不是一個邏輯,導致使用這個 ListView 的 RN 代碼,可能沒法直接應用到 Android 里面去。

    關于這個控件的話,其實在我們首頁的兩個子頁面上都有使用,一個是酒店的城市的頁面,還有酒店的整個收藏的頁面。

    關于 Tableview 往 ListView 上過渡,還有一個 github 的項目。

    react-native-tableview

    https://github.com/aksonov/react-native-tableview

    兩種UITableView實現差別

    同樣是 Bridge UITableView,這個開源項目跟我們的實現方式還有一點差別,它在考慮使用組建這塊的時候,對于每一個 Tableview,他都是用 RCTRootView 做基礎的 contentView,他對于每一個 cell,他都有一套 JS 和 Native 的 Bridge。我們就覺得這樣的方式稍微來說有點重。但是它的好處在于,在RN里面所有我們注冊的控件都是直接可以使用的,相對來說靈活性更強。

    這個開源組件還有一個復雜的地方在于,對于每一個重用 cell,我們在去做寫RN的代碼的時候,我們都要注冊到 RN 的 AppRegistry 里面去,他需要注冊組建把它當做一個獨立的組建去使用。

    這里有一個截圖,他需要注冊每一個 TableviewCell 去做他的組建。

    Weex 的 ListView 又是如何做的?

    最后我們來看一看 weex 在 RN 的基礎上做了優化開發以及優化更多的思考。

    weex 的 ListView 是通過原生來實現的,而且它是在Android和iOS兩端都是原生的,即使是兩個平臺實現不太一致的地方也在 JS 端進行了統一,比如 iOS 的 Section Header,Android SDK 中沒有相關的實現,weex 就引入了 StikyHeader 來實現。

    那么Weex實現Cell復用了么?

    回到剛才說的復用問題,Weex 到底有沒有實現復用呢?

    我們跟著代碼看一下,這個是weex 在 iOS 上的實現。

    在 cellForRowAtIndexPath 中,weex 使用了統一的 reuseIdentifier。但我們注意這樣一個方法

    WXCellComponent *cell = [self cellForIndexPath:indexPath];

    通過 indexPath 拿到一個 cell,會不會里面實現了復用呢?

    這段代碼也只是通過 Section 和 Row 獲取到了一個 CellComponent 對象。所以他仍然只是一個緩存,那么緩存,他就是把所有的 Cell 都緩存起來而已。它仍然沒有達到復用的一個效果。

    但是后來我又看了看Android, Android的實現有些不同

    首先它用了 recyclerView,我們找到了 weex 實現的一個方法 generateViewType

    在 weex 代碼里面從 JS 端可以設置一個叫做 scope 的一個屬性,Recycview會調用 getItemViewType` 來獲取對應 position 的 viewType

    然后通過 ViewType 來創建 ViewHolder,在復用時調用 onBindViewHolder 來更新數據

    我們再進入到 Component 的 bindData 方法,發現他最終通過 updateProperties 將 Component的屬性設置到 ViewHolder 的子控件上

    結論

    所以其實在這里,weex 在 Android 上最終解決了這個復用的問題。

    總結

    最后做一個簡單的總結,大概前面說了這么多種方法,一個是包括,首先說RN的方法,說了我們在做JS上面做 RecycleView 的方法,還有我們在 Native 上面拓展 UITableView。

    從性能上來看,因為從順序上來說,我覺得我們客戶端實現的那個相對來說比較好一點,因為它用的這個相對來說,從內存上面來說,占用比這個上面更少一些,但是這個也要看需求。weex 本身會比 RN 以及用JS端的實現更好。

    從跨平臺上來看,其實RN和JS去實現的跨平帶上做的更好一些,原因是它純粹是 JS 實現,JS 在各個平臺上只有性能的差異,不會有實現的差異。其次是 weex,能夠做到在兩個端實現同樣的代碼,但是兩端的性能上是有差異的。

    再其次就是React,以及最后我們在客戶端實現的,大概就是這樣的情況。

    我今天的分享就到這兒,大家看看有沒有什么問題。

    互動問答

    Q1:像咱們這套是基于RN最新的版本去進行開發的是吧?

    姜琢:我們就做RN的時候,其實這個是一個很大的困擾的點,因為RN本身官方的代碼不斷去更新,然后后面我們不可能說每次RN代碼Cell我們都跟著更新,導致每次框架更新一次,導致整個測試成本成倍的提升,如果每次更新每次都要做一次回歸的話很耗費時間。

    Q2:咱們大概有哪些策略?

    姜琢:最開始我們去改一些官方的框架的時候,可能稍微會有一些,相對來說改會有一點問題。現在的話,我們盡量的把不去侵入整個RN本身,即使是有些侵入的東西,我們也盡量保證在他核心代碼的里面做最少的改動,把它傳到外部插件中去,保證以后在Merge的時候,最好工作量的去完成。但是每一次回歸仍然是必要的,或者我們也會去關注每次更新的時可能會產生一些問題,對于測試可能會更多的去關注。

    Q3:咱們RN之前做過版本的回顧,剛才講RN遇到一個很大的問題,這個是一個什么方式呢?

    姜琢:這是純組件,侵入主要涉及到RN本身的一些JS加載這塊的東西,以及包括更新這塊的東西。

    關于這個分享以及本身RN,因為這個分享準備的還相對補是特別充分,所以可能講的時候稍微漏了一些問題。大家可以看一下,剛才我提到的,在去哪兒旅行酒店里面的兩個模塊,去體驗一下本身用bridge的方式去希望實現建構的一個效果。

    Q4:能不能切到剛才給的三個截圖?拿首頁這個來說的話,里頭如果用RN寫組建的話你們會怎么做拆分。

    【騰訊Bugly干貨分享】跨平臺 ListView 性能優化

    姜琢:按照Native的方式,因為這個是這樣的,相對來說,從首頁上來說這個頁面還不是很長。下面推薦的內容沒有特別特別的多,運營的內容沒有那么多。對于這種,這種不是太有復用性的這種,用ScrollView來實現就好了

    Q5:你們整個界面全都是用RN,有沒有Native跟RN混用的界面。

    姜琢:現在應該沒有,但是同一個布局的界面里頭不會說上面是Native,下面是RN的這種情況。其實我覺得,反正跨平臺這塊,其實總游離在一個相對來說比較尷尬的一個位置。不管Hybrid還是RN,原因是大家主要是不清楚谷歌和蘋果以后會走一個什么樣的路線。他們倆,因為現在從各種開發的SDK上面的話,越來越體現出這種差異化。不是往一個統一化的方式來走。大家都是考慮自己平臺上的東西來去做這個SDK,就會導致說跨平臺的東西很難去說能夠絕對的對于所有的需求都能夠達到統一。

    然后我還提一點,非死book和wexx兩個公司考慮的一個方式可能不太一樣,非死book是覺得,我做一個框架,我應該去實現能夠達到所有的需求的一個目的。weex并不完全是,他考慮在現有的情況下的應用,他在做ListView的時候,并不是像非死book做一個特別通用性的,它相對來說保證性能的條件下能夠達到最大的業務的適用范圍。雖然RN性能不怎么樣,但是他可以實現你現在所有的需求。

    Q6:我再問一個問題,你剛才開始也講了,現在是iOS開發也會寫RN代碼?

    姜琢:其實這塊的話,我們最開始做RN有一個特別大的原因就是說,因為去哪兒網之前是在web上面起家,而且web上面的業務非常多。其實在公司內部前端的同學比客戶端的同學更多的。但是整個的流量,從web端往客戶端去切的話,人不可能那么快去切換。所以會涉及到的一個問題,前端的同學如何參與客戶端開發的問題,最開始都是客戶端的同學,之后我們前端的同學能否加入進來。相對來說技術比較好一點的,其實做RN我覺得是沒問題的。確實這個東西所需要學習成本對兩個端的同學都不少,客戶端學前端可能比前端學客戶端還要難。前端主要考慮的就是說,他學客戶端他只需要關注UI本身就可以了,因為邏輯都是前端來寫的,所以他主要關注UI上面展示和前端的有什么差異性。但是客戶端去了解前端,其實從JS本身的輪子就太多了。而且我感覺還有一點JS代碼可讀性和iOS其實差挺多的。JS都會也需要進行打包轉譯,你寫的時候是一種樣子,運行的時候是另外一個樣子。

    Q7:咱們JS這塊的代碼的質量是怎么保證的?比如說我們客戶端可能Native的代碼我們可以通過各種測試,各種檢查,讓它保證一定的安全性,JS這塊怎么保證的?

    姜琢:這塊確實現在還沒有一個嚴格的規定吧。但是相對來說,因為基本上都是客戶端里相對來說學習能力比較強的人,前端也是相對學習能力比較強的人在做這件事兒,相對來說,從人上還過得去。

    還有測試。

    追問:有測試,等于自動化測試現在覆蓋的還不是那么的多是嗎?

    姜琢:對,是,本身客戶端的自動化測試還有前端的自動化測試都沒法保證特別全面,因為本身測試的case的成本也不低。不過現在確實,應該主流的大多數公司都在做了。我們也會有一些,但是主要不會做一些新功能的測試,所以真的有人寫了一個特別不太好測的這種,可能確實不太好弄。而且這個RN,如果要測試的話,他相當于跨了兩個平臺,需要保證代碼質量,但是它又不像現有的這種,反正現有的前端的檢測工具,不一定能查得出來。

    追問:等于說在發版之前可能做一些,在RN的代碼發版之前可能要做一些基本的測試?

    姜琢:對。應該其實對于,像美團跟去哪兒,所有的東西你改一行代碼都要測的,除非是非主要業務,只要是主要業務肯定都是要測的。

     

     

    來自:https://mp.weixin.qq.com/s/FbiSLPxFdGqJ00WgpJ94yw

     

 本文由用戶 ElmoNivison 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!