王尘宇王尘宇

研究百度干SEO做推广变成一个被互联网搞的人

iOS 最优无痕埋点方案

iOS 最优无痕埋点方案

view层级

可以看出仅仅由于其中某个子 view 的改变,却导致其它子 view 的深度都发生了变化。因此,在设计的时候需要注意,在新增/移除某一 view 时,尽量减少对已有 view 的深度的影响,调整了对节点的深度的计算方式:采用当前 view 位于其父 view 中的所有 与当前 view 同类型 子view 中的索引值。

我们再看一下上面的这个例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除后,button1、button2 的深度依次为:0、1。可以看出,在这个例子中,label 的移除并未对 button1、button2 的深度造成影响,这种调整后的计算方式在一定程度上增强了 xpath 的抗干扰性。

另外,调整后的深度的计算方式是依赖于各节点的类型的,因此,此时必须要将各节点的名称放到viewPath中,而不再是仅仅为了增加可读性。

在标识控件元素的层级时,需要知道「当前 view 位于其父 view 中的所有 与当前 view 同类型 子view 中的索引值」。参看上图,如果不是同类型的话,则唯一性得不到保证。

5. 同类型的 view 的唯一定位

有个问题,比如我们点击的元素是 UITableViewCell,那么它虽然可以定位到类似于这个标示 xxApp.GoodsViewController.GoodsTableView.GoodsCell,同类型的 Cell 有多个,所以单凭借这个字符串是没有办法定位具体的那个 Cell 被点击了。

当然有解决方案啦。

  • 找出当前元素在父层同类型元素中的索引。根据当前的元素遍历当前元素的父级元素的子元素,如果出现相同的元素,则需要判断当前元素是所在层级的第几个元素

    对当前的控件元素的父视图的全部子视图进行遍历,如果存在和当前的控件元素同类型的控件,那么需要判断当前控件元素在同类型控件元素中的所处的位置,那么则可以唯一定位。举例:GoodsCell-3.GoodsTableView.GoodsViewController.xxApp

 //UIResponder分类
- (NSString *)lbp_identifierKa
{
//    if (self.xq_identifier_ka == nil) {
        if ([self isKindOfClass:[UIView class]]) {
            UIView *view = (id)self;
            NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath];
            NSMutableString *str = [NSMutableString string];
            //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode
            NSString *className = [NSString stringWithUTF8String:object_getClassName(view)];
            if (!view.accessibilityIdentifier || [className isEqualToString:@"lbpButton"]) {
                [str appendString:sameViewTreeNode];
                [str appendString:@","];
            }
            while (view.nextResponder) {
                [str appendFormat:@"%@,", NSStringFromClass(view.class)];
                if ([view.class isSubclassOfClass:[UIViewController class]]) {
                    break;
                }
                view = (id)view.nextResponder;
            }
            self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@"%@",str]];
            //            self.xq_identifier_ka = [NSString stringWithFormat:@"%@",str];
        }
//    }
    return self.xq_identifier_ka;
}
 
// UIView 分类
- (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat
{    
    NSString *classStr = NSStringFromClass([self class]);
    //cell的子view
    //UITableView 特殊的superview (UITableViewContentView)
    //UICollectionViewCell
    BOOL shouldUseSuperView =
    ([classStr isEqualToString:@"UITableViewCellContentView"]) ||
    ([[self.superview class] isKindOfClass:[UITableViewCell class]])||
    ([[self.superview class] isKindOfClass:[UICollectionViewCell class]]);
    if (shouldUseSuperView) {
        return [self obtainIndexPathByView:self.superview];
    }else {
        return [self obtainIndexPathByView:self];
    }
}
 
- (NSString *)obtainIndexPathByView:(UIView *)view
{    
    NSInteger viewTreeNodeDepth = NSIntegerMin;
    NSInteger sameViewTreeNodeDepth = NSIntegerMin;
    
    NSString *classStr = NSStringFromClass([view class]);
   
    NSMutableArray *sameClassArr = [[NSMutableArray alloc]init];
    //所处父view的全部subviews根节点深度
    for (NSInteger index =0; index < view.superview.subviews.count; index ++) {
        //同类型
        if  ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){
            [sameClassArr addObject:view.superview.subviews[index]];
        }
        if (view == view.superview.subviews[index]) {
            viewTreeNodeDepth = index;
            break;
        }
    }
    //所处父view的同类型subviews根节点深度
    for (NSInteger index =0; index < sameClassArr.count; index ++) {
        if (view == sameClassArr[index]) {
            sameViewTreeNodeDepth = index;
            break;
        }
    }
    return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth];

    复制代码
}

