头图

1. 前言

神策分析是依托于数据进行的,数据是分析的根基。因此,数据上报的时效性是至关重要的。那么 iOS SDK(后面简称 SDK)是如何保证数据上报的时效性呢?

接下来,我们就围绕这个问题来看看 SDK 究竟做了什么。

2. 上报策略

直观来说,要解决数据上报的时效性问题似乎很简单:实时上报(当触发事件后立刻上报到服务端)不就可以保证时效性了吗?但是,事实并非如此简单。

不同于服务端,移动设备上的资源是非常有限的,采取实时上报的方式势必会造成 App 整体性能的下降,如何平衡性能与数据上报的时效性是 SDK 需要面临的一个挑战。

目前 SDK 中使用的数据上报策略是事件触发后不立即上报,而是先将事件缓存在本地,然后满足一定的条件再进行上报。

SDK 每次触发事件时会检查如下条件,用于判断是否向服务端上报数据:

当前网络是否符合发送策略 flushNetworkPolicy(默认 3G、4G、5G、WiFi);
与上次发送的时间间隔是否大于指定的时间间隔 flushInterval(默认 15 秒);
本地缓存的事件条数是否大于最大缓存事件数 flushBulkSize(默认 100 条)。
只有 1、2 或者 1、3 满足时,SDK 才会发送数据。当然,为了满足不同的需求,可以通过修改 flushNetworkPolicy、flushInterval、flushBulkSize 的值来控制事件上报。

SDK 的数据上报流程如图 2-1 所示:

图 2-1 SDK 的数据上报流程图

3. 时效性优化

按照我们指定的上报策略进行数据上报,对于一般的自定义埋点事件及全埋点事件是可以满足时效性的要求。但是,这种上报策略存在一些弊端:

App 退到后台或终止后如果不再打开,最后未上报的数据不会及时地上报到神策分析平台;
无法满足一些事件的实时上报需求。
为了解决这两个问题,SDK 进行了如下的优化。

3.1. App 进入后台时上报

33.1.1. iOS 后台机制

在了解 App 进入后台时如何上报之前,我们先来看下 iOS 的后台机制。iOS 系统中,App 在执行时可能会出现 Active、Inactive、Background、Not Running、Suspended 这几种状态[1]。当我们的 App 由前台进入到后台时,会有 5 秒的时间执行任务,在此后 App 将被系统置为挂起状态(Suspended)。此时 App 运行在后台,但无法执行代码。

对于大多数 App 来说,5 秒的时间足够执行一些进入后台的关键任务。考虑一些 App 需要更多后台时间来处理任务,iOS 提供了用于延长应用后台执行时间[2] 的接口,可以申请额外的后台运行时间以保证任务执行完成。经过测试,在 iOS 12 及以上的系统最多可以申请 30 秒的运行时间。

3.1.2. 后台数据上报

我们已经知道 App 在进入后台时会在很短的时间内被系统挂起,由于数据上报时网络环境的不确定性,很难保证 SDK 在 5 秒时间内完成数据上报。因此,SDK 在 App 进入后台时会主动申请 App 后台任务。代码如下所示:

UIApplication *application = UIApplication.sharedApplication;
__block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid;
void (^endBackgroundTask)(void) = ^() {

[application endBackgroundTask:backgroundTaskIdentifier];
backgroundTaskIdentifier = UIBackgroundTaskInvalid;

};
backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:endBackgroundTask];

dispatch_async(self.serialQueue, ^{
// 上传所有的数据
[self.eventTracker flushAllEventRecords];
// 结束后台任务
endBackgroundTask();
});

3.2. App 终止时上报

上一节讲述了 App 进入后台时如何进行数据上报,如果 App 终止了,数据还能及时上报吗?答案是肯定的。

App 终止之前,系统会先发出 UIApplicationDidEnterBackgroundNotification 通知。此时,SDK 会申请后台任务进行数据入库及上报。

接下来,系统就会立刻尝试终止后台任务,而 SDK 采集的退出事件($AppEnd)以及退到后台时采集的一些自定义事件可能没有足够的时间入库。这种情况不但无法完成数据上报,甚至会导致数据丢失。因此,我们需要在 App 即将终止时获得一段时间用于保存我们的事件数据。

在 App 即将终止时系统会发出 UIApplicationWillTerminateNotification[3] 通知。因此,我们只要监听该通知并阻塞当前线程,从而完成数据保存及上报。代码如下所示:

  • (void)applicationWillTerminateNotification:(NSNotification *)notification {
    dispatch_sync(self.serialQueue, ^{});
    }

    3.3. 主动上报

    在 SDK 的预置事件里,有一些预置事件对时效性要求比较高。例如:用来分析 UV(日活)的 $AppStart(App 启动)事件,我们希望可以实时分析到真实的 UV 数据。因此,在触发 $AppStart 事件时 SDK 会主动上报一次数据。

3.4. 手动上报

由于 SDK 主动上报只能针对一些预置事件做处理,因此 SDK 对外提供了一个接口用于自定义事件的主动上报。代码如下所示:

// 触发自定义事件
...
[[SensorsAnalyticsSDK sharedInstance] flush];

