一、定义
APP埋点自动采集是指用户在APP内的操作行为自动采集并上报日志,其表现在APP上的元素(按钮、图片等)的行为主要分为点击和曝光行为。其中曝光意为该元素在可视区域停留时长达到一定阈值,即标记为一次曝光行为。本文主要定位为对iOS端内部自动采集技术的原理剖析。
二、核心原理
对于曝光行为与点击行为的检测原理是完全不同的,接下来会从两种不同的行为检测机制开始介绍.
2.1 曝光检测原理
2.1.1 自动曝光相关概念
1.有效曝光:视图元素在有效曝光面积的情况下,停留时长达到有效的曝光时长,才能算一次有效曝光
2.曝光面积::视图元素在window显示的面积
3.曝光时长:视图元素由显示到隐藏的时间间隔
4.业务参数:业务自定义参数
2.1.2 判断一个view是否显示的几个关键因素
1.hidden,layer.hidden:显而易见,这个属性代表view是否显示.
2.frame:frame的修改,也会决定view是否被看见,如果已经移除window外了,则判断为代表隐藏;
3.alpha:alpha为0,则为隐藏
所以简单的判断条件如下
+ (BOOL)isViewVisible:(UIView *)view{
if (!view.window || view.hidden || view.layer.hidden || !view.alpha) {
return NO;
}
}
4.view面积的计算是较复杂的逻辑: 如果面积大于exposureAreaThreshold(自定义有效面积),则代表视图显示
CGRect viewRectInWindow = [view convertRect:view.bounds toView:view.window];
CGRect intersectRect = CGRectIntersection(view.window.bounds, viewRectInWindow);
if (intersectRect.size.width != 0.f && intersectRect.size.height != 0.f) {
// modify size threshold,80%
CGFloat areaThreshold = exposureAreaThreshold;
CGFloat areaReal = intersectRect.size.width * intersectRect.size.height / (viewRectInWindow.size.width * viewRectInWindow.size.height);
if (areaReal >= areaThreshold) {
return YES;
} else{
return NO;
}
}
2.1.3 自动曝光检测时机
由上述的几个关键因素很容易得出曝光检测的时机:
检测hidden:
-[UIView setHidden:]
检测Alpha
-[UIView setAlpha:]
检测window
-[UIView didMoveToWindow]
检测显示面积
-[UIView setFrame:]
-[UIScrollView setContentOffset:];
因此我们会通过Swizzle上述几个方法,作为view曝光检测的时机
@implementation UIView (ViewExposure)
+ (void)doSwizzleForUTViewExposure {
//for UIView's hidden
[HookRegister swizzleInstanceMethod:@selector(setHidden:) withSelector:@selector(exposure_setHidden:) withInstance:self];
[HookRegister swizzleInstanceMethod:@selector(setFrame:) withSelector:@selector(exposure_setFrame:) withInstance:self];
//for UIViewController's switch
[HookRegister swizzleInstanceMethod:@selector(didMoveToWindow) withSelector:@selector(exposure_didMoveToWindow) withInstance:self];
[HookRegister swizzleInstanceMethod:@selector(setCenter:) withSelector:@selector(exposure_setCenter:) withInstance:self];
}
#pragma mark - swizzle method
-(void)exposure_setHidden:(BOOL)hidden {
BOOL orig = self.hidden;
[self utexposure_setHidden:hidden];
if (orig != hidden) {
//..做曝光检测的判断
}
}
-(void)exposure_setFrame:(CGRect)frame {
CGRect orig = self.frame;
[self utexposure_setFrame:frame];
if(!CGRectEqualToRect(frame, orig)){
//..做曝光检测的判断
}
}
-(void)exposure_didMoveToWindow {
[self utexposure_didMoveToWindow];
//..做曝光检测的判断
}
- (void)exposure_setCenter:(CGPoint)center {
[self utexposure_setCenter:center];
//..做曝光检测的判断
}
@end
另外[UIScrollView setContentOffset:],由于滚动会比较频繁,为了性能优化,额外加入检测间隔的判断, 为scroll记录上次检测时间戳.
static const char* kLastLayoutDate = "lastLayoutDate";
@implementation UIScrollView (ViewExposure)
+ (void)doSwizzleForUTViewExposure {
[HookRegister swizzleInstanceMethod:@selector(setContentOffset:) withSelector:@selector(exposure_setContentOffset:) withInstance:self];
}
-(NSDate*)lastLayoutDate {
return objc_getAssociatedObject(self, kLastLayoutDate);
}
-(void)setLastLayoutDate:(NSDate*)date {
objc_setAssociatedObject(self, kLastLayoutDate, date, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (void)exposure_setContentOffset:(CGPoint)contentOffset {
[self exposure_setContentOffset:contentOffset];
if ([self lastLayoutDate] &&
([[NSDate date] timeIntervalSince1970] - [[self lastLayoutDate] timeIntervalSince1970])*1000 < (exposureTimeThreshold / 2)) {
return;
}
[self setLastLayoutDate:[NSDate date]];
//..做曝光检测的判断
}
@end
2.2 点击检测原理
点击逻辑会简单许多,依然基于swizzle,只需对touches和gesture做拦截,然后插入指定的点击埋点逻辑
@implementation UIView (TouchHelper)
+ (void)loadTouchHelper{
SwizzleInstanceMethod([self class], NSSelectorFromString(@"touchesEnded:withEvent:"),Arguments(NSSet* touchs,UIEvent * event), SWReplacement({
CallOriginal(touchs, event);
[self touchesEnded:touchs withEvent:event];
}));
SwizzleInstanceMethod([self class], NSSelectorFromString(@"addGestureRecognizer:"), RSSWReturnType(void), RSSWArguments(UIGestureRecognizer * gestureRecognizer), RSSWReplacement({
CallOriginal(gestureRecognizer);
[self addGestureRecognizer:gestureRecognizer];
}));
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if(![self isKindOfClass:[UISegmentedControl class]] &&
![self isKindOfClass:[UISwitch class]] &&
![self isKindOfClass:[UISlider class]]){
//..做点击埋点逻辑
}
}
- (void)addGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer {
if ([gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]] ||
[gestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]]) {
[gestureRecognizer addTarget:self action:@selector(autoEventAction:)];
}
}
- (void)autoEventAction:(UIGestureRecognizer *)gestureRecognizer {
if(![self isKindOfClass:[UISegmentedControl class]] &&
![self isKindOfClass:[UISwitch class]] &&
![self isKindOfClass:[UISlider class]]){
//..做点击埋点逻辑
}
}
@end
三、实践
下面将具体介绍如何借助上述的埋点检测时机实现自动曝光和自动点击.
3.1 如何实现曝光
曝光的实现逻辑核心在于如何持续性监听某个控件的可见状态。依托于上述的曝光检测时机,可实现对于View的持续状态的持续性监控。对于每个可见的控件而言,需要记录其曝光的整个生命周期,包括从开始曝光->持续曝光->结束曝光。其中,整个生命周期需要建立在基础的曝光规则之上,即达到可见面积≥50%,可见时长≥500ms才为合规的曝光。因此,一旦控件从不可见状态转变为可见状态时,我们将记录其当前可见状态的面积和可见时间点,当触发view曝光检测逻辑,需要对已有的曝光控件的状态进行更新,具体更新规则可见如下源码:
对不同的检测情况选择是否需要对子view遍历,如hidden、alpha、scrollview的检测需要遍历子view,而window则不需要
+ (void)findDestViewInSubviewsAndAdjustStateOn:(UIView *)view recursive:(BOOL)recursive{
if ([view ignoreExposure] ) {
return;
}
if (recursive) {
NSMutableArray *subViews = [ViewTrackerManager getSubViews:view];
for (UIView *subview in subViews) {
[self findDestViewInSubviewsAndAdjustState:subview];
}
}else{
[self findDestViewInSubviewsAndAdjustState:view];
}
}
view当前显示状态的检测
+ (void)findDestViewInSubviewsAndAdjustStatec:(UIView *)view{
// 如果这个view已经进行过表光计算并属于有效曝光,则不再进行曝光计算
if ([[ExposureManager shareInstance].exposedDataKeys containsObject:[self exposedKeyForView:view]) {
return;
}
//在此计算曝光面积和透明度是否达到阈值
BOOL visible = [self isViewVisible:view];
UTViewVisibleType state = visible ? UTViewVisibleTypeVisible : UTViewVisibleTypeInvisible;
[self setState:state forView:view];
}
根据view显示状态的变化生成对应的日志或者对应的操作
+ (void)setState:(ViewVisibleType)state forView:(UIView *)view{
if (state == view.ExposureShowing) return;
// if view has controlName, recode to map.
if (view.autoExposureControlName && view.autoExposureViewIndex) {
// start visible,exposure begin.
if (view.exposureShowing != ViewVisibleTypeVisible && state == ViewVisibleTypeVisible) {
// get pageName after exposure end.
[self viewBecomeVisible:view];
}
// exposure end.
else if(view.exposureShowing == ViewVisibleTypeVisible && state == ViewVisibleTypeInvisible){
[self viewBecomeInVisible:view];
}
view.exposureShowing = state;
}
}
一旦曝光控件达到曝光时长及曝光面积限制,并且当前控件已从可见态转为不可见状态时,将提交缓存的曝光控件信息,调用采集SDK接口上报曝光日志。其核心逻辑实现流程图如下:
自动曝光大致整体流程
3.2 如何实现点击
自动点击的逻辑比较简单,只需要在点击检测的逻辑中读取相应视view绑定的业务参数即可
大概的代码逻辑如下
@implementation UIView (TouchHelper)
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if(![self isKindOfClass:[UISegmentedControl class]] && ![self isKindOfClass:[UISwitch class]] && ![self isKindOfClass:[UISlider class]])
{
[[CollectionManager sharedInstance] collectClick:self];
}
}
@end
@implementation ATCollectionManager
-(BindInfo *)collectClick:(UIView *)view {
// if sdk disable
if (![[SyncManager sharedInstance] isSDKEnabled]) {
return nil;
}
NSString *parentVc = [view parentVC];
NSString *identifier = [view identifer];
if ([Utils isEmptyString:identifier]) {
return nil;
}
dao *dao = [Dao shareInstance];
Event *event = [dao getEvent:parentVc identifer:identifier];
if (event == nil) {
return nil;
}
BindInfo* bindInfo = nil;
ClickManager* clickMgr = [ClickManager sharedInstance];
if (![clickMgr isValid:view event:event]) {
return bindInfo;
}
bindInfo = [clickMgr collect:view event:event];
// call assemble manager
[[AssemblyManager sharedInstance] assemble:bindInfo];
return bindInfo;
}
@end
四、总结
自动采集和自动曝光技术实现手段较多,但每种实现类型差异也较大,尤其是自动曝光技术对UI性能影响比较大,可以针对具体的业务场景作出对应的性能优化手段.一般来说大部分优化的空间都是在视图的遍历条件上,比如减少视图扫描的层级、不支持子view曝光、用空间换时间,用弱引用存储需要扫描的视图等等,最合适的业务场景方案才是最完美的方案.