iOS 最优无痕埋点方案

页面唯一标识示意图

6. 同类型的view,但是点击的意义却不一样。如何唯一标识?

问题5说明的是在一个界面上有多个不同的 view,他们的类型是同一种(CycleBannerView,但是数据源不一样,那么当数据源长度大于1的时候会轮播,下面会展示 UIPageControl。如果数据源是1个,那么就不会轮播和展示 UIPageControl)。情况6是同一种类型的 View,但是根据展示的内容不一样,点击的意义也不一样。也就是运营需要去知道用户到底点击的是哪一个。如下图所示,「立即抢购」和「分享赚佣金」是同一种类型的 View,但是点击意义不一样,需要我们需要唯一标识出来。之前的方法通过 “viewPath 配合同类型的 view 去加索引值“ 的方式还是没有办法唯一标识出来。所以想到一个方案,给 NSObject 添加一个分类,在分类里面添加一个协议。让需要复用但需要唯一标识的 view 去实现协议方法,因为是给 NSObject 分类添加的协议,所以 view 不需要去指定遵循。

iOS 最优无痕埋点方案

image

关键步骤:

  • 添加 NSObject 的 Category。在分类里面声明唯一标识的协议

  • 在生成 viewPath 的地方去拿出当前 view 的唯一标识(view 调用协议方法)。然后拼接之前拿出的 viewPath

//NSObject+UniqueIdentify.h
#import <Foundation/Foundation.h>
 
NS_ASSUME_NONNULL_BEGIN
 
@class NSObject;
@protocol UniqueIdentify<NSObject>
 
@optional
- (NSString *)setUniqueIdentifier;
 
@end
 
@interface NSObject (UniqueIdentify)<UniqueIdentify>
 
@end
 
NS_ASSUME_NONNULL_END
    
//NSObject+UniqueIdentify.m
#import "NSObject+UniqueIdentify.h"
 
@implementation NSObject (UniqueIdentify)
 
@end
复制代码
//MallTGoodTagView.h
 
extern NSString * _Nonnull const ImmediateyPurchase;
extern NSString * _Nonnull const ShareToAward;
 
//MallTGoodTagView.m
NSString *const ImmediateyPurchase = @"立即抢购";
NSString *const ShareToAward = @"分享赚佣金";
 
- (NSString *)setUniqueIdentifier
{
    if (self.tagString) {
        return self.tagString;
    } else {
        return NSStringFromClass([self class]);
    }
}
复制代码
//UIResponder Category 生成 viewPath
- (NSString *)lbp_identifierKa
{
//    if (self.xq_identifier_ka == nil) {
        if ([self isKindOfClass:[UIView class]]) {
            UIView *view = (id)self;
            NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath];
            NSMutableString *str = [NSMutableString string];
            //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode
            NSString *className = [NSString stringWithUTF8String:object_getClassName(view)];
            if (!view.accessibilityIdentifier || [className isEqualToString:@"lbpButton"]) {
                [str appendString:sameViewTreeNode];
                [str appendString:@","];
            }
            while (view.nextResponder) {
                 if ([view respondsToSelector:@selector(setUniqueIdentifier)]) {
                    NSString *unqiueIdentifier = [view setUniqueIdentifier];
                    if (unqiueIdentifier) {
                        [str appendFormat:@"%@,", unqiueIdentifier];
                    }
                }00
                [str appendFormat:@"%@,", NSStringFromClass(view.class)];
                if ([view.class isSubclassOfClass:[UIViewController class]]) {
                    break;
                }
                view = (id)view.nextResponder;
            }
            self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@"%@",str]];
            //            self.xq_identifier_ka = [NSString stringWithFormat:@"%@",str];
        }
