スポンサーリンク

アーキテクチャ

Dio/Retrofitを活用したAPI通信の実装
概要と特徴

今回のPokemon Appでは、HTTP通信にDio、RESTクライアントにRetrofitを採用しました。この組み合わせは、Flutter開発における型安全なAPI通信のゴールドスタンダードとして広く使われています。

Dio の特徴:

  • インターセプターによる共通処理の実装
  • リクエスト/レスポンスのログ出力
  • タイムアウト設定やリトライ処理
  • エラーハンドリングの統一化

Retrofit の特徴:

  • アノテーションベースのAPI定義
  • 型安全なRESTクライアント
  • コード生成による自動実装
  • JSON シリアライゼーションとの統合

Claude Codeはこれらのパッケージの特性を理解した上で、以下のような実装を自動生成してくれました。

// api/pokemon_api.dart
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import '../model/pokemon.dart';
import '../model/pokemon_list_response.dart';

part 'pokemon_api.g.dart';

@RestApi(baseUrl: "https://pokeapi.co/api/v2/")
abstract class PokemonApi {
  factory PokemonApi(Dio dio, {String baseUrl}) = _PokemonApi;

  @GET("/pokemon")
  Future<PokemonListResponse> getPokemonList(
    @Query("limit") int limit,
    @Query("offset") int offset,
  );

  @GET("/pokemon/{name}")
  Future<PokemonDetail> getPokemonDetail(
    @Path("name") String name,
  );
}
具体的なメリット

1. 型安全性の確保

Retrofitを使うことで、APIのレスポンス型が明確になり、コンパイル時に型エラーを検出できます。

// リポジトリ層での使用例
class PokemonRepository {
  final PokemonApi _api;

  Future<Pokemon> fetchPokemonDetail(String name) async {
    try {
      final detail = await _api.getPokemonDetail(name);
      // detail は PokemonDetail 型として扱える
      return Pokemon(
        name: detail.name,
        imageUrl: detail.sprites.frontDefault,
        types: detail.types.map((e) => e.type.name).toList(),
      );
    } catch (e) {
      throw Exception('Failed to fetch pokemon detail: $e');
    }
  }
}

2. 保守性の向上

API仕様の変更があった場合、変更箇所が一箇所に集約されます。

// API定義を変更するだけで、全体に反映される
@GET("/pokemon")
Future<PokemonListResponse> getPokemonList(
  @Query("limit") int limit,
  @Query("offset") int offset,
  // 新しいパラメータを追加
  @Query("type") String? type,
);

3. ロギングとデバッグの容易性

Dio のインターセプターを活用した包括的なログ出力。

// provider/dio_provider.dart
@riverpod
Dio dio(DioRef ref) {
  final dio = Dio();
  
  // ロギングインターセプター
  dio.interceptors.add(
    LogInterceptor(
      requestBody: true,
      responseBody: true,
      logPrint: (obj) => logger.d(obj),
    ),
  );

  return dio;
}

4. エラーハンドリングの統一化

共通のエラーハンドリングをリポジトリ層で実装。

Future<List<Pokemon>> fetchPokemonList(int offset) async {
  try {
    final response = await _api.getPokemonList(20, offset);
    
    return await Future.wait(
      response.results.map((result) => 
        fetchPokemonDetail(result.name)
      ),
    );
  } on DioException catch (e) {
    // ネットワークエラーやHTTPエラーを適切に処理
    logger.e('API Error: ${e.message}');
    throw Exception('Failed to fetch pokemon list');
  }
}
実装のポイント

Claude Codeは以下のベストプラクティスを自動で適用してくれました。

1. API定義の分離

  • PokemonApi クラスでAPI仕様を定義
  • ビジネスロジックとAPI通信を分離

2. Riverpod によるDI

@riverpod
PokemonApi pokemonApi(PokemonApiRef ref) {
  final dio = ref.watch(dioProvider);
  return PokemonApi(dio);
}

3. コード生成の活用

# build_runner でRetrofitの実装を自動生成
fvm flutter pub run build_runner build --delete-conflicting-outputs

4. モデルクラスとの統合

  • Freezed によるイミュータブルなモデル
  • json_serializable による自動シリアライゼーション

MVVM + Repository アーキテクチャの実装
概要と特徴

Claude Codeが生成したコードは、MVVM (Model-View-ViewModel) + Repository パターンを採用しています。
これは、Flutter開発における関心の分離を実現する代表的なアーキテクチャパターンのひとつです。

