iOS面试题库iOS记录

iOS 全埋点-控件点击事件(3)

2021-10-18  本文已影响0人  smile_frank

写在前面

传送门:

前面的系列章节可以查看上面连接,本章节主要是介绍 iOS全埋点序列文章(3)控件点击事件分析

Target-Action设计模式

在具体介绍如何实现之前,我们需要先了解在UIKit框架下点击或拖动 事件的Target-Action设计模式。
Target-Action模式主要包含两个部分。

Target可以是任意类型的对象。但是在iOS应用程序中,通常情况下会 是一个控制器,而触发事件的对象和接收消息的对象(Target)一样,也可 以是任意类型的对象。例如,手势识别器UIGestureRecognizer就可以在识 别到手势后,将消息发送给另一个对象。

当我们为一个控件添加Target-Action后,控件又是如何找到Target并执 行对应的Action的呢?

UIControl类中有一个方法:
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;

用户操作控件(比如点击)时,首先会调用这个方法,并将事件转发 给应用程序的UIApplication对象。

同时,在UIApplication类中也有一个类似的实例方法:
- (BOOL)sendAction:(SEL)action to:(nullable id)target from:(nullable id)sender forEvent:(nullable UIEvent *)event;

如果Target不为nil,应用程序会让该对象调用对应的方法响应事件;如果Targetnil,应用程序会在响应链中搜索定义了该方法的对象,然后 执行该方法。

基于Target-Action设计模式,有两种方案可以实现$AppClick事件的全埋点。下面我们将逐一进行介绍。

方案一

描述

通过Target-Action设计模式可知,在执行Action之前,会先后通过控件 和UIApplication对象发送事件相关的信息。因此,我们可以通过Method Swizzling交换UIApplication类中的-sendAction:to:from:forEvent:方法,然后 在交换后的方法中触发$AppClick事件,并根据targetsender采集相关属性,实现$AppClick事件的全埋点。

代码实现

新建一个UIApplication的分类

+ (void)load {
    [UIApplication sensorsdata_swizzleMethod:@selector(sendAction:to:from:forEvent:) withMethod:@selector(CountData_sendAction:to:from:forEvent:)];
}

- (BOOL)CountData_sendAction:(SEL)action to:(id)target from:(id)sender forEvent:(UIEvent *)event {
    [[SensorsAnalyticsSDK sharedInstance] track:@"$appclick" properties:nil];
    return  [self CountData_sendAction:action to:target from:sender forEvent:event];
}

一般情况下,对于一个控件的点击事件,我们至少还需要采集如下信息(属性):

获取控件类型

先为你介绍一下NSObject对象的继承关系图

NSObject的体系

从上图可以看出,控件都是继承于UIView,所以获取要想获取控件类型,可以声明UIView的分类

新建UIView的分类(UIView+TypeData)

UIView+TypeData.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIView (TypeData)

@property (nonatomic,copy,readonly) NSString *elementType;

@end

NS_ASSUME_NONNULL_END

UIView+TypeData.m

#import "UIView+TypeData.h"

@implementation UIView (TypeData)

- (NSString *)elementType {
    return  NSStringFromClass([self class]);
}
@end

获取控件类型的埋点实现

+ (void)load {
    [UIApplication sensorsdata_swizzleMethod:@selector(sendAction:to:from:forEvent:) withMethod:@selector(CountData_sendAction:to:from:forEvent:)];
}

- (BOOL)CountData_sendAction:(SEL)action to:(id)target from:(id)sender forEvent:(UIEvent *)event {
    UIView *view = (UIView *)sender;
    NSMutableDictionary *prams = [[NSMutableDictionary alloc]init];
    //获取控件类型
    prams[@"$elementtype"] = view.elementType;
    [[SensorsAnalyticsSDK sharedInstance] track:@"$appclick" properties:prams];
    return  [self CountData_sendAction:action to:target from:sender forEvent:event];
}

获取显示的文本

获取显示的文本,我们只需要针对特定的控件,调用相应的方法即可。我们以UIButton为例来介绍实现步骤。
首先声明一个UIView的分类UIView+TextContentData,然后在UIView的分类UIView+TextContentData添加 UIButton的分类
UIButton的分类。

UIView+TextContentData.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIView (TextContentData)
@property (nonatomic,copy,readonly) NSString *elementContent;
@end

@interface UIButton (TextContentData)

@end

NS_ASSUME_NONNULL_END

UIView+TextContentData.m

#import "UIView+TextContentData.h"

@implementation UIView (TextContentData)

- (NSString *)elementContent {
    return  nil;
}

@end

