写在前面
传送门:
前面的系列章节可以查看上面连接,本章节主要是介绍 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;
@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
了。
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;
@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控件过程中,系统会依次触发 UITouchPhaseBegan
、UITouchPhase-Moved
、UITouchPhaseMoved
、……、 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
类中其实并没有实现-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;
}
//获取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
属性进行逻辑判断,有可能会引入一些无法预料的问题。 因此,在选择方案的时候,读者可以根据自 己的实际情况和需求,来确定最终的实现方案。
网友评论