レイヤー構造:

[UI層]
 View (HookConsumerWidget)
 ↓
 ViewModel (StateNotifier)
 ↓
[データ層]
 Repository
 ↓
 ApiClient (Retrofit)
 ↓
 Dio

各層の責務:

  1. View層: UIの描画とユーザー操作の受付
  2. ViewModel層: UI状態の管理とビジネスロジック
  3. Repository層: データソースの抽象化
  4. API層: 外部APIとの通信

この明確な責務分離により、テストがしやすく、変更に強い設計が実現されています。

具体的なメリット

1. 状態管理の明確化

ViewModelで状態を一元管理。

// pokemon_list_state.dart
@freezed
class PokemonListState with _$PokemonListState {
  const factory PokemonListState({
    @Default([]) List<Pokemon> pokemons,
    @Default(false) bool isLoading,
    @Default(false) bool hasMore,
    String? error,
  }) = _PokemonListState;
}

// pokemon_list_viewmodel.dart
@riverpod
class PokemonListViewModel extends _$PokemonListViewModel {
  @override
  PokemonListState build() {
    loadMore();
    return const PokemonListState();
  }

  Future<void> loadMore() async {
    if (state.isLoading || !state.hasMore) return;

    state = state.copyWith(isLoading: true, error: null);

    try {
      final repository = ref.read(pokemonRepositoryProvider);
      final newPokemons = await repository.fetchPokemonList(
        state.pokemons.length,
      );

      state = state.copyWith(
        pokemons: [...state.pokemons, ...newPokemons],
        isLoading: false,
        hasMore: newPokemons.isNotEmpty,
      );
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        error: e.toString(),
      );
    }
  }
}

2. テスタビリティの向上

各層を独立してテスト可能。

// repository のテスト例
void main() {
  group('PokemonRepository', () {
    late PokemonRepository repository;
    late MockPokemonApi mockApi;

    setUp(() {
      mockApi = MockPokemonApi();
      repository = PokemonRepository(mockApi);
    });

    test('fetchPokemonList returns list of pokemon', () async {
      // モックの設定
      when(() => mockApi.getPokemonList(any(), any()))
          .thenAnswer((_) async => mockResponse);

      // テスト実行
      final result = await repository.fetchPokemonList(0);

      // 検証
      expect(result, isA<List<Pokemon>>());
      expect(result.length, 20);
    });
  });
}

3. ビジネスロジックのUI層からの分離

Viewはシンプルに描画に集中。

// pokemon_list_page.dart
class PokemonListPage extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(pokemonListViewModelProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pokemon List')),
      body: state.isLoading && state.pokemons.isEmpty
          ? const Center(child: CircularProgressIndicator())
          : ListView.builder(
              itemCount: state.pokemons.length + 1,
              itemBuilder: (context, index) {
                if (index == state.pokemons.length) {
                  return _buildLoadMoreButton(ref);
                }
                return _buildPokemonCard(
                  context,
                  state.pokemons[index],
                );
              },
            ),
    );
  }
}

4. データソースの切り替えが容易

Repository層でデータソースを抽象化。

// 将来的にローカルDBを追加する場合
class PokemonRepository {
  final PokemonApi _api;
  final LocalDatabase _db; // ローカルDB追加

  Future<List<Pokemon>> fetchPokemonList(int offset) async {
    // キャッシュをチェック
    final cached = await _db.getCachedPokemons(offset);
    if (cached.isNotEmpty) {
      return cached;
    }

    // APIから取得
    final pokemons = await _fetchFromApi(offset);
    
    // キャッシュに保存
    await _db.savePokemons(pokemons);
    
    return pokemons;
  }
}
実装のポイント

Claude Codeが自動的に適用してくれた設計のポイント。

1. Freezed による不変性の確保

@freezed
class PokemonListState with _$PokemonListState {
  const factory PokemonListState({
    @Default([]) List<Pokemon> pokemons,
    // ...
  }) = _PokemonListState;
}

2. Riverpod によるDI と状態管理の統合

@riverpod
class PokemonListViewModel extends _$PokemonListViewModel {
  // ViewModelの実装
}

3. Repository パターンによるデータソースの抽象化

class PokemonRepository {
  final PokemonApi _api;
  
  const PokemonRepository(this._api);
  
