開源一個自用的Android IM庫,基于Netty+TCP+Protobuf實現(xiàn)

歡迎轉(zhuǎn)載,轉(zhuǎn)載請注明出處:http://www.itdecent.cn/p/00ba0ac2fc96

寫在前面

一直想寫一篇關(guān)于im即時通訊分享的文章,無奈工作太忙,很難抽出時間。今天終于從公司離職了,打算好好休息幾天再重新找工作,趁時間空閑,決定靜下心來寫一篇文章,畢竟從前輩那里學(xué)到了很多東西。工作了五年半,這三四年來一直在做社交相關(guān)的項目,有
直播
即時通訊、
短視頻分享、
社區(qū)論壇
等產(chǎn)品,深知即時通訊技術(shù)在一個項目中的重要性,本著開源分享的精神,也趁這機會總結(jié)一下,所以寫下這篇文章,文中有不對之處歡迎批評與指正。

本文將介紹:

  • Protobuf序列化
  • TCP拆包與粘包
  • 長連接握手認(rèn)證
  • 心跳機制
  • 重連機制
  • 消息重發(fā)機制
  • 讀寫超時機制
  • 離線消息
  • 線程池
  • AIDL跨進程通信

本想花一部分時間介紹一下利用AIDL實現(xiàn)多進程通信,提升應(yīng)用保活率,無奈這種方法在目前大部分Android新版本上已失效,而且也比較復(fù)雜,所以考慮再三,把AIDL這一部分去掉,需要了解的童鞋可以私信我。

先來看看效果:


最終運行效果

不想看文章的同學(xué)可以直接移步到Github fork源碼:github地址

接下來,讓我們進入正題。


為什么使用TCP?

這里需要簡單解釋一下,TCP/UDP/WebSocket的區(qū)別。
這里就很好地解釋了TCP/UDP的優(yōu)缺點和區(qū)別,以及適用場景,簡單地總結(jié)一下:

  • 優(yōu)點:

    • TCP的優(yōu)點體現(xiàn)在穩(wěn)定、可靠上,在傳輸數(shù)據(jù)之前,會有三次握手來建立連接,而且在數(shù)據(jù)傳遞時,有確認(rèn)、窗口、重傳、擁塞控制機制,在數(shù)據(jù)傳完之后,還會斷開連接用來節(jié)約系統(tǒng)資源。
    • UDP的優(yōu)點體現(xiàn)在,比TCP稍安全,UDP沒有TCP擁有的各種機制,是一個無狀態(tài)的傳輸協(xié)議,所以傳遞數(shù)據(jù)非常快,沒有TCP的這些機制,被攻擊利用的機制就少一些,但是也無法避免被攻擊。
  • 缺點:

    • TCP缺點就是,效率低,占用系統(tǒng)資源高易被攻擊,TCP在傳遞數(shù)據(jù)之前要先建立連接,這會消耗時間,而且在數(shù)據(jù)傳遞時,確認(rèn)機制、重傳機制、擁塞機制等都會消耗大量時間,而且要在每臺設(shè)備上維護所有的傳輸連接。
    • UDP缺點就是不可靠不穩(wěn)定,因為沒有TCP的那些機制,UDP在傳輸數(shù)據(jù)時,如果網(wǎng)絡(luò)質(zhì)量不好,就會很容易丟包,造成數(shù)據(jù)的缺失。
  • 適用場景:

    • TCP:當(dāng)對網(wǎng)絡(luò)通訊質(zhì)量有要求時,比如HTTP、HTTPS、FTP等傳輸文件的協(xié)議, POP、SMTP等郵件傳輸?shù)膮f(xié)議。
    • UDP:對網(wǎng)絡(luò)通訊質(zhì)量要求不高時,要求網(wǎng)絡(luò)通訊速度要快的場景。

至于WebSocket,后續(xù)可能會專門寫一篇文章來介紹。
綜上所述,決定采用TCP協(xié)議。


為什么使用Protobuf?

對于App網(wǎng)絡(luò)傳輸協(xié)議,我們比較常見的、可選的,有三種,分別是json/xml/protobuf,老規(guī)矩,我們先分別來看看這三種格式的優(yōu)缺點:

  • 優(yōu)點:

    • json優(yōu)點就是較XML格式更加小巧,傳輸效率較xml提高了很多,可讀性還不錯。
    • xml優(yōu)點就是可讀性強,解析方便。
    • protobuf優(yōu)點就是傳輸效率快(據(jù)說在數(shù)據(jù)量大的時候,傳輸效率比xml和json快10-20倍),序列化后體積相比Json和XML很小,支持跨平臺多語言,消息格式升級和兼容性還不錯,序列化反序列化速度很快。
  • 缺點:

    • json缺點就是傳輸效率也不是特別高(比xml快,但比protobuf要慢很多)。
    • xml缺點就是效率不高,資源消耗過大。
    • protobuf缺點就是使用不太方便。

