リリース直後にAPIの仕様を変えざるを得なくなったり、決済の重大なバグを見つけたりして、「古いバージョンを使い続けているユーザーをどう新しい版に移すか」で悩んだ経験はないでしょうか。私もRorkで作ったアプリを公開してから、この問題に何度かぶつかりました。アップデートを促す仕組みがないままだと、古いバージョンが残り続けて問い合わせが増え、サーバー側もいつまで旧APIを残せばよいか判断できなくなっていきます。
ここではRorkアプリで「強制アップデート」を実装するときの設計の考え方と、実際のコード、そして運用面の落とし穴を順を追って整理します。Expo SDKで動くJavaScript/TypeScriptのコードを中心に扱いますので、Rork Maxで生成したiOS/Androidネイティブのプロジェクトでも、ロジックはそのまま参考にしていただけます。
なぜ強制アップデートの仕組みが必要なのか
App StoreやGoogle Playには「自動アップデート」の設定がありますが、ユーザー側でオフにしている方も少なくありません。ストアの審査ガイドラインで決済の挙動が変わったときや、Rork側のSDKがメジャーバージョンアップしたとき、サーバーAPIに後方互換性のない変更が入ったときには、古い版を使い続けているユーザーに対してアプリ起動時に「アップデートしてください」と能動的に伝える必要が出てきます。
私自身の体感では、有料機能を備えたアプリほどこの仕組みは早い段階で入れておいたほうが安心です。一度クラッシュレポートが上がり始めてから慌てて実装すると、肝心の「クラッシュしているバージョンの利用者にしか届かない」という矛盾に陥りやすいからです。Rorkでアプリを作ったらまず雛形として用意しておく、くらいの位置づけがちょうど良いと感じています。
ソフト強制とハード強制を分けて設計する
強制アップデートには大きく2段階あります。
- ソフト強制(Soft update): 起動時にダイアログを出すが、「あとで」を押せばそのままアプリを使える設計です。新機能リリースや軽微な改善のときに使います
- ハード強制(Hard update): ダイアログにストアへ進むボタンしか出さず、アプリの主要機能は動かない設計です。決済の重大バグや、サーバー側で旧APIを切る前のリリースで使います
最初からハード強制を出すと、ストアに反映されるまでのタイムラグでユーザーが何もできなくなるリスクがあります。私はリリースから3〜7日はソフト強制で様子を見て、そこからハード強制に切り替えるという二段階運用に落ち着きました。
リモート設定で「最低必要バージョン」を管理する
判定に必要なのは、現在のアプリバージョンと、サーバー側で定義した最低必要バージョンの比較です。最低必要バージョンはアプリにハードコードせず、Firebase Remote Config・Supabase・Cloudflare KVなどの外部設定から取得します。これによりアプリ側を再ビルドせずに切り替え可能になり、緊急対応の幅が広がります。
設定値の例(JSON)はこのような形にしておくと、後から運用しやすくなります。
{
"ios": {
"minimumVersion": "1.4.0",
"softUpdateVersion": "1.5.0",
"storeUrl": "https://apps.apple.com/app/idXXXXXXXXX"
},
"android": {
"minimumVersion": "1.4.0",
"softUpdateVersion": "1.5.0",
"storeUrl": "https://play.google.com/store/apps/details?id=com.example.app"
}
}minimumVersion を下回ったらハード強制、softUpdateVersion を下回ったらソフト強制、という二段階のルールにします。ストアURLも一緒に持たせておくと、プラットフォーム別の細かい変更にも強くなります。
アプリ起動時のバージョンチェック実装
クライアント側のコード例です。Expoの expo-application でインストール済みのバージョンを取得し、サーバーから取得した設定値と比較しています。
// utils/checkAppVersion.ts
import * as Application from "expo-application";
import { Platform, Linking, Alert } from "react-native";
type RemoteVersionConfig = {
ios: { minimumVersion: string; softUpdateVersion: string; storeUrl: string };
android: { minimumVersion: string; softUpdateVersion: string; storeUrl: string };
};
// "1.4.0" → [1, 4, 0] のように分割して比較する
function compareVersion(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
export async function checkAppVersion(remote: RemoteVersionConfig) {
// 現在のバージョン (例: "1.3.2") を取得
const current = Application.nativeApplicationVersion ?? "0.0.0";
const cfg = Platform.OS === "ios" ? remote.ios : remote.android;
if (compareVersion(current, cfg.minimumVersion) < 0) {
// ハード強制: 「アップデート」ボタンしかないダイアログ
Alert.alert(
"アップデートが必要です",
"サービス継続のため、最新版へのアップデートをお願いいたします。",
[{ text: "アップデート", onPress: () => Linking.openURL(cfg.storeUrl) }],
{ cancelable: false }
);
return "hard" as const;
}
if (compareVersion(current, cfg.softUpdateVersion) < 0) {
// ソフト強制: 「あとで」も選べる
Alert.alert(
"新しいバージョンがあります",
"新機能や改善を含む新しいバージョンが利用できます。",
[
{ text: "あとで", style: "cancel" },
{ text: "アップデート", onPress: () => Linking.openURL(cfg.storeUrl) },
]
);
return "soft" as const;
}
return "ok" as const;
}このユーティリティをアプリのルート(_layout.tsx など)の useEffect で1回呼び出します。期待する戻り値は "hard" | "soft" | "ok" の3種類で、"hard" のときは続く処理を停止して何も操作させず、"ok" のときは通常の起動フローに戻る設計です。
リモート設定の取得が失敗したときは、ローカルに最後に成功した値を保存しておき、それすら無ければ "ok" を返すフォールバック設計にしておきます。ネットワーク不通時にユーザーがアプリを起動できなくなるリスクの方が、運用上は重く感じられるからです。
ストア遷移とユーザー体験で気をつけたいこと
Linking.openURL でストアに飛ばすときは、https://apps.apple.com/... 形式と itms-apps:// スキームのどちらが手元の環境で安定するかを必ず確認しておきます。私が試した範囲では、最新のiOSでは https://apps.apple.com/... をそのまま渡したほうがApp Storeが確実に開きました。
もう一点、Expo Goやシミュレータでは Linking.openURL がストアアプリと連携しないため、開発中の動作確認はTestFlightや実機ビルドで行う必要があります。このあたりの確認手順は Rork × TestFlightベータテスト完全ガイド でも触れています。ハード強制を出したあとも、「ヘルプを開く」「お問い合わせフォームに飛ぶ」といった代替の導線は残しておくのがおすすめです。アップデート不可のデバイスを使っているユーザー(古い端末や社用端末など)の救済手段になります。
軽い変更ならEAS Updateで先に解決する
ロジックの軽微な不具合や、文言修正だけなら、ストア審査を介さずに配信できる Rork × EAS Update 完全ガイド で先に解消できます。OTAで対応できる範囲のものをわざわざ強制アップデートに乗せてしまうと、ユーザーの体験を不必要に損なうことになります。
私の判断軸は次の通りです。
- ネイティブモジュールの差し替え、アプリ起動順序の変更、決済まわりの構造変更は、ストア配信+強制アップデートが必要です
- JS層だけで完結する文言・UIの修正、バグ修正、設定値の変更は、EAS Updateで十分です
強制アップデート切り替え後の監視
切り替えた直後には、SentryやCrashlyticsで「該当バージョンの起動数がどれくらい残っているか」を観察しておきます。減り方が想定よりも遅いときは、ユーザーが端末のストレージ不足でアップデートできない、企業端末でストアが制限されている、Wi-Fi限定で更新が止まっているなど、別の要因が混じっている可能性があります。クラッシュ監視の準備が未着手であれば、Rork × Sentry クラッシュ監視セットアップガイド を先に整えておくと、強制アップデートの効果検証がぐっと進めやすくなります。
合わせて、ダイアログ自体の挙動もログに残しておくと判断がしやすくなります。「ソフト強制が何回表示されたか」「『アップデート』を押した割合と『あとで』を押した割合」「softUpdateVersion を下回るインストールの比率が日ごとにどう動いているか」といった粒度です。ダイアログの表示時とボタンタップ時にイベントを送るだけでも、運用判断に必要な情報の8割は揃います。私が経験した中では、最初の2日間は数字が動かず、3日目に急にストアアプリの更新が反映され始めて一気に減ったというケースもありました。日次の内訳が見えていないと、ハード強制への切り替えを焦って早めてしまいがちです。
つまずきがちなポイント
参考までに、私が実際に時間を溶かした失敗をいくつか挙げておきます。形は変わっても繰り返し出てくる類のものです。
- バージョンを文字列のまま比較してしまう:
"1.10.0" < "1.9.0"は文字列比較だとtrueになり、意図と逆になります。上のように一度数値に分解してから比較する地味な実装が、結局いちばん長持ちします - AndroidのversionCodeを忘れる: ユーザー向けのダイアログ表示はsemverで判断して問題ありませんが、ビルド番号で別の制御をしたい場合は
Application.nativeBuildVersionから読み出して別軸で判断します。semverと混在させないことが大事です - スプラッシュ表示中にダイアログを出す: スプラッシュのアニメーションとダイアログが競合すると、ユーザーが反射的にタップしてしまい、メッセージを読まないまま「あとで」を選ぶことが起きます。スプラッシュ終了後、少なくとも1フレーム待ってから出す癖をつけておくと安定します
- ダイアログがループする: 「あとで」を選ばれたあと、画面遷移のたびに再チェックを走らせるとプロンプトがしつこくなります。
AsyncStorageなどに最後の表示時刻を保存して、12〜24時間程度のクールダウンを設けるのがおすすめです
実装に取りかかる前にチェックリストとして眺めておくと、本番で踏むより明確に安く済みます。
全体を振り返って — 今日できる最初の一歩
強制アップデートは、機能というより「リリース運用の設計」に近い領域です。コードの量自体は多くありませんが、いつ、どの粒度で、どこから切り替えるかという判断軸のほうが長く使えます。ソフトからハードに切り替えるタイミング、しきい値を誰が握るのか、ユーザーがストアに辿り着くまでに何を目にするのか — このあたりは個別のリリースより長く残るので、まだ平和なうちに整えておく価値があります。
最初にやるなら、リモート設定に minimumVersion と softUpdateVersion のキーを追加し、ローカルでバージョン比較の関数だけ書いて単体テストしてみてください。本番に出す前にダイアログの文言と動作を確認できれば、実際にハード強制を投入するときの心理的なハードルがぐっと下がります。一度ハード強制が必要になる前に、ソフト強制だけでも実運用に乗せておくと、ストアURLがすべてのロケールで正しく開くか、ダイアログの文言が読みやすいか、サポートに来る問い合わせを捌けるかといった現場感の検証ができておすすめです。