再谈 IM 架构设计(下)

国庆长假已经接近尾声了,那么,伴随着国庆的结束,这系列文章也将告一段落。前面两篇里,我们更多的都是在关注设计方面的东西,设计本身就很虚无,难以琢磨。所以,本篇将重点介绍一些实现细节,一些能够实实在在解决问题的策略。

异步、异步、再异步

在网络相关的应用中,我们肯定避免不了异步操作,由于异步操作的特殊性,导致结果传递以及逻辑处理上带来很多复杂性。在微软的.NET中,有async/wait操作关键字来将异步代码组织的与同步相似,这在很大程度上解决了代码组织的问题,而在Objective-C中并没有异步相关的编程范式,不过却有很多有用的基础支持,比如操作队列、GCD等,那么,我们可以基于这些来封装出更适合异步代码组织的方法和类。

Future / Promise

首先要介绍的便是Future对象,这个在JDK中有相关的实现,并在MINA中进行了进一步的扩充。在很多脚本语言中也有相似的概念和实现,在Objective-C中最出名的实现莫过于下面的开源项目:

https://github.com/mxcl/PromiseKit

这个作者便是Homebrew的主要贡献者,起初我们的项目里便是用了这个库,后来发现有以下几个问题:

  1. 这个库同时支持SwiftObjective-C,但混合使用时经常出现无法导入模块(@import)的问题,导致头文件组织时需要非常小心
  2. 这个库很强大,但我们实际上用到的功能连20%都没达到
  3. 由于当时处于Swift的变动期,语法更新很频繁,但我们受限于这个库的支持

基于上述原因,后来决定放弃使用这个PMK,决定自己构建一套更加轻量级的实现。其实要实现这样一个库还是非常简单的,只要做好块和回调的管理即可,另外Future/Promise可以用一句话加以理解:我向你保证一个未来!Promise便是许下的承诺,它会产生一个未来,也就是Future,而这个Future的结果还是要看Promise最终是否兑现。感觉像是谈恋爱时哄骗女生的伎俩,但这个异步模型的确对代码组织有很大的帮助。

具体的代码实现就不赘述了,这里简单的介绍一处使用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (CCNFutureOf(Response))sendRequest:(CCNRequest *)request {
if ([request conformsToProtocol:@protocol(CCNUncacheablePackage)])
return [self.innerChannel sendRequest:request];

CCNCachedFuture *cachedFuture = [self.cachedFutures objectForKey:request];
if (cachedFuture == nil) {
cachedFuture = [CCNCachedFuture new];
cachedFuture.cacheTime = [NSDate date];
cachedFuture.future = [self.innerChannel sendRequest:request];
[self.cachedFutures setObject:cachedFuture forKey:request];
} else {
NSDate *now = [NSDate date];
if ([now timeIntervalSinceDate:cachedFuture.cacheTime] > self.timeout) {
cachedFuture.cacheTime = [NSDate date];
cachedFuture.future = [self.innerChannel sendRequest:request];
[self.cachedFutures removeObjectForKey:request];
[self.cachedFutures setObject:cachedFuture forKey:request];
}
}

return cachedFuture.future;
}

上面的代码完全看不出和异步有关,其实sendRequest本身就是异步的,这里是对RequestChannel做了层装饰,在指定的间隔时间内,如果发出两个完全相同的Request,并且这个Request没有标记为UncacheablePackage,则只会做一次真正的网络请求,但所有请求都会被响应。试想一下,如果没有Future异步模型,我们要实现这样的功能还是会比较麻烦的。

AsyncArray

介绍完了Future,关于异步还有一个必须要提一下的地方,那便是人为延迟。其实,如果有个健全的服务端设计,好比 telegram 那样,那客户端方面根本不需要添加人为延迟,但现在木已成舟,考虑以下场景:

强西是一位屌丝,经过不断地打拼,终于和绿茶妹成为了情侣关系。强西和绿茶平时都用我们所设计的这款 IM 沟通感情,有一次他们闹别扭了,强西为了挽回绿茶妹,在绿茶妹不在线时给她留言了九千九百九十九条对不起。而绿茶妹那边,她的Boss要她上线接收一下公司最新的企划,绿茶妹一上线就傻眼了,眼前不断的跳出“对不起”,然后整个应用处于无法使用的卡死状态,她又急又气,一个电话过去和强西彻底分手了。