在一個需要大量的數(shù)據(jù)傳輸?shù)膱鼍爸?,如果?shù)據(jù)量很大,那么選擇protobuf可以明顯的減少數(shù)據(jù)量,減少網(wǎng)絡(luò)IO,從而減少網(wǎng)絡(luò)傳輸所消耗的時間??紤]到作為一個主打社交的產(chǎn)品,消息數(shù)據(jù)量會非常大,同時為了節(jié)約流量,所以采用protobuf是一個不錯的選擇。


為什么使用Netty?

首先,我們來了解一下,Netty到底是個什么東西。網(wǎng)絡(luò)上找到的介紹:Netty是由JBOSS提供的基于Java NIO的開源框架,Netty提供異步非阻塞、事件驅(qū)動、高性能、高可靠、高可定制性的網(wǎng)絡(luò)應(yīng)用程序和工具,可用于開發(fā)服務(wù)端和客戶端。

  • 為什么不用Java BIO?

    • 一連接一線程,由于線程數(shù)是有限的,所以這樣非常消耗資源,最終也導(dǎo)致它不能承受高并發(fā)連接的需求。
    • 性能低,因為頻繁的進行上下文切換,導(dǎo)致CUP利用率低。
    • 可靠性差,由于所有的IO操作都是同步的,即使是業(yè)務(wù)線程也如此,所以業(yè)務(wù)線程的IO操作也有可能被阻塞,這將導(dǎo)致系統(tǒng)過分依賴網(wǎng)絡(luò)的實時情況和外部組件的處理能力,可靠性大大降低。
  • 為什么不用Java NIO?

    • NIO的類庫和API相當(dāng)復(fù)雜,使用它來開發(fā),需要非常熟練地掌握Selector、ByteBuffer、ServerSocketChannel、SocketChannel等。
    • 需要很多額外的編程技能來輔助使用NIO,例如,因為NIO涉及了Reactor線程模型,所以必須必須對多線程和網(wǎng)絡(luò)編程非常熟悉才能寫出高質(zhì)量的NIO程序。
    • 想要有高可靠性,工作量和難度都非常的大,因為服務(wù)端需要面臨客戶端頻繁的接入和斷開、網(wǎng)絡(luò)閃斷、半包讀寫、失敗緩存、網(wǎng)絡(luò)阻塞的問題,這些將嚴(yán)重影響我們的可靠性,而使用原生NIO解決它們的難度相當(dāng)大。
    • JDK NIO中著名的BUG--epoll空輪詢,當(dāng)select返回0時,會導(dǎo)致Selector空輪詢而導(dǎo)致CUP100%,官方表示JDK1.6之后修復(fù)了這個問題,其實只是發(fā)生的概率降低了,沒有根本上解決。
  • 為什么用Netty?

    • API使用簡單,更容易上手,開發(fā)門檻低
    • 功能強大,預(yù)置了多種編解碼功能,支持多種主流協(xié)議
    • 定制能力高,可以通過ChannelHandler對通信框架進行靈活地拓展
    • 高性能,與目前多種NIO主流框架相比,Netty綜合性能最高
    • 高穩(wěn)定性,解決了JDK NIO的BUG
    • 經(jīng)歷了大規(guī)模的商業(yè)應(yīng)用考驗,質(zhì)量和可靠性都有很好的驗證。

以上摘自:為什么要用Netty開發(fā)

  • 為什么不用第三方SDK,如:融云、環(huán)信、騰訊TIM?
    這個就見仁見智了,有的時候,是因為公司的技術(shù)選型問題,因為用第三方的SDK,意味著消息數(shù)據(jù)需要存儲到第三方的服務(wù)器上,再者,可擴展性、靈活性肯定沒有自己開發(fā)的要好,還有一個小問題,就是收費。比如,融云免費版只支持100個注冊用戶,超過100就要收費,群聊支持人數(shù)有限制等等...
    融云收費

Mina其實跟Netty很像,大部分API都相同,因為是同一個作者開發(fā)的。但感覺Mina沒有Netty成熟,在使用Netty的過程中,出了問題很輕易地可以找到解決方案,所以,Netty是一個不錯的選擇。

好了,廢話不多說,直接開始吧。


