💡

Flutter Notion バックアップ実践ガイド (1/3) — Firebase vs Notion とスナップショット方式

に公開

Flutter アプリのユーザーデータを Firebase なしでクラウドバックアップしたい。本連載では Notion API をバックアップ先に選び、スナップショット方式で実装する設計戦略から具体的なコードまでを 3 回に分けて解説します。

https://zenn.dev/motowo/articles/flutter-hive-persistence-guide-1

https://zenn.dev/motowo/articles/flutter-hive-persistence-guide-2

https://zenn.dev/motowo/articles/flutter-hive-persistence-guide-3

はじめに — ローカルの次はクラウドへ

前作「Flutter Hive 実践ガイド」では、Hive CE を使ったローカルファーストなデータ永続化を実装しました。お気に入り・検索履歴・閲覧履歴といったユーザーデータをローカルに保存し、高速に読み書きできる仕組みです。

しかし、ローカルだけでは解決できない課題が残っています。端末の紛失、ストレージの破損、機種変更。数ヶ月かけて蓄積したデータが一瞬で失われるリスクは、ユーザー体験を根底から揺るがします。

本連載では全 3 回に分けて、この課題を解決するクラウドバックアップの仕組みを構築します。

テーマ
第 1 回(本記事) 設計戦略編:Firebase vs Notion とスナップショット方式
第 2 回 通信設計編:Throttling・指数バックオフ・SHA-256
第 3 回 自動化・実践編:CLI ツール設計とトラブル 4 事例

なぜユーザーデータのバックアップが「失われてはならない」のか

ユーザーデータのバックアップが必要な理由は、大きく 3 つあります。

蓄積の継続性

お気に入りや検索履歴は、ユーザーが日々の利用を通じて少しずつ積み上げるデータです。数ヶ月分のお気に入りが突然消えれば、ユーザーはアプリそのものへの信頼を失います。「もう使わない」という判断に直結しかねません。

不慮の事故への耐性

ストレージの物理的な破損、OS アップデート時の不具合、電源断によるデータ破壊。ローカルストレージだけに依存していると、これらの事故からデータを守る手段がありません。

デバイス移行ニーズ

スマートフォンの買い替えは 2〜3 年周期で発生します。ローカルデータを新端末に引き継ぐ仕組みがなければ、ユーザーはゼロからやり直すことになります。

これら 3 つの課題を解決するために、クラウドバックアップは不可欠です。では、バックアップ先としてどのサービスを選ぶべきでしょうか。

クラウドバックアップ先の選定:Firebase vs Notion API

Flutter のクラウドバックアップといえば、まず候補に挙がるのが Firebase Firestore です。しかし、今回のユースケースではあえて Notion API を選びます。以下の 5 軸で比較した結果です。

観点 Firebase Firestore Notion API
開発コスト 高い(SDK 導入・Auth・Security Rules の設定が必要。最低 6 ステップ) 低い(REST API + API キー 1 つで完結)
データの可視性 低い(Firebase Console でしか確認できない) 高い(ユーザー自身が Notion 上でデータを閲覧可能)
エコシステム親和性 汎用的 Notion を日常的に使うチームに自然にフィット
同期速度 極めて高速(リアルタイム同期対応) 低い(平均 3 req/s のレート制限)
維持コスト 無料枠はあるが、使用量の監視が必要 無料枠で十分。メンテナンスフリー

選定の核心:リアルタイム同期は不要

Firebase の最大の強みはリアルタイム同期ですが、バックアップ用途ではこの強みが活きません。ユーザーがお気に入りを登録してから、そのデータがクラウドに反映されるまでに数秒〜数十秒の遅延があっても、体験上の問題はありません。

一方で、Notion API にはユニークな利点があります。ユーザーが自分のデータを Notion のページとして直接閲覧・検索できる透明性です。「自分のデータがどこに、どんな形で保存されているか」を目で確認できることは、バックアップへの信頼感に直結します。

スナップショット方式:JSON Bundle で API コール数を最小化する

Notion API のレート制限は平均 3 req/s です。お気に入り 100 件を 1 件ずつ個別のページとして保存すると、それだけで 100 回以上の API コールが必要になり、レート制限に抵触します。

この問題を解決するのがスナップショット方式です。

正規化方式 vs スナップショット方式

正規化方式とスナップショット方式の比較。正規化方式では100件で100回以上のAPIコールが必要だが、スナップショット方式では5〜6回で完了する

【正規化方式】1データ = 1ページ
favorites/
  ├── item_001  →  API コール 1回
  ├── item_002  →  API コール 1回
  ├── item_003  →  API コール 1回
  └── ... (100件 → 100回以上のAPIコール)