@implementation  UIButton (TextContentData)

- (NSString *)elementContent {
    return self.titleLabel.text;
}

@end

获取控件的文本埋点实现

+ (void)load {
    [UIApplication sensorsdata_swizzleMethod:@selector(sendAction:to:from:forEvent:) withMethod:@selector(CountData_sendAction:to:from:forEvent:)];
}

- (BOOL)CountData_sendAction:(SEL)action to:(id)target from:(id)sender forEvent:(UIEvent *)event {
    UIView *view = (UIView *)sender;
    NSMutableDictionary *prams = [[NSMutableDictionary alloc]init];
    //获取控件类型
    prams[@"$elementtype"] = view.elementType;
    prams[@"element_content"] = view.elementContent;
    [[SensorsAnalyticsSDK sharedInstance] track:@"$appclick" properties:prams];
    return  [self CountData_sendAction:action to:target from:sender forEvent:event];
}

我们这里只是以UIButton为例,如果想扩充其他控件,直接添加对应控件的分类。

获取控件所属页面

如何知道UIView属于那个UIViewController,这个就需要借助UIResponder了。

UIApplicationUIViewControllerUIView类都是UIResponder的子类,在iOS应用程序中,UIApplication、 UIViewController、UIView类的对象也都是响应者,这些响应者会形成一个 响应者链。

一个完整的响应者链传递规则(顺序)大概如下: UIViewUIViewControllerUIWindowUIApplicationUIApplicationDelegate
如下图所示:

响应者链

通过响应链图可知,对于任意一个视图来说,都能通过响应者链找到它所 在的视图控制器,也就是其所属的页面,从而达到获取所属页面信息的目 的。

注意:对于在iOS应用程序中实现了UIApplicationDelegate协议的类(通常为AppDelegate),如果它是继承自UIResponder,那么也会参与响应者 链的传递;如果不是继承自UIResponder(例如NSObject),那么不会参与响应者链的传递。

UIView+TextContentData.h

@interface UIView (TextContentData)

@property (nonatomic,copy,readonly) NSString *elementContent;
@property (nonatomic,strong,readonly) UIViewController *myViewController;

@end

UIView+TextContentData.m

#import "UIView+TextContentData.h"

@implementation UIView (TextContentData)

- (NSString *)elementContent {
    return  nil;
}

- (UIViewController *)myViewController {
    UIResponder *responder = self;
    while ((responder = [responder nextResponder])) {
        if ([responder isKindOfClass:[UIViewController class]]) {
            return (UIViewController *)responder;
        }
    }
    return  nil;
}

@end

获取控件所属页面埋点实现

+ (void)load {
    [UIApplication sensorsdata_swizzleMethod:@selector(sendAction:to:from:forEvent:) withMethod:@selector(CountData_sendAction:to:from:forEvent:)];
}

- (BOOL)CountData_sendAction:(SEL)action to:(id)target from:(id)sender forEvent:(UIEvent *)event {
    UIView *view = (UIView *)sender;
    NSMutableDictionary *prams = [[NSMutableDictionary alloc]init];
    //获取控件类型
    prams[@"$elementtype"] = view.elementType;
    //获取控件的内容
    prams[@"element_content"] = view.elementContent;
    //获取所属的页面
    UIViewController *vc = view.myViewController;
    prams[@"element_screen"] = NSStringFromClass(vc.class);
    [[SensorsAnalyticsSDK sharedInstance] track:@"$appclick" properties:prams];

    return  [self CountData_sendAction:action to:target from:sender forEvent:event];
}

更多控件

支持获取UISwitch控件文本信息

通过测试可以发现,UISwitch$AppClick事件没有$element_content属性。针对这个问题,可以解释为UISwitch控件本身就没有显示任何文本。 为了方便分析,针对获取UISwitch控件的文本信息,我们可以定一个简单的规则:当UISwitch控件的on属性为YES时,文本为“checked”;当 UISwitch控件的on属性为NO时,文本为“unchecked”。

解决方案
声明 UISwitch的分类

@implementation UISwitch (TextContentData)

- (NSString *)elementContent {
    return self.on ? @"checked":@"unchecked";
}

@end

滑动UISlider控件重复触发$AppClick事件解决方案

原因
我们在滑动UISlider控件过程中,系统会依次触发 UITouchPhaseBeganUITouchPhase-MovedUITouchPhaseMoved、……、 UITouchPhaseEnded事件,而每一个事件都会触发UIApplication- sendAction:to:from:forEvent:方法执行,从而触发$AppClick事件。
防止滑动UISlider重复响应,只有在UITouchPhaseEnded开始响应

 //防止滑动UISlider控制
    if(event.allTouches.anyObject.phase == UITouchPhaseEnded || [sender isKindOfClass:[UISwitch class]]) {
        [[SensorsAnalyticsSDK sharedInstance] track:@"$appclick" properties:prams];
    }

