网易HubbleData是一款探索用户行为的数据分析系统。本文主要介绍无埋点SDK在iOS端的设计与实现,分享在无埋点开发过程中的一些关键技术的,包括事件唯一ID的设计与无埋点的实现。

0 引言

最近在负责公司的HubbleData的埋点SDK的开发任务,产品的雏形其实在几年前就已经有了,公司内部的诸如考拉、易信、LOFTER、美学、漫画等多款产品都已接入使用。

下图给出HubbleData SDK某个应用的部分分析的展示页面:

(1)概览示意图

事件

(2)事件分析示意图

事件

(3)实时分析示意图

事件

此外HubbleData平台还具备留存分析、漏斗分析、粘性分析、数据看板等多种功能,方便相关负责人员对产品用户行为进行进一步的探索分析。

老版本的SDK的设计是代码埋点实现的,虽然对于一些较为成熟的产品,代码埋点完全能够达到产品方的需求,但是对于一些新起步或者需频繁变更的需求的新产品等,考虑到其维护的成本大,代价高等缺点,HubbleData无埋点SDK的设计就显得尤为重要了。

本人主要负责iOS端无埋点以及可视化圈选的工作,文章主要系统讲解一下HubbleData无埋点SDK在iOS端的设计与实现和一些相关问题的解决,后续将针对整个埋点的实现流程与可视化圈选等内容再作分享。

一、埋点简介

1.1 三种埋点的实现方式简介

埋点的方式分为三类:代码埋点、可视化埋点和无埋点。这里简单的介绍一下三种埋点方式:

(1) 代码埋点即是在代码的关键部位植入所要收集数据的N行代码,需要挖开产品本身,深入了解产品的业务逻辑及项目结构,下面代码模拟展示的即是点击提交订单的时候HubbleData SDK代码埋点;

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)buyButtonClick:(id)sender {
//处理用户的业务逻辑
[self handleOrder];
//用户自定义埋点属性
NSDictionary *properties = @{@"商品名称" : self.productName,
@"商品价格" : self.productPrice,
@"商品类别" : self.productType,
@"购买时间" : self.productSaleTime};
//代码埋点(事件名称:EventID 事件属性:properties)
[[DATracker sharedTracker] trackEvent:@"EventID" withAttributes:properties];
}

(2) 可视化埋点即用可视化交互的方式圈选出所要采集数据的控件,当用户行为产生时,即可收集到相应的埋点数据。相比于前面的代码埋点而言,可视化埋点能够解决代码埋点代价大成本高的问题,但是无法灵活的自定义埋点属性。

可视化埋点流程

(3) 无埋点也叫全埋点,即不需要用户主动埋点,可以收集用户所有的操作行为,同样采用可视化圈选,用户能够拿到所想采集的埋点数据,能够解决可视化圈选中数据不可回溯的问题。下图给出了无埋点数据收集的简单流程。

无埋点数据收集流程

HubbleData SDK的设计主要是代码埋点结合无埋点的数据采集方式,其中也涉及到可视化埋点中的屏幕序列化及事件绑定机制,本文主要介绍一下无埋点的设计与实现。

1.2 无埋点SDK设计详细流程

下图给出HubbleData无埋点SDK在iOS端的设计实现:

无埋点数据收集流程

从上图可以看出,HubbleData的无埋点是在代码埋点的基础上实现的,所处无埋点的难点也就集中在以下三个方面:

(1)自动获取埋点的EventID
(2)自动获取埋点的时机
(3)自动获取埋点需采集的属性

本文主要就这三个方面进行分析,第二部分主要讲一下事件唯一ID的确定,第三部分主要讲一下无埋点的采集的实现,主要是各种事件发生采集的时机以及待采集的属性的配置。

HubbleData SDK还涉及到许多其他功能,包括屏幕序列可视化、代码埋点、精准渠道追踪等,这里不再介绍,后面会陆续分享相关的技术实现。

二、事件唯一ID的确定

