跃迁引擎

空気を読んだ雨降らないでよ

iOS Research & Development


iOS13 及以上系统的应用进程后台保活

最近在处理一项应用稳定性相关的问题,iOS 应用进程后台保活,记录一下解决问题的思路和方案

问题背景

目前洋葱基础架构组负责的 iOS 端在 iOS 13.0+ 系统上的后台进程存活时间仅为30s,当应用退入后台,30s后再次返回前台,将触发应用的冷启动,从而无法满足常驻性业务场景需求。

开始排查

猜测

  • 内存占用过大被系统触杀
  • 后台频繁I/O操作导致系统触杀

验证

  • 查看本机系统Jetsam、CrashReporter等日志,未发现洋葱学院入列;关闭本机相册、相机等高耗存后仍存活不过30s,排除内存问题触发异常原因被强杀 ❌
  • Instruments 监测挂起状态时的后台I/O操作,发现存在 Background Task ✅
    • 经 File Activity 排查,挂起阶段发现批量I/O操作行为,集中在后台下载任务YCVipGoodInfoVM、SDImageCache磁盘I/O、YCTaskPriorityQueue、UM/MobClickInternal、UM/WorkDispatch、JCore等处
    • 排查挂起阶段代码,发现后台下载 & 埋点上传等 Background Task
    • 将上述代码注释掉之后进行测试:
      • 将应用挂入后台30s后返回,未触发冷启动
      • 挂入后台5’30s后返回,未触发冷启动
      • 挂入后台10’35s后返回,未触发冷启动
      • 挂入后台16’后返回,未触发冷启动
      • 挂入后台30’后返回,未触发冷启动

定位

经过上面的分析,可以确认是由于后台任务导致的系统触杀,范围锁定在下列三个函数作用域中:

1
- (void)applicationWillResignActive:(UIApplication *)application
1
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
1
- (void)applicationDidEnterBackground:(UIApplication *)application

测试1:只保留上述三个函数中的 applicationWillResignActive 函数中埋点上传代码,后台挂起30s后返回,应用未触发冷启动,排除该函数

测试二:只保留后两个函数,屏蔽第一个函数,应用触发冷启动,范围确定在后两个函数

测试三:屏蔽第二个函数中的 YCVideoDownloadManager 相关方法,应用触发冷启动,进一步锁定在后台下载管理类 YCThunderDownloadManager 的相关方法

测试三:屏蔽YCThunderDownloadManager 的相关方法,应用触发冷启动,证明并非是其造成的

测试三:屏蔽 beginBackgroundTaskWithExpirationHandler 函数,关闭所有可能的后台任务入口,应用未触发冷启动,证明可能存在其他后台任务影响了应用进程的存活。

思考

为何 beginBackgroundTaskWithExpirationHandler 函数一执行,就会造成应用在后台被杀的情况呢?去官方文档查阅了对该函数的描述,发现有这么一句话:

Failure to end the task explicitly will result in the termination of the app.

这句话是说,如果任务没有明确结束,将导致应用程序的终止。看到这里虎躯一震,我好像知道问题出在哪里了,摁住躁动的心继续往下看

A unique identifier for the new background task. You must pass this value to the endBackgroundTask: method to mark the end of this task.

意思就是说,必须将后台任务的唯一标识符传递给 endBackgroundTask: 方法,以标记此任务的结束。这么一来可以看出,如果没有执行 endBackgroundTask: 方法,后台任务就无法被标记结束

看到这里我立刻返回工程里搜索了一下 endBackgroundTask,发现除了个别第三方库中调用了该函数外,洋葱本身的工程里是没有任何后台结束标记的,这个问题就很严重了,是个低级人为错误。

另外,文档中还提到

For background tasks requiring more time use Background Tasks.

对于需要更多时间的后台任务,需要使用Background Tasks,我去看了下相关文档,这是一个iOS 13 才开放的API,可以指定在后台运行的最大时间,相当于一个可自由设置时间的后台保活 API 该API只能设置在后台运行的最早时间,且系统无法保证一定在该时间触发,只保证不在你设置的时间之前触发该后台任务,只适合周期性后台任务,不适合高频次后台任务。

