壁紙アプリを個人開発で運用していて、いちばん要望が多かったのが「毎朝、開かなくても新しい壁紙に変わっていてほしい」でした。素朴に考えれば、バックグラウンドで定期実行して画像を差し替えればよさそうです。ところが iOS でこれを真面目に作ると、「昨日は更新されたのに今日は変わっていない」という再現性のない不具合報告に悩まされることになります。
原因は実装のバグではなく、iOS のバックグラウンド実行そのものが「いつ走るか分からない」前提で設計されているからです。Rork が生成するのは Expo アプリですが、その下では iOS の BGTaskScheduler が動いており、ここの気まぐれさを理解しないまま作ると、ユーザー体験が運任せになります。実行されない前提で破綻しない更新体験の作り方を、配線と判断の両面で、私自身が壁紙アプリで踏んだ落とし穴とともに追っていきます。
「毎朝更新されるはず」が更新されない理由
iOS のバックグラウンドタスクは、開発者が時刻を指定して必ず走らせられるものではありません。システムが、端末の使用パターン・バッテリー・ネットワーク状況を見て「今なら走らせてよい」と判断したときにだけ実行されます。
つまり、毎日アプリを開くユーザーには比較的よく走り、ほとんど開かないユーザーにはめったに走りません。皮肉なことに、いちばん「開かなくても更新されてほしい」休眠ユーザーほど、バックグラウンドが回らないのです。この非対称を最初に受け入れないと、設計を間違えます。
expo-background-task の最小登録
現在の Expo では、非推奨になった expo-background-fetch ではなく expo-background-task(内部で BGTaskScheduler / WorkManager を使う)を用います。タスク本体は expo-task-manager で定義します。
import * as TaskManager from "expo-task-manager";
import * as BackgroundTask from "expo-background-task";
const REFRESH_TASK = "daily-wallpaper-refresh";
TaskManager.defineTask(REFRESH_TASK, async () => {
try {
const updated = await fetchAndCacheTodaysWallpaper();
// 成功・失敗を必ず返す。返さないと iOS が将来の実行枠を絞る
return updated
? BackgroundTask.BackgroundTaskResult.Success
: BackgroundTask.BackgroundTaskResult.Failed;
} catch {
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
export async function registerRefreshTask() {
const status = await BackgroundTask.getStatusAsync();
if (status !== BackgroundTask.BackgroundTaskStatus.Available) return;
await BackgroundTask.registerTaskAsync(REFRESH_TASK, {
minimumInterval: 60 * 12, // 単位は分。ここでは12時間
});
}
app.json 側では iOS の UIBackgroundModes に processing を含める必要があります。Expo なら infoPlist に書きます。
{
"expo": {
"ios": {
"infoPlist": {
"UIBackgroundModes": ["fetch", "processing"],
"BGTaskSchedulerPermittedIdentifiers": ["daily-wallpaper-refresh"]
}
}
}
}
BGTaskSchedulerPermittedIdentifiers にタスク ID を書き忘れると、登録自体は通るのに実機で一度も走らない、という静かな失敗になります。私はここで半日溶かしました。
minimumInterval は「最短」であって「毎回」ではない
minimumInterval を12時間に設定しても、それは「12時間より短い間隔では走らせない」という下限の指定でしかありません。上限はなく、条件が揃わなければ24時間でも48時間でも走らないことがあります。
ここを「12時間ごとに走る」と読んでしまうと、テスト中はたまたま走って安心し、本番のユーザー端末で再現性のない不具合として跳ね返ってきます。あくまで希望を伝えるだけで、決定権は OS にある、と理解するのが正しい距離感です。
iOS が背面実行をケチる条件
実行確率が下がる典型条件は次の3つです。Low Power Mode が有効なとき、アプリの起動頻度が低いとき、そして長時間充電もネットワークもない状態のときです。
逆に、夜間に充電しながら Wi-Fi につながっている端末は、バックグラウンド実行が回りやすい条件が揃います。壁紙アプリで「朝には更新されている」を成立させやすいのは、まさにこの就寝中の充電時間帯を当てにできるからです。設計時は「どの時間帯のどんな端末状態を当てにするか」を具体的に想定すると、現実的な期待値に近づきます。
失敗しても破綻しない二段構え
結論として、バックグラウンド更新は「当たればラッキー」の補助線として置き、本命はフォアグラウンド起動時の補完にします。
// アプリがフォアグラウンドに戻るたびに、最終更新時刻を見て必要なら更新
import { AppState } from "react-native";
AppState.addEventListener("change", async (next) => {
if (next !== "active") return;
const last = await getLastRefreshedAt();
const stale = Date.now() - last > 1000 * 60 * 60 * 12;
if (stale) await fetchAndCacheTodaysWallpaper();
});
この二段構えにすると、バックグラウンドが走ったユーザーは開く前に更新済み、走らなかったユーザーも開いた瞬間に更新される、という形になります。どちらの経路でも「古いまま」が起きません。私はこの設計に変えてから、更新に関する問い合わせがほぼ来なくなりました。
実機での検証方法
シミュレータはバックグラウンド実行の挙動を正確には再現しません。検証は実機で行い、Xcode のデバッガから手動でタスクをトリガーするのが確実です。デバッグ実行中に LLDB で次を打つと、登録済みタスクを即時に走らせられます。
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"daily-wallpaper-refresh"]
この手動トリガーで処理が正しく動くことを確認したうえで、「実際に何時間後に自然実行されたか」をログで数日観測すると、自分のアプリでの現実的な実行頻度が見えてきます。
実行頻度をログで掴んでから判断する
感覚で語ると設計を誤るので、まず自分のアプリでの実行頻度を数字で掴むことを推奨します。私の場合、BackgroundTaskResult を返すたびに実行時刻をサーバーへ記録し、1週間ぶんを集計しました。
- タスク本体の冒頭で
Date.now() を取得し、結果コードと一緒に送信する。
- 端末ごとに「前回実行からの経過時間」を算出する。
- 24時間以内に実行された割合を、全アクティブ端末ぶんで平均する。
この計測で見えたのは、毎日アプリを開く層では24時間以内の実行率がおおむね70%前後に達するのに対し、週に1回しか開かない休眠層では20%を下回る、という明確な差でした。バックグラウンド更新は継続率の高いユーザーほどよく回り、リテンションの低いユーザーには届きにくい——この非対称が数字で裏づけられたわけです。
本番運用での教訓は、この実行率を上げようと minimumInterval を短くしても、ほとんど効果がないことです。OS の判断材料は間隔指定ではなく端末の使用状況だからです。短くするとむしろ「走らせてもいい」と判断される前に枠を消費し、逆効果になる場面すらありました。間隔は実用上の下限(私は12時間)に置き、頻度はフォアグラウンド補完で底上げする、という役割分担に落ち着いています。
どこまで背面に頼り、どこを諦めるか
バックグラウンド実行は、ユーザー体験を底上げする補助としては優秀ですが、機能の前提条件にすると脆くなります。「開かなくても更新される」は約束ではなく、当たったときの嬉しさとして設計するのが、個人開発で長く運用するうえでの落としどころだと考えています。
App Store のレビューでも、確実性を求められる更新(課金状態の反映など)をバックグラウンド任せにしていると指摘されることがあります。確実性が要る処理はフォアグラウンドかサーバー側に寄せる——この線引きを最初に決めておくと、後から設計を作り直さずに済みます。