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?

# 個人開発で1万件のオフライン同期を1秒で実現した話【Flutter × AWS Amplify Gen 2】

Posted at
  • 学習カードアプリでオフライン同期を実装したら、最初は30秒以上かかって絶望した
  • 差分同期 + バッチ処理 + 並列実行で1秒以下まで改善できた
  • 通信量は99.9%削減、同期速度は50倍向上
  • この記事では実装コードと設計思想を全部公開します

はじめに:同期地獄の始まり

「オフラインでも使える学習アプリを作ろう」

軽い気持ちで始めた個人開発。ローカルDBにSQLite、クラウドにDynamoDB。シンプルな構成のはずでした。

ところが、いざ同期機能を実装してみると...

初回同期: 45秒
通常同期: 30秒
ユーザー: 「遅すぎて使えない」

地獄の始まりでした。

この記事では、私が開発中の学習カードアプリ「COBO MEMO」で、この同期地獄をどう解決したかを共有します。同じ課題に直面している方の参考になれば幸いです。

技術スタック

項目 技術
フロントエンド Flutter (Dart)
バックエンド AWS Amplify Gen 2
クラウドDB DynamoDB
ローカルDB SQLite
API GraphQL

問題の整理:なぜ遅かったのか

最初の実装の問題点を整理します。

問題1: 毎回全量取得していた

// ❌ 悪い例:毎回全データを取得
Future<void> syncAllData() async {
  final allCards = await fetchAllCardsFromCloud(); // 10,000件
  await saveToLocalDB(allCards);
}

10,000件のカードを毎回取得 → 通信量10MB/回

問題2: 1件ずつ処理していた

// ❌ 悪い例:1件ずつ保存
for (final card in cards) {
  await saveToLocalDB(card); // 10,000回のDB操作
}

10,000回のDB操作 → 処理時間30秒以上

問題3: すべて直列実行していた

// ❌ 悪い例:テーブルごとに順番に同期
await syncCards();      // 5秒
await syncDecks();      // 5秒
await syncSettings();   // 5秒
// 合計: 15秒

解決策の全体像

┌─────────────────┐
│  Flutter App    │
│  (SQLite)       │
└────────┬────────┘
         │ 差分同期(更新分のみ)
         ▼
┌─────────────────┐
│  AWS Amplify    │
│  GraphQL API    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│   DynamoDB      │
└─────────────────┘

3つの最適化を組み合わせました:

  1. 差分同期 → 更新されたデータのみ取得(通信量99.9%削減)
  2. バッチ処理 → 50件ずつまとめて処理(処理速度50倍向上)
  3. 並列実行 → 独立したテーブルを同時に同期(時間1/3に短縮)

解決策1: 差分同期の実装

SyncCursor(同期カーソル)の導入

各テーブルごとに「最後に同期した時刻」を記録し、それ以降の更新のみを取得します。

// db_helper.dart
class DBHelper {
  // 同期カーソルの取得
  Future<DateTime?> getSyncCursor(String userId, String tableName) async {
    final db = await database;
    final maps = await db.query(
      'SyncCursor',
      where: 'userId = ? AND tableName = ?',
      whereArgs: [userId, tableName],
      limit: 1,
    );
    if (maps.isEmpty) return null;
    final s = maps.first['lastSyncTime'] as String?;
    return s == null ? null : DateTime.tryParse(s);
  }

  // 同期カーソルの更新
  Future<void> upsertSyncCursor(
    String userId, String tableName, DateTime t) async {
    final db = await database;
    await db.insert(
      'SyncCursor',
      {
        'userId': userId,
        'tableName': tableName,
        'lastSyncTime': t.toIso8601String(),
      },
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }
}

GraphQLクエリで差分取得

Future<List<MyRootStorage>> _fetchCloudMyRootStorages() async {
  // 3分のバッファを持たせて取りこぼしを防ぐ
  final DateTime lastSync = 
      (_lastSyncTime ?? DateTime(1970))
          .subtract(const Duration(minutes: 3));

  const String graphQLDocument = r'''
    query MyRootStorageQuery($userId: ID!, $updatedAt: AWSDateTime, $nextToken: String) {
      myRootStorageQuery(userId: $userId, updatedAt: $updatedAt, nextToken: $nextToken) {
        items {
          userId
          rootId
          type
          updatedAt
          isDeleted
        }
        nextToken
      }
    }
  ''';

  List<MyRootStorage> allItems = [];
  String? nextToken;
  
  do {
    final request = GraphQLRequest<String>(
      document: graphQLDocument,
      variables: {
        'userId': userIdentifier,
        'updatedAt': lastSync.toUtc().toIso8601String(),
        'nextToken': nextToken,
      },
      authorizationMode: APIAuthorizationType.userPools,
    );

    final response = await Amplify.API.query(request: request).response;
    final data = jsonDecode(response.data!);
    
    final items = (data['myRootStorageQuery']['items'] as List)
        .map((json) => MyRootStorage.fromJson(json))
        .toList();
    allItems.addAll(items);
    
    nextToken = data['myRootStorageQuery']['nextToken'] as String?;
  } while (nextToken != null);

  return allItems;
}

ポイント:

  • updatedAt > lastSyncTime でフィルタリング
  • 3分のバッファで時刻ズレによる取りこぼしを防止
  • ページネーション(nextToken)に対応

効果

指標 Before After 改善率
取得件数 10,000件 10件(平均) 99.9%削減
通信量 10MB 10KB 99.9%削減

解決策2: バッチ処理の実装

大量データを一度に処理するとメモリ不足やタイムアウトが発生するため、適切なサイズに分割します。

/// MyRootStorage の同期
Future<void> _syncMyRootStorage() async {
  safePrint('🔄 [SYNC] _syncMyRootStorage: 同期開始');
  try {
    // 差分取得を利用したクラウドデータの取得
    final cloudData = await _fetchCloudMyRootStorages();
    safePrint('🔄 [SYNC] cloudData取得完了 ${cloudData.length}件');

    // バッチサイズの設定
    const int batchSize = 50;
    final cloudBatches = _createBatches<MyRootStorage>(cloudData, batchSize);
    safePrint('🔄 [SYNC] バッチ数=${cloudBatches.length}');

    // バッチ処理を並列実行
    await Future.wait([
      for (var batch in cloudBatches) _processCloudMyRootBatch(batch),
    ]);
    safePrint('✅ [SYNC] _syncMyRootStorage done');
  } catch (e) {
    safePrint('❌ [SYNC] Error in _syncMyRootStorage: $e');
  }
}

/// バッチを分割するヘルパー
List<List<T>> _createBatches<T>(List<T> items, int batchSize) {
  final List<List<T>> batches = [];
  for (var i = 0; i < items.length; i += batchSize) {
    batches.add(items.sublist(i, min(i + batchSize, items.length)));
  }
  return batches;
}

/// クラウドバッチをローカルに反映
Future<void> _processCloudMyRootBatch(List<MyRootStorage> cloudBatch) async {
  await Future.wait(cloudBatch.map((cloudItem) async {
    await DBHelper().insertMyRootStorage(
      MyRootStorageLocal.fromCloudModel(cloudItem),
    );
  }));
}

効果

指標 Before After 改善率
ネットワーク往復 1,000回 20回 50倍削減
処理時間 50秒 1秒 50倍高速化

解決策3: 並列実行の実装

独立したテーブルは同時に同期できます。ただし、依存関係があるテーブルは順序を考慮します。

// 依存関係を考慮した順序で同期処理を追加
final orderedTables = [
  'PlanCatalog',      // マスターデータ(依存なし)
  'UserEntitlement',  // 課金情報(依存なし)
  'MyRootStorage',    // デッキ設定
  'RootStorage',      // デッキ本体
  'DesignPattern',    // デザイン
  'MyChildStorage',   // カード設定
  'ChildStorage',     // カード関連
  'MyCard',           // カードユーザ設定
  'Card',             // カード本体
  // ...
];

テーブルごとの同期カーソル管理

各テーブルごとに個別の同期カーソルを管理することで、より細かい粒度で差分同期を実現します:

/// 各テーブルごとのカーソルを使って同期を実行するラッパー
Future<void> Function() _buildPerTableOperation(
    String tableName, Future<void> Function() operation) {
  return () async {
    final userIdentifier = await AuthHelper.getUserIdentifier();

    try {
      // テーブル単位のカーソルを読み込み
      _lastSyncTime =
          await DBHelper().getSyncCursor(userIdentifier, tableName);
    } catch (_) {
      _lastSyncTime = null;
    }

    // 本体実行
    await operation();

    // 正常完了時にテーブル単位のカーソルを更新
    try {
      final now = DateTime.now().toUtc();
      await DBHelper().upsertSyncCursor(userIdentifier, tableName, now);
    } catch (_) {}
  };
}

効果

指標 Before(直列) After(並列) 改善率
合計時間 15秒 5秒 3倍高速化

おまけ: GlobalTableMetadataによる効率化

すべてのテーブルを毎回チェックするのは無駄です。「どのテーブルが更新されたか」をメタデータで管理します。

Future<Set<String>> _getUpdatedTables(DateTime lastSyncTime) async {
  final Set<String> updatedTables = <String>{};

  try {
    // GlobalTableMetadataから更新されたテーブルを確認
    final globalUpdatedTables = await _getGlobalUpdatedTables(lastSyncTime);
    updatedTables.addAll(globalUpdatedTables);

    // UserTableMetadataから更新されたテーブルを確認
    final userUpdatedTables = await _getUserUpdatedTables(lastSyncTime);
    updatedTables.addAll(userUpdatedTables);
  } catch (e) {
    // エラー時は全テーブルを同期対象とする(安全側に倒す)
    updatedTables.addAll([
      'RootStorage', 'MyRootStorage', 'Card', 'MyCard',
      // ... 全テーブル
    ]);
  }

  return updatedTables;
}

DynamoDB Streamsを利用して、テーブル更新時に自動的にメタデータを更新する仕組みです。

効果

指標 Before After 改善率
APIコール 16回 1回 16倍削減

エラーハンドリング:部分的な失敗への対応

同期処理では「一部だけ失敗する」ケースを想定する必要があります。

Future<void> syncData({
  Function(String operation, double progress)? onProgress,
}) async {
  try {
    final updatedTables = await _getUpdatedTables(lastSync);
    
    if (updatedTables.isEmpty) {
      onProgress?.call('同期対象なし', 1.0);
      return;
    }
    
    final syncOperations = _getSyncOperationsForTables(updatedTables);
    
    for (int i = 0; i < syncOperations.length; i++) {
      final (operationName, operation) = syncOperations[i];
      
      try {
        await operation();
      } catch (e) {
        // 個別のテーブル同期エラーはログに記録して続行
        safePrint('❌ [SYNC] Error syncing $operationName: $e');
        // 次のテーブルの同期を継続
      }
    }
  } catch (e) {
    // 致命的なエラーでもアプリは継続
    safePrint('❌ [SYNC] Fatal error: $e');
  } finally {
    // 同期完了時刻を更新(部分的な成功でも記録)
    await DBHelper().updateLastSyncTime(DateTime.now().toUtc());
  }
}

ポイント:

  • テーブル単位でtry-catchして、1つ失敗しても他は継続
  • 部分的な成功でも同期時刻は更新(次回は失敗分だけリトライ)
  • ユーザーにはエラーを見せず、バックグラウンドで回復

実装時の注意点

1. タイムゾーンはUTCに統一

DynamoDBとSQLiteで時刻を比較するとき、タイムゾーンが異なるとバグの温床になります。

// ✅ 良い例:常にUTCで扱う
class RootStorageLocal {
  static RootStorageLocal fromCloudModel(RootStorage cloud) {
    return RootStorageLocal(
      rootId: cloud.rootId,
      updatedAt: cloud.updatedAt.getDateTimeInUtc(), // UTCに変換
    );
  }
}

2. 削除は論理削除で

物理削除すると「削除されたこと」が同期できません。

// GraphQLクエリで削除フラグも取得
query {
  items {
    rootId
    isDeleted  // 削除フラグ
    deletedAt
  }
}

3. 競合解決はLast-Write-Wins

複数デバイスで同時編集した場合、updatedAtが新しい方を採用します。


まとめ:改善効果

最適化 効果
差分同期 通信量 99.9%削減
バッチ処理 処理速度 50倍向上
並列実行 同期時間 1/3に短縮
メタデータ活用 APIコール 16倍削減

結果:30秒以上 → 1秒以下


今後の改善案

  • GraphQL Subscriptionsによるリアルタイム同期
  • コンフリクト解決の高度化(フィールド単位マージ)
  • オフライン変更のキューイングと自動リトライ

📱 テスター募集中

この同期機能を実装している Cobo Memo は、現在Androidクローズドテストを実施中です。

Cobo Memoとは?

AIがテキスト・画像・動画からフラッシュカードを自動生成する学習アプリです。
技術のキャッチアップや資格取得の勉強にお使いください。

👉 テスター募集の詳細・参加はこちら

興味のある方はぜひご参加ください!

質問や感想があれば、コメント欄やX(@CoboMemo)でお気軽にどうぞ!

参考資料

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?