方案二

描述

当一个视图被添加到父视图上时,系统会自 动调用-didMoveToSuperview方法。因此,我们可 以通过Method Swizzling交换UIView- didMoveToSuperview方法,然后在交换方法里给 控件添加一组UIControlEventTouchDown类型的 Target-Action,并在Action里触发$AppClick事 件,从而实现$AppClick事件全埋点,这就是方案二的实现原理。

代码实现

新建一个UIControl的分类

UIControl+CountData.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIControl (CountData)

@end

NS_ASSUME_NONNULL_END

UIControl+CountData.m

+ (void)load {
    
    [UIControl sensorsdata_swizzleMethod:@selector(didMoveToSuperview) withMethod:@selector(CountData_didMoveToSuperview)];
}

- (void)CountData_didMoveToSuperview {
    
    //调用前交换原始方法
    [self CountData_didMoveToSuperview];
    [self addTarget:self action:@selector(CountData_touchDownAction:withEvent:) forControlEvents:UIControlEventTouchDown];

}

-(void)CountData_touchDownAction:(UIControl *)sender withEvent:(UIEvent *)event {
    if ([self CountData_isAddMultipleTargetActionsWithDefaultEvent:UIControlEventTouchDown]) {
        //触发$AppClick事件
        UIView *view = (UIView *)sender;
        NSMutableDictionary *prams = [[NSMutableDictionary alloc]init];
        //获取控件类型
        prams[@"$elementtype"] = view.elementType;
        //获取控件的内容
        prams[@"element_content"] = view.elementContent;
        //获取所属的页面
        UIViewController *vc = view.myViewController;
        prams[@"element_screen"] = NSStringFromClass(vc.class);
          
        [[SensorsAnalyticsSDK sharedInstance]track:@"appclick" properties:prams];
    }
}

注意点UIControl类中其实并没有实现-didMoveToSuperview方法,这个方法是 从它的父类UIView继承而来的。因此,我们实际上交换的是UIView中的- didMoveToSuperview方法。当UIView对象调用-didMoveToSuperview方法时,其实调用的是在UIControl+CountData.m中实现的- CountData_didMoveToSuperview方法。但是,UIView对象或者除了 UIControl类的其他UIView子类的对象,在执行-CountData_didMoveToSuperview方法时,并没有实现-CountData_didMoveToSuperview方法,因此,程序会出现 找不到方法而崩溃的情况。

针对这个问题,我们需要修改NSObject+SASwizzler.m文件中的 +sensorsdata_swizzleMethod:withMethod:类方法,即将其修改为:在方法交换之前,先在当前类中添加需要交换的方法,并在添加成功之后获取新的方法指针。

+ (BOOL)sensorsdata_swizzleMethod:(SEL)originalSEL withMethod:(SEL)alternateSEL {
   
    //获取原始的方法
    Method originalMethod = class_getInstanceMethod(self, originalSEL);
    if (!originalMethod) {
        return NO;
    }
    //获取将要交换的方法
    Method alternateMethod = class_getInstanceMethod(self, alternateSEL);
    if (!alternateMethod) {
        return NO;
    }
    
    //获取originalSel方法实现
    IMP originalIMP = method_getImplementation(originalMethod);
    //获取originalSEL方法的类型
    const char *originalMethodType = method_getTypeEncoding(originalMethod);
    //往类中添加originalSEL方法,如果已经存在,则添加失败,并返回NO
    if (class_addMethod(self, originalSEL, originalIMP, originalMethodType)) {
        //如果添加成功,重新获取originalSEL实例方法
        originalMethod = class_getInstanceMethod(self, originalSEL);
    }

    //获取alternateIMP方法实现
    IMP alternateIMP = method_getImplementation(alternateMethod);
    //获取alternateSEL方法的类型
    const char *alternateMethodType = method_getTypeEncoding(alternateMethod);
    //往类中添加alternateSEL方法,如果已经存在,则添加失败,并返回NO
    if (class_addMethod(self, alternateSEL, alternateIMP, alternateMethodType)) {
        //如果添加成功,重新获取alternateSEL实例方法
        alternateMethod = class_getInstanceMethod(self, alternateSEL);
    }

    //交互两个方法的实现
    method_exchangeImplementations(originalMethod, alternateMethod);  
    //返回yes,方法交换成功
    return YES;
}

