Flutter パターン は何をしますか?
いつ使用するか
以下のような場合にこのスキルを使用します:
- 新しい Flutter 機能を開始する際に、状態管理、ナビゲーション、データアクセスの慣用的なパターンが必要な場合
- Dart コードをレビューまたは記述する際に、Null 安全性、sealed 型、または非同期合成に関するガイダンスが必要な場合
- 新しい Flutter プロジェクトをセットアップし、BLoC、Riverpod、Provider のいずれかを選択する場合
- 安全な HTTP クライアント、WebView 統合、またはローカルストレージを実装する場合
- Flutter ウィジェット、Cubit、または Riverpod プロバイダーのテストを記述する場合
- 認証ガード付きの GoRouter を設定する場合
仕組み
このスキルは、関心ごとに整理された、コピーしてすぐに使用できる Dart/Flutter コードパターンを提供します:
- Null 安全性 —
!を避け、?./??/パターンマッチングを推奨 - 不変状態 — sealed クラス、
freezed、copyWith - 非同期合成 — 並行処理の
Future.wait、await後の安全なBuildContext - ウィジェットアーキテクチャ — メソッドではなくクラスに抽出、
constの伝播、スコープ化された再構築 - 状態管理 — BLoC/Cubit イベント、Riverpod notifier と派生プロバイダー
- ナビゲーション —
refreshListenableを使用したリアクティブな認証ガード付き GoRouter - ネットワーキング — インターセプター付き Dio、ワンタイムリトライガードを使用したトークンリフレッシュ
- エラー処理 — グローバルキャプチャ、
ErrorWidget.builder、Crashlytics 連携 - テスト — ユニットテスト(BLoC)、ウィジェットテスト(ProviderScope のオーバーライド)、モックの代わりに Fake
例
// Sealed state — ありえない状態を防止
sealed class AsyncState<T> {}
final class Loading<T> extends AsyncState<T> {}
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }
// GoRouter with reactive auth redirect
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final authed = context.read<AuthCubit>().state is AuthAuthenticated;
if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
return null;
},
routes: [...],
);
// Riverpod derived provider with safe firstWhereOrNull
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
Dart および Flutter アプリケーションのための、実用的で本番環境に耐えうるパターンです。可能な限りライブラリに依存せず、最も一般的なエコシステムパッケージを明示的にカバーします。
1. Null 安全性の基本
パターンを Bang 演算子よりも優先
// 悪い例 — 実行時に null でクラッシュ
final name = user!.name;
// 良い例 — フォールバックを提供
final name = user?.name ?? 'Unknown';
// 良い例 — Dart 3 パターンマッチング(複雑な場合に推奨)
final display = switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Guest',
};
// 良い例 — 早期リターンでガード
String getUserName(User? user) {
if (user == null) return 'Unknown';
return user.name; // チェック後は非nullに昇格
}
late の過剰使用を避ける
// 悪い例 — Null エラーを実行時に先送り
late String userId;
// 良い例 — 明示的な初期化でnullable
String? userId;
// OK — 最初のアクセス前に初期化が保証されている場合のみ late を使用
// (例:initState() で、ウィジェットとのやり取りの前に)
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}
2. 不変状態
状態階層のための Sealed クラス
sealed class UserState {}
final class UserInitial extends UserState {}
final class UserLoading extends UserState {}
final class UserLoaded extends UserState {
const UserLoaded(this.user);
final User user;
}
final class UserError extends UserState {
const UserError(this.message);
final String message;
}
// 網羅的な Switch — コンパイラがすべての分岐を強制
Widget buildFrom(UserState state) => switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorText(message),
};
Freezed によるボイラープレートフリーの不変性
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// 使用例
final user = User(id: '1', name: 'Alice', email: '[email protected]');
final updated = user.copyWith(name: 'Alice Smith'); // 不変更新
final json = user.toJson();
final fromJson = User.fromJson(json);
3. 非同期合成
Future.wait を使用した構造化並行処理
Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
// 並行実行 — 逐次待機しない
final (userList, orderList) = await (
users.getAll(),
orders.getRecent(),
).wait; // Dart 3 レコード分割 + Future.wait 拡張
return DashboardData(users: userList, orders: orderList);
}
Stream パターン
// リポジトリはライブデータ用にリアクティブなストリームを公開
Stream<List<Item>> watchCartItems() => _db
.watchTable('cart_items')
.map((rows) => rows.map(Item.fromRow).toList());
// ウィジェット層 — 宣言的、手動購読不要
StreamBuilder<List<Item>>(
stream: cartRepository.watchCartItems(),
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const CircularProgressIndicator(),
AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
AsyncSnapshot(:final data?) => CartList(items: data),
_ => const SizedBox.shrink(),
},
)
Await 後の BuildContext
// 重要 — StatefulWidget の await 後は常に mounted をチェック
Future<void> _handleSubmit() async {
setState(() => _isLoading = true);
try {
await authService.login(_email, _password);
if (!mounted) return; // ← context を使用する前にガード
context.go('/home');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
4. ウィジェットアーキテクチャ
メソッドではなくクラスに抽出
// 悪い例 — ウィジェットを返すプライベートメソッド、最適化を妨げる
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
// 良い例 — 個別のウィジェットクラス、const と要素の再利用が可能に
class _PageHeader extends StatelessWidget {
const _PageHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
}
const の伝播
// 悪い例 — 再構築のたびに新しいインスタンス
child: Padding(
padding: EdgeInsets.all(16.0), // const ではない
child: Icon(Icons.home, size: 24.0), // const ではない
)
// 良い例 — const は再構築の伝播を止める
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.home, size: 24.0),
)
スコープ化された再構築
// 悪い例 — カウンターが変わるたびにページ全体が再構築される
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // すべてを再構築
return Scaffold(
body: Column(children: [
const ExpensiveHeader(), // 不必要に再構築される
Text('$count'),
const ExpensiveFooter(), // 不必要に再構築される
]),
);
}
}
// 良い例 — 再構築部分を分離
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(children: [
ExpensiveHeader(), // 再構築されない (const)
_CounterDisplay(), // これだけが再構築される
ExpensiveFooter(), // 再構築されない (const)
]),
);
}
}
class _CounterDisplay extends ConsumerWidget {
const _CounterDisplay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}
5. 状態管理: BLoC/Cubit
// Cubit — 同期または単純な非同期状態
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authService) : super(const AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authService.login(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
void logout() {
_authService.logout();
emit(const AuthState.initial());
}
}
// ウィジェット内
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const LoginForm(),
AuthLoading() => const CircularProgressIndicator(),
AuthAuthenticated(:final user) => HomePage(user: user),
AuthError(:final message) => ErrorView(message: message),
},
)
6. 状態管理: Riverpod
// 自動破棄の非同期プロバイダー
@riverpod
Future<List<Product>> products(Ref ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getAll();
}
// 複雑な変更を伴う Notifier
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void add(Product product) {
final existing = state.where((i) => i.productId == product.id).firstOrNull;
if (existing != null) {
state = [
for (final item in state)
if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
else item,
];
} else {
state = [...state, CartItem(productId: product.id, quantity: 1)];
}
}
void remove(String productId) =>
state = state.where((i) => i.productId != productId).toList();
void clear() => state = [];
}
// 派生プロバイダー(セレクターパターン)
@riverpod
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
// firstWhereOrNull (collection パッケージより) は、製品が見つからない場合の StateError を回避
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
7. GoRouter を使用したナビゲーション
final router = GoRouter(
initialLocation: '/',
// refreshListenable は認証状態が変わるたびにリダイレクトを再評価
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
final isGoingToLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isGoingToLogin) return '/login';
if (isLoggedIn && isGoingToLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/products/:id',
builder: (context, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
],
),
],
);
8. Dio を使用した HTTP
final dio = Dio(BaseOptions(
baseUrl: const String.fromEnvironment('API_URL'),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
// 認証インターセプターを追加
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await secureStorage.read(key: 'auth_token');
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (error, handler) async {
// 無限リトライループに対するガード:リクエストごとにリフレッシュは1回のみ試行
final isRetry = error.requestOptions.extra['_isRetry'] == true;
if (!isRetry && error.response?.statusCode == 401) {
final refreshed = await attemptTokenRefresh();
if (refreshed) {
error.requestOptions.extra['_isRetry'] = true;
return handler.resolve(await dio.fetch(error.requestOptions));
}
}
handler.next(error);
},
));
// Dio を使用したリポジトリ
class UserApiDataSource {
const UserApiDataSource(this._dio);
final Dio _dio;
Future<User> getById(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return User.fromJson(response.data!);
}
}
9. エラー処理アーキテクチャ
// グローバルエラーキャプチャ — main() で設定
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashlytics.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const App());
}
// 本番環境用のカスタム ErrorWidget
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
theme: appTheme,
builder: (context, widget) {
ErrorWidget.builder = (details) => const CustomErrorScreen();
return widget!;
},
);
}
}
10. テスト
BLoC/Cubit のユニットテスト
void main() {
late AuthCubit authCubit;
late MockAuthService mockAuthService;
setUp(() {
mockAuthService = MockAuthService();
authCubit = AuthCubit(mockAuthService);
});
tearDown(() {
authCubit.close();
});
group('login', () {
test('emits [loading, authenticated] on success', () async {
// モックの設定
when(() => mockAuthService.login(any(), any()))
.thenAnswer((_) async => User(id: '1', name: 'Alice'));
// 期待される状態のリスト
final expected = [
const AuthState.initial(),
const AuthState.loading(),
AuthState.authenticated(User(id: '1', name: 'Alice')),
];
// アサート
expectLater(
authCubit.stream,
emitsInOrder(expected),
);
// アクション
authCubit.login('[email protected]', 'password');
});
test('emits [loading, error] on failure', () async {
// 例外を投げるようにモックを設定
when(() => mockAuthService.login(any(), any()))
.thenThrow(AuthException('Invalid credentials'));
final expected = [
const AuthState.initial(),
const AuthState.loading(),
const AuthState.error('Invalid credentials'),
];
expectLater(
authCubit.stream,
emitsInOrder(expected),
);
authCubit.login('[email protected]', 'wrong');
});
});
}
Riverpod プロバイダーのテスト
void main() {
test('CartNotifier adds items correctly', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final product = Product(id: '1', name: 'Test', price: 10.0);
container.read(cartNotifierProvider.notifier).add(product);
expect(container.read(cartNotifierProvider).length, 1);
expect(container.read(cartNotifierProvider).first.quantity, 1);
// 同じ商品を追加
container.read(cartNotifierProvider.notifier).add(product);
expect(container.read(cartNotifierProvider).first.quantity, 2);
});
}
モックの代わりに Fake を使用するウィジェットテスト
class FakeUserRepository implements UserRepository {
@override
Future<User> getUser(String id) async {
return User(id: id, name: 'Test User', email: '[email protected]');
}
}
void main() {
testWidgets('UserProfile loads and displays user', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
child: const MaterialApp(home: UserProfile(userId: '42')),
),
);
// ローディングインジケーターを確認
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 非同期完了を待つ
await tester.pumpAndSettle();
// ユーザーデータが表示されていることを確認
expect(find.text('Test User'), findsOneWidget);
expect(find.text('[email protected]'), findsOneWidget);
});
}