バックグラウンド更新を入れたつもりが、リリース後しばらく経って Crashlytics に BGTaskSchedulerErrorDomain Code=1 が静かに溜まっているのを見つける、というのは個人開発でいちばん起こりやすい事故のひとつだと感じています。私自身、累計5,000万ダウンロード規模のアプリ群を 2014年から運用してきたなかで、この Code=1 を本気で潰した経験は両手では足りません。発火しないことに気づくのが半年遅れたりすると、そのぶん DAU が静かに削られていきます。
Code=1 は仕様上 BGTaskSchedulerErrorCodeUnavailable と定義されており、ドキュメントには「Background App Refresh が無効、もしくは現在の環境でタスクをスケジュールできない状態」と一行だけ書かれています。実際に詰まる原因は 6 種類くらいに分かれていて、上から潰していけば必ず原因にたどり着けます。本稿は Rork で生成したアプリに後から BGTaskScheduler を組み込んだ前提で、その 6 パターンを順番に整理します。
宮大工だった両家の祖父を見て育ったせいか、私はこういう「動かない原因」を一段ずつ確認していく作業が嫌いではありません。表に出ない部分こそ手をかけたいタイプなので、参考にしていただけたら嬉しいです。
まず「どの状態で Code=1 になっているか」を特定する
Code=1 はひとつのエラーコードに 4〜5 種類の原因が押し込まれているため、最初に「いつ起きているか」を切り分けるだけで体感作業時間が半分になります。次のように submit の周囲をログで包んで、リリースビルドの実機・シミュレーター・TestFlight それぞれで挙動を確認します。
// AppDelegate.swift もしくは Expo モジュール内
import BackgroundTasks
import os
let log = Logger(subsystem: "net.rorklab.app", category: "bgtask")
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "net.rorklab.app.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
log.info("bgtask submitted: id=\(request.identifier)")
} catch let error as NSError {
log.error("bgtask submit failed: domain=\(error.domain) code=\(error.code) info=\(error.userInfo)")
// userInfo の NSLocalizedFailureReason をそのまま Crashlytics などに送る
}
}ここで userInfo を捨ててしまうと、後段の切り分けでもう一周ハマります。私は必ず userInfo ごと送って Crashlytics の Custom Key に乗せるようにしています。
原因 1:Info.plist の BGTaskSchedulerPermittedIdentifiers が未登録
いちばん多いのがこれです。submit する identifier が Info.plist の BGTaskSchedulerPermittedIdentifiers 配列に列挙されていない場合、iOS は Code=1 を返します。Expo / Rork 系の構成では app.json (もしくは app.config.ts) の ios.infoPlist から注入する形になります。
{
"expo": {
"ios": {
"infoPlist": {
"BGTaskSchedulerPermittedIdentifiers": [
"net.rorklab.app.refresh",
"net.rorklab.app.processing"
],
"UIBackgroundModes": ["fetch", "processing"]
}
}
}
}注意点としては、Bundle ID と prefix が一致している必要はないものの、実機での submit 時に Info.plist 配列に文字通り一致する文字列が存在しないと弾かれます。prebuild 後に Xcode 上でも Info.plist を開いて値が反映されているか必ず確認します。EAS Build を挟む構成だと、prebuild をスキップして古い Info.plist のままビルドが回ってしまうことがあるので、eas build --clear-cache を 1 度走らせるのも有効です。
原因 2:Background App Refresh が OS 設定で OFF
実機で再現するけれどシミュレーターでは起きない、というときはほぼこれです。「設定 > 一般 > Background App Refresh」が OFF になっている、もしくは「省電力モード」が ON になっていると、ユーザーごとに submit が Code=1 で弾かれます。
これはアプリ側で復旧できる問題ではないので、UX 上は次の 2 点で対応します。
- 初回起動時に
UIApplication.shared.backgroundRefreshStatusを確認し、.deniedまたは.restrictedのときは「最新情報を閉じている間も取りに行くには Background App Refresh を ON にしてください」と一段だけ案内を出す - Crashlytics には
backgroundRefreshStatusを Custom Key として常時送っておき、Code=1 の発生数と OFF ユーザー数の相関を後から確認できるようにする
これを入れただけで、私が運用しているアプリの 1 つでは「Code=1 のうち 7 割は OS 設定 OFF が原因」だったことが分かり、無駄な再現調査をしなくて済むようになりました。
原因 3:シミュレーターで実行している
地味に時間を奪うのがこれです。BGTaskScheduler はシミュレーターでも submit 自体は通る場合があるのですが、iOS 17 以降は条件によって Code=1 を返すようになっています。シミュレーターでバックグラウンド処理を検証するときは、submit の成功・失敗ではなく、デバッガから直接タスクを発火させる方を主軸にします。
LLDB で次のコマンドを叩くと、登録済みのタスクを強制的に発火できます。
(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"net.rorklab.app.refresh"]これで handler が即座に呼ばれるので、Code=1 自体は気にせず、handler 側のロジックの検証に集中できます。実機での submit 成功率の確認は TestFlight 配信後に Crashlytics の Code=1 件数で見るのが現実的です。
原因 4:UIBackgroundModes の指定漏れ
BGAppRefreshTaskRequest を submit するなら UIBackgroundModes に fetch が必要、BGProcessingTaskRequest を使うなら processing も必要、というのは公式ドキュメントどおりなのですが、Expo / Rork の構成では prebuild 時に上書きされて消えるケースがあります。
{
"ios": {
"infoPlist": {
"UIBackgroundModes": ["fetch", "processing"]
}
}
}prebuild 後に ios/{AppName}/Info.plist の UIBackgroundModes を必ず目視確認します。fetch だけでは BGProcessingTaskRequest で Code=1 を返されるので、両方使うアプリは必ず 2 種類書きます。
原因 5:earliestBeginDate を過去 / 直近すぎる時刻にしている
earliestBeginDate を Date() のような直近の時刻にすると、iOS のスケジューラの内部状態次第で Code=1 を返してくることがあります。最低でも 15 分先に設定するのが安全です。
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)過去日時を入れた場合は Code=1 ではなく Code=3 (TooManyPendingTaskRequests) 相当の挙動になることもあり、エラーコードだけ見て切り分けると遠回りします。earliestBeginDate を nil もしくは 15 分以上先に固定するのが安定挙動でした。
原因 6:register と submit の identifier 不一致
BGTaskScheduler.shared.register で登録した identifier と submit した identifier がわずかでも違うと Code=1 になります。Rork の生成コードを後から手動で編集すると、文字列がコピペでずれることがあるので、定数化して両者で参照する形に統一しておくと安全です。
enum BackgroundTaskID {
static let appRefresh = "net.rorklab.app.refresh"
static let processing = "net.rorklab.app.processing"
}
// AppDelegate.swift
BGTaskScheduler.shared.register(forTaskWithIdentifier: BackgroundTaskID.appRefresh, using: nil) { task in
handleAppRefresh(task: task as! BGAppRefreshTask)
}私の場合、6 本並行運用している壁紙アプリのうち 1 本で「app.refresh と appRefresh の表記揺れ」を見落として 2 週間 Code=1 を出し続けたことがあり、それ以来必ず enum で固めるようにしています。
切り分けが終わったあとの確認順序
ここまでで原因が一巡したので、最後に「次に何をするか」を明確にしておきます。
- AppDelegate / Expo モジュールに
submitのuserInfo込みログを仕込む(数分) - Info.plist の
BGTaskSchedulerPermittedIdentifiersとUIBackgroundModesを Xcode で目視確認する(5 分) - 実機で「設定 > 一般 > Background App Refresh」を ON にして再現するか確認する
register/submitの identifier を enum で固める(10 分)- TestFlight 配信して 24 時間 Crashlytics で Code=1 の件数推移を見る
ここまでやれば、Code=1 の 9 割は原因が特定でき、残りの 1 割は OS 設定や省電力モード由来でアプリ側ではどうしようもない領域として扱えます。BGTaskScheduler はエラーコードがシンプルすぎる代わりに、原因の母数が決まっているので、いったんチェックリスト化してしまえば再発しても 30 分以内に原因にたどり着けます。
私自身、累計5,000万ダウンロードのアプリ群で同じ作業を繰り返してきましたが、最終的にこの順序に落ち着いたのは「文字通りこれ以上短くできない」という確信があるからです。同じ症状で詰まっている方の半日が、これで救われたら嬉しいです。実装の参考になれば幸いです。