F30ed1e30b1400e55d5fd3e27bf75a02
全新后台任务框架及最佳实践

WWDC 2019 Session 707: Advances in App Background Execution

2010年 iOS4 时代,iOS 的多任务系统面世,至今已经9个年头,期间后台模式及场景也逐渐增多,这为开发者和用户带来了很多可能性。随着 iOS 版本的迭代,慢慢的越来越多的后台运行场景被苹果所支持。与此同时为了改善用户体验以及延长电池寿命,苹果对于应用后台任务有着比较严苛的限制及审核规则,只有特定使用场景,应用才可能在后台持续运行,比如导航、音乐播放,VoIP 等。如果我们的应用恰好符合后台模式的场景,那么应该怎样利用好这一点来给用户好的体验呢?相信通过这一集 Session,你心中应该会有一个比较明确的答案~

概览

目前苹果支持9种后台模式,具体类型可使用 Xcode 的 Capabilities 来查看,如下图所示

新旧 Xcode 后台模式对比

新旧 Xcode 后台模式对比

通过上图对比可以看到 Xcode11 将 Newsstand downloads 这种后台模式移除,并新增了一个 Background processing(后面会具体说)。这些后台模式都有 API 与之对应,苹果在设计后台任务相关 API 时,将以下3点作为主要考虑因素来确保流畅的用户体验。

  • 电池

电量几乎时刻都在被消耗,那么如何保证后台任务尽可能的减小电量的消耗呢?答案就是在后台任务完成时及时调用对应的 completion 通知系统任务已结束,以此来减小电量的消耗。

  • 性能

在日常使用情况下,手机上通常同时运行着多个应用,某个应用在前台时,其它的应用在后台。在资源有限的情况下,为了保证设备尽可能的流畅,系统会为每个应用智能分配 CPU 及内存的阈值,一旦应用超过对应阈值,将会被系统终止。
我们日常开发中发生的 OOM(Out Of Memory) 以及主线程长时间未响应而触发系统的“看门狗”,都是由于应用耗尽了系统分配的资源而被系统终止。

延展阅读
触发“看门狗”通常会生成一份 Crash 日志,日志内容类似下面这样,经典的 0x8badf00d
Exception Type: EXC_CRASH (SIGKILL)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note: EXC_CORPSE_NOTIFY
Termination Reason: Namespace SPRINGBOARD, Code 0x8badf00d
Termination Description: SPRINGBOARD, process-launch watchdog transgression: com.xxxx exhausted real (wall clock) time allowance of 20.00 seconds | | ProcessVisibility: Unknown | ProcessState: Running | WatchdogEvent: process-launch | WatchdogVisibility: Foreground | WatchdogCPUStatistics: ( | "Elapsed total CPU time (seconds): 2.910 (user 2.910, system 0.000), 7% CPU", | "Elapsed application CPU time (seconds): 0.000, 0% CPU" | )
Triggered by Thread: 0
如果对系统 Crash 日志感兴趣,可以看看我去年写的这篇文章 WWDC 2018:理解崩溃以及崩溃日志

大部分 OOM 的情况下一般会生成一份 JetsamEvent 开头的日志文件,可在设备的 设置->隐私->分析 中查看到,里面的内容会有崩溃现场的一些进程信息以及内存分配情况。更多关于 JetsamEvent 的介绍,可以查看这篇文章 iOS内存abort(Jetsam) 原理探究

  • 隐私

由于应用在执行后台任务时,用户是无感的,但是用户对于自己的隐私信息是敏感的,所以在相关 API 的设计时会告知用户,哪些数据会被使用。
从今年的 WWDC 的动作来看,苹果对用户的隐私越来越重视,这点非常值得称赞,比如今年推出的 Sign In With Apple、地理位置权限的变更、后台地理位置访问的弹窗等。当然,这不是开始也不是结束,为苹果爸爸点赞👍。

最佳实践

了解了后台任务相关 API 的设计初衷,是时候来看看如何实践才能保证流畅的用户体验以及延长电池寿命。

想象一下一个类似微信的即时通讯软件拥有的一些功能:即时消息、勿扰模式、VoIP、历史记录下载等,对于这些功能,结合系统提供的各种后台任务应用场景,该以何种姿势使用这些 API 呢?且往下看~

应用场景

应用场景

即时消息

即时消息肯定需要确保时效性,尽可能快的触达对方才能保证良好的用户体验。但是某些情况下(比如较差网络环境),不一定能马上将消息发送到对方,此时用户可能切回到桌面或者其它应用,那么如何才能保证发送消息这个操作完成呢?答案就是使用 Background Task Completion 相关 API。

// Guarding Important Tasks While App is Still in the Foreground
func send(_ message: Message) {
    let sendOperation = SendOperation(message: message)
    var identifier: UIBackgroundTaskIdentifier!
    // 1
    identifier = UIApplication.shared.beginBackgroundTask(expirationHandler: {
        // 2
        sendOperation.cancel()
        postUserNotification("Message not sent, please resend")
        // Background task will be ended in the operation's completion block below
    })
    sendOperation.completionBlock = {
        // 3
        UIApplication.shared.endBackgroundTask(identifier)
    }
    operationQueue.addOperation(sendOperation)
}

