项目已交接,暂不再更新。
-----------------------------------------------------分割线-----------------------------------------------------------
新做了聊天业务,和后台一起,都是原生搭建的。目前为止开发了聊天室,还没有做聊天房间列表。已经开发出简版~ 过程有很多弯路和坑,留下记录,给小伙伴们提供思路,也希望能一起讨论还有什么可以改进的地方。
(边开发边记录,慢慢更新。文章太长,作者太懒…)
本文不会重点记录代码,主要记录重点部分的实现逻辑。
本文撰写过程中,老大对后台架构做了改版,新版非常强大,原来客户端的很多控制和判断都不再需要,膜拜老大。
(这个老大真的很厉害,我们办公室有很多人减肥,都减不下来,但他从某天决定开始减肥之后,一个月瘦10斤,现在保持健身。很有意志力。)
聊天流程
已更新。新架构下的流程:
进入房间,通过url得到socket topic(相当于服务器中以房间为单位的业务处理器),连接socket,请求到未读消息存入本地数据库,然后通过数据库获取消息来显示。
每次发送和接收消息,都存入数据库。
发送时,通过消息队列实现超时时间内的持续发送(刚听说这种实现有个高大上的名字叫做 消费者——生产者模式),兼容网络不稳定的情况。在1.3中有详细说明。
发送成功和失败后需要在页面上(View)和数据库(Model)里改变发送状态。
业务内容:
- 消息的各种状态处理
- 超时时间内持续发送
- 网络自动重连
1. 关键技术点:
- 1.1 消息3次握手
- 1.2 socket重连
- 1.3 消息队列
- 1.4 图片加载——被迫造轮子
2. 逻辑绕弯点:
- 2.1 显示方案——消息如何排序
- 2.2 聊天对话框的复用(左右 -> 融合两侧隐藏一边)
- 2.3 图片复用非常重要,还要及时释放!
3. 其他问题:
- 3.1 全屏下竖屏的系统缺陷(Android系统bug)
- 3.2 发送表情时,listview不能滚动到底部
理想功能:
- 未发送成功的消息记录,在下一次打开社区时发送到远程,真正实现多端历史记录同步(未验证微信QQ是否有此功能,有待验证)
1.关键技术点
1.1 每条消息的3次握手
握手过程图:
第一次:客户端发送消息到服务器
第二次:服务器成功接收消息,返回反馈信息到客户端
第三次:本地接收到反馈信息,发送已收到反馈到服务器
握手的3个环节中,任何一个环节都有可能发送失败。将导致牵扯出消息重复、客户端与服务端记录的消息状态不同步、退出聊天页面再次进入后历史消息有重叠等等的可能情况,鉴于太过复杂,开发周期太长,目前的初版有很多处理还没有开发。暂留做升级功能。
-------------------更新--------------------
部门老大出手对后台改了架构,服务器把消息发送到服务端时对TCP的发送状态做了监听,所以3次握手成功省略掉第3个环节,进化成2次握手~ !鼓掌庆祝~!
所以发送成功的处理与客户端分离,客户端只需要接收消息并显示,成功回归无脑本质,非常nice。
1.2 聊天的基础——socket
保持连接——轮询联网
因为聊天功能的实时通讯性质,必须与服务器保持连接,所以聊天时,持续的连接是必须的。每次断开,都要能自动重连上。
所以这就是第一个轮询:socket重连机制。
之所以列为机制的地位,必然是踩了不少坑。
以下是轮询相关代码:
// 重连
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() {
// 调用重连
onConnectFailed();
}
onError() {
// 调用重连
onConnectFailed();
}
}
onConnectFailed() {
// reconnect
if (isReconnecting == false) {
isReconnecting = true;
handler.postDelayed(reconnectRunnable, reconnectDeliver);
}
// 移除心跳机制,在下一次连接成功后再开启,不清空会导致ping越来越频繁
handler.removeCallbacks(heartbeatRunnable);
}
最后别忘了,在要手动关闭socket的方法里面,要关掉重连机制哦,不然会又自动连上。(也要关闭心跳机制)
public void close() {
// 移除重连轮询
handler.removeCallbacks(reconnectRunnable);
// 移除心跳
handler.removeCallbacks(heartbeatRunnable);
if (socket != null) {
socket.close();
}
}
1.3 确保发送——未发送成功的消息在超时时间内轮询发送
表现效果:
观察QQ和微信的断网下发送消息,发现在数分钟内会持续发送状态,再次恢复网络后,一两秒成功发送。本次使用的消息轮询机制即是参考这种实现效果。
技术关键:
- 存储发送中消息的消息队列
- 每条消息的发送超时时长(从发送到发送失败的时长)
- 轮询发送的时间间隔
注意!需要后台同志配合做去重处理!因为轮询保持3秒一次,但任何时候都有可能发送消息,所以有可能在发送的几乎同时轮询,又发送了一次,所以导致会在收到消息反馈之前重复发送到服务器,服务器会接到2条所以需要后台做去重的处理。
实现逻辑:
每条消息在发送的同时添加进消息队列,这条消息会一直在队列中被轮询发送,直到presenter收到了这条消息发送到服务器后发回的反馈,然后找到这条对应的消息移除出发送队列,于是这条消息的轮询结束。
注意:实现此功能需要后台配合,每条消息发送到服务器之后,服务器会发回(表示发送成功的)反馈信息
- 每个消息实体类有一个唯一key属性
- 进入消息页面,创建一个消息队列(存储消息bean)
- 每发送一条消息,添加进消息队列
- 通过定时器(此处使用handler和runnable)每过n秒遍历一次队列,发送队列中的所有消息
- 每条消息一旦成功发送到服务器,添加进队列,这条消息会一直在队列中被轮询发送,直到presenter收到了这条消息发送到服务器后发送会的反馈,于是找到这条对应的消息,移除出发送队列,这条消息的轮询结束。(可以使用先添加进队列再发送的逻辑顺序,虽然网络信息往返的时间[即服务器收到消息后发回反馈信息过程中的耗时]很少会比本地操作快,但是逻辑上的万无一失值得追求,如果养成习惯更能顺手预防掉很多千奇百怪的坑)
简单地说,就是有页面里有一个消息队列一直在轮询发送未达消息,而每条消息根据自身发送情况进出队列。
还有一个逻辑点,我项目需求是简单版的聊天,不考虑关闭当前聊天页之后聊天消息的发送,也就是关闭页面后所有还未发送成功的消息都当做发送失败。而这里为了达到这种效果,采取了最简单粗暴的方法,每条消息发送出去后在数据库里直接存储为发送失败。若发送成功收到回执消息,则修改对应消息的发送状态为成功,否则失败。
所以,再次打开页面,显示的历史消息里,上次未发送成功的也就显示为发送失败了。不需要额外修改数据库和判断。
对上面这部分逻辑有疑问的盆友可以好好想一想,欢迎讨论。
代码:
创建消息队列,在打开页面时开启(view的onCreate中创建presenter,presenter构造函数里开始轮询)。懒加载或者直接写成属性field都可以,这里直接写成field,key的类型看具体情况。
private Map<Integer, ChatBean> messageQueue = new HashMap();
发送时添加进队列
public void send() {
messageQueue.add();
// send to server
...
}
收到消息反馈,从队列移除
private void onResponse(response) {
switch (response.type) {
case msg_update: // 服务器接收到某条消息的反馈
// get msg key
int msgKey = ...;
updateMsg
break;
case message:
break;
}
}
private void updateMsg(int msgKey) {
messageQueue.remove(msgKey);
}
以上操作并不会对服务器带来额外的访问压力,因为每条消息发送到服务器后就不会再发送第二次。
(此处有一个问题,如果消息发送到服务器,但服务器发回反馈信息时出现问题,无法发送回客户端,则可能服务器会在一定时间内,最多会持续到消息超时前,一直接收到该消息。但是这种情况是否有可能存在或者会在什么情况下可能存在,需要查证网络通讯相关资料验证。)
2.逻辑绕弯点
2.1 显示方案
考虑情况:
所有成功接收和发送的消息保存的都是网络时间。但存在一种情况,即用户修改系统时间。
此时:
- 发送成功会保存网络时间
- 无网络发送失败,会保存本地时间,而可能是1999年
所以最初犯了一个错误,为了兼容这种情况,每次收发一条消息显示时,都会按照时间重新排序所有消息。但出现问题,即每次收发消息,很容易出现所有消息排序混乱的情况。
而重新理解聊天业务线后发现,实际上整个对话其实相当于一个时间轴。每次发送,每次接收,都即时保存在数据库里。所以,数据库里保存的顺序,就是正确的时间顺序,无须重新排序。数据库存储的顺序其实就是用户真正发送的时间顺序,所以用户修改系统时间与否都没关系,无须重新排序。
依照这个方向,开始重新整理发送逻辑和显示逻辑。成功解决历史消息的显示问题。
3.其他问题
3.1 全屏下竖屏的系统缺陷(Android系统bug)
需求:因为做的是游戏内插入的SDK,游戏都是全屏的。而sdk兼容横竖屏两种状态,所以在最普遍使用的竖屏状态下,出现全屏&竖屏这个问题。
问题:Android系统在全屏下竖屏时,软键盘弹出会顶出整个布局,相当于不管你设置成什么样,都无法成为adjustResize的效果,只有adjustPan的效果。
所以我在
- 尝试设置adjustResize / adjstPan / ...。照样顶起布局,失败
- 尝试设置为绝对布局。无效,失败
- 尝试在绝对布局下:
1. 设置软键盘弹出时页面不动
2. 监听软键盘弹出时,获取软键盘高度,把输入框手动弄上去。
最后还是失败,原因:只有在设置为adjustResize | adjstPan时,软键盘弹出页面不会动,不再整体上移。但此时,软键盘监听的接口失效,无法收到软键盘弹起的事件(不知道什么时候被弹起),也无法得到软键盘高度。 - 监听软键盘弹起时,根据软键盘的高度计算出屏幕剩余高度,设置视图高度为屏幕剩余高度。结果出现每次弹起屏幕页面都会重绘导致页面白闪,无法接受,失败。
...(省略一万字)
心态快要崩了。
最后得知真相的我对google说了一万句MMP。
(以上记录了一位程序员黑发变白发最后秃顶的心路历程)
最后没有办法,
- 把activity设置成带头部栏的样式Theme.NoTitleBar
- 在xml文件里设置最外层使用LinearLayout,中间的listview设置为width = 0, weight = 1
于是一切正常。
注释:真的是憋出内伤的系统bug。因为出现问题往往最相信的是系统,但是怀疑自己怀疑代码怀疑人生怀疑世界之后,实在没办法了,才发现是系统TM挖的坑。
3.2 发送带有表情(SpannableString)的消息,listview不能滚动到底部。
参考:android的listview中setselection()不起作用的解决方案
事实证明,简单粗暴往往很有效。
解决办法:
list.setAdapter(adapter); // or list.setAdapter(list.getAdapter())
list.setPosition(...);
辣眼睛,但是没办法。
最后唠嗑
最近一直在思考搬砖和我的人生价值实现到底有几毛钱的关系。然而面包就是面包,再怎么不爽都只能先填饱肚子才有力气较劲儿。唉。
对于程序员而言,最重要的不是某个具体技能,而是解决问题的思路和创造思路的手段。
本文持续更新中,见证后台架构与前台架构的迭代。