4. 踩过的坑

通过上面的介绍可以知道,SDK 采取了很多方式用于保证数据上报的时效性。看起来似乎已经很完美了,但是在测试过程中还是发现了一些问题...

4.1. App 终止时导致的问题

在测试过程中遇到一个问题:当 App 终止时,数据上报出现了崩溃。

此时,我们不免会有疑问:只是数据上报为什么会造成崩溃?真的是 SDK 的原因造成的吗?带着这些疑问我们分析了崩溃堆栈。如图 4-1 所示:


图 4-1 Watchdog 造成的系统强杀

首先,我们看到崩溃的原因是触发了系统的 Watchdog[4] 机制从而导致 App 被系统强杀,而此时 SDK 正在执行数据上报任务。

结合 SDK 源码我们发现:App 终止时 SDK 为了保证数据上报成功,会采取阻塞当前线程的方式来延长 App 后台存活时间。代码如下所示:

  • (void)applicationWillTerminateNotification:(NSNotification *)notification {
    dispatch_sync(self.serialQueue, ^{});
    }
    而在弱网环境下数据可能一直无法上报成功,最终触发系统的 Watchdog 机制,从而导致崩溃。

问题原因我们已经明白了,但是修复这个问题会涉及到 “鱼和熊掌不可兼得” 的问题:是保证数据及时上传还是保证 App 不触发 Watchdog 机制?

由于这个场景是在 App 终止时发生的,本身并不会影响使用 App 的体验。其次,由于只在弱网环境下出现,发生的概率较小。因此,在之前版本的 SDK 默认会强制上报所有数据,同时提供手动关闭的接口,关闭后退到后台时不再上报数据。代码如下所示:

// 关闭后台上报
options.flushBeforeEnterBackground = NO;

4.2. 事件上报导致的问题

同样是弱网问题。由于 SDK 数据上报和数据采集是在同一个串行队列,数据上报时会阻塞该队列,导致数据无法正常入库,此时 App 终止可能会造成数据丢失。

数据是分析的根基,保证数据不丢失是神策的红线。

鉴于 iOS 系统对后台任务愈发严格的要求以及强制上报数据造成的影响,最终我们决定重构后台上报的逻辑。主要有以下几点变化:

flushBeforeEnterBackground 作用改变,由退到后台是否上报数据更改为是否同步上报数据;
flushBeforeEnterBackground 默认值修改为 NO(即异步上报),不再阻塞当前线程。
代码如下所示:

  • (void)flushEventRecords:(NSArray<SAEventRecord *> *)records completion:(void (^)(BOOL success))completion {
    __block BOOL flushSuccess = NO;
    // 当设置 flushBeforeEnterBackground 为 YES 或 debug 模式下,使用线程锁
    BOOL isWait = self.flushBeforeEnterBackground || self.isDebugMode;
    [self requestWithRecords:records completion:^(BOOL success) {

      if (isWait) {
          flushSuccess = success;
          dispatch_semaphore_signal(self.flushSemaphore);
      } else {
          completion(success);
      }

    }];
    if (isWait) {

      dispatch_semaphore_wait(self.flushSemaphore, DISPATCH_TIME_FOREVER);
      completion(flushSuccess);

    }
    }
    这段代码的含义如下:

如果 flushBeforeEnterBackground = YES,- flushEventRecords:completion: 方法为同步执行。当方法执行完成时,意味着数据上传也完成了;
如果 flushBeforeEnterBackground = NO,- flushEventRecords:completion: 方法为异步执行,不会等待数据上传完成,等数据上报完成后主动结束后台任务即可,代码如下所示:

if (newState == SAAppLifecycleStateEnd) {

UIApplication *application = UIApplication.sharedApplication;
__block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid;
void (^endBackgroundTask)(void) = ^() {
    [application endBackgroundTask:backgroundTaskIdentifier];
    backgroundTaskIdentifier = UIBackgroundTaskInvalid;
};
backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:endBackgroundTask];

dispatch_async(self.serialQueue, ^{
    // 上传所有的数据
    [self.eventTracker flushAllEventRecordsWithCompletion:^{
        // 结束后台任务
        endBackgroundTask();
    }];
});
return;

}
目前,SDK 中 flushBeforeEnterBackground 默认值即为 NO。但是,这样会引入另外一个问题:在弱网环境下,可能会重复上报事件。原因如下:

SDK 只会在服务端返回成功时才会删除本地保存的数据;
异步上报数据发起网络请求后就会继续执行下一个任务;
在弱网环境下可能服务端已经接收到数据但 SDK 没有接收到返回成功,此时 App 终止会导致无法删除本地保存的数据;
下次启动 App 时会重新上报该数据导致重复上报。

这个问题可以通过服务端去重机制解决,保证相同数据不会重复入库。

5. 总结

数据分析是个复杂的系统,需要保证每一个环节都不会出错。

在数据上报这一环节,关于时效性的优化我们一直在努力,并且一定会持续下去。

  1. 参考文献
    [1] https://developer.apple.com/d...

[2] https://developer.apple.com/d...

[3] https://developer.apple.com/d...

[4] https://developer.apple.com/d...

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


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