Skip to main content
  1. Posts/

Background Taskでデータの自動更新や自動アップロードを実装する

iOSアプリがバックグランドにあるときに、何かしらのデータ更新やデータのアップロードを実現しようとする場合、Background Taskが便利です。 サーバからデータのフェッチをするなど短時間で済むタスクはBGAppRefreshTaskを、処理に長時間かかるタスクはBGProcessingTaskを使います。 今回は、この2つのBackground Taskについて、実験してわかったことをまとめます。

この投稿では、以下のリンクを参考に、実験してわかったことをまとめます。

本題の前に… #

iOSアプリでは、バックグラウンドで処理を実行する様々な方法が用意されています。 適切な方法を選択することで、アプリの快適な体験を実現することができるので、手法ごとの特性を踏まえて実装すると良いです。

Choosing Background Strategies for Your App | Apple Developer Documentation

2種類のBackground Taskと挙動 #

ではまず、Background Taskには2種類のタスクがあります。BGAppRefreshTaskBGProcessingTaskです。どちらも、アプリがバックグラウンドにいる時にOSから呼び出され、OSに登録したタスクを実行することができます。どちらも同じように見えますが、挙動の異なる部分があるので、表にまとめます。

BGTask 実行タイミング 実行時間の制限 強制終了 複数登録
BGAppRefreshTask 端末がアクティブになった時や、おそらくアプリをよく使う時間帯の前など。端末が非アクティブである必要はない。 30秒まで 端末のリソースに余裕がある間は止まらなさそう 複数のタスクを登録できるが、実行予約できるのは同時に1件まで
BGProcessingTask 端末が非アクティブの時。主に充電中が多い。 5分まで 実行中に端末がアクティブになると強制的にexpireされる。オプションで、ネットワークが必要か、外部電源が必要かを指定できる。 複数登録できる。10件まで?

BGAppRefreshTaskは、端末を通常通り使用しているときもバックグラウンドで呼び出されます。おそらく、アプリの使用状況を学習していて、アプリをよく使う時間帯の前に実行されているのだと思います。最長30秒の制限がありますが、30秒以内で強制終了されることは滅多にありませんでした。

BGProcessingTaskは、端末が非アクティブの時しか実行されません。また、基本的に充電中しか実行されません。バッテリー駆動でも呼び出せるように指定することができますが、充電中以外で呼び出された経験はありません。実行時間の制限は緩く、長時間にわたり実行できますが、端末がアクティブになった途端にexpireされます。

実装方法 #

実装方法は、ざっくり4ステップで完了です。

  • Capabilitiesに、Background Modeを追加する
  • Info.plistに、Background Taskのidentifierを追記する
  • Background Taskを登録し、呼び出された時のハンドラーを実装する
  • Background Taskをスケジュールする

Capabilitiesに、Background Modeを追加する #

まず、Background Taskを使う準備をします。Capabilitiesに、Background Modeを追加します。そして、BGAppRefreshTaskを使う場合は「Background fetch」を、BGProcessingTaskを使う場合は「Background processing」にチェックを入れます。

Info.plistに、Background Taskのidentifierを追記する #

次に、Info.plistにBackground Taskのidentifierを追記します。「Permitted background task scheduler identifiers」(BGTaskSchedulerPermittedIdentifiersと入力すると出現)に、identifierを追記ます。StringのArrayになっているので複数追記することができます。

Background Taskを登録し、呼び出された時のハンドラーを実装する #

では、ソースコードを編集していきます。Background Taskを実行できるようにするには、まずシステムに対してBackground Taskを登録します。この登録処理は、アプリの起動が完了する前に行う必要があります。AppDelegateがあればapplication(_:didFinishLaunchingWithOptions:)の中で行うのが良さそうです。SwiftUIのアプリでは、AppDelegateを使用していない場合の書き方もあるのでそちらも紹介します。

AppDelegateを使用している場合 #

application(_:didFinishLaunchingWithOptions:)で以下のコードを呼び出します。

BGTaskScheduler.shared.register(
    forTaskWithIdentifier: identifier,  // Info.plistにセットしたID
    using: nil,  // 実行するQueueを指定できる、nilならシステムが用意する
    launchHandler: handler(_:)  // システムから起動された時に呼ばれる
)

呼び出されていればOKなので、実装場所は別クラスでも大丈夫です。 IDが同じタスクを複数回登録されると、最新のもので上書きされます。 Developer Documentには、「システムはアプリをkillする」とありますが、そのような雰囲気はありませんでした。

では次に、BGTaskScheduler.shared.registerで渡したハンドラーを実装します。 このハンドラーは、システムがアプリを呼び出した時に実行されます。引数には、BGTaskのオブジェクトが渡されます。これをBGAppRefreshTaskBGProcessingTaskにダウンキャストして、何らかの処理をすることも可能です。

なお、このハンドラーを抜けてもアプリは終了せず、アプリの実行は継続されます。スコープが抜けないように意識する必要はなく、普通にアプリを実行している時と同じように、非同期処理を呼び出すことができます。

