flutter-best-practices について
モバイル、Web、デスクトップ向け Flutter アプリの開発とコードレビューに役立つ実践ガイドです。アーキテクチャ、状態管理、パフォーマンス、テスト、リリースを扱います。
主な機能
- 01機能別のレイヤー構成
- 02RiverpodとBLoCの状態管理指針
- 03go_routerによるナビゲーションとディープリンク
- 04パフォーマンスとライフサイクルの実践
- 05ユニット・ウィジェット・統合テスト
- 06クロスプラットフォームとリリースの指針
活用シーン
- 01新しいFlutterプロジェクトの構成
- 02状態管理とナビゲーション方式の選定
- 03パフォーマンスとリソース解放の確認
- 04Flutterアプリのテスト計画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mauriciog87/ flutter-agent-skill --skill flutter-best-practicesインストール後、Claude Code で/flutter-best-practicesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/performance.mdplatform-channels.mdstate-management.mdtesting.md詳細
Flutter のベストプラクティス
モバイル、ウェブ、デスクトップの各プラットフォームで、本番環境に対応した Flutter アプリケーションを構築するための専門的なガイドラインです。
クイックスタート
新しい Flutter プロジェクトを開始するとき、またはコードをレビューするときは、次の点に従ってください。
- アーキテクチャ:関心事を明確に分離した、機能優先のフォルダー構成を使用します
- 状態管理:新しいプロジェクトには Riverpod を推奨します。複雑な非同期フローには BLoC を使用します
- ナビゲーション:ディープリンクとウェブ対応には go_router を使用します
- テスト:テストピラミッド(ユニット → ウィジェット → 統合)に従います
- パフォーマンス:
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 |
| ローカル状態を持つインタラクティブな UI | StatefulWidget |
| ツリー内の下位ウィジェット間で共有するデータ | 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
参考資料
特定のトピックに関する詳細なガイドについては、以下を参照してください。
- 状態管理の詳細:references/state-management.mdを参照してください
- テストのパターン:references/testing.mdを参照してください
- パフォーマンスの最適化:references/performance.mdを参照してください
- プラットフォーム統合:references/platform-channels.mdを参照してください