IM開發乾貨分享:萬字長文,詳解IM“訊息“列表卡頓優化實踐

JackJiang發表於2021-10-27

本文由融雲技術團隊原創分享,原題“萬字乾貨:IM “訊息”列表卡頓優化實踐”,為使文章更好理解,內容有修訂。

1、引言

隨著移動網際網路的普及,無論是IM開發者還是普通使用者,IM即時通訊應用在日常使用中都是必不可少的,比如:熟人社交的某信、IM活化石的某Q、企業場景的某釘等,幾乎是人人必裝。

以下就是幾款主流的IM應用(看首頁就知道是哪款,我就不廢話了):

正如上圖所示,這些IM的首頁(也就是“訊息”列表介面)對於使用者來說每次開啟應用必見的。隨著時間和推移,這個首頁“訊息”列表裡的內容會越來越多、訊息種類也越來越雜。

無論哪款IM,隨著“訊息”列表裡資料量和型別越來越多,對於列表的滑動體驗來說肯定會受到影響。而作為整個IM的“第一頁”,這個列表的體驗如何直接決定了使用者的第一印象,非常重要!

有鑑於此,市面上的主流IM對於“訊息”列表的滑動體驗(主要是卡頓問題)問題,都會特別關注並著重優化。

本文將要分享是融雲IM技術團隊基於對自有產品“訊息”列表卡頓問題的分析和實踐(本文以Andriod端為例),為你展示一款IM在解決類似問題時的分析思路和解決方案,希望能帶給你啟發。

特別說明:本文優化實踐的產品原始碼可以從公開渠道獲取到,感興趣的讀者可以從本文“附錄1:原始碼下載”下載,建議僅用於研究學習目的哦。

學習交流:

  • 即時通訊/推送技術開發交流5群:215477170 [推薦]
  • 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
  • 開源IM框架原始碼:https://github.com/JackJiang2...

(本文已同步釋出於:http://www.52im.net/thread-37...

2、相關文章

IM客戶端優化相關文章:

《IM開發乾貨分享:我是如何解決大量離線訊息導致客戶端卡頓的》
《IM開發乾貨分享:網易雲信IM客戶端的聊天訊息全文檢索技術實踐》
《融雲技術分享:融雲安卓端IM產品的網路鏈路保活技術實踐》
《阿里技術分享:閒魚IM基於Flutter的移動端跨端改造實踐》

融雲技術團隊分享的其它文章:

《融雲IM技術分享:萬人群聊訊息投遞方案的思考和實踐》
《融雲技術分享:全面揭祕億級IM訊息的可靠投遞機制》
《IM訊息ID技術專題(三):解密融雲IM產品的聊天訊息ID生成策略》
《即時通訊雲融雲CTO的創業經驗分享:技術創業,你真的準備好了?》
《融雲技術分享:基於WebRTC的實時音視訊首幀顯示時間優化實踐》

3、技術背景

對於一款 IM 軟體來說,“訊息”列表是使用者首先接觸到的介面,“訊息”列表滑動是否流暢對使用者的體驗有著很大的影響。

隨著功能的不斷增加、資料累積,“訊息”列表上要展示的資訊也越來越多。

我們發現,產品每使用一段時間後,比如打完 Call 返回到“訊息”列表介面進行滑動時,會出現嚴重的卡頓現象。

於是我們開始對“訊息”列表卡頓情況進行了詳細的分析,期待找出問題的根源,並使用合適的解決手段來優化。

PS:本文所討論產品的原始碼可以從公開渠道獲取到,感興趣的讀者可以從本文“附錄1:原始碼下載”下載。

4、到底什麼是卡頓?

提到APP的卡頓,很多人都會說是因為在UI 16ms 內無法完成渲染導致的。

那麼為什麼需要在 16ms 內完成呢?以及在 16ms 以內需要完成什麼工作?

帶著這兩個問題,在本節我們來深入地學習一下。

4.1 重新整理率(RefreshRate)與幀率(FrameRate)
重新整理率:指的是螢幕每秒重新整理的次數,是針對硬體而言的。目前大部分的手機重新整理率都在 60Hz(螢幕每秒鐘重新整理 60 次),有部分高階機採用的 120Hz(比如 iPad Pro)。

幀率:是每秒繪製的幀數,是針對軟體而言的。通常只要幀率與重新整理率保持一致,我們看到的畫面就是流暢的。所以幀率在 60FPS 時我們就不會感覺到卡。

那麼重新整理率和幀率之間到底有什麼關係呢?

舉個直觀的例子你就懂了:

如果幀率為每秒鐘 60 幀,而螢幕重新整理率為 30Hz,那麼就會出現螢幕上半部分還停留在上一幀的畫面,螢幕的下半部分渲染出來的就是下一幀的畫面 —— 這種情況被稱為畫面【撕裂】。相反,如果幀率為每秒鐘 30 幀,螢幕重新整理率為 60Hz,那麼就會出現相連兩幀顯示的是同一畫面,這就出現了【卡頓】。

所以單方面的提升幀率或者重新整理率是沒有意義的,需要兩者同時進行提升。

由於目前大部分 Android 機螢幕都採用的 60Hz 的重新整理率,為了使幀率也能達到 60FPS,那麼就要求在 16.67ms 內完成一幀的繪製(即:1000ms/60Frame = 16.666ms / Frame)。

4.2 垂直同步技術
由於顯示器是從最上面一行畫素開始,向下逐行重新整理,所以從最頂端到最底部的重新整理是有時間差的。

常見的有兩個問題:

1)如果幀率(FPS)大於重新整理率,那麼就會出現前文提到的畫面撕裂;
2)如果幀率再大一點,那麼下一幀的還沒來得及顯示,下下一幀的資料就覆蓋上來了,中間這幀就被跳過了,這種情況被稱為跳幀。

