寫在前面
傳送門:
前面的系列章節(jié)可以查看上面連接,本章節(jié)主要是介紹 iOS全埋點序列文章(3)控件點擊事件分析
Target-Action設(shè)計模式
在具體介紹如何實現(xiàn)之前,我們需要先了解在UIKit框架下點擊或拖動 事件的Target-Action設(shè)計模式。
Target-Action模式主要包含兩個部分。
-
Target(對象):接收消息的對象。 -
Action(方法):用于表示需要調(diào)用的方法
Target可以是任意類型的對象。但是在iOS應(yīng)用程序中,通常情況下會 是一個控制器,而觸發(fā)事件的對象和接收消息的對象(Target)一樣,也可 以是任意類型的對象。例如,手勢識別器UIGestureRecognizer就可以在識 別到手勢后,將消息發(fā)送給另一個對象。
當我們?yōu)橐粋€控件添加Target-Action后,控件又是如何找到Target并執(zhí) 行對應(yīng)的Action的呢?
UIControl類中有一個方法:
- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;
用戶操作控件(比如點擊)時,首先會調(diào)用這個方法,并將事件轉(zhuǎn)發(fā) 給應(yīng)用程序的UIApplication對象。
同時,在UIApplication類中也有一個類似的實例方法:
- (BOOL)sendAction:(SEL)action to:(nullable id)target from:(nullable id)sender forEvent:(nullable UIEvent *)event;
如果Target不為nil,應(yīng)用程序會讓該對象調(diào)用對應(yīng)的方法響應(yīng)事件;如果Target為nil,應(yīng)用程序會在響應(yīng)鏈中搜索定義了該方法的對象,然后 執(zhí)行該方法。
基于Target-Action設(shè)計模式,有兩種方案可以實現(xiàn)$AppClick事件的全埋點。下面我們將逐一進行介紹。
方案一
描述
通過Target-Action設(shè)計模式可知,在執(zhí)行Action之前,會先后通過控件 和UIApplication對象發(fā)送事件相關(guān)的信息。因此,我們可以通過Method Swizzling交換UIApplication類中的-sendAction:to:from:forEvent:方法,然后 在交換后的方法中觸發(fā)$AppClick事件,并根據(jù)target和sender采集相關(guān)屬性,實現(xiàn)$AppClick事件的全埋點。
代碼實現(xiàn)
新建一個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對象的繼承關(guān)系圖

