手势事件采集究竟有多难?

一、前言

手势事件采集是 iOS 点击事件采集的核心功能,手势事件采集实现思路并不复杂,但是其中难点较多,本文针对这些难点逐一给出了解决方案。

下面我们来看看如何在 iOS 中实现手势事件采集。

二、手势介绍

Apple 提供了 UIGestureRecognizer[1] 相关的类用于处理手势操作,常见的手势如下:

UITapGestureRecognizer:点击;
UILongPressGestureRecognizer:长按;

UIPinchGestureRecognizer:捏合;

UIRotationGestureRecognizer:旋转。
UIGestureRecognizer 类定义了一组公共行为,可以为所有具体的手势识别器配置这些行为。

手势识别器能够对特定视图进行触摸响应,因此需要通过 UIView 的 - addGestureRecognizer: 方法将视图和手势进行关联。

一个手势识别器可以拥有多个 Target-Action 对,这些 Target-Action 是相互独立的,手势识别后会向每个 Target-Action 对发送消息。

三、采集方案

因为每个手势识别器可以关联多个 Target-Action,结合 Runtime 的 Method Swizzling,我们可以在用户为手势添加 Target-Action 时,再额外添加一个采集事件的 Target-Action 对。

总体流程如图 3-1 所示:


image.png

图 3-1 手势事件采集流程

下面我们来看下具体的代码实现。

  1. Method Swizzling:
  • (void)enableAutoTrackGesture {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
    [UIGestureRecognizer sa_swizzleMethod:@selector(initWithTarget:action:)
    withMethod:@selector(sensorsdata_initWithTarget:action:)
    error:NULL];
    [UIGestureRecognizer sa_swizzleMethod:@selector(addTarget:action:)
    withMethod:@selector(sensorsdata_addTarget:action:)
    error:NULL];
    });
    }
  1. 添加采集事件的 Target-Action:
  • (void)sensorsdata_addTarget:(id)target action:(SEL)action {
    self.sensorsdata_gestureTarget = [SAGestureTarget targetWithGesture:self];
    [self sensorsdata_addTarget:self.sensorsdata_gestureTarget action:@selector(trackGestureRecognizerAppClick:)];
    [self sensorsdata_addTarget:target action:action];
    }
  1. 手势事件采集:
  • (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    // 手势事件采集
    ...
    }
    通过 Method Swizzling 我们能够如愿采集手势事件,但这存在一个问题:系统的诸多行为也是通过手势进行实现的,同样会被我们采集,但我们的初衷是只采集用户添加的手势。
    部分私有手势如表 3-1 所示:


    image.png

    表 3-1 部分私有手势
    如何不采集系统私有手势事件,成为了亟待解决的问题。

3.1屏蔽系统私有手势

系统私有手势和公开对外的手势并没有本质区别,都继承或间接继承自 UIGestureRecognizer 类。

当手势被添加了 Target-Action 后,我们可以通过 Target 对象归属的类所在的 Bundle 判断当前的手势是否是系统私有手势。

系统库的 bundle 格式如下:

/System/Library/PrivateFrameworks/UIKitCore.framework
/System/Library/Frameworks/WebKit.framework

开发者的 bundle 格式如下:

/private/var/containers/Bundle/Application/8264D420-DE23-48AC-9985-A7F1E131A52A/CDDStoreDemo.app

实现如下:

  • (BOOL)isPrivateClassWithObject:(NSObject *)obj {
    if (!obj) {
    return NO;
    }

    NSString *bundlePath = [[NSBundle bundleForClass:[obj class]] bundlePath];
    if ([bundlePath hasPrefix:@"/System/Library"]) {
    return YES;
    }

    return NO;
    }

这里需要注意的是:该方法不适用于模拟器。
该方案能够区分是否是系统私有手势,但当添加的 Target 是 UIGestureRecognizer 实例对象本身时则无法区分是否是需要采集的手势事件,因此该方案不可行。

3.2仅采集点击和长按手势
调试时能够发现,大部分系统私有手势是子类化的,且开发者很少会对手势进行子类化操作,因此我们可以仅实现对 UITapGestureRecognizer、UILongPressGestureRecognizer 手势的采集,子类化的手势不采集。
我们在创建 Target 对象时,对手势校验,满足条件的手势返回一个有效的 Target 对象。

  • (SAGestureTarget * _Nullable)targetWithGesture:(UIGestureRecognizer *)gesture {
    NSString *gestureType = NSStringFromClass(gesture.class);
    if ([gesture isMemberOfClass:UITapGestureRecognizer.class] ||
    [gesture isMemberOfClass:UILongPressGestureRecognizer.class]) {
    return [[SAGestureTarget alloc] init];
    }
    return nil;
    }