為了解決這種幀率大於重新整理率的問題,引入了垂直同步的技術,簡單來說就是顯示器每隔 16ms 傳送一個垂直同步訊號(VSYNC),系統會等待垂直同步訊號的到來,才進行一幀的渲染和緩衝區的更新,這樣就把幀率與重新整理率鎖定。

4.3 系統是如何生成一幀的
在 Android4.0 以前:處理使用者輸入事件、繪製、柵格化都由 CPU 中應用主執行緒執行,很容易造成卡頓。主要原因在於主執行緒的任務太重,要處理很多事件,其次 CPU 中只有少量的 ALU 單元(算術邏輯單元),並不擅長做圖形計算。

Android4.0 以後應用預設開啟硬體加速。

開啟硬體加速以後:CPU 不擅長的影像運算就交給了 GPU 來完成,GPU 中包含了大量的 ALU 單元,就是為實現大量數學運算設計的(所以挖礦一般用 GPU)。硬體加速開啟後還會將主執行緒中的渲染工作交給單獨的渲染執行緒(RenderThread),這樣當主執行緒將內容同步到 RenderThread 後,主執行緒就可以釋放出來進行其他工作,渲染執行緒完成接下來的工作。

那麼完整的一幀流程如下:

如上圖所示:

1)首先在第一個 16ms 內,顯示器顯示了第 0 幀的內容,CPU/GPU 處理完第一幀;
2)垂直同步訊號到來後,CPU 馬上進行第二幀的處理工作,處理完以後交給 GPU(顯示器則將第一幀的影像顯示出來)。

整個流程看似沒有什麼問題,但是一旦出現幀率(FPS)小於重新整理率的情況,畫面就會出現卡頓。

圖上的 A 和 B 分別代表兩個緩衝區。因為 CPU/GPU處理時間超過了 16ms,導致在第二個 16ms 內,顯示器本應該顯示 B 緩衝區中的內容,現在卻不得不重複顯示 A 緩衝區中的內容,也就是掉幀了(卡頓)。

由於 A 緩衝區被顯示器所佔用,B 緩衝區被 GPU 所佔用,導致在垂直同步訊號 (VSync) 到來時 CPU 沒辦法開始處理下一幀的內容,所以在第二個 16ms內,CPU 並沒有觸發繪製工作。

4.4 三緩衝區(Triple Buffer)
為了解決幀率(FPS)小於螢幕重新整理率導致的掉幀問題,Android4.1 引入了三級緩衝區。

在雙緩衝區的時候,由於 Display 和 GPU 各佔用了一個緩衝區,導致在垂直同步訊號到來時 CPU 沒有辦法進行繪製。那麼現在新增一個緩衝區,CPU 就能在垂直同步訊號到來時進行繪製工作。

在第二個 16ms 內,雖然還是重複顯示了一幀,但是在 Display 佔用了 A 緩衝區,GPU 佔用了 B 緩衝區的情況下,CPU 依然可以使用 C 緩衝區完成繪製工作,這樣 CPU 也被充分地利用起來。後續的顯示也比較順暢,有效地避免了 Jank 進一步的加劇。

通過繪製的流程我們知道,出現卡頓是因為掉幀了,而掉幀的原因在於垂直同步訊號到來時,還沒有準備好資料用於顯示。所以我們要處理卡頓,就要儘量縮短 CPU/GPU 繪製的時間,這樣就能保證在 16ms 內完成一幀的渲染。

5、卡頓問題分析

5.1 在中低端手機中的卡頓效果
有了以上的理論基礎,我們開始分析“訊息”列表卡頓的問題。由於 Boss 使用的 Pixel5 屬於高階機,卡頓並不明顯,我們特意從測試同學手中借來了一臺中低端機。

這臺中低端機的配置如下:

先看一下優化之前的效果:

果然是很卡,看看手機重新整理率是多少:

是 60Hz 沒問題。

去高通網站上查詢一下 SDM450 具體的架構:

可以看該手機的 CPU 是 8 核 A53 Processor:

A53 Processor 一般在大小核架構中當作小核來使用,其主要作用是省電,那些效能要求很低的場景一般由它們負責,比如待機狀態、後臺執行等,而A53 也確實把功耗做到了極致。

在三星 Galaxy A20s 手機上,全都採用該 Processor,並且沒有大核,那麼處理速度自然不會很快,這也就要求我們的 APP 優化得更好才行。

在有了對手機大致的瞭解以後,我們使用工具來檢視一下卡頓點。

5.2 分析一下卡頓點
首先開啟系統自帶的 GPU 呈現模式分析工具,對“訊息”列表進行檢視。

可以看見直方圖已經高出了天際。在圖中最下面有一條綠色的水平線(代表16ms),超過這條水平線就有可能出現掉幀。

根據 Google 給出的顏色對應表,我們來看看耗時的大概位置。

首先我們要明確,雖然該工具叫 GPU 呈現模式分析工具,但是其中顯示的大部分操作發生在 CPU 中。

其次根據顏色對照表大家可能也發現了,谷歌給出的顏色跟真機上的顏色對應不上。所以我們只能判斷耗時的大概位置。

從我們的截圖中可以看見,綠色部分佔很大比例,其中一部分是 Vsync 延遲,另外一部分是輸入處理+動畫+測量/佈局。

