写点什么

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

发布于: 3 小时前
手势事件采集究竟有多难?

前言

手势事件采集是 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 所示:


图 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 所示:


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


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


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


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


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


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


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


@end5. 总结本文介绍了 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


文章来源:公众号-神策技术社区

发布于: 3 小时前阅读数: 5
用户头像

公众号:神策技术社区 2021.03.09 加入

公众号:神策技术社区

评论

发布
暂无评论
手势事件采集究竟有多难?