【Swift】BackgroundTasksでいつでも新鮮アプリ

はじめに

iOS13からBackgroundTasksというフレームワークが追加されました。

これまではアプリ内のコンテンツを最新に保つため、アプリがバックグラウンドにいる間も定期的に処理を行いたいと考えた場合、Background Fetchというものを使用していました。

これがDeprecatedになり、代わりに用意されたのがBackgroundTasksです。

BackgroundTasksでできることは以下の2点です。

  • アプリを最新に保つための30秒以内で終わる処理(Background Fetchの新装版)
  • 重めのデータ更新や機械学習のトレーニング処理、データベースのメンテナンス等、数分程度かかる処理(Background Processing)← New!

今回はこちらの使い方をまとめたいと思います。

製作・実行環境は Xcode 11.6 / Swift 5.2 です。

・2020/09/15 編集
WriteArticlesOperationとDeleteOldArticlesOperationのコードの中で、DataManagerのインスタンスを作る箇所をメンバー変数の定義位置からmain関数の中に移動しました。(理由は以下のとおり)

If you use Operation, you must create the context in main (for a serial queue) or start (for a concurrent queue).

Apple Developer Documentation – NSManagedObjectContextの「Concurrency」の項目

作るものの説明

BackgroundTasksの説明をするにあたり、ここではサンプルとして「インターネットからニュースのRSSを拾ってきて、その記事タイトルの一覧を表示するアプリ」を作ってみたいと思います。

Background Fetchを使って定期的に新しい記事タイトルを取得し、Background Processingを使って古くなった記事を削除します。
ユーザーはただ見るだけのアプリです。

RSSはVer2.0のものを想定しています。
下記サンプルソース中のRSSへのURLは適宜書き換えてください。

まずは準備

BackgroundTasksを使用するには「XcodeプロジェクトのCapabilitiesにBackground Modeを追加」と「Info.plistにタスクの識別子を記述」の2点の準備が必要です。

それぞれ見ていきましょう。

Background Modeの追加

Xcodeのプロジェクト設定から必要なターゲットを選択して、① Signing & Capabilities → ② + Capability とクリックします。

それから、出てきたウィンドウから ③ Background Modes を選んでダブルクリックします。

追加されたBackground Modesの中から、① 最大30秒のアプリのリフレッシュ処理を行いたい場合は「Background fetch」を、② 数分程度かかる少し重めの処理を行いたい場合は「Background processing」を、それぞれチェックONします。

ここでは両方試すため、両方にチェックを入れます。

Info.plistの設定

次にInfo.plistのエディタを開いて、新規キー「BGTaskSchedulerPermittedIdentifiers」を追加します。(permitted background… と打てば出てきます)

中身はStringのArrayで、ここに “タスク”(バックグラウンド処理の実行単位)の識別子を、作成するタスク分だけ追加します。

システムはここで記載した識別子のタスクのみ認識するため、ここでの記載を忘れると動作しません。

また、BGTaskSchedulerPermittedIdentifiersのキーを追加すると、iOS13以降では従来のBackground Fetchの記述(application(:performFetchWithCompletionHandler:) やsetMinimumBackgroundFetchInterval(:))が無効となるため、既存アプリをiOS13に対応させる場合などでは注意が必要です。

ここでは例として、Background FetchとBackground Processingのタスクを1つずつ作るため、

  • com.exampleapp.LearnBackgroundTasks.apprefresh
  • com.exampleapp.LearnBackgroundTasks.maintenance

の2行を追加しています。

識別子はシステム全体でユニークである必要があるため、リバースドメイン形式での命名が推奨されています。

オペレーションの作成

準備ができたら、最初にバックグラウンド処理の中身となる “オペレーション” を作成します。

オペレーションはOperationクラスを派生して作ります。

ここでは例として、以下の3つのオペレーションを作成します。

GetArticlesOperation
 HTTP通信でニュースのRSSを取得し、記事タイトルを抜き出す

WriteArticlesOperation
 記事タイトルの一覧をCore Dataに書き込む

DeleteOldArticlesOperation
 Core Dataから古い記事を削除する