Vsync 延遲圖示中給出的解釋為兩個連續幀之間的操作所花的時間。

其實就是 SurfaceFlinger 在下一次分發 Vsync 的時候,會往 UI 執行緒的 MessageQueue 中插入一條 Vsync 到來的訊息,而該訊息並不會馬上執行,而是等待前面的訊息被執行完畢以後,才會被執行。所以 Vsync 延遲指的就是 Vsync 被放入 MessageQueue 到被執行之間的時間。這部分時間越長說明 UI 執行緒中進行的處理越多,需要將一些任務分流到其他執行緒中執行。

輸入處理、動畫、測量/佈局這部分都是垂直同步訊號到達並開始執行 doFrame 方法時的回撥。

void doFrame(long frameTimeNanos, int frame) {
//...省略無關程式碼

  try{
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame");

        AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);

        mFrameInfo.markInputHandlingStart();

        //輸入處理

        doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);

        mFrameInfo.markAnimationsStart();

        //動畫

        doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);

        doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);

        mFrameInfo.markPerformTraversalsStart();

        //測量/佈局

        doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);

        doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);

    } finally{
        AnimationUtils.unlockAnimationClock();

        Trace.traceEnd(Trace.TRACE_TAG_VIEW);

    }

}

這部分如果比較耗時,需要檢查是否在輸入事件回撥中是否執行了耗時操作,或者是否有大量的自定義動畫,又或者是否佈局層次過深導致測量 View 和佈局耗費太多的時間。

6、具體優化方案及實踐總結

6.1 非同步執行
有了大概的方向以後,我們開始對“訊息”列表進行優化。

在問題分析中,我們發現 Vsync 延遲佔比很大,所以我們首先想到的是將主執行緒中的耗時任務剝離出來,放到工作執行緒中執行。為了更快地定位主執行緒方法耗時,可以使用滴滴的 Dokit 或者騰訊的 Matrix 進行慢函式定位。

我們發現在“訊息”列表的 ViewModel 中,使用了 LiveData 訂閱了資料庫中使用者資訊表的變更、群資訊表的變更、群成員表的變更。只要這三張表有變化,都會重新遍歷“訊息”列表,進行資料更新,然後通知頁面重新整理。

這部分邏輯在主執行緒中執行,耗時大概在 80ms 左右,如果“訊息”列表多,資料庫表資料變更大,這部分的耗時還會增加。

mConversationListLiveData.addSource(getAllUsers(), new Observer<List<User>>() {

       @Override

       public void onChanged(List<User> users) {
           if(users != null&& users.size() > 0) {
               //遍歷“訊息”列表

               Iterator<BaseUiConversation> iterable = mUiConversationList.iterator();

               while(iterable.hasNext()) {
                   BaseUiConversation uiConversation = iterable.next();

                   //更新每個item上使用者資訊

                   uiConversation.onUserInfoUpdate(users);

               }

               mConversationListLiveData.postValue(mUiConversationList);

           }

       }

   });

既然這部分比較耗時,我們可以將遍歷更新資料的操作放到子執行緒中執行,執行完畢以後再呼叫 postValue 方法通知頁面進行重新整理。

我們還發現每次進入“訊息”列表時都需要從資料庫中獲取“訊息”列表資料,載入更多時也會從資料庫中讀取會話資料。

讀取到會話資料以後,我們會對獲取到的會話進行過濾操作,比如不是同一個組織下的會話則應該過濾掉。

過濾完成以後會進行去重:

1)如果該會話已經存在,則更新當前會話;
2)如果不存在,則建立一個新的會話並新增到“訊息”列表。
然後還需要對“訊息”列表按一定規則進行排序,最後再通知 UI 進行重新整理。

這部分的耗時為 500ms~600ms,並且隨著資料量的增大耗時還會增加,所以這部分必須放到子執行緒中執行。

但是這裡必須注意執行緒安全問題,否則會出現資料多次被新增,“訊息”列表上出現多條重複的資料。

6.2 增加快取
在檢查程式碼的時候,我們發現有很多地方會獲取當前使用者的資訊,而當前使用者資訊儲存在了本地 SP 中(後改為MMKV),並且以 Json 格式儲存。那麼在獲取使用者資訊的時候會從 SP 中先讀取出來(IO 操作),再反序列化為物件(反射)。

/**

  • 獲取當前使用者資訊

*/

public UserCacheInfo getUserCache() {

  try{
      String userJson = sp.getString(Const.USER_INFO, "");

      if(TextUtils.isEmpty(userJson)) {
          return null;

      }

      Gson gson = newGson();

      UserCacheInfo userCacheInfo = gson.fromJson(userJson, UserCacheInfo.class);

      returnuserCacheInfo;

  } catch(Exception e) {
      e.printStackTrace();

  }

  return null;

}

每次都這樣獲取當前使用者的資訊會非常的耗時。

為了解決這個問題,我們將第一次獲取的使用者資訊進行快取,如果記憶體中存在當前使用者的資訊則直接返回,並且在每次修改當前使用者資訊的時候,更新記憶體中的物件。

/**

  • 獲取當前使用者資訊

*/

public UserCacheInfo getUserCacheInfo(){

  //如果當前使用者資訊已經存在,則直接返回

  if(mUserCacheInfo != null){
      return  mUserCacheInfo;

  }

  //不存在再從SP中讀取

  mUserCacheInfo = getUserInfoFromSp();

  if(mUserCacheInfo == null) {
      mUserCacheInfo = newUserCacheInfo();

  }

  return mUserCacheInfo;

}

/**

  • 儲存使用者資訊

*/

