mcpField
F
flutter-best-practices

flutter-best-practices

github.com/mauriciog87開発361 回インストール38 週間前に更新
リポジトリ

flutter-best-practices について

モバイル、Web、デスクトップ向け Flutter アプリの開発とコードレビューに役立つ実践ガイドです。アーキテクチャ、状態管理、パフォーマンス、テスト、リリースを扱います。

主な機能

  1. 01機能別のレイヤー構成
  2. 02RiverpodとBLoCの状態管理指針
  3. 03go_routerによるナビゲーションとディープリンク
  4. 04パフォーマンスとライフサイクルの実践
  5. 05ユニット・ウィジェット・統合テスト
  6. 06クロスプラットフォームとリリースの指針

活用シーン

  1. 01新しいFlutterプロジェクトの構成
  2. 02状態管理とナビゲーション方式の選定
  3. 03パフォーマンスとリソース解放の確認
  4. 04Flutterアプリのテスト計画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mauriciog87/flutter-agent-skill --skill flutter-best-practices

インストール後、Claude Code で/flutter-best-practicesを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
performance.md
platform-channels.md
state-management.md
testing.md

詳細

Flutter のベストプラクティス

モバイル、ウェブ、デスクトップの各プラットフォームで、本番環境に対応した Flutter アプリケーションを構築するための専門的なガイドラインです。

クイックスタート

新しい Flutter プロジェクトを開始するとき、またはコードをレビューするときは、次の点に従ってください。

  1. アーキテクチャ:関心事を明確に分離した、機能優先のフォルダー構成を使用します
  2. 状態管理:新しいプロジェクトには Riverpod を推奨します。複雑な非同期フローには BLoC を使用します
  3. ナビゲーション:ディープリンクとウェブ対応には go_router を使用します
  4. テスト:テストピラミッド(ユニット → ウィジェット → 統合)に従います
  5. パフォーマンス:const コンストラクター、ListView.builder、適切な破棄パターンを使用します

アーキテクチャパターン

3 層アーキテクチャ

保守性を高めるため、コードを明確に分かれたレイヤーに整理します。

lib/
├── features/
│   └── feature_name/
│       ├── presentation/     # UI widgets, screens
│       ├── domain/           # Business logic, use cases, entities
│       └── data/             # Repositories, data sources, models
├── core/
│   ├── theme/               # AppTheme, colors, typography
│   ├── router/              # GoRouter configuration
│   └── utils/               # Shared utilities
└── main.dart

原則:

  • 依存関係のルール:上位レイヤーは下位レイヤーのみに依存します
  • 機能 API インターフェース:インターフェースの背後に実装の詳細を隠します
  • リポジトリパターン:テストしやすいよう、データアクセスを抽象化します
ウィジェットアーキテクチャ

ウィジェットの種類を選ぶための判断基準:

シナリオ使用するもの
静的な UI、状態が変化しないStatelessWidget
ローカル状態を持つインタラクティブな UIStatefulWidget
ツリー内の下位ウィジェット間で共有するデータInheritedWidget / Riverpod
複雑で再利用可能な UI コンポーネントカスタムウィジェットクラス

StatefulWidget のライフサイクル(重要):

class _MyWidgetState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    // One-time initialization (controllers, subscriptions)
  }
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Called when InheritedWidget dependencies change
  }
  
  @override
  Widget build(BuildContext context) {
    // Keep fast and synchronous - no heavy computation
    return Container();
  }
  
  @override
  void dispose() {
    // CRITICAL: Dispose controllers, cancel subscriptions
    _controller.dispose();
    _subscription?.cancel();
    super.dispose();
  }
}

鉄則:コントローラー、スクロールコントローラー、テキストコントローラーは必ず破棄し、ストリームのサブスクリプションはキャンセルしてください。

状態管理

Riverpod(新しいプロジェクトに推奨)

セットアップ:

// main.dart
void main() {
  runApp(ProviderScope(child: MyApp()));
}

// providers.dart
final counterProvider = StateProvider<int>((ref) => 0);

final userProvider = FutureProvider<User>((ref) async {
  return await ref.watch(authRepositoryProvider).getCurrentUser();
});

@riverpod
class TodoList extends _$TodoList {
  @override
  List<Todo> build() => [];
  
  void add(Todo todo) => state = [...state, todo];
  void toggle(String id) {
    state = state.map((t) => 
      t.id == id ? t.copyWith(completed: !t.completed) : t
    ).toList();
  }
}

使用パターン:

class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Watch for UI updates
    final count = ref.watch(counterProvider);
    final asyncUser = ref.watch(userProvider);
    final todos = ref.watch(todoListProvider);
    
    // Read for one-time access (callbacks)
    void onPressed() {
      ref.read(todoListProvider.notifier).add(newTodo);
    }
    
    return asyncUser.when(
      data: (user) => Text(user.name),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => ErrorWidget(err),
    );
  }
}

主なメソッド:

  • ref.watch(provider):プロバイダーが変更されたときにウィジェットを再構築します
  • ref.read(provider):値を一度取得します(コールバック内で使用し、build 内では使用しません)
  • ref.listen(provider):変更を監視し、副作用を実行します
BLoC パターン(複雑な非同期フロー向け)

BLoC を使用する場合:

  • 複雑なイベント駆動型アーキテクチャ
  • ビジネスロジックを厳密に分離する必要があるアプリ
  • リアクティブプログラミングに慣れたチーム
// Events
abstract class CounterEvent {}
class CounterIncrementPressed extends CounterEvent {}

// States
class CounterState {
  final int count;
  CounterState(this.count);
}

// BLoC
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0)) {
    on<CounterIncrementPressed>((event, emit) {
      emit(CounterState(state.count + 1));
    });
  }
}

// Usage
BlocBuilder<CounterBloc, CounterState>(
  builder: (context, state) {
    return Text('Count: ${state.count}');
  },
)
状態の選択ガイド
ユースケース解決策
単純なローカルウィジェット状態StatefulWidget + setState()
ウィジェット間で共有する単一の値StateProvider
非同期データ(API 呼び出し)FutureProvider
ストリームベースのデータStreamProvider
ビジネスロジックを伴う複雑な状態StateNotifier / BLoC
アプリ全体の状態スコープ付きプロバイダーを使った Riverpod

ナビゲーション

go_router(推奨)

セットアップ:

final _router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    // Auth guard
    final isLoggedIn = authState.isAuthenticated;
    final isLoggingIn = state.matchedLocation == '/login';
    
    if (!isLoggedIn && !isLoggingIn) return '/login';
    if (isLoggedIn && isLoggingIn) return '/';
    return null;
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomeScreen(),
      routes: [
        GoRoute(
          path: 'details/:id',
          builder: (context, state) => DetailScreen(
            id: state.pathParameters['id']!,
          ),
        ),
      ],
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => LoginScreen(),
    ),
  ],
);

// In MaterialApp
MaterialApp.router(routerConfig: _router);

ナビゲーション:

// Navigate
context.go('/details/123');
context.push('/details/123');  // Preserves navigation stack
context.pop();

// With query parameters
context.goNamed('details', queryParameters: {'tab': 'reviews'});

ディープリンクのセットアップ:

Android(AndroidManifest.xml):

<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW"/>
  <category android:name="android.intent.category.DEFAULT"/>
  <category android:name="android.intent.category.BROWSABLE"/>
  <data android:scheme="https" android:host="yourdomain.com"/>
</intent-filter>

iOS(info.plist):

<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>yourscheme</string>
    </array>
  </dict>
</array>

ウィジェットパターン

レイアウトの基本
// Common layout patterns
Column(           // Vertical layout
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [...],
)

