[Android]搭建原生聊天架构,用到了3次握手思想...

项目已交接,暂不再更新。

-----------------------------------------------------分割线-----------------------------------------------------------

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

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

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

聊天流程


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

业务内容

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

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次握手示意图.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 显示方案

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

  • 发送成功会保存网络时间
  • 无网络发送失败,会保存本地时间,而可能是1999年

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

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

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(...);

辣眼睛,但是没办法。

最后唠嗑


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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,465评论 25 707
  • 来源 RabbitMQ是用Erlang实现的一个高并发高可靠AMQP消息队列服务器。支持消息的持久化、事务、拥塞控...
    jiangmo阅读 10,343评论 2 34
  • 一、 Spring技术概述1、什么是Spring : Spring是分层的JavaSE/EE full-stack...
    luweicheng24阅读 723评论 0 1
  • 艺术品需要时间的磨砺,才能展现出最美的光辉。每一件称之为有划时代意义的作品,不仅代表了当时审美高度和技术水准,同时...
    影子倒了阅读 374评论 1 6