public void saveUserCache(UserCacheInfo userCacheInfo) {

  //更新快取物件

  mUserCacheInfo = userCacheInfo;

  //將使用者資訊存入SP

  saveUserInfo(userCacheInfo);

}

6.3 減少重新整理次數
在這個方案裡,一方面要減少不合理的重新整理,另外一方面要將部分全域性重新整理改為區域性重新整理。

在“訊息”列表的 ViewModel 中,LiveData 訂閱了資料庫中使用者資訊表的變更、群資訊表的變更、群成員表的變更。只要這三張表有變化,都會重新遍歷“訊息”列表,進行資料更新,然後通知頁面重新整理。

邏輯看似沒問題,但是卻把通知頁面重新整理的程式碼寫在迴圈當中,也就是每更新完一條會話資料,就通知頁面重新整理一次,如果有 100 條會話就需要重新整理 100 次。

mConversationListLiveData.addSource(getAllUsers(), new Observer<List<User>>() {

       @Override

       public void onChanged(List<User> users) {
           if(users != null&& users.size() > 0) {
               //遍歷“訊息”列表

               Iterator<BaseUiConversation> iterable = mUiConversationList.iterator();

               while(iterable.hasNext()) {
                   BaseUiConversation uiConversation = iterable.next();

                   //更新每個item上使用者資訊

                   uiConversation.onUserInfoUpdate(users);

                   //未優化前的程式碼,頻繁通知頁面重新整理

                   //mConversationListLiveData.postValue(mUiConversationList);

               }

               mConversationListLiveData.postValue(mUiConversationList);

           }

       }

   });

優化方法就是:將通知頁面重新整理的程式碼提取到迴圈外面,等待資料更新完畢以後重新整理一次即可。

我們 APP 裡面有個草稿功能,每次從會話裡出來,都需要判斷會話的輸入框中是否存在未刪除文字(草稿),如果有,則儲存起來並在“訊息”列表上顯示【Draft】+內容,使用者下次再進入會話後將草稿還原。由於草稿的存在,每次從會話退回到“訊息”列表都需要重新整理一下頁面。在未優化之前,此處採用的是全域性重新整理,而我們其實只需要重新整理剛剛退出的會話對應的 item 即可。

對於一款 IM 應用,提醒使用者訊息未讀是一個常見的功能。在“訊息”列表的使用者頭像上面會顯示當前會話的訊息未讀數,當我們進入會話以後,該未讀數需要清零,並且更新“訊息”列表。在未優化之前,此處採用的也是全域性重新整理,這部分其實也可以改為重新整理單條 item。

我們的 APP 新增了一個叫做 typing 的功能,只要有使用者在會話裡面正在輸入文字,在“訊息”列表上就會顯示某某某 is typing...的文案。在未優化之前,此處也是採用列表全域性重新整理,如果在好幾個會話中同時有人 typing,那麼基本上整個“訊息”列表就會一直處於重新整理的狀態。所以此處也改為了區域性重新整理,只重新整理當前有人 typing 的會話 item。

6.4 onCreateViewHolder 優化

在分析 Systrace 報告時,我們發現了上圖中這種情況:一次滑動伴隨著大量的 CreateView 操作。

為什麼會出現這種情況呢?

我們知道 RecyclerView 本身是存在快取機制的,滑動中如果新展示的 item 佈局跟老的一致,就不會再執行 CreateView,而是複用老的 item,執行 bindView 來設定資料,這樣可減少建立 view 時的 IO 和反射耗時。

那麼這裡為什麼跟預期不一樣呢?

我們先來看看 RecyclerView 的快取機制。

RecyclerView 有4級快取,我們這裡只分析常用的 2級:

1)mCachedViews;
2)mRecyclerPool。
mCachedViews 的預設大小為 2,當 item 剛剛被移出螢幕可視範圍時,item 就會被放入 mCachedViews 中,因為使用者很可能再重新將 item 移回到螢幕可視範圍,所以放入 mCachedViews 中的 item 是不需要重新執行 createView 和 bindView 操作的。

mCachedViews 中採用 FIFO 原則,如果快取數量達到最大值,那麼先進入的 item 會被移出並放入到下一級快取中。

mRecyclerPool 是 RecycledViewPool 型別,其中根據 item 型別建立對應的快取池,每個快取池預設大小為 5,從 mCachedViews 中移除的 item 會被清除掉資料,並根據對應的 itemType 放入到相應的快取池中。

這裡有兩個值得注意的地方:

1)第一個就是 item 被清除了資料,這意味著下次使用這個 item 時需要重新執行 bindView 方法來重設資料;
2)另外一個就是根據 itemType 的不同,會存在多個快取池,每個快取池的大小預設為 5,也就是說不同型別的 item 會放入不同的緩衝池中,每次在顯示新的 item 時會先找對應型別的快取池,看裡面是否有可以複用的 item,如果有則直接複用後執行 bindView,如果沒有則要重新建立 view,需要執行 createView 和 bindView 操作。
Systrace 報告中出現大量的 CreateView,說明在複用 item 時出現了問題,導致每次顯示新的 item 都需要重新建立。

我們來考慮一種極端場景,我們“訊息”列表中分為 3 種型別的 item:

1)群聊 item;
2)單聊 item;
3)密聊 item。
我們一屏能展示 10 個 item。其中前 10 個 item 都是群聊型別。從 11 個開始到 20 個都是單聊 item,從 21 個到 30 個都是密聊 item。