GetArticlesOperation

HTTP通信でニュースのRSSを取得し、記事タイトルを抜き出すオペレーションです。

RSSのXML解析は適当(正規表現で引っこ抜いているだけ)です。

NSRegularExpressionの使い方は別記事にてまとめていますので、よければ合わせてご参照ください。

Operationの使い方に関しては他のサイト様を参照してください。

import Foundation

class GetArticlesOperation: Operation {

    enum OperationError: Error {
        case cancelled
    }

    // URLSessionのバックグラウンド実行用の識別子
    private let identifier: String = "get_articles_operation"

    private var url: URL
    private var dataTask: URLSessionDataTask? = nil
    private var titles: [String] = []

    // 処理結果を格納する変数
    var result: Result<[String], Error>? = nil

    init(url: URL) {
        self.url = url
    }

    // 非同期処理を行う場合、isAsynchronousをオーバーライドしてtrueを返すようにする
    override var isAsynchronous: Bool {
        return true
    }

    // 非同期処理を行う場合、isExecutingのオーバーライドが必要
    // 値を変更するときはKVOの変更通知を行う
    private var _isExecuting: Bool = false
    override var isExecuting: Bool {
        get {
            _isExecuting
        }
        set {
            willChangeValue(forKey: #keyPath(isExecuting))
            _isExecuting = newValue
            didChangeValue(forKey: #keyPath(isExecuting))
        }
    }

    // 非同期処理を行う場合、isFinishedのオーバーライドが必要
    // 値を変更するときはKVOの変更通知を行う
    private var _isFinished: Bool = false
    override var isFinished: Bool {
        get {
            _isFinished
        }
        set {
            willChangeValue(forKey: #keyPath(isFinished))
            _isFinished = newValue
            didChangeValue(forKey: #keyPath(isFinished))
        }
    }

    // キャンセル処理
    override func cancel() {
        super.cancel()

        if let task = dataTask {
            task.cancel()
        }
    }

    // 非同期オペレーションのエントリーポイント
    override func start() {

        // 実行中フラグON
        isExecuting = true

        if isCancelled {
            completionHandler(result: .failure(OperationError.cancelled))
            return
        }

        // バックグラウンド用セッションの作成
        let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
        configuration.isDiscretionary = false

        let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

        // RSS XML取得
        let request = URLRequest(url: url)
        
        dataTask = session.dataTask(with: request)
        dataTask!.resume()
    }

    // 処理完了ハンドラ
    private func completionHandler(result: Result<[String], Error>) {

        guard isExecuting else {
            return
        }

        // 実行中フラグOFF
        isExecuting = false

        self.result = result
        self.dataTask = nil

        // 終了フラグON
        isFinished = true
    }
}

extension GetArticlesOperation: URLSessionDelegate, URLSessionDataDelegate {

    // データ受信完了
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {

        if isCancelled {
            completionHandler(result: .failure(OperationError.cancelled))
            return
        }

        let xml = String(data: data, encoding: .utf8)!

        do {
            var titles: [String] = []

            // 正規表現でタイトル抽出
            let pattern = "<title>([^<]+)</title>"
            let regex = try NSRegularExpression(pattern: pattern, options: [
                .anchorsMatchLines, .dotMatchesLineSeparators
            ])
            let range = NSRange(location: 0, length: xml.count)
            let results = regex.matches(in: xml, options: [], range: range)

            for result in results {
                let start = xml.index(xml.startIndex, offsetBy: result.range(at: 1).location)
                let end = xml.index(start, offsetBy: result.range(at: 1).length)
                let text = String(xml[start..<end])

                titles.append(text)
            }

            // 1行目はRSSのタイトルなので落とす
            if !titles.isEmpty {
                titles.remove(at: 0)
            }

            self.titles = titles
        }
        catch {
            // No Action
        }
    }

    // タスク完了
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {

        if isCancelled {
            completionHandler(result: .failure(OperationError.cancelled))
            return
        }

        if let error = error {
            self.completionHandler(result: .failure(error))
        }
        else {
            self.completionHandler(result: .success(titles))
        }
    }
}

WriteArticlesOperation

記事タイトルの一覧をCore Dataに書き込むオペレーションです。

Core Dataに関する処理(DataManagerクラス)は後述します。

import Foundation

class WriteArticlesOperation: Operation {

    // 記事タイトルの一覧
    // 外部から渡してもらう想定
    var titles: [String] = []

    // 同期オペレーションのエントリーポイント
    override func main() {

        // CoreData関連のマネージャ
        let manager = DataManager(concurrencyType: .privateQueueConcurrencyType)

        manager.context.performAndWait {

            titles.forEach {
                let article = manager.insertNewArticle()
                article.date = Date()
                article.title = $0
            }

            manager.commit()
        }
    }
}

DeleteOldArticlesOperation

Core Dataから古い記事を削除するオペレーションです。

import Foundation

class DeleteOldArticlesOperation: Operation {

    // 有効期限
    private var expirationDate: Date

    init(expirationDate: Date) {
        self.expirationDate = expirationDate
    }

    // 同期オペレーションのエントリーポイント
    override func main() {

        // CoreData関連のマネージャ
        let manager = DataManager(concurrencyType: .privateQueueConcurrencyType)

        manager.context.performAndWait {
            manager.deleteArticles(olderThan: expirationDate)
            manager.commit()
        }
    }
}

DataManager

Core Dataに関連する処理を行うクラスです。

Core Dataの基本的な使い方についてはこちらを参照してください。

このサンプルでは以下のように、Articleというエンティティを1つだけ作成しています。

↓DataManagerクラス

import UIKit
import CoreData

class DataManager {

    /// Persistent Container of Core Data
    private static let persistentContainer = (UIApplication.shared.delegate as! AppDelegate).persistentContainer

    /// Managed Object Context
    private var privateContext: NSManagedObjectContext? = nil

    var viewContext: NSManagedObjectContext {
        return DataManager.persistentContainer.viewContext
    }

    var context: NSManagedObjectContext {
        return privateContext ?? viewContext
    }

    /// Convenience Initializer
    convenience init(concurrencyType: NSManagedObjectContextConcurrencyType) {
        self.init()

        // コンテキスト作成
        let context = NSManagedObjectContext(concurrencyType: concurrencyType)
        context.persistentStoreCoordinator = DataManager.persistentContainer.persistentStoreCoordinator
        context.automaticallyMergesChangesFromParent = true
        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy

        self.privateContext = context
    }

    /// 保存
    func commit() {

        guard context.hasChanges else {
            return
        }

        do {
            try context.save()
        }
        catch let error as NSError {
            print("Save Context Error: \(error), \(error.userInfo)")
        }
    }

    /// 取り消し
    func rollback() {
        context.rollback()
    }

    /// 新規記事の追加
    func insertNewArticle() -> Article {
        return NSEntityDescription.insertNewObject(forEntityName: "Article", into: context) as! Article
    }

    /// 記事一覧の取得
    func fetchArticles(newerThan date: Date? = nil) -> [Article] {

        let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Article")

        if let date = date {
            request.predicate = NSPredicate(format: "date >= %@", date as NSDate)
        }

        request.sortDescriptors = [
            NSSortDescriptor(key: "date", ascending: false)
        ]

        do {
            return try context.fetch(request) as! [Article]
        }
        catch {
            fatalError()
        }
    }

    /// 古い記事の削除
    func deleteArticles(olderThan date: Date? = nil) {

        let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Article")

        if let date = date {
            request.predicate = NSPredicate(format: "date < %@", date as NSDate)
        }

        let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)

        do {
            try DataManager.persistentContainer.persistentStoreCoordinator.execute(deleteRequest, with: context)
        }
        catch let error as NSError {
            print(error)
        }
    }
}

↓Articleエンティティ。後にSwiftUIのForEachで回すため、Identifiableプロトコルに適用させています。

import Foundation
import CoreData

@objc(Article)
public class Article: NSManagedObject, Identifiable {

    public var id: String = UUID().uuidString
}
import Foundation
import CoreData


extension Article {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Article> {
        return NSFetchRequest<Article>(entityName: "Article")
    }

    @NSManaged public var title: String
    @NSManaged public var date: Date?

}

タスクのスケジュール

オペレーションを作成したら、次はタスクをスケジュールする処理を書きます。

ここではUIApplicationDelegate(AppDelegateクラス)に書いてしまいます。
(ひとまず必要な部分だけ。ソースの全体は最後に別途載せます)

import UIKit
import CoreData
import BackgroundTasks

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    /// リフレッシュ処理タスクのIdentifier
    private let apprefreshIdentifier: String = "com.exampleapp.LearnBackgroundTasks.apprefresh"

    /// メンテナンス処理タスクのIdentifier
    private let maintenanceIdentifier: String = "com.exampleapp.LearnBackgroundTasks.maintenance"

    // --- (中略) ---

    // MARK: BackgroundTasks

    /// リフレッシュ処理タスクの予約
    func scheduleAppRefresh() {

        // リクエスト作成
        let request = BGAppRefreshTaskRequest(identifier: apprefreshIdentifier)

        // タスク実行までのディレイ
        // 指定日時より早くに実行されることはないが、逆にこの日時に達したら即実行されるものでもない
        request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)

        do {
            // リクエスト送信
            try BGTaskScheduler.shared.submit(request)
        }
        catch {
            print("Could not schedule app refresh: \(error)")
        }
    }

    /// メンテナンス処理タスクの予約
    func scheduleMaintenance() {

        // リクエスト作成
        let request = BGProcessingTaskRequest(identifier: maintenanceIdentifier)

        // nilにすると固定のディレイを置かず、純粋にシステムの判断で実行される
        request.earliestBeginDate = nil

        // ネットワーク接続を必要とするならtrueにする
        request.requiresNetworkConnectivity = false

        // 外部電源に接続されている場合にのみ実行するならtrue
        request.requiresExternalPower = true

        do {
            // リクエスト送信
            try BGTaskScheduler.shared.submit(request)
        }
        catch {
            print("Could not schedule maintenance: \(error)")
        }
    }

    // --- (以下略) ---

}

まずは3行目の
import BackgroundTasks
でフレームワークをインポート。

次に、Background Fetchのタスクをスケジュールする場合はBGAppRefreshTaskRequestを、Background Processingのタスクをスケジュールする場合はBGProcessingTaskRequestを使用します。
それぞれ生成する際に、Info.plistで定義した識別子を渡します。

各リクエストが持つメンバーとその役割についてはソース中のコメントに記載しました。

最後に、BGTaskSchedulersubmitメソッドにリクエストを渡して呼び出せばスケジュール完了となります。

ちなみに、公式ドキュメントにあるように、

Submitting a task request for an unexecuted task that’s already in the queue replaces the previous task request.

There can be a total of 1 refresh task and 10 processing tasks scheduled at any time. Trying to schedule more tasks returns BGTaskScheduler.Error.Code.tooManyPendingTaskRequests.

Apple Developer Documentation – https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler/3142252-submit

スケジュールできるタスクはBGAppRefreshTaskRequestは1つだけ、BGProcessingTaskRequestは10個までと制限があるようです。

また、スケジュール時に同じ識別子を持つ未実行のタスクがある場合は、新しいもので上書きされるようです。

スケジュール処理の呼び出し

スケジュールを行う処理を書いたので、それを呼び出す方も書いておきます。

AppleのサンプルソースなどではUIApplicationDelegateのapplicationDidEnterBackgroundで呼び出したりしていますが、iOS13からのSceneが有効になっているとこれが呼ばれないため、ここではUIWindowSceneDelegate(SceneDelegateクラス)に書きたいと思います。

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    // --- (中略) ---

