自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)

时间:2020-10-20

视频会议源码


 1.写在前面

 我一直想写一篇关于分享的文章,但是我太忙了,没有时间。今天,我终于离开了公司。我计划在再次找工作之前好好休息几天。我决定冷静下来,有空的时候写一篇文章。毕竟,我从我的前任那里学到了很多。

 在工作了五年半之后,在过去的三四年里,我一直在做与社会相关的项目,包括直播、即时通讯、视频分享、社区论坛和其他产品。我知道即时通讯技术在项目中的重要性,并借此机会总结开源共享的精神,所以我写了这篇文章。

 *重要提示:本文不是一篇即时通讯理论文章,但文章的内容都是由实战代码组织的。如果你对即时通讯(即时通讯)技术理论知之甚少,建议你仔细阅读它:“初学者足够了:从头开始开发移动即时通讯”。这个网站上的其他文章,“拿起键盘是干的:用我的双手开发一个分布式即时消息系统”,“适合初学者:从头开始开发一个即时消息服务器(基于Netty,带有完整的源代码)”和“适合初学者:教你用Go(带有源代码)构建一个高性能和可扩展的即时消息系统”,也值得一读。

 本文的实际内容将涉及以下即时通讯技术内容:

 1)Protobuf序列化;

 2)TCP拆包和粘贴;

 3)长连接握手认证;

 4)心跳机制;

 5)重连机制;

 6)消息重传机制;

 7)读写超时机制;

 8)离线消息;

 9)线程池。

 不想阅读本文的学生可以直接从Github下载本文的源代码:

 1)原地址:https://github.com/FreddyChen/NettyChat

 2)备用地址:https://github。com/52im/nettychat

 接下来,让我们言归正传。

 2.本文的阅读对象

 这篇文章适合没有开发经验的白人小开发者。它将教你如何从头开始开发一个典型的基于Netty+TCP+Protobuf的即时通讯(即时消息)系统。它非常适合从头开始的安卓开发者。

 本文不适合没有编程的潜在开发人员,因为即时通讯(IM)系统属于特定的业务领域,如果您几乎不能编写一般的逻辑代码,不建议您阅读本文。这篇文章显然不是编程语言的入门教程。

 此外,本网站上另一篇类似的文章“拉起键盘是干的:一个徒手的分布式即时通讯系统”也值得一读。

 3.关于作者

 自己开发即时通讯有这么难吗?教你自己创建一个简单的即时消息版本的Andriod(源代码)_WX20190721-165501@2x.jpg)

 这篇文章的原始内容最初由FreddyChen共享。作者现在从事安卓程序开发。他的技术博客地址是https://金爵。im/user/5bd 7 aff be 51d 4547 f 763 Fe 72

 4.为什么要使用传输控制协议?

 在这里,我们需要简单地解释一下TCP/UDP的区别,并简单地总结一下。

 优势:

 1)TCP:其优点是稳定可靠。在数据传输之前,会有一个三次握手来建立连接。数据传输时,将有确认、窗口、重传和拥塞控制机制。数据传输后,连接将被断开,以节省系统资源。

 2)UDP:它的优点是比TCP更快、更安全。UDP是一种无状态传输协议,没有TCP所拥有的各种机制,因此它可以非常快速地传输数据。如果没有TCP的这些机制,被攻击使用的机制会更少,但是无法避免被攻击。

 缺点:

 1)TCP:它的缺点是速度慢、效率低、系统资源高、易受攻击。TCP需要在传输数据之前建立连接,这将消耗时间。此外,当传输数据时,确认机制、重传机制和拥塞机制将消耗大量时间,并且所有传输连接都应该在每个设备上维护。

 2)UDP:缺点是不可靠和不稳定,因为没有TCP机制。UDP传输数据时,如果网络质量不好,很容易丢失数据包,导致数据丢失。

 适用场景:

 1)TCP:当对网络通信质量有要求时,如HTTP、HTTPS、FTP等文件传输协议,POP、SMTP等邮件传输协议。

 2)UDP:当网络通信质量不高时,要求网络通信速度快。

 至于WebSocket,稍后可能会写一篇特别的文章来介绍它。总而言之,我们决定采用TCP协议。

 有关TCP和UDP的比较和选择的详细文章,请参见:

 简单介绍了TCP和UDP的区别

 为什么QQ使用UDP而不是TCP?》

 "移动即时通讯协议选择:UDP还是TCP?》

 网络编程中的懒人介绍(4):快速理解TCP和UDP的区别

 网络编程中的懒人入门(5):快速理解为什么UDP有时优于TCP

 安卓程序员必须知道的网络通信传输层协议——UDP和TCP

 或者,如果您对TCP和UDP协议知之甚少,您可以阅读本文:

 TCP/IP的详细说明-第11章UDP:用户数据报协议

 TCP/IP的详细说明-第17章TCP:传输控制协议

 TCP/IP的详细说明-第18章TCP连接的建立和终止

 TCP/IP的详细说明-第21章TCP的超时和重传

 脑残网络编程导论(1):学习TCP三次握手和四波动画

 技术过去:TCP/IP协议改变世界(珍贵的多画面,手机注意)

 易于理解——对TCP协议的深刻理解(一):理论基础

 网络编程中的懒人介绍(3):快速理解TCP协议就足够了

 更高层次:优秀安卓程序员必须了解的网络基础。

 5.为什么使用原蟾?

 有三种常见的和可选的应用网络传输协议,即json/xml/protobuf,它们是旧的规则。让我们先分别看看这三种格式的优缺点。

 附言:如果您不知道什么是protobuf,建议您详细阅读:Protobuf通信协议详细说明:代码演示、详细原理介绍等。

 优势:

 1)json:它的优点是比xml格式小,传输效率比XML高得多,可读性也不错。

 2)xml:它的优点是可读性强,解析方便。

 3)protobuf:优点是传输效率快(据说当数据量大时,传输效率比xml和json快10-20倍)。序列化后,体积比Json和XML小,并且支持跨平台多语言。消息格式升级和兼容性还不错,序列化和反序列化速度非常快。

 缺点:

 1)json:缺点是传输效率不是特别高(比xml快,但比protobuf慢得多)。

 2)xml:缺点是低效率和过度的资源消耗。

 3)原虫:缺点是使用不方便。

 在需要大量数据传输的场景中,如果数据量很大,protobuf可以明显减少数据量和网络IO,从而减少网络传输所消耗的时间。考虑到作为一种社交产品,消息数据量将非常大,为了节省流量,protobuf是一个不错的选择。

 有关即时消息相关协议格式选择的更多文章,请进一步阅读:

 如何选择即时通讯应用的数据传输格式

 强烈建议使用Protobuf作为您的即时通讯应用程序数据传输格式

 综合评估:Protobuf的性能比JSON快五倍吗?》

 移动即时通信发展中面临的技术问题(包括通信协议选择)

 简要描述移动即时消息开发的难点:架构设计、通信协议和客户端

 理论与实践相结合:典型即时通信协议的详细设计

 分享技术实践,如58户实时信息系统的协议设计

 详细解释如何在节点中使用谷歌的原型

 技术素养:新一代基于UDP的低延迟网络传输层协议

 “金蝶手写团队分享:仍在使用JSON?Protobuf使数据传输更经济、更快(原理)

 “金蝶手写团队分享:仍在使用JSON?Protobuf使数据传输越来越快(实战)

 > >更多类似的文章...

 6.为什么使用Netty?

 首先,让我们看看什么是内蒂。网络上的介绍:Netty是一个基于JBOSS提供的Java NIO的开源框架。Netty提供异步无阻塞、事件驱动、高性能、高可靠性和高度可定制的网络应用程序和工具,可用于开发服务器和客户端。

 附言:如果你不知道经典的信息作战、网络作战或网络作战框架,请阅读以下文章:

 历史上最强大的Java NIO简介:如果你担心开始和放弃,请阅读这篇文章!》

 “放开我!让您在一分钟内了解Java NIO和经典IO之间的区别

 初学者:网络的学习方法和高级策略——一个Java高性能NIO框架

 NIO框架的详细说明:Netty的高性能

 为什么不使用Java生物?

 1)一个连接一个线程:由于线程数量有限,消耗大量资源,最终无法满足高并发连接的需求。

 2)低性能:频繁的上下文切换导致CUP的利用率低。

 3)可靠性差:由于所有的IO操作都是同步的,即使对于业务线程,业务线程的IO操作也可能被阻塞,这将导致系统过于依赖网络的实时性和外部组件的处理能力,从而大大降低了可靠性。

 为什么不使用Java NIO呢?

 1)NIO的类库和API相当复杂。要使用它进行开发,您需要掌握选择器、字节缓冲、服务器套接字通道、套接字通道等。

 2)需要许多额外的编程技巧来帮助NIO的使用。例如,因为NIO涉及反应器线程模型,所以有必要熟悉多线程和网络编程来编写高质量的NIO程序。

 3)要具有高可靠性,工作量和难度都很大,因为服务器需要面对频繁的客户端访问和断开、网络闪烁、半包读写、故障缓存和网络阻塞等问题,这些问题会严重影响我们的可靠性,用本机NIO解决起来相当困难。

 4)JDK NIO BUG - epoll空轮询中的著名错误,当select返回0时,将导致选择器的空轮询,并导致100%的CUP。这位官员说,这个问题在JDK1.6之后已经解决了,但实际上,发生的可能性降低了,而且没有从根本上解决。

 为什么使用Netty?

 1)应用编程接口简单易用,开发门槛低;

 2)功能强大,预置多种编解码功能,支持多种主流协议;

 3)定制能力强,可以通过ChannelHandler灵活扩展通信框架;

 4)高性能。与许多NIO主流框架相比,Netty具有最高的综合性能;

 5)稳定性高,解决了BUGJDK NIO;

 6)经历了大规模商业应用的测试,质量和可靠性得到了很好的验证。

 为什么不使用第三方软件开发工具包,如融云、环欣和腾讯TIM?

 这是一个意见问题。有时,这是因为公司的技术选择,因为使用第三方SDK意味着消息数据需要存储在第三方服务器上。此外,可伸缩性和灵活性肯定不如我们自己开发的那些。还有一个小问题,那就是充电。例如,融云的免费版只支持100个注册用户,超过100个就要收费,群聊支持者的数量有限,等等...

 自己开发即时通讯有这么难吗?手工1.jpg教你一个简单的安卓版本的即时消息(带源代码)。

 ▲以上截图内容来自云即时通讯官方网站

 Mina实际上与Netty非常相似,大多数API都是相同的,因为它们是由同一作者开发的。然而,我觉得米娜没有妮蒂成熟。在使用Netty的过程中,如果出现问题,很容易找到解决方案,因此Netty是一个不错的选择。

 注意:关于MINA和Netty框架之间的关系和比较,请参见下面的文章了解详细信息:

 关于“为什么选择Netty”的11个问题和答案

 关于开源NIO框架的流言蜚语——首先是MINA还是Netty?》

 选择内蒂还是米娜:深入研究与比较(一)

 选择内蒂还是米娜:深入研究和比较(2)

 好吧,我们废话少说,开始吧。

 7.准备工作

 首先,我们创建一个新项目,然后在项目中创建一个安卓库。模块名称暂时为im_lib,如图所示:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_7-1.jpg。

 然后,在分析了我们的消息结构之后,每条消息应该有一个消息唯一的id、发送者id、接收者id、消息类型、发送时间等等。经过分析,一般的消息类型分类如下:

 MsgId:消息Id

 发件人Id:发件人id

 ToId:收件人Id

 MsgType:消息类型

 MsgContentType:消息内容类型

 Timestamp:消息时间戳

 状态报告:状态报告

 扩展:扩展字段

 根据以上所述,我编制了一张思维导图供你参考:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_7-2.jpg。

 上面的图片比原始图片大。如果你看不清楚,请下载一张清晰的大图:

 信息结构——思维导图[清晰的大图]。zip (314.71 KB,下载次数:175)

 这是最基本的部分。当然,您可以根据自己的需要定制适合自己的消息结构。

 我们根据用户定义的消息类型编写原型文件:

 01020304050607080910111213141516171819 syntax = " proto 3 ";//指定原型版本选项。原蟾蜍";//指定包名选项。//指定生成的类名消息消息{ Head Head = 1;//消息头字符串正文= 2;//消息正文}消息头{字符串MsGid = 1;//消息id int 32 MSgType = 2;//消息类型int 32 MsgContentType = 3;//消息内容类型字符串FromId = 4;//消息发送者id字符串ToID = 5;//消息接收方id int 64timestamp = 6;//消息时间戳int 32 StatusReport = 7;//状态报告字符串extend = 8;//扩展字段,json}以键/值形式存储}

 然后执行命令(我使用的mac和windows命令应该类似):

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_ 7-3.jpg。

 然后,我们将看到一个java类将在与原型文件相同的目录中生成,这就是我们需要使用的:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_7-4.jpg。

 我们打开扫描:

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_7-5.jpg

 有很多事情,所以不要担心它们。这是谷歌为我们生成的protobuf类。直接用吧。如何使用它?

 只需直接使用这个类文件,并将其复制到我们开始指定的项目包的路径中:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_7-6.jpg。

 添加依赖项后,您可以看到MessageProtobuf类文件没有报告错误。顺便介绍一下内蒂的罐子包和法斯特森的:

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_7-7.jpg

 建议使用最终版的jar包。如果您以后熟悉它,可以使用简化的jar包。

 至此,准备工作结束了。现在,让我们编写java代码来实现即时通讯的功能。

 8.代码封装

 为什么你需要封装?直截了当地说,这是为了解耦,并且在不改变调用位置的情况下,将来切换到不同的框架实现是很方便的。

 以栗子为例,早期流行的安卓图片加载框架是通用图像加载器。后来,由于某些原因,原作者停止了项目的维护。目前,流行的图片加载框架是毕加索或格莱德,因为有很多地方可以调用图片加载功能。如果在早期使用通用图像加载器时没有进行一些封装,那么现在就必须切换到Glide,而且变化会非常非常大,可能会有遗漏,风险非常高。

 那么,解决方案是什么?

 非常简单,我们可以使用工厂设计模式进行一些包装。有三种工厂模式:工厂方法模式、抽象工厂模式和工厂方法模式。在这里,我使用工厂方法模式进行包装。具体差异请参考“我对三种设计模式的理解:简单工厂、工厂方法和抽象工厂”。

 让我们分析一下,ims(即时消息服务,以下简称ims)应该具有初始化、建立连接、重新连接、关闭连接、释放资源、判断长连接是否关闭、发送消息等功能。

 基于以上分析,我们可以抽象出一个接口:

 自己开发即时通讯有这么难吗?为Andriod教你一个简单的im(带源代码)_8-1.jpg。

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_8-2.jpg。

 OnEventListener是一个与应用层交互的侦听器:

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_ 8-3.jpg。

 IMConnectStatusCallback是im的连接状态回调侦听器:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_8-4.jpg。

 然后编写一个Netty tcp实现类:

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_8-5.jpg

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_8-6.jpg。

 接下来,编写一个工厂方法:

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_8-7.jpg

 封装部分到此结束。接下来,它实现了。

 9.初始化

 我们首先实现init (vector server urllist,onevent侦听器,imsconnectstatuscallback回调)方法,初始化一些参数,并建立第一个连接。

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_9-1.jpg

 MsgDispatcher是消息转发器,负责将收到的消息转发到应用层:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_9-2.jpg。

 ExecutorServiceFactory是一个线程池工厂,负责调度重新连接和心跳线程:

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_ 9-3.jpg。

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_ 9-4.jpg。

 自己开发即时通讯有这么难吗?教你自己创建一个Andriod版本的sIMple im(带源代码)_9-5.jpg

 10.连接和重新连接

 resetConnect()方法用作连接的起点,第一个连接和重新连接逻辑都在resetConnect()方法中进行逻辑处理。

 让我们看一眼:

 自己开发即时通讯有这么难吗?为Andriod教你一个简单的im(带源代码)_10-1.jpg。

 可以看出,当第一次连接时,即当连接失败一个周期后重新连接时,线程将休眠一段时间,因为此时网络条件可能不是很好。然后,判断ims是否关闭或者是否正在进行重新连接操作。由于重新连接操作是由子线程执行的,因此需要一些并发处理来避免重复的重新连接。

 重新连接任务开始后,分四个步骤执行:

 1)更改重新连接状态标识;

 2)向应用层回调连接状态;

 3)关闭先前打开的连接通道;;

 4)使用线程池执行新的重新连接任务。

 ResetConnectRunnable是一个重新连接任务,核心重新连接逻辑放在这里执行:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_10-2.jpg。

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_10-3.jpg。

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_10-4.jpg。

 ToServer()是服务器实际连接的位置:

 自己开发即时通讯有这么难吗?为Andriod教你一个简单的im(带源代码)_10-5.jpg。

 InitBootstrap()用于初始化Netty引导:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_10-6.jpg。

 注意:将NioEventLoopGroup的线程数设置为4可以满足QPS超过一百万的情况。如果应用程序需要承受数千万的流量,它需要额外调整线程的数量。(请参考:“netty的实际百万流量NioEventLoopGroup线程数配置”)

 接下来,让我们看一下TCPChannelInitializerHanlder:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_10-7.jpg。

 其中,protobufEncoder和ProtobufDecoder增加了对Protobuf的支持,LoginAuthRespHandler是接收服务器握手认证消息响应的处理程序,HeartbeatRespHandler是接收服务器心跳消息响应的处理程序,TCPReadHandler是从服务器接收其他消息后的处理程序。让我们别管它。我们将重点分析纵向字段预处理器和纵向字段基帧解码器,这需要扩展到解包和绑定TCP。

 11.解包和粘贴TCP

 什么是TCP解包?为什么要进行TCP解包?

 简而言之,我们都知道,TCP以“流”的形式传输数据,为了提高TCP的性能,发送方会将待发送的数据刷入缓冲区,等待缓冲区满,然后将缓冲区中的数据发送给接收方。类似地,接收器也将具有接收数据的缓冲机制。解包意味着当套接字读取时,它不会读取一个完整的数据包,而只会读取其中的一部分。

 什么是TCP粘性数据包?为什么会出现TCP粘性数据包?

 同上,粘贴包是指在读取套接字时,读取实际意义上的两个或多个数据包的内容,并同时将它们作为一个数据包进行处理。

 引用一张图片来解释三种情况:拆包、粘贴和正常状态:

 自己开发即时通讯有这么难吗?为Andriod教你一个简单的im(带源代码)_11-1.jpg。

 了解TCP拆包/卡的原因,如何解决?

 一般来说,有四种解决方案:

 1)消息固定长度;

 2)使用回车换行作为消息结束标志;

 3)特殊分隔符用作消息结束标志,如\ t \ n等。,而回车换行实际上是一种特殊的分隔符;

 4)消息分为消息头和消息体,消息的总长度由消息头中的字段标识。

 Netty为上述四种场景封装了以下四个相应的解码器:

 1)固定长度的帧解码器,固定长度的消息解码器;

 2)LineBasedFrameDecoder,回车换行符消息解码器;

 3)DELimITERBASEDFRAMEDCODER,特殊分隔符消息解码器;

 4)长度字段基本帧解码器,一个自定义长度的消息解码器。

 我们使用的是LengthFieldBasedframeCoder自定义长度消息解码器,它与LengthFieldRepeater编码器一起使用。对于参数配置,建议参考文章“netty -最常见的TCP粘性包解决方案:纵向字段基础帧编码器和纵向字段中继器”,并详细解释。

 我们的配置是消息头的长度是2字节,所以消息包的最大长度需要小于65536字节。netty将消息内容的长度存储在消息头字段中,接收者可以根据消息头字段获得该消息的总长度。当然,netty提供的LengthFieldBasedFrameDecoder已经打包了处理逻辑。我们只需要配置长度字段偏移量、长度字段长度、长度调整、初始化标签条,就可以解决TCP的解包和粘贴问题。与本机nio相比,这是netty的便利之处,本机nio需要自己处理解包/粘贴问题。

 12.长连接握手认证

 然后,让我们来看看LoginAuthHandler和HeartbeatRespHandler。

 当客户机和服务器之间的连接成功建立时,客户机主动向服务器发送一个登录验证消息,引入与当前用户相关的参数,如令牌。服务器收到此消息后,会向数据库查询用户信息。如果是合法有效的用户,它会向客户端返回登录成功消息;否则,它会向客户端返回登录失败消息。这里,它是接收到服务器返回的登录状态后的处理程序。

 例如:

 自己开发即时通讯有这么难吗?为Andriod教你一个简单的im(带源代码)_12-1.jpg。

 可以看出,在收到服务器握手消息响应后,状态将从扩展字段中取出。如果状态=1,则表示握手成功。此时,将首先向服务器发送心跳消息,然后使用Netty的IdleStateHandler读写超时机制定期向服务器发送心跳消息,以维持长连接并检测长连接是否仍然存在。

 当客户端收到服务器成功登录的消息时,它会主动向服务器发送心跳消息。心跳消息可以是一个空包,包越小越好。服务器从客户端接收到心跳数据包后,会将其原样返回给客户端。这里,它是接收服务器返回的心跳消息响应的处理程序。

 例如:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_12-2.jpg。

 这相对简单。当您收到心跳消息响应时,您不需要处理任务,因此可以直接打印出来供我们分析。

 13、心跳机制和读写超时机制

 心跳包定期发送,也可以自己定义一个周期,比如“移动即时通讯练习:在安卓版实现微信智能心跳机制”。为简单起见,这里规定当它应用于前台时,心跳包将在8秒内发送,当它被切换到后台时,它将在30秒内发送一次,这可以根据您的实际情况进行修改。心跳数据包用于维护长连接,并检测长连接是否断开。

 附注:关于心跳保持活力的更多文章,请参阅:

 "安卓端消息推送概述:实现原理、心跳保持、遇到的问题等."

 “为什么基于TCP的移动即时消息仍然需要心跳保持机制?》

 “微信团队原创分享:安卓版微信背景保活战斗分享(网络保活文章)”

 “移动即时通讯实践:WhatsApp、Line和微信心跳策略分析”

 然后,我们使用Netty的读写超时机制来实现心跳消息管理处理程序:

 自己开发即时通讯有这么难吗?为Andriod教你一个简单的im(带源代码)_13-1.jpg。

 可以看出,读超时/写超时/读和写超时可以通过回调userEventTriggered()方法来判断,下面的代码将被粘贴。

 首先,我们可以在READER_IDLE事件中检测到在指定时间内是否没有收到服务器心跳数据包响应,如果是,它将触发重新连接操作。在WRITER_IDEL事件中,可以检测客户端是否在指定时间内没有向服务器发送心跳数据包,如果是,它将主动发送心跳数据包。发送心跳数据包是在子线程中执行的,因此我们可以使用以前编写的工作线程池来进行线程管理。

 AddHeartbeatHandler()代码如下:

 自己开发即时通讯有这么难吗?为Andriod教你一个sIMple im(带源代码)_13-2.jpg。

 从图中可以看出,在IdleStateHandler中配置的读取超时是心跳间隔的3倍,也就是说,当3次心跳没有响应时,长连接被视为断开,并触发重新连接操作。写超时是心跳间隔的长度,这意味着每个心跳间隔都会发送一个心跳数据包。没有使用读写超时,因此它被配置为0。

 onconnectstatuscallback(int connectstatus)是一个连接状态回调,以及一些常见的逻辑处理:



自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-3.jpg 


连接成功后,立即发送一条握手消息,再次梳理一下整体流程:



1)客户端根据服务端返回的host及port,进行第一次连接;

2)连接成功后,客户端向服务端发送一条握手认证消息(1001);

3)服务端在收到客户端的握手认证消息后,从扩展字段里取出用户token,到本地数据库校验合法性;

4)校验完成后,服务端把校验结果通过1001消息返回给客户端,也就是握手消息响应;

5)客户端收到服务端的握手消息响应后,从扩展字段取出校验结果。若校验成功,客户端向服务端发送一条心跳消息(1002),然后进入心跳发送周期,定期间隔向服务端发送心跳消息,维持长连接以及实时检测链路可用性,若发现链路不可用,等待一段时间触发重连操作,重连成功后,重新开始握手/心跳的逻辑。


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

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-4.jpg 

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-5.jpg 


可以看到,在channelInactive()及exceptionCaught()方法都触发了重连,channelInactive()方法在当链路断开时会调用,exceptionCaught()方法在当出现异常时会触发,另外,还有诸如channelUnregistered()、channelReadComplete()等方法可以重写,在这里就不贴了,相信聪明的你一眼就能看出方法的作用。


我们仔细看一下channelRead()方法的逻辑,在if判断里,先判断消息类型,如果是服务端返回的消息发送状态报告类型,则判断消息是否发送成功,如果发送成功,从超时管理器中移除,这个超时管理器是干嘛的呢?