從圖中我們可以看到群聊 1 和群聊 2 已經被移出了螢幕,這時候會被放入 mCachedViews 快取中。而單聊 1 和單聊 2 因為在 mRecyclerPool 的單聊快取池中找不到可以複用的 item,所以需要執行 CreateView 和 BindView 操作。

由於之前移出螢幕的都是群聊,所以單聊 item 進入時一直沒用辦法從單聊快取池中拿到可以複用的 item,所以一直需要 CreateView 和 BindView。

直到單聊 1 進入到快取池,也就是上圖所示,如果即將進入螢幕的是單聊 item 或者群聊 item,都是可以複用的,可惜進來的是密聊,由於密聊快取池中沒用可以複用的 item,所以接下來進入螢幕的密聊 item 也都需要執行 CreateView 和 BindView。整個 RecyclerView 的快取機制在這種情況下,基本失效。

這裡額外提一句,為什麼群聊快取池中是群聊 1 ~ 群聊 5,而不是群聊 6 ~ 群聊 10?這裡不是畫錯了,而是 RecyclerView 判斷,在快取池滿了的情況下,就不會再加入新的 item。

/**

   * Add a scrap ViewHolder to the pool.

   * <p>

   * If the pool is already full for that ViewHolder's type, it will be immediately discarded.

   *

   * @param scrap ViewHolder to be added to the pool.

   */

  public void putRecycledView(ViewHolder scrap) {
      final int viewType = scrap.getItemViewType();

      final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;

      //如果快取池大於等於最大可快取數,則返回

      if(mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
          return;

      }

      if(DEBUG && scrapHeap.contains(scrap)) {
          throw new  IllegalArgumentException("this scrap item already exists");

      }

      scrap.resetInternal();

      scrapHeap.add(scrap);

  }

到這裡也就可以解釋,為什麼我們從 Systrace 報告中發現瞭如此多的 CreateView。知道了問題所在,那麼我們就需要想辦法解決。多次建立 View 主要是因為複用機制失效或者沒有很好的運作導致,而失效的原因主要在於我們同時有 3 種不同的 item 型別,如果我們能將 3 種不同的 item 變為一種,那麼我們就能在單聊 4 進入螢幕時,從快取池中拿到可以複用的 item,從而省去 CreateView 的步驟,直接 BindView 重置資料。

有了思路以後,我們在檢查程式碼時發現,無論是群聊、單聊還是密聊,使用的都是同一個佈局,完全可以採用同一個 itemType。以前之所以分開,是因為使用了一些設計模式,想讓群聊、單聊、密聊在各自的類中實現,也方便以後如果有新的擴充套件會更方便清晰。

這時候就需要在效能和模式上有所取捨,但是仔細一想,“訊息”列表上面不同型別的聊天,佈局基本是一致的,不同聊天型別僅僅在 UI 展示上有所不同,這些不同我們可以在 bindView 時重新設定。

我們在註冊的時候只註冊 BaseConversationProvider,這樣 itemType 型別就只有這一個。GroupConversationProvider、PrivateConversationProvider、SecretConversationProvider 都繼承於 BaseConversationProvider 類,onCreateViewHolder 方法只在 BaseConversationProvider 類實現。

在 BaseConversationProvider 類中包含一個 List,用於儲存 GroupConversationProvider、PrivateConversationProvider、SecretConversationProvider 這三個物件,在執行執行 bindViewHolder 方法時,先執行父類的方法,在這裡面處理一些三種聊天型別公共的邏輯,比如頭像、最後一條訊息傳送的時間等,處理完畢以後通過 isItemViewType 判斷當前是哪種聊天,並且呼叫相應的子類 bindViewHolder 方法,進行子類特有的資料處理。這裡需要注意重用時導致的頁面顯示錯誤,比如在密聊中修改了會話標題的顏色,但是由於 item 的複用,導致群聊的會話標題顏色也改變了。

經過改造以後,我們就可以省去大量 的CreateView 操作(IO+反射),讓 RecyclerView 的快取機制可以良好的執行。

6.5 預載入+全域性快取
雖然我們減少了 CreateView 的次數,但是我們在首次進入時第一屏還是需要 CreateView,並且我們發現 CreateView 的耗時也挺長。

這部分時間能不能優化掉?

我們首先想到的是在 onCreateViewHolder 時採用非同步載入佈局的方式,將 IO、反射放在子執行緒來做,後來這個方案被去掉了(具體原因後文會說)。如果不能非同步載入,那麼我們就考慮將建立 View 的操作提前來執行並且快取下來。

我們首先建立了一個 ConversationItemPool 類,該類用於在子執行緒中預載入 item,並且將它們快取起來。當執行 onCreateViewHolder 時直接從該類中獲取快取的 item,這樣就可以減少 onCreateViewHolder 執行耗時。

/**

   * Add a scrap ViewHolder to the pool.

   * <p>

   * If the pool is already full for that ViewHolder's type, it will be immediately discarded.

   *

   * @param scrap ViewHolder to be added to the pool.

   */

  public void putRecycledView(ViewHolder scrap) {
      final  int viewType = scrap.getItemViewType();

      final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;

      //如果快取池大於等於最大可快取數,則返回

      if(mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
          return;

      }

      if(DEBUG && scrapHeap.contains(scrap)) {
          throw new IllegalArgumentException("this scrap item already exists");

      }

      scrap.resetInternal();

      scrapHeap.add(scrap);

  }

ConversationItemPool 中我們使用了一個執行緒安全佇列來快取建立的 item。由於是全域性快取,所以這裡要注意記憶體洩漏的問題。

那麼我們預載入多少個 item 合適呢?

