Flutterで開発しているチェスアプリ「ぽよぽよチェス」の「履歴管理」機能について、Clean Architectureに基づいた実装と構成を整理しました。
データ流れの概覧
依存関係図
ディレクトリ構成
lib/
│
├── data/
│ ├── datasources/
│ │ └── local/
│ │ ├── database_helper.dart # DBの初期化と管理
│ │ └── game_history_table.dart # テーブル構造の定数定義
│ ├── repositories/
│ │ ├── base_repository.dart # insert/updateなど共通操作の提供
│ │ └── game_history_repository.dart # 実際のリポジトリ実装
│
├── domain/
│ ├── entities/
│ │ └── game_record.dart # GameRecordエンティティ
│ ├── repositories/
│ │ └── igame_history_repository.dart # リポジトリインターフェース
│
├── application/
│ └── usecases/
│ ├── get_all_game_records.dart # ユースケース1:すべての記録を取得
│ └── get_game_record_by_id.dart # ユースケース2:IDで記録を取得
│
├── presentation/
│ ├── viewmodels/
│ │ └── game_history_view_model.dart # UIと連携するViewModel
│ ├── screens/
│ │ ├── game_history_screen.dart # 一覧画面
│ │ └── replay_screen.dart # 棋譜リプレイ画面
│ └── providers/
│ └── repository_providers.dart # Providerの依存注入定義
│
├── core/
│ └── utils/
│ └── (オプション)
│
├── l10n/
│ └── app_localizations.dart # 多言語対応(国際化)
-
Presentation層: UIとその独自状態を管理
-
Application層:ユースケース(アプリケーション固有の処理)
-
Domain層: ビジネスルールの中心。エンティティ、インターフェース、ドメインサービスなどを保持
-
Data層: 外部リソースへのアクセス実装(DB、APIなど)
アーキテクチャーの依存分離の意義
-
IGameHistoryRepository をドメインに置くことで、UseCaseやViewModelはData層に依存せず、テスト性が高まります
-
Providerを分離することで、部分的なモックとしての交換も容易
コードのカプセル化
Data層
database_helper
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
factory DatabaseHelper() => _instance;
DatabaseHelper._internal();
static const String _dbName = 'chess_records.db';
static const int _dbVersion = 1;
static Database? _database;
Future<Database> get database async {
return _database ??= await _initDatabase();
}
Future<Database> _initDatabase() async {
try {
final dbPath = await getDatabasesPath();
final path = join(dbPath, _dbName);
return await openDatabase(
path,
version: _dbVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
} catch (e) {
throw DatabaseException('Failed to initialize database: $e');
}
}
// Create database tables
Future<void> _onCreate(Database db, int version) async {
await db.execute(GameHistoryTable.createTableSql());
await db.execute(GameHistoryTable.createIndexSql());
}
// Handle database upgrades between versions
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Will implement version migration logic when needed
// if (oldVersion < 2 && newVersion >= 2) {
// // For future upgrades
// }
}
// Close database connection
Future<void> close() async {
if (_database != null) {
await _database!.close();
_database = null;
}
}
}
// Custom exception class for database errors
class DatabaseException implements Exception {
final String message;
DatabaseException(this.message);
@override
String toString() => 'DatabaseException: $message';
}
GameHistoryTable
class GameHistoryTable {
static const String tableName = 'game_history';
static const String colId = 'id';
static const colWhiteName = 'white_name'; // White player's name (e.g., “Alice”)
static const colBlackName = 'black_name'; // Black player's name (e.g., “Bob”)
static const String colResult =
'result'; // Game result, such as 1-0, 0-1, 1/2-1/2, or * (asterisk) if unfinished
static const String colPgn = 'pgn'; // PGN (Portable Game Notation) text, includes headers and moves
static const String colFen = 'fen'; // Optional: FEN string representing the final board state
static const colStartTime = 'start_time'; // Game start time in ISO-8601 string format
static const colEndTime = 'end_time'; // Game end time in ISO-8601 string format
static const colIsFinished = 'is_finished'; // Game completion flag (1 = finished, 0 = unfinished)
static String createTableSql() {
return '''
CREATE TABLE $tableName (
$colId INTEGER PRIMARY KEY AUTOINCREMENT,
$colWhiteName TEXT NOT NULL,
$colBlackName TEXT NOT NULL,
$colResult TEXT NOT NULL,
$colPgn TEXT NOT NULL,
$colFen TEXT,
$colStartTime TEXT NOT NULL,
$colEndTime TEXT,
$colIsFinished INTEGER NOT NULL DEFAULT 0
);
''';
}
static String createIndexSql() {
return '''
CREATE INDEX IF NOT EXISTS idx_player ON $tableName ($colWhiteName, $colBlackName);
''';
}
}
BaseRepository
abstract class BaseRepository {
Future<T> wrapDbCall<T>(Future<T> Function() action) async {
try {
return await action();
} catch (e) {
throw DatabaseException('DB operation failed: $e}');
}
}
Future<Database> getDb() => DatabaseHelper().database;
Future<List<dynamic>> wrapBatchCall(
Future<List<dynamic>> Function(Batch) handler,
) async {
return wrapDbCall(() async {
final db = await getDb();
final batch = db.batch();
final result = await handler(batch);
return await batch.commit();
});
}
Future<int> count(String table) async {
return wrapDbCall(() async {
final db = await getDb();
return Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM $table'),
) ??
0;
});
}
Future<int> deleteAll(String table) async {
return wrapDbCall(() async {
final db = await getDb();
return await db.delete(table);
});
}
Future<List<Map<String, dynamic>>> queryWhere({
required String table,
required String where,
required List<Object?> whereArgs,
String? orderBy,
int? limit,
}) async {
return wrapDbCall(() async {
final db = await getDb();
return await db.query(
table,
where: where,
whereArgs: whereArgs,
orderBy: orderBy,
limit: limit,
);
});
}
// Inserts a single record into the specified table.
Future<int> insert(
String table,
Map<String, dynamic> data, {
ConflictAlgorithm? conflictAlgorithm,
}) async {
return wrapDbCall(() async {
final db = await getDb();
return db.insert(table, data, conflictAlgorithm: conflictAlgorithm);
});
}
// Inserts multiple records in a batch operation.
Future<int> update(
String table,
Map<String, dynamic> data, {
required String where,
required List<Object?> whereArgs,
}) async {
return wrapDbCall(() async {
final db = await getDb();
return db.update(table, data, where: where, whereArgs: whereArgs);
});
}
// Deletes records from the specified table based on the provided conditions.
Future<int> delete(
String table, {
required String where,
required List<Object?> whereArgs,
}) async {
return wrapDbCall(() async {
final db = await getDb();
return db.delete(table, where: where, whereArgs: whereArgs);
});
}
// Executes a raw SQL query and returns the results as a list of maps.
Future<List<Map<String, dynamic>>> rawQuery(
String sql, [
List<Object?>? args,
]) async {
return wrapDbCall(() async {
final db = await getDb();
return db.rawQuery(sql, args);
});
}
}
GameHistoryRepository
class GameHistoryRepository extends BaseRepository
implements IGameHistoryRepository {
Future<int> insertRecord(GameRecord record) {
return insert(
GameHistoryTable.tableName,
record.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<int>> insertRecords(List<GameRecord> records) {
return wrapBatchCall((batch) {
for (final r in records) {
batch.insert(
GameHistoryTable.tableName,
r.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
return Future.value([]);
}).then((value) => value.cast<int>());
}
Future<List<GameRecord>> getAllRecords({
int? limit,
int? offset,
bool orderDesc = true,
}) async {
final maps = await queryWhere(
table: GameHistoryTable.tableName,
where: '1 = 1',
// 全件取得
whereArgs: [],
orderBy: buildOrderByClause(orderDesc, GameHistoryTable.colEndTime),
limit: limit,
);
return maps.map(GameRecord.fromMap).toList();
}
Future<int> deleteRecordById(int id) {
return delete(
GameHistoryTable.tableName,
where: '${GameHistoryTable.colId} = ?',
whereArgs: [id],
);
}
Future<int> updateResult(int id, String result, {bool isFinished = true}) {
return update(
GameHistoryTable.tableName,
{
GameHistoryTable.colResult: result,
GameHistoryTable.colIsFinished: isFinished ? 1 : 0,
GameHistoryTable.colEndTime: DateTime.now().toIso8601String(),
},
where: '${GameHistoryTable.colId} = ?',
whereArgs: [id],
);
}
Future<List<GameRecord>> getRecordsByPlayer(String playerName) async {
final maps = await queryWhere(
table: GameHistoryTable.tableName,
where:
'${GameHistoryTable.colWhiteName} = ? OR ${GameHistoryTable.colBlackName} = ?',
whereArgs: [playerName, playerName],
orderBy: '${GameHistoryTable.colEndTime} DESC',
);
return maps.map(GameRecord.fromMap).toList();
}
Future<GameRecord?> getRecordById(int id) async {
final maps = await queryWhere(
table: GameHistoryTable.tableName,
where: '${GameHistoryTable.colId} = ?',
whereArgs: [id],
limit: 1,
);
return maps.isNotEmpty ? GameRecord.fromMap(maps.first) : null;
}
Future<int> updateRecord(GameRecord record) {
return update(
GameHistoryTable.tableName,
record.toMap(),
where: '${GameHistoryTable.colId} = ?',
whereArgs: [record.id],
);
}
Future<List<GameRecord>> getUnfinishedGames() async {
final maps = await queryWhere(
table: GameHistoryTable.tableName,
where: '${GameHistoryTable.colIsFinished} = ?',
whereArgs: [0],
orderBy: '${GameHistoryTable.colStartTime} DESC',
);
return maps.map(GameRecord.fromMap).toList();
}
Future<List<GameRecord>> searchByPlayer(String keyword) async {
final maps = await queryWhere(
table: GameHistoryTable.tableName,
where:
'${GameHistoryTable.colWhiteName} LIKE ? OR ${GameHistoryTable.colBlackName} LIKE ?',
whereArgs: ['%$keyword%', '%$keyword%'],
orderBy: '${GameHistoryTable.colEndTime} DESC',
);
return maps.map(GameRecord.fromMap).toList();
}
Future<int> updateResultWithPGN({
required int id,
required String result,
required String pgn,
String? fen,
bool isFinished = true,
}) {
return update(
GameHistoryTable.tableName,
{
GameHistoryTable.colResult: result,
GameHistoryTable.colPgn: pgn,
GameHistoryTable.colFen: fen ?? '-',
GameHistoryTable.colIsFinished: isFinished ? 1 : 0,
GameHistoryTable.colEndTime: DateTime.now().toIso8601String(),
},
where: '${GameHistoryTable.colId} = ?',
whereArgs: [id],
);
}
String buildOrderByClause(bool desc, String column) =>
'$column ${desc ? "DESC" : "ASC"}';
}
Domain層
IGameHistoryRepository
abstract class IGameHistoryRepository {
Future<int> insertRecord(GameRecord record);
Future<List<int>> insertRecords(List<GameRecord> records);
Future<List<GameRecord>> getAllRecords({
int? limit,
int? offset,
bool orderDesc,
});
Future<int> deleteRecordById(int id);
Future<int> updateResult(int id, String result, {bool isFinished = true});
Future<List<GameRecord>> getRecordsByPlayer(String playerName);
Future<GameRecord?> getRecordById(int id);
Future<int> updateRecord(GameRecord record);
Future<List<GameRecord>> getUnfinishedGames();
Future<List<GameRecord>> searchByPlayer(String keyword);
Future<int> updateResultWithPGN({
required int id,
required String result,
required String pgn,
String? fen,
bool isFinished,
});
}
GameRecord
class GameRecord {
final int? id;
final String startTime;
final String endTime;
final String whiteName;
final String blackName;
final String result;
final String? pgn;
final String? fen;
final int isFinished; // 1 = true, 0 = false
GameRecord({
this.id,
required this.whiteName,
required this.blackName,
required this.result,
this.pgn,
this.fen,
String? startTime,
String? endTime,
this.isFinished = 0,
}) : startTime = startTime ?? DateTime.now().toIso8601String(),
endTime = endTime ?? DateTime.now().toIso8601String();
factory GameRecord.fromMap(Map<String, dynamic> map) {
return GameRecord(
id: map[GameHistoryTable.colId],
whiteName: map[GameHistoryTable.colWhiteName],
blackName: map[GameHistoryTable.colBlackName],
result: map[GameHistoryTable.colResult],
pgn: map[GameHistoryTable.colPgn],
fen: map[GameHistoryTable.colFen],
startTime: map[GameHistoryTable.colStartTime],
endTime: map[GameHistoryTable.colEndTime],
isFinished: map[GameHistoryTable.colIsFinished],
);
}
Map<String, dynamic> toMap() {
return {
GameHistoryTable.colId: id,
GameHistoryTable.colWhiteName: whiteName,
GameHistoryTable.colBlackName: blackName,
GameHistoryTable.colResult: result,
GameHistoryTable.colPgn: pgn,
GameHistoryTable.colFen: fen,
GameHistoryTable.colStartTime: startTime,
GameHistoryTable.colEndTime: endTime,
GameHistoryTable.colIsFinished: isFinished,
};
}
@override
String toString() {
return 'GameRecord{id: $id, startTime: $startTime, endTime: $endTime, whiteName: $whiteName, blackName: $blackName, result: $result, pgn: $pgn, fen: $fen, isFinished: $isFinished}';
}
}
Application層
GetAllGameRecords
class GetAllGameRecords {
final IGameHistoryRepository repository;
GetAllGameRecords(this.repository);
Future<List<GameRecord>> call({
int? limit,
int? offset,
bool orderDesc = true,
}) {
return repository.getAllRecords(
limit: limit,
offset: offset,
orderDesc: orderDesc,
);
}
}
Presentation層
GameHistoryViewModel
class GameHistoryViewModel extends StateNotifier<AsyncValue<List<GameRecord>>> {
final GetAllGameRecords _getAllGameRecords;
GameHistoryViewModel(this._getAllGameRecords) : super(const AsyncLoading()) {
fetchRecords();
}
Future<void> fetchRecords({bool orderDesc = true}) async {
state = const AsyncLoading();
try {
final records = await _getAllGameRecords(orderDesc: orderDesc);
state = AsyncValue.data(records);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
game_history_providers
final gameHistoryRepositoryProvider = Provider<IGameHistoryRepository>((ref) {
return GameHistoryRepository(); // ここだけdata層に依存してOK
});
final getAllGameRecordsProvider = Provider<GetAllGameRecords>((ref) {
final repo = ref.read(gameHistoryRepositoryProvider); // Repository注入
return GetAllGameRecords(repo);
});
final gameHistoryViewModelProvider =
StateNotifierProvider<GameHistoryViewModel, AsyncValue<List<GameRecord>>>((
ref,
) {
final usecase = ref.watch(getAllGameRecordsProvider);
return GameHistoryViewModel(usecase);
});
まとめ
Clean Architecture を実践すると、各層の責任が明確になり、拡張性やテスト性に優れます。
資源
以下、依存関係図のmermaidコードです。
flowchart TD
subgraph Presentation
A1[GameHistoryScreen]
A2[GameHistoryViewModel]
A3[ReplayScreen]
A4[ReplayViewModel]
end
subgraph Application
B1[GetAllGameRecords UseCase]
B2[GetGameRecordById UseCase]
end
subgraph Domain
C1[IGameHistoryRepository]
C2[GameRecord Entity]
end
subgraph Data
D1[GameHistoryRepository]
D2[BaseRepository]
D3[DatabaseHelper]
D4[GameHistoryTable]
end
%% Presentation -> Application
A1 --> A2
A2 --> B1
A3 --> A4
A4 --> B2
%% Application -> Domain (Interface)
B1 --> C1
B2 --> C1
%% Data -> Domain (Implements)
D1 -->C1
%% Domain Entity
B1 --> C2
B2 --> C2
D1 --> C2
%% Data Internal dependencies
D1 --> D2
D1 --> D3
D1 --> D4