[Android]搭建原生聊天架構(gòu),用到了3次握手思想...

項目已交接,暫不再更新。

-----------------------------------------------------分割線-----------------------------------------------------------

新做了聊天業(yè)務(wù),和后臺一起,都是原生搭建的。目前為止開發(fā)了聊天室,還沒有做聊天房間列表。已經(jīng)開發(fā)出簡版~ 過程有很多彎路和坑,留下記錄,給小伙伴們提供思路,也希望能一起討論還有什么可以改進的地方。
(邊開發(fā)邊記錄,慢慢更新。文章太長,作者太懶…)

本文不會重點記錄代碼,主要記錄重點部分的實現(xiàn)邏輯。

本文撰寫過程中,老大對后臺架構(gòu)做了改版,新版非常強大,原來客戶端的很多控制和判斷都不再需要,膜拜老大。
(這個老大真的很厲害,我們辦公室有很多人減肥,都減不下來,但他從某天決定開始減肥之后,一個月瘦10斤,現(xiàn)在保持健身。很有意志力。)

聊天流程


已更新。新架構(gòu)下的流程:
進入房間,通過url得到socket topic(相當(dāng)于服務(wù)器中以房間為單位的業(yè)務(wù)處理器),連接socket,請求到未讀消息存入本地數(shù)據(jù)庫,然后通過數(shù)據(jù)庫獲取消息來顯示。
每次發(fā)送和接收消息,都存入數(shù)據(jù)庫。
發(fā)送時,通過消息隊列實現(xiàn)超時時間內(nèi)的持續(xù)發(fā)送(剛聽說這種實現(xiàn)有個高大上的名字叫做 消費者——生產(chǎn)者模式),兼容網(wǎng)絡(luò)不穩(wěn)定的情況。在1.3中有詳細說明。
發(fā)送成功和失敗后需要在頁面上(View)和數(shù)據(jù)庫(Model)里改變發(fā)送狀態(tài)。

業(yè)務(wù)內(nèi)容

  1. 消息的各種狀態(tài)處理
  2. 超時時間內(nèi)持續(xù)發(fā)送
  3. 網(wǎng)絡(luò)自動重連

1. 關(guān)鍵技術(shù)點

  • 1.1 消息3次握手
  • 1.2 socket重連
  • 1.3 消息隊列
  • 1.4 圖片加載——被迫造輪子

2. 邏輯繞彎點:

  • 2.1 顯示方案——消息如何排序
  • 2.2 聊天對話框的復(fù)用(左右 -> 融合兩側(cè)隱藏一邊)
  • 2.3 圖片復(fù)用非常重要,還要及時釋放!

3. 其他問題

  • 3.1 全屏下豎屏的系統(tǒng)缺陷(Android系統(tǒng)bug)
  • 3.2 發(fā)送表情時,listview不能滾動到底部

理想功能

  • 未發(fā)送成功的消息記錄,在下一次打開社區(qū)時發(fā)送到遠程,真正實現(xiàn)多端歷史記錄同步(未驗證微信QQ是否有此功能,有待驗證)

1.關(guān)鍵技術(shù)點


1.1 每條消息的3次握手

握手過程圖:

消息的3次握手示意圖.png

第一次:客戶端發(fā)送消息到服務(wù)器
第二次:服務(wù)器成功接收消息,返回反饋信息到客戶端
第三次:本地接收到反饋信息,發(fā)送已收到反饋到服務(wù)器

握手的3個環(huán)節(jié)中,任何一個環(huán)節(jié)都有可能發(fā)送失敗。將導(dǎo)致牽扯出消息重復(fù)、客戶端與服務(wù)端記錄的消息狀態(tài)不同步、退出聊天頁面再次進入后歷史消息有重疊等等的可能情況,鑒于太過復(fù)雜,開發(fā)周期太長,目前的初版有很多處理還沒有開發(fā)。暫留做升級功能。

-------------------更新--------------------
部門老大出手對后臺改了架構(gòu),服務(wù)器把消息發(fā)送到服務(wù)端時對TCP的發(fā)送狀態(tài)做了監(jiān)聽,所以3次握手成功省略掉第3個環(huán)節(jié),進化成2次握手~ !鼓掌慶祝~!
所以發(fā)送成功的處理與客戶端分離,客戶端只需要接收消息并顯示,成功回歸無腦本質(zhì),非常nice。

