README.mdとARCHITECTURE.mdの具体例
すると途端に働き出しました。
どうやら、最初に text_edit_page.dartを作成しましたというのは、最小限の機能を作成した状態だったのかもしれません。本格的に text_edit_page.dart の作成が進み出しました。
そして最終的に実装した機能を一覧化して、他のページとの統一性も合わせた状態で完成させてくれました。
README.md
# Pokemon App
PokéAPIを使用したFlutterサンプルアプリケーション。
MVVM + Repositoryアーキテクチャ、hooks_riverpod、freezed、retrofitなど、モダンなFlutter開発のベストプラクティスを実装しています。
## 使用技術
### 状態管理
- hooks_riverpod - 状態管理
- flutter_hooks - Hooksを使用したWidget開発
- state_notifier - ViewModelの状態管理
### ルーティング
- go_router - 宣言的ルーティング
### API通信
- dio - HTTPクライアント
- retrofit - 型安全なRESTクライアント
### モデル
- freezed - イミュータブルなモデルクラス生成
- json_serializable - JSONシリアライゼーション
### その他
- logger - ロギング
- flutter_lints - Lintルール
- mocktail - テスト用モック
## セットアップ
### 1. 依存関係のインストール
```bash
fvm flutter pub get
```
### 2. コード生成
```bash
fvm flutter pub run build_runner build --delete-conflicting-outputs
```
または、ファイル変更を監視して自動生成する場合:
```bash
fvm flutter pub run build_runner watch --delete-conflicting-outputs
```
または、シェルスクリプトで一括実行:
```bash
./generate.sh
```
### 3. アプリの起動
```bash
fvm flutter run
```
## 開発コマンド
### コード生成
```bash
# 一度だけ実行
fvm flutter pub run build_runner build --delete-conflicting-outputs
# ファイル変更を監視
fvm flutter pub run build_runner watch --delete-conflicting-outputs
```
### テスト実行
```bash
# 全テスト実行
fvm flutter test
# 特定のテストファイル実行
fvm flutter test test/repository/pokemon_repository_test.dart
# カバレッジ付き実行
fvm flutter test --coverage
```
### コード解析
```bash
fvm flutter analyze
```
### コード整形
```bash
fvm flutter format .
```
## アーキテクチャ
```
[UI層]
View (HookConsumerWidget)
↓
ViewModel (StateNotifier)
↓
[データ層]
Repository
↓
ApiClient (Retrofit)
↓
Dio
```
## フォルダ構成
```
lib/
├── model/ # データモデル (Freezed)
├── api/ # APIクライアント (Retrofit)
├── repository/ # リポジトリ層
├── page/ # 画面UI
│ ├── pokemon_list_page/
│ │ ├── pokemon_list_page.dart
│ │ └── component/
│ │ ├── pokemon_list_state.dart
│ │ └── pokemon_list_viewmodel.dart
│ └── pokemon_detail_page/
│ ├── pokemon_detail_page.dart
│ └── component/
│ ├── pokemon_detail_state.dart
│ └── pokemon_detail_viewmodel.dart
├── provider/ # Riverpod Provider
├── util/ # ユーティリティ
└── main.dart # エントリーポイント
```
## 機能
- ポケモン一覧表示(無限スクロール対応)
- ポケモン詳細表示
- プルダウンリフレッシュ
- エラーハンドリング
- ロギング
## 参考リンク
- [PokéAPI](https://pokeapi.co/)
- [Flutter Architecture Guide](https://docs.flutter.dev/app-architecture/guide)
ARCHITECTURE.md
# Architecture
このドキュメントでは、本プロジェクトのアーキテクチャ設計、レイヤー構造、データフローについて説明します。
## 目次
- [アーキテクチャパターン](#アーキテクチャパターン)
- [レイヤー構造](#レイヤー構造)
- [データフロー](#データフロー)
- [ディレクトリ構造](#ディレクトリ構造)
- [状態管理](#状態管理)
- [依存性注入](#依存性注入)
- [テスト戦略](#テスト戦略)
- [コード生成](#コード生成)
- [ルーティング](#ルーティング)
- [ロギング](#ロギング)
## アーキテクチャパターン
本プロジェクトは **MVVM (Model-View-ViewModel) + Repository パターン**を採用しています。
参考: [Flutter App Architecture Guide](https://docs.flutter.dev/app-architecture/guide)
### MVVMの各要素
```
┌──────────────────────────────────────────────┐
│ UI層 │
│ ┌────────┐ ┌──────────────┐ │
│ │ View │ ────>│ ViewModel │ │
│ └────────┘ └──────────────┘ │
│ │ │
└─────────────────────────│─────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ データ層 │
│ ┌──────────────┐ ┌──────────┐ │
│ │ Repository │ ────>│ Service │ │
│ └──────────────┘ │ (API) │ │
│ └──────────┘ │
└──────────────────────────────────────────────┘
```
#### View
- ユーザーインターフェースを担当
- `HookConsumerWidget`を使用してViewModelの状態を監視
- ユーザーの操作をViewModelに委譲
- **責務**: UI表示、ユーザー入力の受付
#### ViewModel
- ビジネスロジックと状態管理を担当
- `StateNotifier`を継承して実装
- Repositoryからデータを取得し、Stateを更新
- **責務**: ビジネスロジック、状態管理、Viewへのデータ提供
#### Repository
- データソースへのアクセスを抽象化
- APIクライアントやデータベースとの通信を担当
- **責務**: データの取得・永続化、エラーハンドリング
#### Model
- ドメインオブジェクト・データクラス
- `freezed`によるイミュータブルなデータクラスとして定義
- **責務**: データ構造の定義
## レイヤー構造
### UI層 (Presentation Layer)
```
lib/page/
├── pokemon_list_page/
│ ├── pokemon_list_page.dart # View
│ └── component/
│ ├── pokemon_list_viewmodel.dart # ViewModel
│ └── pokemon_list_state.dart # State
└── pokemon_detail_page/
├── pokemon_detail_page.dart
└── component/
├── pokemon_detail_viewmodel.dart
└── pokemon_detail_state.dart
```
#### Viewの実装例
```dart
class PokemonListPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ViewModelの取得
final viewModel = ref.watch(pokemonListViewModelProvider.notifier);
// Stateの監視
final state = ref.watch(pokemonListViewModelProvider);
return Scaffold(
body: ListView.builder(
itemCount: state.pokemons.length,
itemBuilder: (context, index) {
final pokemon = state.pokemons[index];
return ListTile(
title: Text(pokemon.name),
onTap: () => context.push('/pokemon/${pokemon.name}'),
);
},
),
);
}
}
```
#### ViewModelの実装例
```dart
class PokemonListViewModel extends StateNotifier<PokemonListState> {
PokemonListViewModel(this._repository) : super(const PokemonListState()) {
fetchPokemonList();
}
final PokemonRepository _repository;
Future<void> fetchPokemonList() async {
state = state.copyWith(isLoading: true);
try {
final response = await _repository.fetchPokemonList();
state = state.copyWith(
pokemons: response.results,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
}
```
#### Stateの実装例
```dart
@freezed
class PokemonListState with _$PokemonListState {
const factory PokemonListState({
@Default([]) List<Pokemon> pokemons,
@Default(false) bool isLoading,
@Default(true) bool hasMore,
String? error,
}) = _PokemonListState;
}
```
### データ層 (Data Layer)
```
lib/
├── repository/
│ └── pokemon_repository.dart # Repository
├── api/
│ └── pokemon_api_client.dart # API Client (Retrofit)
└── model/
├── pokemon.dart # Domain Model
├── pokemon_detail.dart
└── pokemon_list_response.dart
```
#### Repositoryの実装例
```dart
class PokemonRepository {
PokemonRepository(this._apiClient);
final PokemonApiClient _apiClient;
Future<PokemonListResponse> fetchPokemonList({
int limit = 20,
int offset = 0,
}) async {
try {
logger.d('Fetching pokemon list: limit=$limit, offset=$offset');
final response = await _apiClient.getPokemonList(
limit: limit,
offset: offset,
);
logger.d('Successfully fetched ${response.results.length} pokemons');
return response;
} catch (e, stackTrace) {
logger.e('Failed to fetch pokemon list', error: e, stackTrace: stackTrace);
rethrow;
}
}
}
```
#### APIクライアントの実装例 (Retrofit)
```dart
@RestApi(baseUrl: 'https://pokeapi.co/api/v2/')
abstract class PokemonApiClient {
factory PokemonApiClient(Dio dio, {String baseUrl}) = _PokemonApiClient;
@GET('/pokemon')
Future<PokemonListResponse> getPokemonList({
@Query('limit') int limit = 20,
@Query('offset') int offset = 0,
});
@GET('/pokemon/{id}')
Future<PokemonDetail> getPokemonDetail(@Path('id') int id);
}
```
#### Modelの実装例 (Freezed)
```dart
@freezed
class Pokemon with _$Pokemon {
const factory Pokemon({
required String name,
required String url,
}) = _Pokemon;
factory Pokemon.fromJson(Map<String, dynamic> json) =>
_$PokemonFromJson(json);
}
```
## データフロー
### 通常のデータフロー
```
User Action (View)
│
▼
ViewModel Method Call
│
▼
Repository Method Call
│
▼
API Client Request
│
▼
Response (API)
│
▼
Repository Process
│
▼
ViewModel State Update
│
▼
View Rebuild
```
### 具体例: ポケモンリストの取得
1. **User Action**: ユーザーがアプリを起動
2. **ViewModel初期化**: `PokemonListViewModel`のコンストラクタで`fetchPokemonList()`が呼ばれる
3. **State更新**: `state.isLoading = true`に設定
4. **Repository呼び出し**: `_repository.fetchPokemonList()`を実行
5. **API呼び出し**: `_apiClient.getPokemonList()`でPokeAPIにリクエスト
6. **Response処理**: JSONレスポンスを`PokemonListResponse`にデシリアライズ
7. **State更新**: 取得したポケモンリストで`state.pokemons`を更新
8. **View再構築**: Riverpodが変更を検知し、Viewを再構築
## ディレクトリ構造
```
lib/
├── model/ # データモデル(Domain Objects)
│ ├── pokemon.dart
│ ├── pokemon_detail.dart
│ └── pokemon_list_response.dart
│
├── api/ # APIクライアント
│ └── pokemon_api_client.dart # Retrofit API Client
│
├── repository/ # データソース(Repository Pattern)
│ └── pokemon_repository.dart
│
├── page/ # 画面UI
│ ├── pokemon_list_page/ # 画面ごとにフォルダ化
│ │ ├── pokemon_list_page.dart # View
│ │ └── component/ # 画面に付随するコンポーネント
│ │ ├── pokemon_list_viewmodel.dart # ViewModel
│ │ └── pokemon_list_state.dart # State
│ └── pokemon_detail_page/
│ ├── pokemon_detail_page.dart
│ └── component/
│ ├── pokemon_detail_viewmodel.dart
│ └── pokemon_detail_state.dart
│
├── provider/ # Riverpod Provider定義
│ ├── dio_provider.dart
│ ├── pokemon_api_client_provider.dart
│ └── pokemon_repository_provider.dart
│
├── util/ # ユーティリティ
│ ├── logger.dart # ロガー
│ └── router.dart # ルーティング (go_router)
│
├── constant/ # 定数・設定
├── view/ # 汎用的・共通UIコンポーネント
├── viewmodel/ # 汎用的・共通ViewModel
└── extension/ # 拡張機能
assets/
└── images/ # 画像・アイコン
```
### 命名規則
- **ページ**: `{feature}_page.dart` (例: `pokemon_list_page.dart`)
- **ViewModel**: `{feature}_viewmodel.dart` (例: `pokemon_list_viewmodel.dart`)
- **State**: `{feature}_state.dart` (例: `pokemon_list_state.dart`)
- **Repository**: `{domain}_repository.dart` (例: `pokemon_repository.dart`)
- **Model**: `{entity}.dart` (例: `pokemon.dart`)
## 状態管理
### Riverpod + StateNotifier
状態管理には **hooks_riverpod** と **state_notifier** を使用しています。
#### Providerの定義
```dart
// ViewModelのProvider
final pokemonListViewModelProvider =
StateNotifierProvider<PokemonListViewModel, PokemonListState>((ref) {
final repository = ref.watch(pokemonRepositoryProvider);
return PokemonListViewModel(repository);
});
// RepositoryのProvider
final pokemonRepositoryProvider = Provider<PokemonRepository>((ref) {
final apiClient = ref.watch(pokemonApiClientProvider);
return PokemonRepository(apiClient);
});
// API ClientのProvider
final pokemonApiClientProvider = Provider<PokemonApiClient>((ref) {
final dio = ref.watch(dioProvider);
return PokemonApiClient(dio);
});
```
#### Providerの使用
```dart
class PokemonListPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// StateNotifierの取得(メソッド呼び出し用)
final viewModel = ref.watch(pokemonListViewModelProvider.notifier);
// Stateの監視(状態の取得)
final state = ref.watch(pokemonListViewModelProvider);
// ...
}
}
```
### State設計のポイント
- **Immutable**: `freezed`を使用してイミュータブルなStateを定義
- **copyWith**: 部分的な状態更新に`copyWith`メソッドを使用
- **Default値**: `@Default(...)`でデフォルト値を設定
- **Nullable**: エラーメッセージなどはnullableで定義
```dart
@freezed
class PokemonListState with _$PokemonListState {
const factory PokemonListState({
@Default([]) List<Pokemon> pokemons, // 空リストがデフォルト
@Default(false) bool isLoading, // falseがデフォルト
@Default(true) bool hasMore, // trueがデフォルト
String? error, // nullableで定義
}) = _PokemonListState;
}
```
## 依存性注入
Riverpodによる依存性注入(Dependency Injection)を実現しています。
### 依存関係グラフ
```
PokemonListViewModel
↓ (depends on)
PokemonRepository
↓ (depends on)
PokemonApiClient
↓ (depends on)
Dio
```
### Providerチェーン
```dart
// 1. Dioの提供
final dioProvider = Provider<Dio>((ref) {
return Dio(BaseOptions(
baseUrl: 'https://pokeapi.co/api/v2/',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 3),
));
});
// 2. API Clientの提供
final pokemonApiClientProvider = Provider<PokemonApiClient>((ref) {
final dio = ref.watch(dioProvider);
return PokemonApiClient(dio);
});
// 3. Repositoryの提供
final pokemonRepositoryProvider = Provider<PokemonRepository>((ref) {
final apiClient = ref.watch(pokemonApiClientProvider);
return PokemonRepository(apiClient);
});
// 4. ViewModelの提供
final pokemonListViewModelProvider =
StateNotifierProvider<PokemonListViewModel, PokemonListState>((ref) {
final repository = ref.watch(pokemonRepositoryProvider);
return PokemonListViewModel(repository);
});
```
### テスト時のオーバーライド
```dart
testWidgets('Pokemon list test', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// モックRepositoryで上書き
pokemonRepositoryProvider.overrideWithValue(mockRepository),
],
child: const MyApp(),
),
);
});
```
## テスト戦略
### テストの種類
1. **Unit Test**: ViewModel、Repositoryの単体テスト
2. **Widget Test**: Viewのウィジェットテスト (❌️今回対応しない)
3. **Integration Test**: E2Eテスト (❌️今回対応しない)
### モックライブラリ
プロジェクトでは2つのモックライブラリをサポートしています:
#### 1. Mockito(推奨)
コード生成によるタイプセーフなモック。
```dart
@GenerateMocks([PokemonApiClient])
void main() {
late MockPokemonApiClient mockApiClient;
late PokemonRepository repository;
setUp(() {
mockApiClient = MockPokemonApiClient();
repository = PokemonRepository(mockApiClient);
});
test('正常にポケモンリストを取得できる', () async {
when(mockApiClient.getPokemonList(limit: 20, offset: 0))
.thenAnswer((_) async => mockResponse);
final result = await repository.fetchPokemonList(limit: 20, offset: 0);
expect(result.results.length, 2);
verify(mockApiClient.getPokemonList(limit: 20, offset: 0)).called(1);
});
}
```
#### 2. Mocktail
手軽にモックを作成できるライブラリ。
```dart
class MockPokemonApiClient extends Mock implements PokemonApiClient {}
void main() {
late MockPokemonApiClient mockApiClient;
late PokemonRepository repository;
setUp(() {
mockApiClient = MockPokemonApiClient();
repository = PokemonRepository(mockApiClient);
});
test('正常にポケモンリストを取得できる', () async {
when(() => mockApiClient.getPokemonList(limit: 20, offset: 0))
.thenAnswer((_) async => mockResponse);
final result = await repository.fetchPokemonList(limit: 20, offset: 0);
expect(result.results.length, 2);
verify(() => mockApiClient.getPokemonList(limit: 20, offset: 0)).called(1);
});
}
```
### テストファイルの配置
```
test/
├── repository/
│ ├── pokemon_repository_test.dart # mockito版
│ └── pokemon_repository_test_mocktail.dart # mocktail版
└── viewmodel/
├── pokemon_list_viewmodel_test.dart # mockito版
└── pokemon_list_viewmodel_test_mocktail.dart # mocktail版
```
### テストのベストプラクティス
1. **AAA パターン**: Arrange(準備)、Act(実行)、Assert(検証)の構造で記述
2. **1テスト1検証**: 各テストは1つの振る舞いのみを検証
3. **モックの活用**: 外部依存は必ずモック化
4. **エラーケースのテスト**: 正常系だけでなく異常系もテスト
## コード生成
本プロジェクトでは以下のコード生成を使用しています。
### 使用パッケージ
- **freezed**: イミュータブルなデータクラス生成
- **json_serializable**: JSON シリアライゼーション/デシリアライゼーション
- **retrofit_generator**: Retrofit API クライアント生成
- **mockito**: モッククラス生成(テスト用)
### 生成コマンド
```bash
# コード生成(初回)
flutter pub run build_runner build
# コード生成(競合ファイルを削除)
flutter pub run build_runner build --delete-conflicting-outputs
# ウォッチモード(ファイル変更を監視して自動生成)
flutter pub run build_runner watch
```
### 生成されるファイル
- `*.freezed.dart`: Freezedによるデータクラス
- `*.g.dart`: JSON シリアライゼーション用コード
- `*.mocks.dart`: Mockitoによるモッククラス(テスト用)
## ルーティング
### go_router
画面遷移には **go_router** を使用しています。
```dart
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const PokemonListPage(),
),
GoRoute(
path: '/pokemon/:name',
builder: (context, state) {
final name = state.pathParameters['name']!;
return PokemonDetailPage(name: name);
},
),
],
);
```
### 画面遷移の方法
```dart
// 通常の遷移
context.push('/pokemon/pikachu');
// 置き換え遷移
context.replace('/pokemon/pikachu');
// 戻る
context.pop();
```
## ロギング
### logger パッケージ
ロギングには **logger** パッケージを使用しています。
```dart
// lib/util/logger.dart
final logger = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 50,
colors: true,
printEmojis: true,
),
);
```
### 使用方法
```dart
logger.d('Debug message'); // デバッグ
logger.i('Info message'); // 情報
logger.w('Warning message'); // 警告
logger.e('Error message', // エラー
error: exception,
stackTrace: stackTrace,
);
```
楽天ブックス
¥1,980 (2026/02/12 13:48時点 | 楽天市場調べ)
楽天ブックス
¥1,980 (2026/02/06 09:24時点 | 楽天市場調べ)
楽天ブックス
¥2,640 (2026/02/10 19:23時点 | 楽天市場調べ)