經過我們對不同解析度測試機的對比,首屏展示的 item 數量一般為 10-12 個,由於在第一次滑動時,前 3 個 item 是拿不到快取的,也需要執行 CreateView 方法,那麼我們還需要把這 3 個也算上,所以我們這邊設定預載入數量為 16 個。之後在 onViewDetachedFromWindow 方法中將 View 進行回收再次放入快取池。

@Override

public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {

//從快取池中取item

View view = ConversationListItemPool.getInstance().getItemFromPool();

//如果沒取到,正常建立Item

if(view == null) {
    view = LayoutInflater.from(parent.getContext()).inflate(R.layout.rc_conversationlist_item,parent,false);

}

return  ViewHolder.createViewHolder(parent.getContext(), view);

}

注意:在 onCreateViewHolder 方法中要有降級操作,萬一沒取到快取 View,需要正常建立一個使用。這樣我們成功地將 onCreateViewHolder 的耗時降低到了 2 毫秒甚至更低,在 RecyclerView 快取生效時,可以做到 0 耗時。

解決從 XML 建立 View 耗時的方案,除了在非同步執行緒中預載入,還可以使用一些開源庫比如 X2C 框架,主要原理就是在編譯期間將 XML 檔案轉換為 Java 程式碼來建立 View,省去 IO 和反射的時間。或者使用 jetpack compose 宣告式 UI 來構建佈局。

6.6 onBindViewHolder 優化

我們在檢視 Systrace 報告時還發現:除了 CreateView 耗時,BindView 竟然也很耗時,而且這個耗時甚至超過了 CreateView。這樣在一次滑動過程中,如果有 10 個 item 新展示出來,那麼耗時將達到 100 毫秒以上。

這是絕對不能接受的,於是我們開始清理 onBindViewHolder 的耗時操作。

首先我們必須清楚 onBindViewHolder 方法中只用於 UI 設定,不應該做任何的耗時操作和業務邏輯處理,我們需要把耗時操作和業務處理提前處理好,存入資料來源中。

我們在檢查 onBindViewHolder 方法時發現,如果使用者頭像不存在,會再生成一個預設的頭像,該頭像會以使用者名稱首字母來生成。在該方法中,首先進行了 MD5 加密,然後建立 Bitmap,再壓縮,再存入本地(IO)。這一系列操作非常的耗時,所以我們決定把該操作從 onBindViewHolder 中提取出來,提前將生成資料放入資料來源,用的時候直接從資料來源中獲取。

我們的“訊息”列表上面,每條會話都需要顯示最後一條訊息的傳送時間,時間顯示格式非常複雜,每次在 onBindViewHolder 中都會將最後一條訊息的毫秒數格式化成相應的 String 來顯示。這部分也非常耗時,我們把這部分的程式碼也提取出來處理,在 onBindViewHolder 中只需要從資料來源中取出格式化好的字串顯示即可。

在我們的頭像上面會顯示當前未讀訊息數量,但是這個未讀訊息數幾種不同的情況。

比如:

1)未讀訊息數是個位數,則背景圖是圓的;
2)未讀訊息數是兩位數,背景圖是橢圓;
3)未讀訊息數大於 99,顯示 99+,背景圖會更長;
4)該訊息被遮蔽,只顯示一個小圓點,不顯示數量。
如下圖:

由於存在這幾種情況,此處的程式碼直接根據未讀訊息數,設定了不同的 png 背景圖片。這部分的背景其實完全可以採用 Shape 來實現。

如果使用 png 圖片的話,需要對 png 進行解碼,然後再由 GPU 渲染,圖片解碼會消耗 CPU 資源。而 Shape 資訊會直接傳到底層由 GPU 渲染,速度更快。所以我們將 png 圖片替換為 Shape 實現。

除了圖片的設定,在 onBindViewHolder 中用的最多的就是 TextView,TextView 在文字測量上花費的時間佔文字設定的很大比例,這部分測量的時間其實是可以放在子執行緒中執行的,Android 官方也意識到了這點,所以在 Android P 推出了一個新的類:PrecomputedText,該類可以讓最耗時的文字測量在子執行緒中執行。由於該類是 Android P 才有,所以我們可以使用 AppCompatTextView 來代替 TextView,在 AppCompatTextView 中做了版本相容性處理。

AppCompatTextView tv = (AppCompatTextView) view;

// 用這個方法代替setText

tv.setTextFuture(PrecomputedTextCompat.getTextFuture(text,tv.getTextMetricsParamsCompat(),ThreadManager.getInstance().getTextExecutor()));

使用起來很簡單,原理這裡就不贅述了,可以自行谷歌。在低版本中還使用了 StaticLayout 來進行渲染,可以加快速度,具體可以看Instagram分享的一篇文章《Improving Comment Rendering on Android》。

4.7 佈局優化
除了減少 BindView 的耗時以外,佈局的層級也影響著 onMeasure 和 onLayout 的耗時。我們在使用 GPU 呈現模式分析工具時發現測量和佈局花費了大量的時間,所以我們打算減少 item 的佈局層級。

在未優化之前,我們 item 佈局的最大層級為 5。其實有些只是為了控制顯隱方便而多增加了一層佈局來包裹,我們最後使用約束佈局,將最大層級降低到了 2 層。

除此之外我們還檢查了是否存在重複設定背景顏色的情況,因為重複設定背景顏色會導致過度繪製。所謂過度繪製指的是某個畫素在同一幀內被繪製了多次。如果不可見的 UI 也在做繪製操作,這會導致某些區域的畫素被繪製了多次,浪費大量的 CPU、GPU 資源。

