
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];
复制代码
}

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

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;
}
复制代码

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

改进版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]]; //页面消失
复制代码

绑定页面唯一标识与功能描述的对应关系
总结下来关键步骤:
- hook 系统的各种事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 应用程序、控制器生命周期。在做本来的逻辑之前添加额外的监控代码
- 对于点击的元素按照视图树生成对应的唯一标识(addCartButton.GoodsView.GoodsViewController) 的 md5 值
- 在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App层级、业务模块、关键页面、关键操作)给绑定起来。比如 addCartButton.GoodsView.GoodsViewController.tbApp 对应了 tbApp-商城模块-商品详情页-加入购物车功能。
- 将所需要的数据存储下来
- 设计机制等到合适的时机去上传数据
举例说明一个完整的埋点上报流程
埋点模块分为2个pod组件库,TriggerKit 负责拦截系统事件,拿到埋点数据。Appmonitor 负责收集埋点数据,本地持久化或内存储存,等到合适时机去上传埋点数据。
-
通过接口获取数据,给对应的 view 的 accessibilityIdentifier 属性绑定埋点数据

接口拿到的数据

绑定埋点数据到view
-
hook 系统事件,点击拿到 view,获取 accessibilityIdentifier 属性值

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

拦截系统事件后将数据交给数据中心处理
原文作者:虚心学习的HZK
原文地址:http://002ii.cn/wX76p

评论列表