1. 程式人生 > >【騰訊Bugly乾貨分享】跨平臺 ListView 效能優化

【騰訊Bugly乾貨分享】跨平臺 ListView 效能優化

導語

精神哥前陣子去參加了好友小青在北京辦的T沙龍,探討移動端熱更新相關的話題。Bugly 曾為大家介紹過不少騰訊內部的熱更新的框架,正好這次看到了美團,去哪兒以及微博同學在應用熱更新方面的實踐。

上週為大家整理了《美團大眾點評 Hybrid 化建設》,本週我們繼續帶來“去哪兒網 無線 iOS 技術總監”分享的《跨平臺 ListView 效能優化》。

正文

大家好!今天由我來分享《跨平臺 ListView 效能優化》主題。

先介紹一下自己,我叫姜琢,2011年加入去哪兒網,在從2013年開始負責酒店的 iOS 團隊,平時我會關注包括像跨平臺,iOS 架構以及客戶端基礎設施的一些技術方向。

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 包含兩個屬性 — initialListSizepageSizeinitialListSize決定了第一屏載入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');

裡面什麼都沒做,RecyclerViewBackedScrollViewScrollView 完全是一個東西,我覺得好像 RN 只是埋了一個坑期望社群在社群的演進中解決。

我又看了一下Android的,Android裡面的程式碼做了什麼事情呢?

就是它確實引入了一個原生的 RecycleView 來去做佈局,那麼再深入來看一下,Android在 Native程式碼中是怎麼來做的,我們我們重點看 onCreateViewHolderonBindViewHolder 的實現

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;

這兩個方法是 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 的專案。

兩種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

    @Override
    public int getItemViewType(int position) {
        return generateViewType(getChild(position));
    }

    private int generateViewType(WXComponent component) {
        long id;
        try {
            id = Integer.parseInt(component.getDomObject().getRef());
            String type = component.getDomObject().getAttrs().getScope();

            if (!TextUtils.isEmpty(type)) {
                if (mRefToViewType == null) {
                    mRefToViewType = new ArrayMap<>();
                }
                if (!mRefToViewType.containsKey(type)) {
                    mRefToViewType.put(type, id);
                }
                id = mRefToViewType.get(type);

            }
        } catch (RuntimeException e) {
           WXLogUtils.eTag(TAG, e);
           id = RecyclerView.NO_ID;
           WXLogUtils.e(TAG, "getItemViewType: NO ID, this will crash the whole render system of WXListRecyclerView");
        }
        return (int) id;
    }

然後通過 ViewType 來建立 ViewHolder,在複用時呼叫 onBindViewHolder 來更新資料

    @Override
    public void onBindViewHolder(ListBaseViewHolder holder, int position) {
        if (holder == null) return;
        holder.setComponentUsing(true);
        WXComponent component = getChild(position);
        if ( component == null
                || (component instanceof WXRefresh)
                || (component instanceof WXLoading)
                || (component.getDomObject()!=null && component.getDomObject().isFixed())
                ) {
            if(WXEnvironment.isApkDebugable()) {
                WXLogUtils.d(TAG, "Bind WXRefresh & WXLoading " + holder);
            }
            return;
        }

        if (component != null&& holder.getComponent() != null
                && holder.getComponent() instanceof WXCell) {

                holder.getComponent().bindData(component);
        }

    }

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

  public void bindData(WXComponent component){
    if(!isLazy()) {
      if (component == null) {
        component = this;
      }
      mCurrentRef = component.getDomObject().getRef();
      updateProperties(component.getDomObject().getStyles());
      updateProperties(component.getDomObject().getAttrs());
      updateExtra(component.getDomObject().getExtra());
    }
  }

結論

所以其實在這裡,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寫組建的話你們會怎麼做拆分。

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

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

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

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

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

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

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

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

還有測試。

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

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

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

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

更多精彩內容歡迎關注騰訊 Bugly的微信公眾賬號:

騰訊 Bugly是一款專為移動開發者打造的質量監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智慧合併功能幫助開發同學把每天上報的數千條 Crash 根據根因合併分類,每日日報會列出影響使用者數最多的崩潰,精準定位功能幫助開發同學定位到出問題的程式碼行,實時上報可以在釋出後快速的瞭解應用的質量情況,適配最新的 iOS, Android 官方作業系統,鵝廠的工程師都在使用,快來加入我們吧!