Row(              // Horizontal layout
  children: [
    Expanded(flex: 2, child: ...),  // Takes 2/3 space
    Expanded(flex: 1, child: ...),  // Takes 1/3 space
  ],
)

Stack(            // Overlapping widgets
  children: [
    Positioned.fill(child: Background()),
    Align(alignment: Alignment.bottomCenter, child: ...),
  ],
)

// Responsive layouts
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 600) {
      return DesktopLayout();
    }
    return MobileLayout();
  },
)
リストの最適化

大規模なリストや無限リストには必ず builder を使用してください:

// Good - lazy loading, only builds visible items
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ListTile(
    title: Text(items[index].title),
  ),
)

// For grids with images
GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: crossAxisCount,
  ),
  itemCount: items.length,
  itemBuilder: (context, index) => ImageCard(items[index]),
)

// With custom scroll effects
CustomScrollView(
  slivers: [
    SliverAppBar(
      expandedHeight: 200,
      flexibleSpace: FlexibleSpaceBar(title: Text('Title')),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(...),
        childCount: items.length,
      ),
    ),
  ],
)
フォームの処理
class _MyFormState extends State<MyForm> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  
  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _nameController,
            decoration: InputDecoration(labelText: 'Name'),
            validator: (value) {
              if (value?.isEmpty ?? true) return 'Required';
              return null;
            },
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _formKey.currentState!.save();
                // Process form
              }
            },
            child: Text('Submit'),
          ),
        ],
      ),
    );
  }
}

パフォーマンスの最適化

パフォーマンスに関する重要なルール
ルール実装優先度
const コンストラクターを使用するconst Text('Hello')重要
リストには builder を使用するListView.builder重要
コントローラーを破棄するcontroller.dispose()重要
コストの高い処理をキャッシュする重い処理には compute() を使用する高
RepaintBoundary を使用するアニメーションするウィジェットをラップする中
クリッピングを避けるClip ウィジェットを最小限にする中
メモリ管理
// Always check mounted before setState in async
void _loadData() async {
  final data = await fetchData();
  if (mounted) {  // CRITICAL
    setState(() => _data = data);
  }
}

// Limit image cache for memory-constrained apps
PaintingBinding.instance.imageCache.maximumSize = 100;
PaintingBinding.instance.imageCache.maximumSizeBytes = 50 * 1024 * 1024;

// Use weak references for long-lived callbacks
final weakRef = WeakReference(object);
Isolate の使用

UI スレッドから重い計算処理を切り離します。

// Heavy computation
final result = await compute(parseJson, largeJsonString);

// For complex parsing
Future<List<Model>> parseModels(String jsonString) async {
  return compute((str) {
    final decoded = json.decode(str) as List;
    return decoded.map((e) => Model.fromJson(e)).toList();
  }, jsonString);
}
DevTools によるプロファイリング

デバッグ用に次のフラグを有効にします。

// In main.dart for debugging
void main() {
  // Visualize widget rebuilds
  debugRepaintRainbowEnabled = true;
  
  // Show paint bounds
  debugPaintSizeEnabled = true;
  
  runApp(MyApp());
}

DevTools の主なビュー:

  • ウィジェットの再構築回数:過剰な再構築を特定します
  • パフォーマンス:フレームのタイミング、シェーダーのコンパイル
  • メモリ:ヒープスナップショット、割り当ての追跡
  • ネットワーク:API 呼び出しの監視

テスト

テストピラミッド
       ▲ Integration (Full flows)
      ╱ ╲
     ╱   ╲ Widget (UI components)
    ╱     ╲
   ╱       ╲
  ╱_________╲
 Unit (Logic, pure Dart)
ユニットテスト
test('can calculate total price', () {
  // Arrange
  final cart = Cart(items: [
    Item(price: 10, quantity: 2),
    Item(price: 5, quantity: 1),
  ]);
  
  // Act
  final total = cart.total;
  
  // Assert
  expect(total, equals(25));
});

