「Apple Watch 应用开发系列」Apple Watch App Lifecycle

2,688 阅读11分钟

Watch App Lifecycle

watchOS App 的生命周期比 iOS App 的生命周期要更复杂一些。watchOS App 可能会处于以下五种状态:

  • Not running - 未运行
  • Inactive - 不活跃
  • Active - 活跃
  • Background - 后台
  • Suspended - 挂起

常见的状态转换

watchOS App 的五种状态由下图中的紫色框表示:

作为开发者,我们只会与其中三个状态进行交互:Inactive、Active、Background。 而在 Not running 和 Suspended 状态下 App 未运行。

启动 App 到 Active 状态

如果用户尚未运行 App 或系统从内存中清除了 App,则 App 以 Not running 状态开始。App 启动后,它会从 Not running 转换到 Inactive 状态。

处于 Inactive 状态时,App 仍然在前台运行,但不会响应用户的任何操作。但是 App 可能仍在执行某些代码。在此状态下,App 并不是无法运行。

App 几乎立即将转换到 Active 状态,这是在 Apple Watch 屏幕上运行的 App 的正常模式。当处于 Active 状态时,App 可以接收来自 Apple Watch 上的物理控件和用户手势的操作。

当用户启动我们的 App 但尚未运行时,watchOS 将执行以下操作:

  • 调用 applicationDidFinishLaunching() 并将 scenePhase 设置为 .inactive;
  • 创建 App 的初始 scene 和 rootView;
  • 调用 applicationWillEnterForeground();
  • 调用 applicationDidBecomeActive() 并将 scenePhase 设置为 .active;
  • App 将出现在屏幕上,watchOS 将调用 rootView 的 onAppear(perform:)。

注意:从 watchOS 7 及更高版本开始,在使用 SwiftUI 时,scenePhase 环境变量的更新,要早于 WKExtensionDelegate 方法的调用。

App 到 Inactive 状态

一旦用户放下手臂,watchOS 将变为 Inactive 状态。如前所述,该 App 仍在运行并执行我们的代码。Inactive 状态需要减少 App 对 Apple Watch 电池的影响,我们应暂停或取消任何不需要的电池密集型操作。例如,我们可以禁止当前正在运行的动画的展示。

我们还需要考虑是否需要保存一些数据,保存 Core Data Stack?向 UserDefaults 写入内容?其实,一旦用户再次抬起手臂,App 就会再次激活。因此,此时并不需要保存或保存太多内容,否则可能会对电量产生较大压力。

当我们的 App 转换到 Inactive 状态时,watchOS 会将 scenePhase 设置为 .inactive,然后调用 WKExtensionDelegate 的 applicationWillResignActive() 方法。

App 到 Background 状态

在转换到 Inactive 状态两分钟后,或者当用户切换到另一个 App 时,我们的 App 将转换到 Background 状态。通过系统也可以直接将 App 启动到 Background 状态,如后 Background session 和 Background task。

在 Suspended 状态之前,操作系统会为 Background 状态的 App 提供一小段不确定的时间。如果我们的 App 从 Inactive 状态转换到 Background 状态,我们需要快速执行必要的操作来处理 App。

我们可以使用 SwiftUI ScenePhase 或 WKExtensionDelegate 的 applicationDidEnterBackground 来确定我们的 App 何时到 Background 状态。当从 Inactive 状态转换到 Background 状态时,watchOS 会将scenePhase 设置为 .background,然后调用applicationDidEnterBackground()。如果 App 需要太多资源,watchOS 将暂停该 App。

返回表盘

在 watchOS 7 之前,我们可以在 App 转换到 Background 后请求 8 分钟。在 watchOS 7 及后续版本中,用户可以通过 Apple Watch 上的 设置 ▸ 通用 ▸ 返回表盘 来进行超时配置。用户可以选择三个选项:“始终”、“2 分钟后”或“1 小时后”。默认情况下,所选设置会应用到所有 App,但用户也可以为每个 App 选取自定时间。默认值为两分钟。 根据功能需要,我们可能希望告诉用户如何将 App 的设置更改为一小时。

额外的 Background 执行时间

如果在转换到 Background 时,App 需要执行的工作量比 watchOS 为我们的App 提供时间要长,那么我们需要重新考虑 App 进行的操作,例如删除网络调用。如果我们已经进行了所有可以进行的优化,但仍然需要更多的处理时间,我们可以调用 ProcessInfo 类的 performExpiringActivity(withReason:using:) 方法。如果在 App 处于前台时调用,将获得 30 秒。如果在后台调用,将获得 10 秒。

系统将异步尝试执行我们提供给 using 参数的代码块,它将返回一个布尔值,让我们知道 App 是否即将暂停。如果我们收到 false 值,那么我们可以继续,并尽快执行我们的操作。如果我们收到一个 true 值,系统不会给我们额外的时间,App 需要立即停止。