下面讲到消息重发机制的时候会详细地讲。在else里,收到其他消息后,会立马给服务端返回一个消息接收状态报告,告诉服务端,这条消息我已经收到了,这个动作,对于后续需要做的离线消息会有作用。如果不需要支持离线消息功能,这一步可以省略。最后,调用消息转发器,把接收到的消息转发到应用层即可。


代码写了这么多,我们先来看看运行后的效果,先贴上缺失的消息发送代码及ims关闭代码以及一些默认配置项的代码。


发送消息:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-6.jpg 


关闭ims:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-7.jpg 


ims默认配置:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-8.jpg 


还有,应用层实现的ims client启动器:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-9.jpg 


由于代码有点多,不太方便全部贴上,如果有兴趣可以下载本文的完整demo进行体验。


额,对了,还有一个简易的服务端代码,如下:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-10.jpg 

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-11.jpg

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_13-12.jpg 


14、运行调试


我们先来看看连接及重连部分(由于录制gif比较麻烦,体积较大,所以我先把重连间隔调小成3秒,方便看效果)。


启动服务端:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_14-1.gif


启动客户端:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_14-2.gif


可以看到,正常的情况下已经连接成功了,接下来,我们来试一下异常情况。


比如服务端没启动,看看客户端的重连情况:



这次我们先启动的是客户端,可以看到连接失败后一直在进行重连,由于录制gif比较麻烦,在第三次连接失败后,我启动了服务端,这个时候客户端就会重连成功。


