スポンサーリンク

その他の重要なベストプラクティス

APIクライアントとRepositoryの実装パターン
Retrofit + Dioの組み合わせ

API通信には、型安全性が高く保守しやすい【Retrofit + Dio】の組み合わせが推奨されます。

// api/user_api_client.dart
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'user_api_client.g.dart';

@RestApi(baseUrl: 'https://api.example.com/v1/')
abstract class UserApiClient {
  factory UserApiClient(Dio dio, {String baseUrl}) = _UserApiClient;

  @GET('/users')
  Future<UserListResponse> getUserList({
    @Query('page') int page = 1,
    @Query('limit') int limit = 20,
  });

  @GET('/users/{id}')
  Future<UserDetail> getUserDetail(@Path('id') int id);

  @POST('/users')
  Future<User> createUser(@Body() CreateUserRequest request);
}
Repositoryでのエラーハンドリング
class UserRepository {
  UserRepository(this._apiClient, this._logger);

  final UserApiClient _apiClient;
  final Logger _logger;

  Future<UserListResponse> fetchUserList({
    int page = 1,
    int limit = 20,
  }) async {
    try {
      _logger.d('Fetching user list: page=$page, limit=$limit');
      final response = await _apiClient.getUserList(
        page: page,
        limit: limit,
      );
      _logger.d('Successfully fetched ${response.results.length} users');
      return response;
    } on DioException catch (e, stackTrace) {
      _logger.e(
        'Failed to fetch user list',
        error: e,
        stackTrace: stackTrace,
      );
      // エラーを適切な例外に変換
      throw _handleDioError(e);
    } catch (e, stackTrace) {
      _logger.e(
        'Unexpected error while fetching user list',
        error: e,
        stackTrace: stackTrace,
      );
      rethrow;
    }
  }

  Exception _handleDioError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return NetworkException('通信がタイムアウトしました');
      case DioExceptionType.badResponse:
        final statusCode = e.response?.statusCode;
        if (statusCode == 404) {
          return NotFoundException('リソースが見つかりません');
        } else if (statusCode == 401) {
          return UnauthorizedException('認証が必要です');
        }
        return ServerException('サーバーエラーが発生しました');
      case DioExceptionType.cancel:
        return CancelledException('リクエストがキャンセルされました');
      default:
        return NetworkException('ネットワークエラーが発生しました');
    }
  }
}
ロギング戦略

開発効率を上げるため、適切なロギングは不可欠です:

// util/logger.dart
import 'package:logger/logger.dart';

final logger = Logger(
  printer: PrettyPrinter(
    methodCount: 0,
    errorMethodCount: 5,
    lineLength: 80,
    colors: true,
    printEmojis: true,
    printTime: true,
  ),
  level: Level.debug, // Releaseビルドでは Level.info に変更
);

使用例:

logger.d('Debug message');        // デバッグ情報
logger.i('Info message');         // 一般情報
logger.w('Warning message');      // 警告
logger.e('Error message',         // エラー
  error: exception,
  stackTrace: stackTrace,
);
ルーティング設計

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

// util/router.dart
import 'package:go_router/go_router.dart';

final router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const UserListPage(),
    ),
    GoRoute(
      path: '/user/:id',
      builder: (context, state) {
        final id = int.parse(state.pathParameters['id']!);
        return UserDetailPage(userId: id);
      },
    ),
    GoRoute(
      path: '/settings',
      builder: (context, state) => const SettingsPage(),
      routes: [
        GoRoute(
          path: 'profile',
          builder: (context, state) => const ProfileEditPage(),
        ),
      ],
    ),
  ],
  errorBuilder: (context, state) => const NotFoundPage(),
);

画面遷移の実装:

// 通常の遷移
context.push('/user/123');

// 置き換え遷移(戻るボタンで前画面に戻らない)
context.replace('/user/123');

// 戻る
context.pop();

// 名前付きパラメータの取得
final userId = context.pathParameters['id'];
テスト戦略とモック化

ユニットテストの基本構造:

// test/viewmodel/user_list_viewmodel_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

@GenerateMocks([UserRepository])
void main() {
  late MockUserRepository mockRepository;
  late UserListViewModel viewModel;

  setUp(() {
    mockRepository = MockUserRepository();
    viewModel = UserListViewModel(mockRepository);
  });

  group('UserListViewModel', () {
    test('初期状態が正しいこと', () {
      expect(viewModel.state.users, isEmpty);
      expect(viewModel.state.isLoading, false);
      expect(viewModel.state.error, isNull);
    });

    test('ユーザー一覧の取得に成功', () async {
      // Arrange
      final mockResponse = UserListResponse(
        results: [
          const User(id: 1, name: 'Test User', email: 'test@example.com'),
        ],
      );
      when(mockRepository.fetchUserList())
          .thenAnswer((_) async => mockResponse);

      // Act
      await viewModel.fetchUserList();

      // Assert
      expect(viewModel.state.users.length, 1);
      expect(viewModel.state.users.first.name, 'Test User');
      expect(viewModel.state.isLoading, false);
      expect(viewModel.state.error, isNull);
      verify(mockRepository.fetchUserList()).called(1);
    });

    test('ユーザー一覧の取得に失敗', () async {
      // Arrange
      when(mockRepository.fetchUserList())
          .thenThrow(NetworkException('通信エラー'));

      // Act
      await viewModel.fetchUserList();

      // Assert
      expect(viewModel.state.users, isEmpty);
      expect(viewModel.state.isLoading, false);
      expect(viewModel.state.error, isNotNull);
      expect(viewModel.state.error, contains('通信エラー'));
    });
  });
}