为了实现在可视化圈选时候的事件的唯一性,每一个无埋点的事件采集都必须有且仅有一个唯一的标识符来区分不同的事件。不同于代码埋点,用户可以自定义的配置自己所需的EventID,无埋点过程中,需要SDK自己配置每一个采集事件的EventID,通过可视化圈选的操作,筛选出相应的EventID所对应的数据信息。HubbleData采用的是构造view唯一标识字符串的方式去唯一的标识这样的一个事件,主要由view的层级结构path路径、该view的所在页面类名以及view所带的一些自身固定属性等构成,并通过SHA256编码来获取唯一的EventID。

下面将整体系统介绍一些事件唯一ID的生成过程。

2.1 控件的层级结构path构造

2.1.1 普通view的层级结构path构造

层级结构path主要是基于页面的控件树构造而成,每个view都有superview与subviews的属性,将每一个view的superview作为树的父节点,将其subviews作为子节点,这样就能把整个app上的所有view组成一棵庞大的控件树,其中树的顶层是UIWindow,然后是每一个view节点依次向下展开。下图给出一个简单的控件树的结构图。

空间树结构

下面会详细介绍一下HubbleData的唯一标识路径的构造方式。

不同类 同类

像上图1所示,如果一个view的subviews中都是不同类型的,比如像下图图1所示的控件树那样,可以唯一标识UILabel和UIButton控件为:

UIView_UILabel
UIView_UIButton

但是真正的页面是不会像理想中的所有控件都是不同类型的,可以说这种极端情况基本不存在,如果还是按照上述的方法来构造路径的话,两个UILabel都会被标识成UIView_UILabel,这显然无法区分两个控件。因此仅仅是每个控件节点的路径名称是无法唯一标识这个控件的,这里HubbleData加入了此控件节点在父视图中的index。比如上图2,可以将两个UILabel标识为:

UIView(0)_UILabel(0)
UIView(0)_UILabel(1)

这里假设父视图是index为0的一个节点,这样就可以完全的区分出两个控件了。

那么剩下的问题就是每个UIView index索引值的确定。

每个UIView都有subviews属性,每一个子视图都有一个被addsubView的次序,其实要拿的这个index就是子视图被add的次序,那么该怎么拿到这个次序呢,在苹果的官方说明文档中,岁UIView的subviews属性,是这么介绍的:

@property(nonatomic, readonly, copy) NSArray *subviews

You can use this property to retrieve the subviews associated with your custom view hierarchies. 
The order of the subviews in the array reflects their visible order on the screen.

即每一个子视图在这个subviews数组中的索引就是HubbleData要拿的index。

针对复杂的视图形式,如下图所示,按照上述的层级结构路径构造方法得到的唯一层级路径为:

UIView(0)_UILabel(0)
UIView(0)_UIButton(1)
UIView(0)_UIButton(2)  
混合

从上述的分析可知,按照上述介绍的方法进行view的唯一层级路径标识,对大部分的页面来说已经足够,但是对于一些更为灵活点的页面,由于一些业务需求等原因,开发人员经常会调用removeFromSuperview, insertSubview:atIndex:, insertSubview: belowSubview:等函数,都会极大的影响整个页面的subviews的索引值,比如现在我将上图所示的UILabel移动到两个UIButton的后边,那么得到的唯一层级路径为:

UIView(0)_UIButton(0)
UIView(0)_UIButton(1)
UIView(0)_UILabel(2)  
混合

可以发现,唯一层级路径已经被改变,但是整个页面却没有发生变化,不仅会产生新的事件(比如UIButton(0),UILabel(2)),连UIButton(1)事件的采集也会出错,即使是不同的事件,却得到了不同的eventID,所以需要提高构造的层级结构路径的稳健型。

正像刚刚提到的,不同类型的UIView不需要做index的区分,那么在获取这个index的时候,不是简单的从subviews这个数组中获取其对应的索引值,而是进行一个简单的同类归并再取索引值,一个很简单的处理。

1
2
3
4
5
for (UIView *view in subviews) {
if ([NSStringFromClass([subview class]) isEqualToString:NSStringFromClass(class)]) { //class为待筛选的类
[array addObject:view];
}
}

这样就可以取得array中的index作为其真正的索引值,得到的层级结构路径为:

UIView(0)_UILabel(0)
UIView(0)_UIButton(0)
UIView(0)_UIButton(1) 

