📱

Live Activityをアプリ終了時に確実に終了させる方法を検証した

に公開

Live Activity とは

詳しい内容については Apple 公式のこちらをお読みいただきたいですが、Live Activity(ライブアクティビティ)は簡単にいうとロックスクリーンや iPhone のフロントカメラのある楕円部分の Dynamic Island などに表示可能な App Extension (Widget)です。
https://developer.apple.com/jp/design/human-interface-guidelines/live-activities

基本的な用途としては、アプリを開いていない状態でもアプリのステートなどを表示してユーザーに提供することができる非常に便利な機能ですが、アプリが終了した時には用無しとなるようなユースケースでは、Live Activity も終了させたいということは少なくないと思います。

今回は、このユースケースの実装時に色々学びがあったのでまとめておきます。

TL;DR

結論 の章をご覧ください。

アプリ終了時をフックするには?

まず、アプリ終了時をフックする方法が複数あります。

AppDelegate

これが最も適しているとは思いますが、終了前をフックするライフサイクルメソッドが AppDelegate にて提供されています。

AppDelegate.swift
func applicationWillTerminate(_ application: UIApplication) {
    // do something
}

https://developer.apple.com/documentation/uikit/uiapplicationdelegate/applicationwillterminate(_:)

SceneDelegate

こちらは、Scene が破棄された時、すなわち、通常ケースのような一つしか Scene を扱わないアプリにおいては実質アプリが終了する時であり、複数 Scene を用いるようなアプリケーションにおいてはその破棄タイミングで呼び出すことができるのでそういった場合にはこちらを使うのも良いと思われます。

SceneDelegate.swift
func sceneDidDisconnect(_ scene: UIScene) {
    // do something
}

https://developer.apple.com/documentation/uikit/uiscenedelegate/scenediddisconnect(_:)

Notification Center

最後に willTerminate は、Notification のキーとしても存在するため、AppDelegate 以外の場所で購読したい時はこちらを使うこともできると思います。

cancellable = NotificationCenter.default
    .publisher(for: UIApplication.willTerminateNotification)
    .sink { _ in
        // do something
    }

https://developer.apple.com/documentation/uikit/uiapplication/willterminatenotification

Live Activity を終了するには?

終了を呼び出すには、LiveActivity を取り出して、end メソッドを呼ぶ形になります。
dismissalPolicy は破棄の仕方を指定することができますが、今回のケースとしては .immediate を指定するのが良いでしょう。
複数個出せてしまうポテンシャルもあるので、網羅的に全てを取得して全て終了するというのが確実かなと思います。

for activity in Activity<Attributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
}

https://developer.apple.com/documentation/activitykit/activity/end(_:dismissalpolicy:)

⚠️ end(_:dismissalPolicy:) は async メソッド

ここで、注意なのが end メソッドが非同期関数となるため、上記アプリ終了をフックする同期的なメソッドで呼び出すには Task を用いて非同期処理を構築する必要があることです。
Task の呼び出し方にも様々あるため、色々な組み合わせで動作を検証しました。

検証

シンプルな 1 つの Scene を持つ SwiftUI App のプロジェクトを立ち上げ、起動時に Live Activity を開始し、アプリ終了時に Live Activity も終了させる形で、アプリ終了時の終了のさせ方を様々な方法にて検証してみました。

Task の作り方は、Task(), Task.detached()の 2 種類と、ライフサイクルメソッドが同期処理のため、同期的な処理になるように DispatchSemaphore を使った 2 パターンの計 4 種の実装においてそれぞれのライフサイクルで終了ができるか検証しました。

https://developer.apple.com/documentation/dispatch/dispatchsemaphore

パターン別の実装

それぞれ 4 種の実装はこんな感じになります。

// Task
Task {
  for activity in Activity<Attributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
  }
}

// Task.detached
Task.detached {
  for activity in Activity<Attributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
  }
}

// Task + Semaphore
let semaphore = DispatchSemaphore()
Task {
  for activity in Activity<Attributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
  }
  semaphore.signal()
}
semaphore.wait()

