WWDC Quick Look 💓 By SwiftGGTeam
Keep your complications up to date

Keep your complications up to date

观看原视频

Highlight

watchOS 提供前台刷新、Background App Refresh、background URLSession 和 complication push 四种机会更新 ClockKit timeline;后台刷新通常最多每小时 4 次,complication push 每只 Watch 每天最多 50 次,App 需要按数据来源控制更新节奏。

核心内容

Apple Watch 的 complication 一直在表盘上。用户抬腕看到的是 App 的状态,不一定会先打开 App。watchOS 因此会给带 complication 的 App 一些特殊机会:App 可能被系统保留,必要时也会被重新启动来更新表盘内容(00:43)。

这场演讲的范围很窄:已经有 complication 后,如何让它在用户没有打开 App 时继续显示新数据。示例 App 是一个独立 Watch App,表盘上有三类信息:从 HealthKit 取活动数据、从服务器取天气和风速、接收朋友发来的鼓励消息(02:13)。

前台更新最直接。用户打开 App、修改选择、或者 App 在前台收到新数据时,代码调用 CLKComplicationServer.sharedInstance().reloadTimeline(for:),ClockKit 随后请求当前 timeline entry。这里要选择性更新,只更新真正需要变化的 complication(03:11)。

后台更新要按数据来源选机制。Background App Refresh 适合访问 Watch 上的 API 和本地数据,例如 HealthKit;background URLSession 适合从服务器下载数据;complication push 适合事件驱动的数据,例如朋友发来一条新鼓励。三种机制都能在 App 不活跃时唤醒 App,但每一种都有频率、时间和完成任务的约束(01:33)。

详细内容

前台用 reloadTimeline 触发当前 entry 重建

03:32updateActiveComplications() 取得当前活跃的 complications,然后逐个要求 ClockKit 重新加载 timeline。

class ExtensionDelegate: NSObject, WKExtensionDelegate {

    func updateActiveComplications() {

        let complicationServer = CLKComplicationServer.sharedInstance()

        if let activeComplications = complicationServer.activeComplications {

            for complication in activeComplications {

                complicationServer.reloadTimeline(for: complication)

            }
        }
    }
}

关键点:

  • CLKComplicationServer.sharedInstance() 是更新 ClockKit timeline 的入口。
  • activeComplications 只包含当前表盘上真正启用的 complication。
  • 示例代码更新所有活跃 complications;演讲提醒真实 App 应该更细,只刷新需要更新的项。
  • 每次 reloadTimeline(for:) 之后,ClockKit 会再向 data source 请求当前 entry。

04:26)ClockKit 请求当前 entry 时,App 根据 complication family 创建对应 template,并通过 handler 交回 CLKComplicationTimelineEntry

class ComplicationController: NSObject, CLKComplicationDataSource {

    func getCurrentTimelineEntry(for complication: CLKComplication,
        withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {

        switch (complication.family) {

        case .modularSmall:
            let template = CLKComplicationTemplateModularLargeTallBody.init(
                headerTextProvider: headerTextProvider,
                bodyTextProvider: bodyTextProvider)

            entry = CLKComplicationTimelineEntry(date: Date(),
                complicationTemplate: template)
        }

        handler(entry)
    }
}

关键点:

  • complication.family 决定要返回哪一种 template。
  • template 由 text providers 等内容提供者填充。
  • CLKComplicationTimelineEntry(date:complicationTemplate:) 把生效时间和显示模板绑定到一起。
  • handler(entry) 是必须完成的回调,ClockKit 通过它取得要显示的内容。

Background App Refresh 适合本地数据和 Watch API

05:20)示例 App 用 Background App Refresh 拉取 HealthKit 活动数据。演讲给出的频率上限是每小时最多四次,实际次数还会受其他进程和电量影响。

private func scheduleBAR(_ first: Bool) {
    let now = Date()
    let scheduledDate = now.addingTimeInterval(first ? 60 : 15*60)

    let info: NSDictionary = ["submissionDate": now]

    let wkExt = WKExtension.shared()
    wkExt.scheduleBackgroundRefresh(withPreferredDate: scheduledDate, userInfo: info)
    { (error: Error?) in
        if (error != nil) {
            print("background refresh could not be scheduled \(error.debugDescription)")
        }
    }
}