此时无论UIlabel的位置放在何处,都不会改变这个路径的构造形式,大大增加了稳健型。其实也能发现,这仅仅只能提高稳健型,并不能从根本上解决这个问题,比如若我把两个UIButton的顺序调换了,或者删除了第一个,此时依然会得到一些不准确的层级路径。此问题会后续解决,会逐步引入误差容量和相似度这个概念,即只要在误差范围内,则会进行进一步的匹配,具体的解决方案本篇不在介绍。

2.1.2 几种特殊情况的处理

2.1.1主要讲的是一些普通view的层级结构的path构造方式,但是有一些特殊情况需要特别的考虑处理:

  • UITableViewCell

由于UITableViewCell具有可复用的机制,当一个页面中在持续滚动的时候,cell在不断的复用,如果还使用2.1.1中介绍的方法来获取index索引值话,那么会引起整个页面无埋点数据采集的混乱。

当获取当前UITableViewCell的index时,可以使用indexPath参数进行替换,这个参数可准确的获取section和row的值,唯一的对应每一个cell。唯一层级路径的形式可以自定义配置,HubbleData的设置方式为:类名+(section: row:),下面给出一个示例:

MyTableViewCell(section:0 row:7)
  • UICollectionViewCell

UICollectionViewCell的path生成原理同UITableViewCell,HubbleData的设置方式为:类名+(section:item:),下面给出一个示例:

MyCollectionViewCell(section:0 item:7)
  • UIControl

其实UIButton也算是一种普通view的一种,大多数情况下,使用上述的层级结构path以及页面类名的组合能够唯一的确定当前UIControl的唯一标识符,但是有一种特殊的情况,当作为UINavigationItem时会出现特殊情况,下面的所给出的两个例子。

bar1
bar2

当点击第一个NavigationBar的右侧的按钮时,得到的层级路径为:

...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton(1)

分析可知,左侧的设置按钮的索引为0,所以右侧的按钮索引为1。同时获取的当前页面为:UINavigationController。

当点击第二个页面的同一个类型的按钮时,即同样标有数字7的item时,此时得到的层级路径为:

...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton(2)

可以发现此时的按钮的索引变成了2,已经不同于上述第一个NavigationBar的同一个按钮的层级路径了,经过分析,索引值为1的按钮是最右侧的表格的那个item,经过验证可以得到其层级路径:

...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton(1)

获取的页面为:UINavigationController。

其实这种页面很常见,由于页面的切换,NavigationBar上的一些按钮的位置可能顺序会打乱,导致同一个功能的NavigationItem已经无法确定标识唯一,即使是获取了当前按钮所在的页面也无法区分,因为获取的都是UINavigationController。从上面的分析可以看出,这种情况甚至会导致严重错乱的数据采集。

其实仔细分析一下,如果分析得出该UIControl是在UINavigationBar上,则无需设置其相应的index值,即上述的所有navigationItem的层级结构路径都为:

...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton

即都不做区分。

HubbleData采用增加一种新的属性来区分各个item,其实很明显可以看出来,这个item的执行的action肯定是不同的,所以取其action属性来区分,最终的区分形式如下:

path(...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton)&actions(button1Click:)
path(...UIViewControllerWrapperView(0)_UIView(0)_UILayoutContainerView(0)_UINavigationBar(0)_UIButton)&actions(button2Click:)

这样,HubbleData就可以准确的区分不同的item了,同时实现同一种功能的item,由于其action相同,所以也会准确的标识其唯一性。

  • UIAlertController

由于不同的UIAlertController在选择确定、取消等选项时,选取的进行唯一层级路径判定的view需要进行一定的处理,同时为了保证不同的UIAlertController处于同一位置的选项的埋点EventID不同,这里在构造唯一标志字符串的时候还要加入该UIAlertController的message和title信息。3.5小节中会进行相关无埋点采集的介绍。

  • viewController的嵌套

一般情况下,普通的view只需按照一般的层次路径收集index即可,但是当存在pageViewController时,如下图所示分别给出了一个横向滚动(以公司考拉app为例)和纵向滚动(以公司严选app为例)的app的截图的示例:

不同类 不同类

其实可以看出,pageViewController会应用到各种各样app中,所以这类app在使用过程中的无埋点问题尤其要考虑。

(1) 各个子页面的controller不同?

