神策分析 iOS SDK 全埋点解析之元素点击与页面浏览
前言
本文是继 《神策分析 iOS SDK 全埋点解析之启动与退出》之后,全埋点解析系列博客的第二篇,主要介绍元素点击与页面浏览的全埋点采集方案。在介绍具体的方案之前,我们需要先了解下相关的背景知识。
背景知识
Target-Action
Target-Action,也叫 “目标 - 动作” 模式,即当某个事件发生的时候,调用特定对象的特定方法。“特定对象” 就是 Target,“特定方法” 就是 Action。例如:在 LoginViewController 页面上有一个按钮,点击按钮时,会调用 LoginViewController 里的 - loginBtnOnClick 方法,则 Target 是 LoginViewController, Action 是 - loginBtnOnClick 方法。
Target-Action 设计模式主要包含两个部分:
Target(对象):接收消息的对象;
Action(方法):用于表示需要调用的方法。
Target 可以是任意类型的对象。但是在 iOS 应用程序中,通常情况下会是一个控制器,而触发事件的对象和接收消息的对象(Target)一样,也可以是任意类型的对象。例如:手势识别器 UIGestureRecognizer 就可以在识别到手势后,将消息发送给另一个对象。关于 Target-Action 模式,最常见的应用场景是在控件中。iOS 中的控件都是 UIControl 类或者其子类,当用户操作这些控件时,控件会将消息发送到指定的 Target,而对应的 Action 必须符合以下几种形式之一 :
- (void)doSomething;
- (void)doSomething:(id)sender;
- (void)doSomething:(id)sender forEvent:(UIEvent *)event;
- (IBAction)doSomething;
- (IBAction)doSomething:(id)sender;
- (IBAction)doSomething:(id)sender forEvent:(UIEvent *)event。
我们可以通过代码或者 Interface Builder 为一个控件添加一个 Target 以及相应的 Action。
若想使用代码方式添加 Target-Action(Target-Action 可用来表示一个 Target 以及相对应的 Action),我们可以直接调用控件对象的如下方法:
- (void)sendAction:(SEL)action to:(nullableid)target forEvent:(nullable UIEvent *)event;
我们也可以多次调用 - addTarget:action:forControlEvents: 方法给控件添加多个 Target-Action,即使多次调用 - addTarget:action:forControlEvents: 添加相同的 Target 且不同的 Action,也不会出现相互覆盖的问题。另外,在添加 Target-Action 时,Target 也可以为 nil(默认先在 self 里查找 Action)。
当我们为一个控件添加 Target-Action 后,控件又是如何找到 Target 对象并执行对应的 Action 呢?
在 UIControl 类中有一个方法: - (void)sendAction:(SEL)action to:(nullableid)target forEvent:(nullable UIEvent *)event;
用户操作控件(例如点击)时,首先会调用这个方法,并将事件转发给应用程序的 UIApplication 对象。
同时,在 UIApplication 类中也有一个类似的实例方法: - (BOOL)sendAction:(SEL)action to:(nullableid)target from:(nullableid)sender forEvent:(nullable UIEvent *)event;
如果 Target 对象不为 nil,应用程序会让该对象调用对应的方法响应事件;如果 Target 为 nil,应用程序会在响应者链中搜索定义了该方法的对象,然后执行该方法。
基于 Target-Action 设计模式,我们可以实现 $AppClick 事件的全埋点。
Method Swizzling
在 Objective-C 的 runtime 中,一个类是用一个名为 objc_class 的结构体表示的,它的定义如下:
struct objc_class {
Class _Nonnull isaOBJC_ISA_AVAILABILITY;
#if !__OBJC2__Class _Nullable super_classOBJC2_UNAVAILABLE;
const char * _Nonnull nameOBJC2_UNAVAILABLE;
long versionOBJC2_UNAVAILABLE;
long infoOBJC2_UNAVAILABLE;
long instance_sizeOBJC2_UNAVAILABLE;
struct objc_ivar_list * _Nullable ivarsOBJC2_UNAVAILABLE;
struct objc_method_list * _Nullable * _Nullable methodListsOBJC2_UNAVAILABLE;
struct objc_cache * _Nonnull cacheOBJC2_UNAVAILABLE;
struct objc_protocol_list * _Nullable protocolsOBJC2_UNAVAILABLE;
#endif } OBJC2_UNAVAILABLE;
在上面的结构体中,虽然有很多字段在 OBJC2 中已经废弃了(OBJC2_UNAVAILABLE),但是了解这个结构体有助于我们理解 Method Swizzling 的底层原理。从上述结构体中可以发现,有一个 objc_method_list 指针,它保存着当前类的所有方法列表。同时,objc_method_list 也是一个结构体,它的定义如下:
struct objc_method {
SEL _Nonnull method_name OBJC2_UNAVAILABLE;
char * _Nullable method_types OBJC2_UNAVAILABLE;
IMP _Nonnull method_imp OBJC2_UNAVAILABLE;
}
在上面的结构体中,有一个 objc_method 字段,它的定义如下:
struct objc_method {
SEL _Nonnull method_name OBJC2_UNAVAILABLE;
char * _Nullable method_types OBJC2_UNAVAILABLE;
IMP _Nonnull method_imp OBJC2_UNAVAILABLE;
}
从上面的结构体中可以看出,一个方法由下面三个部分组成:
method_name:方法名;
method_types:方法类型;
method_imp:方法实现。
使用 Method Swizzling 交换方法,其实就是修改了 objc_method 结构体中的 method_imp,也即改变了 method_name 和 method_imp 的映射关系:原有的 SEL(A)-IMP(A)、SEL(B)-IMP(B) 对应关系变成 SEL(A)-IMP(B)、SEL(B)-IMP(A)。如图 2-1 所示:
文章图片
图 2-1 Method Swizzling 前后的映射关系
响应者链 众所周知,UIResponder 类是 iOS 应用程序中专门用来响应用户操作事件的,例如:
Touch Events:即触摸事件;
Motion Events:即运动事件;
Remote Control Events:即远程控制事件。
因为 UIApplication、UIViewController、UIView 类都是 UIResponder 的子类,所以它们都具有响应以上事件的能力。另外,自定义的 UIView 和自定义视图控制器也都可以响应以上事件。在 iOS 应用程序中,UIApplication、UIViewController、UIView 类的对象也都是一个个响应者,这些响应者会形成一个响应者链。一个完整的响应者链传递规则(顺序)如下:UIView → UIViewController → RootViewController → Window → UIApplication → UIApplicationDelegate,如图 2-2 所示:
文章图片
图 2-2 事件响应者链(图片来源于 Apple 开发者文档)
点击事件 元素点击 方案简介
通过 Target-Action 执行模式可知,在执行 Action 方法之前,会先后通过控件和 UIApplication 对象发送事件相关的信息。因此,我们可以通过 Method Swizzling 交换 UIApplication 的 - sendAction:to:from:forEvent: 方法,然后在交换后的方法中触发 $AppClick 事件,并根据 target 和 sender 采集相关的属性,即可实现 $AppClick 事件的全埋点 。
对于 UIApplication 类中的 - sendAction:to:from:forEvent: 方法,我们以给 UIButton 设置 action 为例介绍如下:
[button addTarget:person action:@selector(btnAction) forControlEvents:UIControlEventTouchUpInside];
参数:
action:Action 方法对应的 selector,即示例中的 btnAction;
target:Target 对象,即示例中的 person。如果 Target 为 nil,应用程序会将消息发送给第一个响应者,并从第一个响应者沿着响应链向上发送消息,直到消息被处理为止;
sender:被用户点击或拖动的控件(发送 Action 消息的对象),即示例中的 button;
event:UIEvent 对象,它封装了触发事件的相关信息。
返回值:
如果有 responder 对象处理了此消息,返回 YES,否则返回 NO。
具体实现
下面我们详细介绍如何通过 Method Swizzling 交换 UIApplication 的 - sendAction:to:from:forEvent: 方法来实现 $AppClick 事件的全埋点。
- 在 SDK 初始化时交换 - sendAction:to:from:forEvent: 方法:
- (void)_enableAutoTrack {... NSError *error = NULL; //$AppClick // Actions & Events [UIApplication sensorsdata_swizzleMethod:@selector(sendAction:to:from:forEvent:) withMethod:@selector(sensorsdata_sendAction:to:from:forEvent:) error:&error]; ...}
- 在交换的方法里做埋点操作:
至此,一个简单的 $AppClick 事件的全埋点就完成了。
方案优化
通过 Method Swizzling 我们实现了简单的 $AppClick 事件的全埋点。但是,仅仅采集一个点击动作并不能满足实际的业务需求,还需要采集与控件相关的信息。
一般情况下,对于一个控件的点击事件,我们至少还需要采集如下信息(属性):
控件类型($element_type);
控件上显示的文本($element_content);
控件所属页面,即 UIViewController($screen_name)。
基于目前的方案,我们来看如何实现采集上述三个属性。
- 获取控件类型。获取控件类型相对比较简单,我们可以直接使用控件的 class 名称来代表当前控件的类型。获取控件的 class 名称可用如下方式:
- 获取控件上显示的文本。由于一般实现点击的控件都继承于 UIView,因此我们创建一个 UIView 的分类并且实现获取显示内容的方法:
- (NSString )sensorsdata_elementContent { ... NSMutableArray
- (NSString )sensorsdata_elementContent { NSString text = self.titleLabel.text; if (!text) { text = super.sensorsdata_elementContent; } return text; }
- 获取控件所属页面。如何知道一个 UIView 属于哪个 UIViewController ?这就需要借助 UIResponder 了。通过响应者链可以知道,对于任意一个视图来说,都能通过响应者链找到它所在的视图控制器,也就是其所属的页面,从而达到获取所属页面信息的目的:
- (UIViewController )sensorsdata_viewController {... UIResponder response = self; while ((response = [response nextResponder])) { if ([response isKindOfClass:[UIViewController class]]) { return (UIViewController *)response; } } return nil; ...}
UITableView 和 UICollectionView 点击
方案简介
上一节中我们介绍了通过 Target-Action 方式采集 $AppClick 事件的全埋点,不过还存在两个比较特殊的控件:UITableView 和 UICollectionView。
这两个控件的点击一般是指采集 UITableViewCell 和 UICollectionViewCell 的点击事件,而 UITableViewCell 和 UICollectionViewCell 都是直接继承自 UIView 类,而不是 UIControl 类。因此,上节提到的 $AppClick 事件的全埋点方案并不适用。
我们知道,UITableView 和 UICollectionView 实现点击的代理方法分别为 - tableView:didSelectRowAtIndexPath: 和 - collectionView:didSelectItemAtIndexPath:。因此,可以通过 Method Swizzling 的方式来交换方法的实现,从而实现 UITableView 和 UICollectionView 控件的 $AppClick 事件的全埋点采集。
具体实现
由于 UITableView 和 UICollectionView 实现 $AppClick 事件的全埋点方案类似,这里以 UITableView 为例,来介绍如何实现 $AppClick 事件的全埋点。
- 交换 UITableVIew 的 - setDelegate: 方法:
- load {... [UITableVIew sensorsdata_swizzleMethod:@selector(setDelegate:) withMethod:@selector(sensorsdata_setDelegate:)]; ...}
- 在 - sensorsdata_setDelegate: 方法中,我们可以通过参数 delegate 来获取实现 UITableViewDelegate 协议的对象。然后,交换该对象中的 - tableVIew:didSelectRowAtIndexPath: 方法即可:
- 由于 UITableView 的 delegate 对象是在运行时动态设置的,该对象具有不确定性。因此,我们需要动态地对 delegate 对象添加需要交换的方法,才能与 - tableView:didSelectRowAtIndexPath: 方法进行交换:
至此,我们已经实现了 UITableView 控件的 $AppClick 事件的全埋点采集。UICollectionView 的 $AppClick 事件的全埋点采集整体上与 UITableView 类似,不同之处是交换的方法需要换成 UICollectionView 对应的代理方法 - collectionView:didSelectItemAtIndexPath: ,这里不再赘述了。
方案优化
目前为止我们已经实现了 UITableView 和 UICollectionView 的 $AppClick 事件的全埋点采集。同样的,仅仅采集一个点击动作并不能满足实际的业务需求,还需要采集与控件相关的信息。
对于 UITableView 和 UICollectionView 而言,通常需要采集被点击 cell 的显示内容和所在位置。下面以 UITableView 为例,介绍如何采集 cell 的显示内容和所在位置。
- 获取点击的 UITableViewCell 对象:
- 由于 UITableViewCell 是一个复杂的控件,可能是由众多的元素组合而成。因此,我们需要遍历 UITableViewCell 上的所有元素,然后把获取的元素内容按照一定规则进行拼接:
- (NSString )sensorsdata_elementContent { ... NSMutableArray
- 至此,我们已经找到了 UITableViewCell 上所有非隐藏元素的显示内容。接下来,我们需要获取 UITableViewCell 的位置。因为已经获取到 indexPath 参数,所以直接按照规则拼接即可:
- (NSString )sensorsdata_elementPositionWithIndexPath:(NSIndexPath )indexPath { return [NSString stringWithFormat: @"%ld:%ld", (long)indexPath.section, (long)indexPath.row]; }
手势采集
在平时的开发过程中,系统提供的可点击控件往往不能满足我们复杂的需求,因此经常需要对 UIView 添加手势来实现视图的点击。
苹果公司为了降低开发者在手势事件处理方面的开发难度,定义了一个抽象类 UIGestureRecognizer 来协助开发者。UIGestureRecognizer 是具体手势识别器的抽象基类,它定义了一组手势识别器常见行为,还支持通过设置委托(即实现了 UIGestureRecognizerDelegate 协议的对象),对某些行为进行更细粒度的定制。
手势识别器必须被添加在一个特定的视图上(例如:UILabel、UIImageView 等控件),这需要通过调用 UIView 类中的 - addGestureRecognizer: 方法进行添加。手势识别器也是用了 Target-Action 设计模式。当我们为一个手势识别器添加一个或者多个 Target-Action 后,在视图上进行触摸操作时,一旦系统识别了该手势,就会向所有的 Target(对象)发送消息,并执行 Action(方法)。虽然手势识别器和 UIControl 类一样,都是使用了 Target-Action 设计模式,但是手势识别器并不会将消息交由 UIApplication 对象来进行发送。因此,我们无法使用与 UIControl 控件相同的处理方式,即无法通过响应者链的方式来实现对手势操作的全埋点。
因为 UIGestureRecognizer 是一个抽象基类,所以它并不会处理具体的手势。因此,对于轻拍(UITapGestureRecognizer)、长按(UILongPressGestureRecognizer)等具体的手势触摸事件,需要使用相应的子类(即具体的手势识别器)进行处理。
常见的具体手势识别器有:
UITapGestureRecognizer:轻拍手势;
UILongPressGestureRecognizer:长按手势;
UIPinchGestureRecognizer:捏合(缩放)手势;
UIRotationGestureRecognizer:旋转手势;
UISwipeGestureRecognizer:轻扫手势;
UIPanGestureRecognizer:平移手势;
UIScreenEdgePanGestureRecognizer:屏幕边缘平移手势。
方案简介
通过上节的介绍可以知道,常见的具体手势识别器有很多种。不过,给所有的具体手势识别器添加 Target-Action 的方法都是相同的,常见的主要是通过以下的两个方法进行添加:
- initWithTarget:action:;
- addTarget:action。
因此,我们可以在添加一个新的 Target-Action 的方法时通过 Method Swizzling 来实现手势全埋点采集。
具体实现
- 在初始化 SDK 时交换 - initWithTarget:action: 和 - addTarget:action: 方法:
- (SensorsAnalyticsSDK )sharedInstanceWithConfig:(nonnull SAConfigOptions )configOptions {... [UITapGestureRecognizer sensorsdata_swizzleMethod:@selector(initWithTarget:action:) withMethod:@selector(sensorsdata_initWithTarget:action:) error:&error]; [UITapGestureRecognizer sensorsdata_swizzleMethod:@selector(addTarget:action:) withMethod:@selector(sensorsdata_addTarget:action:)]; [UILongPressGestureRecognizer sa_swizzleMethod:@selector(addTarget:action:) withMethod:@selector(sa_addTarget:action:) error:&error]; [UILongPressGestureRecognizer sa_swizzleMethod:@selector(initWithTarget:action:) withMethod:@selector(sa_initWithTarget:action:) error:&error]; ...}
- 在交换后的方法里添加一个新的 Target-Action 即可实现 UITapGestureRecognizer 和 UILongPressGestureRecognizer 手势的采集:
- (instancetype)sensorsdata_initWithTarget:(id)target action:(SEL)action { [self sensorsdata_initWithTarget:target action:action]; // 由于方法已经交换,所以这里是调用 sensorsdata_addTarget:action: 的实现方法 [self addTarget:target action:action]; return self; } - (void)sensorsdata_addTarget:(id)target action:(SEL)action { [self sensorsdata_addTarget:self action:@selector(trackGestureRecognizerAppClick:)]; [self sensorsdata_addTarget:target action:action]; }
- 在 - trackGestureRecognizerAppClick: 方法中可以实现 $AppClick 事件的采集:
- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer )gesture { ... UIView view = gesture.view; // 神策暂定只采集 UILable 和 UIImageView BOOL isTrackClass = [view isKindOfClass:UILabel.class] || [view isKindOfClass:UIImageView.class]; if (!isTrackClass) { return; } // 触发 $AppClick 事件}
我们知道,对于任何一个手势,其实都有不同的状态,例如:
UIGestureRecognizerStateBegan;
UIGestureRecognizerStateChanged;
UIGestureRecognizerStateEnded;
UIGestureRecognizerStateCancelled。
上述不同的状态均会触发 Action。因此,目前的方案会造成多次采集 $AppClick 事件。如何解决这个问题呢?由于在触发 Action 的时候可以获取到手势的状态,因此只需要在手势的状态为 UIGestureRecognizerStateEnded 时触发 $AppClick 事件即可解决该问题:
- (void)trackGestureRecognizerAppClick:(UIGestureRecognizer *)gesture { // 手势处于 Ended 状态 if (gesture.state != UIGestureRecognizerStateEnded) { return; } ... // 触发 $AppClick 事件}
页面浏览事件
众所周知,每一个 UIViewController 都管理着一个由多个视图组成的树形结构,其中根视图保存在 UIViewController 的 view 属性中。UIViewController 会懒加载它所管理的视图集,直到第一次访问 view 属性时,才会去加载或者创建 UIViewController 的视图集。
几种常用的加载或者创建 UIViewController 的视图集的方法如下:
使用 Storyboard;
使用 Nib 文件;
使用代码,即重写 - loadView。
以上这些方法最终都会创建出合适的根视图并保存在 UIViewController 的 view 属性中,这是 UIViewController 生命周期的第一步。当 UIViewController 的根视图需要展示在页面上时,会调用 - viewDidLoad 方法。在这个方法中,我们可以做一些对象初始化相关的工作。
需要注意的是:此时视图的 bounds 还没有确定。如果使用代码创建视图,- viewDidLoad 方法会在 - loadView 方法调用结束之后运行;如果使用 Storyboard 或者 Nib 文件创建视图,- viewDidLoad 方法则会在 - awakeFromNib 方法之后调用。
当 UIViewController 的视图在屏幕上的显示状态发生变化时,UIViewController 会自动回调一些方法,确保子类能够响应到这些变化。图 4-1 展示了 UIViewController 在不同的显示状态时回调不同的方法:
文章图片
图 4-1 UIViewController 不同状态下的方法调用(图片来源于 Apple 开发者文档)
在 UIViewController 被销毁之前,还会回调 - dealloc 方法,我们一般通过重写这个方法来主动释放不能被 ARC 自动释放的资源。
方案简介
我们现在对 UIViewController 的整个生命周期有了一些基本了解。那么如何实现页面浏览事件( $AppViewScreen)的全埋点呢?
通过 UIViewController 的整个生命周期可知,当执行到 - viewDidAppear: 方法时,表示视图已经在屏幕上渲染完成,即页面已经显示出来,正等待用户进行下一步操作。因此,执行到 - viewDidAppear: 方法的时间点是触发页面浏览事件的最佳时机。
如果想要实现页面浏览事件的全埋点,可以通过 Method Swizzling 来交换 - viewDidAppear: 方法,然后在交换后的方法里采集 $AppViewScreen 即可。
具体实现
- 在初始化 SDK 时交换 - viewDidAppear: 方法:
- (SensorsAnalyticsSDK )sharedInstanceWithConfig:(nonnull SAConfigOptions )configOptions {... [UIViewController sensorsdata_swizzleMethod:@selector(viewDidAppear:) withMethod:@selector(sensorsdata_viewDidAppear:)]; ...}
- 在 UIViewController 的分类中实现 - sensorsdata_viewDidAppear: 方法:
- (void)sensorsdata_viewDidAppear:(BOOL)animated { [self sensorsdata_viewDidAppear:animated]; // 触发 $AppViewScreen}
- 由于这种方式采集到的页面浏览事件可能存在一些我们添加的 childViewController,通常情况下我们并不需要这些页面的浏览事件。因此,默认情况下禁止采集 childViewController 的页面浏览事件,并提供使用预编译宏的方式打开采集 childViewController 的页面浏览事件:
- (void)sensorsdata_viewDidAppear:(BOOL)animated { SensorsAnalyticsSDK instance = [SensorsAnalyticsSDK sharedInstance]; #ifndef SENSORS_ANALYTICS_ENABLE_AUTOTRACK_CHILD_VIEWSCREEN UIViewController viewController = (UIViewController *)self; if (![viewController.parentViewController isKindOfClass:[UIViewController class]] || [viewController.parentViewController isKindOfClass:[UITabBarController class]] || [viewController.parentViewController isKindOfClass:[UINavigationController class]] || [viewController.parentViewController isKindOfClass:[UIPageViewController class]] || [viewController.parentViewController isKindOfClass:[UISplitViewController class]]) { // 触发 $AppViewScreen [instance autoTrackViewScreen:viewController]; }#else // 触发 $AppViewScreen [instance autoTrackViewScreen:self]; #endif } [self sensorsdata_viewDidAppear:animated]; }
由于上述方案交换了所有 UIViewController 的 - viewDidAppear: 方法,因此会采集到很多我们并不需要的系统页面。为了解决这个问题,我们引入了黑名单机制:把需要忽略的页面存放在一个 json 文件里,在触发 $AppViewScreen 之前对黑名单里的 UIViewController 进行过滤:
- (void)trackViewScreen:(UIViewController )controller properties:(nullable NSDictionary
- (void)sensorsdata_viewDidAppear:(BOOL)animated { if (instance.previousTrackViewController != self) { // 触发 $AppViewScreen } if (instance.previousTrackViewController != self && UIApplication.sharedApplication.keyWindow == self.view.window) { instance.previousTrackViewController = self; } [self sensorsdata_viewDidAppear:animated]; }
总结
本文主要介绍了神策分析 iOS SDK 的元素点击与页面浏览的全埋点采集方案,详细的实现可以参考 iOS SDK 源码。
在全埋点采集方案中大量使用了 Method Swizzling,这种方式的优点如下:
Method Swizzling 属于成熟的技术,相对比较稳定;
性能相对来说也比较高。
但是,缺点也显而易见:
对原始代码有入侵,容易造成冲突;
一旦出现问题,影响的范围较大。
因此,欢迎大家在开源社区一起讨论更好的解决方案。
【神策分析 iOS SDK 全埋点解析之元素点击与页面浏览】文章来源:神策技术社区
推荐阅读
- 如何寻找情感问答App的分析切入点
- 2020-04-07vue中Axios的封装和API接口的管理
- D13|D13 张贇 Banner分析
- iOS中的Block
- 自媒体形势分析
- 2020-12(完成事项)
- Android事件传递源码分析
- Python数据分析(一)(Matplotlib使用)
- 记录iOS生成分享图片的一些问题,根据UIView生成固定尺寸的分享图片
- 2019-08-29|2019-08-29 iOS13适配那点事