关键点:

  • first ? 60 : 15*60 对应首次尽快调度,后续按 15 分钟节奏请求。
  • userInfo 可以带上调度时的上下文,示例用 submissionDate 计算系统实际唤醒延迟。
  • scheduleBackgroundRefresh(withPreferredDate:userInfo:) 的时间是偏好时间,系统只保证在这个时间之后选择合适时机唤醒。
  • completion handler 在主线程异步调用,调度失败要处理错误。

08:47)如果后台工作是异步的,必须等数据提供者完成后再更新 complication、安排下一次刷新、标记任务完成。

class ExtensionDelegate: NSObject, WKExtensionDelegate {

    var healthDataProvider: HealthDataProvider

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        for task in backgroundTasks {
            switch task {
            case let backgroundTask as WKApplicationRefreshBackgroundTask:

                healthDataProvider.refresh() { (update: Bool) -> Void in
                    if update {
                        self.updateActiveComplications()
                    }
                    self.scheduleBAR(first: false)
                    backgroundTask.setTaskCompletedWithSnapshot(false)
                }

关键点:

  • WKApplicationRefreshBackgroundTask 是 Background App Refresh 到达时的任务类型。
  • healthDataProvider.refresh() 负责异步读取 HealthKit 数据。
  • 只有 update 为 true 时才调用 updateActiveComplications(),避免没有数据变化时浪费刷新机会。
  • scheduleBAR(first: false) 在当前任务完成前安排下一次请求,因为同一时间只有一个请求处于 outstanding 状态。
  • setTaskCompletedWithSnapshot(false) 必须在所有工作完成后调用;演讲指出忘记标记完成是超过 15 秒总时限的常见原因。

Background App Refresh 不能做网络请求。演讲明确说 URLSession 是例外,尝试使用会失败。它还给出两个时间限制:最多 4 秒 active CPU time,最多 15 秒 total time(09:22)。

background URLSession 适合服务器数据

11:35)天气数据来自服务器,所以示例 App 改用 background URLSession。关键设置是 background configuration、非 discretionary、以及 sessionSendsLaunchEvents = true

class WeatherDataProvider: NSObject, URLSessionDownloadDelegate {

    private lazy var backgroundURLSession: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: "BackgroundWeather")
        config.isDiscretionary = false
        config.sessionSendsLaunchEvents = true

        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

关键点:

  • URLSessionConfiguration.background(withIdentifier:) 创建后台下载会话。
  • isDiscretionary = false 表示请求不交给系统自由推迟。
  • sessionSendsLaunchEvents = true 允许下载事件到达时在后台唤醒 App。
  • delegateQueue: nil 让 URLSession delegate 回调进入后台 serial queue。

12:02)下载任务通过 earliestBeginDate 指定最早开始时间,并设置预期发送/接收字节数。

func schedule(_ first: Bool) {

    if backgroundTask == nil {

        if let url = self.currentWeatherURLForLocation(delegate.currentLocationCoordinate)
        {
            let bgTask = backgroundURLSession.downloadTask(with: url)

            bgTask.earliestBeginDate = Date().addingTimeInterval(first ? 60 : 15*60)

            bgTask.countOfBytesClientExpectsToSend = 200
            bgTask.countOfBytesClientExpectsToReceive = 1024

            bgTask.resume()

            backgroundTask = bgTask
        }
    }
}

关键点:

  • backgroundTask == nil 保证这个示例里同一时间只保留一个天气下载任务。
  • URL 来自最新缓存位置,演讲场景是按地点获取风速天气。
  • earliestBeginDate 复用首次 60 秒、后续 15 分钟的调度策略。
  • countOfBytesClientExpectsToSendcountOfBytesClientExpectsToReceive 帮系统了解传输规模。
  • resume() 之后,下载可以独立于 App 运行;完成后系统会通过 WKURLSessionRefreshBackgroundTask 唤醒 App。

14:08)下载完成后,delegate 从文件 URL 读取数据并处理 JSON。之后 didCompleteWithError 在主线程调用保存的 completion handler。

class WeatherDataProvider: NSObject, URLSessionDownloadDelegate {

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {

        if location.isFileURL {
            do {

                let jsonData = try Data(contentsOf: location)
                if let kiteFlyingWeather = KiteFlyingWeather(jsonData) {
                    // Process weather data here.
                }

            } catch let error as NSError {
                print("could not read data from \(location)")
            }
        }
    }

关键点:

  • didFinishDownloadingTo 收到的是下载到本地的文件位置。
  • 示例先确认 location.isFileURL,再读取 Data(contentsOf:)
  • KiteFlyingWeather(jsonData) 是示例 App 的天气解析入口。
  • 任务完成后仍要等 URLSession delegate 事件交付完,再标记 WKURLSessionRefreshBackgroundTask 完成。

background URLSession 通常也可以每小时发送和接收最多四个请求,实际次数受 Wi-Fi、蜂窝信号和电量影响。App 被激活时要重新 attach 到 session,这样才能收到可能待处理的 delegate callbacks(10:31)。

complication push 适合事件驱动数据

16:16)如果数据来自服务器上的突发事件,push 比定时拉取更合适。示例 App 用 complication push 更新朋友发来的鼓励消息。

class PushNotificationProvider: NSObject, PKPushRegistryDelegate {

    func startPushKit() -> Void {
        let pushRegistry = PKPushRegistry(queue: .main)
        pushRegistry.delegate = self
        pushRegistry.desiredPushTypes = [.complication]
    }

    func pushRegistry(_ registry: PKPushRegistry,
                      didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
        // Send credentials to server
    }

    func pushRegistry(_ registry: PKPushRegistry,
                      didReceiveIncomingPushWith payload: PKPushPayload,
                      for type: PKPushType, completion: @escaping () -> Void) {
        // Process payload
        delegate.updateActiveComplications()
        completion()
    }
}

关键点:

  • PKPushRegistry(queue: .main) 指定 PushKit 回调使用主队列。
  • desiredPushTypes = [.complication] 对应 complication push 类型。
  • didUpdate pushCredentials 返回的凭据要上传给服务器。
  • 收到 push 后先处理 payload,再调用 updateActiveComplications()
  • completion() 必须在 payload 处理完成后调用。

服务器侧也有硬性要求:App identifier 必须以 .watchkitapp.complication 结尾,并用它创建 Apple Push Notification service SSL Certificate。WatchKit extension 还需要 remote notification background mode 和 push notifications capability(16:51)。

每只 Watch 每天最多收到 50 个 complication pushes。第 51 个以及之后的通知会被忽略,所以服务器要按用户和设备做节流。push payload 按后台 push 的方式组织,在 aps 字典里提供 content-available18:34)。

核心启发

  • 活动进度 complication:做什么:在表盘显示当天活动状态。为什么值得做:session 示例用 Background App Refresh 读取 HealthKit,并且后台任务可以每小时最多运行四次。怎么开始:把 HealthKit 查询放进 HealthDataProvider.refresh(),只有数据变化时再调用 updateActiveComplications()
  • 风速天气 complication:做什么:根据最新缓存位置显示适合放风筝的风速和天气。为什么值得做:session 示例用 background URLSession 在 App 不活跃时下载服务器数据。怎么开始:创建 URLSessionConfiguration.background,设置 sessionSendsLaunchEvents = true,下载完成后解析 JSON 并更新 timeline。
  • 朋友鼓励 complication:做什么:在表盘显示朋友发来的最新鼓励。为什么值得做:session 说明事件驱动数据适合 complication push,push 可以按突发事件发送。怎么开始:注册 PKPushRegistry.complication 类型,把 push credentials 上传到服务器,并在收到 payload 后刷新相关 complication。
  • 设置变更即时预览:做什么:用户在 Watch App 里改了地点、目标或显示项后,表盘立即显示新内容。为什么值得做:session 建议在 App 前台活跃、状态响应输入变化时更新 complications。怎么开始:在设置保存后取得 activeComplications,只对受影响的 complication 调用 reloadTimeline(for:)

关联 Session

评论

GitHub Issues · utterances