Jack Jiang

          我的最新工程MobileIMSDK:http://git.oschina.net/jackjiang/MobileIMSDK
          posts - 499, comments - 13, trackbacks - 0, articles - 1

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

          1、引言

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

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

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

          無論哪款IM,隨著“消息”列表里數據量和類型越來越多,對于列表的滑動體驗來說肯定會受到影響。而作為整個IM的“第一頁”,這個列表的體驗如何直接決定了用戶的第一印象,非常重要!

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

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

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

          學習交流:

          - 即時通訊/推送技術開發交流5群:215477170 [推薦]

          - 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM

          - 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK

          本文已同步發布于:http://www.52im.net/thread-3732-1-1.html

          2、相關文章

          IM客戶端優化相關文章:

          1. IM開發干貨分享:我是如何解決大量離線消息導致客戶端卡頓的
          2. IM開發干貨分享:網易云信IM客戶端的聊天消息全文檢索技術實踐
          3. 融云技術分享:融云安卓端IM產品的網絡鏈路保活技術實踐
          4. 阿里技術分享:閑魚IM基于Flutter的移動端跨端改造實踐

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

          1. 融云IM技術分享:萬人群聊消息投遞方案的思考和實踐
          2. 融云技術分享:全面揭秘億級IM消息的可靠投遞機制
          3. IM消息ID技術專題(三):解密融云IM產品的聊天消息ID生成策略
          4. 即時通訊云融云CTO的創業經驗分享:技術創業,你真的準備好了?
          5. 融云技術分享:基于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-3732-1-1.html



          作者:Jack Jiang (點擊作者姓名進入Github)
          出處:http://www.52im.net/space-uid-1.html
          交流:歡迎加入即時通訊開發交流群 215891622
          討論:http://www.52im.net/
          Jack Jiang同時是【原創Java Swing外觀工程BeautyEye】【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
          本博文 歡迎轉載,轉載請注明出處(也可前往 我的52im.net 找到我)。


          只有注冊用戶登錄后才能發表評論。


          網站導航:
           
          Jack Jiang的 Mail: jb2011@163.com, 聯系QQ: 413980957, 微信: hellojackjiang
          主站蜘蛛池模板: 武汉市| 始兴县| 双流县| 邮箱| 莎车县| 石台县| 铜山县| 小金县| 宣恩县| 石屏县| 伊川县| 平安县| 馆陶县| 镇赉县| 新蔡县| 玉林市| 当阳市| 惠来县| 巩义市| 汕头市| 靖西县| 逊克县| 尚义县| 砚山县| 蓝田县| 陆河县| 宁波市| 天津市| 焦作市| 河津市| 毕节市| 南皮县| 客服| 普定县| 南京市| 迭部县| 花莲市| 钦州市| 会东县| 通河县| 湖州市|