「アプリを閉じている時間こそが、いちばん勝負どころなんですよ」と、ある先輩開発者から言われたことを今でもよく思い出します。私が個人開発を始めた頃、ユーザーがアプリを起動している時間にだけ気を配っていました。けれど数年運営してみて、本当に効くのは「閉じている間に何をしておけるか」だと痛感しました。たった3秒の起動直後に新着が見えるか、5秒経ってからやっと表示されるか。その差が翌日の継続率を1〜2ポイント動かしてしまうことを、ダッシュボードを見ながら何度も実感してきました。
Rorkで生成したアプリにこの「閉じてる間の更新」を組み込もうとすると、iOSとAndroidの仕様の違いに最初は戸惑います。Silent Pushを送ってもなぜか届かない、BGTaskSchedulerを登録したのに発火しない、AndroidのWorkManagerがDozeで止まってしまう。ドキュメント通りに書いたつもりなのに、本番では半分くらいしか動いていありません。私自身、累計5,000万ダウンロードのアプリを運用するなかで、このバックグラウンド処理の落とし穴に何度もぶつかってきました。
ここで扱うのはその経験を整理しながら、Rorkで作ったアプリに「閉じてる間も最新」を本番品質で実装する手順を、コードと運用パターンの両面からまとめます。
iOSとAndroidのバックグラウンド実行モデルを正確に理解する
最初にしておきたいのが、両OSの「バックグラウンドで何ができるか」の違いを正確に把握することです。ここを曖昧にしたまま実装を進めると、片方のOSでは動くけれどもう片方では沈黙する、という状態になります。
iOSのバックグラウンド更新は、大きく3つの経路があります。BGAppRefreshTaskは短時間(30秒前後)の軽い更新を、システムが学習したユーザーの利用パターンに合わせて発火します。BGProcessingTaskは数分〜数時間の重い処理(端末の充電中・Wi-Fi接続中などの条件付き)を担います。そしてSilent Push、つまりapns-push-type: backgroundかつcontent-available: 1を含むAPNS通知が、サーバ側からトリガーする更新の入口です。これらは互いに排他ではなく、実用上は3つを併用して「いずれかが届く確率を上げる」設計になります。
一方Androidは、API 31(Android 12)以降のWorkManagerが事実上の正解になっています。OneTimeWorkRequestとPeriodicWorkRequestの組み合わせ、ExpeditedWorkRequestによる即時実行、Constraintsによるネットワーク・充電条件の指定、これらを正しく使い分けることで、Doze・App Standby下でも実用的な実行頻度を保てます。Androidの大きな利点は、JobSchedulerやAlarmManagerに比べて「OSのバージョン差を吸収してくれる」点で、Android 8〜15まで同じコードで動かせるのは個人開発者には本当にありがたい設計です。
両者で決定的に違うのは、iOSはシステム主導でいつ実行するかを決め、開発者は「動くチャンス」を最大化する設計をする のに対し、Androidは開発者が条件と頻度をある程度コントロールできる 点です。この前提を共有してから、コードに入ります。
iOS: BGTaskSchedulerの本番設定パターン
Rorkで生成されたInfo.plistには、デフォルトでバックグラウンドモードの設定が入っていないことが多いので、まずexpo-task-managerとexpo-background-fetchを入れたうえで、app.jsonでバックグラウンドモードを宣言します。
// app.json
{
"expo" : {
"ios" : {
"infoPlist" : {
"UIBackgroundModes" : [ "fetch" , "processing" , "remote-notification" ],
"BGTaskSchedulerPermittedIdentifiers" : [
"net.rorklab.refresh" ,
"net.rorklab.heavy-sync"
]
}
},
"plugins" : [
[ "expo-task-manager" ],
[ "expo-background-fetch" , { "ios" : { "minimumInterval" : 900 } }]
]
}
}
注意点として、BGTaskSchedulerPermittedIdentifiersに登録した識別子と、コードでBGTaskScheduler.shared.registerに渡す識別子は完全一致させる必要があります。私は以前、net.rorklab.refreshとnet.rorklab.app.refreshという揺らぎで2日間悩んだことがあるので、定数化してSwift側・JS側で共有することをおすすめします。同じことを繰り返さないために、識別子はconstants/background.tsのような専用ファイルにまとめるのが安全です。
JavaScript側では、expo-task-managerにタスクを定義してからBackgroundFetchに登録します。
// background-refresh.ts
import * as TaskManager from "expo-task-manager" ;
import * as BackgroundFetch from "expo-background-fetch" ;
import { fetchLatestFeed } from "./api/feed" ;
import { saveToCache } from "./store/cache" ;
export const REFRESH_TASK = "net.rorklab.refresh" ;
TaskManager. defineTask ( REFRESH_TASK , async () => {
const startedAt = Date. now ();
try {
const feed = await fetchLatestFeed ({ limit: 20 , since: "auto" });
await saveToCache ( "home_feed" , feed);
// 期待出力: console に "[refresh] 18 items in 1.2s" のような短い完了ログ
console. log ( `[refresh] ${ feed . length } items in ${ ( Date . now () - startedAt ) / 1000 }s` );
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (error) {
console. warn ( "[refresh] failed" , error);
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
export async function registerBackgroundRefresh () {
const status = await BackgroundFetch. getStatusAsync ();
if (status === BackgroundFetch.BackgroundFetchStatus.Restricted ||
status === BackgroundFetch.BackgroundFetchStatus.Denied) {
return ;
}
await BackgroundFetch. registerTaskAsync ( REFRESH_TASK , {
minimumInterval: 60 * 15 , // iOSはあくまで「最小間隔」のヒント
stopOnTerminate: false ,
startOnBoot: true ,
});
}
ここでよくある間違いが2つあります。1つめはminimumIntervalを「実際の発火間隔」だと勘違いすることです。これはあくまでヒントで、iOSは端末の状態とユーザーの利用パターンから独自に判断します。実機では1日2〜4回、ユーザーが朝・昼・夜にアプリを開く習慣がある場合はその直前に集中して動く傾向があります。2つめは、戻り値を必ずNewData/NoData/Failedのいずれかで返すことです。返さないとシステムが「不誠実なアプリ」と判断し、徐々に発火頻度を絞ってきます。実装を急いでreturnを書き忘れていたために、リリース後の発火率が日に日に下がっていく事故を、私も一度経験しています。
iOS: Silent Push(content-available)が届かない理由とAPNSのthrottling
「Silent Pushを送ったのに届かない」という相談は本当によく受けます。原因のほとんどは、APNSのthrottlingです。Appleは公式にこう述べています — Silent Pushは1時間あたり2〜3通程度に絞られる、と。1日100通送っても全部は届きません。さらに端末がLow Power Modeに入っていたり、長時間未使用のアプリだったりすると、絞りはもっと厳しくなります。
これを踏まえた本番運用パターンは、次の3つを組み合わせることです。
第一に、Silent Pushは「優先度の高い情報変更」のみに使う こと。例えばチャットの新着メッセージが10件溜まったときの一括同期、決済ステータスの変化などです。「タイムラインに新しい投稿があるよ」程度の情報では送らないようにします。送る必要がない情報まで送ってしまうと、本当に届けたい1通がthrottlingの犠牲になる、というジレンマに陥ります。
第二に、apns-priority: 5を必ず付ける こと。Silent Pushではpriority: 10は使えません。下記はNode.js側の実装例です。
// send-silent-push.ts
import jwt from "jsonwebtoken" ;
const TEAM_ID = process.env. APNS_TEAM_ID ! ;
const KEY_ID = process.env. APNS_KEY_ID ! ;
const KEY = process.env. APNS_AUTH_KEY ! ; // .p8 の中身
const TOPIC = "net.rorklab.app" ;
function makeToken () {
return jwt. sign ({ iss: TEAM_ID , iat: Math. floor (Date. now () / 1000 ) }, KEY , {
algorithm: "ES256" ,
header: { alg: "ES256" , kid: KEY_ID },
});
}
export async function sendSilentPush ( deviceToken : string , payload : object ) {
const url = `https://api.push.apple.com/3/device/${ deviceToken }` ;
const res = await fetch (url, {
method: "POST" ,
headers: {
authorization: `bearer ${ makeToken () }` ,
"apns-topic" : TOPIC ,
"apns-push-type" : "background" ,
"apns-priority" : "5" ,
"apns-expiration" : String (Math. floor (Date. now () / 1000 ) + 60 * 60 ),
"content-type" : "application/json" ,
},
body: JSON . stringify ({ aps: { "content-available" : 1 }, ... payload }),
});
if ( ! res.ok) {
// throttled の場合、429 ではなく 200 で reason: "TopicDisallowed" 等が返ることもある
console. warn ( "[apns]" , res.status, await res. text ());
}
return res.status;
}
第三に、Silent Pushが届かないことを前提に、BGTaskSchedulerをフォールバックとして併用する こと。Silent Pushが届けば即時更新、届かなくても1日数回はBGAppRefreshTaskで同期される、という二重化が実用的です。さらに「最後の砦」として、ユーザーがアプリを開いた瞬間にAppStateのactive遷移をフックして強制リフレッシュするコードも入れておくと、3段構えになって体感品質が一気に上がります。
私が運営しているアプリでは、この三段構えに切り替えてから、「閉じている間にデータが古くなっていた」という低レビューが目に見えて減りました。ユーザーは「常に最新」を期待していて、その期待に応える仕組みを裏でこれだけ重ねてやっと、体感として届くものだと感じています。技術ドキュメントを読んだだけでは見えてこない、運用してみて初めて分かる感覚でした。
Android: WorkManager実装とDozeモード対策
Androidは、WorkManagerを中心に組み立てます。Expoの世界ではexpo-background-fetchがAndroid側でWorkManagerを使うラッパとして動きますが、本番品質のアプリでは細かな制御のために、Expo Modulesで自前のネイティブモジュールを薄く書くことをおすすめします。下記は最小限のWorker実装です。
// android/app/src/main/java/.../RefreshWorker.kt
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
class RefreshWorker (
context: Context ,
params: WorkerParameters
) : CoroutineWorker ( context , params ) {
override suspend fun doWork (): Result {
return try {
val ok = ApiClient. fetchLatestFeed ()
if (ok) Result. success () else Result. retry ()
} catch (e: Exception ) {
// ネットワーク一時不通など回復可能な失敗は retry
if (runAttemptCount < 3 ) Result. retry () else Result. failure ()
}
}
}
スケジュール側は次のようになります。
// android/app/src/main/java/.../scheduleRefresh.kt
import androidx.work. *
import java.util.concurrent.TimeUnit
fun scheduleRefresh (context: Context ) {
val constraints = Constraints. Builder ()
. setRequiredNetworkType (NetworkType.CONNECTED)
. setRequiresBatteryNotLow ( true )
. build ()
val request = PeriodicWorkRequestBuilder < RefreshWorker >(
15 , TimeUnit.MINUTES // 最小15分。これより短くは設定不可
)
. setConstraints (constraints)
. setBackoffCriteria (BackoffPolicy.EXPONENTIAL, 30 , TimeUnit.SECONDS)
. build ()
WorkManager. getInstance (context). enqueueUniquePeriodicWork (
"rorklab.refresh" ,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
// 期待出力: WorkManager の DB に "rorklab.refresh" が ENQUEUED 状態で登録される
Dozeモード対策で重要なのは、ExpeditedWorkRequestとForeground Serviceの使い分けです。即時実行が必要な決済確認のような処理はExpeditedWorkRequestを使い、長時間の同期処理はForeground Serviceにしてユーザーに通知を出します。Androidは「ユーザーから見えない長時間処理」を強く嫌うので、見えるところに置く設計が結果的に安定します。Doze下ではPeriodicWorkRequestは最大1時間ほど遅延することがあるので、「15分に1回必ず動く」と期待すると現実とずれます。実機では「8〜30分の間にだいたい動く」と捉えるのが正解です。
メーカー固有の最適化(HuaweiのEMUI、XiaomiのMIUI、SamsungのDevice Care)は、WorkManagerすら殺してくることがあります。日本国内向けアプリではあまり問題になりませんが、グローバル展開する場合はDon't Kill My App のようなサイトを参照しつつ、Settings.canDrawOverlays()の確認や、ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSの案内を出す対応を検討してください。
React Nativeから両OSを統一インターフェースで扱うブリッジ設計
ここまでで、iOSとAndroidそれぞれの実装が見えてきました。Rorkで生成されたReact Nativeコードからこれを呼ぶときに大事なのが、統一インターフェースを作る ことです。下記は私が実際に使っているパターンを簡略化したものです。
// background-bridge.ts
import { Platform, NativeModules } from "react-native" ;
import * as BackgroundFetch from "expo-background-fetch" ;
interface BackgroundBridge {
registerRefresh () : Promise < void >;
unregisterRefresh () : Promise < void >;
getLastRunAt () : Promise < number | null >;
}
const iosBridge : BackgroundBridge = {
async registerRefresh () {
await BackgroundFetch. registerTaskAsync ( "net.rorklab.refresh" , {
minimumInterval: 60 * 15 ,
stopOnTerminate: false ,
startOnBoot: true ,
});
},
async unregisterRefresh () {
await BackgroundFetch. unregisterTaskAsync ( "net.rorklab.refresh" );
},
async getLastRunAt () {
return await NativeModules.RefreshTracker?. getLastRunAt () ?? null ;
},
};
const androidBridge : BackgroundBridge = {
async registerRefresh () {
await NativeModules.RefreshScheduler. schedule (); // Kotlin 側で WorkManager 登録
},
async unregisterRefresh () {
await NativeModules.RefreshScheduler. cancel ();
},
async getLastRunAt () {
return await NativeModules.RefreshScheduler. getLastRunAt ();
},
};
export const Background : BackgroundBridge =
Platform. OS === "ios" ? iosBridge : androidBridge;
こうしておくと、UI層はBackground.registerRefresh()を呼ぶだけで済みます。「OSの違いはブリッジが吸収する、上のコードはOSを意識しない」という分割線を引いておくのが、Rorkで生成されたコードと自前のロジックを長く共存させるコツです。1年運用してから「Androidの実装を入れ替えたい」と思ったときも、ブリッジの中だけ変えればよく、UI側のコードは一切触らずに済みます。
バックグラウンド更新の頻度をどう設計するか
ここはコードよりも「考え方」が大事な部分です。バックグラウンド更新の頻度は、たいていの開発者が「とにかく最大頻度」で設定したくなりますが、私の経験では真逆で、最小頻度から始めて必要になってから上げる のが結局いちばん安定します。
理由は3つあります。第一に、頻度を上げるほどOSが「無駄に動くアプリ」と判断して、絞ってきます。15分ごとに登録してもDoze下では1時間に1回しか動かない、というのは絞られた結果です。第二に、ユーザーの電池とデータ通信を消費するので、レビューに直結します。第三に、サーバ側のコストが想定外に膨らむ場合があります。100万MAUのアプリで15分ごとにフルリフレッシュさせると、月のAPIコストが2倍3倍に化けます。
頻度設計の指針として、私は「ユーザーごとの最終アクセス時刻」を見て動的に決めています。直近24時間に開いたユーザーは1時間に1回、3〜7日開いていないユーザーは1日1回、それ以上は週1回。これだけでサーバ負荷は半分以下になり、しかも体感的にはほぼ変わりません。「全員に同じ頻度」をやめるのが、運用品質を上げるいちばんのコツでした。
本番でハマった3つのアンチパターンと対処
ここからは、私自身が痛い目にあった具体例を3つ紹介します。
アンチパターン1: バックグラウンド更新でフルAPIを叩く
最初のアプリで、バックグラウンドに入るたびに「全データを取り直す」という富豪的な実装をしていました。Wi-Fiの安定した環境では問題なくても、4G環境のユーザーから「アプリを開いていないのにデータ通信量が膨れ上がる」というクレームが来ました。今はIf-Modified-Sinceまたは独自のsince=cursor方式の差分取得に統一しています。差分取得は実装が一手間増えますが、サーバ負荷もユーザーの通信量も劇的に減るので、最初から組み込んでおく価値があります。
アンチパターン2: バックグラウンドでBatteryを浪費
BGProcessingTaskで動画のサムネイル生成をやっていたら、ユーザーのBattery Usage設定で常に上位に出てしまい、OSがバックグラウンド実行を絞ってきました。重い処理は「ユーザーがアプリを開いている時間」にやるか、requiresExternalPower: trueを付けて充電中にだけ動かすほうが結果的に安定します。CPU負荷が大きい処理は、見える時間に置く。これは設計の哲学として効きます。
アンチパターン3: Silent Pushの結果をUIに反映するのを忘れる
Silent Pushでデータをキャッシュに書き込むだけ書き込んで、次にユーザーがアプリを開いたとき古いキャッシュ=古い表示になっていた、というバグ。これはキャッシュ書き込み時にAppStateのactive遷移を購読してUIを再描画する設計にしてから消えました。「データを書く」と「UIを更新する」を別レイヤとして設計する、というのは当たり前のようでいて、忙しい個人開発者がいちばん見落としがちなポイントだと感じています。
これらの「ハマりどころ」は、ドキュメントを読んでも見えてこない領域です。実装ガイドの行間にあるリスクを、できる限り言語化したつもりです。同じ穴に落ちないでいただけたら嬉しいです。
計測と改善: バックグラウンド成功率を追跡する
最後に、いちばん見落とされがちな話をします。バックグラウンド処理は「動いているはず」では済みません。実行成功率を必ず計測する 仕組みを最初から組み込んでください。
私の場合、各バックグラウンドタスクの開始・終了・失敗をローカルのSQLiteに書き込んでおき、次にアプリが起動したときにバッチでバックエンドに送ります。下記のような最小限の構造です。
// background-tracking.ts
import { db } from "./db" ;
export async function trackBackgroundRun (
task : string ,
status : "success" | "failed" | "noData" ,
durationMs : number
) {
await db. runAsync (
"INSERT INTO bg_runs (task, status, duration_ms, ran_at) VALUES (?, ?, ?, ?)" ,
[task, status, durationMs, Date. now ()]
);
}
export async function flushBackgroundLogs () {
const rows = await db. getAllAsync ( "SELECT * FROM bg_runs WHERE sent = 0" );
if ( ! rows. length ) return ;
await fetch ( "/api/bg-runs" , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify (rows),
});
await db. runAsync ( "UPDATE bg_runs SET sent = 1 WHERE sent = 0" );
}
// 期待出力: ダッシュボードで「成功率 87.4%」「24h で 3,212 回起動」のような数字が見えるようになる
数字が見えるようになると、本当のことが見えてきます。私のあるアプリでは、バックグラウンド実行の成功率が当初52%でした。Silent Pushへの依存を減らしBGTaskSchedulerを併用するようになって、80%台まで持ち上がりました。さらにAndroid側でConstraintsを緩めて68%だったのを78%まで改善した、という具体的な数字を持って初めて「最適化する価値がある」と判断できました。「動いているはず」ではなく「78%動いている」と言えるようになって、プロダクトの方向性が初めて科学的に決められるようになった感覚があります。
バックグラウンド復帰ファネルの設計 — 離脱しかけたユーザーを取り戻す
ここまでの仕組みは、それ自体が目的ではありません。本当の目的は、「閉じている間に最新を作っておく」ことで、ユーザーが戻ってきたときの体験を引き上げて、離脱を防ぐ ことです。私はこれを「バックグラウンド復帰ファネル」と呼んで設計しています。
ファネルは4段階で考えます。第一段階は「今日も開いてくれた人」、第二段階は「2〜3日開いていない人」、第三段階は「1週間開いていない人」、第四段階は「2週間以上開いていない人」。それぞれにバックグラウンド処理の役割が違います。第一段階のユーザーには「最新の差分を静かに同期しておく」だけで十分ですが、第二段階以降には「Silent Pushで再エンゲージのトリガを作る」「ローカル通知で『あなたが見てないうちに新着がN件あります』とそっと知らせる」という攻めの設計が効きます。
私の運営アプリの一例では、第二段階のユーザーに対してバックグラウンドで翌日朝のローカル通知をスケジュールするように変えただけで、再オープン率が3週間で22%上がりました。Silent Pushを送るのではなく、「アプリ自身が裏で気を利かせてローカル通知を予約する」設計にすることで、サーバ通信なしで動くのも個人開発者には嬉しいポイントです。離脱しかけたユーザーを取り戻すのは、サーバではなく、アプリ自身の中にある仕組みでも十分にできるんだと、ここで初めて実感しました。
ファネルを設計するときに見落とされがちなのが、「やりすぎないこと」の大切さです。第三段階・第四段階のユーザーにまで毎日Silent Pushや通知を送ってしまうと、それは「気の利いたアプリ」ではなく「うるさいアプリ」として認識されます。私自身、攻めの設計を強くしすぎて通知Off率が一気に上がってしまった失敗があります。再エンゲージは「相手の生活を邪魔しない頻度」から始めて、データを見ながら少しずつ最適点を探るのが、結局いちばん早い道でした。技術というよりは、ユーザーへの想像力の話だと感じています。
詳細なパフォーマンス計測の考え方は、Rorkアプリのパフォーマンス最適化完全ガイド もあわせて参考にしてみてください。Push通知の基礎についてはRork Push通知実装ガイド、より高度なバックグラウンド処理はRork Max BGTaskScheduler活用ガイド で扱っています。
全体を振り返って
「閉じている時間のアプリ品質」は、開発者が手を抜きやすい一方で、ユーザー体験を最も左右する場所です。次にあなたのRorkアプリで取り組むなら、まずダッシュボードに「直近24時間のバックグラウンド実行成功率」を表示するところから始めてみてください。数字が見えれば、何を直せばいいかが自然と見えてきます。
私自身、まだ学びの途中です。ここに書いたことも、来年には新しいOSの仕様で書き換える必要が出るはずです。それでも、この記事のどこかが、あなたのアプリの「閉じている間」を少し豊かにする手がかりになれば嬉しいです。お読みいただきありがとうございました。