準(zhǔn)備工作

  • 首先,我們新建一個Project,在Project里面再新建一個Android Library,Module名稱暫且叫做im_lib,如圖所示:


    新建項目
  • 然后,分析一下我們的消息結(jié)構(gòu),每條消息應(yīng)該會有一個消息唯一id,發(fā)送者id,接收者id,消息類型,發(fā)送時間等,經(jīng)過分析,整理出一個通用的消息類型,如下:

    • msgId 消息id
    • fromId 發(fā)送者id
    • toId 接收者id
    • msgType 消息類型
    • msgContentType 消息內(nèi)容類型
    • timestamp 消息時間戳
    • statusReport 狀態(tài)報告
    • extend 擴展字段

    根據(jù)上述所示,我整理了一個思維導(dǎo)圖,方便大家參考:

    消息結(jié)構(gòu)

    這是基礎(chǔ)部分,當(dāng)然,大家也可以根據(jù)自己需要自定義比較適合自己的消息結(jié)構(gòu)。

    我們根據(jù)自定義的消息類型來編寫proto文件。


    編寫proto文件

    然后執(zhí)行命令(我用的mac,windows命令應(yīng)該也差不多):


    執(zhí)行protoc命令

    然后就會看到,在和proto文件同級目錄下,會生成一個java類,這個就是我們需要用到的東東:
    生成的protobuf java類文件

    我們打開瞄一眼:


    打開的protobuf java類文件

    東西比較多,不用去管,這是google為我們生成的protobuf類,直接用就行,怎么用呢?直接用這個類文件,拷到我們開始指定的項目包路徑下就可以啦:
    導(dǎo)入protobuf java類文件到項目中

    添加依賴后,可以看到,MessageProtobuf類文件已經(jīng)沒有報錯了,順便把netty的jar包也導(dǎo)進來一下,還有fastjson的:
    導(dǎo)入protobuf以及netty的依賴

    建議用netty-all-x.x.xx.Final的jar包,后續(xù)熟悉了,可以用精簡的jar包。

    至此,準(zhǔn)備工作已結(jié)束,下面,我們來編寫java代碼,實現(xiàn)即時通訊的功能。


封裝

為什么需要封裝呢?說白了,就是為了解耦,為了方便日后切換到不同框架實現(xiàn),而無需到處修改調(diào)用的地方。舉個栗子,比如Android早期比較流行的圖片加載框架是Universal ImageLoader,后期因為某些原因,原作者停止了維護該項目,目前比較流行的圖片加載框架是Picasso或Glide,因為圖片加載功能可能調(diào)用的地方非常多,如果不作一些封裝,早期使用了Universal ImageLoader的話,現(xiàn)在需要切換到Glide,那改動量將非常非常大,而且還很有可能會有遺漏,風(fēng)險度非常高。

那么,有什么解決方案呢?

很簡單,我們可以用工廠設(shè)計模式進行一些封裝,工廠模式有三種:簡單工廠模式、抽象工廠模式、工廠方法模式。在這里,我采用工廠方法模式進行封裝,具體區(qū)別,可以參見:通俗講講我對簡單工廠、工廠方法、抽象工廠三種設(shè)計模式的理解

我們分析一下,ims(IM Service,下文簡稱ims)應(yīng)該是有初始化、建立連接重連、關(guān)閉連接、釋放資源、判斷長連接是否關(guān)閉、發(fā)送消息等功能,基于上述分析,我們可以進行一個接口抽象:

抽象的ims接口1

抽象的ims接口2

OnEventListener是與應(yīng)用層交互的listener:
OnEventListener

IMConnectStatusCallback是ims連接狀態(tài)回調(diào)監(jiān)聽器:
IMConnectStatusCallback

然后寫一個Netty tcp實現(xiàn)類:


Netty tcp ims1

Netty tcp ims2

接下來,寫一個工廠方法:


ims實例工廠方法

封裝部分到此結(jié)束,接下來,就是實現(xiàn)了。


初始化

我們先實現(xiàn)init(Vector<String> serverUrlList, OnEventListener listener, IMSConnectStatusCallback callback)方法,初始化一些參數(shù),以及進行第一次連接等:

初始化參數(shù)

其中,MsgDispatcher是消息轉(zhuǎn)發(fā)器,負(fù)責(zé)將接收到的消息轉(zhuǎn)發(fā)到應(yīng)用層:

MsgDispatcher

ExecutorServiceFactory是線程池工廠,負(fù)責(zé)調(diào)度重連及心跳線程:

ExecutorServiceFactory1

ExecutorServiceFactory2

ExecutorServiceFactory3


連接及重連

resetConnect()方法作為連接的起點,首次連接以及重連邏輯,都是在resetConnect()方法進行邏輯處理,我們來瞄一眼:

resetConnect

可以看到,非首次進行連接,也就是連接一個周期失敗后,進行重連時,會先讓線程休眠一段時間,因為這個時候也許網(wǎng)絡(luò)狀況不太好,接著,判斷ims是否已關(guān)閉或者是否正在進行重連操作,由于重連操作是在子線程執(zhí)行,為了避免重復(fù)重連,需要進行一些并發(fā)處理。開始重連任務(wù)后,分四個步驟執(zhí)行:

  • 改變重連狀態(tài)標(biāo)識
  • 回調(diào)連接狀態(tài)到應(yīng)用層
  • 關(guān)閉之前打開的連接channel
  • 利用線程池執(zhí)行一個新的重連任務(wù)