    func sceneDidEnterBackground(_ scene: UIScene) {

        // バックグラウンド移行時にタスクのスケジュールを行う
        if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
            appDelegate.scheduleAppRefresh()
            appDelegate.scheduleMaintenance()
        }
    }
}

これでアプリがバックグラウンドに回った時に、両方のタスクがスケジュールされるようになりました。

タスクハンドラの作成

タスクのスケジュールができたので、次は呼ばれたときのハンドラを作成します。

引き続き、UIApplicationDelegate(AppDelegateクラス)に書き加えていきます。
(ここでは上記から追加された分だけ記載します)

import UIKit
import CoreData
import BackgroundTasks

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    // --- (中略) ---

    /// RSS URL
    private let rssURL: String = "(RSSのURLを記入してください)"

    // --- (中略) ---

    /// リフレッシュ処理タスクハンドラ
    private func handleAppRefresh(task: BGAppRefreshTask) {

        // 次回の予約
        self.scheduleAppRefresh()

        // 1. オペレーションキューの作成
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1

        // 2. オペレーションの作成
        // ここでは例として、RSSから記事タイトルの一覧を取得(GetArticlesOperation)し、
        // それをCore Dataに書き込む(WriteArticlesOperation)ことを行う
        let getOperation = GetArticlesOperation(url: URL(string: rssURL)!)
        let writeOperation = WriteArticlesOperation()

        // オペレーションとオペレーションを繋ぐためのオペレーション
        let blockOperation = BlockOperation { [unowned getOperation, unowned writeOperation] in
            if case let .success(titles) = getOperation.result {
                writeOperation.titles = titles
            }
            else {
                writeOperation.cancel()
            }
        }

        // 依存関係を加えて処理順を設定
        blockOperation.addDependency(getOperation)
        writeOperation.addDependency(blockOperation)

        let operations = [getOperation, blockOperation, writeOperation]
        let lastOperation = operations.last!

        // 3. 処理完了ハンドラの設定
        lastOperation.completionBlock = {

            // タスク完了の報告(オペレーションがキャンセルされてなければ成功とみなす)
            task.setTaskCompleted(success: !lastOperation.isCancelled)
        }

        // 4. タスクの実行が時間切れになったときの設定
        task.expirationHandler = {

            // すべてのオペレーションをキャンセル
            queue.cancelAllOperations()
        }

        // 5. キューに追加(実行)
        queue.addOperations(operations, waitUntilFinished: false)
    }

    /// メンテナンス処理タスクハンドラ
    private func handleMaintenance(task: BGProcessingTask) {

        // 1. オペレーションキューの作成
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1

        // 2. オペレーションの作成
        // ここでは例として、2日前より以前の記事を削除する
        let date = Date(timeIntervalSinceNow: -2 * 24 * 60 * 60)
        let operation = DeleteOldArticlesOperation(expirationDate: date)

        // 3. 処理完了ハンドラの設定
        operation.completionBlock = {

            // タスク完了の報告(オペレーションがキャンセルされてなければ成功とみなす)
            task.setTaskCompleted(success: !operation.isCancelled)
        }

        // 4. タスクの実行が時間切れになったときの設定
        task.expirationHandler = {

            // すべてのオペレーションをキャンセル
            queue.cancelAllOperations()
        }

        // 5. キューに追加(実行)
        queue.addOperation(operation)
    }

    // --- (以下略) ---

}

