今日研究了下项目中使用的环信SDK源码中的聊天模块,重点研究了下其中的事件分发的实现。结合最近在看的《Effective Objective-C 2.0》中讲到的事件分发,对iOS事件分发机制有了更深入的了解。
一、环信类的封装
用于展示聊天消息的单元格Cell之间的继承关系如下:
而EMChatViewBaseCell拥有一个EMChatBaseBubbleView类型的属性。EMChatBaseBubbleView类如下:
因为消息分很多种类型,所以具体消息的展现用EMChatBaseBubbleView子类来实现的,EMChatBaseBubbleView有如下子类,分别实现对应消息的展现以及事件的处理:
EMChatViewBaseCell有一个属性MessageModel,EMChatViewBaseCell会根据MessageModel类型来创建与之对应的上面五个EMChatBaseBubbleView的子类中的一个。
二、环信事件分发流程
1、环信给UIResponder类创建了一个分类,并添加了一个新的分发事件的分发:
#import <UIKit/UIKit.h>
@interface UIResponder (Router)
/**
* 发送一个路由器消息, 对eventName感兴趣的 UIResponsder 可以对消息进行处理
*
* @param eventName 发生的事件名称
* @param userInfo 传递消息时, 携带的数据, 数据传递过程中, 会有新的数据添加
*
*/
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo;
@end
#import "UIResponder+Router.h"
@implementation UIResponder (Router)
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
[[self nextResponder] routerEventWithName:eventName userInfo:userInfo];
}
@end
2、点击事件传递
在EMChatViewCell中有一个点击重发聊天消息的按钮事件:
// 重发按钮事件
-(void)retryButtonPressed:(UIButton *)sender
{
[self routerEventWithName:kResendButtonTapEventName
userInfo:@{kShouldResendCell:self}];
}
其实是走的其父类EMChatViewBaseCell中的实例方法:
//点击头像事件
-(void)headImagePressed:(id)sender
{
[super routerEventWithName:kRouterEventChatHeadImageTapEventName
userInfo:@{KMESSAGEKEY:self.messageModel}];
}
-(void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
[super routerEventWithName:eventName userInfo:userInfo];
}
//注意,这里都是用的super 而不是self,
因为当前类中也实现了一个同名routerEventWithName:userInfo:方法。
调用super,走NSResponder+Ronter中添加的方法。
然后在EMChatBaseBubbleView的五个子类中分别具体分发相关的点击事件,比如播放音频的Cell中:
-(void)bubbleViewPressed:(id)sender
{
[self routerEventWithName:kRouterEventAudioBubbleTapEventName
userInfo:@{KMESSAGEKEY:self.model}];
}
3、事件最终捕获与处理
上面说的UITableViewCell都是展示在聊天页面UITableView中的,在聊天页面ChatViewController中实现了捕获各种类型的点击事件并具体实现。
- (void)routerEventWithName:(NSString *)eventName userInfo:(NSDictionary *)userInfo
{
MessageModel *model = [userInfo objectForKey:KMESSAGEKEY];
// 点击url链接
if ([eventName isEqualToString:kRouterEventTextURLTapEventName]) {
[self chatTextCellUrlPressed:[userInfo objectForKey:@"url"]];
}
// 点击音频
else if ([eventName isEqualToString:kRouterEventAudioBubbleTapEventName]) {
[self chatAudioCellBubblePressed:model];
}
// 点击图片
else if ([eventName isEqualToString:kRouterEventImageBubbleTapEventName]){
[self chatImageCellBubblePressed:model];
}
// 点击地理位置
else if ([eventName isEqualToString:kRouterEventLocationBubbleTapEventName]){
[self chatLocationCellBubblePressed:model];
}
// 点击重发
else if([eventName isEqualToString:kResendButtonTapEventName]){
EMChatViewCell *resendCell = [userInfo objectForKey:kShouldResendCell];
MessageModel *messageModel = resendCell.messageModel;
if ((messageModel.status != eMessageDeliveryState_Failure) && (messageModel.status != eMessageDeliveryState_Pending))
{
return;
}
id <IChatManager> chatManager = [[EaseMob sharedInstance] chatManager];
[chatManager asyncResendMessage:messageModel.message progress:nil];
NSIndexPath *indexPath = [self.tableView indexPathForCell:resendCell];
[self.tableView beginUpdates];
[self.tableView reloadRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationNone];
[self.tableView endUpdates];
}
// 点击视频
else if([eventName isEqualToString:kRouterEventChatCellVideoTapEventName]){
[self chatVideoCellPressed:model];
}
}
三、iOS事件分发机制
1、hit-Testing
iOS中的事件大概分为三种,分别是 Milti-Touch Events, Motion Events 和Remote Control Events(events for controlling multimedia)。
每当我们点击了一下iOS设备的屏幕,UIKit就会生成一个事件对象UIEvent,然后会把这个Event分发给当前active的app。
告知当前活动的app有事件之后,UIApplication 单例就会从事件队列中去取最新的事件,然后分发给能够处理该事件的对象。UIApplication 获取到Event之后,Application就纠结于到底要把这个事件传递给谁,这时候就要依靠HitTest来决定了。
iOS中,hit-Testing的作用就是找出这个触摸点下面的View是什么,HitTest会检测这个点击的点是不是发生在这个View上,如果是的话,就会去遍历这个View的subviews,直到找到最小的能够处理事件的view,如果整了一圈没找到能够处理的view,则返回自身。如图:
假设我们现在点击到了图中的E,hit-testing将进行如下步骤的检测(不包含重写hit-test并且返回非默认View的情况)
1、触摸点在ViewA内,所以检查ViewA的Subview B、C
2、触摸点不在ViewB内,触摸点在ViewC内部,所以检查ViewC的Subview D、E
3、触摸点不在ViewD内,触摸点发生在ViewE内部,并且ViewE没有subview,所以ViewE属于ViewA中包含这个点的最小单位,所以ViewE变成了该次触摸事件的hit-TestView
PS.
1、默认的hit-testing顺序是按照UIView中Subviews的逆顺序
2、如果View的同级别Subview中有重叠的部分,则优先检查顶部的Subview,如果顶部的Subview返回nil, 再检查底部的Subview
3、Hit-Test也是比较聪明的,检测过程中有这么一点,就是说如果点击没有发生在某View中,那么该事件就不可能发生在View的Subview中,所以检测过程中发现该事件不在ViewB内,也直接就不会检测在不在ViewF内。也就是说,如果你的Subview设置了clipsToBounds=NO,实际显示区域可能超出了superView的frame,你点击超出的部分,是不会处理你的事件的,就是这么任性!
<strong>hit-Test 是事件分发的第一步,就算你的app忽略了事件,也会发生hit-Test。确定了hit-TestView之后,才会开始进行下一步的事件分发。</strong>
2、The Responder Chain
响应链简单来说,就是一系列的相互关联的对象,从firstResponder开始,到application对象结束,如果firstResponder 无法响应事件,则交给nextResponder来处理,直到结束为止。iOS中很多类型的事件分发,都依赖于响应链;在响应链中,所有对象的基类都是UIResponder,也就是说所有能响应事件的类都是UIResponder的子类,UIApplication/UIView/UIViewController都是UIResponder的子类,这说明所有的Views,绝大部分Controllers(不用来管理View的Controller除外)都可以响应事件。
PS.CALayer 不是UIResponder的子类,这说明CALayer无法响应事件,这也是UIView和CALayer的重要区别之一。
nextResponder的事件传递过程:
PS.View处理事件的方式有手势或者重写touchesEvent方法或者利用系统封装好的组件(UIControls)。
图中所表示的正是nextResponder的查找过程,两种方式分别对应两种app的架构,左边的那种app架构比较简单,只有一个VC,右边的稍微复杂一些,但是寻找路线的原则是一样的,先解释一下,UIResponder本身是不会去存储或者设置nextResponder的,所谓的nextResponder都是子类去实现的(这里说的是UIView,UIViewController,UIApplication),关于nextResponder的值总结如下:
1、UIView的nextResponder是直接管理它的UIViewController(也就是VC.view.nextResponder=VC),如果当前View不是ViewController直接管理的View,则nextResponder是它的superView(view.nextResponder = view.superView)
2、UIViewController的nextResponder是它直接管理的View的superView(VC.nextResponder = VC.view.superView)
3、UIWindow的nextResponder是UIApplication
4、UIApplication的nextResponder是UIApplicationDelegate(官方文档说是nil)
<strong>
需要注意的是:
如果你自己想自定义一个非TouchEvent的事件,当需要继续传递事件的话,切记不要在实现内直接显示的调用nextResponder的对应方法, 而是直接调用父类中对应的事件处理方法并让UIKit来处理响应链的遍历。
</strong>