四、难点攻克

到目前为止,似乎可以正常实现点击和长按手势的采集了。但是,事实远非如此,还有一些难点需要解决。
场景一:在开发者添加 Target-Action 后,又移除了;
场景二:在开发者添加 Target-Action 后,Target 在某些场景下被释放了;
场景三:虽然仅采集了 UITapGestureRecognizer、UILongPressGestureRecognizer,但仍存在一些系统私有手势是未子类化的,被错误采集;
场景四:UIAlertController 点击事件采集需要特殊处理;
场景五:对于部分手势状态需要特殊处理

4.1 管理 Target-Action
针对场景一和场景二,SDK 不应当采集手势事件。但是 SDK 已经添加了 Target-Action,因此需要在采集时判断除了 SDK 添加的 Target-Action,是否还存在有效的 Target-Action,如果不存在则不应当采集手势事件。

对于 UIGestureRecognizer 系统并未提供公开的 API 接口获取当前手势所有的 Target-Action。虽然能够通过私有 API ‘_targets’ 获取,但是有可能对客户产生影响。因此我们通过 hook 相关方法,自己记录 Target-Action 的数量。
新建 SAGestureTargetActionModel 类,用于管理 Target 和 Action:

@interface SAGestureTargetActionModel : NSObject

@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL action;
@property (nonatomic, assign, readonly) BOOL isValid;

  • (instancetype)initWithTarget:(id)target action:(SEL)action;
  • (SAGestureTargetActionModel * _Nullable)containsObjectWithTarget:(id)target andAction:(SEL)action fromModels:(NSArray <SAGestureTargetActionModel >)models;

@end

在 - addTarget:action: 和 - removeTarget:action: 中记录 Target 数量:

  • (void)enableAutoTrackGesture {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
    ...
    [UIGestureRecognizer sa_swizzleMethod:@selector(removeTarget:action:)
    withMethod:@selector(sensorsdata_removeTarget:action:)
    error:NULL];
    });
    }

  • (void)sensorsdata_addTarget:(id)target action:(SEL)action {
    if (self.sensorsdata_gestureTarget) {
    if (![SAGestureTargetActionModel containsObjectWithTarget:target andAction:action fromModels:self.sensorsdata_targetActionModels]) {
    SAGestureTargetActionModel *resulatModel = [[SAGestureTargetActionModel alloc] initWithTarget:target action:action];
    [self.sensorsdata_targetActionModels addObject:resulatModel];
    [self sensorsdata_addTarget:self.sensorsdata_gestureTarget action:@selector(trackGestureRecognizerAppClick:)];
    }
    }
    [self sensorsdata_addTarget:target action:action];
    }

  • (void)sensorsdata_removeTarget:(id)target action:(SEL)action {
    if (self.sensorsdata_gestureTarget) {
    SAGestureTargetActionModel *existModel = [SAGestureTargetActionModel containsObjectWithTarget:target andAction:action fromModels:self.sensorsdata_targetActionModels];
    if (existModel) {
    [self.sensorsdata_targetActionModels removeObject:existModel];
    }
    }
    [self sensorsdata_removeTarget:target action:action];
    }

在事件采集时,校验是否满足采集条件:

  • (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    if ([SAGestureTargetActionModel filterValidModelsFrom:gesture.sensorsdata_targetActionModels].count == 0) {
    return NO;
    }
    // 手势事件采集
    ...
    }

4.2 黑名单
针对场景三,神策 SDK 增加了黑名单的配置,通过配置 View 类型来屏蔽这些手势的采集。
{
"public": [
"UIPageControl",
"UITextView",
"UITabBar",
"UICollectionView",
"UISearchBar"
],
"private": [
"_UIContextMenuContainerView",
"_UIPreviewPlatterView",
"UISwitchModernVisualElement",
"WKContentView",
"UIWebBrowserView"
]
}
在进行类型比较时,我们对公开和私有的类型进行了区分处理:
公开类名使用 - isKindOfClass: 判断;
私有类名使用字符串匹配判断。

  • (BOOL)isIgnoreWithView:(UIView *)view {
    ...
    // 公开类名使用 - isKindOfClass: 判断
    id publicClasses = info[@"public"];
    if ([publicClasses isKindOfClass:NSArray.class]) {
    for (NSString *publicClass in (NSArray *)publicClasses) {
    if ([view isKindOfClass:NSClassFromString(publicClass)]) {
    return YES;
    }
    }
    }
    // 私有类名使用字符串匹配判断
    id privateClasses = info[@"private"];
    if ([privateClasses isKindOfClass:NSArray.class]) {
    if ([(NSArray *)privateClasses containsObject:NSStringFromClass(view.class)]) {
    return YES;
    }
    }
    return NO;
    }

