バックグラウンド更新を入れたつもりが、リリース後しばらく経って Crashlytics に BGTaskSchedulerErrorDomain Code=1 が静かに溜まっているのを見つける、というのは個人開発でいちばん起こりやすい事故のひとつだと感じています。クラッシュしないので落ちたことにも気づけません。発火していないと気づくのが半年遅れれば、そのぶん DAU が静かに削られていきます。
Code=1 は仕様上 BGTaskSchedulerErrorCodeUnavailable と定義されており、ドキュメントには「Background App Refresh が無効、もしくは現在の環境でタスクをスケジュールできない状態」と一行だけ書かれています。実際に詰まる原因は 6 種類くらいに分かれていて、上から潰していけば必ず原因にたどり着けます。本稿は Rork で生成したアプリに後から BGTaskScheduler を組み込んだ前提で、その 6 パターンを順番に整理します。
Code=1 が厄介なのは、エラーの文面が原因を一つも教えてくれないところです。裏を返せば、原因の母数さえ確定させてしまえば、あとは上から潰していくだけの作業になります。実際に踏んだ順番のまま並べます。
まず「どの状態で 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 に乗せるようにしています。
Expo / Rork 構成では identifier が「自分のもの」ではない
先に、Swift で書いた場合には絶対に起きない Rork 固有の落とし穴を潰しておきます。Rork が生成するのは React Native(Expo)のアプリなので、Swift を直接触らずに expo-background-task 経由でバックグラウンド処理を組む方が多いはずです。
このとき内部で submit される identifier は、自分で決めた文字列ではなく Expo モジュールが持つ com.expo.modules.backgroundtask.processing です。Info.plist の BGTaskSchedulerPermittedIdentifiers にこの文字列が入っていなければ、JS 側のタスク定義がどれだけ正しくても iOS は走らせません。
{
"expo": {
"ios": {
"infoPlist": {
"BGTaskSchedulerPermittedIdentifiers": [
"com.expo.modules.backgroundtask.processing"
]
}
}
}
}CNG(Continuous Native Generation)で prebuild を通す構成なら、UIBackgroundModes とこの identifier は prebuild が自動で入れます。手を出さなければ動く、ということです。問題は、自分のネイティブ側タスクを足したくなって ios.infoPlist.BGTaskSchedulerPermittedIdentifiers を自分で書いたときに起きます。自前の identifier だけを列挙すると Expo の分が配列から抜け、JS 側のタスクだけが黙って止まります。
「Swift の submit は成功しているのに BackgroundTask が発火しない」という一見矛盾した状態は、たいていこれです。両方使うなら両方書きます。
"BGTaskSchedulerPermittedIdentifiers": [
"com.expo.modules.backgroundtask.processing",
"net.rorklab.app.refresh"
]この要件が公式ドキュメントに書かれていない期間が長く、expo/expo の Issue #40440 で「ドキュメントに記載がない」と報告されています。公式を読んでも見つからないときは、先に Issue を検索したほうが早いことがあります。
もう一点、prebuild を繰り返すと config plugin が同じキーを重ねて書き込み、Info.plist の配列に同じ値が並ぶ挙動も報告されています。値そのものは残るので Code=1 の直接原因にはなりませんが、diff を読むときのノイズになります。配列が膨らんできたら prebuild --clean を一度通します。
原因 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 で固めるようにしています。
submit が「通ったつもり」になっていないか確かめる
submit が例外を投げなかったことと、iOS がその登録を保持していることは別の話です。登録が本当に残っているかは getPendingTaskRequests で読み出せます。
func dumpPendingTasks() {
BGTaskScheduler.shared.getPendingTaskRequests { requests in
guard !requests.isEmpty else {
log.error("no pending bgtask requests — submit did not persist")
return
}
for request in requests {
log.info("pending: id=\(request.identifier) earliest=\(String(describing: request.earliestBeginDate))")
}
}
}使い方は、アプリを一度バックグラウンドへ送ってから前面に戻し、この関数を呼ぶだけです。読み方はこう分かれます。
submitの直後から空 → identifier か Info.plist 側の問題(原因 1・4・6)submit直後は入っているのに復帰後に空 → 端末側の状態に寄っている(原因 2・原因 5)
ログを一往復増やすだけで、原因が「自分で直せる側」と「ユーザーの OS 設定側」のどちらに寄っているかが先に分かります。私はこの判定を入れてから、再現しない不具合を追いかける時間がはっきり減りました。
観測できた症状から原因へ戻る
上から順に潰す代わりに、手元で見えている症状から逆引きしたいこともあります。実際に踏んだものを対応表にすると次のようになります。
| 観測できる症状 | 疑う原因 | 最初に見る場所 |
|---|---|---|
| シミュレーターでのみ Code=1、実機は成功 | 原因 3 | LLDB で直接発火させ handler 側だけを検証 |
| 実機でのみ Code=1、しかも特定の端末だけ | 原因 2 | 設定 > 一般 > Background App Refresh と省電力モード |
| 全端末・全ビルドで必ず Code=1 | 原因 1 / 原因 4 | prebuild 後の Info.plist の 2 つの配列 |
| app refresh は動くが processing だけ Code=1 | 原因 4 | UIBackgroundModes に processing があるか |
| Swift 側は成功するが JS のタスクだけ発火しない | Expo の identifier 欠落 | 許可配列に com.expo.modules.backgroundtask.processing があるか |
| submit は成功するのに保留リストが空 | 原因 5 | earliestBeginDate が直近すぎないか |
| ある日を境に急に Code=1 が増えた | 原因 1 / 原因 4 | 直近の prebuild で Info.plist が上書きされていないか |
最後の行が、個人開発でいちばん見落としやすいところです。コードを一行も触っていないのにエラーが増えたときは、まず prebuild の前後で Info.plist を diff します。原因がコードの外にあると分かるだけで、探す場所が変わります。
切り分けが終わったあとの確認順序
ここまでで原因が一巡したので、最後に「次に何をするか」を明確にしておきます。
- AppDelegate / Expo モジュールに
submitのuserInfo込みログを仕込む(数分) - Info.plist の
BGTaskSchedulerPermittedIdentifiersとUIBackgroundModesを Xcode で目視確認する(5 分) - 実機で「設定 > 一般 > Background App Refresh」を ON にして再現するか確認する
register/submitの identifier を enum で固める(10 分)- TestFlight 配信して 24 時間 Crashlytics で Code=1 の件数推移を見る
この 5 番目で、そもそも TestFlight まで届かないことがあります。私自身、Code=1 を追っていたつもりが App Store Connect の「処理中」で止まっていただけ、という回り道をしたことがありました。そちらで足踏みしているなら Rorkで書き出したアプリがApp Store Connectで「処理中」のまま止まる に切り分けをまとめています。
ここまでやれば、Code=1 の大半は原因が特定でき、残りは OS 設定や省電力モード由来でアプリ側ではどうしようもない領域として切り分けられます。発火したあとの処理そのものを本番品質で組む段になったら、Rorkアプリで「閉じてる間も最新」を本番品質で実装する に Silent Push と Android WorkManager まで含めた設計を書いています。BGTaskScheduler はエラーコードがシンプルすぎる代わりに、原因の母数が決まっているので、いったんチェックリスト化してしまえば再発しても 30 分以内に原因にたどり着けます。
この順序に落ち着いたのは、何度か遠回りをした末に「これ以上短くできない」と確信できたからです。同じ症状で止まっている方の半日が、これで戻ってきたら嬉しく思います。