支持更多控件

支持UISwitch、UISegmentedControl、UIStepper控件

这些控件都不响应UIControlEventTouchDown类型的Action,也就是说,没有触发-sensorsdata_touchDownAction:event:方法,因此,也就不会触发$AppClick事件。实际上,这些控件添加的是 UIControlEventValueChanged类型的Action

+ (void)load { 
    [UIControl sensorsdata_swizzleMethod:@selector(didMoveToSuperview) withMethod:@selector(CountData_didMoveToSuperview)];
}

- (void)CountData_didMoveToSuperview {
    
    //调用前交换原始方法
    [self CountData_didMoveToSuperview];
    //判断是否为一些特殊的控件
    if([self isKindOfClass:[UISwitch class]] ||
       [self isKindOfClass:[UISegmentedControl class]] ||
       [self isKindOfClass:[UIStepper class]] 
     ) {
        [self addTarget:self action:@selector(countData_valueChangedAction:event:) forControlEvents:UIControlEventValueChanged];
    }else {
        [self addTarget:self action:@selector(CountData_touchDownAction:withEvent:) forControlEvents:UIControlEventTouchDown];
    }
}

-(void)countData_valueChangedAction:(UIControl *)sender event:(UIEvent *)event {
    
    if ([self CountData_isAddMultipleTargetActionsWithDefaultEvent:UIControlEventValueChanged]) {    
        [[SensorsAnalyticsSDK sharedInstance]track:@"appclick" properties:nil];
    }
    
}

-(BOOL)CountData_isAddMultipleTargetActionsWithDefaultEvent:(UIControlEvents)defaultEvent {
    ///如果有多个target,说明除了添加的target,还有其他
    ///那么返回YES,触发$AppClick事件
    if (self.allTargets.count > 2) {
        return YES;
    }
    
    //如果控件本身为target,并且添加了不是UIControlEventTouchDown类型的Action
    //说明开发者以控件本身为target,并且已添加添加Action
    //那么返回YES,触发$AppClick事件
    if((self.allControlEvents & UIControlEventAllEvents) != UIControlEventTouchDown) {
        return YES;
    }
    
    //如果控件本身为Target,并且添加了两个以上的UIControlEventTouchDown类型的Action
    //说明开发者自行添加了Action
    //那么返回YES,触发$AppClick事件
    if([self actionsForTarget:self forControlEvent:defaultEvent].count > 2) {
        return YES;
    }

    return NO;
    
}

支持UISlider控件

UISlider添加的是UIControlEventTouchDown 类型的Action,这会导致在只点击而没有滑动UISlider时,也会触发 $AppClick事件,我们更希望只有手停止滑动UISlider时,才触发$AppClick事件。因此,需要修改UIControl+SensorsData.m文件中的- sensorsdata_didMoveToSuperview方法,默认也给UISlider添加UIControlEventValueChanged类型的Action

- (void)CountData_didMoveToSuperview {
    
    //调用前交换原始方法
    [self CountData_didMoveToSuperview];
    //判断是否为一些特殊的控件
    if([self isKindOfClass:[UISwitch class]] ||
       [self isKindOfClass:[UISegmentedControl class]] ||
       [self isKindOfClass:[UIStepper class]] ||
       [self isKindOfClass:[UISlider class]]) {
        [self addTarget:self action:@selector(countData_valueChangedAction:event:) forControlEvents:UIControlEventValueChanged];
    }else {
        [self addTarget:self action:@selector(CountData_touchDownAction:withEvent:) forControlEvents:UIControlEventTouchDown];
    }
}

在滑动UISlider过程中,会一直触发$AppClick事件。因此,我们还需要修改UIControl+CountData.m文件中 的-CountData_valueChanged Action:event:方法,确保如果是UISlider控件, 只有在手抬起的时候才触发$AppClick事件。

-(void)countData_valueChangedAction:(UIControl *)sender event:(UIEvent *)event {
    
    if ([sender isKindOfClass:UISlider.class] && event.allTouches.anyObject.phase != UITouchPhaseEnded) {
        return;
    }
    
    if ([self CountData_isAddMultipleTargetActionsWithDefaultEvent:UIControlEventValueChanged]) {  
        [[SensorsAnalyticsSDK sharedInstance]track:@"appclick" properties:nil];
    }
    
}


这样处理之后,当我们滑动UISlider时,只会在手抬起时触发 $AppClick事件。

方案总结

方案一和方案二其实都运用了iOS中的Target- Action模式,这两种方案各有优劣。

上一篇下一篇

猜你喜欢

热点阅读