如果pageViewController中的各个子页面不同,虽然后续2.2节HubbleData会加入页面controller的信息来区分这些不同的子页面,但是可能会由于每个子页面加入的顺序不同,导致每次app进来的时候同一个页面的事件会获取不同的EventID,举例来说明一下,如上图1所示,比如前四个子页面是ViewController1, ViewController2, ViewController3, ViewController4,这类pageViewController除非设置四个子页面同时预加载出来,那么此时的获取的层级路径为:

ViewController1对应路径为:superview(0)_subControllerView(0) 
ViewController2对应路径为:superview(0)_subControllerView(1)
ViewController3对应路径为:superview(0)_subControllerView(2)
ViewController4对应路径为:superview(0)_subControllerView(3)

但是app基本都不会预加载出所有页面,对于用户不感兴趣的页面完全没必要一次性全部加载处理,只有当用户选择了该条目时,该对应的子页面才会加载出来,如果现在用户点击的顺序是ViewController1,ViewController3,ViewController4,ViewController2,由于addChildViewController或者addSubView的顺序的改变,那么此时获取的层级路径为:

ViewController1对应路径为:superview(0)_subControllerView(0) 
ViewController2对应路径为:superview(0)_subControllerView(3)
ViewController3对应路径为:superview(0)_subControllerView(1)
ViewController4对应路径为:superview(0)_subControllerView(2)

可以发现,index值变了,层级路径不唯一了,那么无埋点采集的EventID可能会由于用户选择页面顺序的不同而不同,造成埋点数据的混乱。

HubbleData对于此类页面的处理是,遇到此类页面,即不用index标注,所以会统一的标识成:

ViewController1对应路径为:superview(0)_subControllerView 
ViewController2对应路径为:superview(0)_subControllerView
ViewController3对应路径为:superview(0)_subControllerView
ViewController4对应路径为:superview(0)_subControllerView

后续可以通过不同的页面的controller的类名获取其不同的唯一标识字符串。

(2) 各个子页面的controller相同?

其实做过此类页面的基本应该都熟悉,很多情况下子页面都是共用的,只不过是填入的model不同而已,那么遇到这种情况,如果是按照问题1的解决思路,即使按照2.2拿到了当前页面的controller,那么还是无法区分出这些页面,所以还是需要设置新的具有辨识度的index。

其实通过pageViewController可以发现,用户可以通过左右滑动或者上下滑动来切换子页面,说明所有的子页面都是嵌入在一个scrollView之中,那么就可以从这个scrollView入手,重新确定index。下面给出HubbleData解决这个问题的方法。

一开始想使用当前scrollView的contentOffset整除此pageViewController的页面宽度和高度所得到的值作为区分子页面的index,但是考虑到可能contentOffset的连续变化以及子页面横跨pageViewController整数倍宽度的边界时,可能会导致获取的index不唯一的情况,所以后来使用该子页面的起始位置整除pageViewController的相应地宽度和高度得到相应地index。具体的实现如下,其中controller为当前的页面:

1
2
3
4
5
6
if (view == controller.view || view == controller.view.superview) {
NSInteger index_x = view.center.x / [view superview].frame.size.width;
NSInteger index_y = view.center.y / [view superview].frame.size.height;
NSString *path = [NSString stringWithFormat:@"%@(indexx:%ld indexy:%ld)",
NSStringFromClass([view class]), index_x, index_y];
}

所以同样针对上述(1)所给出的四个ViewController1,优化后的到的唯一的标识为:

ViewController1对应路径为:superview(0)_subControllerView(indexx:0 indexy:0)
ViewController2对应路径为:superview(0)_subControllerView(indexx:1 indexy:0)
ViewController3对应路径为:superview(0)_subControllerView(indexx:2 indexy:0)
ViewController4对应路径为:superview(0)_subControllerView(indexx:3 indexy:0)

这样即使各个子页面的controller相同,也能通过优化后的index来区分各个不同的子页面。当然这种只是针对嵌套scrollView的子页面的情况,不过能解决大部分的该类问题,对于一些其他的特殊情况等,需详细分析页面布局进行分析。

2.2 当前页面controller的获取

