头图

1. 前言

在介绍元素标识之前,先回顾一下之前的《可视化全埋点系列文章之功能介绍篇》,根据这篇文章我们了解到:可视化全埋点事件,是通过可视化的方式,把某些全埋点事件创建成一个重新命名的虚拟事件[1],进而从数量庞大的全埋点事件中快速筛选到我们所关心的事件[2]。

那么问题来了,如何将 SDK 触发的元素点击事件 $AppClick 和前端定义的可视化全埋点事件进行匹配?也就是说,保存了哪些配置信息,就可以唯一标识一个元素,从而筛选出这个元素触发的 $AppClick 事件?

答案是:只有准确地进行元素标识,我们才能将可视化全埋点创建的虚拟事件和元素进行匹配,从而准确筛选出这个元素触发的 $AppClick 事件。
下面,我们就来看下可视化全埋点中如何进行元素标识。

2. 什么是元素标识

2.1. 概念

所谓元素标识,也就是通过某些信息,可以唯一标识某个元素。根据概念,需要实现两点:
根据信息,能够筛选出所需的元素,不会遗漏;
根据信息,只能筛选出所需的元素,不会多查。
例如:根据某些关键信息,可以唯一定位 App 中商品详情页面的 “加入购物车” 这个按钮元素。需要保证既不会匹配成 “立即购买” 按钮,也不会匹配成商品列表或其他页面的 “加入购物车” 按钮。如图 2-1 所示:

图 2-1 商品详情页面的 “加入购物车” 按钮

2.2. 常见问题

保证元素的唯一标识,意味着在各种复杂环境,都需要保证元素的唯一匹配。在实际项目开发中,会面临一些常见问题:

2.2.1. 系统兼容

采用的元素标识方案,需要兼容不同的系统。例如:iOS14 使用的标识信息,在其他系统也能唯一标识这个元素。

2.2.2. 设备兼容

元素标识需要兼容不同的设备,设备差异对元素标识的主要影响是屏幕尺寸大小。在 iOS 上,大多数 App 开发都会采用自动布局进行屏幕适配。也就是说,可能同一个元素在 iPhone5s 显示在屏幕最底部,但是在 iPhone11 Pro Max 就可能显示在屏幕中间。对于不同的设备,需要可以使用同一套标识信息进行唯一匹配。

2.2.3. 样式改变

上文提到的唯一标识某个元素,是相对于用户交互而言的。随着 App 版本升级,元素的显示样式可能会发生变化,包括但不限于:
把按钮的颜色从 “红色” 改成 “蓝色”;
把文字内容从 “加入购物车” 改成 “添加购物车”;
把按钮形状从圆角改成矩形;
移动按钮的位置。
虽然显示样式发生了变化,但是对于当前 App 的用户而言,点击的都还是 “添加商品到购物车” 这个按钮。至于形状或颜色,用户并不关注。
用户行为采集是为了支撑业务分析,从业务的角度来看,因为这些样式变化都没有改变这个元素的业务功能(例如把一个商品添加到购物车),所以也不影响最终的分析结果。
因此,即使元素的样式变化了,之前存储的标识信息,还可以继续标识这个元素。

3. 如何进行元素标识

了解元素标识的概念和常见问题之后,我们来看下进行元素标识的方案有哪些。

3.1. Target + Action + Tag

3.1.1. 方案说明

在 iOS 开发中,针对一个元素(例如 UIButton)添加点击事件,一般的实现方法如下:
MyViewController.m

- (void)viewDidLoad {
    [super viewDidLoad];
 
    // 省略其他逻辑
    [self.myButton addTarget:self action:@selector(onButtonClick:) forControlEvents:UIControlEventTouchUpInside];
}
 
- (IBAction)onButtonClick:(UIButton *)sender {
    // 按钮点击的业务实现
}

针对 UIButton,只有存在 target-action,才有可能触发点击事件。
一般来说,某个元素添加 target-action 创建点击事件后,这时候 target-action 是唯一确定的。另一方面,同一个页面(即 target 相同)不同元素添加点击事件对应的方法名 action 一般是不同的。
当然,如果多个不同按钮添加同一个 action,在业务开发中通常会给按钮设置 tag,并在 action 中使用 tag 区分不同元素。例如:

- (IBAction)onButtonClick:(UIButton *)sender {
    if (sender.tag == 1) {
        // 按钮 1 点击
    } else if (sender.tag == 2) {
        // 按钮 2 点击
    } else {
        // 其他按钮点击处理
    }
}

