歡迎轉(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ā)送消息等功能,基于上述分析,我們可以進行一個接口抽象:
OnEventListener是與應(yīng)用層交互的listener:
IMConnectStatusCallback是ims連接狀態(tài)回調(diào)監(jiān)聽器:
然后寫一個Netty tcp實現(xiàn)類:
接下來,寫一個工廠方法:
封裝部分到此結(jié)束,接下來,就是實現(xiàn)了。
初始化
我們先實現(xiàn)init(Vector<String> serverUrlList, OnEventListener listener, IMSConnectStatusCallback callback)方法,初始化一些參數(shù),以及進行第一次連接等:
其中,MsgDispatcher是消息轉(zhuǎn)發(fā)器,負(fù)責(zé)將接收到的消息轉(zhuǎn)發(fā)到應(yīng)用層:
ExecutorServiceFactory是線程池工廠,負(fù)責(zé)調(diào)度重連及心跳線程:
連接及重連
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í)行:
toServer()是真正連接服務(wù)器的地方:
initBootstrap()是初始化Netty Bootstrap:
注:NioEventLoopGroup線程數(shù)設(shè)置為4,可以滿足QPS是一百多萬的情況了,至于應(yīng)用如果需要承受上千萬上億流量的,需要另外調(diào)整線程數(shù)。參考自:netty實戰(zhàn)之百萬級流量NioEventLoopGroup線程數(shù)配置
接著,我們來看看TCPChannelInitializerHanlder:
其中,ProtobufEncoder和ProtobufDecoder是添加對protobuf的支持,LoginAuthRespHandler是接收到服務(wù)端握手認(rèn)證消息響應(yīng)的處理handler,HeartbeatRespHandler是接收到服務(wù)端心跳消息響應(yīng)的處理handler,TCPReadHandler是接收到服務(wù)端其它消息后的處理handler,先不去管,我們重點來分析下LengthFieldPrepender和LengthFieldBasedFrameDecoder,這就需要引申到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出現(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)證
接著,我們來看看LoginAuthHandler和HeartbeatRespHandler:
-
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:
可以看到,利用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()代碼如下:
從圖上可看到,在IdleStateHandler里,配置的讀超時為心跳間隔時長的3倍,也就是3次心跳沒有響應(yīng)時,則認(rèn)為長連接已斷開,觸發(fā)重連操作。寫超時則為心跳間隔時長,意味著每隔heartbeatInterval會發(fā)送一個心跳包。讀寫超時沒用到,所以配置為0。
onConnectStatusCallback(int connectStatus)為連接狀態(tài)回調(diào),以及一些公共邏輯處理:
連接成功后,立即發(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收到消息是怎么處理的:
可以看到,在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ā)送消息:
關(guān)閉ims:
ims默認(rèn)配置:
還有,應(yīng)用層實現(xiàn)的ims client啟動器:
由于代碼有點多,不太方便全部貼上,如果有興趣可以下載demo體驗。
額,對了,還有一個簡易的服務(wù)端代碼,如下:
調(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:
MsgTimeoutTimerManager:
然后,我們看看收消息的TCPReadHandler的改造:
最后,看看發(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ā):
離線消息
由于離線消息機制,需要服務(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中定義了很多變量,為了防止大家不明白變量的定義,還是貼上代碼吧:
應(yīng)用層封裝
這個就見仁見智啦,每個人代碼風(fēng)格不同,我把自己簡單封裝的代碼貼上來吧:
MessageProcessor消息處理器:
IMSEventListener與ims交互的listener:
MessageBuilder消息轉(zhuǎn)換器:
AbstractMessageHandler抽象的消息處理handler,每個消息類型對應(yīng)不同的messageHandler:
SingleChatMessageHandler單聊消息處理handler:
GroupChatMessageHandler群聊消息處理handler:
MessageHandlerFactory消息handler工廠:
MessageType消息類型枚舉:
IMSConnectStatusListenerIMS連接狀態(tài)監(jiān)聽器:
由于每個人代碼風(fēng)格不同,封裝代碼都有自己的思路,所以,在此就不過多講解,只是把自己簡單封裝的代碼全部貼上來,作一個參考即可。只需要知道,接收到消息時,會回調(diào)OnEventListener的dispatchMsg(MessageProtobuf.Msg msg)方法:
發(fā)送消息需要調(diào)用imsClient的sendMsg(MessageProtobuf.Msg msg)方法:
即可,至于怎樣去封裝得更好,大家自由發(fā)揮吧。
最后,為了測試消息收發(fā)是否正常,我們需要改動一下服務(wù)端:
可以看到,當(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)(就是聊天頁及會話頁的封裝)的話,我再分享出來吧。
發(fā)現(xiàn)的bug
-
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包的大小對比:
代碼已更新到Github.
接下來,會抽時間把下圖想寫的文章都寫了,沒有先后順序,想到哪就寫到哪吧。。。
另外,創(chuàng)建了一個Android即時通訊技術(shù)交流QQ群:1015178804,有需要的同學(xué)可以加進來,不懂的問題,我會盡量解答,一起學(xué)習(xí),一起成長。
最新新開了一個微信公眾號,方便后續(xù)KulaChat發(fā)布一些系列文章,同時也是為了激勵自己寫作。主要發(fā)布一些原創(chuàng)的Android IM相關(guān)的文章(也會包含其它方向),不定時更新。感興趣的同學(xué)可以關(guān)注一下,謝謝。PS:感覺鴻洋大神提供的公眾號文章排版方式,感激不盡~~
The end.