看上去,大多数情况下2.1的view的层级结构path已经基本确定view的唯一标识字符串,但是普遍存在这么一种情况,当同一个页面跳转两个不同的页面时,假如这两个不同的页面上都取第一个按钮的层级路径,得到的简化后的结果都如下所示:

.../UINavigationTransitionView(0)/UIViewControllerWrapperView(0)/UIView(0)/UIButton(0)

是无法进行这两个页面上的按钮区分的,其实页面的类名是区分的一个最直接的方式。HubbleData是按照下面的方法获取某个view所在的controller的类名的。

1
2
3
4
5
6
7
8
9
10
11
+ (NSString *)currentController:(UIView *)view {
NSString *result = @"";
UIResponder *responder = [view nextResponder];
while (responder && ![responder isKindOfClass:[UIViewController class]]) {
responder = [responder nextResponder];
}
if (responder) {
result = NSStringFromClass([responder class]);
}
return result;
}

将view的层级路径结合当前页面的名称,已经能够解决掉大部分的唯一标识字符串的问题了。

这里需要注意的一点是,当页面类型一样,只是填充的model不同时,比如浏览商品详情时,所进入的页面都是一个,只是model不同,目前HubbleData对这种情况暂时未做处理。后续可参考文章3.2节UIViewController的无埋点采集,对一些页面,用户可以自定义诸如screenTitle的字段,定义该页面的名称,比如screenTitle包含产品唯一ID时,此时将该字段加入唯一标识字符串中即可区分。目前这块还未做相关处理,这里只是提供一个简单的解决思路。

三、无埋点的采集的实现

3.1 AOP 简介

下面讲一下无埋点的具体实现,用到的主要是AOP(Aspect-Oriented-Programming),面向切面编程,面对的是处理过程中的某个步骤和方法。在运行时,动态的将代码插入到类的制定方法、指定位置上的编程思想就是面向切面编程。熟悉iOS Runtime的应该很清楚,相关的介绍文章也很多,这里不再过多的赘述。

HubbleData无埋点的实现主要就是借助AOP,hook对应类的方法,并在原实现代码的基础上插入自己定义的埋点的代码,当该类的被hook的函数执行时,就能实现无埋点数据采集的功能。下面给出HubbleData里面Method Swizzling的一个简单的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ (void)swizzleSelector:(SEL)selector onClass:(Class)class withBlock:(swizzleBlock)block {
Method method = class_getInstanceMethod(class, selector);
IMP originalMethod = method_getImplementation(aMethod);//原函数的实现
IMP newMethod = (IMP)new_swizzledMethod;//新函数的实现
........ //省去一些预操作的处理
method_setImplementation(method, newMethod);//设置method指向newMethod的实现
........ //省去originalMethod、block等参数的处理操作
}
static void new_swizzledMethod(id self, SEL _cmd) {
........ //省去originalMethod、block等参数的处理操作
((void(*)(id, SEL))originalMethod)(self, _cmd); //先执行原有的函数
swizzleBlock block;
while ((block = [blocks nextObject])) { //再执行block
block(self, _cmd);
}
}

上述代码只是给出了一个简单的实现的逻辑结构,new_swizzledMethod也只是selector没有参数的情况(除去self和_cmd),真正在埋点的处理过程需要考虑的情况比较多。

3.2 UIViewController的无埋点采集

主要是收集页面的生命周期,这里HubbleData采用的是hook UIViewController的viewWillAppear方法,按照3.1给出的方式:

1
2
3
[DASwizzler swizzleBoolSelector:@selector(viewWillAppear:)
onClass:[UIViewController class]
withBlock:executeAppearBlock];

当viewWillAppear函数执行时,插入埋点的代码。HubbleData的设计方法为:

EventID设置为固定的da_screen,即不会通过EventID来区分各个页面的信息,HubbleData将各个页面的区分信息放在了properties中,其中properties的设置为:

(1) $screenName 为当前页面的名称;
(2) $screenTitle 为当前页面的title,可为空;

同时HubbleData SDK提供了一个protocol

1
2
3
4
5
6
7
8
9
10
11
12
@protocol DAScreenAutoTracker
@required
//返回当前页面的Title
-(NSString *)screenTitle;
@optional
//实现该Protocol的Controller对象可以通过接口向自动采集的事件中加入属性
-(NSDictionary *)trackProperties;
//返回当前页面的Url
-(NSString *)screenUrl;
@end