因此,通过 target + action + tag 可以标识一个按钮元素。针对普通按钮点击,可以通过 hook UIApplication 的 - sendAction:to:from:forEvent: 方法采集按钮的点击事件[3]。然后在 hook 后的方法中,获取 target + action + tag 拼接,即可获取元素标识。实现如下:

- (BOOL)sa_sendAction:(SEL)action to:(id)to from:(id)from forEvent:(UIEvent *)event {
    UIView *element = (UIView *)from;
    // 解析元素标识
    NSString *identifier = [NSString stringWithFormat:@"%@/%@/%ld", [to class], NSStringFromSelector(action), element.tag];
 
    // 获取其他信息,触发埋点事件
    return [self sa_sendAction:action to:to from:from forEvent:event];
}

最终获取到元素标识形式如下:
"MyViewController/onButtonClick:/0"

3.1.2. 优缺点

优点:
方案实现比较简单;
可以兼容基于 target-action 添加点击事件的元素,处理 UIControl 及其子类,手势点击事件的元素标识也适用。
缺点:
不适用于 UITableViewCell 这种基于 delegate 并通过代理方法 - tableView:didSelectRowAtIndexPath: 采集点击事件的元素,类似的还有 UICollectionViewCell、UIAlertView 等;
如果不同元素添加相同 action,并且未设置 tag(action 内部实现可能通过文字内容或其他方式区分),这种场景使用 target + action + tag 拼接的元素标识无法区分;
由于依赖于 action 方法名,如果在后期 App 版本迭代过程中修改了 action 的名称,会导致元素标识被修改,这样和以前触发事件的历史数据就无法兼容。

3.2. 响应者链全路径

3.2.1. 方案说明

3.2.1.1. 响应者和响应者链

我们知道,UIResponder 是 UIKit 框架中响应用户操作的基类,在 UIResponder 类中定义了专门用于处理用户交互事件的接口。我们熟知的 UIView、UIViewController、UIApplication、UIWindow 都是直接或间接继承自 UIResponder 的子类,因此它们的实例都可以响应用户交互行为,从而构成了响应者。在用户操作 App 过程中,由离用户最近的 view 向系统层层传递,从而构成了响应者链,例如:interactive view –> superview(nextResponder) –> ..... –> viewController –> window –> Application –> AppDelegate,如图 3-1 所示:

图 3-1 响应者链(来源于 Apple 开发者文档)[4]

3.2.1.2. 拼接响应者链

根据响应者链的定义我们可以看出,任意一个可点击的 view,点击过程中的响应者链是一条唯一并确定的链路。如果将这个链路上的所有响应者类名进行拼接构成字符串,这串字符就形成了当前 view 的唯一特征,从而有了元素标识。
另一方面,同一个 App 不同元素对应响应者链上的 window(一般是 keyWindow)、Application 和 AppDelegate 这三者都是相同的,也就没有辨识作用,因此在拼接元素标识过程中不必包含。
拼接响应者链的大致实现逻辑如下:
SAVisualizedUtils.m
// 递归遍历响应者链获取元素路径

+ (NSArray<NSString *> *)viewPathsForResponder:(UIResponder *)responder {
    NSMutableArray *viewPathArray = [NSMutableArray array];
    do {
        // 遍历 view 层级路径
        NSString *className = NSStringFromClass(responder.class);
        [viewPathArray addObject:className];
    } while ((responder = (id)responder.nextResponder) && [responder isKindOfClass:UIView.class] && ![responder isKindOfClass:UIWindow.class]);
 
    if ([responder isKindOfClass:UIViewController.class]) {
        // 遍历 controller 层路径
        [viewPathArray addObjectsFromArray:[self viewPathsForViewController:(UIViewController *)responder]];
    }
    return viewPathArray;
}
 

// 拼接元素路径,获取当前元素标识

+ (NSString *)identifierForResponder:(UIResponder *)responder  {
    NSArray *viewPaths = [[[self viewPathsForView:responder] reverseObjectEnumerator] allObjects];
    NSString *identifier = [viewPaths componentsJoinedByString:@"/"];
    return viewPath;
}

这样针对一个自定义的 CustomButton,最终构建的元素标识如下:
"UINavigationController/AutoTrackViewController/UIView/CustomButton"

3.2.1.3. 同类元素区分

测试发现,一个页面中如果存在多个相同类型的元素,上述标识方法无法唯一标识某个元素。为了区分这些同类元素,可以引入此元素在父视图 subviews 中的序号 index。
这样以来,如果一个页面包含多个元素,它们构建的元素标识结构如下:

"UINavigationController/AutoTrackViewController/UIView/CustomButton[0]"
"UINavigationController/AutoTrackViewController/UIView/CustomButton[1]"
"UINavigationController/AutoTrackViewController/UIView/UILabel[2]"
3.2.1.4. 优化

在实际开发过程中,对于一些 UI 交互比较灵活的页面,为了满足功能需要,开发人员可能会调用 - insertSubview:atIndex:、- exchangeSubviewAtIndex:withSubviewAtIndex:、- insertSubview:aboveSubview: 等方法。这样会影响元素所在父视图的 subviews 中索引值,从而影响最终的元素标识。例如:上述页面中的按钮移动到页面的最上层,这样最终拼接的元素标识结构如下:

"UINavigationController/AutoTrackViewController/UIView/CustomButton[0]"
"UINavigationController/AutoTrackViewController/UIView/UILabel[1]"
"UINavigationController/AutoTrackViewController/UIView/CustomButton[2]"

可以发现,第二个 CustomButton 和 UILabel 的元素标识都改变了,这样元素点击事件 $AppClick 中采集的对应属性也会变化,导致之前定义的可视化全埋点事件无法准确查询。
通过分析可知,不同类型的元素其实没必要做 index 区分。因此在获取 index 的时候,不能简单地获取当前元素在 subviews 中的序号,还需要做同类元素的归并处理。大致实现如下:
// 获取元素序号