ResetConnectRunnable是重連任務(wù),核心的重連邏輯都放到這里執(zhí)行:

ResetConnectRunnable1

ResetConnectRunnable2

ResetConnectRunnable3

toServer()是真正連接服務(wù)器的地方:

toServer

initBootstrap()是初始化Netty Bootstrap:

initBootstrap

注:NioEventLoopGroup線程數(shù)設(shè)置為4,可以滿足QPS是一百多萬的情況了,至于應(yīng)用如果需要承受上千萬上億流量的,需要另外調(diào)整線程數(shù)。參考自:netty實戰(zhàn)之百萬級流量NioEventLoopGroup線程數(shù)配置

接著,我們來看看TCPChannelInitializerHanlder

TCPChannelInitializerHandler

其中,ProtobufEncoderProtobufDecoder是添加對protobuf的支持,LoginAuthRespHandler是接收到服務(wù)端握手認(rèn)證消息響應(yīng)的處理handler,HeartbeatRespHandler是接收到服務(wù)端心跳消息響應(yīng)的處理handler,TCPReadHandler是接收到服務(wù)端其它消息后的處理handler,先不去管,我們重點來分析下LengthFieldPrependerLengthFieldBasedFrameDecoder,這就需要引申到TCP的拆包與粘包啦。


TCP的拆包與粘包

  • 什么是TCP拆包?為什么會出現(xiàn)TCP拆包?

    簡單地說,我們都知道TCP是以“流”的形式進行數(shù)據(jù)傳輸?shù)模襎CP為提高性能,發(fā)送端會將需要發(fā)送的數(shù)據(jù)刷入緩沖區(qū),等待緩沖區(qū)滿了之后,再將緩沖區(qū)中的數(shù)據(jù)發(fā)送給接收方,同理,接收方也會有緩沖區(qū)這樣的機制,來接收數(shù)據(jù)。
    拆包就是在socket讀取時,沒有完整地讀取一個數(shù)據(jù)包,只讀取一部分。

  • 什么是TCP粘包?為什么會出現(xiàn)TCP粘包?

    同上。
    粘包就是在socket讀取時,讀到了實際意義上的兩個或多個數(shù)據(jù)包的內(nèi)容,同時將其作為一個數(shù)據(jù)包進行處理。

引用網(wǎng)上一張圖片來解釋一下在TCP出現(xiàn)拆包、粘包以及正常狀態(tài)下的三種情況,如侵請聯(lián)系我刪除:


TCP拆包、粘包、正常狀態(tài)

了解了TCP出現(xiàn)拆包/粘包的原因,那么,如何解決呢?通常來說,有以下四種解決方式:

  • 消息定長
  • 用回車換行符作為消息結(jié)束標(biāo)志
  • 用特殊分隔符作為消息結(jié)束標(biāo)志,如\t、\n等,回車換行符其實就是特殊分隔符的一種。
  • 將消息分為消息頭和消息體,在消息頭中用字段標(biāo)識消息總長度。

netty針對以上四種場景,給我們封裝了以下四種對應(yīng)的解碼器:

  • FixedLengthFrameDecoder,定長消息解碼器
  • LineBasedFrameDecoder,回車換行符消息解碼器
  • DelimiterBasedFrameDecoder,特殊分隔符消息解碼器
  • LengthFieldBasedFrameDecoder,自定義長度消息解碼器。

我們用到的就是LengthFieldBasedFrameDecoder自定義長度消息解碼器,同時配合LengthFieldPrepender編碼器使用,關(guān)于參數(shù)配置,建議參考netty--最通用TCP黏包解決方案:LengthFieldBasedFrameDecoder和LengthFieldPrepender這篇文章,講解得比較細(xì)致。我們配置的是消息頭長度為2個字節(jié),所以消息包的最大長度需要小于65536個字節(jié),netty會把消息內(nèi)容長度存放消息頭的字段里,接收方可以根據(jù)消息頭的字段拿到此條消息總長度,當(dāng)然,netty提供的LengthFieldBasedFrameDecoder已經(jīng)封裝好了處理邏輯,我們只需要配置lengthFieldOffset、lengthFieldLength、lengthAdjustment、initialBytesToStrip即可,這樣就可以解決TCP的拆包與粘包,這也就是netty相較于原生nio的便捷性,原生nio需要自己處理拆包/粘包等問題。


長連接握手認(rèn)證

