1. 前言

页面浏览时长是用于统计用户在页面的停留时长。对于神策分析 iOS SDK 而言,在没有推出页面浏览时长自动采集功能之前,客户是通过手动调用开始计时和结束计时的相关接口实现页面浏览时长采集的。这种手动采集的方式对客户业务代码侵入性大,并且客户使用的成本较高。

因此,为了解决上述问题,神策分析 iOS SDK 3.1.5[1] 版本推出了页面浏览时长自动采集功能[2]。该功能无需用户手动调用接口,即可实现自动采集页面浏览时长。

在实现此功能的过程中,我们做了很多尝试,下面先来看一下自动采集页面浏览时长的两种方案。

2. 采集方案分析

2.1. 方案一

此方案主要是针对单页面的情况,采集原理是:当进入某个页面或者应用进入前台时定时器开始计时;当应用退到后台或者进入一个新的页面时(此时视为当前页面已经消失)结束计时。

具体的采集逻辑如下

  1. 当收到应用进入前台的通知时,定时器开始计时;
  2. 当执行到页面的生命周期方法 – viewDidAppear: 时,触发上一个页面的关闭事件并记录页面浏览时长,同时开始当前页面的计时;
  3. 当收到应用进入后台的通知时,触发当前页面的关闭事件并记录页面浏览时长。

优点:

  1. 采集逻辑简单;
  2. 业务代码侵入性小;
  3. 埋点成本低;
  4. 应用强杀可以正常采集页面浏览时长。

缺点:

  1. 不支持多页面,不能满足父子页面同时存在时的采集需求;
  2. 不支持暂停和恢复计时器。

2.2. 方案二

此方案既支持单页面的情况,也支持多页面的情况。采集原理是:当进入某个页面或者应用进入前台时定时器开始计时,当页面已经消失或者应用退到后台时结束计时。

具体采集逻辑如下:

  1. 当收到应用进入前台的通知时,定时器开始计时;
  2. 当执行到页面的生命周期方法 – viewDidAppear: 时,定时器开始计时;
  3. 当收到应用进入后台的通知时,定时器结束计时,触发当前页面的关闭事件并记录页面浏览时长;
  4. 当执行到页面的生命周期方法 – viewDidDisappear: 时,定时器结束计时,触发当前页面的关闭事件并记录页面浏览时长。

优点:

  1. 支持多页面;
  2. 业务代码侵入性小;
  3. 埋点成本低;
  4. 应用强杀可以正常采集页面浏览时长。

缺点:

  1. 弹出的子页面遮挡了父页面,父页面只要没有执行 – viewDidDisappear: 方法就不会结束计时;
  2. 不支持暂停和恢复计时器。

2.3. 小结

通过上述分析,我们可以知道两种方案各有利弊。不过方案一不支持多页面的场景,因此最终我们选择了方案二作为自动采集页面浏览时长的方案。

3. 具体实现

在介绍自动采集页面浏览时长[2]的具体实现之前,我们先来看下 SDK 生命周期的概念。

3.1. SDK 生命周期

SDK 生命周期是结合了应用的生命周期和 SDK 的内部逻辑,列举了 SDK 需要的状态:

// SDK 生命周期状态
typedef NS_ENUM(NSUInteger, SAAppLifecycleState) {
    SAAppLifecycleStateInit,
    SAAppLifecycleStateStart, // 应用冷(热)启动
    SAAppLifecycleStateStartPassively, // 被动启动[3]
    SAAppLifecycleStateEnd, // 退出
    SAAppLifecycleStateTerminate, // 终止
};

这样只需要关注 SDK 的状态变化,就可以准确地触发各种事件。例如:SDK 状态变为 SAAppLifecycleStateEnd 说明应用退出了,此时应该触发页面的关闭事件。代码如下:

- (void)appLifecycleStateWillChange:(NSNotification *)notification {
    NSDictionary *userInfo = notification.userInfo;
    SAAppLifecycleState newState = [userInfo[kSAAppLifecycleNewStateKey] integerValue];
    // 冷(热)启动
    if (newState == SAAppLifecycleStateStart) {
       // 开始计时
        return;
    }
    // 退出应用
    if (newState == SAAppLifecycleStateEnd) {
        // 结束计时
    }
}