從上圖可以看出,控件都是繼承于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
獲取控件類型的埋點實現(xiàn)
+ (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];
}
獲取顯示的文本
獲取顯示的文本,我們只需要針對特定的控件,調(diào)用相應(yīng)的方法即可。我們以UIButton為例來介紹實現(xiàn)步驟。
首先聲明一個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
獲取控件的文本埋點實現(xiàn)
+ (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為例,如果想擴充其他控件,直接添加對應(yīng)控件的分類。
獲取控件所屬頁面
如何知道UIView屬于那個UIViewController,這個就需要借助UIResponder了。
UIApplication、UIViewController、UIView類都是UIResponder的子類,在iOS應(yīng)用程序中,UIApplication、 UIViewController、UIView類的對象也都是響應(yīng)者,這些響應(yīng)者會形成一個 響應(yīng)者鏈。
一個完整的響應(yīng)者鏈傳遞規(guī)則(順序)大概如下: UIView→UIViewController→UIWindow→UIApplication→UIApplicationDelegate
如下圖所示:

通過響應(yīng)鏈圖可知,對于任意一個視圖來說,都能通過響應(yīng)者鏈找到它所 在的視圖控制器,也就是其所屬的頁面,從而達到獲取所屬頁面信息的目 的。
注意:對于在iOS應(yīng)用程序中實現(xiàn)了UIApplicationDelegate協(xié)議的類(通常為AppDelegate),如果它是繼承自UIResponder,那么也會參與響應(yīng)者 鏈的傳遞;如果不是繼承自UIResponder(例如NSObject),那么不會參與響應(yīng)者鏈的傳遞。
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
獲取控件所屬頁面埋點實現(xiàn)
+ (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;
//獲取控件的內(nèi)容
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控件文本信息
通過測試可以發(fā)現(xiàn),UISwitch的$AppClick事件沒有$element_content屬性。針對這個問題,可以解釋為UISwitch控件本身就沒有顯示任何文本。 為了方便分析,針對獲取UISwitch控件的文本信息,我們可以定一個簡單的規(guī)則:當UISwitch控件的on屬性為YES時,文本為“checked”;當 UISwitch控件的on屬性為NO時,文本為“unchecked”。
解決方案
聲明 UISwitch的分類
@implementation UISwitch (TextContentData)
- (NSString *)elementContent {
return self.on ? @"checked":@"unchecked";
}
@end
滑動UISlider控件重復觸發(fā)$AppClick事件解決方案
原因:
我們在滑動UISlider控件過程中,系統(tǒng)會依次觸發(fā) UITouchPhaseBegan、UITouchPhase-Moved、UITouchPhaseMoved、……、 UITouchPhaseEnded事件,而每一個事件都會觸發(fā)UIApplication的- sendAction:to:from:forEvent:方法執(zhí)行,從而觸發(fā)$AppClick事件。
防止滑動UISlider重復響應(yīng),只有在UITouchPhaseEnded開始響應(yīng)
//防止滑動UISlider控制
if(event.allTouches.anyObject.phase == UITouchPhaseEnded || [sender isKindOfClass:[UISwitch class]]) {
[[SensorsAnalyticsSDK sharedInstance] track:@"$appclick" properties:prams];
}
方案二
描述
當一個視圖被添加到父視圖上時,系統(tǒng)會自 動調(diào)用-didMoveToSuperview方法。因此,我們可 以通過Method Swizzling交換UIView的- didMoveToSuperview方法,然后在交換方法里給 控件添加一組UIControlEventTouchDown類型的 Target-Action,并在Action里觸發(fā)$AppClick事 件,從而實現(xiàn)$AppClick事件全埋點,這就是方案二的實現(xiàn)原理。
代碼實現(xiàn)
新建一個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 {
//調(diào)用前交換原始方法
[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]) {
//觸發(fā)$AppClick事件
UIView *view = (UIView *)sender;
NSMutableDictionary *prams = [[NSMutableDictionary alloc]init];
//獲取控件類型
prams[@"$elementtype"] = view.elementType;
//獲取控件的內(nèi)容
prams[@"element_content"] = view.elementContent;
//獲取所屬的頁面
UIViewController *vc = view.myViewController;
prams[@"element_screen"] = NSStringFromClass(vc.class);
[[SensorsAnalyticsSDK sharedInstance]track:@"appclick" properties:prams];
}
}
注意點:UIControl類中其實并沒有實現(xiàn)-didMoveToSuperview方法,這個方法是 從它的父類UIView繼承而來的。因此,我們實際上交換的是UIView中的- didMoveToSuperview方法。當UIView對象調(diào)用-didMoveToSuperview方法時,其實調(diào)用的是在UIControl+CountData.m中實現(xiàn)的- CountData_didMoveToSuperview方法。但是,UIView對象或者除了 UIControl類的其他UIView子類的對象,在執(zhí)行-CountData_didMoveToSuperview方法時,并沒有實現(xiàn)-CountData_didMoveToSuperview方法,因此,程序會出現(xiàn) 找不到方法而崩潰的情況。
針對這個問題,我們需要修改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方法實現(xiàn)
IMP originalIMP = method_getImplementation(originalMethod);
//獲取originalSEL方法的類型
const char *originalMethodType = method_getTypeEncoding(originalMethod);
//往類中添加originalSEL方法,如果已經(jīng)存在,則添加失敗,并返回NO
if (class_addMethod(self, originalSEL, originalIMP, originalMethodType)) {
//如果添加成功,重新獲取originalSEL實例方法
originalMethod = class_getInstanceMethod(self, originalSEL);
}
//獲取alternateIMP方法實現(xiàn)
IMP alternateIMP = method_getImplementation(alternateMethod);
//獲取alternateSEL方法的類型
const char *alternateMethodType = method_getTypeEncoding(alternateMethod);
//往類中添加alternateSEL方法,如果已經(jīng)存在,則添加失敗,并返回NO
if (class_addMethod(self, alternateSEL, alternateIMP, alternateMethodType)) {
//如果添加成功,重新獲取alternateSEL實例方法
alternateMethod = class_getInstanceMethod(self, alternateSEL);
}
//交互兩個方法的實現(xiàn)
method_exchangeImplementations(originalMethod, alternateMethod);
//返回yes,方法交換成功
return YES;
}
支持更多控件
支持UISwitch、UISegmentedControl、UIStepper控件
這些控件都不響應(yīng)UIControlEventTouchDown類型的Action,也就是說,沒有觸發(fā)-sensorsdata_touchDownAction:event:方法,因此,也就不會觸發(fā)$AppClick事件。實際上,這些控件添加的是 UIControlEventValueChanged類型的Action。
+ (void)load {
[UIControl sensorsdata_swizzleMethod:@selector(didMoveToSuperview) withMethod:@selector(CountData_didMoveToSuperview)];
}
- (void)CountData_didMoveToSuperview {
//調(diào)用前交換原始方法
[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,觸發(fā)$AppClick事件
if (self.allTargets.count > 2) {
return YES;
}
//如果控件本身為target,并且添加了不是UIControlEventTouchDown類型的Action
//說明開發(fā)者以控件本身為target,并且已添加添加Action
//那么返回YES,觸發(fā)$AppClick事件
if((self.allControlEvents & UIControlEventAllEvents) != UIControlEventTouchDown) {
return YES;
}
//如果控件本身為Target,并且添加了兩個以上的UIControlEventTouchDown類型的Action
//說明開發(fā)者自行添加了Action
//那么返回YES,觸發(fā)$AppClick事件
if([self actionsForTarget:self forControlEvent:defaultEvent].count > 2) {
return YES;
}
return NO;
}
支持UISlider控件
給UISlider添加的是UIControlEventTouchDown 類型的Action,這會導致在只點擊而沒有滑動UISlider時,也會觸發(fā) $AppClick事件,我們更希望只有手停止滑動UISlider時,才觸發(fā)$AppClick事件。因此,需要修改UIControl+SensorsData.m文件中的- sensorsdata_didMoveToSuperview方法,默認也給UISlider添加UIControlEventValueChanged類型的Action。
- (void)CountData_didMoveToSuperview {
//調(diào)用前交換原始方法
[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過程中,會一直觸發(fā)$AppClick事件。因此,我們還需要修改UIControl+CountData.m文件中 的-CountData_valueChanged Action:event:方法,確保如果是UISlider控件, 只有在手抬起的時候才觸發(fā)$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時,只會在手抬起時觸發(fā) $AppClick事件。
方案總結(jié)
方案一和方案二其實都運用了iOS中的Target- Action模式,這兩種方案各有優(yōu)劣。
-
對于方案一:如果給一個控件添加了多個
Target-Action,會導致多次觸發(fā)$AppClick事件。 -
對于方案二:由于SDK為控件添加了一個默認觸發(fā)類型的
Action,因此,如果開發(fā)者在開發(fā) 過程中使用UIControl類的allTargets或者allControlEvents屬性進行邏輯判斷,有可能會引入一些無法預料的問題。 因此,在選擇方案的時候,讀者可以根據(jù)自 己的實際情況和需求,來確定最終的實現(xiàn)方案。