リフレッシュ用とメンテナンス用で2つ作成していますが、やっていることは同じです。

  1. OperationQueueの作成
    • この例では並列処理は行わないのでmaxConcurrentOperationCountに1を設定
  2. 実行するOperationの作成
  3. 処理が終わった際にBGTaskのsetTaskCompletedを呼ばないといけないので、最後に実行されるOperationに対して完了時処理を付加する
    • BGTaskはBGAppRefreshTaskとBGProcessingTaskの共通の親クラス
    • OperationがキャンセルされてもcompletionBlockは呼ばれるので、expirationHandlerでsetTaskCompletedを呼ぶ必要はない
  4. BGTaskに対し、タスクが時間切れになってしまった時に残りのオペレーションをキャンセルする処理を付加する
  5. キューにオペレーションを追加して、あとはよしなに実行してもらう

これに加え、リフレッシュ用では定期的に実行するため、冒頭で次回分のスケジュールを行っています。

タスクハンドラの登録

ハンドラを作っても、それをシステムに登録しないと呼び出してくれません。

ので、さらにUIApplicationDelegate(AppDelegateクラス)に書き加えます。

import UIKit
import CoreData
import BackgroundTasks

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    // --- (中略) ---

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // リフレッシュ処理用タスクハンドラの登録
        // - forTaskWithIdentifier: タスクの識別子
        // - using: タスクの実行キュー。nilを渡すとデフォルトのバックグラウンドキューが使用される
        // - launchHandler: タスク実行のためアプリが起動したときに呼ばれるブロック。引数はスケジュール時に使ったRequestによりBGAppRefreshTaskかBGProcessingTaskのどちらか
        BGTaskScheduler.shared.register(forTaskWithIdentifier: apprefreshIdentifier, using: nil) { task in
            self.handleAppRefresh(task: task as! BGAppRefreshTask)
        }

        // メンテナンス処理用タスクハンドラの登録
        BGTaskScheduler.shared.register(forTaskWithIdentifier: maintenanceIdentifier, using: nil) { task in
            self.handleMaintenance(task: task as! BGProcessingTask)
        }

        // 予約されているタスクの確認
        BGTaskScheduler.shared.getPendingTaskRequests { requests in
            print(requests)
        }

        return true
    }

    // --- (以下略) ---

}

