0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Firebase無料枠だけで"リアルタイム存在確認アプリ"を運用する設計と実装

0
Posted at

Firebase無料枠だけで"リアルタイム存在確認アプリ"を運用する設計と実装

この記事の対象読者

  • Firebaseで個人開発アプリを月額0円で運用したい人
  • Firestore と Realtime Database の使い分けに悩んでいる人
  • PWAで「ネイティブアプリっぽい体験」を実現したい人

TL;DR

  • Firestore の書き込み課金を避けるため、プレゼンス管理を Realtime Database に分離した
  • onDisconnect フックで サーバーレスな死活監視 を実現した
  • CSS @keyframes による弾性振動で、framer-motion なしでSpring Physics風アニメーションを実装した
  • Web Audio API で通知音をコード生成し、バンドルサイズを0にした
  • 結果として Firebase Spark プラン(無料枠)で安定運用中

作ったもの

離れて暮らす家族の「存在確認」に特化したPWAアプリ 「Haptics」 を開発しました。

画面上に浮かぶ光(=他のユーザー)をタップするだけで、相手に音とアニメーションが届くシンプルなアプリです。メッセージ機能は意図的に実装していません。

🔗 https://www.haptics.akirakano.work/

技術構成

React + TypeScript + Vite
├── Firebase Hosting(CDN配信・SSL自動)
├── Cloud Firestore(永続データ)
├── Realtime Database(プレゼンス管理)
├── Cloud Functions(見守りメール通知)
├── Web Audio API(通知音生成)
└── Tailwind CSS v4(UI)

以降、各技術要素でハマったポイントと解決策を詳しく書きます。


1. 【設計】Firestore と Realtime Database を併用してコストを抑える

課題

リアルタイムアプリでは「この人は今オンラインか?」というプレゼンス情報が必要です。しかし、この情報をFirestoreで管理すると 読み書き課金が爆発 します。

例えば、10人のユーザーが各自のプレゼンスを10秒ごとに更新し、全員の状態を監視する場合:

書き込み: 10人 × 6回/分 × 60分 = 3,600回/時
読み取り: 10人 × 10人 × 6回/分 × 60分 = 36,000回/時

Firestoreの無料枠は1日あたり書き込み20,000回・読み取り50,000回なので、たった10人でも数時間で枯渇します。

解決策:Realtime Database に分離

Realtime Databaseは同時接続数ベースの課金であり、書き込み・読み取り自体にはほぼ課金されません(帯域・ストレージ上限はある)。プレゼンス情報のように「頻繁に更新される一時的なデータ」に最適です。

  • Realtime Database: プレゼンス(オンライン状態)── 頻繁に更新・切断時に自動削除
  • Cloud Firestore: ユーザー情報・タップ履歴 ── 永続的に保持するデータ

実装: onDisconnect による自動クリーンアップ

Realtime Database の onDisconnect は、クライアントの接続が切れた時にサーバー側で自動実行されるフックです。これによりサーバーレスで正確なプレゼンス管理が可能になります。

import { ref, set, onDisconnect, onValue } from 'firebase/database';

export class RTDBPresenceRepository {
    async setPresence(user: User): Promise<void> {
        const presenceRef = ref(this.db, `presence/${user.id}`);

        // 切断時に自動削除(サーバー側で実行される)
        await onDisconnect(presenceRef).remove();

        // プレゼンス情報を書き込み
        await set(presenceRef, {
            name: user.name,
            iconConfig: user.iconConfig,
            iconScale: user.iconScale,
            lastActive: Date.now()
        });
    }

    // リアルタイムで全ユーザーのプレゼンスを監視
    subscribeToPresence(callback: (users: User[]) => void): () => void {
        const presenceRef = ref(this.db, 'presence');
        const unsubscribe = onValue(presenceRef, (snapshot) => {
            const users: User[] = [];
            snapshot.forEach((child) => {
                users.push(this.snapshotToUser(child));
            });
            callback(users);
        });
        return unsubscribe;
    }
}

ハマりポイント: onDisconnectset より先に呼ぶ必要があります。set の後に onDisconnect を設定すると、その間にネットワークが切れた場合にゾンビデータが残ります。


2. 【実装】Web Audio API で通知音をコード生成する

課題

タップ受信時に心地よい通知音を鳴らしたい。しかし、音声ファイルを使うと:

  • バンドルサイズが増える
  • ライセンス管理が面倒
  • 音の微調整がしにくい

解決策:Web Audio API でリアルタイム生成

class SoundService {
    private audioCtx: AudioContext | null = null;

    // iOS対応:ユーザージェスチャーのタイミングで初期化
    initOnGesture(): void {
        if (!this.audioCtx) {
            this.audioCtx = new AudioContext();
        }
    }

    playPikon(): void {
        if (!this.audioCtx) return;
        const ctx = this.audioCtx;

        const oscillator = ctx.createOscillator();
        const gainNode = ctx.createGain();

        oscillator.connect(gainNode);
        gainNode.connect(ctx.destination);

        // 高音から低音へスライドする「ピコン」
        oscillator.type = 'sine';
        oscillator.frequency.setValueAtTime(880, ctx.currentTime);
        oscillator.frequency.exponentialRampToValueAtTime(
            440, ctx.currentTime + 0.15
        );

        // フェードアウト
        gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
        gainNode.gain.exponentialRampToValueAtTime(
            0.01, ctx.currentTime + 0.2
        );

        oscillator.start(ctx.currentTime);
        oscillator.stop(ctx.currentTime + 0.2);
    }
}