// Task.detached + Semaphore
let semaphore = DispatchSemaphore()
Task.detached {
  for activity in Activity<Attributes>.activities {
    await activity.end(nil, dismissalPolicy: .immediate)
  }
  semaphore.signal()
}
semaphore.wait()

検証手順

今回は、現時点での最新環境の Xcode 16.3 (Swift 6) 及び、 OS は 18.4 の実機 iPhone で検証を行いました。
また、検証の際のアプリの終了方法として、挙動に違いがあったため、以下の 2 つのパターンそれぞれにて実施しました。

  1. アプリを開いた状態(フォアグラウンド)からアプリスイッチャーへ移行し、終了する
  2. アプリを起動し、ホーム画面を表示させて(バックグラウンド)からアプリスイッチャーへ移行し、終了する

検証結果

⚪︎ どちらの検証手順でも LiveActivity を終了できた
△ 検証手順のどちらかのみ LiveActivity を終了できた
× いずれの手順でも LiveActivity を終了できなかった

# Task Task + Semaphore Task.detached Task.detached + Semaphore
AppDelegate △ (手順 2 のみ) × × ⚪︎
SceneDelegate △ (手順 2 のみ) × ⚪︎ ⚪︎ (※ Hang risk)
Notification × × × ⚪︎

※ Hang risk: Debug 中に下記の紫色の警告ログが出ました。
Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions

結論

アプリの終了時に Live Activity を確実に終了させるには、、、

AppDelegate や NotificationCenter で実装するなら、Task.detached + Semaphore パターン

AppDelegate.swift
func applicationWillTerminate(_ application: UIApplication) {
    let semaphore = DispatchSemaphore()
    Task.detached {
        for activity in Activity<Attributes>.activities {
            await activity.end(nil, dismissalPolicy: .immediate)
        }
        semaphore.signal()
    }
    semaphore.wait()
}

SceneDelegate で実装するなら、Task.detached パターンが良さそうです。(Task.detached + Semaphoreパターンでも警告ログは出るが動作上は問題ないです)

SceneDelegate.swift
func sceneDidDisconnect(_ scene: UIScene) {
    Task.detached {
        for activity in Activity<Attributes>.activities {
            await activity.end(nil, dismissalPolicy: .immediate)
        }
    }
}

その他考察

今回検証した環境においては、SceneDelegate の sceneDidDisconnect の方が、AppDelegate の applicationWillTerminate よりもコールされるのが早い挙動でした。
それに伴い、SceneDelegate においては、非同期実行の書き方(Task.detached)にしていても、アプリが終了するまでに実行が完了するので問題なかったと推測されます。

逆に、applicationWillTerminate(Notification も含む)のタイミングで非同期実装にしていると実際にプロセスが Kill に至るまでの時間が短く、非同期実装だと終わりきらず、同期実装が必要になるということかもしれません。

アプリがフォアグラウンドにいる時にアプリスイッチャーからアプリを終了するのと、バックグラウンドにいるときにアプリスイッチャーからアプリを終了するのでも、このプロセス Kill に至るまでの時間に若干の差異があると思われる挙動でした。それが、Task と Task.detached の非同期実行時間の差異となって検証結果に現れ、後者のみ終了できたという風にも考察ができそうです。

そもそも、Task と Task.detached の違いは、親の actor や QoS などのコンテキストを引き継ぐか否かというところで差異がある認識ですが、Task での動作がいずれも完全な動作に至らなかったところを見ると、アプリ終了系のライフサイクルで呼び出されるコンテキストの priority が低めであるということがいえるでしょう。
であれば、DispatchSemaphore を用いて同期にすれば Task でも動作しそうですが、これが動作しなかった理由は正直わかりません。おそらく、ライフサイクル内で待てる時間を超えたから無視されたとかそういったなんらかのブラックボックスな要因が考えられそうです。
また、Task.detached を使う場合は、Task よりも priority が上がり、実行までの時間が早くなったという仮説が成り立ちそうです。引数の priority をいじることによって、より実行時間を早め確実性を上げることができるかもしれません(今回はそこまでは検証してません)。

Discussion