ハンドラの登録は、BGTaskSchedulerのregisterメソッドを使って行います。

registerはタスクの数だけ呼び出し(ただし、同じ識別子のものを複数回呼んだらダメ)、かつ公式ドキュメントにあるように「application(_:didFinishLaunchingWithOptions:)のメソッドが終わるまでに実施する」必要があります。

Every identifier in the BGTaskSchedulerPermittedIdentifiers requires a handler. Registration of all launch handlers must be complete before the end of applicationDidFinishLaunching(_:).

Apple Developer Documentation – https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler/3180427-register

引数についてはソース中のコメントで示したとおり。
スケジュール時と登録時とで、識別子とタスクの種類の不一致がないようにしてください。

ちなみに、registerの下に書かれているBGTaskSchedulerのgetPendingTaskRequestsは、スケジュールされてまだ未実行状態にあるタスクを取得するメソッドになります。

これを使うとスケジュール状態の確認ができ、さらにcancelメソッド、cancelAllTaskRequestsメソッドを使うと実行をキャンセルすることもできますが、ここでは単にデバッグ目的のため書いています。

タスク周りに関しては、以上でもって完了となります。

確認用画面の作成

最後に画面を作ります。

取得した記事をリスト表示するだけの単純な画面です。

import SwiftUI

