4

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

 3 years ago
source link: https://segmentfault.com/a/1190000040524177
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

2. 手势介绍

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

UITapGestureRecognizer:点击;

UILongPressGestureRecognizer:长按;

UIPinchGestureRecognizer:捏合;

UIRotationGestureRecognizer:旋转。

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

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

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

3. 采集方案

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

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

图 3-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];

    });
    }
    添加采集事件的 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];
    }
    手势事件采集:
  • (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    // 手势事件采集
    ...
    }
    通过 Method Swizzling 我们能够如愿采集手势事件,但这存在一个问题:系统的诸多行为也是通过手势进行实现的,同样会被我们采集,但我们的初衷是只采集用户添加的手势。

部分私有手势如表 3-1 所示:

1 UIScrollViewKnobLongPressGestureRecognizer UIScrollViewKnobLongPressGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
2 _UIDragAddItemsGesture _UIDragAddItemsGesture -> UITapGestureRecognizer -> UIGestureRecognizer
3 _UIDragLiftGestureRecognizer _UIDragLiftGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
4 _UIDragLiftPointerGestureRecognizer _UIDragLiftPointerGestureRecognizer -> _UIDragLiftGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
5 UIKBProductivitySingleTapGesture UIKBProductivitySingleTapGesture -> UITapGestureRecognizer -> UIGestureRecognizer
6 UIKBProductivityDoubleTapGesture UIKBProductivityDoubleTapGesture -> UITapGestureRecognizer -> UIGestureRecognizer
7 _UIWebHighlightLongPressGestureRecognizer _UIWebHighlightLongPressGestureRecognizer -> UILongPressGestureRecognizer -> UIGestureRecognizer
8 _UISingleFingerTapExtensionGesture _UISingleFingerTapExtensionGesture -> UITapGestureRecognizer -> UIGestureRecognizer
9 WKSyntheticTapGestureRecognizer WKSyntheticTapGestureRecognizer -> UITapGestureRecognizer -> UIGestureRecognizer
10 ... ...
表 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;
    }

4. 难点攻克

到目前为止,似乎可以正常实现点击和长按手势的采集了。但是,事实远非如此,还有一些难点需要解决。

场景一:在开发者添加 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;
    }

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;
    }

4.4. 处理手势状态

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

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

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

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

  • (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture {
    if (gesture.state != UIGestureRecognizerStateEnded &&

      gesture.state != UIGestureRecognizerStateCancelled) {
      return;

    }
    // 手势事件采集
    ...
    }

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

  1. 参考文献
    [1]https://developer.apple.com/d...

[2]https://developer.apple.com/d...

[3]https://github.com/sensorsdat...


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK