美文网首页iOS面试题库iOS记录
iOS 全埋点-控件点击事件(3)

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

作者: smile_frank | 来源:发表于2021-10-18 19:36 被阅读0次

    写在前面

    传送门:

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

    Target-Action设计模式

    在具体介绍如何实现之前,我们需要先了解在UIKit框架下点击或拖动 事件的Target-Action设计模式。
    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];
    }
    
    

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

    • 控件类型($element_type
    • 控件上显示的文本($element_content
    • 控件所属页面($screen_name

    获取控件类型

    先为你介绍一下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模式,这两种方案各有优劣。

    • 对于方案一:如果给一个控件添加了多个 Target-Action,会导致多次触发$AppClick事件。
    • 对于方案二:由于SDK为控件添加了一个默认触发类型的Action,因此,如果开发者在开发 过程中使用UIControl类的allTargets或者 allControlEvents属性进行逻辑判断,有可能会引入一些无法预料的问题。 因此,在选择方案的时候,读者可以根据自 己的实际情况和需求,来确定最终的实现方案。

    相关文章

      网友评论

        本文标题:iOS 全埋点-控件点击事件(3)

        本文链接:https://www.haomeiwen.com/subject/ykwnoltx.html