struct ContentView: View {

    private var articles: [Article] = []
    private var formatter: DateFormatter

    init() {
        // 2日分の記事を取得
        let manager = DataManager()
        self.articles = manager.fetchArticles(newerThan: Date(timeIntervalSinceNow: -2 * 24 * 60 * 60))

        let df = DateFormatter()
        df.calendar = Calendar(identifier: .gregorian)
        df.locale = .current
        df.timeZone = .current
        df.dateStyle = .short
        df.timeStyle = .short
        self.formatter = df
    }

    var body: some View {
        List {
            ForEach(self.articles) { article in
                VStack(alignment: .leading, spacing: 2) {

                    Text(self.formatter.string(from: article.date!))
                        .font(.system(size: 10))
                        .foregroundColor(.gray)

                    Text(article.title)
                        .font(.system(size: 16))
                }
            }
        }
    }
}

動作確認

一通りできたので、動作確認をしたいと思います。

基本的にはタスクのスケジュールが行われる操作をして待つだけなのですが、それだとデバッグが困難なので、ちゃんとタスクのテスト実行の方法も用意されています。

手動でタスクの実行を行う(開発時)

公式ドキュメントに書いてあるとおりですが、要するにタスクがスケジュールされた状態でアプリを一時停止させ、デバッガでコマンドを打って、アプリを再開させればいいようです。

やってみます。

まずはXcodeからアプリをデバッグ起動させ、アプリをバックグラウンドに移行させてタスクのスケジュールを行います。

再びデバッグ起動を行い、① タスクがスケジュールされていることを確認します。(上記のAppDelegateクラス内でgetPendingTaskRequestsを使ってprintした表示になります)