接著,我們來看看LoginAuthHandlerHeartbeatRespHandler

  • LoginAuthRespHandler是當(dāng)客戶端與服務(wù)端長連接建立成功后,客戶端主動向服務(wù)端發(fā)送一條登錄認(rèn)證消息,帶入與當(dāng)前用戶相關(guān)的參數(shù),比如token,服務(wù)端收到此消息后,到數(shù)據(jù)庫查詢該用戶信息,如果是合法有效的用戶,則返回一條登錄成功消息給該客戶端,反之,返回一條登錄失敗消息給該客戶端,這里,就是在接收到服務(wù)端返回的登錄狀態(tài)后的處理handler,比如:

    LoginAuthRespHandler

    可以看到,當(dāng)接收到服務(wù)端握手消息響應(yīng)后,會從擴展字段取出status,如果status=1,則代表握手成功,這個時候就先主動向服務(wù)端發(fā)送一條心跳消息,然后利用Netty的IdleStateHandler讀寫超時機制,定期向服務(wù)端發(fā)送心跳消息,維持長連接,以及檢測長連接是否還存在等。

  • HeartbeatRespHandler是當(dāng)客戶端接收到服務(wù)端登錄成功的消息后,主動向服務(wù)端發(fā)送一條心跳消息,心跳消息可以是一個空包,消息包體越小越好,服務(wù)端收到客戶端的心跳包后,原樣返回給客戶端,這里,就是收到服務(wù)端返回的心跳消息響應(yīng)的處理handler,比如:

    HeartbeatRespHandler

    這個就比較簡單,收到心跳消息響應(yīng),無需任務(wù)處理,直接打印一下方便我們分析即可。


心跳機制及讀寫超時機制

心跳包是定期發(fā)送,也可以自己定義一個周期,比如Android微信智能心跳方案,為了簡單,此處規(guī)定應(yīng)用在前臺時,8秒發(fā)送一個心跳包,切換到后臺時,30秒發(fā)送一次,根據(jù)自己的實際情況修改一下即可。心跳包用于維持長連接以及檢測長連接是否斷開等。

接著,我們利用Netty的讀寫超時機制,來實現(xiàn)一個心跳消息管理handler:

HeartbeatHandler

可以看到,利用userEventTriggered()方法回調(diào),通過IdleState類型,可以判斷讀超時/寫超時/讀寫超時,這個在添加IdleStateHandler時可以配置,下面會貼上代碼。首先我們可以在READER_IDLE事件里,檢測是否在規(guī)定時間內(nèi)沒有收到服務(wù)端心跳包響應(yīng),如果是,那就觸發(fā)重連操作。在WRITER_IDEL事件可以檢測客戶端是否在規(guī)定時間內(nèi)沒有向服務(wù)端發(fā)送心跳包,如果是,那就主動發(fā)送一個心跳包。發(fā)送心跳包是在子線程中執(zhí)行,我們可以利用之前寫的work線程池進行線程管理。
addHeartbeatHandler()代碼如下:
addHeartbeatHandler

從圖上可看到,在IdleStateHandler里,配置的讀超時為心跳間隔時長的3倍,也就是3次心跳沒有響應(yīng)時,則認(rèn)為長連接已斷開,觸發(fā)重連操作。寫超時則為心跳間隔時長,意味著每隔heartbeatInterval會發(fā)送一個心跳包。讀寫超時沒用到,所以配置為0。

onConnectStatusCallback(int connectStatus)為連接狀態(tài)回調(diào),以及一些公共邏輯處理:


onConnectStatusCallback

連接成功后,立即發(fā)送一條握手消息,再次梳理一下整體流程:

  • 客戶端根據(jù)服務(wù)端返回的host及port,進行第一次連接。
  • 連接成功后,客戶端向服務(wù)端發(fā)送一條握手認(rèn)證消息(1001)
  • 服務(wù)端在收到客戶端的握手認(rèn)證消息后,從擴展字段里取出用戶token,到本地數(shù)據(jù)庫校驗合法性。
  • 校驗完成后,服務(wù)端把校驗結(jié)果通過1001消息返回給客戶端,也就是握手消息響應(yīng)。
  • 客戶端收到服務(wù)端的握手消息響應(yīng)后,從擴展字段取出校驗結(jié)果。若校驗成功,客戶端向服務(wù)端發(fā)送一條心跳消息(1002),然后進入心跳發(fā)送周期,定期間隔向服務(wù)端發(fā)送心跳消息,維持長連接以及實時檢測鏈路可用性,若發(fā)現(xiàn)鏈路不可用,等待一段時間觸發(fā)重連操作,重連成功后,重新開始握手/心跳的邏輯。

看看TCPReadHandler收到消息是怎么處理的:

TCPReadHandler1

TCPReadHandler2