Each call to this method must be balanced by a matching call to the endBackgroundTask: method. Apps running background tasks have a finite amount of time in which to run them. (You can find out the maximum background time available using the backgroundTimeRemaining property.) If you do not call endBackgroundTask: for each task before time expires, the system kills the app. If you provide a block object in the handler parameter, the system calls your handler before time expires to give you a chance to end the task.

大概就是说,每一个 beginBackgroundTaskWithExpirationHandler 都必须对应一个 endBackgroundTask,也就是这个方法的调用必须成对平衡。而且运行后台任务的应用程序所运行它们的时间是有限的,会存在一个最大背景时间,具体时间可以通过 backgroundTimeRemaining 属性查看,经测试,在没有任何后台任务正在执行的情况下,7s 左右后台运行时间就结束了。如果你不调用 endBackgroundTask: 在时间到期之前,系统会终止该应用程序。如果在 handlder 参数中提供了一个 block 对象,系统会在时间过期之前调用你的 handlder 程序,让你有机会结束任务。

在 applicationDidEnterBackground: 的相关文档中还提到:

Your implementation of this method has approximately five seconds to perform any tasks and return. If you need additional time to perform any final tasks, request additional execution time from the system by calling beginBackgroundTaskWithExpirationHandler:. In practice, you should return from applicationDidEnterBackground: as quickly as possible. If the method doesn’t return before time runs out, your app is terminated and purged from memory.

Perform any tasks relating to adjusting your user interface before this method exits. Move other tasks (such as saving state) to a concurrent dispatch queue or secondary thread as needed. Because it’s likely any background tasks you start in applicationDidEnterBackground: won’t run until after that method exits, request additional background execution time before starting those tasks. In other words, first call beginBackgroundTaskWithExpirationHandler: and then run the task on a dispatch queue or secondary thread.

applicationDidEnterBackground: 方法的实现大约有5秒钟的时间来执行任何任务并返回。如果你需要额外的时间来执行后台任务,可以通过调用 beginBackgroundTaskWithExpirationHandler: 从系统请求额外的执行时间。苹果建议开发者应该尽快从 applicationDidEnterBackground 返回,如果方法在时间结束前没有返回,那么你的应用程序将被终止并从内存中清除。

在 applicationDidEnterBackground: 方法退出之前,会执行与调整用户界面有关的任何任务。根据需要将其他任务(如保存状态)移动到并发分派队列或辅助线程。因为它很可能是在 applicationDidEnterBackground 中启动的任何后台任务: 在该方法退出之前不会运行,所以在启动这些任务之前请求额外的后台执行时间。换句话说,首先调用 beginBackgroundTaskWithExpirationHandler: 然后在调度队列或辅助线程上运行该任务。

结论

根据官方文档的说明我们可以得出下列结论:

  • applicationDidEnterBackground: 在不开启后台任务的前提下,只5秒左右的时间来执行任务,不要在该方法内启动超时任务,否则会被系统触杀
  • applicationDidEnterBackground: 方法中可以调用 beginBackgroundTaskWithExpirationHandler: 从系统请求额外的执行时间,以此来运行后台任务
  • beginBackgroundTaskWithExpirationHandler: 与 endBackgroundTask: 这两个方法必须成对出现,否则会被系统触杀
  • endBackgroundTask: 的最大触发时机取决于系统当前允许的最大后台运行时间
  • 可以通过 backgroundtimeremining 属性拿到系统当前允许的最大后台运行时间
  • iOS 13+ 系统可以使用 Background Tasks 相关 API 来进行精准后台任务管控(它更适合比如一周一次的定时后台清理任务)

解决方案

1.最简单的处理办法是在 beginBackgroundTaskWithExpirationHandler: 的回调中加入 endBackgroundTask:

1
2
3
4
5
6
7
AppDelegate.m
// 类似处理方式可解决上述问题(仅供参考)
- (void)applicationDidEnterBackground:(UIApplication *)application {
__block UIBackgroundTaskIdentifier backgroundTask = [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:^{
[[UIApplication sharedApplication] endBackgroundTask:backgroundTask];
}];
}