然后,我们再来调试一下握手认证消息即心跳消息:



可以看到,长连接建立成功后,客户端会给服务端发送一条握手认证消息(1001),服务端收到握手认证消息会,给客户端返回了一条握手认证状态消息,客户端收到握手认证状态消息后,即启动心跳机制。gif不太好演示,下载demo就可以直观地看到。


接下来,在讲完消息重发机制及离线消息后,我会在应用层做一些简单的封装,以及在模拟器上运行,这样就可以很直观地看到运行效果。


15、消息重发机制


消息重发,顾名思义,即使对发送失败的消息进行重发。考虑到网络环境的不稳定性、多变性(比如从进入电梯、进入地铁、移动网络切换到wifi等),在消息发送的时候,发送失败的概率其实不小,这时消息重发机制就很有必要了。


有关即时通讯(IM)应用中的消息送达保证机制,可以详细阅读以下文章:



《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》

《IM群聊消息如此复杂,如何保证不丢不重?》

《完全自已开发的IM该如何设计“失败重试”机制?》


我们先来看看实现的代码逻辑。


MsgTimeoutTimer:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-1.jpg 

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-2.jpg 


MsgTimeoutTimerManager:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-3.jpg

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-4.jpg


然后,我们看看收消息的TCPReadHandler的改造:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-5.jpg 