1.2 聊天的基礎(chǔ)——socket

保持連接——輪詢聯(lián)網(wǎng)
因為聊天功能的實時通訊性質(zhì),必須與服務(wù)器保持連接,所以聊天時,持續(xù)的連接是必須的。每次斷開,都要能自動重連上。
所以這就是第一個輪詢:socket重連機制。

之所以列為機制的地位,必然是踩了不少坑。

以下是輪詢相關(guān)代碼:

// 重連
private boolean isReconnecting = false;
// 重連時間間隔
private final int reconnectDeliver = 5000;

Runnable reconnectRunnable = new Runnable() {
    @Override
    public void run() {
        if (isConnecting()) {
            // 停止重連
            isReconnecting = false;
        } else {
            connect(applicationContext);
            handler.postDelayed(reconnectRunnable, reconnectDeliver);
        }
    }
};

socket = new WebSocket() {
    onOpen() {
        isReconnecting = false;
    }
    onClose() {
        // 調(diào)用重連
        onConnectFailed();
    }

    onError() {
        // 調(diào)用重連
        onConnectFailed();
    }
}

onConnectFailed() {
    // reconnect
    if (isReconnecting == false) {
        isReconnecting = true;
        handler.postDelayed(reconnectRunnable, reconnectDeliver);
    }

    // 移除心跳機制,在下一次連接成功后再開啟,不清空會導(dǎo)致ping越來越頻繁
    handler.removeCallbacks(heartbeatRunnable);
}

最后別忘了,在要手動關(guān)閉socket的方法里面,要關(guān)掉重連機制哦,不然會又自動連上。(也要關(guān)閉心跳機制)

public void close() {
    // 移除重連輪詢
    handler.removeCallbacks(reconnectRunnable);
    // 移除心跳
    handler.removeCallbacks(heartbeatRunnable);
    if (socket != null) {
        socket.close();
    }
}

1.3 確保發(fā)送——未發(fā)送成功的消息在超時時間內(nèi)輪詢發(fā)送

表現(xiàn)效果:
觀察QQ和微信的斷網(wǎng)下發(fā)送消息,發(fā)現(xiàn)在數(shù)分鐘內(nèi)會持續(xù)發(fā)送狀態(tài),再次恢復(fù)網(wǎng)絡(luò)后,一兩秒成功發(fā)送。本次使用的消息輪詢機制即是參考這種實現(xiàn)效果。

技術(shù)關(guān)鍵:

  1. 存儲發(fā)送中消息的消息隊列
  2. 每條消息的發(fā)送超時時長(從發(fā)送到發(fā)送失敗的時長)
  3. 輪詢發(fā)送的時間間隔

注意!需要后臺同志配合做去重處理!因為輪詢保持3秒一次,但任何時候都有可能發(fā)送消息,所以有可能在發(fā)送的幾乎同時輪詢,又發(fā)送了一次,所以導(dǎo)致會在收到消息反饋之前重復(fù)發(fā)送到服務(wù)器,服務(wù)器會接到2條所以需要后臺做去重的處理。

實現(xiàn)邏輯:
每條消息在發(fā)送的同時添加進消息隊列,這條消息會一直在隊列中被輪詢發(fā)送,直到presenter收到了這條消息發(fā)送到服務(wù)器后發(fā)回的反饋,然后找到這條對應(yīng)的消息移除出發(fā)送隊列,于是這條消息的輪詢結(jié)束。
注意:實現(xiàn)此功能需要后臺配合,每條消息發(fā)送到服務(wù)器之后,服務(wù)器會發(fā)回(表示發(fā)送成功的)反饋信息

  1. 每個消息實體類有一個唯一key屬性
  2. 進入消息頁面,創(chuàng)建一個消息隊列(存儲消息bean)
  3. 每發(fā)送一條消息,添加進消息隊列
  4. 通過定時器(此處使用handler和runnable)每過n秒遍歷一次隊列,發(fā)送隊列中的所有消息
  5. 每條消息一旦成功發(fā)送到服務(wù)器,添加進隊列,這條消息會一直在隊列中被輪詢發(fā)送,直到presenter收到了這條消息發(fā)送到服務(wù)器后發(fā)送會的反饋,于是找到這條對應(yīng)的消息,移除出發(fā)送隊列,這條消息的輪詢結(jié)束。(可以使用先添加進隊列再發(fā)送的邏輯順序,雖然網(wǎng)絡(luò)信息往返的時間[即服務(wù)器收到消息后發(fā)回反饋信息過程中的耗時]很少會比本地操作快,但是邏輯上的萬無一失值得追求,如果養(yǎng)成習(xí)慣更能順手預(yù)防掉很多千奇百怪的坑)