当然,如果你有多个后台任务,最好是为每一个后台任务单独执行一对 beginBackgroundTask 和 endBackgroundTask。

2.使用 Background Tasks 相关 API 来进行 精准后台任务管控 周期性后台任务管控,具体使用可以参考官方文档,篇幅限制这里就不多做介绍了,需要注意一点:

Every identifier in the BGTaskSchedulerPermittedIdentifiers requires a handler. Registration of all launch handlers must be complete before the end of applicationDidFinishLaunching:.

Bgtaskscheduler 中的每个标识符都需要一个处理程序。所有启动处理程序的注册必须在 applicationDidFinishLaunching: 结束之前完成,否则运行过程中会发生 Crash。

xcode-build-error.png

1
2
3
4
BGProcessingTaskRequest *taskRequest = [[BGProcessingTaskRequest alloc] initWithIdentifier:@"com.yangcong345.match.test_background_task"];
taskRequest.requiresNetworkConnectivity = true; // 后台任务是否需要网络连接
taskRequest.requiresExternalPower = false; // 是否仅在设备连接到外部电源时才执行此请求表示的后台任务 (任务占用大量资源时,推荐打开,可减少对电池寿命的影响)
taskRequest.earliestBeginDate = [[NSDate alloc] initWithTimeIntervalSinceNow:0]; // 后台任务最早运行时间(延迟多久执行,注意:此属性不能保证在指定的时间开始,只能保证不会提前开始,如果设置的时间过长,系统可能选择不启动该后台任务)

The delay between the time you schedule a background task and when the system launches your app to run the task can be many hours. While developing your app, you can use two private functions to start a task and to force early termination of the task according to your selected timeline. The debug functions work only on devices.

从你调度一个后台任务到系统启动你的应用程序运行这个任务之间的时间延迟可能要好几个小时。在开发你的应用程序时,你可以使用两个私有函数来启动一个任务,并根据你选择的时间表强制提前终止任务(只能使用真机调试)。

调试启动一个后台任务:

1
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"TASK_IDENTIFIER"]

调试强制终止一个后台任务:

1
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"TASK_IDENTIFIER"]

溯本求源

为什么系统会杀死我们在后台的应用呢?

根据苹果在 WWDC 2020 上的介绍,目前会导致应用在后台被杀死的 case 大概有以下 6 种

  • 发生 Crash
  • CPU 资源限制
  • 触发看门狗事件
  • 超出内存限制
  • 内存压力退出
  • 后台任务超时

题外话:在 iOS 14 中提供了新的 MetricKit API,可以轻松的获取到应用退出的次数及具体原因等相关信息

发生 Crash

Crash 想必不用多说了,应用在后台发生崩溃会导致应用直接退出,系统会产生各种直接或间接的事故报告,即崩溃日志,可以通过本机系统日志或 Xcode Organizer 查看。

CPU 资源限制

CPU 是有严格的使用限制的,当后台的 CPU 内存占用持续很高时,系统会生成一份 Energy Exception 报告,如果这个持续性工作的时间长到一定程度,那么系统会终止 App 的运行。同样可以通过 Xcode Organizer 中查看 CPU 资源例外日志。

触发看门狗事件

应用在关键切换期间长时间挂起等待,会触发看门狗事件,比如打开、切到后台然后再切回前台,这种切换大概有 20s 的时间限制。出现看门狗通常意味着发生了严重问题,比如说死锁、无限循环,或者主线程上发生的其他无线同步工作。和 Crash 相似,系统也会产生相应的事故报告。

超出内存限制

和 CPU 过渡使用类似,如果内存占用过多系统会在内存占用率超过界限值时,马上终止 app 的运行,值得注意的是,前台和后台的占用率界限值是一样的,但不同设备的界限值是不同的,一般来说设备越老界限值越低(如果是 iPhone 6s 以下设备,就需要尽量把内存占用量控制在 200MB 以下)。可以通过 Instruments 和 Xcode 的 Memory Debugger 来找到应用中占用过多内存的源头。

内存压力退出