让我们依次看看上面标注的步骤:

  1. 应用在前台时通过对应 API 创建一个后台任务,此时即使 app 进入后台,也会获得一定的时间来处理消息发送。
  2. 在系统给出的时间内还没有处理完,应用即将被挂起,则取消发送,同时本地 push 通知用户。
  3. 如果发送成功,则通知系统该任务已结束,以此降低对电量的消耗。

如果是 Extension,可以使用 ProcessInfo.performExpiringActivity(withReason:using:)

相信这种方式大家或多或少都用过,有些应用甚至用这个接口去做所谓的“保活”。但是这里要提醒大家注意一个点(说多了都是泪),就是 task 的 begin 和 end 的调用要对应,你会踩到我踩过的坑:没有成对调用的 task 会触发 0x8badf00d 看门狗。但是这里的 Crash 堆栈和上面说的的主线程卡太久而被强杀的堆栈是不一样的,具体可以看看这篇文章的分析:iOS App 后台任务的坑

电话

有些时候会觉得打字麻烦而直接打电话,系统同样也提供了对应的 API————VoIP 通知。它是一种特殊的通知类型,可以唤起应用,提醒用户有电话呼入,代码实现起来也比较简单

func registerForVoIPPushes() {
    self.voipRegistry = PKPushRegistry(queue: nil)
    self.voipRegistry.delegate = self
    self.voipRegistry.desiredPushTypes = [.voIP]
}

同时必须在 didReceiveIncomingPush 回调中使用 CallKit 来处理 VoIP push 通知,否则系统会“杀”掉应用,并且系统可能在收到 VoIP 通知时不再唤起应用,示例代码如下:

let provider = CXProvider(configuration: providerConfiguration)

func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload:
PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
    if type == .voIP {
        if let handle = payload.dictionaryPayload["handle"] as? String {
            let callUpdate = CXCallUpdate()
            callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
            let callUUID = UUID()
            provider.reportNewIncomingCall(with: callUUID, update: callUpdate) { _ in
                completion()
            }
            establishConnection(for: callUUID)
        }
    }
}

同时以下几点也可以关注一下:

  • payload 中填充尽可能多的信息,以便展示更加完善的 UI(当然不能超过其限制)。
  • 因为电话的实时性很高,payload 中的 apns-expiration 的值尽可能小或者为0,以便通知能立即触发。
  • 如果不想要类似系统电话的全屏 UI,也可以使用标准的推送 API 来触发 banner 样式。
  • 如果想要自定义 push 内容,则可以使用 Notification Service Extension,比如想要做一些加密操作。

由于笔者没有实际使用过 VoIP 相关技术,所以这里推荐大家看看苹果的官方文档 VoIP 最佳实践 以及闲鱼技术团队写的这篇文章iOS VoIP电话:CallKit与PushKit的应用

勿扰模式

聊完 VoIP,我们紧接着看看勿扰模式的最佳实践。微信中通常聊天列表里躺着几十个甚至上百个会话,有些活跃的群可能一天有上千条信息,如果一直收到 push,肯定会不胜其烦。所以一般都会对这个群开启消息免打扰模式,但是又不想错过重要信息(比如被别人@)。那么这种勿扰模式,在后台模式下该如何实现呢?使用静默推送!~

静默推送可以在用户无感知的情况下,将数据推送到设备上。只需要将 push payload 里的 content-available 的值设置为 1,同时 payload 中不要包含 alertsoundbadge 字段,示例如下:

示例摘抄自 Creating the Remote Notification Payload Listing 7-1

{
    "aps" : {
        "content-available" : 1
    },
    "acme1" : "bar",
    "acme2" : 42
}

当收到静默推送后,系统出于对电池寿命和性能的保证,会智能地在后台唤起应用去下载相关内容。

下图还是以消息免打扰为例,用户在前台对某个会话开启了消息免打扰,然后回到后台,一段时间后该会话有新的内容,但是用户开了勿扰模式,所以我们需要“偷偷地”更新会话内容,但是用户却无感知。这里“偷偷地”就是系统在收到静默推送时,会在合适的时机在后台唤起应用去加载该会话的新内容(该后台任务可以持续30秒)。等用户回到前台,会发现免打扰的会话里的内容也有了更新,极大提高了用户体验。

时间线

时间线

关于静默推送的其它几点 tips:

  • 必须将 apns-priority 设置为 5,否则系统不会唤起应用。
  • watchOS 应用必须(其它平台则强烈推荐)将 apns-push-type 设置为 background

以上涉及到 payload 里的相关字段的设置,其实是在向 APNs 服务器发起请求时,请求体里的相关字段,更多内容可参考Sending Notification Requests to APNs
关于最后一点需要稍微吐槽一下,这集 Session Keynote 上是说 watchOS 必须设置,其它平台强烈推荐设置。但是官方文档却说从 iOS13 和 watchOS6 起,这个 key 必须设置,建议还是以文档为准。

关于推送测试,推荐一下这个工具 Knuff

top Created with Sketch.