簡單地說,就是有頁面里有一個消息隊列一直在輪詢發(fā)送未達消息,而每條消息根據(jù)自身發(fā)送情況進出隊列。

還有一個邏輯點,我項目需求是簡單版的聊天,不考慮關(guān)閉當(dāng)前聊天頁之后聊天消息的發(fā)送,也就是關(guān)閉頁面后所有還未發(fā)送成功的消息都當(dāng)做發(fā)送失敗。而這里為了達到這種效果,采取了最簡單粗暴的方法,每條消息發(fā)送出去后在數(shù)據(jù)庫里直接存儲為發(fā)送失敗。若發(fā)送成功收到回執(zhí)消息,則修改對應(yīng)消息的發(fā)送狀態(tài)為成功,否則失敗。
所以,再次打開頁面,顯示的歷史消息里,上次未發(fā)送成功的也就顯示為發(fā)送失敗了。不需要額外修改數(shù)據(jù)庫和判斷。
對上面這部分邏輯有疑問的盆友可以好好想一想,歡迎討論。

代碼:
創(chuàng)建消息隊列,在打開頁面時開啟(view的onCreate中創(chuàng)建presenter,presenter構(gòu)造函數(shù)里開始輪詢)。懶加載或者直接寫成屬性field都可以,這里直接寫成field,key的類型看具體情況。

private Map<Integer, ChatBean> messageQueue = new HashMap();

發(fā)送時添加進隊列

public void send() {
    messageQueue.add();
    // send to server
    ...
}

收到消息反饋,從隊列移除

private void onResponse(response) {
    switch (response.type) {
        case msg_update: // 服務(wù)器接收到某條消息的反饋
            // get msg key
            int msgKey = ...;
            updateMsg
            break;
        case message:
            break;
    }
}

private void updateMsg(int msgKey) {
    messageQueue.remove(msgKey);
}

以上操作并不會對服務(wù)器帶來額外的訪問壓力,因為每條消息發(fā)送到服務(wù)器后就不會再發(fā)送第二次。
(此處有一個問題,如果消息發(fā)送到服務(wù)器,但服務(wù)器發(fā)回反饋信息時出現(xiàn)問題,無法發(fā)送回客戶端,則可能服務(wù)器會在一定時間內(nèi),最多會持續(xù)到消息超時前,一直接收到該消息。但是這種情況是否有可能存在或者會在什么情況下可能存在,需要查證網(wǎng)絡(luò)通訊相關(guān)資料驗證。)

2.邏輯繞彎點


2.1 顯示方案

考慮情況:
所有成功接收和發(fā)送的消息保存的都是網(wǎng)絡(luò)時間。但存在一種情況,即用戶修改系統(tǒng)時間
此時:

  • 發(fā)送成功會保存網(wǎng)絡(luò)時間
  • 無網(wǎng)絡(luò)發(fā)送失敗,會保存本地時間,而可能是1999年

所以最初犯了一個錯誤,為了兼容這種情況,每次收發(fā)一條消息顯示時,都會按照時間重新排序所有消息。但出現(xiàn)問題,即每次收發(fā)消息,很容易出現(xiàn)所有消息排序混亂的情況。

而重新理解聊天業(yè)務(wù)線后發(fā)現(xiàn),實際上整個對話其實相當(dāng)于一個時間軸。每次發(fā)送,每次接收,都即時保存在數(shù)據(jù)庫里。所以,數(shù)據(jù)庫里保存的順序,就是正確的時間順序,無須重新排序。數(shù)據(jù)庫存儲的順序其實就是用戶真正發(fā)送的時間順序,所以用戶修改系統(tǒng)時間與否都沒關(guān)系,無須重新排序
依照這個方向,開始重新整理發(fā)送邏輯和顯示邏輯。成功解決歷史消息的顯示問題。