BGTaskScheduler.shared.register(
    forTaskWithIdentifier: identifier,
    using: nil
) { task in
    scheduleForNextBackgroundTask() // 次回のBackground Taskを予約する

    // 処理の呼び出し。
    // 他のクラスの実装を呼んでも良いし、Swift Concurrencyの実装でも良い
    let operation = BlockOperation {
        let duration = [10.0, 20.0, 30.0, 40.0].randomElement()!
        Thread.sleep(forTimeInterval: duration)
    }

    // Background Taskの処理時間が終了する時に呼び出されるハンドラー。
    // 時間切れになってもアプリがkillされるのではなく、アプリは停止するだけ。
    task.expirationHandler = {
        print("タイムアウト")
        operation.cancel()
    }

    operation.completionBlock = {
        print(!operation.isCancelled ? "正常終了" : "処理キャンセル")
        task.setTaskCompleted(success: !operation.isCancelled)
    }

    operationQueue.addOperation(operation)

    // ここまで実行してもアプリは終了せず、非同期タスクが継続される
}

SwiftUIの場合 #

SwiftUIの場合は、WindowSceneのモディファイアで登録を行います。 なお、AppDelegateAdapterを使っている場合はそちらの実装が優先されるような挙動がありました。 そのため、AppDelegateを使っている場合は、上の方法で実装するのが良さそうです。

WindowGroup {
    ContentView()
}
.backgroundTask(.appRefresh(identifier)) {
    await withTaskCancellationHandler {
        scheduleForNextBackgroundTask() // 次回のBackground Taskを予約する

        // 処理の呼び出し。
        // 他のクラスの実装を呼んでも良いし、Swift Concurrencyの実装でも良い
        let duration = [10, 20, 30, 40].randomElement()!
        try? await Task.sleep(nanoseconds: UInt64(duration * 100_000_000))

        print("正常終了")
    } onCancel: {
        // Background Taskの処理時間が終了する時に呼び出されるハンドラー。
        print("処理キャンセル")
    }
}

withTaskCancellationHandlerを使うことで、タイムアウトのハンドリングが可能です。 それが不要であれば、そのまま実装して問題ありません。

Background Taskをスケジュールする #

最後に、BackgroundTaskをスケジュールします。 多くの例では、アプリがバックグラウンドに遷移するタイミング(applicationDidEnterBackgroundなど)でスケジュールしますが、必ずそのタイミングである必要はありません。

BGAppRefreshTaskの場合 #

let request = BGAppRefreshTaskRequest(identifier: identifier)
request.earliestBeginDate = nil
try BGTaskScheduler.shared.submit(request)

BGProcessingTaskの場合 #

let request = BGProcessingTaskRequest(identifier: identifier)
request.earliestBeginDate = nil
request.requiresExternalPower = requiresExternalPower
request.requiresNetworkConnectivity = requiresNetworkConnectivity
try BGTaskScheduler.shared.submit(request)

earliestBeginDateは「少なくともこの日時までは実行しない」という指定であって、実行時刻を指定するものではありません。実行のタイミングはあくまでOS次第です。 requiresExternalPowerは、電源が必要かどうか。充電中に実行したいタスクは、これをtrueにします。ただし、これがfalseだからと言って、バッテリー駆動の時に実行されるかというと、あまり期待できない感じでした。充電していない場合、1日以上実行されないこともありました。 requiresNetworkConnectivityは、その名の通り、通信が必要かどうかを指定します。

デバッグ方法 #

最後に、デバッグ方法をまとめます。デバッグは、LLDBからコマンドを投げて実行させます。

  1. まず、アプリをデバッグ実行します。Xcodeから通常通りRunしたら良いです。
  2. つぎに、アプリをデバッグのため一時停止します
  3. LLDBのコマンドを入力する欄で、e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"BackgroundTaskのID"]を入力し、Enterします
  4. 一時停止を解除し、実行を再開します。

詳細は、Starting and Terminating Tasks During Development | Apple Developer Documentationにて。

バックグラウンド更新の設定状況の検出 #

iOSでは、「設定」アプリで、アプリのバックグラウンド更新を制御することができます。 このBackground Taskは、バックグラウンド更新が有効化されていないと実行されません。 これの検出には、以下の実装を使用します。

UIApplication.backgroundRefreshStatusDidChangeNotification

リアルタイムに検出したい場合は、

@Published private(set)
var backgroundRefreshStatus: UIBackgroundRefreshStatus = 
    UIApplication.shared.backgroundRefreshStatus

// Combineの場合
NotificationCenter.default.publisher(
    for: UIApplication.backgroundRefreshStatusDidChangeNotification
)
    .receive(on: DispatchQueue.main)
    .map { ($0.object as! UIApplication).backgroundRefreshStatus }
    .assign(to: &$backgroundRefreshStatus)

こんな感じで実装をしてあげれば、最新の設定状況を取得することができます。

おわりに #

今回は、iOSアプリのBackground Taskをまとめました。 実現するために準備することはいくつかありますが、比較的少ないステップで実現することができます。ユーザーがアプリを使用していない間に、データの処理や整理をすることは、ユーザーの体験を向上することにもつながります。バックグラウンドでサーバから最新のデータをフェッチしておいたり、たくさんの写真を整理しておいたり、アイデア次第ではスマホの体験や生活をより快適にするアプリを作ることができそうです。