iOS 全埋点-UITaleView和UICollectionView的点击事件(4
)
iOS 全埋点- 手势事件 (5)
前面的系列章节可以查看上面连接,本章节主要是介绍 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
,应用程序会让该对象调用对应的方法响应事件;如果
Target
为
nil
,应用程序会在响应链中搜索定义了该方法的对象,然后 执行该方法。
基于
Target-Action
设计模式,有两种方案可以实现$AppClick事件的全埋点。下面我们将逐一进行介绍。
通过
Target-Action
设计模式可知,在执行
Action
之前,会先后通过控件 和
UIApplication
对象发送事件相关的信息。因此,我们可以通过
Method Swizzling
交换
UIApplication
类中的
-sendAction:to:from:forEvent:
方法,然后 在交换后的方法中触发
$AppClick
事件,并根据
target
和
sender
采集相关属性,实现
$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
对象的继承关系图
从上图可以看出,控件都是继承于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;
NS_ASSUME_NONNULL_END
UIView+TypeData.m
#import "UIView+TypeData.h"
@implementation UIView (TypeData)
- (NSString *)elementType {
return NSStringFromClass([self class]);
获取控件类型的埋点实现
+ (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;
@interface UIButton (TextContentData)
NS_ASSUME_NONNULL_END
UIView+TextContentData.m
#import "UIView+TextContentData.h"
@implementation UIView (TextContentData)
- (NSString *)elementContent {
return nil;
@implementation UIButton (TextContentData)
- (NSString *)elementContent {
return self.titleLabel.text;
获取控件的文本埋点实现
+ (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
了。
UIApplication
、UIViewController
、UIView
类都是UIResponder
的子类,在iOS应用程序中,UIApplication、 UIViewController、UIView类的对象也都是响应者,这些响应者会形成一个 响应者链。
一个完整的响应者链传递规则(顺序)大概如下: UIView
→UIViewController
→UIWindow
→UIApplication
→UIApplicationDelegate
如下图所示:
通过响应链图可知,对于任意一个视图来说,都能通过响应者链找到它所 在的视图控制器,也就是其所属的页面,从而达到获取所属页面信息的目 的。
注意:
对于在iOS应用程序中实现了UIApplicationDelegate
协议的类(通常为AppDelegate
),如果它是继承自UIResponder
,那么也会参与响应者 链的传递;如果不是继承自UIResponder
(例如NSObject
),那么不会参与响应者链的传递。
UIView+TextContentData.h
@interface UIView (TextContentData)
@property (nonatomic,copy,readonly) NSString *elementContent;
@property (nonatomic,strong,readonly) UIViewController *myViewController;
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;
获取控件所属页面埋点实现
+ (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";
滑动UISlider控件重复触发$AppClick事件解决方案
我们在滑动UISlider控件过程中,系统会依次触发 UITouchPhaseBegan
、UITouchPhase-Moved
、UITouchPhaseMoved
、……、 UITouchPhaseEnded
事件,而每一个事件都会触发UIApplication
的- sendAction:to:from:forEvent:
方法执行,从而触发$AppClick
事件。
防止滑动UISlider重复响应,只有在UITouchPhaseEnded开始响应
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)
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]) {
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
类中其实并没有实现-didMoveToSupervie
w方法,这个方法是 从它的父类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;
IMP originalIMP = method_getImplementation(originalMethod);
const char *originalMethodType = method_getTypeEncoding(originalMethod);
if (class_addMethod(self, originalSEL, originalIMP, originalMethodType)) {
originalMethod = class_getInstanceMethod(self, originalSEL);
IMP alternateIMP = method_getImplementation(alternateMethod);
const char *alternateMethodType = method_getTypeEncoding(alternateMethod);
if (class_addMethod(self, alternateSEL, alternateIMP, alternateMethodType)) {
alternateMethod = class_getInstanceMethod(self, alternateSEL);
method_exchangeImplementations(originalMethod, alternateMethod);
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 {
if (self.allTargets.count > 2) {
return YES;
if((self.allControlEvents & UIControlEventAllEvents) != UIControlEventTouchDown) {
return YES;
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
属性进行逻辑判断,有可能会引入一些无法预料的问题。 因此,在选择方案的时候,读者可以根据自 己的实际情况和需求,来确定最终的实现方案。