即用户可以通过实现该protocol,HubbleData SDK会将screenTitle返回的值作为页面的名称,trackProperties返回的属性加入对应页面的da_screen事件的属性中,作为用户访问该页面时的事件属性,screenUrl返回的字符串作为页面的Url,用于做一些页面之间相互跳转的分析等。

同时增加了白名单设置,有一些UIViewController的信息用户不想采集,可以通过设置白名单的方式,将一些不想采集的UIViewController过滤掉,比如说SFBrowserRemoteViewController,UIInputWindowController等系统自带的一些。

最后会调用trackEvent记录该采集的事件,同上述介绍的代码埋点一样,调用的方法如下:

[[DATracker sharedTracker] trackScreenEvent:@“da_screen” withAttributes:properties];

其中properties即为上述要采集的一些属性。

3.3 UIControl的无埋点采集

针对UIControl,HubbleData采用的是hook UIControl的sendAction:to:forEvent:方法。由官方文档可知,在UIControl执行对应的action时都会首先调用sendAction:to:forEvent:方法,实现如下:

1
2
3
[DASwizzler swizzleSendActionSelector:@selector(sendAction:to:forEvent:)
onClass:[UIControl class]
withBlock:executeBlock];

考虑到UIControl的子类较多,所以HubbleData选取了其中使用较多的几种进行了特殊的分析:主要是UITextField、UIButton和UISwitch,其余的暂时未做特殊分析。具体的埋点的采集设计为:

无论是哪种UIControl,EventID均采用的是第三部分介绍的唯一标识字符串的SHA256编码值,但是相关采集properties有所差别。

3.3.1 UITextField

UITextField是UIControl的一个子类,由于UITextField涉及到用户的隐私比较多,比如用户名、密码、聊天文本等,所以HubbleData不会对此类的UITextField进行埋点的采集。

HubbleData主要采集的是UISearchBar中的UITextField,即UISearchBarTextField,并获取搜索的文本内容,这对于一些电商类的App来说,能够较好的分析用户感兴趣的商品等,这是作为HubbleData SDK无埋点的一个需求。

hook住sendAction:to:forEvent:后,如果对UISearchBarTextField的所有actions都进行hook的话,那么_searchFieldBeginEditing、_searchFieldEndEditing等所有的action发生的时候都会进行数据的采集,会采集到很多无用的信息,导致采集的数据混乱。HubbleData SDK只有当_searchFieldEndEditing action发生时才会进行埋点,收集的properties为:

(1) type 为UIControl采集的事件类型,这里设置为searchBarEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) searchText 为_searchFieldEndEditing发生时采集到搜索框的搜索文字(此字段不为空);

这样就能对搜索框进行无埋点采集,并能收集搜索的文本内容。此方法只是在_searchFieldEndEditing发生时采集数据,有可能该action执行时并未尽兴真正的搜索操作,可能会与业务数据库的数据有出入,但是也能够较为准确的分析用户感兴趣的搜索内容。

3.3.2 UIButton

UIControl中使用最多最常见的是UIButton,因此对UIButton的采集非常重要。在使用UIButton的时候可以随意的设置其title等属性来表示业务逻辑的不同状态。这里可以举一个简单的例子:基本app的登录页面,在用户名和密码都未输入时、都输入时以及登录中各个状态,登录按钮的title、titleColor等属性可能都是不同的,即每一种button的样式都代表着一种样式,但是得到的EventID是相同的。针对此种情况,HubbleData会加入title、titleColor作为属性值,以方便后台进行进一步的分析。

当按钮的两种状态只是两种不同的背景图片时,比如微博或者微信的点赞等,其实是变换了一种背景图片,针对对这种情况处理,HubbleData则会获取图片的imageName作为其中一个属性。

(1) type 为UIControl采集的事件类型,这里设置为buttonEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) title 为当前按钮的title;
(4) titleColor 为当前title的color,会转换成字符串的形式,rgba(r, g, b, alpha);
(5) imageName 为当前按钮的背景图片的name;
(6) frame 为UIButton的frame,用于分析同类元素,会转换成字符串的形式,rect(x, y, width, height);

