EChatFramework 设计和关键代码

我们知道信息交流非常重要,那网络中进程之间如何通信,比如我们每天打开浏览器浏览网页时,浏览器的进程怎么与web服务器通信的?当你用QQ聊天时,QQ进程怎么与服务器通信、或者怎么与你好友所在的QQ进程通信?这些都得靠 socket

EChatFramework 就是一套基于 Socket 实现的即时通讯框架。

先看一下 EChatFramework 的整体结构和关键类:


EChatFramework 的整体结构和关键类

一、CocoaAsyncSocket

它是对socket的OC封装,省去了我们直接使用socket复杂的编程。CocoaAsyncSocket 的接口非常简单清晰,使用也很方便,感兴趣的同学可以自己去研究,这里就简单提一下。

GCDAsyncSocket

GCDAsyncSocket 是建立在 GCD 之上的一个 TCP/IP 协议的 socket 网络库.

GCDAsyncSocket is a TCP/IP socket networking library built atop Grand Central Dispatch.

GCDAsyncUdpSocket

GCDAsyncUdpSocket 是建立在 GCD 之上的一个 UDP/IP 协议的 socket 网络库.

GCDAsyncUdpSocket is a UDP/IP socket networking library built atop Grand Central Dispatch.

二、网络进程之间如何通信?什么是 Socket ?

网络中进程之间如何通信(这里指的是不同计算机上的进程,需要联网)?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起。就像两个人打电话,电话号码就是两个人的唯一标识,不知道电话号码就没办法通话。

TCP/IP协议已经帮我们解决了这个问题。在 TCP/IP 协议簇中,网络层的“ip地址”可以唯一标识网络中的主机,传输层的传输协议(TCP 协议或 UDP 协议)+ 端口号可以唯一标识主机中的应用程序(进程)。这样,利用三元组(ip地址、传输协议、端口)就可以标识某一个网络进程了。使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket) 来实现网络进程之间的通信。

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。Socket 起源于Unix下的一组编程接口,因为被广泛使用,所以windows、linux都支持socket编程。常见的编程语言,C、C++、C#、JAVA等等都提供了socket的编程函数(库)。socket主要用于编写网络上传输数据的程序、如聊天软件、下载软件、浏览器...... 主要使用TCP/IP协议来传输数据。socket提供了一组函数,如建立连接、收发数据等等,这些函数在不同平台和语言下都是类似的。

OSI模型和网际协议簇中的个层
OSI模型和网际协议簇中的对应各层.png

上图表示,嵌套字(Socket)编程接口是从顶上三层(网络协议的应用层)进入传输层的接口。

那么在使用 Socket 编程时,选择哪种传输协议呢?

TCP提供IP环境下的数据可靠传输,它提供的服务包括数据流传送、可靠性、有效流控、全双工操作和多路复用。通过面向连接、端到端和可靠的数据包发送。通俗说,它是事先为所发送的数据开辟出连接好的通道,然后再进行数据发送;而UDP则不为IP提供可靠性、流控或差错恢复功能。一般来说,TCP对应的是可靠性要求高的应用,而UDP对应的则是可靠性要求低、传输经济的应用。

区分几个概念:

Socket/Websocket

WebSocket一种在单个TCP连接上进行全双工通讯的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并被RFC7936所补充规范。WebSocket API也被W3C定为标准。——维基百科

Websocket 是一个应用层协议,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。

Socket 并不是一个协议,而是为了方便使用 TCP 或 UDP 而抽象出来的一层,是位于应用层和传输控制层之间的一组接口。如果你要使用HTTP来构建服务,那么就不需要关心Socket,如果你想基于 TCP/IP 来构建服务,那么 Socket 可能就是你会接触到的 API。

长连接/短连接

这是一组针对传输层的概念,也就是,TCP 连接才有长/短连接之说
短连接是指通讯双方有数据交互时,就建立一个 TCP 连接,数据发送完成后,则断开此连接,即每次连接只完成一项业务的发送。