可以看到,在channelInactive()及exceptionCaught()方法都觸發(fā)了重連,channelInactive()方法在當(dāng)鏈路斷開時會調(diào)用,exceptionCaught()方法在當(dāng)出現(xiàn)異常時會觸發(fā),另外,還有諸如channelUnregistered()、channelReadComplete()等方法可以重寫,在這里就不貼了,相信聰明的你一眼就能看出方法的作用。
我們仔細(xì)看一下channelRead()方法的邏輯,在if判斷里,先判斷消息類型,如果是服務(wù)端返回的消息發(fā)送狀態(tài)報告類型,則判斷消息是否發(fā)送成功,如果發(fā)送成功,從超時管理器中移除,這個超時管理器是干嘛的呢?下面講到消息重發(fā)機制的時候會詳細(xì)地講。在else里,收到其他消息后,會立馬給服務(wù)端返回一個消息接收狀態(tài)報告,告訴服務(wù)端,這條消息我已經(jīng)收到了,這個動作,對于后續(xù)需要做的離線消息會有作用。如果不需要支持離線消息功能,這一步可以省略。最后,調(diào)用消息轉(zhuǎn)發(fā)器,把接收到的消息轉(zhuǎn)發(fā)到應(yīng)用層即可。

代碼寫了這么多,我們先來看看運行后的效果,先貼上缺失的消息發(fā)送代碼及ims關(guān)閉代碼以及一些默認(rèn)配置項的代碼。
發(fā)送消息:

發(fā)送消息

關(guān)閉ims:
關(guān)閉ims

ims默認(rèn)配置:
ims默認(rèn)配置

還有,應(yīng)用層實現(xiàn)的ims client啟動器:
IMSClientBootstrap

由于代碼有點多,不太方便全部貼上,如果有興趣可以下載demo體驗。
額,對了,還有一個簡易的服務(wù)端代碼,如下:
NettyServerDemo1

NettyServerDemo2

NettyServerDemo3


調(diào)試

我們先來看看連接及重連部分(由于錄制gif比較麻煩,體積較大,所以我先把重連間隔調(diào)小成3秒,方便看效果)。

  • 啟動服務(wù)端:
    啟動服務(wù)端
  • 啟動客戶端:
    啟動客戶端

    可以看到,正常的情況下已經(jīng)連接成功了,接下來,我們來試一下異常情況,比如服務(wù)端沒啟動,看看客戶端的重連情況:


    調(diào)試重連

    這次我們先啟動的是客戶端,可以看到連接失敗后一直在進行重連,由于錄制gif比較麻煩,在第三次連接失敗后,我啟動了服務(wù)端,這個時候客戶端就會重連成功。

然后,我們再來調(diào)試一下握手認(rèn)證消息即心跳消息:


握手消息及心跳消息測試

可以看到,長連接建立成功后,客戶端會給服務(wù)端發(fā)送一條握手認(rèn)證消息(1001),服務(wù)端收到握手認(rèn)證消息會,給客戶端返回了一條握手認(rèn)證狀態(tài)消息,客戶端收到握手認(rèn)證狀態(tài)消息后,即啟動心跳機制。gif不太好演示,下載demo就可以直觀地看到。

接下來,在講完消息重發(fā)機制及離線消息后,我會在應(yīng)用層做一些簡單的封裝,以及在模擬器上運行,這樣就可以很直觀地看到運行效果。


消息重發(fā)機制

消息重發(fā),顧名思義,即使對發(fā)送失敗的消息進行重發(fā)??紤]到網(wǎng)絡(luò)環(huán)境的不穩(wěn)定性、多變性(比如從進入電梯、進入地鐵、移動網(wǎng)絡(luò)切換到wifi等),在消息發(fā)送的時候,發(fā)送失敗的概率其實不小,這時消息重發(fā)機制就很有必要了。
我們先來看看實現(xiàn)的代碼邏輯。
MsgTimeoutTimer:

MsgTimeoutTimer1

MsgTimeoutTimer2

MsgTimeoutTimerManager:
MsgTimeoutTimerManager1

MsgTimeoutTimerManager2

然后,我們看看收消息的TCPReadHandler的改造:
加入消息重發(fā)機制的TCPReadHandler

最后,看看發(fā)送消息的改造:
加入消息重發(fā)機制的發(fā)送消息

說一下邏輯吧:發(fā)送消息時,除了心跳消息、握手消息、狀態(tài)報告消息外,消息都加入消息發(fā)送超時管理器,立馬開啟一個定時器,比如每隔5秒執(zhí)行一次,共執(zhí)行3次,在這個周期內(nèi),如果消息沒有發(fā)送成功,會進行3次重發(fā),達(dá)到3次重發(fā)后如果還是沒有發(fā)送成功,那就放棄重發(fā),移除該消息,同時通過消息轉(zhuǎn)發(fā)器通知應(yīng)用層,由應(yīng)用層決定是否再次重發(fā)。如果消息發(fā)送成功,服務(wù)端會返回一個消息發(fā)送狀態(tài)報告,客戶端收到該狀態(tài)報告后,從消息發(fā)送超時管理器移除該消息,同時停止該消息對應(yīng)的定時器即可。
另外,在用戶握手認(rèn)證成功時,應(yīng)該檢查消息發(fā)送超時管理器里是否有發(fā)送超時的消息,如果有,則全部重發(fā):