// With Riverpod
test('counter increments', () {
  final container = ProviderContainer();
  addTearDown(container.dispose);
  
  final notifier = container.read(counterProvider.notifier);
  notifier.state = 5;
  notifier.state++;
  
  expect(container.read(counterProvider), equals(6));
});
ウィジェットテスト
testWidgets('displays user name', (WidgetTester tester) async {
  // Arrange
  await tester.pumpWidget(
    MaterialApp(
      home: UserProfile(user: User(name: 'John')),
    ),
  );
  
  // Act & Assert
  expect(find.text('John'), findsOneWidget);
  expect(find.byType(CircularProgressIndicator), findsNothing);
});

testWidgets('tapping button increments counter', (tester) async {
  await tester.pumpWidget(MyApp());
  
  await tester.tap(find.byIcon(Icons.add));
  await tester.pumpAndSettle();
  
  expect(find.text('1'), findsOneWidget);
});
ゴールデンテスト(ビジュアルリグレッション)
testGoldens('UserProfile renders correctly', (tester) async {
  final builder = GoldenBuilder.grid(columns: 2)
    ..addScenario('Light', UserProfile(user: mockUser))
    ..addScenario('Dark', Theme(data: darkTheme, child: UserProfile(user: mockUser)));
  
  await tester.pumpWidgetBuilder(builder.build());
  await screenMatchesGolden(tester, 'user_profile');
});

// Update goldens: flutter test --update-goldens --tags=golden
統合テスト
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  
  testWidgets('full login flow', (tester) async {
    app.main();
    await tester.pumpAndSettle();
    
    await tester.enterText(find.byType(TextField).first, '[email protected]');
    await tester.enterText(find.byType(TextField).last, 'password');
    await tester.tap(find.byType(ElevatedButton));
    await tester.pumpAndSettle();
    
    expect(find.text('Welcome'), findsOneWidget);
  });
}

ネットワーキング

HTTP サービスパターン
class ApiService {
  final Dio _dio;
  
  ApiService({Dio? dio}) : _dio = dio ?? Dio(BaseOptions(
    baseUrl: 'https://api.example.com',
    connectTimeout: Duration(seconds: 5),
    receiveTimeout: Duration(seconds: 3),
  )) {
    _dio.interceptors.add(AuthInterceptor());
    _dio.interceptors.add(LogInterceptor());
  }
  
  Future<List<User>> getUsers() async {
    final response = await _dio.get('/users');
    return (response.data as List)
      .map((json) => User.fromJson(json))
      .toList();
  }
}

// With Riverpod
final apiServiceProvider = Provider<ApiService>((ref) {
  return ApiService();
});

final usersProvider = FutureProvider<List<User>>((ref) async {
  final api = ref.watch(apiServiceProvider);
  return api.getUsers();
});
JSON シリアライゼーション
// With freezed (recommended)
@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    @Default([]) List<String> tags,
    DateTime? createdAt,
  }) = _User;
  
  factory User.fromJson(Map<String, dynamic> json) => 
      _$UserFromJson(json);
}

// Manual approach
class User {
  final String id;
  final String name;
  
  User({required this.id, required this.name});
  
  factory User.fromJson(Map<String, dynamic> json) => User(
    id: json['id'] as String,
    name: json['name'] as String,
  );
  
  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
  };
}

Firebase との統合

Firebase のセットアップ
# Install FlutterFire CLI
dart pub global activate flutterfire_cli

# Configure project
flutterfire configure --project=your-project-id
クラウドメッセージング(プッシュ通知)
class NotificationService {
  final FirebaseMessaging _messaging;
  
  NotificationService(this._messaging);
  
  Future<void> initialize() async {
    // Request permissions
    final settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );
    
    // Get FCM token
    final token = await _messaging.getToken();
    await _saveToken(token);
    