这是一个悲伤的故事,由于服务端没有做消息的打包功能,导致离线消息频繁更新 UI,大量占用了主线程的时间片,整个应用自然处于无法使用的状况。为了保证强东和奶茶妹不会悲剧重演,我们必须要做一些策略上的控制,也就是在客户端人为的加入延迟。

所谓人为延迟,便是在收到数据时 delay 指定的时间,并在收到后续的数据时和当前正在 delay 的数据进行合并,直到达到超时或某个临界点时,将打包的数据抛至上层进行进一步处理。比如同样是上述场景,我们在收到离线消息时进行 delay,当收到后续消息时和前面的离线消息进行合并,倘若合并周期超过了5秒,则将合并好的数据上报,从而进行下一轮的合并操作。如此一来,UI 端可能在两个或三个合并周期便能完成所有离线消息的接收,整个界面只会刷新两到三次,那么结局就会不一样了。或许,绿茶妹满满的感动,立马打了个飞的就跑到强西身边了。

关于这样的一个策略,我们可以将其进行抽象封装,下面便是抽象出的接口,实现部分很简单,为了不占用过多的篇幅,就不贴出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@interface CCNAsyncArray<TObjectType: NSObject *> : NSObject

/**
* 添加对象到异步数组中
*
* @param object 要添加的对象
*/

- (void)addObject:(TObjectType)object;

/**
* 添加一批对象到异步数组中
*
* @param objects 要添加的对象数组
*/

- (void)addObjectsFromArray:(NSArray<TObjectType> *)objects;

/**
* 设置回调代理
*
* @param delegate 代理
* @param queue 代理回调队列
*/

- (void)setDelegate:(id<CCNAsyncArrayDelegate>)delegate delegateQueue:(CCNDispatchQueue *)queue;

/**
* 移除回调代理
*/

- (void)removeDelegate;

/**
* 超时时间,达到这个时间后会立即调用代理回调
* 默认 5 秒
*/

@property (nonatomic, assign) NSTimeInterval timeout;

/**
* 间隔时间,在这个间隔时间内的添加,都不会调用代理回调
* 默认 0.35 秒
*/

@property (nonatomic, assign) NSTimeInterval interval;

/**
* 最大数量,达到这个数量后,会立即调用代理回调
* 默认 30
*/

@property (nonatomic, assign) NSUInteger maxCount;

/**
* 获取当前数组中的所有对象
*/

@property (nonatomic, strong, readonly) NSArray<TObjectType> *objects;

@end

/**
* 异步数据 Delegate
*/

@protocol CCNAsyncArrayDelegate <NSObject>
@optional

/**
* 过滤操作,如果这里返回了 YES,则相应的添加对象不会影响计时,也不会添加到数组中
*
* @param asyncArray 异步数组
* @param object 要检测的对象
*
* @return 返回 YES 代表过滤掉,否则应该返回 NO
*/

- (BOOL)asyncArray:(CCNAsyncArray *)asyncArray filterWithObject:(id)object;

/**
* 异步数组回调
*
* @param asyncArray 异步数组
* @param objects 回调中的所有对象
*/

- (void)asyncArray:(CCNAsyncArray *)asyncArray didCallbackWithObjects:(NSArray *)objects;

@end

有了这样的一个封装后,我们可以用在除上述场景外的很多地方。比如群聊时的ACK回复,好友状态变更时打包通知,首次登陆时的数据同步更新等。人为延迟在一定的程度上降低了实时性,但却能带来更好的用户体验,这也是一种很无奈的变通策略。

引用、引用、再引用

Objective-C中引入 ARC 之后,对内存的管理已近演化成对引用的管理,我们只要保证不出现循环引用,基本上也就不会出现内存泄露的问题。其实引用的管理便是对象生命周期的管理,一个对象到底应该什么时候被释放,什么时候又不应该被释放,这是我们在进行程序设计时必须要考虑的问题。

而这里需要谈的引用更多的是为了保持对象一致性的一种管理策略,这种策略会用在很多业务场景中,所以,我觉得有必要单独提出来。什么是对象一致性?也就是代表同一种抽象体的对象,在内存中应该只能保持一份。比如,我有一个Conversation对象,是我在双击某个用户头像时所创建的,如果不进行任何会话操作,切换至其他会话,这个对象应该被释放掉,而如果我发送了一条消息或者收到了对方一条消息,这个会话应该被持久化下来,后续所有和该会话相关的操作应该都是最初创建的那个Conversation对象。