和内存占用过高导致 app 终止不同,还有另一种与内存有关的 app 终止情况:内存压力退出(也叫自动清理),这是最常见的后台终止 app 的情况。出现这种情况不一定是你的应用出了问题,单纯就是系统需要为前台正在运行或其他运行中的的其他 app 腾出内存,比如说音乐和地图导航 app。

如果你的 app 在前台占用内存过多,那么系统就会将其终止,当你的 app 转到后台后,尽量减少它占用的内存,就可以直接降低终止率,尽量让转到后台的 app 占用的内存少于 50MB,占的内存越少越好。考虑清除缓存以及所有能从磁盘上读取的资源,当 app 转回前台的时候,你随时都可以恢复到之前的设置。但记住,你可能会导致其他 app 被自动清理,所以尽量管理好自己的 app,也尽可能减少在前台使用的内存。

不过即使你成功将占用的内存控制在了 50MB 以内,也无法完全排除自动清理的风险,自动清理是无法避免的,它的发生也非常难以预测。你的 app 转入后台之后,如果用户接下来的操作需要使用大量内存,自动清理就会在短短几秒内发生,比如打开相机并快速拍摄大量照片。那你这时候应该怎么办?你一定要保存转入后台时的状态,这样的话你的 app 再次被打开时,就能回到用户之前退出时的状态。如果用户之前是在编辑本地文本框,它希望回到 app 时文字还在那里,如果涉及媒体内容播放一定要确保恢复到播放位置。

你可以用 UIKit 内置的状态恢复 API来实现这个效果,许多复杂的工作它都能帮你完成。如果将状态恢复用在整个 app 上,许多用户甚至不会注意到 app 在后台终止了而不得不重新启动。

后台任务超时

另外一个常见的被杀场景就是后台任务超时,从前台转到后台时,可以通过调用 UIApplication.beginBackgroundTaskWithExpirationHandler 指令获得额外的运行时间完成关键工作,当工作完成时,需要调用 endBackgroundTask 指令。一件容易被忽略的事,如果你没有明确的调用 endBackgroundTask,那时间一到系统就会终止 app 运行,app 挂起 30s 后就会终止。如果 app 并未采用状态恢复,可能会对操作造成极大的不便。每个任务就像一个倒计时 30s 的定时炸弹,一旦 app 转入后台,计时就开始了,只要你能在 30s 内结束所有任务,那 app 将会优雅的挂起而不会被终止。当终止发生时,苹果不会显示崩溃日志,但可以通过 MXBackgroundExitData 显示出围绕其频率的统计数据。只要谨慎的处理后台任务,这些问题都是可以避免的。