    // Listen to token refresh
    _messaging.onTokenRefresh.listen(_saveToken);
    
    // Handle foreground messages
    FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
    
    // Handle background/terminated
    FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
    final initialMessage = await _messaging.getInitialMessage();
    if (initialMessage != null) {
      _handleNotificationTap(initialMessage);
    }
  }
  
  @pragma('vm:entry-point')
  static Future<void> _firebaseMessagingBackgroundHandler(
    RemoteMessage message,
  ) async {
    await Firebase.initializeApp();
    // Handle background message
  }
}

// Register in main
FirebaseMessaging.onBackgroundMessage(
  NotificationService._firebaseMessagingBackgroundHandler,
);

クロスプラットフォームに関する考慮事項

プラットフォームの検出
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;

bool get isWeb => kIsWeb;
bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
bool get isDesktop => !kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);

// Conditional UI
if (Platform.isIOS) {
  return CupertinoButton(child: Text('Done'), onPressed: () {});
}
return ElevatedButton(child: Text('Done'), onPressed: () {});
アダプティブレイアウト
// Using flutter_adaptive_scaffold
AdaptiveLayout(
  primaryNavigation: SlotLayout(config: {
    Breakpoints.mediumAndUp: SlotLayout.from(
      builder: (_) => NavigationRail(destinations: [...]),
    ),
  }),
  bottomNavigation: SlotLayout(config: {
    Breakpoints.small: SlotLayout.from(
      builder: (_) => BottomNavigationBar(items: [...]),
    ),
  }),
  body: SlotLayout(config: {
    Breakpoints.small: SlotLayout.from(builder: (_) => MobileBody()),
    Breakpoints.mediumAndUp: SlotLayout.from(builder: (_) => DesktopBody()),
  }),
)
プラットフォームチャネル

Flutter 側:

class PlatformChannelService {
  static const MethodChannel _channel = 
      MethodChannel('com.example.app/channel');
  
  Future<String?> getNativeData() async {
    try {
      final result = await _channel.invokeMethod<String>('getNativeData');
      return result;
    } on PlatformException catch (e) {
      print('Error: ${e.message}');
      return null;
    }
  }
}

アニメーション

暗黙的アニメーション
// Simple animations that trigger on value change
AnimatedContainer(
  duration: Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  width: isExpanded ? 200 : 100,
  height: isExpanded ? 200 : 100,
  color: isExpanded ? Colors.red : Colors.blue,
  child: child,
)

AnimatedOpacity(
  duration: Duration(milliseconds: 200),
  opacity: isVisible ? 1.0 : 0.0,
  child: child,
)
明示的アニメーション
class _MyWidgetState extends State<MyWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
    _controller.forward();
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _animation,
      child: ScaleTransition(
        scale: _animation,
        child: child,
      ),
    );
  }
}
ヒーローアニメーション
// Source page
Hero(
  tag: 'image-${item.id}',
  child: Image.network(item.imageUrl),
)

// Destination page (same tag)
Hero(
  tag: 'image-${item.id}',
  child: Image.network(item.imageUrl),
)

本番環境へのデプロイ

ビルドコマンド
# Android App Bundle (recommended for Play Store)
flutter build appbundle --release --obfuscate --split-debug-info=symbols/

# Android APK
flutter build apk --release --split-per-abi

# iOS
flutter build ios --release

# Web
flutter build web --release

# Desktop
flutter build windows --release
flutter build macos --release
flutter build linux --release
コードの難読化

本番環境では必ず難読化してください:

flutter build appbundle \
  --obfuscate \
  --split-debug-info=symbols/

クラッシュのシンボル化に備えて、シンボルファイルを保管してください。

CI/CD パイプライン(GitHub Actions の例)
name: Flutter CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.x'
      
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test
      - run: flutter build apk --release

参考資料

特定のトピックに関する詳細なガイドについては、以下を参照してください。

コメント