モック生成コマンド:

flutter pub run build_runner build --delete-conflicting-outputs
状態管理の粒度設計

状態は【適切な粒度で分割】することが重要です。

悪い例(粒度が粗すぎる):

// アプリ全体の状態を一つにまとめる
class AppState {
  final List<User> users;
  final User? currentUser;
  final List<Product> products;
  final CartState cart;
  // ... 他にも大量の状態
}

この場合、どれか一つの状態が変わるだけで、アプリ全体が再構築されてしまいます。

良い例(適切な粒度):

// 画面やドメインごとに状態を分割
final userListStateProvider = StateNotifierProvider<...>(...);
final currentUserStateProvider = StateNotifierProvider<...>(...);
final productListStateProvider = StateNotifierProvider<...>(...);
final cartStateProvider = StateNotifierProvider<...>(...);

各Providerは関連する状態のみを管理し、不要な再構築を避けます。

パフォーマンス最適化のポイント
1. constコンストラクタの活用

Widgetツリーの再構築を最小限にするため、可能な限りconstを使用します:

const Text('Hello')  // ✅ 推奨
Text('Hello')        // ❌ 非推奨
2. buildメソッドの分割

大きなbuildメソッドは、小さなWidgetに分割します:

// ❌ 悪い例
Widget build(BuildContext context) {
  return Column(
    children: [
      // 100行以上のWidget定義...
    ],
  );
}

// ✅ 良い例
Widget build(BuildContext context) {
  return Column(
    children: [
      const _HeaderWidget(),
      const _BodyWidget(),
      const _FooterWidget(),
    ],
  );
}
3. ListView.builderの活用

大量のリスト表示では、必ずListView.builderを使用します:

// ✅ 推奨: 遅延レンダリング
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemWidget(items[index]),
)

// ❌ 非推奨: 全アイテムを一度に生成
ListView(
  children: items.map((item) => ItemWidget(item)).toList(),
)

まとめ

この記事では、Flutter開発における小規模から中規模プロジェクトのベストプラクティスについて解説しました。
重要なポイントをおさらいすると以下のとおりです。

重要なポイント:

  1. MVVM + Repositoryパターンによる明確なレイヤー分離
    • View、ViewModel、Repository、Modelの4層構造
    • 関心の分離による保守性とテスタビリティの向上
  2. Riverpod + Hooks + Freezedの技術スタック
    • 型安全性と開発効率を両立
    • イミュータブルなデータクラスによる予測可能な状態管理
  3. プロジェクト立ち上げ時の設計指針
    • fvmによるバージョン管理
    • 適切なディレクトリ構造とコード生成
    • チームで統一した命名規則とLintルール
モバイルアプリエンジニアにとってのメリット

開発効率の向上:

  • コード生成により【ボイラープレートが削減】
  • 明確な構造により【新メンバーのオンボーディングが容易】
  • ホットリロードと組み合わせた【高速な開発サイクル】

品質の向上:

  • 型安全性により【コンパイル時のエラー検出】
  • レイヤー分離により【単体テストが容易】
  • 状態管理の一元化により【バグの発見が簡単】

保守性の向上:

  • アーキテクチャパターンにより【変更の影響範囲が限定】
  • 依存性注入により【モジュールの交換が容易】
  • ドキュメント化により【長期運用が可能】
留意点

一方で、以下の点に注意が必要です。

学習コスト:

  • Riverpod、Hooks、Freezedの概念理解に【初期学習時間が必要】
  • build_runnerのワークフローに【慣れるまで時間がかかる】

オーバーエンジニアリングのリスク:

  • 【極小規模プロジェクト】では過剰な設計となる可能性
  • プロトタイピング段階では【シンプルな構成が適切】な場合もある

ツール依存:

  • コード生成ツールの【ビルド時間】が開発速度に影響
  • パッケージのアップデートに【継続的な対応】が必要

これらのデメリットは、プロジェクトの規模や要件に応じて適切に判断することで、十分に管理可能です。

Flutter公式の推奨とベストプラクティス

Flutter公式ドキュメントでも、以下のベストプラクティスが強調されています。

  • 関心の分離 - UIロジックとビジネスロジックの明確な分離
  • 状態の明示的な管理 - 状態の流れを追跡可能にする
  • テスト容易性の確保 - 各レイヤーを独立してテスト可能にする
  • 単一責任の原則 - 各クラスやメソッドは一つの責務のみを持つ

本記事で紹介したアーキテクチャとベストプラクティスは、これらの公式推奨に沿ったものです。


関連

関連記事

これらの実装例を参考に、自身のプロジェクトに合わせてカスタマイズすることをおすすめします。


楽天ブックス
¥3,080 (2026/02/12 13:46時点 | 楽天市場調べ)
スポンサーリンク

Twitterでフォローしよう

おすすめの記事