  // データ取得の詳細を隠蔽
  Future<List<Pokemon>> fetchPokemonList(int offset) async {
    // ...
  }
}

4. エラーハンドリングの各層での実装

  • Repository: API通信エラー
  • ViewModel: ビジネスロジックエラー
  • View: UI表示エラー

その他の実装された機能とベストプラクティス
Pokemon Appの画面遷移
hooks_riverpod による状態管理

Claude Codeは、hooks_riverpod を使った最新の状態管理パターンを自動で実装してくれました。

主な活用箇所:

  1. グローバル状態の管理
@riverpod
class PokemonListViewModel extends _$PokemonListViewModel {
  // アプリ全体で共有される状態
}
  1. 依存性の注入
@riverpod
PokemonRepository pokemonRepository(PokemonRepositoryRef ref) {
  final api = ref.watch(pokemonApiProvider);
  return PokemonRepository(api);
}
  1. Hooks との組み合わせ
class PokemonDetailPage extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final scrollController = useScrollController();
    // Hooks で Widget のライフサイクルを管理
  }
}

freezed によるイミュータブルなモデル設計

データクラスの定義を簡潔かつ安全に:

@freezed
class Pokemon with _$Pokemon {
  const factory Pokemon({
    required String name,
    required String imageUrl,
    required List<String> types,
  }) = _Pokemon;

  factory Pokemon.fromJson(Map<String, dynamic> json) =>
      _$PokemonFromJson(json);
}

Freezed のメリット:

  • copyWith メソッドの自動生成
  • == 演算子と hashCode の自動実装
  • パターンマッチング対応
  • JSON シリアライゼーションの統合

go_router による宣言的ルーティング

画面遷移を型安全に管理:

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const PokemonListPage(),
    ),
    GoRoute(
      path: '/pokemon/:name',
      builder: (context, state) {
        final name = state.pathParameters['name']!;
        return PokemonDetailPage(name: name);
      },
    ),
  ],
);

無限スクロールの実装

ユーザー体験を向上させる機能:

Widget _buildLoadMoreButton(WidgetRef ref) {
  return Center(
    child: state.isLoading
        ? const Padding(
            padding: EdgeInsets.all(16),
            child: CircularProgressIndicator(),
          )
        : state.hasMore
            ? ElevatedButton(
                onPressed: () {
                  ref
                      .read(pokemonListViewModelProvider.notifier)
                      .loadMore();
                },
                child: const Text('Load More'),
              )
            : const Padding(
                padding: EdgeInsets.all(16),
                child: Text('No more pokemon'),
              ),
  );
}

プルダウンリフレッシュ

データの更新を直感的に:

RefreshIndicator(
  onRefresh: () async {
    await ref
        .read(pokemonListViewModelProvider.notifier)
        .refresh();
  },
  child: ListView.builder(
    // ...
  ),
)

ロギングとデバッグ

開発体験を向上させるロギング:

import 'package:logger/logger.dart';

final logger = Logger(
  printer: PrettyPrinter(
    methodCount: 0,
    errorMethodCount: 5,
    lineLength: 50,
    colors: true,
    printEmojis: true,
  ),
);

// 使用例
logger.d('Fetching pokemon list...');
logger.e('API Error: ${e.message}');

その他の工夫

1. エラーメッセージの表示

if (state.error != null) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(Icons.error, size: 64, color: Colors.red),
        const SizedBox(height: 16),
        Text(state.error!),
        ElevatedButton(
          onPressed: () => ref.read(
            pokemonListViewModelProvider.notifier,
          ).loadMore(),
          child: const Text('Retry'),
        ),
      ],
    ),
  );
}

2. レスポンシブデザイン

GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: MediaQuery.of(context).size.width > 600 ? 3 : 2,
    childAspectRatio: 0.8,
  ),
  // ...
)

3. パフォーマンス最適化

// 画像のキャッシング
Image.network(
  pokemon.imageUrl,
  cacheWidth: 200,
  cacheHeight: 200,
)

まとめ

要約

今回の開発体験を通じて、まずやりたいこと(目的)を指定するだけで、いくつかの選択肢を持った提案をしてくれることに感動しました。そして言語とアーキテクチャを決めるだけで、実際にアプリ作成まで完結できることがわかります。そしてその品質は十分で、かゆいところまで手が届くものでした。