4.3 UIAlertController 点击事件采集
UIAlertController 内部是通过手势实现用户交互操作,但其手势所在的 View 并不是用户操作的 View,且在不同的系统版本中内部实现略有不同。

我们通过使用不同的处理器来处理这种特殊逻辑。
新建工厂类 SAGestureViewProcessorFactory 来决定使用的处理器:

@implementation SAGestureViewProcessorFactory

  • (SAGeneralGestureViewProcessor *)processorWithGesture:(UIGestureRecognizer *)gesture {
    NSString *viewType = NSStringFromClass(gesture.view.class);
    if ([viewType isEqualToString:@"_UIAlertControllerView"]) {
    return [[SALegacyAlertGestureViewProcessor alloc] initWithGesture:gesture];
    }
    if ([viewType isEqualToString:@"_UIAlertControllerInterfaceActionGroupView"]) {
    return [[SANewAlertGestureViewProcessor alloc] initWithGesture:gesture];
    }
    return [[SAGeneralGestureViewProcessor alloc] initWithGesture:gesture];
    }

@end

然后在具体的处理器中处理差异:

pragma mark - 适配 iOS 10 以前的 Alert

@implementation SALegacyAlertGestureViewProcessor

  • (BOOL)isTrackable {
    if (![super isTrackable]) {
    return NO;
    }
    // 屏蔽 SAAlertController 的点击事件
    UIViewController *viewController = [SAAutoTrackUtils findNextViewControllerByResponder:self.gesture.view];
    if ([viewController isKindOfClass:UIAlertController.class] && [viewController.nextResponder isKindOfClass:SAAlertController.class]) {
    return NO;
    }
    return YES;
    }

  • (UIView *)trackableView {
    NSArray <UIView >visualViews = sensorsdata_searchVisualSubView(@"_UIAlertControllerCollectionViewCell", self.gesture.view);
    CGPoint currentPoint = [self.gesture locationInView:self.gesture.view];
    for (UIView *visualView in visualViews) {
    CGRect rect = [visualView convertRect:visualView.bounds toView:self.gesture.view];
    if (CGRectContainsPoint(rect, currentPoint)) {
    return visualView;
    }
    }
    return nil;
    }

@end

pragma mark - 适配 iOS 10 及以后的 Alert

@implementation SANewAlertGestureViewProcessor

  • (BOOL)isTrackable {
    if (![super isTrackable]) {
    return NO;
    }
    // 屏蔽 SAAlertController 的点击事件
    UIViewController *viewController = [SAAutoTrackUtils findNextViewControllerByResponder:self.gesture.view];
    if ([viewController isKindOfClass:UIAlertController.class] && [viewController.nextResponder isKindOfClass:SAAlertController.class]) {
    return NO;
    }
    return YES;
    }

  • (UIView *)trackableView {
    NSArray <UIView >visualViews = sensorsdata_searchVisualSubView(@"_UIInterfaceActionCustomViewRepresentationView", self.gesture.view);
    CGPoint currentPoint = [self.gesture locationInView:self.gesture.view];
    for (UIView *visualView in visualViews) {
    CGRect rect = [visualView convertRect:visualView.bounds toView:self.gesture.view];
    if (CGRectContainsPoint(rect, currentPoint)) {
    return visualView;
    }
    }
    return nil;
    }

@end

4.4 处理手势状态
手势识别器是由状态机驱动的,默认状态是 UIGestureRecognizerStatePossible,表示已经准备好开始处理事件。

状态之间的转换如图 4-1 所示:


image.png

图 4-1 手势状态转换动[2]

针对全埋点,无论手势状态是 UIGestureRecognizerStateEnded 还是 UIGestureRecognizerStateCancelled 都应当采集手势事件:

  • (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    if (gesture.state != UIGestureRecognizerStateEnded &&
    gesture.state != UIGestureRecognizerStateCancelled) {
    return;
    }
    // 手势事件采集
    ...
    }

@end

五、总结

本文介绍了 iOS 手势事件采集的一种具体实现方式,同时也介绍了针对部分难点是如何进行处理的。更多细节可参考神策 iOS SDK 源码[3],如果大家有更好的想法,欢迎加入开源社区一起讨论。

参考文献:

[1]https://developer.apple.com/documentation/uikit/uigesturerecognizer?language=objc
[2]https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/implementing_a_custom_gesture_recognizer/about_the_gesture_recognizer_state_machine?language=objc
[3]https://github.com/sensorsdata/sa-sdk-ios

更多内容,可关注公众号:神策技术社区

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

推荐阅读更多精彩内容