除了去掉重複的背景,我們還可以儘量減少使用透明度,Android 系統在繪製透明度時會將同一個區域繪製兩次,第一次是原有的內容,第二次是新加的透明度效果。基本上 Android 中的透明度動畫都會造成過度繪製,所以可以儘量減少使用透明度動畫,在 View 上面也儘量不要使用 alpha 屬性。具體原理可以參考谷歌官方視訊。

在使用約束佈局來減少層級,並且去掉重複背景以後,我們發現還是會有點卡。在網上查閱相關資料,發現也有網友反饋在 RecyclerView 的 item 中使用約束佈局會有卡頓的問題,應該是約束佈局的 Bug 導致,我們也檢查了一下我們使用的約束佈局版本號。

// App dependencies

appCompatVersion = '1.1.0'

constraintLayoutVersion = '2.0.0-beta3'

用的是 beta 版本,我們改為最新穩定版 2.1.0。發現情況好了很多。所以商業應用盡量不要使用測試版本。

6.8 其他優化
除了上面所說的優化點,還有一些小的優化點,比如以下這幾點。

1)比如使用高版本的 RecyclerView,會預設開啟預取功能:

從上圖中我們可以看見,UI 執行緒完成資料處理交給 Render 執行緒以後就一直處於空閒狀態,需要等待個 Vsync 訊號的到來才會進行資料處理,而這空閒時間就被白白浪費了,開啟預取以後就能合理地使用這段空閒時間。

2)將 RecyclerView 的 setHasFixedSize 方法設定為 true。當我們的 item 寬高固定時,使用 Adapter 的 onItemRangeChanged()、onItemRangeInserted()、onItemRangeRemoved()、onItemRangeMoved() 這幾個方法更新 UI,不會重新計算大小。

3)如果不使用 RecyclerView 的動畫,可以通過 ((SimpleItemAnimator) rv.getItemAnimator()).setSupportsChangeAnimations(false) 把預設動畫關閉來提升效率。

7、棄用的優化方案

在做“訊息”列表卡頓優化過程中,我們採用了一些優化方案,但是最終沒有采用,這裡也列出加以說明。

7.1 非同步載入佈局
在前文中有提到,我們在減少 CreateView 耗時的過程中,最初打算採用非同步載入佈局的方式來將 IO、反射放在子執行緒中執行。

我們使用的是谷歌官方的 AsyncLayoutInflater 來非同步載入佈局,該類會將佈局載入完成以後回撥通知我們。但是它一般用於 onCreate 方法中。而在 onCreateViewHolder 方法中需要返回 ViewHolder,所以沒有辦法直接使用。

為了解決這個問題,我們自定義了一個 AsyncFrameLayout 類,該類繼承於 FrameLayout,我們會在 onCreateViewHolder 方法中將 AsyncFrameLayout 作為 ViewHolder 的根佈局新增進去,並且呼叫自定義的 inflate 方法,進行非同步載入佈局,載入成功以後再把載入成功的佈局新增到 AsyncFrameLayout 中,作為 AsyncFrameLayout 的子 View。

public void inflate(int layoutId, OnInflateCompleted listener) {

   new AsyncLayoutInflater(getContext()).inflate(layoutId, this, newAsyncLayoutInflater.OnInflateFinishedListener() {
       @Override

       public void onInflateFinished(@NotNull View view, int resid, @Nullable @org.jetbrains.annotations.Nullable ViewGroup parent) {
           //標記已經inflate完成

           isInflated = true;

           //載入完佈局以後,新增為AsyncFrameLayout中

           parent.addView(view);

           if(listener != null) {
               //載入完資料後,需要重新請求BindView繫結資料

               listener.onCompleted(mBindRequest);

           }

           mBindRequest = null;

       }

   });

}

這裡注意:因為是非同步執行,所以在 onCreateViewHolder 執行完成以後,會執行 onBinderViewHolder 方法,而這時候佈局是很有可能沒有載入完成的,所以需要用一個標誌為 isInflated 來標識佈局是否載入成功,如果沒有載入完成,就先不繫結資料。同時要記錄本次 BindView 請求,當佈局載入完成以後,主動地呼叫一次去重新整理資料。

沒有采用此方法的主要原因在於會增加布局層級,在使用預載入以後,可以不使用此方案。

7.2 DiffUtil
DiffUtil 是谷歌官方提供的一個資料對比工具,它可以對比兩組新老資料,找出其中的差異,然後通知 RecyclerView 進行重新整理。

DiffUtil 使用 Eugene W. Myers 的差分演算法來計算將一個列表轉換為另一個列表的最少更新次數。但是對比資料時也會耗時,所以也可以採用 AsyncListDiffer 類,把對比操作放在非同步執行緒中執行。

在使用 DiffUtil 中我們發現,要對比的資料項太多了,為了解決這個問題,我們對資料來源進行了封裝,在資料來源裡新增了一個表示是否更新的欄位,把所有變數改為 private 型別,並且提供 set 方法,在 set 方法中統一將是否更新的欄位設定為 true。這樣在進行兩組資料對比時,我們只需要判斷該欄位是否為 true,就知道是否存在更新。

想法是美好的,但是在實際封裝資料來源時發現,類中還有類(也就是類中有物件,不是基本資料型別),外部完全可以通過先 get 到一個物件,然後通過改物件的引用修改其中的欄位,這樣就跳過了 set 方法。如果要解決這個問題,那麼我們需要在封裝類中提供類中類屬性的所有 set 方法,並且不提供類中類的 get 方法,改動非常的大。