【スナップショット方式】全データ = 1ページ
backup/
  └── device_snapshot  →  API コール 5〜6回で全データ完了
       └── Rich Text: {"favorites": [...], "history": [...], ...}

スナップショット方式では、全データを JSON 文字列にシリアライズし、Notion ページの Rich Text プロパティに格納します。1 端末につき 1 ページ(1 行)しか使わないため、API コールは以下の数回で完結します。

// スナップショット方式の概念コード
Future<void> backupToNotion() async {
  // 1. ローカルの全データを JSON に変換
  final snapshot = {
    'favorites': hiveBox.get('favorites'),
    'searchHistory': hiveBox.get('searchHistory'),
    'viewHistory': hiveBox.get('viewHistory'),
    'settings': hiveBox.get('settings'),
    'lastUpdated': DateTime.now().toIso8601String(),
  };
  final jsonString = jsonEncode(snapshot);

  // 2. 既存ページの存在確認(1 APIコール)
  final existingPage = await notionClient.queryDatabase(
    databaseId: backupDbId,
    filter: {'device_id': currentDeviceId},
  );

  // 3. ページの作成 or 更新(1 APIコール)
  if (existingPage != null) {
    await notionClient.updatePage(existingPage.id, jsonString);
  } else {
    await notionClient.createPage(backupDbId, jsonString);
  }
}

Rich Text プロパティの容量

Notion の Rich Text プロパティには、1 オブジェクトあたり最大 2,000 文字、配列で最大 100 要素を格納できます(Notion API ドキュメント参照)。理論上 200,000 文字(約 200KB)の JSON を保存可能です。一般的なアプリのユーザーデータであれば十分な容量です。

Rich Text の分割ロジック(概念)。

JSON 文字列が 2,000 文字を超える場合は、複数の Rich Text オブジェクトに分割して格納します。復元時にはこれらを結合してからパースします。この分割・結合ロジックは第 2 回で実装します。

ローカルファースト設計:3 つの原則

https://zenn.dev/motowo/articles/flutter-clean-architecture-design-system-guide

ローカルファーストアーキテクチャの全体像。Flutter AppのHive Local DBをSSOTとし、Background Serviceを経由して非同期でNotion Cloudにバックアップする構成

バックアップ機能を追加する際に、最も重要なのは既存のローカルファースト設計を壊さないことです。以下の 3 原則を守ります。

原則 1:SSOT(Single Source of Truth)はローカル Hive

アプリの動作中、データの正解は常にローカルの Hive です。Notion 側のデータはあくまでバックアップであり、アプリが直接参照することはありません。

// データの読み取りは常にローカルから
final favorites = hiveBox.get('favorites'); // ローカルが SSOT

// Notion はバックアップ専用。読み取りは復元時のみ
// final favorites = await notionClient.read(...);  // 通常運用では使わない

https://zenn.dev/motowo/articles/agile-design-trap-lessons

原則 2:非同期バックアップ(UI をブロックしない)

バックアップ処理は UI スレッドとは別に、バックグラウンドで実行します。ユーザーがお気に入りを登録した瞬間にバックアップが完了している必要はなく、数秒〜数十秒後にバックグラウンドで同期されれば十分です。

// お気に入り登録時のフロー
Future<void> addFavorite(Item item) async {
  // 1. ローカルに即座に保存(UIはここで更新)
  await hiveBox.put(item.id, item.toJson());

  // 2. バックアップは非同期で実行(UIをブロックしない)
  unawaited(backupService.scheduleBackup());
}

原則 3:オフライン動作の保証

ネットワーク接続がなくても、アプリのすべての機能が制限なく動作します。バックアップはネットワーク復帰後に自動的にリトライされます。

// ネットワーク状態に関わらず動作
Future<void> scheduleBackup() async {
  if (await connectivity.hasConnection) {
    await _executeBackup();
  } else {
    // オフライン時はキューに積み、復帰後に実行
    await _enqueueForLater();
  }
}

これら 3 原則により、バックアップ機能を追加しても、前作で構築したローカルファーストなアーキテクチャは一切変更されません。

まとめ — 設計戦略の全体像

本記事では、Flutter アプリのユーザーデータをクラウドバックアップする設計戦略を整理しました。

  • バックアップ先: Firebase ではなく Notion API を選定(軽量・透明性・低コスト)
  • データ構造: スナップショット方式で API コールを最小化(5〜6 回で全データ完了)
  • 設計原則: ローカルファースト 3 原則(SSOT・非同期同期・オフライン動作)

次回の第 2 回では、この設計を元に Notion API クライアントの実装と、実際のバックアップ・復元ロジックを構築していきます。

Discussion