壁紙アプリの利用者から「もう三日も同じ絵のままです」という連絡をいただいたのは、深夜のことでした。
私の手元の端末では、毎朝きちんと絵が切り替わっています。再現しません。ログを引き出して、ようやく事情が飲み込めました。バックグラウンド更新のタスクは、確かに登録されていました。ただ、一度も実行されていなかったのです。
登録は申請にすぎません。実行されるかどうかは、OS が決めます。頭では理解していたはずのその一行が、実際の端末の上ではまったく違う重みを持っていました。
「毎朝更新される」という前提が崩れていた
私が組んでいたのは、素朴な設計でした。深夜に BGAppRefreshTask を予約し、翌朝の起動前に次の壁紙を用意しておく。Android 側は PeriodicWorkRequest で 24 時間ごとに同じことをする。ウィジェットは、そうして用意された画像を読むだけ。
シミュレータでは完璧に動きます。私の iPhone でも動きます。私は毎日そのアプリを開き、充電しながら寝る人間だからです。
利用者は違いました。低電力モードを常用する方。アプリを週に一度しか開かない方。バッテリー最適化の対象にアプリを入れている方。そうした端末では、タスクは予約されたまま順番が回ってこず、ウィジェットは何日も前の画像を表示し続けます。
不具合ではありません。仕様どおりの挙動です。設計が現実を見誤っていただけでした。
実行機会は、申請ではなく抽選である
iOS と Android で言葉づかいは違いますが、構造はよく似ています。アプリは「やりたいこと」を宣言し、OS は端末の状態を見て、実行してよい瞬間を割り当てます。割り当てられない日もあります。
| 観点 |
iOS(BGTaskScheduler) |
Android(WorkManager) |
| 最短間隔 |
earliestBeginDate は下限の希望。上限の保証はない |
PeriodicWorkRequest の最短は 15 分。実行は Doze の窓に依存 |
| 実行時間 |
App Refresh は概ね 30 秒程度で expirationHandler が呼ばれる |
Worker は 10 分。超えると isStopped が立つ |
| 抑制する主因 |
低電力モード、アプリの起動頻度、充電・通信状態 |
Doze、App Standby Bucket、メーカー独自の最適化 |
| 再起動後 |
予約は保持される(アプリ更新時は再登録が必要) |
永続化されるが、強制停止後は再登録まで動かない |
| 失敗の可視性 |
実行されない事実はどこにも記録されない |
WorkInfo に状態は残るが、間引きの理由は残らない |
最後の行が、この問題のいちばん厄介なところです。実行された記録は残ります。実行されなかった記録は、どこにも残りません。だから気づけません。
Android の App Standby Bucket は、その端末でのアプリの使われ方に応じて active / working_set / frequent / rare / restricted と段階が下がっていきます。週に一度しか開かれないアプリが rare に落ちれば、定期実行の窓は一日に一度あるかどうかまで狭まります。私のアプリはまさにそこにいました。
まず、実行機会そのものをログに残す
修正案を考える前に、計測を入れました。「どのくらい実行されていないのか」を数字で持たない限り、どの対策が効いたのかも判定できません。
iOS 側は、タスクの登録時刻と実行時刻を両方記録します。実行されなかったことは記録できませんから、代わりに「前回の実行からの経過時間」をアプリ起動時に読み出します。
import BackgroundTasks
import Foundation
enum RefreshLog {
private static let lastRunKey = "bg.lastRunAt"
private static let lastScheduleKey = "bg.lastScheduledAt"
private static let historyKey = "bg.runHistory"
static func markScheduled() {
UserDefaults.standard.set(Date(), forKey: lastScheduleKey)
}
static func markCompleted(success: Bool) {
let now = Date()
UserDefaults.standard.set(now, forKey: lastRunKey)
// 直近 30 件だけ保持する。解析はこの配列で足りる
var history = UserDefaults.standard.array(forKey: historyKey) as? [[String: Any]] ?? []
history.append([
"at": now.timeIntervalSince1970,
"scheduledAt": (UserDefaults.standard.object(forKey: lastScheduleKey) as? Date)?
.timeIntervalSince1970 ?? 0,
"success": success,
"lowPower": ProcessInfo.processInfo.isLowPowerModeEnabled,
])
UserDefaults.standard.set(Array(history.suffix(30)), forKey: historyKey)
}
/// アプリ起動時に呼ぶ。「最後に更新できてから何時間経ったか」が鮮度そのもの
static func stalenessHours() -> Double? {
guard let last = UserDefaults.standard.object(forKey: lastRunKey) as? Date else { return nil }
return Date().timeIntervalSince(last) / 3600
}
}
登録側では、markScheduled() を必ず通します。ここを忘れると「予約したつもりで予約できていなかった」ケースと、「予約したが実行されなかった」ケースを区別できなくなります。この二つは原因も対策もまったく違います。
func scheduleRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "net.example.wallpaper.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 6 * 3600)
do {
try BGTaskScheduler.shared.submit(request)
RefreshLog.markScheduled()
} catch {
// submit の失敗は静かに起きる。ここを握り潰すと原因追跡が不可能になる
Analytics.log("bg_submit_failed", ["reason": "\(error)"])
}
}
func handle(task: BGAppRefreshTask) {
scheduleRefresh() // 次回分を先に予約する。ここを最後に書くと expiration で失われる
let work = Task {
let ok = await WallpaperUpdater.shared.prepareNext()
RefreshLog.markCompleted(success: ok)
task.setTaskCompleted(success: ok)
}
task.expirationHandler = {
work.cancel()
RefreshLog.markCompleted(success: false)
}
}
handle の冒頭で次回分を予約している点が要です。処理の末尾に置くと、expirationHandler が先に呼ばれた回で予約が途切れ、そのまま二度と実行されない状態に入ります。本番のビルドで私が最初に踏んだ落とし穴が、まさにこれでした。回避策は単純で、予約を最初に済ませておくだけです。
Android 側は WorkInfo を観測すれば済むように見えますが、実際には「なぜ動かなかったか」が残らないため、Worker 自身に痕跡を書かせます。
class WallpaperRefreshWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val prefs = applicationContext.getSharedPreferences("bg", Context.MODE_PRIVATE)
val startedAt = System.currentTimeMillis()
val usageStats = applicationContext.getSystemService(UsageStatsManager::class.java)
val bucket = usageStats?.appStandbyBucket ?: -1 // rare 以下に落ちていないかを見る
return try {
WallpaperUpdater.prepareNext()
prefs.edit()
.putLong("lastRunAt", startedAt)
.putInt("lastBucket", bucket)
.apply()
Result.success()
} catch (e: IOException) {
// ネットワーク起因は再試行に値する。それ以外を retry に混ぜない
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
appStandbyBucket を一緒に残しておくと、後から「実行されなかった日のバケットは何だったか」を突き合わせられます。原因の当たりをつけるための、いちばん安い一行です。
この計測を二週間ほど回しました。個人開発で保守している壁紙アプリ四本から得られたのは、率直に言って想定より厳しい数字でした。24 時間以内に少なくとも一度の実行機会が回ってきた端末は、iOS で概ね 70% 前後。Android では rare バケットに落ちた端末に限ると 45% 前後にとどまります。残りの端末では、鮮度は二日、三日と伸びていきます。
数字を見た瞬間、胸が冷えました。私は「たまに失敗する処理」を書いていたつもりでしたが、実際には「多くの利用者には届かない処理」を書いていたのです。
鮮度を SLO として定義し直す
ここで設計の言葉を変えました。「毎朝更新する」ではなく、「壁紙の経過時間(staleness)を、どの分位でどこまで許容するか」を決めます。
私が置いた基準はこうです。p50 で 24 時間以内、p90 で 72 時間以内。そして 7 日を超えたら、それは更新の遅延ではなく機能の停止として扱う。
この言い換えには、実務上の効き目が三つありました。
第一に、対策の優劣を比較できるようになります。「サイレントプッシュを足すと p90 が 72 時間から 30 時間に縮む」といった形で、施策が数字に接続します。
第二に、諦めてよい領域が決まります。p90 を守るためにアプリ起動時の重い再生成を全端末で走らせるのは、電池と体感速度を犠牲にしすぎです。分位を切っておけば、どこまで手を尽くすかの線が引けます。この場合は、p90 を守る手段をバックグラウンド実行の外に求めるほうが筋が良いと私は考えています。
第三に、これがいちばん大きいのですが、バックグラウンド更新を「機能」ではなく「best effort な補助経路」として扱えるようになります。主経路は別に用意しなければならない、と設計の側から言えるようになります。
来ない前提で崩れない三層に組み直す
そこで、更新経路を三層に分けました。上の層ほど確実で、下の層ほど心地よい。
第一層は、自己完結する生成です。 ウィジェットのタイムラインを組む時点で、次の数エントリぶんの絵を決定論的に決めてしまいます。日付と端末ごとのシードから選択インデックスを算出すれば、ネットワークもバックグラウンド実行も要りません。更新が一度も走らなくても、明日の朝の絵は変わります。
func entryIndex(for date: Date, seed: UInt64, count: Int) -> Int {
let day = Int(date.timeIntervalSince1970 / 86400)
// 端末ごとのシードで並びをずらし、全利用者が同じ絵を見る事態を避ける
var hasher = SplitMix64(state: seed &+ UInt64(day))
return Int(hasher.next() % UInt64(count))
}
この一手で、ウィジェットが何日も凍りつく症状は消えました。バックグラウンド更新の役割は「新しい絵をダウンロードして候補集合を増やす」ことに縮み、「今日の絵を決める」責務からは外れます。責務を減らしたことが、そのまま信頼性になりました。
第二層は、機会主義的な更新です。 アプリが前面に出た瞬間、stalenessHours() を読み、閾値を超えていれば軽い更新を走らせます。利用者がアプリを開いてくれるなら、それが最も確実な実行機会です。
第三層が、バックグラウンド更新とサイレントプッシュです。 ここは best effort と割り切りました。動けば鮮度が上がる。動かなくても第一層が支えている。
| 層 |
実行の確実性 |
担う責務 |
失敗時の影響 |
| 決定論的生成 |
常に成功する |
今日表示する絵の決定 |
なし(候補が古いままになるのみ) |
| 前面復帰時の更新 |
利用者の起動に依存 |
候補集合の補充・鮮度の回復 |
候補が増えない |
| バックグラウンド/プッシュ |
OS の裁量 |
起動しない利用者への補充 |
鮮度の分位が悪化する |
WidgetKit 側のリロード予算にも同じ考え方が要ります。予算をどう実測して設計に落としたかは、毎日変わるはずの壁紙ウィジェットが止まった — WidgetKit のリロード予算を実測して設計を組み直した記録 に詳しく書きました。あわせて読んでいただくと、なぜ「タイムライン側で数日ぶんを先に決める」判断に至ったかが繋がるはずです。
計測の集計は任せ、判断は手元に残す
ログを集めたところで、四本のアプリぶんの実行履歴を毎週眺める時間はありません。そこで集計と異常の指摘を Antigravity のエージェントに任せました。
私が渡したのは、日次でエクスポートされる実行履歴の JSON と、集計の定義だけです。エージェントは p50 / p90 の経過時間を算出し、前週との差分と、バケット別の内訳を短い所見にまとめて返します。
AGENTS.md に書いた境界は、素っ気ないほど単純なものです。
## background-refresh-report
- 入力は `metrics/bg_runs/*.json` のみ。アプリのソースは読まない
- 出力は `reports/bg_freshness_YYYY-MM-DD.md` の 1 ファイル
- p50 / p90 の staleness、実行率、バケット別内訳を必ず含める
- 前週比の悪化が p90 で 12 時間を超えたら、見出しに ⚠ を付ける
- **閾値の変更・スケジュールの修正・コードの編集は行わない**
最後の一行が肝心です。実運用で効いてくる注意点でもあります。エージェントは数字を並べ、悪化を指摘するところまで。閾値を緩めるのか、第二層を厚くするのか、それとも第三層に手を入れるのかは、私が決めます。ここを委ねてしまうと、鮮度が悪化するたびに閾値のほうが静かに緩んでいきます。実際、初期のプロンプトでは「必要なら設定値を調整してよい」と書いてしまい、二週間で SLO が骨抜きになりかけました。
似た構図は、完了通知の取りこぼしを扱った Managed Agent の完了通知を取りこぼさない — サーバーレス受信とポーリング突き合わせの二重化設計 でも書きました。届かないかもしれない通知を前提に突き合わせを置く発想は、届かないかもしれない実行機会を前提に層を分ける発想と、根が同じです。
Android 側で「実行されなかった」の理由を追う際は、プロセスの終了記録が手掛かりになります。強制停止やメモリ回収による終了は ApplicationExitInfo に残るため、クラッシュ一覧には一度も現れない不調 — ANR トレースを ApplicationExitInfo で回収し、エージェントに切り分けを渡す設計 の回収経路をそのまま流用できます。
三ヶ月後に残ったもの
組み直してから三ヶ月が経ちました。p90 の経過時間は 72 時間の基準を割り込み、おおむね 40 時間前後で落ち着いています。改善の大半は第一層、つまり「バックグラウンド更新に今日の絵を決めさせるのをやめた」ことによるものでした。バックグラウンド実行の成功率そのものは、ほとんど変わっていません。
不確実な仕組みを確実にしようと努力するより、不確実な仕組みに依存しないよう責務を移す。書いてしまえば当たり前ですが、私は半年ほど、前者にばかり時間を使っていました。リトライを足し、予約のタイミングを変え、earliestBeginDate を短くしては様子を見る。どれも数字を動かしませんでした。
次にこの種の設計に向き合うときは、順番を変えます。まず、失敗が記録されない経路を探す。次に、その経路に載っている責務を数える。載せすぎているなら、確実に動く層へ移す。実行率を上げにいくのは、そのあとです。同じ問題を抱えている方にも、私はこの順序を推奨します。
もし手元のアプリに定期実行があるなら、今日できることが一つあります。前回成功時刻を保存して、アプリ起動時に経過時間を読み出す。それだけで、いま自分が何を前提に設計しているのかが見えてきます。
最後までお読みいただき、ありがとうございました。私自身まだ手探りの部分が多く、鮮度の基準も端末の分布を見ながら少しずつ動かしています。同じ壁に向き合う方の、遠回りが一つでも減れば嬉しく思います。