ハマりポイント (iOS): iOSのSafariでは、AudioContext の生成をユーザージェスチャー(タップ等)のイベントハンドラ内で行わないと音が鳴りません。アプリ起動直後の初期化では動作しないため、「タップして開始」画面を設け、最初のタップイベント内で new AudioContext() を呼んでいます。


3. 【実装】CSSだけでSpring Physics風アニメーションを実現する

課題

タップ受信時にアイコンが「ボヨン」と弾むSpring Physicsアニメーションを実装したい。

framer-motion で失敗した話

最初は framer-motion の useAnimation + spring トランジションで実装しました。

// ❌ 動かなかったコード
controls.start({
    scale: [1, 1.5, 1],
    transition: { type: "spring", stiffness: 300, damping: 8 }
});

コンソール上はアニメーション完了と表示されるのに、画面上では一切変化が見えない

原因は、framer-motion の spring トランジションは 2点間の補間専用 であり、[1, 1.5, 1] のようなキーフレーム配列を渡すと正しく動作しないためでした。公式ドキュメントにも明記されていますが、見落としやすいポイントです。

解決策:CSS @keyframes で手動実装

framer-motion を除去し、CSSアニメーションで減衰振動を手動で描きました。

@keyframes spring-pop {
    0%   { transform: scale(1); }
    15%  { transform: scale(1.4); }    /* 最大膨張 */
    30%  { transform: scale(0.9); }    /* 反動で収縮 */
    45%  { transform: scale(1.2); }    /* 二次膨張 */
    60%  { transform: scale(0.95); }   /* 二次収縮 */
    75%  { transform: scale(1.05); }   /* 減衰 */
    100% { transform: scale(1); }      /* 元に戻る */
}

React側では key プロパティを変更してコンポーネントを再マウントし、CSSアニメーションを再トリガーしています。

const [popKey, setPopKey] = useState(0);

const handleTap = () => {
    setPopKey(prev => prev + 1); // key変更で再マウント → アニメーション再生
};

return (
    <div
        key={`pop-${popKey}`}
        style={{ animation: popKey > 0 ? 'spring-pop 0.5s ease-out' : 'none' }}
    >
        <UserIcon />
    </div>
);

結果: framer-motion を除去したことでバンドルサイズが約120KB削減(891KB → 772KB)されるという副次的効果もありました。ライブラリの抽象化が合わない場合、生のCSS/JSに戻る判断も大切です。


4. 【設計】PWA最適化のTips

Wake Lock API で常時表示

Hapticsは「リビングに置いて眺める」使い方を想定しているため、画面スリープを防止しています。

// Wake Lock の取得
let wakeLock: WakeLockSentinel | null = null;

async function requestWakeLock() {
    try {
        wakeLock = await navigator.wakeLock.request('screen');
        wakeLock.addEventListener('release', () => {
            // バックグラウンド復帰時に再取得
            requestWakeLock();
        });
    } catch (err) {
        console.log('Wake Lock not supported');
    }
}

注意: visibilitychange イベントでWake Lockが解除されるため、フォアグラウンドに戻った時に再取得するリスナーが必要です。

manifest.json の設定

{
    "name": "Haptics",
    "short_name": "Haptics",
    "display": "standalone",
    "start_url": "/app/",
    "background_color": "#0f172a",
    "theme_color": "#0f172a",
    "icons": [...]
}

display: "standalone" によりブラウザUIが非表示になり、ネイティブアプリのような全画面表示が実現できます。


5. 【運用】Firebase無料枠の消費量

1ヶ月運用した実績値です(アクティブユーザー数名の小規模運用)。

リソース 無料枠 実消費量 消費率
Firestore 読み取り 50,000/日 ~2,000/日 4%
Firestore 書き込み 20,000/日 ~500/日 2.5%
RTDB 帯域 10GB/月 ~50MB/月 0.5%
Hosting 帯域 10GB/月 ~200MB/月 2%
Cloud Functions 呼び出し 2M/月 ~1,000/月 0.05%

プレゼンスをRTDBに分離したことで、Firestoreの課金が劇的に抑えられているのがわかります。


まとめ

課題 解決策
プレゼンス管理のコスト Firestore → Realtime Database に分離
オフライン検出 onDisconnect フックで自動削除
通知音のバンドルサイズ Web Audio API でリアルタイム生成
Spring Physicsアニメーション CSS @keyframes で手動実装
画面スリープ Wake Lock API
全体の運用コスト Firebase Sparkプラン(無料)で運用

個人開発において「無料で運用し続けられる」ことは、アプリの寿命に直結します。Firebase の2つのデータベースを適材適所で使い分けることで、リアルタイム性とコスト効率を両立できました。


🔗 Haptics: https://www.haptics.akirakano.work/

ソースコードやアーキテクチャに関するフィードバックがあれば、ぜひコメントください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?