最后,看看发送消息的改造:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-6.jpg 


说一下逻辑吧:发送消息时,除了心跳消息、握手消息、状态报告消息外,消息都加入消息发送超时管理器,立马开启一个定时器,比如每隔5秒执行一次,共执行3次,在这个周期内,如果消息没有发送成功,会进行3次重发,达到3次重发后如果还是没有发送成功,那就放弃重发,移除该消息,同时通过消息转发器通知应用层,由应用层决定是否再次重发。如果消息发送成功,服务端会返回一个消息发送状态报告,客户端收到该状态报告后,从消息发送超时管理器移除该消息,同时停止该消息对应的定时器即可。


另外,在用户握手认证成功时,应该检查消息发送超时管理器里是否有发送超时的消息,如果有,则全部重发:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_15-7.jpg


16、离线消息


由于离线消息机制,需要服务端数据库及缓存上的配合,代码就不贴了,太多太多。


我简单说一下实现思路吧:客户端A发送消息到客户端B,消息会先到服务端,由服务端进行中转。


这个时候,客户端B存在两种情况:



1)长连接正常,就是客户端网络环境良好,手机有电,应用处在打开的情况;

2)废话,那肯定就是长连接不正常咯。这种情况有很多种原因,比如wifi不可用、用户进入了地铁或电梯等网络不好的场所、应用没打开或已退出登录等,总的来说,就是没有办法正常接收消息。


如果是长连接正常,那没什么可说的,服务端直接转发即可。


如果长连接不正常,需要这样处理:


服务端接收到客户端A发送给客户端B的消息后,先给客户端A回复一条状态报告,告诉客户端A,我已经收到消息,这个时候,客户端A就不用管了,消息只要到达服务端即可。然后,服务端先尝试把消息转发到客户端B,如果这个时候客户端B收到服务端转发过来的消息,需要立马给服务端回一条状态报告,告诉服务端,我已经收到消息,服务端在收到客户端B返回的消息接收状态报告后,即认为此消息已经正常发送,不需要再存库。


如果客户端B不在线,服务端在做转发的时候,并没有收到客户端B返回的消息接收状态报告,那么,这条消息就应该存到数据库,直到客户端B上线后,也就是长连接建立成功后,客户端B主动向服务端发送一条离线消息询问,服务端在收到离线消息询问后,到数据库或缓存去查客户端B的所有离线消息,并分批次返回,客户端B在收到服务端的离线消息返回后,取出消息id(若有多条就取id集合),通过离线消息应答把消息id返回到服务端,服务端收到后,根据消息id从数据库把对应的消息删除即可。



以上是单聊离线消息处理的情况,群聊有点不同,群聊的话,是需要服务端确认群组内所有用户都收到此消息后,才能从数据库删除消息,就说这么多,如果需要细节的话,可以私信我。


更多有关离线消息处理思路的文章,可以详细阅读:



《IM消息送达保证机制实现(二):保证离线消息的可靠投递》

《IM群聊消息如此复杂,如何保证不丢不重?》

《浅谈移动端IM的多点登陆和消息漫游原理》


不知不觉,NettyTcpClient中定义了很多变量,为了防止大家不明白变量的定义,还是贴上代码吧:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_16-1.jpg 


17、应用层封装


这个就见仁见智啦,每个人代码风格不同,我把自己简单封装的代码贴上来吧。


MessageProcessor消息处理器:


001002003004005006007008009010011012013014015016017018019020021022023024025026027028029030031032033034035036037038039040041042043044045046047048049050051052053054055056057058059060061062063064065066067068069070071072073074075076077078079080081082083084085086087088089090091092093094095096097098099100101102103package com.freddy.chat.im; import android.util.Log; import com.freddy.chat.bean.AppMessage;import com.freddy.chat.bean.BaseMessage;import com.freddy.chat.bean.ContentMessage;import com.freddy.chat.im.handler.IMessageHandler;import com.freddy.chat.im.handler.MessageHandlerFactory;import com.freddy.chat.utils.CThreadPoolExecutor; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:MessageProcessor.java</p> * <p>@ PackageName:com . freddy . chat .im</p> *  * <p>@描述:消息处理器</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/10 03:27</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class MessageProcessor implements IMessageProcessor { private static final String TAG = MessageProcessor.class.getSimpleName(); private MessageProcessor() { } private static class MessageProcessorInstance { private static final IMessageProcessor INSTANCE = new MessageProcessor(); } public static IMessageProcessor getInstance() { return MessageProcessorInstance.INSTANCE; } /** * 接收消息 * @param message */ @Override public void receiveMsg(final AppMessage message) { CThreadPoolExecutor.runInBackground(new Runnable() { @Override public void run() { try { IMessageHandler messageHandler = MessageHandlerFactory.getHandlerByMsgType(message.getHead().getMsgType()); if (messageHandler != null) { messageHandler.execute(message); } else { Log.e(TAG, "未找到消息处理handler,msgType=" + message.getHead().getMsgType()); } } catch (Exception e) { Log.e(TAG, "消息处理出错,reason=" + e.getMessage()); } } }); } /** * 发送消息 * * @param message */ @Override public void sendMsg(final AppMessage message) { CThreadPoolExecutor.runInBackground(new Runnable() { @Override public void run() { boolean isActive = IMSClientBootstrap.getInstance().isActive(); if (isActive) { IMSClientBootstrap.getInstance().sendMessage(MessageBuilder.getProtoBufMessageBuilderByAppMessage(message).build()); } else { Log.e(TAG, "发送消息失败"); } } }); } /** * 发送消息 * * @param message */ @Override public void sendMsg(ContentMessage message) { this.sendMsg(MessageBuilder.buildAppMessage(message)); } /** * 发送消息 * * @param message */ @Override public void sendMsg(BaseMessage message) { this.sendMsg(MessageBuilder.buildAppMessage(message)); }}



IMSEventListener与ims交互的listener:


001002003004005006007008009010011012013014015016017018019020021022023024025026027028029030031032033034035036037038039040041042043044045046047048049050051052053054055056057058059060061062063064065066067068069070071072073074075076077078079080081082083084085086087088089090091092093094095096097098099100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163/** * <p>@ProjectName: NettyChat</p> * <p>@ class name:IMSEventListener.java</p> * <p>@ PackageName:com . freddy . chat .im</p> *  * <p>@描述:侦听器与ims交互</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/07 23:55</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class IMSEventListener implements OnEventListener { private String userId; private String token; public IMSEventListener(String userId, String token) { this.userId = userId; this.token = token; } /** * 接收ims转发过来的消息 * * @param msg */ @Override public void dispatchMsg(MessageProtobuf.Msg msg) { MessageProcessor.getInstance().receiveMsg(MessageBuilder.getMessageByProtobuf(msg)); } /** * 网络是否可用 * * @return */ @Override public boolean isNetworkAvailable() { ConnectivityManager cm = (ConnectivityManager) NettyChatApp.sharedInstance().getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = cm.getActiveNetworkInfo(); return info != null && info.isConnected(); } /** * 设置ims重连间隔时长,0表示默认使用ims的值 * * @return */ @Override public int getReconnectInterval() { return 0; } /** * 设置ims连接超时时长,0表示默认使用ims的值 * * @return */ @Override public int getConnectTimeout() { return 0; } /** * 设置应用在前台时ims心跳间隔时长,0表示默认使用ims的值 * * @return */ @Override public int getForegroundHeartbeatInterval() { return 0; } /** * 设置应用在后台时ims心跳间隔时长,0表示默认使用ims的值 * * @return */ @Override public int getBackgroundHeartbeatInterval() { return 0; } /** * 构建握手消息 * * @return */ @Override public MessageProtobuf.Msg getHandshakeMsg() { MessageProtobuf.Msg.Builder builder = MessageProtobuf.Msg.newBuilder(); MessageProtobuf.Head.Builder headBuilder = MessageProtobuf.Head.newBuilder(); headBuilder.setMsgId(UUID.randomUUID().toString()); headBuilder.setMsgType(MessageType.HANDSHAKE.getMsgType()); headBuilder.setFromId(userId); headBuilder.setTimestamp(System.currentTimeMillis()); JSONObject jsonObj = new JSONObject(); jsonObj.put("token", token); headBuilder.setExtend(jsonObj.toString()); builder.setHead(headBuilder.build()); return builder.build(); } /** * 构建心跳消息 * * @return */ @Override public MessageProtobuf.Msg getHeartbeatMsg() { MessageProtobuf.Msg.Builder builder = MessageProtobuf.Msg.newBuilder(); MessageProtobuf.Head.Builder headBuilder = MessageProtobuf.Head.newBuilder(); headBuilder.setMsgId(UUID.randomUUID().toString()); headBuilder.setMsgType(MessageType.HEARTBEAT.getMsgType()); headBuilder.setFromId(userId); headBuilder.setTimestamp(System.currentTimeMillis()); builder.setHead(headBuilder.build()); return builder.build(); } /** * 服务端返回的消息发送状态报告消息类型 * * @return */ @Override public int getServerSentReportMsgType() { return MessageType.SERVER_MSG_SENT_STATUS_REPORT.getMsgType(); } /** * 客户端提交的消息接收状态报告消息类型 * * @return */ @Override public int getClientReceivedReportMsgType() { return MessageType.CLIENT_MSG_RECEIVED_STATUS_REPORT.getMsgType(); } /** * 设置ims消息发送超时重发次数,0表示默认使用ims的值 * * @return */ @Override public int getResendCount() { return 0; } /** * 设置ims消息发送超时重发间隔时长,0表示默认使用ims的值 * * @return */ @Override public int getResendInterval() { return 0; }}



MessageBuilder消息转换器:


001002003004005006007008009010011012013014015016017018019020021022023024025026027028029030031032033034035036037038039040041042043044045046047048049050051052053054055056057058059060061062063064065066067068069070071072073074075076077078079080081082083084085086087088089090091092093094095096097098099100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146package com.freddy.chat.im; import com.freddy.chat.bean.AppMessage;import com.freddy.chat.bean.BaseMessage;import com.freddy.chat.bean.ContentMessage;import com.freddy.chat.bean.Head;import com.freddy.chat.utils.StringUtil;import com.freddy.im.protobuf.MessageProtobuf; /** * <p>@ProjectName: BoChat</p> * <p>@ class name:MessageBuilder.java</p> * <p>@ PACkageName:com . bochat . app . message</p> *  * <p>@描述:消息转换</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/02/07 17:26</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class MessageBuilder { /** * 根据聊天消息,生成一条可以能够传输通讯的消息 * * @param msgId * @param type * @param subType * @param fromId * @param toId * @param extend * @param content * @return */ public static AppMessage buildAppMessage(String msgId, int type, int subType, String fromId, String toId, String extend, String content) { AppMessage message = new AppMessage(); Head head = new Head(); head.setMsgId(msgId); head.setMsgType(type); head.setMsgContentType(subType); head.setFromId(fromId); head.setToId(toId); head.setExtend(extend); message.setHead(head); message.setBody(content); return message; } /** * 根据聊天消息,生成一条可以能够传输通讯的消息 * * @param msg * @return */ public static AppMessage buildAppMessage(ContentMessage msg) { AppMessage message = new AppMessage(); Head head = new Head(); head.setMsgId(msg.getMsgId()); head.setMsgType(msg.getMsgType()); head.setMsgContentType(msg.getMsgContentType()); head.setFromId(msg.getFromId()); head.setToId(msg.getToId()); head.setTimestamp(msg.getTimestamp()); head.setExtend(msg.getExtend()); message.setHead(head); message.setBody(msg.getContent()); return message; } /** * 根据聊天消息,生成一条可以能够传输通讯的消息 * * @param msg * @return */ public static AppMessage buildAppMessage(BaseMessage msg) { AppMessage message = new AppMessage(); Head head = new Head(); head.setMsgId(msg.getMsgId()); head.setMsgType(msg.getMsgType()); head.setMsgContentType(msg.getMsgContentType()); head.setFromId(msg.getFromId()); head.setToId(msg.getToId()); head.setExtend(msg.getExtend()); head.setTimestamp(msg.getTimestamp()); message.setHead(head); message.setBody(msg.getContent()); return message; } /** * 根据业务消息对象获取protoBuf消息对应的builder * * @param message * @return */ public static MessageProtobuf.Msg.Builder getProtoBufMessageBuilderByAppMessage(AppMessage message) { MessageProtobuf.Msg.Builder builder = MessageProtobuf.Msg.newBuilder(); MessageProtobuf.Head.Builder headBuilder = MessageProtobuf.Head.newBuilder(); headBuilder.setMsgType(message.getHead().getMsgType()); headBuilder.setStatusReport(message.getHead().getStatusReport()); headBuilder.setMsgContentType(message.getHead().getMsgContentType()); if (!StringUtil.isEmpty(message.getHead().getMsgId())) headBuilder.setMsgId(message.getHead().getMsgId()); if (!StringUtil.isEmpty(message.getHead().getFromId())) headBuilder.setFromId(message.getHead().getFromId()); if (!StringUtil.isEmpty(message.getHead().getToId())) headBuilder.setToId(message.getHead().getToId()); if (message.getHead().getTimestamp() != 0) headBuilder.setTimestamp(message.getHead().getTimestamp()); if (!StringUtil.isEmpty(message.getHead().getExtend())) headBuilder.setExtend(message.getHead().getExtend()); if (!StringUtil.isEmpty(message.getBody())) builder.setBody(message.getBody()); builder.setHead(headBuilder); return builder; } /** * 通过protobuf消息对象获取业务消息对象 * * @param protobufMessage * @return */ public static AppMessage getMessageByProtobuf( MessageProtobuf.Msg protobufMessage) { AppMessage message = new AppMessage(); Head head = new Head(); MessageProtobuf.Head protoHead = protobufMessage.getHead(); head.setMsgType(protoHead.getMsgType()); head.setStatusReport(protoHead.getStatusReport()); head.setMsgContentType(protoHead.getMsgContentType()); head.setMsgId(protoHead.getMsgId()); head.setFromId(protoHead.getFromId()); head.setToId(protoHead.getToId()); head.setTimestamp(protoHead.getTimestamp()); head.setExtend(protoHead.getExtend()); message.setHead(head); message.setBody(protobufMessage.getBody()); return message; }}



AbstractMessageHandler抽象的消息处理handler,每个消息类型对应不同的messageHandler:


010203040506070809101112131415161718192021222324package com.freddy.chat.im.handler; import com.freddy.chat.bean.AppMessage; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:AbstractMessageHandler.java</p> * <p>@ PackageName:com . freddy . chat .im。处理者</p> *  * <p>@描述:抽象消息处理程序</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/10 03:41</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public abstract class AbstractMessageHandler implements IMessageHandler { @Override public void execute(AppMessage message) { action(message); } protected abstract void action(AppMessage message);}



SingleChatMessageHandler单聊消息处理handler:


010203040506070809101112131415161718192021222324252627282930313233343536373839404142package com.freddy.chat.im.handler; import android.util.Log; import com.freddy.chat.bean.AppMessage;import com.freddy.chat.bean.SingleMessage;import com.freddy.chat.event.CEventCenter;import com.freddy.chat.event.Events; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:SingleChatMessageHandler.java</p> * <p>@ PackageName:com . freddy . chat .im。处理者</p> *  * <p>@描述:类描述</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/10 03:43</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class SingleChatMessageHandler extends AbstractMessageHandler { private static final String TAG = SingleChatMessageHandler.class.getSimpleName(); @Override protected void action(AppMessage message) { Log.d(TAG, "收到单聊消息,message=" + message); SingleMessage msg = new SingleMessage(); msg.setMsgId(message.getHead().getMsgId()); msg.setMsgType(message.getHead().getMsgType()); msg.setMsgContentType(message.getHead().getMsgContentType()); msg.setFromId(message.getHead().getFromId()); msg.setToId(message.getHead().getToId()); msg.setTimestamp(message.getHead().getTimestamp()); msg.setExtend(message.getHead().getExtend()); msg.setContent(message.getBody()); CEventCenter.dispatchEvent(Events.CHAT_SINGLE_MESSAGE, 0, 0, msg); }}



GroupChatMessageHandler群聊消息处理handler:


0102030405060708091011121314151617181920212223242526package com.freddy.chat.im.handler; import android.util.Log; import com.freddy.chat.bean.AppMessage; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:GroupChatMessageHandler.java</p> * <p>@ PackageName:com . freddy . chat .im。处理者</p> *  * <p>@描述:类描述</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/10 03:43</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class GroupChatMessageHandler extends AbstractMessageHandler { private static final String TAG = GroupChatMessageHandler.class.getSimpleName(); @Override protected void action(AppMessage message) { Log.d(TAG, "收到群聊消息,message=" + message); }}



MessageHandlerFactory消息handler工厂:


0102030405060708091011121314151617181920212223242526272829303132333435363738394041424344package com.freddy.chat.im.handler; import android.util.SparseArray; import com.freddy.chat.im.MessageType; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:MessageHandlerFactory.java</p> * <p>@ PackageName:com . freddy . chat .im。处理者</p> *  * <p>@描述:消息处理处理程序工厂</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/10 03:44</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class MessageHandlerFactory { private MessageHandlerFactory() { } private static final SparseArray HANDLERS = new SparseArray(); static { /** 单聊消息处理handler */ HANDLERS.put(MessageType.SINGLE_CHAT.getMsgType(), new SingleChatMessageHandler()); /** 群聊消息处理handler */ HANDLERS.put(MessageType.GROUP_CHAT.getMsgType(), new GroupChatMessageHandler()); /** 服务端返回的消息发送状态报告处理handler */ HANDLERS.put(MessageType.SERVER_MSG_SENT_STATUS_REPORT.getMsgType(), new ServerReportMessageHandler()); } /** * 根据消息类型获取对应的处理handler * * @param msgType * @return */ public static IMessageHandler getHandlerByMsgType(int msgType) { return HANDLERS.get(msgType); }}



MessageType消息类型枚举:


0102030405060708091011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283package com.freddy.chat.im; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:MessageType.java</p> * <p>@ PackageName:com . freddy . chat .im</p> *  * <p>@描述:消息类型</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/08 00:04</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public enum MessageType { /* * 握手消息 */ HANDSHAKE(1001), /* * 心跳消息 */ HEARTBEAT(1002), /* * 客户端提交的消息接收状态报告 */ CLIENT_MSG_RECEIVED_STATUS_REPORT(1009), /* * 服务端返回的消息发送状态报告 */ SERVER_MSG_SENT_STATUS_REPORT(1010), /** * 单聊消息 */ SINGLE_CHAT(2001), /** * 群聊消息 */ GROUP_CHAT(3001); private int msgType; MessageType(int msgType) { this.msgType = msgType; } public int getMsgType() { return this.msgType; } public enum MessageContentType { /** * 文本消息 */ TEXT(101), /** * 图片消息 */ IMAGE(102), /** * 语音消息 */ VOICE(103); private int msgContentType; MessageContentType(int msgContentType) { this.msgContentType = msgContentType; } public int getMsgContentType() { return this.msgContentType; } }}



IMSConnectStatusListenerIMS连接状态监听器:


0102030405060708091011121314151617181920212223242526272829package com.freddy.chat.im; import com.freddy.im.listener.IMSConnectStatusCallback; /** * <p>@ProjectName: NettyChat</p> * <p>@ class name:IMSConnectStatusListener.java</p> * <p>@ PackageName:com . freddy . chat .im</p> *  * <p>@描述:类描述</p> *  * <p>@author: FreddyChen</p> * <p>@date: 2019/04/08 00:31</p> * <p>@ email:[URL = mailto:chenshichao @ outlook . com]chenshichao @ outlook . com[/URL]</p> */public class IMSConnectStatusListener implements IMSConnectStatusCallback { @Override public void onConnecting() { } @Override public void onConnected() { } @Override public void onConnectFailed() { }}



由于每个人代码风格不同,封装代码都有自己的思路,所以,在此就不过多讲解,只是把自己简单封装的代码全部贴上来,作一个参考即可。


只需要知道,接收到消息时,会回调OnEventListener的dispatchMsg(MessageProtobuf.Msg msg)方法:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_17-1.jpg 


发送消息需要调用imsClient的sendMsg(MessageProtobuf.Msg msg)方法:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_17-2.jpg 


即可,至于怎样去封装得更好,大家自由发挥吧。由于代码较多,这里就不一一贴出来了,请自行从github下载完整工程源码:https://github.com/52im/NettyChat


18、最终运行


运行一下,看看效果吧:

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_16a42c85e653b88c11aaa.gif 


运行步骤是:



1)首先,启动服务端。

2)然后,修改客户端连接的ip地址为192.168.0.105(这是我本机的ip地址),端口号为8855,fromId,也就是userId,定义成100001,toId为100002,启动客户端A。

3)再然后,fromId,也就是userId,定义成100002,toId为100001,启动客户端B。

4)客户端A给客户端B发送消息,可以看到在客户端B的下面,已经接收到了消息。

5)用客户端B给客户端A发送消息,也可以看到在客户端A的下面,也已经接收到了消息。


至于,消息收发测试成功。至于群聊或重连等功能,就不一一演示了,还是那句话,下载demo体验一下吧:https://github.com/52im/NettyChat。


由于gif录制体积较大,所以只能简单演示一下消息收发,具体下载demo体验吧。如果有需要应用层UI实现(就是聊天页及会话页的封装)的话,我再分享出来吧。


19、写在最后


终于写完了,这篇文章大概写了10天左右,有很大部分的原因是自己有拖延症,每次写完一小段,总静不下心来写下去,导致一直拖到现在,以后得改改。第一次写技术分享文章,有很多地方也许逻辑不太清晰,由于篇幅有限,也只是贴了部分代码,建议大家把源码下载下来看看。一直想写这篇文章,以前在网上也尝试过找过很多im方面的文章,都找不到一篇比较完善的,本文谈不上完善,但包含的模块很多,希望起到一个抛砖引玉的作用,也期待着大家跟我一起发现更多的问题并完善,最后,如果这篇文章对你有用,希望在github上给我一个star哈。。。


应大家要求,精简了netty-all-4.1.33.Final.jar包,原netty-all-4.1.33.Final.jar包大小为3.9M。


经测试发现目前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(已经上传到github工程了),目前自测没有问题,如果发现bug,请告诉我,谢谢。


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

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_19-1.png

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)_19-2.png