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

写在前面

传送门:

前面的系列章节可以查看上面连接,本章节主要是介绍 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属性进行逻辑判断,有可能会引入一些无法预料的问题。 因此,在选择方案的时候,读者可以根据自 己的实际情况和需求,来确定最终的实现方案。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容