可以看出,HubbleData还采集了该view的frame信息,主要是用来分析同类元素用的,下图给出一个简单的示例:

bar1

目前有六个已关注的产品,当想统计用户所有点赞的事件时,由于每个点赞的按钮都处于一个UITableViewCell中,在前面介绍的获取层级唯一路径UITableViewCell时的特殊处理,由于每个按钮所在的cell的row不同,所以获得的每个按钮的事件的唯一EventID都是不同的,这样后端在分析的时候,无法归类同类元素。当HubbleData给出frame时,后端可以根据frame归类出同一类按钮的事件,具体的归类策略这里不再介绍。

3.3.3 UISwitch

类似于UIButton,只不过这里要采集switchState,即当前的开关状态,具体的采集属性为:

(1) type 为UIControl采集的事件类型,这里设置为switchEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) switchState 为switch的开关状态;

3.3.4 其余UIControl

其余的只是采集type,page属性,目前未做过多的处理。

3.4 UITableView和UICollectionView的无埋点采集

针对UITableView和UICollectionView,HubbleData采用的是先hook UITableView和UICoolectionView的setDelegate:方法,然后找到对应的delegate,然后再hook delegate类中的tableView:didSelectRowAtIndexPath:方法和UICollectionView的collectionView:didSelectItemAtIndexPath:方法。这里以UITableView为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
//先hook setDelegate:方法
[DASwizzler swizzleSelector:@selector(setDelegate:)
onClass:[UITableView class]
withBlock:executeSetDelegateBlock];
//再hook delegate的tableView:didSelectRowAtIndexPath:方法
void (^executeSetDelegateBlock)(id, SEL, id) = ^(id view, SEL command, id<UITableViewDelegate> delegate) {
if ([delegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)]) {
[DASwizzler swizzleSelector:@selector(tableView:didSelectRowAtIndexPath:)
onClass:[delegate class]
withBlock:executeBlock];
}
};

EventID按照上述介绍的方法获取,只不过这里要注意的是,获取的并不是UITableView的唯一标识字符串而是对应的点击的cell的唯一标识字符串。采集的properties为:

(1) type 为UITableView采集的事件类型,这里设置为tableViewSelectEvent;
(2) page 为当前页面的名称,用于前端显示用;
(3) section 为点击的cell所在的section;
(4) row 为点击的cell所在的row;

3.5 UIGestureRecognizer的无埋点采集

在iOS开发中,经常会使用一些手势来处理一些点击的操作,所以也有必要对UIGestureRecognizer进行hook。HubbleData 并不是直接针对UIGestureRecognizer这个类进行hook,而是hook UIView类的addGestureRecognizer:方法,实现如下:

1
2
3
4
5
6
7
8
9
10
11
void (^executeBlock)(id, SEL, id) = ^(id target, SEL command, id arg) {
if ([arg isKindOfClass:[UITapGestureRecognizer class]] ||
[arg isKindOfClass:[UILongPressGestureRecognizer class]]) {
[arg addTarget:self action:@selector(da_autoEventAction:)];//在本类下添加一个action的实现
...........
}
};
[DASwizzler swizzleSelector:@selector(addGestureRecognizer:)
onClass:[UIView class]
withBlock:executeBlock];

通过hook addGestureRecognizer:方法,可以得到该UIView所添加的UIGestureRecognizer,这里只对UITapGestureRecognizer和UILongPressGestureRecognizer进行处理,其他的手势暂未做处理。得到相应的UIGestureRecognizer,添加一个action,当该手势执行的时候,同样会执行该action,在action中执行埋点的操作。

这里获取的是UIGestureRecognizer所在的UIView的唯一标识标识字符串编码作为EventID,采集的属性为:

(1) type 为UIGestureRecognizer采集的事件类型,这里设置为gestureTapEvent;
(2) page 为当前页面的名称,用于前端显示用;

UIAlertController的特殊处理

这里需要对UIAlertController做一个详细的说明,因为UIAlertController在点击诸如取消、确定的选项按钮时,也会进行手势的埋点采集,但是在iOS9和iOS10上略微有些区别。