为了防止终止发生,当务之急就是切换到 UIKit API的命名变体:beginBackgroundTasg(withName:expirationHandler: ,命名变体的好处在于它能够在 app 后台多个任务中,将可能没有结束的那个任务隔离出来,并且该方法是线程安全的。注意,这些终止在 Debugger 中不会再次出现,因此,为了简化操作,从 iOS 13.4 开始苹果新增了控制台信息,在一项任务保持时间过长时,便会打印,即使 app 处于前台,打印仍会运行。因此如果在给 app 进行调试时看到这些打印,就应该检查 beginBackgroundTask 的调用指令,以确保有对应的 endBackgroundTask 调用指令。

事实再次证明,人是不看log的🤗。

另一个解决方案是通过超时处理程序(expirationHandler),所有 beginBackgroundTask 调用均应提供给一个超时处理程序,这是非常好的做法。注意,在处理程序内调用 endBackgroundTask 指令是安全的,需要小心的是应该避免在超时处理程序中,启动其他的高消耗工作,因为你只有几秒钟的时间。

我们可以把超时处理程序想作成一张防止终止的安全网,它们不应该成为唯一调用 endBackgroundTask 指令的地方。在任务实际结束时,你应当调用 endBackgroundTask 指令,使得设备能提早进入休眠模式,这样做也有益于保持电池续航时间。

在 app 处于后台的情况下开始新任务,要倍加小心,因为剩余时间小于 5s 时,超时处理程序并不会被调用。为了防止这种情况的出现,你可以用 5 作为下限,来预估需要多长时间完成,然后与剩余时间对比,如果剩余时间足够,就能安全的调用 beginBackgroundTask,如果剩余时间不够,你可以将这个任务作为后台处理任务排入队列,比如说在设备充电的时候进行。

1
2
3
4
5
6
7
8
9
10
let minimumTimeRemaining = min(5, estimateProcessingTime(inputData))

if UIApplication.shared.backgroundTimeRemaining > minimumTimeRemaining {
// 剩余时间足够,调用 beginBackgroundTask
return UIApplication.shared.beginBackgroundTask { ... }
} else {
// 没有足够的时间,将这个任务推迟到以后再执行
registerProcessingTask(inputData)
return .invalid
}

下一个需要注意的事项是,如何存储你的 UIBackgroundTaskIdentifiers,如果你像下面这个例子一样在实例变量中储存,就很容易遇到问题:当用户点击这个按钮时,开启后台任务,几秒后数据导出完成,你得到了一个完成处理程序。如果我轻点几下这个按钮,可能就会出现好几个未完成的后台任务,所以请记住,实例变量只能一次保持一项任务,这就导致我们会泄露最新任务之外的所有标识符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ArchiveViewController : UIViewController {

var backgroundTaskIdentifier : UIBackgroundTaskIdentifier = .invalid

@IBAction func beginDataExport(send: UIButton) {

self.backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { ... }
// 存档后结束这个后台任务,这个可能需要几秒钟时间
ArchiveUtility.exportUserData(completion: () -> ()) {

UIApplication.shared.endBackgroundTask(self.backgroundTaskIdentifier)
}
}
}

幸运的是,这类漏洞很容易避免:最简单的方式就是将你的 UIBackgroundTaskIdentifier 放在一个本地变量而不是实例变量中,在 Swift 中这个本地变量是被闭包捕获的,所以你可以从完成模块甚至超时处理程序中访问它。用这个方法,每一次调用 beginDataExport 就会在一个单独的底层内存位置,追踪任务标识符,防止泄露

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ArchiveViewController : UIViewController {

@IBAction func beginDataExport(send: UIButton) {

var backgroundTaskIdentifier : UIBackgroundTaskIdentifier = .invalid

self.backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { ... }
// 存档后结束这个后台任务,这个可能需要几秒钟时间
ArchiveUtility.exportUserData(completion: () -> ()) {

UIApplication.shared.endBackgroundTask(self.backgroundTaskIdentifier)
}
}
}

最后

最后苹果的工程师表示:如果你仔细的检查了 app 中所有 beginBackgroundTask 和 endBackgroundTask 的使用,应该就不会遇到后台任务超时终止的情况。并且,如果你把上述的 6 种 case 的情况都修正了,理论上你的 app 的打开速度应该会更快,因为他是继续上一次的状态而不是从头开始(也就是始终保持热启动状态)。对于最常见的终止情况,你可以设置进入后台时少于 50MB 内存,来降低出现自动清理的几率,占用内存越小越好,但自动清理是不可避免的,因为它注定会发生。确保你的 app 可以通过状态恢复平稳启动,回到用户退出前的状态,只要做到以上三点,就可以确保有一个无缝对接的完美对任务体验。

参考

beginBackgroundTaskWithExpirationHandler:

applicationDidEnterBackground:

Background Tasks

backgroundTimeRemaining

BGTask Scheduler

beginBackgroundTaskWithExpirationHandler calling endBackgroundTask but not ending process

registerForTaskWithIdentifier:usingQueue:launchHandler:

WWDC 2020: Why is my app getting killed?

About the UI Restoration Process

Xcode 11 - What is the new “Background Processing” Background Mode?

How to Update App Content With Background Tasks Using The Task Scheduler In iOS 13?

Refreshing and Maintaining Your App Using Background Tasks

Starting and Terminating Tasks During Development

最近的文章

种花问题

题目出自 LeetCode 605 - 种花问题 …

, 开始阅读
更早的文章

链表中倒数第k个节点

题目出自:剑指 Offer 22. 链表中倒数第k个节点 …

, , 开始阅读
comments powered by Disqus