【個人開発】子供が古いバージョンを使い続ける問題を解決!version.jsonを用いたReact Native(Web)アプリの強制アップデート機能

【個人開発】子供が古いバージョンを使い続ける問題を解決!version.jsonを用いたReact Native(Web)アプリの強制アップデート機能 IT
スポンサーリンク

こんにちは。5人の子供を育てながら、自宅を拠点にIT自営業として日々コードを書いているパパエンジニアです。

今回は、我が家で絶賛運用中の「家族専用お小遣い・お手伝い管理アプリ」の開発運用で直面した、リアルな課題と解決策について共有します。

個人開発のWebアプリやスマホアプリで、最も頭を悩ませる問題の一つ。それは、**「ユーザーが古いバージョンのまま使い続けること」**です。

特に子供が使うアプリの場合、どれだけ「アップデートしてね」と口頭やチャットで伝えても、彼らは気にせず古いアプリを開き続けます。その結果、バックエンド(Firebase Firestoreなど)のデータ構造(スキーマ)を変更した際にエラーで動かなくなったり、修正したはずのバグが放置されたままになったりと、デバッグやサポートの手間(というか親としての説明の手間)が日々蓄積していきました。

この「古いバージョン居座り問題」を解決するため、ビルド・デプロイ時に最新バージョン情報を記述した JSON ファイルをサーバーに配置し、クライアント側でシームレスに検知して強制アップデートする仕組みを実装しました。

今回は、その具体的な実装コードと仕組みの全貌を公開します。


💡 システムの全体像と仕組み

仕組みは非常にシンプルですが、強力です。

  1. ビルド&デプロイ時: サーバー(Firebase Hosting)のルートに、最新のバージョン識別子(ビルド時のタイムスタンプ)を書き込んだ version.json を自動生成してデプロイします。
  2. クライアント起動時・復帰時: アプリが起動した瞬間、またはバックグラウンドから前面に戻った(フォーカスされた)時に、この version.json を非同期で取得(フェッチ)します。
  3. バージョン比較: クライアントの localStorage に保存されているバージョン番号と、サーバーから取得した最新バージョン番号を比較します。
  4. トースト表示と更新の実行: バージョンが異なる場合は、画面下部に「新しいバージョンがあります」というトースト通知を表示し、「更新する」ボタンがタップされた瞬間に 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)の構造やバリデーションルールを変更する際、古いバージョンのアプリから古い形式のデータが書き込まれる事故が完全に防げるようになりました。
  • ビルドとシームレスに連動: 開発者は通常通りデプロイコマンドを叩くだけで、バージョン管理を意識する必要が一切ありません。

📝 おわりに

技術は、まず自分自身と家族の「日々の不便」を解決するために使う。

今回のアップデート機能は非常にシンプルな構成ですが、個人開発や小規模な運用においては、複雑なアプリストア審査などを介さずにアジリティ(俊敏性)高くアプリを更新し続けるための最適解の一つだと感じています。

同様の「古いバージョン居座り問題」に悩んでいる開発者の方は、ぜひこのタイムスタンプ比較方式を試してみてください。

タイトルとURLをコピーしました