这里先以iOS9为例,其target是作用在_UIAlertControllerView这个系统的私有类上的,如果直接对这个_UIAlertControllerView进行唯一标识字符串的构造,则取消和确定选项得到的EventID是相同的,这样将无法准确的分析出用户的选择,所以必须以每个选项view作为单独的唯一标识字符串进行分析才能准确区分。通过获取_UIAlertControllerView的_actionViews变量,就能得到各个选项的view,这里要做一个简单的点击坐标获取,判断所点击的区域位于的actionView,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
if ([NSStringFromClass([view class]) isEqualToString:@"_UIAlertControllerView"]) {
Ivar ivar = class_getInstanceVariable([view class], "_actionViews");
NSMutableArray *actionviews = object_getIvar(view, ivar);
for (UIView *actionview in actionviews) {
CGPoint point = [gesture locationInView:actionview];
if ([NSStringFromClass([actionview class]) isEqualToString:@"_UIAlertControllerActionView"] &&
point.x > 0 && point.x < CGRectGetWidth(actionview.bounds) &&
point.y > 0 && point.y < CGRectGetHeight(actionview.bounds) &&
gesture.state == UIGestureRecognizerStateBegan) {
......... //进行埋点操作
}
}
}

这里在条件判断时设定gesture.state == UIGestureRecognizerStateBegan,是由于UILongPressGestureRecognizer会连续两次调用action,因此这里需要加入事件的状态进行区分,防止进行两次相同的数据采集。

iOS10下的UIAlertController的内部实现做了一些改动,其target变换成在_UIAlertControllerInterfaceActionGroupView这个系统的私有类上的,然后需要进行一定的处理,获取UIInterfaceActionSelectionTrackingController的_representationViews变量,遍历得到各个选项的view,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if ([NSStringFromClass([view class]) isEqualToString:@"_UIAlertControllerInterfaceActionGroupView"]) {
NSMutableArray *targets = [gesture valueForKey:@"_targets"];
id targetContainer = targets[0];
id targetOfGesture = [targetContainer valueForKey:@"_target"];
if ([targetOfGesture isKindOfClass:[NSClassFromString(@"UIInterfaceActionSelectionTrackingController") class]]) {
Ivar ivar = class_getInstanceVariable([targetOfGesture class], "_representationViews");
NSMutableArray *representationViews = object_getIvar(targetOfGesture, ivar);
for (UIView *representationView in representationViews) {
CGPoint point = [gesture locationInView:representationView];
if ([NSStringFromClass([representationView class]) isEqualToString:@"_UIInterfaceActionCustomViewRepresentationView"] &&
point.x > 0 && point.x < CGRectGetWidth(representationView.bounds) &&
point.y > 0 && point.y < CGRectGetHeight(representationView.bounds) &&
gesture.state == UIGestureRecognizerStateBegan) {
......... //进行埋点操作
}
}
}
}

通过上述的分析可以发现,这样虽然能区分同一个UIAlertController的不同的操作选项,但是可能无法区分出不同UIAlertController的处于同一位置的选项,所以这里还要加入UIAlertController额外的属性信息来区分。

前面也有提过,可以很容易的想到UIAlertController的message和title能够较好的进行区分,所以在原有的层级路径和当前页面的基础上,还要加上message和title以构成唯一标识字符串。给出一个样例:

path(UIWindow(0)__UIAlertControllerView(0)_UIView(0)_UIView(0)_UIView(0)_UICollectionView(0)__UIAlertControllerCollectionViewCell(section:0 item:0)_UIView(0)__UIAlertControllerActionView(0))&controller(UIAlertController)&message(确认退出群聊吗?)&title(退群)

四、总结

文章主要介绍了HubbleData无埋点SDk在iOS端的设计与实现,涉及的主要内容:事件唯一ID的确定和部分无埋点的实现,当然在无埋点SDK的设计开发中还遇到了各种各样的问题。鉴于文章的篇幅已经较长,一些问题的解决以及关键技术的实现,比如精准渠道追踪、hook冲突解决、代码埋点的实现、屏幕序列化以及可视化圈选部分的内容,本篇文章不再介绍,将会在后续文章中继续介绍。

鉴于当前无埋点SDK仍在不断改进和完善中,待相关功能完善,将考虑开源HubbleData的无埋点SDK设计。由于一些原因,文章介绍的很多东西可能仍有待改进,欢迎大家一起探讨提高。