握手認(rèn)證成功檢查是否有發(fā)送超時的消息


離線消息

由于離線消息機制,需要服務(wù)端數(shù)據(jù)庫及緩存上的配合,代碼就不貼了,太多太多,我簡單說一下實現(xiàn)思路吧:
客戶端A發(fā)送消息到客戶端B,消息會先到服務(wù)端,由服務(wù)端進行中轉(zhuǎn)。這個時候,客戶端B存在兩種情況:

  • 1.長連接正常,就是客戶端網(wǎng)絡(luò)環(huán)境良好,手機有電,應(yīng)用處在打開的情況。
  • 2.廢話,那肯定就是長連接不正???。這種情況有很多種原因,比如wifi不可用、用戶進入了地鐵或電梯等網(wǎng)絡(luò)不好的場所、應(yīng)用沒打開或已退出登錄等,總的來說,就是沒有辦法正常接收消息。

如果是長連接正常,那沒什么可說的,服務(wù)端直接轉(zhuǎn)發(fā)即可。
如果長連接不正常,需要這樣處理:服務(wù)端接收到客戶端A發(fā)送給客戶端B的消息后,先給客戶端A回復(fù)一條狀態(tài)報告,告訴客戶端A,我已經(jīng)收到消息,這個時候,客戶端A就不用管了,消息只要到達(dá)服務(wù)端即可。然后,服務(wù)端先嘗試把消息轉(zhuǎn)發(fā)到客戶端B,如果這個時候客戶端B收到服務(wù)端轉(zhuǎn)發(fā)過來的消息,需要立馬給服務(wù)端回一條狀態(tài)報告,告訴服務(wù)端,我已經(jīng)收到消息,服務(wù)端在收到客戶端B返回的消息接收狀態(tài)報告后,即認(rèn)為此消息已經(jīng)正常發(fā)送,不需要再存庫。如果客戶端B不在線,服務(wù)端在做轉(zhuǎn)發(fā)的時候,并沒有收到客戶端B返回的消息接收狀態(tài)報告,那么,這條消息就應(yīng)該存到數(shù)據(jù)庫,直到客戶端B上線后,也就是長連接建立成功后,客戶端B主動向服務(wù)端發(fā)送一條離線消息詢問,服務(wù)端在收到離線消息詢問后,到數(shù)據(jù)庫或緩存去查客戶端B的所有離線消息,并分批次返回,客戶端B在收到服務(wù)端的離線消息返回后,取出消息id(若有多條就取id集合),通過離線消息應(yīng)答把消息id返回到服務(wù)端,服務(wù)端收到后,根據(jù)消息id從數(shù)據(jù)庫把對應(yīng)的消息刪除即可。
以上是單聊離線消息處理的情況,群聊有點不同,群聊的話,是需要服務(wù)端確認(rèn)群組內(nèi)所有用戶都收到此消息后,才能從數(shù)據(jù)庫刪除消息,就說這么多,如果需要細(xì)節(jié)的話,可以私信我。


不知不覺,NettyTcpClient中定義了很多變量,為了防止大家不明白變量的定義,還是貼上代碼吧:


定義了很多變量的NettyTcpClient

應(yīng)用層封裝

這個就見仁見智啦,每個人代碼風(fēng)格不同,我把自己簡單封裝的代碼貼上來吧:
MessageProcessor消息處理器:

MessageProcessor1

MessageProcessor2

IMSEventListener與ims交互的listener:
IMSEventListener1

IMSEventListener2

IMSEventListener3

MessageBuilder消息轉(zhuǎn)換器:
MessageBuilder1

MessageBuilder2

MessageBuilder3

AbstractMessageHandler抽象的消息處理handler,每個消息類型對應(yīng)不同的messageHandler:
AbstractMessageHandler

SingleChatMessageHandler單聊消息處理handler:
SingleChatMessageHandler

GroupChatMessageHandler群聊消息處理handler:
GroupChatMessageHandler

MessageHandlerFactory消息handler工廠:
MessageHandlerFactory

MessageType消息類型枚舉:
MessageType

IMSConnectStatusListenerIMS連接狀態(tài)監(jiān)聽器:
IMSConnectStatusListener

由于每個人代碼風(fēng)格不同,封裝代碼都有自己的思路,所以,在此就不過多講解,只是把自己簡單封裝的代碼全部貼上來,作一個參考即可。只需要知道,接收到消息時,會回調(diào)OnEventListener的dispatchMsg(MessageProtobuf.Msg msg)方法:
應(yīng)用層接收ims消息入口

發(fā)送消息需要調(diào)用imsClient的sendMsg(MessageProtobuf.Msg msg)方法:
應(yīng)用層調(diào)用ims發(fā)送消息入口