+ (NSInteger)itemIndexForResponder:(UIResponder *)responder {
    NSArray<UIResponder *> *brothersResponder;
    if ([responder isKindOfClass:UIView.class]) {
        UIResponder *next = [responder nextResponder];
        if ([next isKindOfClass:UIView.class]) {
            brothersResponder = [(UIView *)next subviews];
        }
    } else if ([responder isKindOfClass:UIViewController.class]) {
        brothersResponder = [(UIViewController *)responder parentViewController].childViewControllers;
    }
 
    NSString *className = NSStringFromClass(responder.class);
    NSInteger count = 0;
    NSInteger index = -1;
    for (UIResponder *res in brothersResponder) {
        if ([className isEqualToString:NSStringFromClass(res.class)]) {
            count++;
        }
        if (res == responder) {
            index = count - 1;
        }
    }
    // 单个 UIViewController(即不存在其他兄弟 viewController) 拼接路径,不需要序号
    if ([responder isKindOfClass:UIViewController.class] && count == 1) {
        return -1;
    }
 
    /* 序号说明
     -1:nextResponder 不是父视图或同类元素,比如 controller.view,涉及路径不带序号
     >=0:元素序号
     */
    return count == 0 ? -1 : index;

优化之后,上述页面元素最终拼接的元素标识结构如下:

"UINavigationController/AutoTrackViewController/UIView/CustomButton[0]"
"UINavigationController/AutoTrackViewController/UIView/UILabel[0]"
"UINavigationController/AutoTrackViewController/UIView/CustomButton[1]"

如果是 UITabBarController 嵌套 UINavigationController,元素所在 viewController 为 UINavigationController 的 topViewController,则拼接元素标识为:

"UITabBarController/UINavigationController/AutoTrackViewController/UIView/CustomButton[0]"
"UITabBarController/UINavigationController/AutoTrackViewController/UIView/UILabel[0]"
"UITabBarController/UINavigationController/AutoTrackViewController/UIView/CustomButton[1]"

3.2.2. 优缺点

优点:
相对精准地标识一个元素,不受 action 方法名或文字内容等因素影响;
兼容 target-action、UITableViewCell 等各种方式实现点击事件采集的元素标识,可以满足所有点击事件的元素标识。
缺点:
通过不同的方式跳转进入同一个页面,页面内元素的标识可能不同。例如:上述示例中是否通过 UITabBarController 嵌套,会影响页面内元素的标识。因此,针对复杂的页面,难以用同一套元素标识进行匹配;
针对 UITableViewCell,涉及 Cell 重用后,同一个 Cell 在列表中行数可能会变化。直接使用 index 可能无法准确匹配列表中某一行的元素,更无法支持针对列表元素进行不限元素位置标识。

3.3. ViewPath + ScreenName + Position / Content

3.3.1. 方案说明

3.3.1.1. 概念

这个方案是在拼接响应者链全路径的基础上,针对其局限性进行改造而成的一种方案,也是我们目前正在使用的方案。先解释一下各项的含义:
ViewPath
元素路径,根据元素响应者链拼接而成的字符串,以当前元素所在 viewController 的 view 作为起点。例如:页面中部分元素的 viewPath 示例如下:

普通按钮:"UIView/CustomButton[0]"
UILabel:"UIView/UILabel[0]"
UIScrollView 嵌套 按钮:"UIView/UIScrollView[0]/UIButton[0]"
导航栏 UIBarButtonItem:"UILayoutContainerView/UINavigationBar[0]/_UINavigationBarContentView[0]/_UIButtonBarStackView[0]/_UIButtonBarButton[0]"
UITableViewCell:"UIView/UITableView[0]/UITableViewCell[0][-]"  // [-] 为通配符,可以匹配不同位置元素

ScreenName
即页面名称,也就是元素所在当前页面 viewController 的类名。
Position
表示元素位置,只有列表类型元素才支持,用于支持是否限定元素位置。UITableViewCell 的 position 结构即 "indexPath.section : indexPath.row"。
UISegmentedControl 点击的 position 直接使用 selectedSegmentIndex。
Content
元素内容,用于支持是否限定元素内容,即当前元素显示的文字内容。

3.3.1.2. 筛选标识

根据 viewPath、screenName、position、content 几个维度的特征,在元素标识过程中灵活地组合使用,从而满足复杂的应用场景和不同的元素类型:
针对普通元素,使用 viewPath + screenName 两个维度的特征,即可进行唯一标识。例如:某个按钮的筛选条件为:viewPath == "UIView/CustomButton[0]" 且 screenName == "AutoTrackViewController";
如果需要限制元素内容来标识元素,即在定义可视化全埋点事件的时候,需要限定元素内容,增加筛选 content 条件即可。例如:content == "加入购物车";
UITableViewCell、UICollectionViewCell 等列表元素。通过是否包含元素位置条件,即可匹配某一行列表或整个列表元素,从而支持限定元素位置和不限定元素位置两种方式定义可视化全埋点事件。

3.3.2. 优缺点

优点
最大的优点是使用灵活:将每个维度的特征尽可能简单化,然后通过不同维度的条件组合,可以满足不同的应用场景和元素类型,从而支持限定元素位置、限定元素内容等功能;
兼容复杂的页面组合方式:基本匹配条件是 viewPath + screenName,同一个页面(例如商品详情)使用不同的页面嵌套(例如是否包含 UITabBarController 或 UINavigationController 嵌套)或不同的跳转方式(push 或 present),都不会影响页面内元素标识。
缺点
存在系统兼容性问题:此方案也使用了元素类名,并且响应者链拼接最终会依赖页面层级。在 iOS 系统的升级过程中,同一种功能,系统实现的用户交互的元素类名和层级可能不同。例如:iOS11 前后 UIBarButtonItem 内部实现不同,从而获取到的 viewPath 不同:

iOS13: UILayoutContainerView/UINavigationBar[0]/_UINavigationBarContentView[0]/_UIButtonBarStackView[0]/_UIButtonBarButton[0]
iOS10: UILayoutContainerView/UINavigationBar[0]/UINavigationButton[0]

方案依赖于页面名称 screenName,但是针对 tabbar 控件,由于非选中状态 UITabBarItem 所在的页面可能尚未初始化,此时获取页面名称为当前 App 显示的页面。等到点击 tab 切换页面后,页面名称会改变。因此针对 UITabBarItem 暂时只支持 selectedItem 状态的元素进行标识;
如果 App 后续迭代中修改了页面层级或在 subviews 中的序号 index,可能导致 viewPath 修改,最终影响元素标识。

4. 总结

本文是可视化全埋点系列文章的第二篇,开始介绍可视化全埋点的实现,这里主要介绍了可视化全埋点实现的关键步骤 --- 元素标识。

首先说明了元素标识的原因,然后介绍了元素标识方案的演进。
从文中我们可以知道,经过艰难地探索和持续的迭代后,目前我们选用的方案,仍然存在系统兼容和特殊场景的支持问题。因此,这只是在满足业务需求和攻破技术瓶颈权衡下的最优选择,而不是最终方案。后续我们仍然会持续探索,解决遗留的问题。

这个过程,就像爬山,不停地攀登。山顶可能没有新雪,只有前人的脚印,甚至没有峰顶。但是你达到了从未达到的高度,看到了全新的风景,而身后脚步记录的,便是我们的生命。
最后,欢迎大家加入开源社区一起讨论,

5. 参考文献

[1] https://manual.sensorsdata.cn...虚拟事件-22253779.html
[2] 可视化全埋点系列文章之功能介绍篇
[3] https://github.com/sensorsdat...
[4] https://docs-assets.developer...

文章来自公众号——神策技术社区


神策技术社区
15 声望11 粉丝