こんにちは。5人の子供を育てながら、自宅を拠点にIT自営業として日々コードを書いているパパエンジニアです。
今回は、我が家で絶賛運用中の「家族専用お小遣い・お手伝い管理アプリ」の開発運用で直面した、リアルな課題と解決策について共有します。
個人開発のWebアプリやスマホアプリで、最も頭を悩ませる問題の一つ。それは、**「ユーザーが古いバージョンのまま使い続けること」**です。
特に子供が使うアプリの場合、どれだけ「アップデートしてね」と口頭やチャットで伝えても、彼らは気にせず古いアプリを開き続けます。その結果、バックエンド(Firebase Firestoreなど)のデータ構造(スキーマ)を変更した際にエラーで動かなくなったり、修正したはずのバグが放置されたままになったりと、デバッグやサポートの手間(というか親としての説明の手間)が日々蓄積していきました。
この「古いバージョン居座り問題」を解決するため、ビルド・デプロイ時に最新バージョン情報を記述した JSON ファイルをサーバーに配置し、クライアント側でシームレスに検知して強制アップデートする仕組みを実装しました。
今回は、その具体的な実装コードと仕組みの全貌を公開します。
💡 システムの全体像と仕組み
仕組みは非常にシンプルですが、強力です。
- ビルド&デプロイ時: サーバー(Firebase Hosting)のルートに、最新のバージョン識別子(ビルド時のタイムスタンプ)を書き込んだ
version.jsonを自動生成してデプロイします。 - クライアント起動時・復帰時: アプリが起動した瞬間、またはバックグラウンドから前面に戻った(フォーカスされた)時に、この
version.jsonを非同期で取得(フェッチ)します。 - バージョン比較: クライアントの
localStorageに保存されているバージョン番号と、サーバーから取得した最新バージョン番号を比較します。 - トースト表示と更新の実行: バージョンが異なる場合は、画面下部に「新しいバージョンがあります」というトースト通知を表示し、「更新する」ボタンがタップされた瞬間に
localStorageを書き換えてブラウザを強制リロード(window.location.reload())させます。
🛠️ 具体的な実装コード
それでは、実際のソースコードを解説します。
1. ビルド時にバージョンを自動インクリメントする(update-version.mjs)
デプロイのたびに手動でバージョンを書き換えるのは面倒ですし、ミスの元です。そこで、ビルド時に自動的にビルドタイムスタンプを記述した version.json を生成するNode.jsスクリプトを作成しました。
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const versionFilePath = path.join(__dirname, 'dist', 'version.json');
// フォルダがない場合は作成
const distDir = path.join(__dirname, 'dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir);
}
const versionData = {
version: new Date().toISOString(),
timestamp: Date.now()
};
fs.writeFileSync(versionFilePath, JSON.stringify(versionData, null, 2));
console.log(`Version updated: ${versionData.version}`);
このスクリプトにより、ビルドするたびに一意のISO 8601タイムスタンプが version.json に書き込まれます。
続いて、package.json のデプロイスクリプトにこの処理を挟み込みます。
"scripts": {
"export:web": "expo export --platform web -c",
"deploy": "npm run export:web && node update-version.mjs && npx firebase-tools deploy"
}
これにより、npm run deploy を実行するだけで、Expo Webアプリの書き出し(export)、バージョンJSONの生成、Firebaseへのデプロイがワンストップで完了します。
2. アプリ側でのバージョン監視と強制リロード(App.tsx)
アプリ側(React Native / Expo Web)のルートコンポーネントで、定期的にサーバーの version.json をチェックするロジックを実装します。
【重要】
ブラウザやネットワーク機器によるキャッシュを防ぐため、fetchのURL末尾にタイムスタンプ(?t=Date.now())を付与してリクエストを投げることが重要です。
// 自動アップデートチェック用のEffect
useEffect(() => {
if (Platform.OS !== 'web') return;
const checkForUpdates = async () => {
try {
// キャッシュを避けるためにタイムスタンプクエリを付与
const response = await fetch(`/version.json?t=${Date.now()}`);
if (!response.ok) return;
const data = await response.json();
const currentVersion = localStorage.getItem('rinxpay_app_version');
console.log(`[App] Version check: local=${currentVersion}, server=${data.version}`);
// ローカルのバージョンとサーバーのバージョンに差異があれば更新通知を表示
if (currentVersion && currentVersion !== data.version) {
console.log('[App] New version detected:', data.version);
setNewVersionInfo(data.version);
setUpdateAvailable(true);
} else if (!currentVersion) {
// 初回起動時は現在のバージョンをローカルストレージに保存
localStorage.setItem('rinxpay_app_version', data.version);
}
} catch (e) {
console.log('[App] Update check failed', e);
}
};
// 1. アプリ起動直後にチェック
checkForUpdates();
// 2. ブラウザにフォーカス(タブ移動から戻ってきた際)した時にチェック
const handleFocus = () => {
checkForUpdates();
};
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('focus', handleFocus);
};
}, []);
3. トースト通知UIと強制リロードの実行
バージョン差分を検知すると updateAvailable ステートが true になり、画面下部にモダンなトースト通知(バナー)が出現します。
{/* アップデート通知トースト */}
{updateAvailable && (
<View style={styles.updateToast}>
<View style={styles.updateToastContent}>
<Text style={styles.updateToastText}>✨ 新しいバージョンがあります</Text>
<TouchableOpacity
style={styles.updateButton}
onPress={() => {
// 新しいバージョン文字列をローカルストレージに記録
localStorage.setItem('rinxpay_app_version', newVersionInfo);
// キャッシュをクリアしてブラウザを強制リロード
window.location.reload();
}}
>
<Text style={styles.updateButtonText}>更新する</Text>
</TouchableOpacity>
</View>
</View>
)}
「更新する」ボタンがタップされると、localStorage のバージョン情報がサーバー側の最新値に更新された上で、window.location.reload() が実行されます。これにより、ブラウザのキャッシュがクリアされ、最新バージョンのJavaScriptやアセットが読み込まれます。
🚀 導入してみた結果とメリット
この強制アップデート機能を導入したところ、目に見えて開発・運用が快適になりました。
- 子供たちへの説明コストがゼロに: バグ修正や新機能の追加時、「アプリを更新して」と個別に声をかける必要がなくなりました。トーストが出たらタップするだけなので、小学4年生の末っ子でも勝手に更新してくれます。
- データ不整合の防止: データベース(Firestore)の構造やバリデーションルールを変更する際、古いバージョンのアプリから古い形式のデータが書き込まれる事故が完全に防げるようになりました。
- ビルドとシームレスに連動: 開発者は通常通りデプロイコマンドを叩くだけで、バージョン管理を意識する必要が一切ありません。
📝 おわりに
技術は、まず自分自身と家族の「日々の不便」を解決するために使う。
今回のアップデート機能は非常にシンプルな構成ですが、個人開発や小規模な運用においては、複雑なアプリストア審査などを介さずにアジリティ(俊敏性)高くアプリを更新し続けるための最適解の一つだと感じています。
同様の「古いバージョン居座り問題」に悩んでいる開発者の方は、ぜひこのタイムスタンプ比較方式を試してみてください。