スケジュールが確認できたら、② Pauseボタンでアプリを一時停止させます。(公式ではブレークポイントを貼って止めるよう記載されていますが、これでも可能でした)

③ デバッガに以下のコマンドを打ってEnterを押します。

e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.exampleapp.LearnBackgroundTasks.apprefresh"]

※ 識別子の部分は適宜変更してください。

④ タスクが起動されると「Simulating launch for task〜」と表示されるので、⑤ Continueボタンを押してアプリを再開させます。

⑥ タスクの実行が開始されたら「Starting simulated task〜」、完了したら「Marking simulated task complete〜」とそれぞれ表示されます。

アプリを再起動してみると、無事記事の取得ができ、一覧が表示されました 🙌

以下のスクショは、MacのApacheにテスト用のrss.xmlを置いてそれをアクセスしたものになります。
上のコマンド実行時の日付と下の記事取得時の日付が離れている点については、お察しの上スルー願います。🙇‍♂️

自然にタスクが実行されるのを待ってみる

テスト実行で上手くいったので、今度は自然発生させてどうなるか見てみたいと思います。

ホームボタンを押してアプリをバックグラウンドに移行させ、待つことしばし……
(その間にテスト用rssを編集…)

で、その結果が↓

取れました 🙌

BGAppRefreshTaskRequestのearliestBeginDateに5分後と設定しましたが、実際はおよそ8分後に呼ばれたようです。

ちなみにこの後もしばらくおいて様子を見ていたのですが、実行間隔はだんだんと伸びていき、何回かするともうおよそ20〜40分ぐらいの間隔で呼ばれるようになっていました。
(その後のさらなるアプリの使用でまた変わるのかもしれませんが)

ですので、旧Background Fetchの時もそうでしたが、あまり短いスパンでの実行というのは期待しないほうが良さそうです。

また、ハンドラの中での再スケジュールに失敗したためか、途中のどこかで次回予約がなくなって更新が止まってしまっていることも何度か確認しました。(詳細不明)

何はともあれ、まだまだいろいろ調査が必要そうですが、ひとまず今回はこれで完成としたいと思います。

AppDelegateクラスの全体ソース

上では部分部分の掲載でわかりづらいかと思いますので、念の為全体を載せます。