请注意,仅仅是系统允许我们开始额外的工作,并不意味着它会给我们足够的时间来完成它。如果我们的代码块仍在运行,并且操作系统需要暂停我们的 App ,那么我们的代码块将被使用 true 参数再次调用。我们的代码应能够处理此取消请求。

例如,我们可以在每个操作之前检查 watchOS 是否告诉我们停止工作。假设我们有一个布尔实例属性 cancel,我们会执行以下操作:

processInfo.performExpiringActivity(
  withReason: "求求你"
) { suspending in
  guard !suspending else {
    cancel = true
    return
  }
  guard !cancel else { return }
  try? managedObjectContext.save()
  guard !cancel else { return }
  userDefaults.set(someData(), forKey: "criticalData")
}

在代码中:

  1. 立即检查我们是否被允许运行 如果系统告诉你暂停,那么我们将取消属性设置为 true。

  2. 在尝试保存我们的 CoreDataModel 之前,请确保未设置 cancel。另一个线程可能已经调用了相同的方法并请求挂起。

  3. 在保存到 UserDefaults 之前,请检查操作系统是否告诉我们停止。

每次检查取消可能看起来有点奇怪,但这样做可以确保我们遵守操作系统的指示。 在示例中,我们只需在被告知时停止操作,而时间情况下,我们可能需要快速执行其他操作来标记我们无法完成的操作。

App 到 Active 状态

如果用户在 App 处于 Background 状态时与其交互,watchOS 将通过以下过程将其转换回 Active 状态:

  • 以 .background 状态重新启动应用程序;
  • 调用 applicationWillEnterForeground();
  • 将 scenePhase 设置为 .active;
  • 调用 applicationDidBecomeActive()。

我们可能会对用户在后台状态下如何与应用交互感到困惑。这是用户使用了 App 提供的复杂功能。

App 到 Suspended 状态

当我们的 App 最终转换到 Suspended 状态时,所有代码执行都会停止。 App 仍在内存中,但不会处理事件。

当我们的 App 处于 Background 状态并且没有任何待处理的任务要完成时,系统会将我们的 App 转换为 Suspended 状态。

一旦我们的 App 进入 Suspended 状态,它就有资格被清除。 如果操作系统需要更多内存,它可能会在不通知的情况下从内存中清除任何处于 Suspended 状态的 App。

系统将尽最大努力不清除最近执行的 App、Dock 中的任何 App 以及在当前表盘上有复杂功能的任何 App。 如果系统必须清除上述 App 之一,它将在内存可用时重新启动该 App。

始终显示 Always on

在 watchOS 6 之前,当用户最近没有与之交互时,Apple Watch 会息屏。 Always On 改变了这一点,使手表继续显示时间。但是,watchOS 会模糊当前运行的 App,并在显示屏上显示时间。

而现在,在默认情况下,会显示我们的 App 的用户界面,而不是时间。只要它是最前面的 App 或运行 Background session,watchOS 就不会模糊它。处于 Always On 时,手表屏幕会变暗,并且 UI 更新速度会变慢,从而延长电池使用时长。

如果用户与我们的 App 交互,系统将返回其 Active 状态。 Always On 的一个显着优势与日期和时间有关。如果 App 显示计时器或相对日期等,则 UI 将继续更新为正确的值。

如果你希望为我们的 App 禁用 Always On,只需在 Info.plist 中将 WKSupportsAlwaysOnDisplay 键设置为 false。用户还可以通过“设置”▸“显示和亮度”▸“始终显示”来为某些 App 或整个设备禁用 Always on。

状态变化示例

创建一个新项目:

新增 ExtensionDelegate.swift 文件:

import WatchKit
 
final class ExtensionDelegate: NSObject, WKExtensionDelegate {
    
