IM開發(fā)干貨分享:萬字長文,詳解IM“消息“列表卡頓優(yōu)化實踐

本文由融云技術(shù)團隊原創(chuàng)分享,原題“萬字干貨:IM “消息”列表卡頓優(yōu)化實踐”,為使文章更好理解,內(nèi)容有修訂。
1、引言
隨著移動互聯(lián)網(wǎng)的普及,無論是IM開發(fā)者還是普通用戶,IM即時通訊應(yīng)用在日常使用中都是必不可少的,比如:熟人社交的某信、IM活化石的某Q、企業(yè)場景的某釘?shù)龋瑤缀跏侨巳吮匮b。
以下就是幾款主流的IM應(yīng)用(看首頁就知道是哪款,我就不廢話了):
正如上圖所示,這些IM的首頁(也就是“消息”列表界面)對于用戶來說每次打開應(yīng)用必見的。隨著時間和推移,這個首頁“消息”列表里的內(nèi)容會越來越多、消息種類也越來越雜。
無論哪款I(lǐng)M,隨著“消息”列表里數(shù)據(jù)量和類型越來越多,對于列表的滑動體驗來說肯定會受到影響。而作為整個IM的“第一頁”,這個列表的體驗如何直接決定了用戶的第一印象,非常重要!
有鑒于此,市面上的主流IM對于“消息”列表的滑動體驗(主要是卡頓問題)問題,都會特別關(guān)注并著重優(yōu)化。
本文將要分享是融云IM技術(shù)團隊基于對自有產(chǎn)品“消息”列表卡頓問題的分析和實踐(本文以Andriod端為例),為你展示一款I(lǐng)M在解決類似問題時的分析思路和解決方案,希望能帶給你啟發(fā)。
特別說明:本文優(yōu)化實踐的產(chǎn)品源碼可以從公開渠道獲取到,感興趣的讀者可以從本文“附錄1:源碼下載”下載,建議僅用于研究學習目的哦。
學習交流:
- 即時通訊/推送技術(shù)開發(fā)交流5群:215477170?[推薦]
- 移動端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動端IM》
- 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK
(本文已同步發(fā)布于:http://www.52im.net/thread-3732-1-1.html)
2、相關(guān)文章
IM客戶端優(yōu)化相關(guān)文章:
《IM開發(fā)干貨分享:我是如何解決大量離線消息導致客戶端卡頓的》
《IM開發(fā)干貨分享:網(wǎng)易云信IM客戶端的聊天消息全文檢索技術(shù)實踐》
《融云技術(shù)分享:融云安卓端IM產(chǎn)品的網(wǎng)絡(luò)鏈路?;罴夹g(shù)實踐》
《阿里技術(shù)分享:閑魚IM基于Flutter的移動端跨端改造實踐》
融云技術(shù)團隊分享的其它文章:
《融云IM技術(shù)分享:萬人群聊消息投遞方案的思考和實踐》
《融云技術(shù)分享:全面揭秘億級IM消息的可靠投遞機制》
《IM消息ID技術(shù)專題(三):解密融云IM產(chǎn)品的聊天消息ID生成策略》
《即時通訊云融云CTO的創(chuàng)業(yè)經(jīng)驗分享:技術(shù)創(chuàng)業(yè),你真的準備好了?》
《融云技術(shù)分享:基于WebRTC的實時音視頻首幀顯示時間優(yōu)化實踐》
3、技術(shù)背景
對于一款 IM 軟件來說,“消息”列表是用戶首先接觸到的界面,“消息”列表滑動是否流暢對用戶的體驗有著很大的影響。
隨著功能的不斷增加、數(shù)據(jù)累積,“消息”列表上要展示的信息也越來越多。
我們發(fā)現(xiàn),產(chǎn)品每使用一段時間后,比如打完 Call 返回到“消息”列表界面進行滑動時,會出現(xiàn)嚴重的卡頓現(xiàn)象。
于是我們開始對“消息”列表卡頓情況進行了詳細的分析,期待找出問題的根源,并使用合適的解決手段來優(yōu)化。
PS:本文所討論產(chǎn)品的源碼可以從公開渠道獲取到,感興趣的讀者可以從本文“附錄1:源碼下載”下載。
4、到底什么是卡頓?
提到APP的卡頓,很多人都會說是因為在UI 16ms 內(nèi)無法完成渲染導致的。
那么為什么需要在 16ms 內(nèi)完成呢?以及在 16ms 以內(nèi)需要完成什么工作?
帶著這兩個問題,在本節(jié)我們來深入地學習一下。
4.1 刷新率(RefreshRate)與幀率(FrameRate)
刷新率:指的是屏幕每秒刷新的次數(shù),是針對硬件而言的。目前大部分的手機刷新率都在 60Hz(屏幕每秒鐘刷新 60 次),有部分高端機采用的 120Hz(比如 iPad Pro)。
幀率:是每秒繪制的幀數(shù),是針對軟件而言的。通常只要幀率與刷新率保持一致,我們看到的畫面就是流暢的。所以幀率在 60FPS 時我們就不會感覺到卡。
那么刷新率和幀率之間到底有什么關(guān)系呢?
舉個直觀的例子你就懂了:
如果幀率為每秒鐘 60 幀,而屏幕刷新率為 30Hz,那么就會出現(xiàn)屏幕上半部分還停留在上一幀的畫面,屏幕的下半部分渲染出來的就是下一幀的畫面 —— 這種情況被稱為畫面【撕裂】。相反,如果幀率為每秒鐘 30 幀,屏幕刷新率為 60Hz,那么就會出現(xiàn)相連兩幀顯示的是同一畫面,這就出現(xiàn)了【卡頓】。
所以單方面的提升幀率或者刷新率是沒有意義的,需要兩者同時進行提升。
由于目前大部分 Android 機屏幕都采用的 60Hz 的刷新率,為了使幀率也能達到 60FPS,那么就要求在 16.67ms 內(nèi)完成一幀的繪制(即:1000ms/60Frame = 16.666ms / Frame)。
4.2 垂直同步技術(shù)
由于顯示器是從最上面一行像素開始,向下逐行刷新,所以從最頂端到最底部的刷新是有時間差的。
常見的有兩個問題:
1)如果幀率(FPS)大于刷新率,那么就會出現(xiàn)前文提到的畫面撕裂;
2)如果幀率再大一點,那么下一幀的還沒來得及顯示,下下一幀的數(shù)據(jù)就覆蓋上來了,中間這幀就被跳過了,這種情況被稱為跳幀。
為了解決這種幀率大于刷新率的問題,引入了垂直同步的技術(shù),簡單來說就是顯示器每隔 16ms 發(fā)送一個垂直同步信號(VSYNC),系統(tǒng)會等待垂直同步信號的到來,才進行一幀的渲染和緩沖區(qū)的更新,這樣就把幀率與刷新率鎖定。
4.3 系統(tǒng)是如何生成一幀的
在 Android4.0 以前:處理用戶輸入事件、繪制、柵格化都由 CPU 中應(yīng)用主線程執(zhí)行,很容易造成卡頓。主要原因在于主線程的任務(wù)太重,要處理很多事件,其次 CPU 中只有少量的 ALU 單元(算術(shù)邏輯單元),并不擅長做圖形計算。
Android4.0 以后應(yīng)用默認開啟硬件加速。
開啟硬件加速以后:CPU 不擅長的圖像運算就交給了 GPU 來完成,GPU 中包含了大量的 ALU 單元,就是為實現(xiàn)大量數(shù)學運算設(shè)計的(所以挖礦一般用 GPU)。硬件加速開啟后還會將主線程中的渲染工作交給單獨的渲染線程(RenderThread),這樣當主線程將內(nèi)容同步到 RenderThread 后,主線程就可以釋放出來進行其他工作,渲染線程完成接下來的工作。
那么完整的一幀流程如下:
如上圖所示:
1)首先在第一個 16ms 內(nèi),顯示器顯示了第 0 幀的內(nèi)容,CPU/GPU 處理完第一幀;
2)垂直同步信號到來后,CPU 馬上進行第二幀的處理工作,處理完以后交給 GPU(顯示器則將第一幀的圖像顯示出來)。
整個流程看似沒有什么問題,但是一旦出現(xiàn)幀率(FPS)小于刷新率的情況,畫面就會出現(xiàn)卡頓。
圖上的 A 和 B 分別代表兩個緩沖區(qū)。因為 CPU/GPU處理時間超過了 16ms,導致在第二個 16ms 內(nèi),顯示器本應(yīng)該顯示 B 緩沖區(qū)中的內(nèi)容,現(xiàn)在卻不得不重復顯示 A 緩沖區(qū)中的內(nèi)容,也就是掉幀了(卡頓)。
由于 A 緩沖區(qū)被顯示器所占用,B 緩沖區(qū)被 GPU 所占用,導致在垂直同步信號 (VSync) 到來時 CPU 沒辦法開始處理下一幀的內(nèi)容,所以在第二個 16ms內(nèi),CPU 并沒有觸發(fā)繪制工作。
4.4 三緩沖區(qū)(Triple Buffer)
為了解決幀率(FPS)小于屏幕刷新率導致的掉幀問題,Android4.1 引入了三級緩沖區(qū)。
在雙緩沖區(qū)的時候,由于 Display 和 GPU 各占用了一個緩沖區(qū),導致在垂直同步信號到來時 CPU 沒有辦法進行繪制。那么現(xiàn)在新增一個緩沖區(qū),CPU 就能在垂直同步信號到來時進行繪制工作。
在第二個 16ms 內(nèi),雖然還是重復顯示了一幀,但是在 Display 占用了 A 緩沖區(qū),GPU 占用了 B 緩沖區(qū)的情況下,CPU 依然可以使用 C 緩沖區(qū)完成繪制工作,這樣 CPU 也被充分地利用起來。后續(xù)的顯示也比較順暢,有效地避免了 Jank 進一步的加劇。
通過繪制的流程我們知道,出現(xiàn)卡頓是因為掉幀了,而掉幀的原因在于垂直同步信號到來時,還沒有準備好數(shù)據(jù)用于顯示。所以我們要處理卡頓,就要盡量縮短 CPU/GPU 繪制的時間,這樣就能保證在 16ms 內(nèi)完成一幀的渲染。
5、卡頓問題分析
5.1 在中低端手機中的卡頓效果
有了以上的理論基礎(chǔ),我們開始分析“消息”列表卡頓的問題。由于 Boss 使用的 Pixel5 屬于高端機,卡頓并不明顯,我們特意從測試同學手中借來了一臺中低端機。
這臺中低端機的配置如下:
先看一下優(yōu)化之前的效果:
果然是很卡,看看手機刷新率是多少:
是 60Hz 沒問題。
去高通網(wǎng)站上查詢一下 SDM450 具體的架構(gòu):
?
可以看該手機的 CPU 是 8 核 A53 Processor:
?
A53 Processor 一般在大小核架構(gòu)中當作小核來使用,其主要作用是省電,那些性能要求很低的場景一般由它們負責,比如待機狀態(tài)、后臺執(zhí)行等,而A53 也確實把功耗做到了極致。
在三星 Galaxy A20s 手機上,全都采用該 Processor,并且沒有大核,那么處理速度自然不會很快,這也就要求我們的 APP 優(yōu)化得更好才行。
在有了對手機大致的了解以后,我們使用工具來查看一下卡頓點。
5.2 分析一下卡頓點
首先打開系統(tǒng)自帶的 GPU 呈現(xiàn)模式分析工具,對“消息”列表進行查看。
可以看見直方圖已經(jīng)高出了天際。在圖中最下面有一條綠色的水平線(代表16ms),超過這條水平線就有可能出現(xiàn)掉幀。
?根據(jù) Google 給出的顏色對應(yīng)表,我們來看看耗時的大概位置。
首先我們要明確,雖然該工具叫 GPU 呈現(xiàn)模式分析工具,但是其中顯示的大部分操作發(fā)生在 CPU 中。
其次根據(jù)顏色對照表大家可能也發(fā)現(xiàn)了,谷歌給出的顏色跟真機上的顏色對應(yīng)不上。所以我們只能判斷耗時的大概位置。
從我們的截圖中可以看見,綠色部分占很大比例,其中一部分是 Vsync 延遲,另外一部分是輸入處理+動畫+測量/布局。
Vsync 延遲圖標中給出的解釋為兩個連續(xù)幀之間的操作所花的時間。
其實就是 SurfaceFlinger 在下一次分發(fā) Vsync 的時候,會往 UI 線程的 MessageQueue 中插入一條 Vsync 到來的消息,而該消息并不會馬上執(zhí)行,而是等待前面的消息被執(zhí)行完畢以后,才會被執(zhí)行。所以 Vsync 延遲指的就是 Vsync 被放入 MessageQueue 到被執(zhí)行之間的時間。這部分時間越長說明 UI 線程中進行的處理越多,需要將一些任務(wù)分流到其他線程中執(zhí)行。
輸入處理、動畫、測量/布局這部分都是垂直同步信號到達并開始執(zhí)行 doFrame 方法時的回調(diào)。
void doFrame(long frameTimeNanos, int frame) {
??//...省略無關(guān)代碼
??????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);
????????}
}
這部分如果比較耗時,需要檢查是否在輸入事件回調(diào)中是否執(zhí)行了耗時操作,或者是否有大量的自定義動畫,又或者是否布局層次過深導致測量 View 和布局耗費太多的時間。
6、具體優(yōu)化方案及實踐總結(jié)
6.1 異步執(zhí)行
有了大概的方向以后,我們開始對“消息”列表進行優(yōu)化。
在問題分析中,我們發(fā)現(xiàn) Vsync 延遲占比很大,所以我們首先想到的是將主線程中的耗時任務(wù)剝離出來,放到工作線程中執(zhí)行。為了更快地定位主線程方法耗時,可以使用滴滴的?Dokit?或者騰訊的?Matrix?進行慢函數(shù)定位。
我們發(fā)現(xiàn)在“消息”列表的 ViewModel 中,使用了 LiveData 訂閱了數(shù)據(jù)庫中用戶信息表的變更、群信息表的變更、群成員表的變更。只要這三張表有變化,都會重新遍歷“消息”列表,進行數(shù)據(jù)更新,然后通知頁面刷新。
?
這部分邏輯在主線程中執(zhí)行,耗時大概在 80ms 左右,如果“消息”列表多,數(shù)據(jù)庫表數(shù)據(jù)變更大,這部分的耗時還會增加。
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);
???????????????}
???????????}
???????});
既然這部分比較耗時,我們可以將遍歷更新數(shù)據(jù)的操作放到子線程中執(zhí)行,執(zhí)行完畢以后再調(diào)用 postValue 方法通知頁面進行刷新。
我們還發(fā)現(xiàn)每次進入“消息”列表時都需要從數(shù)據(jù)庫中獲取“消息”列表數(shù)據(jù),加載更多時也會從數(shù)據(jù)庫中讀取會話數(shù)據(jù)。
讀取到會話數(shù)據(jù)以后,我們會對獲取到的會話進行過濾操作,比如不是同一個組織下的會話則應(yīng)該過濾掉。
過濾完成以后會進行去重:
1)如果該會話已經(jīng)存在,則更新當前會話;
2)如果不存在,則創(chuàng)建一個新的會話并添加到“消息”列表。
然后還需要對“消息”列表按一定規(guī)則進行排序,最后再通知 UI 進行刷新。
?
這部分的耗時為 500ms~600ms,并且隨著數(shù)據(jù)量的增大耗時還會增加,所以這部分必須放到子線程中執(zhí)行。
但是這里必須注意線程安全問題,否則會出現(xiàn)數(shù)據(jù)多次被添加,“消息”列表上出現(xiàn)多條重復的數(shù)據(jù)。
6.2 增加緩存
在檢查代碼的時候,我們發(fā)現(xiàn)有很多地方會獲取當前用戶的信息,而當前用戶信息保存在了本地 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;
??}
每次都這樣獲取當前用戶的信息會非常的耗時。
為了解決這個問題,我們將第一次獲取的用戶信息進行緩存,如果內(nèi)存中存在當前用戶的信息則直接返回,并且在每次修改當前用戶信息的時候,更新內(nèi)存中的對象。
??/**
??* 獲取當前用戶信息
??*/
??public UserCacheInfo getUserCacheInfo(){
??????//如果當前用戶信息已經(jīng)存在,則直接返回
??????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 減少刷新次數(shù)
在這個方案里,一方面要減少不合理的刷新,另外一方面要將部分全局刷新改為局部刷新。
在“消息”列表的 ViewModel 中,LiveData 訂閱了數(shù)據(jù)庫中用戶信息表的變更、群信息表的變更、群成員表的變更。只要這三張表有變化,都會重新遍歷“消息”列表,進行數(shù)據(jù)更新,然后通知頁面刷新。
邏輯看似沒問題,但是卻把通知頁面刷新的代碼寫在循環(huán)當中,也就是每更新完一條會話數(shù)據(jù),就通知頁面刷新一次,如果有 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);
???????????????????????//未優(yōu)化前的代碼,頻繁通知頁面刷新
???????????????????????//mConversationListLiveData.postValue(mUiConversationList);
???????????????????}
???????????????????mConversationListLiveData.postValue(mUiConversationList);
???????????????}
???????????}
???????});
優(yōu)化方法就是:將通知頁面刷新的代碼提取到循環(huán)外面,等待數(shù)據(jù)更新完畢以后刷新一次即可。
我們 APP 里面有個草稿功能,每次從會話里出來,都需要判斷會話的輸入框中是否存在未刪除文字(草稿),如果有,則保存起來并在“消息”列表上顯示【Draft】+內(nèi)容,用戶下次再進入會話后將草稿還原。由于草稿的存在,每次從會話退回到“消息”列表都需要刷新一下頁面。在未優(yōu)化之前,此處采用的是全局刷新,而我們其實只需要刷新剛剛退出的會話對應(yīng)的 item 即可。
?對于一款 IM 應(yīng)用,提醒用戶消息未讀是一個常見的功能。在“消息”列表的用戶頭像上面會顯示當前會話的消息未讀數(shù),當我們進入會話以后,該未讀數(shù)需要清零,并且更新“消息”列表。在未優(yōu)化之前,此處采用的也是全局刷新,這部分其實也可以改為刷新單條 item。
?我們的 APP 新增了一個叫做 typing 的功能,只要有用戶在會話里面正在輸入文字,在“消息”列表上就會顯示某某某?is typing...的文案。在未優(yōu)化之前,此處也是采用列表全局刷新,如果在好幾個會話中同時有人 typing,那么基本上整個“消息”列表就會一直處于刷新的狀態(tài)。所以此處也改為了局部刷新,只刷新當前有人 typing 的會話 item。
6.4 onCreateViewHolder 優(yōu)化
?
在分析 Systrace 報告時,我們發(fā)現(xiàn)了上圖中這種情況:一次滑動伴隨著大量的 CreateView 操作。
為什么會出現(xiàn)這種情況呢?
我們知道 RecyclerView 本身是存在緩存機制的,滑動中如果新展示的 item 布局跟老的一致,就不會再執(zhí)行 CreateView,而是復用老的 item,執(zhí)行 bindView 來設(shè)置數(shù)據(jù),這樣可減少創(chuàng)建 view 時的 IO 和反射耗時。
那么這里為什么跟預期不一樣呢?
我們先來看看 RecyclerView 的緩存機制。
RecyclerView 有4級緩存,我們這里只分析常用的 2級:
1)mCachedViews;
2)mRecyclerPool。
mCachedViews?的默認大小為 2,當 item 剛剛被移出屏幕可視范圍時,item 就會被放入?mCachedViews?中,因為用戶很可能再重新將 item 移回到屏幕可視范圍,所以放入 mCachedViews 中的 item 是不需要重新執(zhí)行 createView 和 bindView 操作的。
mCachedViews?中采用 FIFO 原則,如果緩存數(shù)量達到最大值,那么先進入的 item 會被移出并放入到下一級緩存中。
mRecyclerPool?是 RecycledViewPool 類型,其中根據(jù) item 類型創(chuàng)建對應(yīng)的緩存池,每個緩存池默認大小為 5,從 mCachedViews 中移除的 item 會被清除掉數(shù)據(jù),并根據(jù)對應(yīng)的 itemType 放入到相應(yīng)的緩存池中。
這里有兩個值得注意的地方:
1)第一個就是 item 被清除了數(shù)據(jù),這意味著下次使用這個 item 時需要重新執(zhí)行 bindView 方法來重設(shè)數(shù)據(jù);
2)另外一個就是根據(jù) itemType 的不同,會存在多個緩存池,每個緩存池的大小默認為 5,也就是說不同類型的 item 會放入不同的緩沖池中,每次在顯示新的 item 時會先找對應(yīng)類型的緩存池,看里面是否有可以復用的 item,如果有則直接復用后執(zhí)行 bindView,如果沒有則要重新創(chuàng)建 view,需要執(zhí)行 createView 和 bindView 操作。
Systrace 報告中出現(xiàn)大量的 CreateView,說明在復用 item 時出現(xiàn)了問題,導致每次顯示新的 item 都需要重新創(chuàng)建。
我們來考慮一種極端場景,我們“消息”列表中分為 3 種類型的 item:
1)群聊 item;
2)單聊 item;
3)密聊 item。
我們一屏能展示 10 個 item。其中前 10 個 item 都是群聊類型。從 11 個開始到 20 個都是單聊 item,從 21 個到 30 個都是密聊 item。
?
從圖中我們可以看到群聊 1 和群聊 2 已經(jīng)被移出了屏幕,這時候會被放入 mCachedViews 緩存中。而單聊 1 和單聊 2 因為在 mRecyclerPool 的單聊緩存池中找不到可以復用的 item,所以需要執(zhí)行 CreateView 和 BindView 操作。
?由于之前移出屏幕的都是群聊,所以單聊 item 進入時一直沒用辦法從單聊緩存池中拿到可以復用的 item,所以一直需要 CreateView 和 BindView。
直到單聊 1 進入到緩存池,也就是上圖所示,如果即將進入屏幕的是單聊 item 或者群聊 item,都是可以復用的,可惜進來的是密聊,由于密聊緩存池中沒用可以復用的 item,所以接下來進入屏幕的密聊 item 也都需要執(zhí)行 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;
??????????//如果緩存池大于等于最大可緩存數(shù),則返回
??????????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 報告中發(fā)現(xiàn)了如此多的 CreateView。知道了問題所在,那么我們就需要想辦法解決。多次創(chuàng)建 View 主要是因為復用機制失效或者沒有很好的運作導致,而失效的原因主要在于我們同時有 3 種不同的 item 類型,如果我們能將 3 種不同的 item 變?yōu)橐环N,那么我們就能在單聊 4 進入屏幕時,從緩存池中拿到可以復用的 item,從而省去 CreateView 的步驟,直接 BindView 重置數(shù)據(jù)。
?有了思路以后,我們在檢查代碼時發(fā)現(xiàn),無論是群聊、單聊還是密聊,使用的都是同一個布局,完全可以采用同一個 itemType。以前之所以分開,是因為使用了一些設(shè)計模式,想讓群聊、單聊、密聊在各自的類中實現(xiàn),也方便以后如果有新的擴展會更方便清晰。
這時候就需要在性能和模式上有所取舍,但是仔細一想,“消息”列表上面不同類型的聊天,布局基本是一致的,不同聊天類型僅僅在 UI 展示上有所不同,這些不同我們可以在 bindView 時重新設(shè)置。
?我們在注冊的時候只注冊?BaseConversationProvider,這樣?itemType?類型就只有這一個。GroupConversationProvider、PrivateConversationProvider、SecretConversationProvider?都繼承于?BaseConversationProvider?類,onCreateViewHolder?方法只在?BaseConversationProvider?類實現(xiàn)。
在?BaseConversationProvider?類中包含一個 List,用于保存?GroupConversationProvider、PrivateConversationProvider、SecretConversationProvider?這三個對象,在執(zhí)行執(zhí)行?bindViewHolder?方法時,先執(zhí)行父類的方法,在這里面處理一些三種聊天類型公共的邏輯,比如頭像、最后一條消息發(fā)送的時間等,處理完畢以后通過 isItemViewType 判斷當前是哪種聊天,并且調(diào)用相應(yīng)的子類 bindViewHolder 方法,進行子類特有的數(shù)據(jù)處理。這里需要注意重用時導致的頁面顯示錯誤,比如在密聊中修改了會話標題的顏色,但是由于 item 的復用,導致群聊的會話標題顏色也改變了。
經(jīng)過改造以后,我們就可以省去大量 的CreateView?操作(IO+反射),讓?RecyclerView?的緩存機制可以良好的運行。
6.5 ?預加載+全局緩存
雖然我們減少了?CreateView?的次數(shù),但是我們在首次進入時第一屏還是需要 CreateView,并且我們發(fā)現(xiàn) CreateView 的耗時也挺長。
?這部分時間能不能優(yōu)化掉?
我們首先想到的是在?onCreateViewHolder?時采用異步加載布局的方式,將 IO、反射放在子線程來做,后來這個方案被去掉了(具體原因后文會說)。如果不能異步加載,那么我們就考慮將創(chuàng)建 View 的操作提前來執(zhí)行并且緩存下來。
我們首先創(chuàng)建了一個?ConversationItemPool?類,該類用于在子線程中預加載 item,并且將它們緩存起來。當執(zhí)行?onCreateViewHolder?時直接從該類中獲取緩存的 item,這樣就可以減少?onCreateViewHolder?執(zhí)行耗時。
/**
???????* 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;
??????????//如果緩存池大于等于最大可緩存數(shù),則返回
??????????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?中我們使用了一個線程安全隊列來緩存創(chuàng)建的 item。由于是全局緩存,所以這里要注意內(nèi)存泄漏的問題。
那么我們預加載多少個 item 合適呢?
經(jīng)過我們對不同分辨率測試機的對比,首屏展示的 item 數(shù)量一般為 10-12 個,由于在第一次滑動時,前 3 個 item 是拿不到緩存的,也需要執(zhí)行 CreateView 方法,那么我們還需要把這 3 個也算上,所以我們這邊設(shè)置預加載數(shù)量為 16 個。之后在 onViewDetachedFromWindow 方法中將 View 進行回收再次放入緩存池。
@Override
public ?ViewHolder onCreateViewHolder(ViewGroup parent, int ?viewType) {
????//從緩存池中取item
????View view = ConversationListItemPool.getInstance().getItemFromPool();
????//如果沒取到,正常創(chuàng)建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,需要正常創(chuàng)建一個使用。這樣我們成功地將?onCreateViewHolder?的耗時降低到了 2 毫秒甚至更低,在?RecyclerView?緩存生效時,可以做到 0 耗時。
解決從 XML 創(chuàng)建 View 耗時的方案,除了在異步線程中預加載,還可以使用一些開源庫比如?X2C?框架,主要原理就是在編譯期間將 XML 文件轉(zhuǎn)換為 Java 代碼來創(chuàng)建 View,省去 IO 和反射的時間?;蛘呤褂?jetpack compose 聲明式 UI 來構(gòu)建布局。
6.6 onBindViewHolder 優(yōu)化
?
我們在查看 Systrace 報告時還發(fā)現(xiàn):除了 CreateView 耗時,BindView 竟然也很耗時,而且這個耗時甚至超過了 CreateView。這樣在一次滑動過程中,如果有 10 個 item 新展示出來,那么耗時將達到 100 毫秒以上。
這是絕對不能接受的,于是我們開始清理?onBindViewHolder?的耗時操作。
首先我們必須清楚?onBindViewHolder?方法中只用于 UI 設(shè)置,不應(yīng)該做任何的耗時操作和業(yè)務(wù)邏輯處理,我們需要把耗時操作和業(yè)務(wù)處理提前處理好,存入數(shù)據(jù)源中。
我們在檢查?onBindViewHolder?方法時發(fā)現(xiàn),如果用戶頭像不存在,會再生成一個默認的頭像,該頭像會以用戶名首字母來生成。在該方法中,首先進行了 MD5 加密,然后創(chuàng)建 Bitmap,再壓縮,再存入本地(IO)。這一系列操作非常的耗時,所以我們決定把該操作從 onBindViewHolder 中提取出來,提前將生成數(shù)據(jù)放入數(shù)據(jù)源,用的時候直接從數(shù)據(jù)源中獲取。
我們的“消息”列表上面,每條會話都需要顯示最后一條消息的發(fā)送時間,時間顯示格式非常復雜,每次在?onBindViewHolder?中都會將最后一條消息的毫秒數(shù)格式化成相應(yīng)的 String 來顯示。這部分也非常耗時,我們把這部分的代碼也提取出來處理,在?onBindViewHolder?中只需要從數(shù)據(jù)源中取出格式化好的字符串顯示即可。
?
在我們的頭像上面會顯示當前未讀消息數(shù)量,但是這個未讀消息數(shù)幾種不同的情況。
比如:
1)未讀消息數(shù)是個位數(shù),則背景圖是圓的;
2)未讀消息數(shù)是兩位數(shù),背景圖是橢圓;
3)未讀消息數(shù)大于 99,顯示 99+,背景圖會更長;
4)該消息被屏蔽,只顯示一個小圓點,不顯示數(shù)量。
如下圖:
?由于存在這幾種情況,此處的代碼直接根據(jù)未讀消息數(shù),設(shè)置了不同的 png 背景圖片。這部分的背景其實完全可以采用 Shape 來實現(xiàn)。
如果使用 png 圖片的話,需要對 png 進行解碼,然后再由 GPU 渲染,圖片解碼會消耗 CPU 資源。而 Shape 信息會直接傳到底層由 GPU 渲染,速度更快。所以我們將 png 圖片替換為 Shape 實現(xiàn)。
除了圖片的設(shè)置,在?onBindViewHolder?中用的最多的就是 TextView,TextView 在文本測量上花費的時間占文本設(shè)置的很大比例,這部分測量的時間其實是可以放在子線程中執(zhí)行的,Android 官方也意識到了這點,所以在 Android P 推出了一個新的類:PrecomputedText,該類可以讓最耗時的文本測量在子線程中執(zhí)行。由于該類是 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 布局優(yōu)化
除了減少 BindView 的耗時以外,布局的層級也影響著 onMeasure 和 onLayout 的耗時。我們在使用 GPU 呈現(xiàn)模式分析工具時發(fā)現(xiàn)測量和布局花費了大量的時間,所以我們打算減少 item 的布局層級。
在未優(yōu)化之前,我們 item 布局的最大層級為 5。其實有些只是為了控制顯隱方便而多增加了一層布局來包裹,我們最后使用約束布局,將最大層級降低到了 2 層。
除此之外我們還檢查了是否存在重復設(shè)置背景顏色的情況,因為重復設(shè)置背景顏色會導致過度繪制。所謂過度繪制指的是某個像素在同一幀內(nèi)被繪制了多次。如果不可見的 UI 也在做繪制操作,這會導致某些區(qū)域的像素被繪制了多次,浪費大量的 CPU、GPU 資源。
?除了去掉重復的背景,我們還可以盡量減少使用透明度,Android 系統(tǒng)在繪制透明度時會將同一個區(qū)域繪制兩次,第一次是原有的內(nèi)容,第二次是新加的透明度效果。基本上 Android 中的透明度動畫都會造成過度繪制,所以可以盡量減少使用透明度動畫,在 View 上面也盡量不要使用 alpha 屬性。具體原理可以參考谷歌官方視頻。
在使用約束布局來減少層級,并且去掉重復背景以后,我們發(fā)現(xiàn)還是會有點卡。在網(wǎng)上查閱相關(guān)資料,發(fā)現(xiàn)也有網(wǎng)友反饋在 RecyclerView 的 item 中使用約束布局會有卡頓的問題,應(yīng)該是約束布局的 Bug 導致,我們也檢查了一下我們使用的約束布局版本號。
// APP dependencies
APPCompatVersion = '1.1.0'
constraintLayoutVersion = '2.0.0-beta3'
用的是 beta 版本,我們改為最新穩(wěn)定版 2.1.0。發(fā)現(xiàn)情況好了很多。所以商業(yè)應(yīng)用盡量不要使用測試版本。
6.8 其他優(yōu)化
除了上面所說的優(yōu)化點,還有一些小的優(yōu)化點,比如以下這幾點。
1)比如使用高版本的 RecyclerView,會默認開啟預取功能:
?從上圖中我們可以看見,UI 線程完成數(shù)據(jù)處理交給 Render 線程以后就一直處于空閑狀態(tài),需要等待個 Vsync 信號的到來才會進行數(shù)據(jù)處理,而這空閑時間就被白白浪費了,開啟預取以后就能合理地使用這段空閑時間。
?2)將 RecyclerView 的?setHasFixedSize?方法設(shè)置為 true。當我們的 item 寬高固定時,使用 Adapter 的?onItemRangeChanged()、onItemRangeInserted()、onItemRangeRemoved()、onItemRangeMoved()?這幾個方法更新 UI,不會重新計算大小。
3)如果不使用 RecyclerView 的動畫,可以通過?((SimpleItemAnimator) rv.getItemAnimator()).setSupportsChangeAnimations(false)?把默認動畫關(guān)閉來提升效率。
7、棄用的優(yōu)化方案
在做“消息”列表卡頓優(yōu)化過程中,我們采用了一些優(yōu)化方案,但是最終沒有采用,這里也列出加以說明。
7.1 異步加載布局
在前文中有提到,我們在減少?CreateView?耗時的過程中,最初打算采用異步加載布局的方式來將 IO、反射放在子線程中執(zhí)行。
我們使用的是谷歌官方的?AsyncLayoutInflater?來異步加載布局,該類會將布局加載完成以后回調(diào)通知我們。但是它一般用于 onCreate 方法中。而在?onCreateViewHolder?方法中需要返回 ViewHolder,所以沒有辦法直接使用。
為了解決這個問題,我們自定義了一個?AsyncFrameLayout?類,該類繼承于?FrameLayout,我們會在?onCreateViewHolder?方法中將?AsyncFrameLayout?作為?ViewHolder?的根布局添加進去,并且調(diào)用自定義的 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) {
???????????????//標記已經(jīng)inflate完成
???????????????isInflated = true;
???????????????//加載完布局以后,添加為AsyncFrameLayout中
???????????????parent.addView(view);
???????????????if(listener != null) {
???????????????????//加載完數(shù)據(jù)后,需要重新請求BindView綁定數(shù)據(jù)
???????????????????listener.onCompleted(mBindRequest);
???????????????}
???????????????mBindRequest = null;
???????????}
???????});
???}
這里注意:因為是異步執(zhí)行,所以在?onCreateViewHolder?執(zhí)行完成以后,會執(zhí)行?onBinderViewHolder?方法,而這時候布局是很有可能沒有加載完成的,所以需要用一個標志為?isInflated?來標識布局是否加載成功,如果沒有加載完成,就先不綁定數(shù)據(jù)。同時要記錄本次 BindView 請求,當布局加載完成以后,主動地調(diào)用一次去刷新數(shù)據(jù)。
沒有采用此方法的主要原因在于會增加布局層級,在使用預加載以后,可以不使用此方案。
7.2 DiffUtil
DiffUtil?是谷歌官方提供的一個數(shù)據(jù)對比工具,它可以對比兩組新老數(shù)據(jù),找出其中的差異,然后通知 RecyclerView 進行刷新。
DiffUtil 使用?Eugene W. Myers 的差分算法來計算將一個列表轉(zhuǎn)換為另一個列表的最少更新次數(shù)。但是對比數(shù)據(jù)時也會耗時,所以也可以采用?AsyncListDiffer?類,把對比操作放在異步線程中執(zhí)行。
在使用 DiffUtil 中我們發(fā)現(xiàn),要對比的數(shù)據(jù)項太多了,為了解決這個問題,我們對數(shù)據(jù)源進行了封裝,在數(shù)據(jù)源里添加了一個表示是否更新的字段,把所有變量改為 private 類型,并且提供 set 方法,在 set 方法中統(tǒng)一將是否更新的字段設(shè)置為 true。這樣在進行兩組數(shù)據(jù)對比時,我們只需要判斷該字段是否為 true,就知道是否存在更新。
想法是美好的,但是在實際封裝數(shù)據(jù)源時發(fā)現(xiàn),類中還有類(也就是類中有對象,不是基本數(shù)據(jù)類型),外部完全可以通過先 get 到一個對象,然后通過改對象的引用修改其中的字段,這樣就跳過了 set 方法。如果要解決這個問題,那么我們需要在封裝類中提供類中類屬性的所有 set 方法,并且不提供類中類的 get 方法,改動非常的大。
如果僅僅是這個問題,還可以解決,但是我們發(fā)現(xiàn)“消息”列表上面有一個功能,就是每當其中一個會話收到了新消息,那么該會話會移動到“消息”列表的第一位。由于位置發(fā)生了改變,整個列表都需要刷新一次,這就違背了使用 DiffUtil 進行局部刷新的初衷了。比如“消息”列表第五個會話收到了新消息,這時第五個會話需要移動到第一個會話,如果不刷新整個列表,就會出現(xiàn)重復會話的問題。
由于這個問題的存在,我們棄用了 DiffUtil,因為就算解決了重復會話的問題,收益依然不會很大。
7.3 滑動停止時刷新
為了避免“消息”列表大量刷新操作,我們將“消息”列表滑動時的數(shù)據(jù)更新給記錄了下來,等待滑動停止以后再進行刷新。
但是在實際測試過程中,停止后的刷新會導致界面卡頓一次,中低端機上比較明顯,所以放棄了此策略。
7.4 提前分頁加載
由于“消息”列表數(shù)量可能很多,所以我們采用分頁的方式來加載數(shù)據(jù)。
為了保證用戶感知不到加載等待的時間,我們打算在用戶將要滑動到列表結(jié)束位置之前獲取更多的數(shù)據(jù),讓用戶無痕地下滑。
想法是理想的,但是實踐過程中也發(fā)現(xiàn)在中低端機上會有一瞬間的卡頓,所以該方法也暫時先棄用。
除了以上方案被棄用了,我們在優(yōu)化過程中發(fā)現(xiàn),其它品牌相似產(chǎn)品的“消息”列表滑動其實速度并沒特別快,如果滑動速度慢的話,那么在一次滑動過程中需要展示的 item 數(shù)量就會小,這樣一次滑動就不需要渲染過多的數(shù)據(jù)。這其實也是一個優(yōu)化點,后面我們可能會考慮降低滑動速度的實踐。
8、本文小結(jié)
在開發(fā)過程中,隨著業(yè)務(wù)的不斷新增,我們的方法和邏輯復雜度也會不斷增加,這時候一定要注意方法耗時,耗時嚴重的盡量提取到子線程中執(zhí)行。
使用 Recyclerview 時千萬不要無腦刷新,能局部刷的絕不全局刷,能延遲刷的絕不馬上刷。
在分析卡頓的時候可以結(jié)合工具進行,這樣效率會提高很多,通過 Systrace 發(fā)現(xiàn)大概的問題和排查方向以后,可以通過 Android Studio 自帶的 Profiler 來進行具體代碼的定位。
附錄:更多IM干貨文章
《新手入門一篇就夠:從零開發(fā)移動端IM》
《從客戶端的角度來談?wù)勔苿佣薎M的消息可靠性和送達機制》
《移動端IM中大規(guī)模群消息的推送如何保證效率、實時性?》
《移動端IM開發(fā)需要面對的技術(shù)問題》
《IM消息送達保證機制實現(xiàn)(一):保證在線實時消息的可靠投遞》
《IM消息送達保證機制實現(xiàn)(二):保證離線消息的可靠投遞》
《如何保證IM實時消息的“時序性”與“一致性”?》
《一個低成本確保IM消息時序的方法探討》
《IM單聊和群聊中的在線狀態(tài)同步應(yīng)該用“推”還是“拉”?》
《IM群聊消息如此復雜,如何保證不丟不重?》
《談?wù)勔苿佣?IM 開發(fā)中登錄請求的優(yōu)化》
《移動端IM登錄時拉取數(shù)據(jù)如何作到省流量?》
《淺談移動端IM的多點登錄和消息漫游原理》
《完全自已開發(fā)的IM該如何設(shè)計“失敗重試”機制?》
《通俗易懂:基于集群的移動端IM接入層負載均衡方案分享》
《微信對網(wǎng)絡(luò)影響的技術(shù)試驗及分析(論文全文)》
《微信技術(shù)分享:微信的海量IM聊天消息序列號生成實踐(算法原理篇)》
《自已開發(fā)IM有那么難嗎?手把手教你自擼一個Andriod版簡易IM (有源碼)》
《融云技術(shù)分享:解密融云IM產(chǎn)品的聊天消息ID生成策略》
《適合新手:從零開發(fā)一個IM服務(wù)端(基于Netty,有完整源碼)》
《拿起鍵盤就是干:跟我一起徒手開發(fā)一套分布式IM系統(tǒng)》
《適合新手:手把手教你用Go快速搭建高性能、可擴展的IM系統(tǒng)(有源碼)》
《IM里“附近的人”功能實現(xiàn)原理是什么?如何高效率地實現(xiàn)它?》
《IM消息ID技術(shù)專題(一):微信的海量IM聊天消息序列號生成實踐(算法原理篇)》
《IM開發(fā)寶典:史上最全,微信各種功能參數(shù)和邏輯規(guī)則資料匯總》
《IM開發(fā)干貨分享:我是如何解決大量離線消息導致客戶端卡頓的》
《零基礎(chǔ)IM開發(fā)入門(一):什么是IM系統(tǒng)?》
《零基礎(chǔ)IM開發(fā)入門(二):什么是IM系統(tǒng)的實時性?》
《零基礎(chǔ)IM開發(fā)入門(三):什么是IM系統(tǒng)的可靠性?》
《零基礎(chǔ)IM開發(fā)入門(四):什么是IM系統(tǒng)的消息時序一致性?》
《一套億級用戶的IM架構(gòu)技術(shù)干貨(下篇):可靠性、有序性、弱網(wǎng)優(yōu)化等》
《IM掃碼登錄技術(shù)專題(三):通俗易懂,IM掃碼登錄功能詳細原理一篇就夠》
《理解IM消息“可靠性”和“一致性”問題,以及解決方案探討》
《阿里技術(shù)分享:閑魚IM基于Flutter的移動端跨端改造實踐》
《融云技術(shù)分享:全面揭秘億級IM消息的可靠投遞機制》
《IM開發(fā)干貨分享:如何優(yōu)雅的實現(xiàn)大量離線消息的可靠投遞》
《IM開發(fā)干貨分享:有贊移動端IM的組件化SDK架構(gòu)設(shè)計實踐》
《IM開發(fā)干貨分享:網(wǎng)易云信IM客戶端的聊天消息全文檢索技術(shù)實踐》
>>?更多同類文章 ……
本文已同步發(fā)布于“即時通訊技術(shù)圈”公眾號。
同步發(fā)布鏈接是:http://www.52im.net/thread-3732-1-1.html