import UIKit
import CoreData
import BackgroundTasks

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    /// リフレッシュ処理タスクのIdentifier
    private let apprefreshIdentifier: String = "com.exampleapp.LearnBackgroundTasks.apprefresh"

    /// メンテナンス処理タスクのIdentifier
    private let maintenanceIdentifier: String = "com.exampleapp.LearnBackgroundTasks.maintenance"

    /// RSS URL
    private let rssURL: String = "(RSSのURLを記入してください)"


    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // リフレッシュ処理用タスクハンドラの登録
        // - forTaskWithIdentifier: タスクの識別子
        // - using: タスクの実行キュー。nilを渡すとデフォルトのバックグラウンドキューが使用される
        // - launchHandler: タスク実行のためアプリが起動したときに呼ばれるブロック。引数はスケジュール時に使ったRequestによりBGAppRefreshTaskかBGProcessingTaskのどちらか
        BGTaskScheduler.shared.register(forTaskWithIdentifier: apprefreshIdentifier, using: nil) { task in
            self.handleAppRefresh(task: task as! BGAppRefreshTask)
        }

        // メンテナンス処理用タスクハンドラの登録
        BGTaskScheduler.shared.register(forTaskWithIdentifier: maintenanceIdentifier, using: nil) { task in
            self.handleMaintenance(task: task as! BGProcessingTask)
        }

        // 予約されているタスクの確認
        BGTaskScheduler.shared.getPendingTaskRequests { requests in
            print(requests)
        }

        return true
    }

    // MARK: BackgroundTasks

    /// リフレッシュ処理タスクの予約
    func scheduleAppRefresh() {

        // リクエスト作成
        let request = BGAppRefreshTaskRequest(identifier: apprefreshIdentifier)

        // タスク実行までのディレイ
        // 指定日時より早くに実行されることはないが、逆にこの日時に達したら即実行されるものでもない
        request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)

        do {
            // リクエスト送信
            try BGTaskScheduler.shared.submit(request)
        }
        catch {
            print("Could not schedule app refresh: \(error)")
        }
    }

    /// メンテナンス処理タスクの予約
    func scheduleMaintenance() {

        // リクエスト作成
        let request = BGProcessingTaskRequest(identifier: maintenanceIdentifier)

        // nilにすると固定のディレイを置かず、純粋にシステムの判断で実行される
        request.earliestBeginDate = nil

        // ネットワーク接続を必要とするならtrueにする
        request.requiresNetworkConnectivity = false

        // 外部電源に接続されている場合にのみ実行するならtrue
        request.requiresExternalPower = true

        do {
            // リクエスト送信
            try BGTaskScheduler.shared.submit(request)
        }
        catch {
            print("Could not schedule maintenance: \(error)")
        }
    }

    /// リフレッシュ処理タスクハンドラ
    private func handleAppRefresh(task: BGAppRefreshTask) {

        // 次回の予約
        self.scheduleAppRefresh()

        // 1. オペレーションキューの作成
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1

        // 2. オペレーションの作成
        // ここでは例として、RSSから記事タイトルの一覧を取得(GetArticlesOperation)し、
        // それをCore Dataに書き込む(WriteArticlesOperation)ことを行う
        let getOperation = GetArticlesOperation(url: URL(string: rssURL)!)
        let writeOperation = WriteArticlesOperation()

        // オペレーションとオペレーションを繋ぐためのオペレーション
        let blockOperation = BlockOperation { [unowned getOperation, unowned writeOperation] in
            if case let .success(titles) = getOperation.result {
                writeOperation.titles = titles
            }
            else {
                writeOperation.cancel()
            }
        }

        // 依存関係を加えて処理順を設定
        blockOperation.addDependency(getOperation)
        writeOperation.addDependency(blockOperation)

        let operations = [getOperation, blockOperation, writeOperation]
        let lastOperation = operations.last!

        // 3. 処理完了ハンドラの設定
        lastOperation.completionBlock = {

            // タスク完了の報告(オペレーションがキャンセルされてなければ成功とみなす)
            task.setTaskCompleted(success: !lastOperation.isCancelled)
        }

        // 4. タスクの実行が時間切れになったときの設定
        task.expirationHandler = {

            // すべてのオペレーションをキャンセル
            queue.cancelAllOperations()
        }

        // 5. キューに追加(実行)
        queue.addOperations(operations, waitUntilFinished: false)
    }

    /// メンテナンス処理タスクハンドラ
    private func handleMaintenance(task: BGProcessingTask) {

        // 1. オペレーションキューの作成
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1

        // 2. オペレーションの作成
        // ここでは例として、2日前より以前の記事を削除する
        let date = Date(timeIntervalSinceNow: -2 * 24 * 60 * 60)
        let operation = DeleteOldArticlesOperation(expirationDate: date)

        // 3. 処理完了ハンドラの設定
        operation.completionBlock = {

            // タスク完了の報告(オペレーションがキャンセルされてなければ成功とみなす)
            task.setTaskCompleted(success: !operation.isCancelled)
        }

        // 4. タスクの実行が時間切れになったときの設定
        task.expirationHandler = {

            // すべてのオペレーションをキャンセル
            queue.cancelAllOperations()
        }

        // 5. キューに追加(実行)
        queue.addOperation(operation)
    }

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
    }

    // MARK: - Core Data stack

    lazy var persistentContainer: NSPersistentCloudKitContainer = {

        let container = NSPersistentCloudKitContainer(name: "Model")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {

                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })

        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy

        return container
    }()
}

おわりに

バックグラウンドでいかに処理を走らせるかというのは、iOSアプリではしばしば出てくる課題ですね。

今回Background Processingなるものが追加され、少しずつですがAppleもやらせてくれることを増やしてくれている印象です。

上手く活用して、アプリをより良いものにしていきたいですね。

ではでは。