- 学習カードアプリでオフライン同期を実装したら、最初は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つの最適化を組み合わせました:
- 差分同期 → 更新されたデータのみ取得(通信量99.9%削減)
- バッチ処理 → 50件ずつまとめて処理(処理速度50倍向上)
- 並列実行 → 独立したテーブルを同時に同期(時間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)でお気軽にどうぞ!