长连接指在一个 TCP 连接上可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要 TCP keep alive。TCP keep alive 的两种方式:

  1. 应用层面的心跳机制
    自定义心跳消息头 : 一般客户端主动发送, 服务器接收后进行回应(也可以不回应).

  2. TCP协议自带的 keep alive:打开keep-alive功能即可. 具体属性也可以通过API设定.

TCP KeepAlive 是用于检测TCP连接的状态,而心跳机制有两个作用:一是检测TCP的状态,二是检测通讯双方的状体。

考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态,一直向当前服务器发送些必然会失败的请求。

从上面我们可以知道,KeepAlive 并不适用于检测双方存活的场景,这种场景还得依赖于应用层的心跳。应用层心跳有着更大的灵活性,可以控制检测时机,间隔和处理流程,甚至可以在心跳包上附带额外信息。从这个角度而言,应用层的心跳的确是最佳实践。

写到这顺便说一下 HTTP Keep Alive 和 TCP keep alive 这两个看上去肯定有点儿啥关系的概念。事实上,HTTP keep-alive与TCP keep-alive 是两个完全没有关系的东西。

HTTP keep-alive是 HTTP 协议的一个特性,目的是为了让TCP连接保持的更久一点,以便客户端/服务端在同一个 TCP 连接上可以发送多个 HTTP request/response。

TCP keep-alive 是一种检测连接状况的保活机制。It keeps TCP connection opened by sending small packets. 当网络两端建立了TCP连接之后,闲置idle(双方没有任何数据流发送往来)了 tcp_keepalive_time 后,服务器内核就会尝试向客户端发送侦测包,来判断TCP连接状况(有可能客户端崩溃、强制关闭了应用、主机不可达等等)。如果没有收到对方的回答(ack包),则会在 tcp_keepalive_intvl 后再次尝试发送侦测包,直到收到对对方的ack,如果一直没有收到对方的ack,一共会尝试 tcp_keepalive_probes 次,每次的间隔时间在这里分别是15s, 30s, 45s, 60s, 75s。如果尝试 tcp_keepalive_probes ,依然没有收到对方的ack包,则会丢弃该 TCP 连接。TCP 连接默认闲置时间是2小时,一般设置为30分钟足够了。

长轮询/短轮询: 略

三、ECStream类

ECStream 是 EChatFramework 中的最底层核心类,它基于 GCDAsyncSocket 实现如下功能:

  1. 建立指定 host 和 port 的 TCP 链接,
  2. 断开链接
  3. 写数据(发送数据包)
  4. 接收数据流,并将数据流解析为数据包(ECPacket)对象
  5. 利用多代理将接收的数据包(ECpacket)分发下去到各个模块别分处理

注:为保证多线程安全,ECStream 类拥有一个自己的串行队列:echatQueue ,比如接收到的数据流的解析或者其他需要同步处理的任务,可以放在该队列中同步执行。数据流的解析必须保证是同步的,解析完一个包才能继续解析下一个包。

关键代码解析:
  1. 发送数据包
- (void)writePacket:(ECHeader *)packet
{
    if(![self isConnected]){
        ECLog(@"%@ : ECStream did not connected now",NSStringFromSelector(_cmd));
        return;
    }
    [self.asyncSocket writeData:[packet streamData] withTimeout:-1 tag:0];
    [self.multipleDelegate ecStream:self didSendPacket:(ECPacket *)packet];
    ECLog(@"didSendPacket:\n%@",packet);
}

其中 self.asyncSocket 是一个 GCDAsyncSocket 实例对象,GCDAsyncSocket 类中提供了多种接口供我们调用,比如建立链接、写数据、读固定字节长度的数据、读到某个指定位置等。

  1. 接收数据流并解析

这个过程最需要注意的问题就是“粘包”和“断包”。
什么是粘包?比如服务端向客户端发送两条数据,data1 和 data2,客户端接数据时有以下几种情况:


  • 先接收到data1,然后接收到data2.
  • 先接收到data1的部分数据,然后接收到data1余下的部分以及data2的全部.
  • 先接收到了data1的全部数据和data2的部分数据,然后接收到了data2的余下的数据.
  • 一次性接收到了data1和data2的全部数据.

