码农日记

[Android]搭建聊天架构

2017-11-16  本文已影响27人  码者

新做了聊天业务,和后台一起,都是原生搭建的。目前为止开发了聊天室,还没有做聊天房间列表。已经开发出简版~ 过程有很多弯路和坑,留下记录,给小伙伴们提供思路,也希望能一起讨论还有什么可以改进的地方。
(边开发边记录,慢慢更新。文章太长,作者太懒…)

本文不会重点记录代码,主要记录重点部分的实现逻辑。

本文撰写过程中,老大对后台架构做了改版,新版非常强大,原来客户端的很多控制和判断都不再需要,膜拜老大。
(这个老大真的很厉害,我们办公室有很多人减肥,都减不下来,但他从某天决定开始减肥之后,一个月瘦10斤,现在保持健身。很有意志力。)

聊天流程


已更新。新架构下的流程:
进入房间,通过url得到socket topic(相当于服务器中以房间为单位的业务处理器),连接socket,请求到未读消息存入本地数据库,然后通过数据库获取消息来显示。
每次发送和接收消息,都存入数据库。
发送时,通过消息队列实现超时时间内的持续发送(刚听说这种实现有个高大上的名字叫做 消费者——生产者模式),兼容网络不稳定的情况。在1.3中有详细说明。
发送成功和失败后需要在页面上(View)和数据库(Model)里改变发送状态。

业务内容

  1. 消息的各种状态处理
  2. 超时时间内持续发送
  3. 网络自动重连

1. 关键技术点

2. 逻辑绕弯点:

3. 其他问题

理想功能

1.关键技术点


1.1 每条消息的3次握手

握手过程图:

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

第一次:客户端发送消息到服务器
第二次:服务器成功接收消息,返回反馈信息到客户端
第三次:本地接收到反馈信息,发送已收到反馈到服务器

握手的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和微信的断网下发送消息,发现在数分钟内会持续发送状态,再次恢复网络后,一两秒成功发送。本次使用的消息轮询机制即是参考这种实现效果。

技术关键:

  1. 存储发送中消息的消息队列
  2. 每条消息的发送超时时长(从发送到发送失败的时长)
  3. 轮询发送的时间间隔

注意!需要后台同志配合做去重处理!因为轮询保持3秒一次,但任何时候都有可能发送消息,所以有可能在发送的几乎同时轮询,又发送了一次,所以导致会在收到消息反馈之前重复发送到服务器,服务器会接到2条所以需要后台做去重的处理。

实现逻辑:
每条消息在发送的同时添加进消息队列,这条消息会一直在队列中被轮询发送,直到presenter收到了这条消息发送到服务器后发回的反馈,然后找到这条对应的消息移除出发送队列,于是这条消息的轮询结束。
注意:实现此功能需要后台配合,每条消息发送到服务器之后,服务器会发回(表示发送成功的)反馈信息

  1. 每个消息实体类有一个唯一key属性
  2. 进入消息页面,创建一个消息队列(存储消息bean)
  3. 每发送一条消息,添加进消息队列
  4. 通过定时器(此处使用handler和runnable)每过n秒遍历一次队列,发送队列中的所有消息
  5. 每条消息一旦成功发送到服务器,添加进队列,这条消息会一直在队列中被轮询发送,直到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 显示方案

考虑情况:
所有成功接收和发送的消息保存的都是网络时间。但存在一种情况,即用户修改系统时间
此时:

所以最初犯了一个错误,为了兼容这种情况,每次收发一条消息显示时,都会按照时间重新排序所有消息。但出现问题,即每次收发消息,很容易出现所有消息排序混乱的情况。

而重新理解聊天业务线后发现,实际上整个对话其实相当于一个时间轴。每次发送,每次接收,都即时保存在数据库里。所以,数据库里保存的顺序,就是正确的时间顺序,无须重新排序。数据库存储的顺序其实就是用户真正发送的时间顺序,所以用户修改系统时间与否都没关系,无须重新排序
依照这个方向,开始重新整理发送逻辑和显示逻辑。成功解决历史消息的显示问题。

3.其他问题


3.1 全屏下竖屏的系统缺陷(Android系统bug)

需求:因为做的是游戏内插入的SDK,游戏都是全屏的。而sdk兼容横竖屏两种状态,所以在最普遍使用的竖屏状态下,出现全屏&竖屏这个问题。

问题:Android系统在全屏下竖屏时,软键盘弹出会顶出整个布局,相当于不管你设置成什么样,都无法成为adjustResize的效果,只有adjustPan的效果。

所以我在

  1. 尝试设置adjustResize / adjstPan / ...。照样顶起布局,失败
  2. 尝试设置为绝对布局。无效,失败
  3. 尝试在绝对布局下:
    1. 设置软键盘弹出时页面不动
    2. 监听软键盘弹出时,获取软键盘高度,把输入框手动弄上去。
    最后还是失败,原因:只有在设置为adjustResize | adjstPan时,软键盘弹出页面不会动,不再整体上移。但此时,软键盘监听的接口失效,无法收到软键盘弹起的事件(不知道什么时候被弹起),也无法得到软键盘高度。
  4. 监听软键盘弹起时,根据软键盘的高度计算出屏幕剩余高度,设置视图高度为屏幕剩余高度。结果出现每次弹起屏幕页面都会重绘导致页面白闪,无法接受,失败。
    ...(省略一万字)

心态快要崩了。
最后得知真相的我对google说了一万句MMP。
(以上记录了一位程序员黑发变白发最后秃顶的心路历程)

最后没有办法,

  1. 把activity设置成带头部栏的样式Theme.NoTitleBar
  2. 在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(...);

辣眼睛,但是没办法。

最后唠嗑


最近一直在思考搬砖和我的人生价值实现到底有几毛钱的关系。然而面包就是面包,再怎么不爽都只能先填饱肚子才有力气较劲儿。唉。
对于程序员而言,最重要的不是某个具体技能,而是解决问题的思路和创造思路的手段。
本文持续更新中,见证后台架构与前台架构的迭代。

上一篇下一篇

猜你喜欢

热点阅读