あらためて要約すると、特に以下の点で優れていることが分かりました。

  1. 提案力と理解力: 「Dio/Retrofitを活かせるAPI」という曖昧な要求から、適切なAPIを複数提案し、それぞれの特徴を説明できる
  2. 包括的な実装: API通信、状態管理、UI構築、エラーハンドリング、ログ出力まで、プロダクションレベルのコードを一度に生成
  3. ベストプラクティスの適用: MVVM + Repository パターン、hooks_riverpod、freezed など、モダンなFlutter開発のベストプラクティスを自動で適用
  4. ドキュメントの自動生成: README.md やコメントを適切に記述し、後から見ても分かりやすいコードを生成
  5. 一貫性のある設計: プロジェクト全体で統一されたネーミング規則、ディレクトリ構造、コーディングスタイル
開発体験の感想

良かった点:

  • 圧倒的な開発速度: 通常なら数時間かかる実装が数分で完成
  • 学習効果: 生成されたコードから、ベストプラクティスを学べる
  • 完成度の高さ: すぐに動作する、プロダクションレベルのコード
  • 柔軟な対応: 要求の変更や追加機能にも素早く対応

改善の余地がある点:

  • テストコードの生成: 基本的なテストは生成されるが、エッジケースのカバーは手動で追加が必要
  • UI/UXの洗練: 機能的には問題ないが、デザインの細部は手作業での調整が必要
  • パフォーマンスチューニング: 基本的な最適化はされているが、大規模データでの動作検証は別途必要

メインの画面以外にもインフィニティースクロール、プルリフレッシュなどの機能も含められていることや、レイアウトもある程度リッチなものにしてくれることに感動しました。

一方デザインが決められた状況ではきちんと伝わるのか、パフォーマンスは最適なのかという点は、まだこれから見てみないとと感じる部分はあります。

ただそうだとしても最初の取っ掛かりとしてまずはAIに自動生成させて修正していくプロセスでも十分に活用できそうです。

今回のレベルであれば十分すぎるほどのアプリを完成させてくれたので、まずは規模を小さくした状態で骨組みを作ってもらうという思考がよいのではと感じました。


おまけ

Flutter開発者にとってのメリットとデメリット

AIを活用することで、素敵なアプリを提案し実際に品質の高いものを作ってくれることに感動しました。
一方で開発者が何をすればよいのかということも問われることにもなると感じます。
どう棲み分けできるのか、開発者にとってのメリット・デメリットを整理して考えたいと思います。

メリット:

開発速度の劇的な向上

  • ボイラープレートコードの自動生成
  • アーキテクチャ設計の自動化
  • 複数ファイルの一括生成

学習効果

  • ベストプラクティスを実践的に学べる
  • 最新のパッケージの使い方を習得
  • アーキテクチャパターンの理解が深まる

品質の底上げ

  • エラーハンドリングの漏れが減る
  • 統一されたコーディングスタイル
  • ドキュメントが自動で生成される

プロトタイピングの高速化

  • アイデアを素早く形にできる
  • 複数のアプローチを短時間で試せる
  • MVPの開発期間を大幅に短縮

デメリット:

コード理解の必要性

  • 生成されたコードを読み解く力が必要
  • ブラックボックス化のリスク
  • デバッグスキルの重要性が増す

カスタマイズの手間

  • 細かいUI調整は手作業が必要
  • ビジネスロジックの複雑な部分は人間が実装
  • プロジェクト固有の要件への対応

依存関係の管理

  • AI が提案するパッケージのバージョン管理
  • 互換性の確認が必要
  • セキュリティアップデートへの対応

スキル格差の拡大

  • AIを使いこなせる開発者とそうでない開発者の差
  • 基礎力の重要性が増す
  • 学習方法の変化への適応

Claude CodeのようなAIツールは、開発者の仕事を奪うものではなく、より創造的な作業に集中できるようにするツールだと感じます。特に以下に注力できるようになるといえるのではないでしょうか。

  • アーキテクチャ設計の判断
  • ビジネスロジックの実装
  • ユーザー体験の最適化
  • パフォーマンスチューニング
  • セキュリティ対策

これからは、AIが生成したコードを読み解き、理解し、必要に応じてカスタマイズできる能力が開発者には求められるのだと感じました。


楽天ブックス
¥3,080 (2026/02/26 14:23時点 | 楽天市場調べ)
楽天ブックス
¥1,760 (2026/02/27 11:23時点 | 楽天市場調べ)
スポンサーリンク

Xでフォローしよう

おすすめの記事