A 属于正常的情况,因为两个包是分开接收到的,此时不需要处理粘包问题。B、C、D 中接收到的两条数据粘在一起,造成“粘包”问题。

为什么两条数据会粘在一起呢?因为TCP使用了优化方法(Nagle算法),它将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这么做优点也很明显,就是为了减少广域网的小分组数目,从而减小网络拥塞的出现。而UDP就不会有这种情况,它不会使用块的合并优化算法。

TCP和UDP都会因为下面两种情况造成粘包:

  • 发送端需要等缓冲区满才发送出去,造成粘包
  • 接收方不及时接收缓冲区的包,造成多个包粘在一起,读取时自然造成粘包

什么是断包?断包应该还是比较好理解的,比如我们发送一条很大的数据包,类似图片和录音等等,很显然一次发送或者读取数据的缓冲区大小是有限的,所以需要分段去发送或者读取数据,造成断包现象。

要正确解析数据,那么必须要使用一种合理的机制去解包。这个机制的思路其实很简单:
我们在封包的时候给每个数据包加一个长度或者一个开始结束标记。
然后我们拆包的时候就能区分每个数据包了,再按照长度或者标记符去分拆成各个数据包。

EChatFramework 中根据包长度解析,算法流程如下:

Echat码流解析流程图

设定每次读取的字节长度,设定读取的字节长度一共只有三种:kHeartBeatLength、kHeaderLength - kHeartBeatLength、messageLength - kHeaderLength 。

/**
 * 数据流解析
 *
 * 控制每次接收数据流的粒度大小为:kHeartBeatLength,kHeaderLength - kHeartBeatLength,messageLength - kHeaderLength 三种
 * kHeartBeatLength                : 1. 心跳包
 *                                   2. 包头一部分
 *                                   3. 包体
 *
 * kHeaderLength - kHeartBeatLength: 1. 包头一部分
 *                                   2. 包体
 *
 * messageLength - kHeaderLength   : 1. 包体
 *
 */
- (void)receiveNewData:(NSData *)newData
{
    //数据流粒度 kHeartBeatLength
     if (newData.length == kHeartBeatLength) {
        ECBeat *heartBeat = [[ECBeat alloc] initWithData:newData];
        if ([ECBeat isBeatPacket:heartBeat] && !readStreamData ) {
            [self.multipleDelegate ecStream:self
                            didNotParseData:newData];
            [self.asyncSocket readDataToLength:kHeartBeatLength withTimeout:-1 tag:0];
            return;
        }else {
            if (!_currentHeader) {
                [self appendStreamData:newData];
                [self.asyncSocket readDataToLength:kHeaderLength - kHeartBeatLength withTimeout:-1 tag:0];
                return;
            }else {
                [self appendStreamData:newData];
                [self handleOneCompletePacketData];
                return;
            }
        }
    }
    //数据流粒度 kHeaderLength - kHeartBeatLength
    if (newData.length == kHeaderLength - kHeartBeatLength) {
        if (!_currentHeader) {
            [self appendStreamData:newData];
            NSData *headerData = [[NSMutableData alloc] initWithData:readStreamData];
            self.currentHeader = [[ECHeader alloc] initWithData:headerData];
            
            if (![self.currentHeader isValiade]) {
                [self disconnect];
                [self connect];
                return;
            }
            
            NSUInteger length = self.currentHeader.messageLength - kHeaderLength;
            if (length == 0) {
                [self handleOneCompletePacketData];
            }
            else
            {
                [self.asyncSocket readDataToLength:length withTimeout:-1 tag:0];
            }
            return;
            
        }else {
            [self appendStreamData:newData];
            [self handleOneCompletePacketData];
            return;
        }
    }

    [self appendStreamData:newData];
    [self handleOneCompletePacketData];
    return;
}
  1. 多代理

