スポンサーリンク

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,
);
```


楽天ブックス
¥3,080 (2026/02/12 13:46時点 | 楽天市場調べ)
楽天ブックス
¥1,760 (2026/02/06 09:24時点 | 楽天市場調べ)

スポンサーリンク

Twitterでフォローしよう

おすすめの記事