3.其他問題


3.1 全屏下豎屏的系統(tǒng)缺陷(Android系統(tǒng)bug)

需求:因為做的是游戲內(nèi)插入的SDK,游戲都是全屏的。而sdk兼容橫豎屏兩種狀態(tài),所以在最普遍使用的豎屏狀態(tài)下,出現(xiàn)全屏&豎屏這個問題。

問題:Android系統(tǒng)在全屏下豎屏?xí)r,軟鍵盤彈出會頂出整個布局,相當(dāng)于不管你設(shè)置成什么樣,都無法成為adjustResize的效果,只有adjustPan的效果。

所以我在

  1. 嘗試設(shè)置adjustResize / adjstPan / ...。照樣頂起布局,失敗
  2. 嘗試設(shè)置為絕對布局。無效,失敗
  3. 嘗試在絕對布局下:
    1. 設(shè)置軟鍵盤彈出時頁面不動
    2. 監(jiān)聽軟鍵盤彈出時,獲取軟鍵盤高度,把輸入框手動弄上去。
    最后還是失敗,原因:只有在設(shè)置為adjustResize | adjstPan時,軟鍵盤彈出頁面不會動,不再整體上移。但此時,軟鍵盤監(jiān)聽的接口失效,無法收到軟鍵盤彈起的事件(不知道什么時候被彈起),也無法得到軟鍵盤高度。
  4. 監(jiān)聽軟鍵盤彈起時,根據(jù)軟鍵盤的高度計算出屏幕剩余高度,設(shè)置視圖高度為屏幕剩余高度。結(jié)果出現(xiàn)每次彈起屏幕頁面都會重繪導(dǎo)致頁面白閃,無法接受,失敗。
    ...(省略一萬字)

心態(tài)快要崩了。
最后得知真相的我對google說了一萬句MMP。
(以上記錄了一位程序員黑發(fā)變白發(fā)最后禿頂?shù)男穆窔v程)

最后沒有辦法,

  1. 把activity設(shè)置成帶頭部欄的樣式Theme.NoTitleBar
  2. 在xml文件里設(shè)置最外層使用LinearLayout,中間的listview設(shè)置為width = 0, weight = 1

于是一切正常

注釋:真的是憋出內(nèi)傷的系統(tǒng)bug。因為出現(xiàn)問題往往最相信的是系統(tǒng),但是懷疑自己懷疑代碼懷疑人生懷疑世界之后,實在沒辦法了,才發(fā)現(xiàn)是系統(tǒng)TM挖的坑。

3.2 發(fā)送帶有表情(SpannableString)的消息,listview不能滾動到底部。

參考:android的listview中setselection()不起作用的解決方案
事實證明,簡單粗暴往往很有效。

解決辦法:

list.setAdapter(adapter);  // or  list.setAdapter(list.getAdapter())
list.setPosition(...);

辣眼睛,但是沒辦法。

最后嘮嗑


最近一直在思考搬磚和我的人生價值實現(xiàn)到底有幾毛錢的關(guān)系。然而面包就是面包,再怎么不爽都只能先填飽肚子才有力氣較勁兒。唉。
對于程序員而言,最重要的不是某個具體技能,而是解決問題的思路和創(chuàng)造思路的手段。
本文持續(xù)更新中,見證后臺架構(gòu)與前臺架構(gòu)的迭代。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 134,993評論 19 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,596評論 25 708
  • 來源 RabbitMQ是用Erlang實現(xiàn)的一個高并發(fā)高可靠AMQP消息隊列服務(wù)器。支持消息的持久化、事務(wù)、擁塞控...
    jiangmo閱讀 10,409評論 2 34
  • 一、 Spring技術(shù)概述1、什么是Spring : Spring是分層的JavaSE/EE full-stack...
    luweicheng24閱讀 741評論 0 1
  • 藝術(shù)品需要時間的磨礪,才能展現(xiàn)出最美的光輝。每一件稱之為有劃時代意義的作品,不僅代表了當(dāng)時審美高度和技術(shù)水準,同時...
    影子倒了閱讀 395評論 1 6