在Objective-C中,经常使用delegate来在对象之间通信,但是delegate一般是对象间一对一的通信,有时候我们希望一个委托者把任务交给多个不同的代理进行处理,也就是下面所介绍的“多代理”。

需求:ECStream 类作为数据的接收以及转发中心,需要将解析好的数据包转发给各个模块,然后在各个模块中进行相应的处理。

为什么使用多代理的呢?一对多通常使用通知的形式,这里不使用通知而使用多代理,原因可能是因为:使用代理更直观方便(不知道两者在性能上有没有明显差异)。也许还有除了通知和多代理的其他实现机制,这里仅介绍 EChat 目前使用的多代理方式。

使用我自己写的一个简单 Demo 举个栗子:

将多代理功能封装在 SNMultidelegate 类中。在头文件 SNMultidelegate .h 中只需暴露两个方法:添加代理,移除代理

//
//  SNMultiDelegate.h
//  YJDemo
//
//  Created by 杨洁 on 2017/11/21.
//  Copyright © 2017年 杨洁. All rights reserved.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN
@interface SNMultiDelegate : NSObject

- (void)addDelegate:(id)delegate;
- (void)removeDelegate:(id)delegate;

@end
NS_ASSUME_NONNULL_END

然后是 SNMultiDelegate.m ,目前还是空的。

//
//  SNMultiDelegate.m
//  YJDemo
//
//  Created by 杨洁 on 2017/11/21.
//  Copyright © 2017年 杨洁. All rights reserved.
//

#import "SNMultiDelegate.h"


@implementation SNMultiDelegate

@end

既然委托方要交给多个代理对象去实现代理方法,就需要有一个容器 delegates 去存放所有的代理对象,为保证多线程安全访问该容器,引入一个串行队列保证同步读写操作,代码如下:

//
//  SNMultiDelegate.m
//  YJDemo
//
//  Created by 杨洁 on 2017/11/21.
//  Copyright © 2017年 杨洁. All rights reserved.
//

#import "SNMultiDelegate.h"

@interface SNMultiDelegate ()
@property (nonatomic, strong) NSMutableSet<id> *delegates;//去除重复对象
@property (nonatomic, strong) dispatch_queue_t serialQueue;

@end