如果僅僅是這個問題,還可以解決,但是我們發現“訊息”列表上面有一個功能,就是每當其中一個會話收到了新訊息,那麼該會話會移動到“訊息”列表的第一位。由於位置發生了改變,整個列表都需要重新整理一次,這就違背了使用 DiffUtil 進行區域性重新整理的初衷了。比如“訊息”列表第五個會話收到了新訊息,這時第五個會話需要移動到第一個會話,如果不重新整理整個列表,就會出現重複會話的問題。

由於這個問題的存在,我們棄用了 DiffUtil,因為就算解決了重複會話的問題,收益依然不會很大。

7.3 滑動停止時重新整理
為了避免“訊息”列表大量重新整理操作,我們將“訊息”列表滑動時的資料更新給記錄了下來,等待滑動停止以後再進行重新整理。

但是在實際測試過程中,停止後的重新整理會導致介面卡頓一次,中低端機上比較明顯,所以放棄了此策略。

7.4 提前分頁載入
由於“訊息”列表數量可能很多,所以我們採用分頁的方式來載入資料。

為了保證使用者感知不到載入等待的時間,我們打算在使用者將要滑動到列表結束位置之前獲取更多的資料,讓使用者無痕地下滑。

想法是理想的,但是實踐過程中也發現在中低端機上會有一瞬間的卡頓,所以該方法也暫時先棄用。

除了以上方案被棄用了,我們在優化過程中發現,其它品牌相似產品的“訊息”列表滑動其實速度並沒特別快,如果滑動速度慢的話,那麼在一次滑動過程中需要展示的 item 數量就會小,這樣一次滑動就不需要渲染過多的資料。這其實也是一個優化點,後面我們可能會考慮降低滑動速度的實踐。

8、本文小結

在開發過程中,隨著業務的不斷新增,我們的方法和邏輯複雜度也會不斷增加,這時候一定要注意方法耗時,耗時嚴重的儘量提取到子執行緒中執行。

使用 Recyclerview 時千萬不要無腦重新整理,能區域性刷的絕不全域性刷,能延遲刷的絕不馬上刷。

在分析卡頓的時候可以結合工具進行,這樣效率會提高很多,通過 Systrace 發現大概的問題和排查方向以後,可以通過 Android Studio 自帶的 Profiler 來進行具體程式碼的定位。

附錄:更多IM乾貨文章

《新手入門一篇就夠:從零開發移動端IM》
《從客戶端的角度來談談移動端IM的訊息可靠性和送達機制》
《移動端IM中大規模群訊息的推送如何保證效率、實時性?》
《移動端IM開發需要面對的技術問題》
《IM訊息送達保證機制實現(一):保證線上實時訊息的可靠投遞》
《IM訊息送達保證機制實現(二):保證離線訊息的可靠投遞》
《如何保證IM實時訊息的“時序性”與“一致性”?》
《一個低成本確保IM訊息時序的方法探討》
《IM單聊和群聊中的線上狀態同步應該用“推”還是“拉”?》
《IM群聊訊息如此複雜,如何保證不丟不重?》
《談談移動端 IM 開發中登入請求的優化》
《移動端IM登入時拉取資料如何作到省流量?》
《淺談移動端IM的多點登入和訊息漫遊原理》
《完全自已開發的IM該如何設計“失敗重試”機制?》
《通俗易懂:基於叢集的移動端IM接入層負載均衡方案分享》
《微信對網路影響的技術試驗及分析(論文全文)》
《微信技術分享:微信的海量IM聊天訊息序列號生成實踐(演算法原理篇)》
《自已開發IM有那麼難嗎?手把手教你自擼一個Andriod版簡易IM (有原始碼)》
《融雲技術分享:解密融雲IM產品的聊天訊息ID生成策略》
《適合新手:從零開發一個IM服務端(基於Netty,有完整原始碼)》
《拿起鍵盤就是幹:跟我一起徒手開發一套分散式IM系統》
《適合新手:手把手教你用Go快速搭建高效能、可擴充套件的IM系統(有原始碼)》
《IM裡“附近的人”功能實現原理是什麼?如何高效率地實現它?》
《IM訊息ID技術專題(一):微信的海量IM聊天訊息序列號生成實踐(演算法原理篇)》
《IM開發寶典:史上最全,微信各種功能引數和邏輯規則資料彙總》
《IM開發乾貨分享:我是如何解決大量離線訊息導致客戶端卡頓的》
《零基礎IM開發入門(一):什麼是IM系統?》
《零基礎IM開發入門(二):什麼是IM系統的實時性?》
《零基礎IM開發入門(三):什麼是IM系統的可靠性?》
《零基礎IM開發入門(四):什麼是IM系統的訊息時序一致性?》
《一套億級使用者的IM架構技術乾貨(下篇):可靠性、有序性、弱網優化等》
《IM掃碼登入技術專題(三):通俗易懂,IM掃碼登入功能詳細原理一篇就夠》
《理解IM訊息“可靠性”和“一致性”問題,以及解決方案探討》
《阿里技術分享:閒魚IM基於Flutter的移動端跨端改造實踐》
《融雲技術分享:全面揭祕億級IM訊息的可靠投遞機制》
《IM開發乾貨分享:如何優雅的實現大量離線訊息的可靠投遞》
《IM開發乾貨分享:有贊移動端IM的元件化SDK架構設計實踐》
《IM開發乾貨分享:網易雲信IM客戶端的聊天訊息全文檢索技術實踐》

本文已同步釋出於“即時通訊技術圈”公眾號。
本文已同步釋出於:http://www.52im.net/thread-37...

相關文章