    func applicationDidFinishLaunching() {
        print( #function)
    }
    
    func applicationWillEnterForeground() {
        print( #function)
    }
    
    func applicationDidBecomeActive() {
        print( #function)
    }
    
    func applicationWillResignActive() {
        print( #function)
    }
    
    func applicationDidEnterBackground() {
        print( #function)
    }
}

修改 LifecycleApp.swift 代码:

import SwiftUI
 
@main  struct Lifecycle_Watch_AppApp: App {
    @Environment(.scenePhase) private var scenePhase
    @WKExtensionDelegateAdaptor(ExtensionDelegate.self) private var extensionDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .onChange(of: scenePhase) {
            print("onChange: ($0)")
        }
    }
}

我们可以在物理设备上运行该项目以观察状态变化。当我们抬起和放下手腕时,会看到状态在 Active 和 Inactive 之间变化。如果我们让应用程序处于 Inactive 状态两分钟,它会切换到 Background 模式。

WKExtendedRuntimeSession

有四种特定的类型,可以让我们的 App 保持运行,甚至在后台运行。

Self care

专注于用户情绪健康或健康的 App 将在前台运行,即使在手表屏幕未打开。 watchOS 将为 App 序提供 10 分钟的 Session,该 Session 将持续到用户切换到另一个 App 或 App 使 Session 无效。

Mindfulness

冥想已越来越成为一种流行的,Mindfulness - 正念 App 将保持在前台。不过,这是一个耗时的过程,所以 watchOS 会给 App 一个 1 小时的 Session。

Physical therapy

伸展等锻炼非常适合物理治疗课程。 与最后两种 Session 类型不同,物理治疗 Session 在后台运行。 后台 Session 将一直运行,直到到时间限制或 App 使 Session 无效,即使用户启动另一个 App 也是如此。物理治疗课程可长达 1 小时。

Smart alarm

当我们需要安排时间检查用户的心率和运动时,智能提醒是一个不错的选择。 App 将获得一个 30 分钟的 Sesion。

与其他三种会话类型不同,我们必须安排智提醒钟在未来某一个时刻开始。 我们需要在接下来的 36 小时内启动会话,并在我们的 App 处于 WKApplicationState.active 状态时安排它。我们的Aoo 能会暂停或终止,但 Sesion 将继续。

当需要处理 Session 时,watchOS 将调用 App 的 WKExtensionDelegate 的 handle(_:)。

注意:我们必须在 App 退出之前设置会话的委托,否则 Session 将终止。

一旦 Session 运行,我们必须通过调用会话的 notifyUser(hapticType:repeatHandler:) 来触发提醒。 如果我们忘记了,watchOS 将显示警告并提议禁用 Session。

刷牙提醒 Demo - Dentisit

搭建项目框架

我们将实现一个刷牙时,提醒用户刷牙时间的 App Dentisit,首先创建项目:

修改 ContentView.swift,这样能让我们更好的看到 App 的状态:

struct ContentView: View {
    @Environment(.scenePhase) private var scenePhase
    
    var body: some View {
        Text("Hello, World!")
        .onChange(of: scenePhase) { print($0) }
    }
}

新增 GetReadyView.swift,它将实现一个准备视图:

import SwiftUI

struct GetReadyView: View {
  private let color: Color // 色环颜色
  @State private var stage: Int // 倒计时秒数,屏幕上展示的值
  private let onComplete: (() -> Void)? // 倒计时完成后回调
  private let denominator: Double // 倒计时秒数,保存总值,用来计算色环
  @State private var trim = 1.0 // 色环显示比例
  @State private var text = "Ready" // 色环中心文案
  private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
   
  init(color: Color = .green,
     stages: Int = 4,
     onComplete: (() -> Void)? = nil) {
    self.color = color
    self.onComplete = onComplete
    _stage = State(initialValue: stages)
    denominator = Double(stages)
  }
   
  var body: some View {
    ZStack {
      Color.black.ignoresSafeArea()
      // 背景色环
      Circle()
        .stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round))
        .foregroundColor(color.opacity(0.5))
      // 色环
      Circle()
        .trim(from: 0, to: trim)
        .stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round))
        .foregroundColor(color)
        .rotationEffect(.degrees(-90))
        .animation(.linear, value: trim)
      // 文案
      Text(text)
        .font(.title)
    }
    .onReceive(timer) { _ in tick() }
    .background(.black)
  }
   
  private func tick() {
    stage -= 1 // 更新当前时间
    self.text = "(self.stage)"
    trim = Double(stage) / denominator // 更新色环
    guard stage > 0 else {
      timer.upstream.connect().cancel()
      WKInterfaceDevice.current().play(.success) // 播放音效
      if let onComplete = onComplete { onComplete() }
      return
    }
    WKInterfaceDevice.current().play(.start) // 播放音效
  }
}

struct GetReadyView_Previews: PreviewProvider {
  static var previews: some View {
    GetReadyView()
  }
}

具体代码内容,已经添加注释以辅助阅读,可以在 ContentView 中添加 GetReadyView() 来查看效果:

struct ContentView: View {
    @Environment(.scenePhase) private var scenePhase
    
    var body: some View {
//        Text("Hello, World!")
        GetReadyView()
        .onChange(of: scenePhase) { print($0) }
    }
}

最终效果如下:

选择 Session 类型

刷牙属于 Self care 类型,按照下图中的步骤添加新功能。 首先,从 Project Navigator 菜单中选择 Dentisit。 然后选择 Dentisit Watch App,选择 Signing & Capabilities,然后按 + Capability 选项。 出现提示时,从功能列表中选择 Background Modes,并修改 Session Type:

添加 ContentModel

创建一个名为 ContentModel.swift 的新文件:

import SwiftUI  
final class ContentModel: NSObject, ObservableObject {
    @Published var roundsLeft = 0     
    @Published var endOfRound: Date?     
    @Published var endOfBrushing: Date?
    private var timer: Timer!
    private var session: WKExtendedRuntimeSession! 
}

在代码中,ContentModel 需要符合 ObservableObject 以便模型可以更新 ContentView。我们还需要继承 NSObject,这是 WKExtendedRuntimeSessionDelegate 的要求。

前三个属性用 @Published 包装,我们将使用它们来跟踪用户还需要刷几轮、刷多久。

最后,我们需要一种方法来知道时间到了,并控制会话。

用户开始刷牙后,我们需要创建会话并更新表盘按钮上显示的文本。将此添加到 ContentModel:

func startBrushing() {
    session = WKExtendedRuntimeSession()
    session.delegate = self
    session.start()
}

将以下代码添加到文件末尾实现 WKExtendedRuntimeSessionDelegate:

extension ContentModel: WKExtendedRuntimeSessionDelegate {

    func extendedRuntimeSessionDidStart(
        _ extendedRuntimeSession: WKExtendedRuntimeSession
    ) {
    }
    
    func extendedRuntimeSessionWillExpire(
        _ extendedRuntimeSession: WKExtendedRuntimeSession
    ) {
    }
    
    func extendedRuntimeSession(
        _ extendedRuntimeSession: WKExtendedRuntimeSession,
        didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
        error: Error?
    ) {
    }
}

遵守协议非常简单:

  1. 一旦 Session 开始运行,系统就会调用 extendedRuntimeSessionDidStart(_:)。
  1. 如果 App 即将超过 Session的时间限制,watchOS 将在强制使会话过期之前调用 extendedRuntimeSessionWillExpire(_:)。
  1. 无论出于何种原因,当会话完成时,watchOS 都会调用 extendedRuntimeSession(_:didInvalidateWith:error:)。

继续在 在extendedRuntimeSessionDidStart(_:) 中添加:

let secondsPerRound = 30.0
let now = Date.now
roundsLeft = 4
endOfRound = now.addingTimeInterval(secondsPerRound)
endOfBrushing = now.addingTimeInterval(secondsPerRound * 4)

let device = WKInterfaceDevice.current()
device.play(.start)

我们不关心实际的日期或时间:我们只需要特定的秒数。当 Session 开始时,让手表快速振动是很好的用户体验。

现在我们知道每轮刷牙需要多长时间,继续设置一个计时器。 添加以下代码以完成该方法:

timer = Timer(
    fire: endOfRound!,
    interval: secondsPerRound,
    repeats: true ) { _ in     
        self.roundsLeft -= 1
        guard self.roundsLeft == 0 else {
            self.endOfRound = Date.now.addingTimeInterval(secondsPerRound)
            device.play(.success)
            return     
        }
        extendedRuntimeSession.invalidate()
        device.play(.success)
        device.play(.success)
    }
RunLoop.main.add(timer, forMode: .common)

我们生成一个计时器,该计时器在当前刷牙 Round 结束时开始,并每隔 secondsPerRound 秒重复一次。如果仍有几轮要执行,则更新一轮结束的时间,以便更新视图的显示。 让手表振动让用户知道是时候切换到他们嘴巴的新部分了。如果最后一轮完成,我们可以进行两次振动提醒用户。最后,将计时器安排到 run loop 中。

extendedRuntimeSession(_:didInvalidateWith:error:) 是禁用计时器的地方:

func extendedRuntimeSession(
    _ extendedRuntimeSession: WKExtendedRuntimeSession,
    didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
    error: Error?
) {
    timer.invalidate()
    timer = nil     
    endOfRound = nil     
    endOfBrushing = nil     
    roundsLeft = 0
}

更新 UI

修改 ContentView:

import SwiftUI
 
struct ContentView: View {
    @Environment(.scenePhase) private var scenePhase
    @ObservedObject private var model = ContentModel()
    @State var showGettingReady = false     
    var body: some View {
        ZStack {
            VStack {
                Button {
                    showGettingReady = true                 
                } label: {
                    Text("Start brushing")
                }
                .disabled(model.roundsLeft != 0)
                .padding()
                if let endOfBrushing = model.endOfBrushing,
                   let endOfRound = model.endOfRound {
                    Text("Rounds Left: (model.roundsLeft - 1)")
                    Text("Total time left: (endOfBrushing, style: .timer)")
                    Text("This round time left: (endOfRound, style: .timer)")
                }
            }
            if showGettingReady {
                GetReadyView {
                    showGettingReady = false                     
                    model.startBrushing()
                }
            } else {
                EmptyView()
            }
        }
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

添加开始按钮、时间展示,并运行项目,我们的 Dentisit 就开始工作了:

附件