@implementation SNMultiDelegate
- (instancetype)init
{
    self = [super init];
    if (self) {
        _delegates = [[NSMutableSet alloc] initWithCapacity:4];
        _serialQueue = dispatch_queue_create("com.guahao", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}


@end

然后实现头文件中的两个方法:

//
//  SNMultiDelegate.m
//  YJDemo
//
//  Created by 杨洁 on 2017/11/21.
//  Copyright © 2017年 杨洁. All rights reserved.
//

#import "SNMultiDelegate.h"

@interface SNMultiDelegate ()
@property (nonatomic, strong) NSMutableSet<id> *delegates;//去除重复对象
@property (nonatomic, strong) dispatch_queue_t serialQueue;

@end

@implementation SNMultiDelegate
- (instancetype)init
{
    self = [super init];
    if (self) {
        _delegates = [[NSMutableSet alloc] initWithCapacity:4];
        _serialQueue = dispatch_queue_create("com.guahao", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}


#pragma mark - public
- (void)addDelegate:(id)delegate
{
    if (!delegate) {
        return;
    }
    dispatch_block_t block = ^{
        [self.delegates addObject:delegate];
    };
    dispatch_sync(self.serialQueue, block);
}

- (void)removeDelegate:(id)delegate
{
    if (!delegate) {
        return;
    }
    dispatch_block_t block = ^{
        [self.delegates removeObject:delegate];
    };
    dispatch_sync(self.serialQueue, block);
}

@end

下一步就是怎么让多代理 SNMultiDelegate 对象能够响应(处理)代理方法。很显然 SNMultiDelegate 类以及父类不能够响应任何方法,于是想到可以在消息转发阶段,将代理方法分发到各个代理对象中。

//
//  SNMultiDelegate.m
//  YJDemo
//
//  Created by 杨洁 on 2017/11/21.
//  Copyright © 2017年 杨洁. All rights reserved.
//

#import "SNMultiDelegate.h"

@interface SNMultiDelegate ()
@property (nonatomic, strong) NSMutableSet<id> *delegates;//去除重复对象
@property (nonatomic, strong) dispatch_queue_t serialQueue;

@end

@implementation SNMultiDelegate
- (instancetype)init
{
    self = [super init];
    if (self) {
        _delegates = [[NSMutableSet alloc] initWithCapacity:4];
        _serialQueue = dispatch_queue_create("com.guahao", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}


#pragma mark - public
- (void)addDelegate:(id)delegate
{
    if (!delegate) {
        return;
    }
    dispatch_block_t block = ^{
        [self.delegates addObject:delegate];
    };
    dispatch_sync(self.serialQueue, block);
}

- (void)removeDelegate:(id)delegate
{
    if (!delegate) {
        return;
    }
    dispatch_block_t block = ^{
        [self.delegates removeObject:delegate];
    };
    dispatch_sync(self.serialQueue, block);
}


#pragma mark - overwrite
//同时在调用forwardInvocation:之前,需要先调用methodSignatureForSelector:获取指定selector的方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
{
    NSMethodSignature *signature = nil;
    for (id delegateObj in self.delegates) {
        signature = [delegateObj methodSignatureForSelector:aSelector];
        if (signature) {
            return signature;
        }
    }
    //避免crash
    return [[self class] instanceMethodSignatureForSelector:@selector(doNothing)];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation
{
    SEL selector = [anInvocation selector];
    for (id delegateObj in self.delegates) {
        if ([delegateObj respondsToSelector:selector]) {
            [anInvocation invokeWithTarget:delegateObj];
        }
    }
}

- (void)doNothing
{
    
}


@end

这样就完全实现了多代理的功能,但不能保证各个代理对象的方法执行顺序。并且要手动调用 removeDelegate: 方法才能使代理对象正确释放。另外,多继承也是使用类似的实现方式,就好像一个类继承了多个类的方法一样。

@protocol SNDemoDelegate <NSObject>

- (void)printTheImplClassName;

@end


@interface SNDemoViewController ()
@property (nonatomic, strong) SNMultiDelegate<YJDemoDelegate> *multiDelegate;

@end

@implementation SNDemoViewController

- (void)viewDidLoad {
    [super viewDidLoad];
   
    SNOne *one = [[SNOne alloc] init];
    [self.multiDelegate addDelegate:one];

    SNTwo *two = [[SNTwo alloc] init];
    [self.multiDelegate addDelegate:two];

    SNTwo *twooo = [[SNTwo alloc] init];
    [self.multiDelegate addDelegate: twooo];
    [self.multiDelegate addDelegate:one];
}


- (IBAction)btnClicked:(id)sender
{
    
    [self.multiDelegate printTheImplClassName];
}

@end


控制台打印:

2017-11-21 15:02:10.812626+0800 YJDemo[4897:543089] ----- SNTwo -------
2017-11-21 15:02:10.812888+0800 YJDemo[4897:543089] ----- SNOne -------
2017-11-21 15:02:10.812985+0800 YJDemo[4897:543089] ----- SNTwo -------

注意下,在调用 [self.multiDelegate printTheImplClassName]; 方法时,并没有像我们平时做的那样判断
if (self.multiDelegate respondsToSelector:@selector(printTheImplClassName)),因为在不管是不是能响应,这里都不会引起crash。

多代理也可以做到让各个代理对象按一定顺序执行代理任务。

对目前 ECStream 类的思考

ECStream 类从作用上来看,它在整个app运行期间都会存在,并且只有一个实例,所以设计成单例更合适。现在的设计并不是单例,并且每个模块都会持有一个 ECStream 实例对象(都是同一个),甚至主项目中的 ECAdapter 中也会暴露 ECStream 这个本应处于底层的类。 所以在新版本SDK的改造中,我们使用单例,而不是像现在一样在 ECAdapter 中实例化一个 ECStream 对象,这样位于底层的类就不会暴露出来,做到模块内的高内聚性。

四、ECModule

模块基类,不能实例化,各个模块均继承自该类。ECModule 中实现了公共的基本方法,比如:激活模块(所做的就是设置ecstream实例对象、设置该模块为ECStream类的代理)、注销模块(将该模块持有的指向 ecstream 实例的指针置为nil、将该模块从ECStream多代理中移除)、检查包是否应该由该模块分发处理、发送包以及发送超时的响应。

关键代码解析:

ECModule 基类中实现了发送消息以及发送超时的处理。

在 ECStream 类中,拥有一个 NSThread 属性:timerThread。
getter 方法代码如下:

/* ECStream.m */
- (NSThread *)timerThread
{
    if (!_timerThread) {
        @synchronized(self) {
            if (!_timerThread) {
                _timerThread = [[NSThread alloc] initWithTarget:self selector:@selector(startWork) object:nil];
                [_timerThread start];
            }
        }
    }
    return _timerThread;
}

- (void)startWork
{
    // Should keep the runloop from exiting
    CFRunLoopSourceContext context = {0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL};
    CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
    
    BOOL runAlways = YES; // Introduced to cheat Static Analyzer
    while (runAlways) {
        @autoreleasepool {
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0e10, true);
        }
    }
    
    // Should never be called, but anyway
    CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
    CFRelease(source);
}

发送消息并带有超时回调的关键代码在 ECModule 类中:

/* ECModule.m */

// 在指定队列中异步执行任务(将任务添加到队列中立即返回)
- (void)executeAsyncBlock:(dispatch_block_t)block
{
    //判断是否在指定队列中
    if (dispatch_get_specific(_moduleQueueTag)) { 
        block();
    } else { 
        dispatch_async(_moduleQueue, block);
    }
}

//在指定队列中同步执行任务
- (void)executeSyncBlock:(dispatch_block_t)block
{
    //判断是否在指定队列中
    if (dispatch_get_specific(_moduleQueueTag)) {
        block();
    } else {
        dispatch_sync(_moduleQueue, block);
    }
}


//发送消息,超时回调 response
- (void)sendPacket:(ECHeader *)packet response:(ECResponseBlock)responseBlock
{
    if(![self.ecStream isConnected]){
        [self.ecStream connect];
        return;
    }
    if (![self checkIfModulePacket:packet]) {
        return;
    }
    [self executeAsyncBlock:^{
        packet.messageId = [self getNextMessageId];
        NSString *messageId = [NSString stringWithFormat:@"%u", packet.messageId];
        if (packet.qosLevel == ECQosLevel1) {
            [self startTimerForMessageId:messageId withResponse:responseBlock];
        } else if (packet.qosLevel == ECQosLevel0 && responseBlock) {
            [self startTimerForMessageId:messageId withResponse:responseBlock];
        }
        [self.ecStream writePacket:packet];
    }];
}

//为该消息开启定时器
- (void)startTimerForMessageId:(NSString *)messageId withResponse:(ECResponseBlock)responseBlock
{
    if (!self.ecStream.timerThread) {
        return;
    }
    
    if (!messageId || [messageId isEqualToString:@""]) {
        return;
    }

    [self executeSyncBlock:^{
        if (responseBlock) {
            [self.messageDict setObject:responseBlock forKey:messageId];
        } else {
            ECResponseBlock block = ^(id response) {
                [self defaultAckHandler:response];
            };
            [self.messageDict setObject:block forKey:messageId];
        }
        [self performSelector:@selector(addTimerForMessageId:)
                     onThread:self.ecStream.timerThread
                   withObject:messageId
                waitUntilDone:NO];
    }];
}

//根据 messgageId 为消息添加定时
- (void)addTimerForMessageId:(NSString *)messageId
{
    NSDictionary *userInfo =@{@"messageId":messageId};
    NSTimer *timer = [NSTimer timerWithTimeInterval:self.timeOutDuration target:self selector:@selector(timeOutHandler:) userInfo:userInfo repeats:NO];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

//超时后触发的方法
- (void)timeOutHandler:(NSTimer *)timer
{
    NSDictionary *userInfo = timer.userInfo;
    [self executeAsyncBlock:^{
        NSString *messageId = [userInfo objectForKey:@"messageId"];
        ECResponseBlock block = [self.messageDict objectForKey:messageId];
        if (block) {
            dispatch_async(dispatch_get_main_queue(), ^{
                block(nil);
            });
            [self.messageDict removeObjectForKey:messageId];
        }
    }];
}

这各地方,是不是可以考虑把 ECStream 类中的 timerThread 这几行代码放在 ECModule 中。因为 timerThread 只在 ECModule 中发送消息超时功能处使用到。

五、各功能模块

下图所示的各个功能模块都继承自 EECModule 基类,每个模块仅处理属于它的协议范围。

各个功能模块.png
ECAutoBeatModule

心跳机制的目的就是让服务端知道某个客户端是否还活着,以确保链接的有效性,及时清理无效链接,节省资源。EChat 由服务端主动发送心跳,客户端被动回复。

ECAutoBeatModule 实现了如下功能:

  • 接收服务端心跳报文后回复心跳
  • 纳秒级精度定时,触发发送心跳

心跳模块实现了 ECStream 类中的 ECStreamDelegate 方法:



//收到心跳包回调
- (void)ecStream:(ECStream *)ecStream didNotParseData:(NSData *)data
{
    //如果是心跳包,则再回复一个心跳
}

我不太明白的一个地方就是,明明是服务端主动发心跳,客户端在收到心跳后回复就好了,为什么客户端又搞了一个定时器,在 beatInterval 时间后主动发送心跳?而且这个定时器好像一直都不会触发。。。

不过这种高精度的定时器还是值得学习下的。

对于精度要求较高的定时器,一般不会选择 NSTimer,因为它会受 runloop 的影响,导致计时不准确。

NSTimer 不是一个基于真实时间的机制。NSTimer被激发需要满足三个条件:

  1. NSTimer 被添加到特定 mode 的 runloop 中;
  2. 该 mode 型的 runloop 正在运行;
  3. 到达触发时间

如果 NSTimer 触发事件的时候 runloop 处于阻塞状态,或者当前 runloop 的 mode 没有监测该 NSTimer,那么定时器就不会被激发,直到下一次 runloop 监测到该 NSTimer 时才会激发。

使用 dispatch_source_t 可以实现纳秒精度的系统级定时器。dispatch_source 字面意思为调度源,它的作用是当有一些特定的较底层的系统事件发生时,调度源会捕捉到这些事件,触发特定逻辑。调度源有多种类型,分别监听对应类型的系统事件。

名称 内容
DISPATCH_SOURCE_TYPE_DATA_ADD 自定义的事件,变量增加
DISPATCH_SOURCE_TYPE_DATA_OR 自定义的事件,变量OR
DISPATCH_SOURCE_TYPE_MACH_SEND MACH端口发送
DISPATCH_SOURCE_TYPE_PROC 进程监听,如进程的退出、创建一个或更多的子线程、进程收到UNIX信号
DISPATCH_SOURCE_TYPE_READ IO操作,如对文件的操作、socket操作的读响应
DISPATCH_SOURCE_TYPE_SIGNAL 接收到UNIX信号时响应
DISPATCH_SOURCE_TYPE_TIMER 定时器
DISPATCH_SOURCE_TYPE_VNODE 文件状态监听,文件被删除、移动、重命名
DISPATCH_SOURCE_TYPE_WRITE IO操作,如对文件的操作、socket操作的写响应

使用 dispatch_source_t 创建定时器代码如下:

dispatch_queue_t queue = dispatch_queue_create("com.guahao", NULL);
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
dispatch_source_set_event_handler(timer, ^{
        //do something
});

//设置开始时间,调用 dispatch_resume 后,5秒后触发 handler
dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (5 * NSEC_PER_SEC));

//设置 DISPATCH_TIME_FOREVER 时间触发一次,0 误差
dispatch_source_set_timer(timer, start, DISPATCH_TIME_FOREVER, 0);

//Resumes the invocation of blocks on a dispatch object.
dispatch_resume(timer); 

其中,

void dispatch_source_set_timer(dispatch_source_t source, dispatch_time_t start, uint64_t interval, uint64_t leeway);    

该方法为 timer source 设置 start time,interval,以及 误差值。下面是摘取的官方文档,足以理解该函数的使用。

Your application can call this function multiple times on the same dispatch timer source object to reset the time interval for the timer source as necessary.
Calling this function has no effect if the timer source has already been canceled.
Once this function returns, any pending source data accumulated for the previous timer values has been cleared; the next fire of the timer will occur at 'start', and every 'interval' nanoseconds thereafter until the timer source is canceled.

The start time parameter also determines which clock is used for the timer. If the start time is DISPATCH_TIME_NOW or is created with dispatch_time, the timer is based on mach_absolute_time. Otherwise, if the start time of the timer is created with dispatch_walltime, the timer is based on gettimeofday(3).

ECAutoReconnectModule

自动重连模块实现了如下功能:

  • 网络监测,网络变化时(有网),自动重连
  • 定时:超过最大空闲时间间隔(最长未与服务端有交互的时间间隔),自动重连。
  • 在收到报文以及发送了报文的回调中,重置定时。
ECUserModule

用户模块负责处理 1000~1999 报文,实现了如下功能:

  • EChat 登录、登出
  • EChat 登录、登出结果回调
  • 远程登录回调
ECChatMudule

点对点聊天模块 负责处理 2000 ~ 2999 的报文,实现了如下功能:

  • 发送消息以及发送结果响应
  • 分页查询与某用户的历史消息记录
  • 接收实时点对点消息
  • 接收离线消息(登录后服务端主动推送)
ECFriendModule

好友模块 负责处理 3000 ~ 3999 的报文,实现了如下功能:

  • 添加好友
  • 拒绝好友申请
  • 接受好友申请
  • 查询好友申请记录列表
  • 查询所有好友
  • 删除好友
  • 消息屏蔽
  • 查询好友申请状态
ECGroupChatModule

群聊模块 负责处理 4000 ~ 4999 的报文,实现了如下功能:

  • 建群
  • 邀请好友加入群
  • 获取群成员
  • 发送群消息
  • 获取群历史消息记录
  • 获取当前用户已经加入的群
  • 退群
  • 修改群名
  • 修改群公告
  • 群消息屏蔽设置
  • 查询群的基本信息
  • 查询群二维码

其他模块就不一一列举了,因为都类似,分别处理与自己对应的报文。

以上就是 EChatFramework 的设计和关键代码分析,在主项目中,又有以下这些基于 EChatFramework 实现的类,他们所实现的主要功能是数据存储。 ECAdapter 是单例,它的作用是初始化并配置各个模块。

这部分的代码,我感觉比较乱,理不顺。。。大家可以讨论下。

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

推荐阅读更多精彩内容

  • 1、TCP为什么需要3次握手,4次断开? “三次握手”的目的是“为了防止已失效的连接请求报文段突然又传送到了服务端...
    杰伦哎呦哎呦阅读 3,470评论 0 6
  • 文章首发于个人blog欢迎指正补充,可联系lionsom_lin@qq.com原文地址:《网络是怎样连接的》阅读整...
    lionsom_lin阅读 14,107评论 6 31
  • 1.这篇文章不是本人原创的,只是个人为了对这部分知识做一个整理和系统的输出而编辑成的,在此郑重地向本文所引用文章的...
    SOMCENT阅读 13,034评论 6 174
  • 当 app 和服务器进行通信的时候,大多数情况下,都是采用 HTTP 协议。HTTP 最初是为 web 浏览器而定...
    Flysss1219阅读 1,252评论 0 4
  • 大一的军训未能像高中一样幸运的逃过,而且及其变态。 白天在操场上被各种操练,晚上回了也不得安宁,每天晚上必须整理好...
    苏家小六阅读 404评论 12 11