即可,至于怎樣去封裝得更好,大家自由發(fā)揮吧。


最后,為了測試消息收發(fā)是否正常,我們需要改動一下服務(wù)端:


改動后的服務(wù)端1

改動后的服務(wù)端2

改動后的服務(wù)端3

改動后的服務(wù)端4

改動后的服務(wù)端5

可以看到,當(dāng)有用戶握手成功后,會保存該用戶對應(yīng)的channel到容器里,給用戶發(fā)送消息時,根據(jù)用戶id從容器里取出對應(yīng)的channel,利用該channel發(fā)送消息。當(dāng)用戶斷開連接后,會把該用戶對應(yīng)的channel從容器里移除掉。

運行一下,看看效果吧:


最終運行效果
  • 首先,啟動服務(wù)端。
  • 然后,修改客戶端連接的ip地址為192.168.0.105(這是我本機的ip地址),端口號為8855,fromId,也就是userId,定義成100001,toId為100002,啟動客戶端A。
  • 再然后,fromId,也就是userId,定義成100002,toId為100001,啟動客戶端B。
  • 客戶端A給客戶端B發(fā)送消息,可以看到在客戶端B的下面,已經(jīng)接收到了消息。
  • 用客戶端B給客戶端A發(fā)送消息,也可以看到在客戶端A的下面,也已經(jīng)接收到了消息。
    至于,消息收發(fā)測試成功。至于群聊或重連等功能,就不一一演示了,還是那句話,下載demo體驗一下吧。。。

由于gif錄制體積較大,所以只能簡單演示一下消息收發(fā),具體下載demo體驗吧。。。

如果有需要應(yīng)用層UI實現(xiàn)(就是聊天頁及會話頁的封裝)的話,我再分享出來吧。

github地址


發(fā)現(xiàn)的bug

  1. MsgTimeoutTimer
    MsgTimeoutTimer bug1

    這個bug是自己在檢查代碼時發(fā)現(xiàn)的,可能是連續(xù)熬幾天夜寫文章魔怔了。。。
    修改如下:
    MsgTimeoutTimer bug1 fix

一個人精力有限,大家在使用過程中,如果發(fā)現(xiàn)其它bug,煩請告訴我,反正我是會虛心接受,堅決不改,呸,一定改,一定改。另外,歡迎fork,期待大家與我一起完善。。。


寫在最后

終于寫完了,這篇文章大概寫了10天左右,有很大部分的原因是自己有拖延癥,每次寫完一小段,總靜不下心來寫下去,導(dǎo)致一直拖到現(xiàn)在,以后得改改。第一次寫技術(shù)分享文章,有很多地方也許邏輯不太清晰,由于篇幅有限,也只是貼了部分代碼,建議大家把源碼下載下來看看。一直想寫這篇文章,以前在網(wǎng)上也嘗試過找過很多im方面的文章,都找不到一篇比較完善的,本文談不上完善,但包含的模塊很多,希望起到一個拋磚引玉的作用,也期待著大家跟我一起發(fā)現(xiàn)更多的問題并完善,最后,如果這篇文章對你有用,希望在github上給我一個star哈。。。

應(yīng)大家要求,精簡了netty-all-4.1.33.Final.jar包。原netty-all-4.1.33.Final.jar包大小為3.9M,經(jīng)測試發(fā)現(xiàn)目前im_lib庫只需要用到以下jar包:

  • netty-buffer-4.1.33.Final.jar
  • netty-codec-4.1.33.Final.jar
  • netty-common-4.1.33.Final.jar
  • netty-handler-4.1.33.Final.jar
  • netty-resolver-4.1.33.Final.jar
  • netty-transport-4.1.33.Final.jar

所以,抽取以上jar包,重新打成了netty-tcp-4.1.33-1.0.jar,目前自測沒有問題,如果發(fā)現(xiàn)bug,請告訴我,謝謝。

附上原jar及裁剪后jar包的大小對比:


666

裁剪后netty-tcp-4.1.33-1.0.jar大小

代碼已更新到Github.


接下來,會抽時間把下圖想寫的文章都寫了,沒有先后順序,想到哪就寫到哪吧。。。


想寫的文章

另外,創(chuàng)建了一個Android即時通訊技術(shù)交流QQ群:1015178804,有需要的同學(xué)可以加進來,不懂的問題,我會盡量解答,一起學(xué)習(xí),一起成長。

最新新開了一個微信公眾號,方便后續(xù)KulaChat發(fā)布一些系列文章,同時也是為了激勵自己寫作。主要發(fā)布一些原創(chuàng)的Android IM相關(guān)的文章(也會包含其它方向),不定時更新。感興趣的同學(xué)可以關(guān)注一下,謝謝。PS:感覺鴻洋大神提供的公眾號文章排版方式,感激不盡~~

FreddyChen的微信公眾號

The end.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容