考虑下上述场景应该如何去实现,其实整个业务逻辑,就是我们对Conversation生命周期的管理,一旦我们创建了Conversation,UI 端肯定会对其进行强引用,而切换后这个引用就会被释放。那么,要完成上述的业务场景,我们可以使用一些更高级的容器类,很简单的就可以实现,弱引用容器类。

Objective-C中,弱引用容器有NSMapTableNSHashTableNSPointerArray,都是可以进行配置容器内对象的引用方式。针对上述场景,我们可以使用NSMapTable,构建类似下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (CCNConversation *)conversationForTarget:(id<CCNInteractiveTarget>)target {
...
[self.locker lock];

CCNConversation *conversation = [self.createdConversations objectForKey:@(subjecter.identifier)];
if (conversation == nil) {
conversation = [[CCNConversation alloc] initWithTarget:subjecter context:self.context];
[self.createdConversations setObject:conversation forKey:@(subjecter.identifier)];

[conversation addDelegate:self delegateQueue:self.dispatchQueue];
}

[self.locker unlock];
return conversation;
}

createdConversations便是NSMapTable的实例,在我们创建Conversation的时候都会去这个弱引用容器中进行查找,这样保证了不会创建出两份一样的Conversation。并且,因为是弱引用的关系,如果新创建出的对象没有进行任何其它操作,UI 端也不再引用时,便会自动释放掉了,我们也无需管理后续的清理工作。倘若进行了进一步的操作,则会触发该对象的Delegate,在这个Delegate的实现里,我们会将这个对象保存到另外一个非弱引用的容器里,这样createdConversations中的引用就不会被释放,后续的创建总是能得到内存中同样的一份。

弱引用容器非常适合用来判断一个对象是否还在内存中存活,这对需要保证对象引用一致性的场景特别适合,而在我们 IM 的业务场景中,还有很多地方会用到这样的策略,数据同步就是一个非常好的例子。为了改善用户体验,一般的 IM 中都会做离线缓存,既然做了离线缓存,那么不可避免的就需要面对史诗级难题————数据同步

当我们在同步讨论组或群时,大体是会是这样的过程:

  1. 应用启动时,加载本地讨论组列表,为了降低内存的使用率,只会加载讨论组的一些核心信息,不会包括成员列表
  2. 所有离线数据加载完毕后,开始请求服务端讨论组信息,此时会和本地数据进行合并,并对高层发出相应的变更通知
  3. 当手动进入某一个讨论组或收到来至某个讨论的消息时,先加载本地成员列表,用以构建合适的会话信息
  4. 本地成员列表加载完毕后,开始和服务端进行同步,并对高层发出变更通知

上述过程看起来似乎比较简单,但其实暗藏着一些挑战,比如我们的第三步收到某条讨论组消息,这时候我们界面上要显示:某某某说了什么,那么如果这是离线消息,并且这个用户在发完这个消息就退出了讨论组,我们的显示和同步流程就会遇到些障碍。解决这个问题的方式便是上面说的使用弱引用容器,在所有的数据同步中,我觉得所创建的任何对象都应该先放置在弱引用容器中,直到最后明确它需要持久化时,再作强引用操作。这样可以避免很多的剔除操作,那些已经失效过期的对象,一旦高层丢弃了它,我们也永远不会再需要它,而这些对象,最适合放置在弱引用容器中,在保证了对象的一致性外,还对内存的利用率有所改善。

细节部分就介绍这么多,剩余的也没有太多的可讲之处,那么,是时候回头看看我们这一路走来的历程了。

非常自然的推导过程

关于设计,不怕你设计不好,就怕你过度设计。糟糕的设计,最多会让代码臃肿、僵化,而过度设计带来的复杂度,会让后续的维护人员无可下手,当然,这是视能力而言。我说过,没有设计便是最好的设计,但这非常难以达到,所以,我只能尽力的让设计的推导过程显得自然。

回头思考一下,我所描述的设计中,每一处都是有针对性的解决一些问题,而这些所谓的设计,也正是我从这些问题的解决方式中推导而来。虽然并不能尽全尽美,但我可以保证这所有设计带来的迂回都不会落空,如果有更好的选择,我非常乐意洗耳恭听。

接下来的就交给你了

讲了这么多,所有这个 IM 中的疑点、难点也都提到了,我相信如果你能够接手到我的代码,并配合这三篇《武林秘籍》,一定能吸收一些思路,并使用它创造出更多价值。出于公司保密协议的约束,这里我并不能提供出源码来,但,如果你能成为我同事的话,我是非常乐意与你交流所有代码的细节。

所以,接下来的就交给你了!