背景

手动埋点一直以来都是一个比较麻烦的行为,埋点不属于业务逻辑,而我们必须插入业务逻辑中,有可能就导致了我们不得不修改设计以满足埋点的需求。

同时数据方很多时候会要求带上额外的数据,而这些额外的数据甚至和当前模块毫无关系,也就是破坏了我们的独立性和解耦原则。比如在事件点击中加入当前页面的pageId,当前cell的index等等。

简介

目前我们手动埋点的埋点信息可能包含的内容比较多,但也可以进行归类。

不同类型的埋点信息都是在不同的层上收集的,比如我们需要pageId,那么这个必然是在当前VC上才有,如果我们要收集上个页面的pageId,那么只有navigationController上会有该信息,而如果我们需要收集cell的位置信息,那么我们需要在dataSource上进行收集。

这给我们的埋点收集一个思路,我们手动埋点的时候不要马上收集完所有数据上报,而是收集当前层存在的信息,并把这个埋点任务抛向上层。每一层都会往埋点任务里面写入自己层的信息,直到结束,这样我们就可以收集一个非常完整的埋点信息。

当然这样做的缺点是埋点里面的信息比较多,比较冗余,但作为埋点信息来说,信息冗余并不是太大的问题。

要建立这样一个结构,如果是一些新业务或者组织架构设计较好的业务来说,会比较简单,但是对于一个已经成型的,有很多复杂的业务却比较麻烦。这里需要去寻找一种方法,使改动量尽可能的小,并且组织灵活。

数据流

数据方向

首先我们来看看一般应用的页面结构。

绿色:Application
蓝色:View
橘色:ViewController

我们更新页面的数据方向是这样的:

但是我们埋点触发,需要收集的数据却是这样的:

我们更新数据和收集数据的方向是完全相反的,这也就是导致我们手动埋点总是会感觉很别扭的原因。

而我们一般做埋点的方式,是将这个事件通过回调等方式一层层传递到ViewController上,然后进行统一埋点。

但是对于一个复杂的VC,我们往往会继续拆分,成为多个独立组件的组合(可以看我之前的DDComponent),这样不仅能避免VC的无限膨胀,也能解耦业务。但是遇到埋点需求时,我们却不得不打破这个组织方式,因为有很多信息必须要在VC上才能获取到。

数据流

我们在上面了解到的埋点现状,可以抽象为一条数据流,在这个数据流上有多个节点,不同的节点可以收集必要的埋点信息。

而每一个节点,可以抽象为一个Stream。

这里我们可以看到每一个节点同时也是响应链的一部分,那么我们是不是就可以依靠响应链来连接整个数据流呢?这样我们就不需要大量改动代码,从而达到建立这套系统了。


@interface UIResponder (TraceStream)

@property (nonatomic, strong, readonly, null_resettable) TraceStream *trace;

@end

这里我们还需要为这条数据流创建一个终点,也就是最终输出端,可以选择Application上的节点为输出节点。

@implementation UIApplication (TracePipeline)

- (TraceStream *)trace {
    NSAssert([NSThread isMainThread], @"Must call on main thread.");
    TraceStream *stream = objc_getAssociatedObject(self, @selector(trace));
    if (stream == nil) {
        TraceStream *stream = [[LoggerTraceStream alloc] init];
        objc_setAssociatedObject(self, @selector(nm_trace), stream, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        return stream;
    }
    return stream;
}

@end

使用

这里我们举一个列表中点赞埋点的列子:

在cell中的某个view中,触发了点赞:

- (void)onPriase:(id)sender {
    [self.trace addTraceInfoWithBuilder:^(TraceInfo * _Nonnull traceInfo) {
        traceInfo
        .setAction(@"praise")
        .setId(self.video.videoId)
        .setType(self.praised ? @"1" : @"0");
    }];
}

但是我们还需要知道这个cell是位于列表中的位置,那么我们可以cell中加入该信息:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = ...;
    
    [cell.trace.filter setFilterBlock:^BOOL(TraceInfo * _Nonnull traceInfo) {
        traceInfo.set(@"index", @(indexPath.row).stringValue);
        return YES;
    } forKey:@"index"];
    
    return cell;
}

然后我们还需要知道是在哪个页面中触发的,那么我们需要在vc中加入:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self.trace.filter setFilterBlock:^BOOL(TraceInfo * _Nonnull traceInfo) {
        traceInfo.setPage(@"test");
        return YES;
    } forKey:@"addPage"];
}

那么最后我们收集到的埋点信息就会包括了praise, index, page信息。

自定义

在开发过程中,我们可能还会有很多业务分割的部分,比如ViewModel,这些内容并不一定在响应链中,那么我们需要能够将这个数据流通过该组件来收集数据。

这里我们可以在默认的数据流中插入一个自定义节点来解决。本身这个数据流和响应链其实并没有什么关系,我们只是利用了响应链的结构而已。

viewModel.view.trace.outStream = viewModel.trace;
viewModel.trace.outStream = controller.trace;

这样我们就可以自定义数据节点了。

过滤器

由于各种原因,我们的埋点并不能完全的通用,比如有时候,同样是点赞,有时候是praise,有时候是like。有时候我们又不希望触发某些埋点。

我们需要在某些确定的节点要求能够过滤或者转换某些埋点,那么我们需要在每个节点上添加过滤器:

[self.trace.filter setFilterBlock:^BOOL(NMTraceInfo * _Nonnull traceInfo) {
    if ([traceInfo.action isEqual:@"praise"]) {
        traceInfo.setAction(@"like");
    }
    return YES;
} forKey:@"addPage"];

虽然这么做并不是特别好,但在一些无法抗拒的情况下还是能够给我们一个修改的机会。

逻辑型埋点

另一种让我们所有人都头疼的就是逻辑型埋点。

一般来说我们的埋点都是事件埋点,也就是触发一个方法,我们埋一次。但是有些需求要统计时长,比如用户点播放,和点暂停之前的时间间隔。按照原有方法埋点,那么我们可能需要在VC里增加一个成员,专门为了记录这个时间间隔。而这些成员和业务毫无关系,只服务于埋点信息,导致了业务的耦合增加。

对于我们程序来说,事件的点是比较简单的,而这种逻辑型埋点则非常复杂。如果我们把逻辑型埋点都转化为事件埋点,那么就会减少很多复杂度。

__block NSDate *playTime = nil;
[self.trace.filter setFilterBlock:^BOOL(NMTraceInfo * _Nonnull traceInfo) {
    if ([traceInfo.action isEqual:@"play"]) {
        playTime = [NSDate date];
        return NO;
    }
    else if ([traceInfo.action isEqual:@"pause"]) {
        // 得到两者之间的时间间隔
        [self.trace addTraceInfoWithBuilder:^(TraceInfo * _Nonnull traceInfo) {
            traceInfo
            .setAction(@"play-pause")
            .setTime([playTime timeIntervalSinceNow]);
        }];
        playTime = nil;
        return NO;
    }
    return YES;
} forKey:@"play-pause"];

总结

这种方案虽然能够解决很大一部分的埋点痛点,但也依然存在一些缺陷。

  • 开发者必须要了解这种方案的原理,才能知道在什么节点收集埋点,以及如何插入自定义节点。
  • 埋点信息会比较冗余,会带上一些无用信息。
  • 埋点信息必须保证大部分是统一的,不然每个页面全部采用不同的key和埋点方式,也会让这个系统失去复用的能力。