//    }
    return self.xq_identifier_ka;
}
复制代码

iOS 最优无痕埋点方案

改进版view唯一标识:立即抢购

iOS 最优无痕埋点方案

改进版view唯一标识:分享赚佣金

7. 数据如何处理

A. 如何处理业务数据

利用系统提供的 accessibilityIdentifier 官方给出的解释是标识用户界面元素的字符串

/*

A string that identifies the user interface element.

default == nil

*/

@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier NS_AVAILABLE_IOS(5_0);

服务端下发唯一标识

接口获取的数据,里面有当前元素的唯一标识。比如在 UITableView 的界面去请求接口拿到数据,那么在在获取到的数据源里面会有一个字段,专门用来存储动态化的经常变动的业务数据。

cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categories[indexPath.section] children][indexPath.row].spmContent yy_modelToJSONString];
复制代码

B. 基础数据

设计上分为2个 pod 库,一个是 TriggerKit(专门用来 hook 机会需要的所有事件,页面停留时间、页面标识、view标识),另一个是 Appmonitor(专门用来提供基础数据、埋点数据的维护、上传机制)。所以在 Appmonitor 里面有个类叫做 UserTrackDataCenter 的类,专门提供一些基础数据(系统版本、操作系统、地理位置、网络等信息)。

对外暴露出一些方法,用来将埋点数据交给 Appmonitor 去维护埋点数据,达到合适的“机制”再去上传埋点数据到服务端。

+ (void)clickEventUuid:(NSString *)uuid otherParam:(NSDictionary *)otherParam spmContent:(NSDictionary *)spmContent {
    if (uuid) {
        NSMutableDictionary *params = [[NSMutableDictionary alloc] initWithDictionary:otherParam];
        params[SDGStatisticEventtagKey] = @"clickMonitorV1";
        NSMutableDictionary *valueDict = [[NSMutableDictionary alloc] initWithDictionary:spmContent];
        valueDict[@"xpath"] = uuid?:@"";
        params[SDGStatisticEventtagValue] = valueDict?:@{};
        [[AppMonotior shareInstance] traceEvent:[AMStatisticEvent eventWithInfo:params]];
    }
}
复制代码

8. 数据的上报

数据通过上面的办法收集完了,那么如何及时、高效的上传到后端,给运营分析、处理呢?

App 运行期间用户会点击非常多的数据,如果实时上传的话对于网络的利用率较低,所以需要考虑一个机制去控制用户产生的埋点数据的上传。

思路是这样的。对外部暴露出一个接口,用来将产生的数据往数据中心存储。用户产生的数据会先保存到 AppMonitor 的内存中去,设置一个临界值(memoryEventMax = 50),如果存储的值达到设置的临界值 memoryEventMax,那么将内存中的数据写入文件系统,以 zip 的形式保存下来,然后上传到埋点系统。如果没有达到临界值但是存在一些 App 状态切换的情况,这时候需要及时保存数据到持久化。当下次打开 App 就去从本地持久化的地方读取是否有未上传的数据,如果有就上传日志信息,成功后删除本地的日志压缩包。

App 应用状态的切换策略如下:

  • didFinishLaunchWithOptions:内存日志信息写入硬盘
  • didBecomeActive:上传
  • willTerimate:内存日志信息写入硬盘
  • didEnterBackground:内存日志信息写入硬盘

下面的代码是 App 埋点数据的保存与上传

// 将App日志信息写入到内存中。当内存中的数量到达一定规模(超过设置的内存中存储的数量)的时候就将内存中的日志存储到文件信息中
- (void)joinEvent:(NSDictionary *)dictionary
{
    if (dictionary) {
        NSDictionary *tmp = [self createDicWithEvent:dictionary];
        if (!s_memoryArray) {
            s_memoryArray = [NSMutableArray array];
        }
        [s_memoryArray addObject:tmp];
        if ([s_memoryArray count] >= s_flushNum) {
            [self writeEventLogsInFilesCompletion:^{
                [self startUploadLogFile];
            }];
        }
    }
}
 