3.2. 采集流程

如果想要使用自动采集页面浏览时长的功能,只需要将 SAConfigOptions 实例的 enableTrackPageLeave 属性设置为 YES 即可。另外,为了兼容应用崩溃的场景,在出现崩溃时补发页面的关闭事件并记录页面浏览时长。

自动采集页面浏览时长的流程如图 3-1 所示:

图 3-1 自动采集页面浏览时长的流程图

3.3. 核心逻辑

3.3.1. hook 页面的生命周期方法

首先需要判断是否开启了页面浏览时长采集,如果开启就 hook UIViewController 的 – viewDidAppear: 和 – viewDidDisappear: 方法。代码如下所示:

// 判断是否开启页面浏览时长采集
if (!self.configOptions.enableTrackPageLeave) {
  return;
}
// hook viewDidAppear: 和 viewDidDisappear:
[UIViewController sa_swizzleMethod:@selector(viewDidAppear:) withMethod:@selector(sensorsdata_pageLeave_viewDidAppear:) error:NULL];
[UIViewController sa_swizzleMethod:@selector(viewDidDisappear:) withMethod:@selector(sensorsdata_pageLeave_viewDidDisappear:) error:NULL];

3.3.2. 开始计时

当进入一个新的页面,检查 timestamp(类型为 NSMutableDictionary,其中 key 是 UIViewController 的地址,value 包含开始计时的时间戳)中是否存在该 UIViewController 的地址:如果存在,则忽略;如果不存在,将当前 UIViewController 的地址及当前时刻的时间戳进行记录。

另外,当应用进入前台时,需要更新 timestamp 里记录的时间戳为当前时间。代码如下所示:

// 进入一个新的页面
- (void)trackPageEnter:(UIViewController *)viewController {
    if (![self shouldTrackViewController:viewController]) {
        return;
    }
    NSString *address = [NSString stringWithFormat:@"%p", viewController];
    // 判断 timestamp 中是否存在该 UIViewController 的地址
     if (self.timestamp[address]) {
        return;
    }
    // 如果不存在,将当前 UIViewController 的地址及该时刻添加到 timestamp 中
    NSMutableDictionary *properties = [[NSMutableDictionary alloc] init];
    properties[kSAPageLeaveTimestamp] = @([[NSDate date] timeIntervalSince1970]);
    properties[kSAPageLeaveAutoTrackProperties] = [self propertiesWithViewController:viewController];
    self.timestamp[address] = properties;
}
- (void)appLifecycleStateWillChange:(NSNotification *)notification {
    NSDictionary *userInfo = notification.userInfo;
    SAAppLifecycleState newState = [userInfo[kSAAppLifecycleNewStateKey] integerValue];
    // 冷(热)启动,应用进入前台
    if (newState == SAAppLifecycleStateStart) {
        // 更新 timestamp 中所有 value 为当前时间
        [self.timestamp enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSMutableDictionary * _Nonnull obj, BOOL * _Nonnull stop) {
            obj[kSAPageLeaveTimestamp] = @([[NSDate date] timeIntervalSince1970]);
        }];
        return;
    }
}

3.3.3. 结束计时

页面消失、应用退到后台、应用崩溃这三种场景会结束计时,下面我们来分别看下是如何处理这几种场景的。

3.3.3.1. 页面消失

当页面消失时,获取当前 UIViewController 地址,查询 timestamp 中对应的 value。如果没有值,则直接返回。如果有值,执行下面的几个步骤:

  1. 计算页面浏览时长 = 当前时间 – 开始时间;
  2. 触发 $AppPageLeave 事件,并添加属性 event_duration 记录页面浏览时长;
  3. 删除 timestamp 中对应的 key-value。

代码如下所示:

// 页面消失时,判断当前 UIViewController 是否是需要计时的 UIViewController
- (void)trackPageLeave:(UIViewController *)viewController {
    if (![self shouldTrackViewController:viewController]) {
        return;
    }
    // 获取当前 UIViewController 的地址,查询 timestamp 中对应的 key-value,
    NSString *address = [NSString stringWithFormat:@"%p", viewController];
    // 如果没有值,则直接返回
     if (!self.timestamp[address]) {
        return;
    }
    // 页面浏览时长 = 当前时间 - 开始时间
    NSTimeInterval currentTimestamp = [[NSDate date] timeIntervalSince1970];
    NSMutableDictionary *properties = self.timestamp[address];
    NSNumber *timestamp = properties[kSAPageLeaveTimestamp];
    NSTimeInterval startTimestamp = [timestamp doubleValue];
    NSMutableDictionary *tempProperties = [[NSMutableDictionary alloc] initWithDictionary:properties[kSAPageLeaveAutoTrackProperties]];
    NSTimeInterval duration = (currentTimestamp - startTimestamp) < 24 * 60 * 60 ? (currentTimestamp - startTimestamp) : 0;
    tempProperties[kSAEventDurationProperty] = @([[NSString stringWithFormat:@"%.3f", duration] floatValue]);
    // 调用触发页面离开事件的方法
    [self trackWithProperties:tempProperties];
    // 删除 timestamp 对应的 key-value
    self.timestamp[address] = nil;
}
// 触发页面离开事件
- (void)trackWithProperties:(NSDictionary *)properties {
    SAPresetEventObject *object = [[SAPresetEventObject alloc] initWithEventId:kSAEventNameAppPageLeave];
    [SensorsAnalyticsSDK.sharedInstance asyncTrackEventObject:object properties:properties];
}

3.3.3.2. 应用退到后台

当应用退到后台时,遍历 timestamp 的 key-value,计算页面浏览时长 = 当前时间 – 开始时间;然后触发 $AppPageLeave 事件,并添加属性 event_duration 记录页面浏览时长。代码如下所示:

// 应用退到后台
- (void)appLifecycleStateWillChange:(NSNotification *)notification {
    NSDictionary *userInfo = notification.userInfo;
    SAAppLifecycleState newState = [userInfo[kSAAppLifecycleNewStateKey] integerValue];
    // 应用退出,调用结束计时方法
    if (newState == SAAppLifecycleStateEnd) {
        [self trackEvents];
    }
}
// 应用退到后台时,遍历 timestamp 的 key-value,触发 $AppPageLeave,时长为 currentTimestamp - startTimestamp
- (void)trackEvents {
    // 遍历 timestamp 的 key-value
    [self.timestamp enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSMutableDictionary * _Nonnull obj, BOOL * _Nonnull stop) {
        NSTimeInterval currentTimestamp = [[NSDate date] timeIntervalSince1970];
        NSNumber *timestamp = obj[kSAPageLeaveTimestamp];
        NSTimeInterval startTimestamp = [timestamp doubleValue];
        NSMutableDictionary *tempProperties = [[NSMutableDictionary alloc] initWithDictionary:obj[kSAPageLeaveAutoTrackProperties]];
        // 计算页面浏览时长
        NSTimeInterval duration = (currentTimestamp - startTimestamp) < 24 * 60 * 60 ? (currentTimestamp - startTimestamp) : 0;
        tempProperties[kSAEventDurationProperty] = @([[NSString stringWithFormat:@"%.3f", duration] floatValue]);]
        //触发页面离开事件
        [self trackWithProperties:[tempProperties copy]];
    }];
}

3.3.3.3. 应用崩溃

如果在应用崩溃时想要自动采集页面浏览时长,需要将 SAConfigOptions 实例的 enableTrackAppCrash 属性设置为 YES,因为我们的崩溃采集是一个独立的模块,需要单独开启。

当应用崩溃时,遍历 timestamp 的 key-value,计算页面浏览时长 = 当前时间 – 开始时间;然后触发 $AppPageLeave 事件,并添加属性 event_duration 记录页面浏览时长。代码如下所示:

// 应用崩溃
- (void)trackPageLeaveWhenCrashed {
    if (!self.enable) {
        return;
    }
    if (!self.configOptions.enableTrackPageLeave) {
        return;
    }
    [SACommonUtility performBlockOnMainThread:^{
        if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive) {
            [self.appPageLeaveTracker trackEvents];
        }
    }];
}
// 应用崩溃时,遍历 timestamp 的 key-value,触发 $AppPageLeave,时长为 currentTimestamp - startTimestamp;
- (void)trackEvents {
    // 遍历 timestamp 的 key-value
    [self.timestamp enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSMutableDictionary * _Nonnull obj, BOOL * _Nonnull stop) {
        NSTimeInterval currentTimestamp = [[NSDate date] timeIntervalSince1970];
        NSNumber *timestamp = obj[kSAPageLeaveTimestamp];
        NSTimeInterval startTimestamp = [timestamp doubleValue];
        NSMutableDictionary *tempProperties = [[NSMutableDictionary alloc] initWithDictionary:obj[kSAPageLeaveAutoTrackProperties]];
        // 计算页面浏览时长
        NSTimeInterval duration = (currentTimestamp - startTimestamp) < 24 * 60 * 60 ? (currentTimestamp - startTimestamp) : 0;
        tempProperties[kSAEventDurationProperty] = @([[NSString stringWithFormat:@"%.3f", duration] floatValue]);]
        //触发页面离开事件
        [self trackWithProperties:[tempProperties copy]];
    }];
}

3.4. 支持场景

说到这里,大家一定想知道目前神策分析 iOS SDK 支持自动采集哪些场景的页面浏览时长。这里总结了 11 种场景供大家参考,如表 3-1 所示:

场景
支持情况
场景
支持情况
1 当前页面上 push 出另一个页面 支持
2 pop 出当前页面 支持
3 present 出来的页面是全屏的 支持
4 present 出来的页面是非全屏的,部分遮挡当前页面 支持
5 当前页面被直接替换掉 支持
6 当前页面被其他页面遮挡(常见的弹窗) 不支持
7 Tab 切换页面 支持
8 应用切换到后台 支持
9 强杀应用 支持
10 应用崩溃 开启崩溃采集时支持
11 手机锁屏 支持

表 3-1 支持自动采集页面浏览时长的场景

4. 常见问题

关于自动采集页面浏览时长的功能,我们遇到了一些常见的问题,例如:

  1. 被动启动[3]是否会影响页面浏览时长采集;
  2. 页面被遮挡了是否采集;
  3. 父子页面同时存在,如何采集。

下面我们来看下这些问题的答案:

  1. 被动启动时,如果执行 – viewDidAppear: 方法,timestamp 会记录,但是在点开应用后会重新计时。因此页面浏览时长是从点开应用到离开页面的时长,从实际情况上来看也是合理的(毕竟被动启动时页面是看不到的);
  2. 如果页面被遮挡后没有执行 – viewDidDisappear: 方法,那么被遮挡的时间也是计入页面浏览时长里的。对于这种场景,其实是不合理的,因为页面被遮挡后就相当于看不到了。因此针对这一点,我们后续会进行优化;
  3. 只要执行了 – viewDidAppear: 方法,我们就会采集。因此父子页面同时存在时,会采集各自的页面浏览时长。

5. 总结

本文主要介绍神策分析 iOS SDK 如何自动采集页面浏览时长,希望大家通过阅读本文能够清晰地了解如何进行实现,更多细节可以参考神策分析 iOS SDK 源码[1]。

目前我们的自动采集页面浏览时长功能还在不断地更新迭代,欢迎大家在开源社区与我们进行交流。

6. 参考文献

[1]https://github.com/sensorsdata/sa-sdk-ios/releases/tag/v3.1.5

[2]https://manual.sensorsdata.cn/sa/latest/tech_sdk_client_ios_super-22253311.html#id-.SDKAPI(iOS)v1.13-%E9%87%87%E9%9B%86%E9%A1%B5%E9%9D%A2%E6%B5%8F%E8%A7%88%E6%97%B6%E9%95%BF

[3]https://manual.sensorsdata.cn/sa/latest/tech_sdk_client_ios_super-22253311.html#id-.SDKAPI(iOS)v1.13-App%E8%A2%AB%E5%8A%A8%E5%90%AF%E5%8A%A8($AppStartPassively)%E4%BA%8B%E4%BB%B6%E8%AF%B4%E6%98%8E