// 外界调用的数据传递入口(App埋点统计)
- (void)traceEvent:(AMStatisticEvent *)event
{
    // 线程锁,防止多处调用产生并发问题
    @synchronized (self) {
        if (event && event.userInfo) {
            [self joinEvent:event.userInfo];
        }
    }
}
 
// 将内存中的数据写入到文件中,持久化存储
- (void)writeEventLogsInFilesCompletion:(void(^)(void))completionBlock
{
    NSArray *tmp = nil;
    @synchronized (self) {
        tmp = s_memoryArray;
        s_memoryArray = nil;
    }
    if (tmp) {
        __weak typeof(self) weakSelf = self;
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            NSString *jsonFilePath = [weakSelf createTraceJsonFile];
            if ([weakSelf writeArr:tmp toFilePath:jsonFilePath]) {
                NSString *zipedFilePath = [weakSelf zipJsonFile:jsonFilePath];
                if (zipedFilePath) {
                    [AppMonotior clearCacheFile:jsonFilePath];
                    if (completionBlock) {
                        completionBlock();
                    }
                }
            }
        });
    }
}
 
// 从App埋点统计压缩包文件夹中的每个压缩包文件上传服务端,成功后就删除本地的日志压缩包
- (void)startUploadLogFile
{
    NSArray *fList = [self listFilesAtPath:[self eventJsonPath]];
    if (!fList || [fList count] == 0) {
        return;
    }
    [fList enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
        if (![obj hasSuffix:@".zip"]) {
            return;
        }
        
        NSString *zipedPath = obj;
        unsigned long long fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:zipedPath error:nil] fileSize];
        if (!fileSize || fileSize < 1) {
            return;
        }
        // 调用接口上传埋点数据
        [self uploadZipFileWithPath:zipedPath completion:^(NSString *completionResult) {
            if ([completionResult isEqual:@"OK"]) {
                [AppMonotior clearCacheFile:zipedPath];
            }
        }];
    }];
}
复制代码

使用的时候就是在 hook 系统事件的时候,去调用统计页面上传数据

//UIViewController
[UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer];  // 页面出现
[UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]];    //页面消失
复制代码

iOS 最优无痕埋点方案

绑定页面唯一标识与功能描述的对应关系

总结下来关键步骤:

  1. hook 系统的各种事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 应用程序、控制器生命周期。在做本来的逻辑之前添加额外的监控代码
  2. 对于点击的元素按照视图树生成对应的唯一标识(addCartButton.GoodsView.GoodsViewController) 的 md5 值
  3. 在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App层级、业务模块、关键页面、关键操作)给绑定起来。比如 addCartButton.GoodsView.GoodsViewController.tbApp 对应了 tbApp-商城模块-商品详情页-加入购物功能。
  4. 将所需要的数据存储下来
  5. 设计机制等到合适的时机去上传数据

举例说明一个完整的埋点上报流程

埋点模块分为2个pod组件库,TriggerKit 负责拦截系统事件,拿到埋点数据。Appmonitor 负责收集埋点数据,本地持久化或内存储存,等到合适时机去上传埋点数据。

  1. 通过接口获取数据,给对应的 view 的 accessibilityIdentifier 属性绑定埋点数据

    iOS 最优无痕埋点方案

    接口拿到的数据

    iOS 最优无痕埋点方案

    绑定埋点数据到view

  2. hook 系统事件,点击拿到 view,获取 accessibilityIdentifier 属性值

    iOS 最优无痕埋点方案

    hook系统事件获取accessibilityIdentifier

  3. 将数据向的数据中心发送,数据中心处理数据(埋点数据结合App基础信息,图上 UserTrackDataCenter 对象)。根据情况将数据存储到内存或者本地,等到合适的时机去上传

    iOS 最优无痕埋点方案

    拦截系统事件后将数据交给数据中心处理

原文作者:虚心学习的HZK
原文地址:http://002ii.cn/wX76p

相关文章

评论列表

发表评论:
验证码

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。