Flutterの状態管理: Riverpod vs BLoC - 完全比較ガイド
Flutterの状態管理におけるRiverpodとBLoCの詳細な比較です。アーキテクチャ、パフォーマンス、テスト容易性、ユースケースから最適なソリューションを選びます。

状態管理はFlutter開発における中心的な課題です。RiverpodとBLoCがエコシステムを牽引しており、それぞれが異なる哲学を提示しています。本ガイドは、プロジェクトの要件に応じた選択を支援するため、両ソリューションを具体的な実装を通じて比較します。
本ガイドはFlutterと状態管理の基礎に習熟していることを前提とします。例ではRiverpod 2.xとflutter_bloc 8.x、現在の安定版を使用します。
両アプローチの中核的な哲学
RiverpodとBLoCは同じ問題を相反するパラダイムで解決します。これらの概念的な違いを理解することで、文脈に適したツールを選べます。
Riverpodは宣言的かつリアクティブなアプローチを採用します。Providerはwidgetが監視するデータソースを定義します。フレームワークがライフサイクル、キャッシュ、Provider間の依存関係を自動で管理します。
BLoC(Business Logic Component)はイベント駆動の厳格なアーキテクチャを強制します。コンポーネントがイベントを発行し、Blocがそれを処理して新しい状態を生成します。この明示的な分離がデータフローの追跡を容易にします。
// Riverpod: simple declaration, framework handles the rest
final counterProvider = StateProvider<int>((ref) => 0);
// Usage in a widget
class CounterWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// Reactive read: automatic rebuild if value changes
final count = ref.watch(counterProvider);
return Text('$count');
}
}// BLoC: explicit events/states separation
abstract class CounterEvent {}
class IncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
// Each event has its dedicated handler
on<IncrementPressed>((event, emit) => emit(state + 1));
}
}
// Usage in a widget
class CounterWidget extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<CounterBloc, int>(
builder: (context, count) => Text('$count'),
);
}
}どちらのアプローチを選ぶかはチームの好みとプロジェクトの制約によります。
初期設定
初期設定では両ソリューション間のエルゴノミクスの違いが見えてきます。Riverpodはシンプルさを優先し、BLoCはより多くの構造を提供します。
Riverpodのインストール
Riverpodは単一のパッケージとアプリケーションのルートにあるラッパーで構成できます。任意のコード生成が生産性を高めます。
// Riverpod configuration: single wrapper at root
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
// ProviderScope wraps the entire application
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
home: HomeScreen(),
);
}
}BLoCのインストール
BLoCは複数のパッケージと、使用するBlocごとのBlocProviderによる、より入念な設定を必要とします。
// BLoC configuration: explicit providers for each Bloc
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
// MultiBlocProvider for multiple Blocs
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc()),
BlocProvider(create: (_) => ThemeBloc()),
],
child: MaterialApp(
home: HomeScreen(),
),
);
}
}BLoCの設定は初期コードを多く必要としますが、依存関係を最初から明示します。
シンプルな状態管理: カウンターとトグル
単純なケースでは各ソリューションの日常的な使い勝手が現れます。Riverpodは簡潔さで際立ち、BLoCはイベント駆動の構造を保ちます。
Riverpodによるカウンター
// StateProvider: simple state without complex logic
final counterProvider = StateProvider<int>((ref) => 0);
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// watch for reactive value
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(child: Text('Count: $count')),
floatingActionButton: FloatingActionButton(
// read for actions (no rebuild)
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Icon(Icons.add),
),
);
}
}BLoCによるカウンター
// Typed events for each possible action
sealed class CounterEvent {}
class CounterIncremented extends CounterEvent {}
class CounterDecremented extends CounterEvent {}
class CounterReset extends CounterEvent {}
// Bloc with handlers for each event
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncremented>((event, emit) => emit(state + 1));
on<CounterDecremented>((event, emit) => emit(state - 1));
on<CounterReset>((event, emit) => emit(0));
}
}
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) => Text('Count: $count'),
),
),
floatingActionButton: FloatingActionButton(
// Event dispatch to modify state
onPressed: () => context.read<CounterBloc>().add(CounterIncremented()),
child: const Icon(Icons.add),
),
);
}
}単純なケースでは、Riverpodがボイラープレートを大きく削減します。BLoCはロジックが複雑化したときに意味を持ちます。
StateProviderは単純なプリミティブ値に向きます。複雑なオブジェクトやビジネスロジックには、StateNotifierProviderかNotifierProviderの方が制御性が高くなります。
非同期な状態管理: API呼び出し
非同期処理では各ソリューションの強みが浮き彫りになります。loading・error・dataの各状態の扱いが大きな課題です。
Riverpodによる非同期データ
// FutureProvider: automatic loading/error/data management
final usersProvider = FutureProvider.autoDispose<List<User>>((ref) async {
final repository = ref.watch(userRepositoryProvider);
// autoDispose releases resources when provider is no longer used
return repository.fetchUsers();
});
class UsersScreen extends ConsumerWidget {
const UsersScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(usersProvider);
// when handles all 3 possible states
return usersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $error'),
ElevatedButton(
// invalidate forces reload
onPressed: () => ref.invalidate(usersProvider),
child: const Text('Retry'),
),
],
),
),
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserTile(user: users[index]),
),
);
}
}BLoCによる非同期データ
// Explicit states for each loading phase
sealed class UsersState {}
class UsersInitial extends UsersState {}
class UsersLoading extends UsersState {}
class UsersLoaded extends UsersState {
final List<User> users;
UsersLoaded(this.users);
}
class UsersError extends UsersState {
final String message;
UsersError(this.message);
}
// Events to trigger actions
sealed class UsersEvent {}
class UsersFetchRequested extends UsersEvent {}
class UsersRefreshRequested extends UsersEvent {}
class UsersBloc extends Bloc<UsersEvent, UsersState> {
final UserRepository _repository;
UsersBloc(this._repository) : super(UsersInitial()) {
on<UsersFetchRequested>(_onFetchRequested);
on<UsersRefreshRequested>(_onRefreshRequested);
}
Future<void> _onFetchRequested(
UsersFetchRequested event,
Emitter<UsersState> emit,
) async {
emit(UsersLoading());
try {
final users = await _repository.fetchUsers();
emit(UsersLoaded(users));
} catch (e) {
emit(UsersError(e.toString()));
}
}
Future<void> _onRefreshRequested(
UsersRefreshRequested event,
Emitter<UsersState> emit,
) async {
// Keep current state during refresh
final currentState = state;
try {
final users = await _repository.fetchUsers();
emit(UsersLoaded(users));
} catch (e) {
// Restore previous state on error
if (currentState is UsersLoaded) {
emit(currentState);
} else {
emit(UsersError(e.toString()));
}
}
}
}// Widget with pattern matching on states
class UsersScreen extends StatelessWidget {
const UsersScreen({super.key});
Widget build(BuildContext context) {
return BlocBuilder<UsersBloc, UsersState>(
builder: (context, state) {
return switch (state) {
UsersInitial() => const Center(
child: ElevatedButton(
onPressed: _fetchUsers,
child: Text('Load'),
),
),
UsersLoading() => const Center(child: CircularProgressIndicator()),
UsersError(:final message) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $message'),
ElevatedButton(
onPressed: () => context
.read<UsersBloc>()
.add(UsersFetchRequested()),
child: const Text('Retry'),
),
],
),
),
UsersLoaded(:final users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserTile(user: users[index]),
),
};
},
);
}
void _fetchUsers(BuildContext context) {
context.read<UsersBloc>().add(UsersFetchRequested());
}
}BLoCは状態遷移ごとに細かく制御できます。RiverpodはAsyncValueを介してより自動化します。
Flutterの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
状態間の依存関係: コンポジションと注入
実際のアプリでは状態同士が相互に依存します。これらの依存関係の管理が両アプローチを大きく分けます。
Riverpodによるコンポジション
// Base provider: configuration
final apiClientProvider = Provider<ApiClient>((ref) {
final baseUrl = ref.watch(environmentProvider).apiUrl;
return ApiClient(baseUrl: baseUrl);
});
// Dependent provider: repository
final productRepositoryProvider = Provider<ProductRepository>((ref) {
// Automatic client injection
final client = ref.watch(apiClientProvider);
return ProductRepository(client);
});
// Provider with parameter: product by ID
final productProvider = FutureProvider.autoDispose.family<Product, String>(
(ref, productId) async {
final repository = ref.watch(productRepositoryProvider);
return repository.getProduct(productId);
},
);
// Derived provider: filtered products
final filteredProductsProvider = Provider<List<Product>>((ref) {
final products = ref.watch(productsProvider).valueOrNull ?? [];
final filter = ref.watch(productFilterProvider);
return products.where((p) => p.category == filter.category).toList();
});
// Usage with parameter
class ProductDetailScreen extends ConsumerWidget {
final String productId;
const ProductDetailScreen({super.key, required this.productId});
Widget build(BuildContext context, WidgetRef ref) {
// family allows passing parameters
final productAsync = ref.watch(productProvider(productId));
return productAsync.when(
loading: () => const ProductSkeleton(),
error: (e, _) => ErrorWidget(error: e),
data: (product) => ProductDetails(product: product),
);
}
}BLoCによるコンポジション
// Repository injected into the Bloc
class ProductBloc extends Bloc<ProductEvent, ProductState> {
final ProductRepository _repository;
final CartBloc _cartBloc;
late final StreamSubscription _cartSubscription;
ProductBloc({
required ProductRepository repository,
required CartBloc cartBloc,
}) : _repository = repository,
_cartBloc = cartBloc,
super(ProductInitial()) {
on<ProductFetchRequested>(_onFetchRequested);
on<ProductAddedToCart>(_onAddedToCart);
// Listen to cart changes
_cartSubscription = _cartBloc.stream.listen((cartState) {
// React to cart changes
if (cartState is CartUpdated) {
add(ProductCartSyncRequested(cartState.items));
}
});
}
Future<void> _onFetchRequested(
ProductFetchRequested event,
Emitter<ProductState> emit,
) async {
emit(ProductLoading());
try {
final product = await _repository.getProduct(event.productId);
// Check if product is in cart
final isInCart = _cartBloc.state.contains(product.id);
emit(ProductLoaded(product, isInCart: isInCart));
} catch (e) {
emit(ProductError(e.toString()));
}
}
Future<void> close() {
_cartSubscription.cancel();
return super.close();
}
}
// Configuration with dependency injection
class ProductsPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ProductBloc(
repository: context.read<ProductRepository>(),
cartBloc: context.read<CartBloc>(),
)..add(ProductFetchRequested()),
child: const ProductsView(),
);
}
}Riverpodは依存関係を宣言的に管理します。BLoCはBloc間のサブスクリプションを手動で管理する必要があります。
テスト容易性とモッキング
テストはプロフェッショナルなプロジェクトで決定的な評価軸となります。両ソリューションともに、異なるアプローチでこの分野で優れています。
Riverpodでのテスト
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
group('UserProvider Tests', () {
late MockUserRepository mockRepository;
late ProviderContainer container;
setUp(() {
mockRepository = MockUserRepository();
// Isolated container with override
container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(mockRepository),
],
);
});
tearDown(() => container.dispose());
test('returns users from repository', () async {
// Arrange
final expectedUsers = [User(id: '1', name: 'Test')];
when(() => mockRepository.fetchUsers())
.thenAnswer((_) async => expectedUsers);
// Act
final users = await container.read(usersProvider.future);
// Assert
expect(users, expectedUsers);
verify(() => mockRepository.fetchUsers()).called(1);
});
test('handles repository errors', () async {
when(() => mockRepository.fetchUsers())
.thenThrow(Exception('Network error'));
expect(
() => container.read(usersProvider.future),
throwsException,
);
});
});
}BLoCでのテスト
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
group('UsersBloc Tests', () {
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
});
// blocTest simplifies state sequence testing
blocTest<UsersBloc, UsersState>(
'emits [Loading, Loaded] when fetch succeeds',
build: () {
when(() => mockRepository.fetchUsers())
.thenAnswer((_) async => [User(id: '1', name: 'Test')]);
return UsersBloc(mockRepository);
},
act: (bloc) => bloc.add(UsersFetchRequested()),
expect: () => [
isA<UsersLoading>(),
isA<UsersLoaded>().having(
(s) => s.users.length,
'users count',
1,
),
],
);
blocTest<UsersBloc, UsersState>(
'emits [Loading, Error] when fetch fails',
build: () {
when(() => mockRepository.fetchUsers())
.thenThrow(Exception('Network error'));
return UsersBloc(mockRepository);
},
act: (bloc) => bloc.add(UsersFetchRequested()),
expect: () => [
isA<UsersLoading>(),
isA<UsersError>(),
],
);
});
}bloc_testパッケージは状態シーケンスをテストする専用構文を提供します。RiverpodはFlutter標準のテストパターンを使います。
通常のケースだけのテストでは不十分です。テストはネットワークエラー、タイムアウト、境界状態、想定外の状態遷移を網羅する必要があります。
パフォーマンスとリビルドの最適化
パフォーマンスはユーザー体験に直結します。両ソリューションは異なる最適化メカニズムを提供します。
Riverpodでの最適化
// select to rebuild only if targeted value changes
class UserNameWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// Rebuilds only if user.name changes
final name = ref.watch(userProvider.select((user) => user.name));
return Text(name);
}
}
// Provider with automatic caching
final expensiveComputationProvider = Provider<ExpensiveResult>((ref) {
final input = ref.watch(inputProvider);
// Computation automatically cached
return performExpensiveComputation(input);
});
// autoDispose to release unused resources
final searchResultsProvider = FutureProvider.autoDispose
.family<List<Product>, String>((ref, query) async {
// Temporary keepAlive during typing
final link = ref.keepAlive();
// Timer to release after inactivity
final timer = Timer(const Duration(seconds: 30), link.close);
ref.onDispose(timer.cancel);
return searchProducts(query);
});BLoCでの最適化
// buildWhen limits rebuilds conditionally
class UserNameWidget extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<UserBloc, UserState>(
// Rebuilds only if name changes
buildWhen: (previous, current) {
if (previous is UserLoaded && current is UserLoaded) {
return previous.user.name != current.user.name;
}
return true;
},
builder: (context, state) {
if (state is UserLoaded) {
return Text(state.user.name);
}
return const SizedBox.shrink();
},
);
}
}
// BlocSelector to extract a specific value
class UserAvatarWidget extends StatelessWidget {
Widget build(BuildContext context) {
return BlocSelector<UserBloc, UserState, String?>(
// Select only the avatar URL
selector: (state) => state is UserLoaded ? state.user.avatarUrl : null,
builder: (context, avatarUrl) {
if (avatarUrl == null) return const DefaultAvatar();
return NetworkImage(avatarUrl);
},
);
}
}両ソリューションとも細かな最適化を提供します。Riverpodはselect、BLoCはbuildWhenとBlocSelectorを使用します。
実用ケース: 完全な認証
認証システムは各ソリューションの実際のパターンを示します。このケースでは永続化された状態、API呼び出し、ナビゲーションを組み合わせます。
Riverpodによる認証
// Authentication state with sealed class
sealed class AuthState {
const AuthState();
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthLoading extends AuthState {
const AuthLoading();
}
class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
class AuthUnauthenticated extends AuthState {
final String? error;
const AuthUnauthenticated([this.error]);
}
// Notifier to manage auth state
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repository;
final SecureStorage _storage;
AuthNotifier(this._repository, this._storage) : super(const AuthInitial()) {
_checkAuthStatus();
}
Future<void> _checkAuthStatus() async {
final token = await _storage.getToken();
if (token != null) {
try {
final user = await _repository.getCurrentUser(token);
state = AuthAuthenticated(user);
} catch (_) {
await _storage.deleteToken();
state = const AuthUnauthenticated();
}
} else {
state = const AuthUnauthenticated();
}
}
Future<void> login(String email, String password) async {
state = const AuthLoading();
try {
final result = await _repository.login(email, password);
await _storage.saveToken(result.token);
state = AuthAuthenticated(result.user);
} catch (e) {
state = AuthUnauthenticated(e.toString());
}
}
Future<void> logout() async {
await _storage.deleteToken();
state = const AuthUnauthenticated();
}
}
// Provider with injected dependencies
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authRepositoryProvider),
ref.watch(secureStorageProvider),
);
});
// Redirect based on auth state
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
redirect: (context, state) {
final isAuth = authState is AuthAuthenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuth && !isAuthRoute) return '/auth/login';
if (isAuth && isAuthRoute) return '/home';
return null;
},
routes: [...],
);
});BLoCによる認証
// Exhaustive states for authentication
sealed class AuthState {
const AuthState();
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthCheckInProgress extends AuthState {
const AuthCheckInProgress();
}
class AuthLoginInProgress extends AuthState {
const AuthLoginInProgress();
}
class AuthSuccess extends AuthState {
final User user;
const AuthSuccess(this.user);
}
class AuthFailure extends AuthState {
final String error;
const AuthFailure(this.error);
}
class AuthLoggedOut extends AuthState {
const AuthLoggedOut();
}
// Authentication events
sealed class AuthEvent {
const AuthEvent();
}
class AuthCheckRequested extends AuthEvent {
const AuthCheckRequested();
}
class AuthLoginSubmitted extends AuthEvent {
final String email;
final String password;
const AuthLoginSubmitted(this.email, this.password);
}
class AuthLogoutRequested extends AuthEvent {
const AuthLogoutRequested();
}
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
final SecureStorage _storage;
AuthBloc({
required AuthRepository repository,
required SecureStorage storage,
}) : _repository = repository,
_storage = storage,
super(const AuthInitial()) {
on<AuthCheckRequested>(_onCheckRequested);
on<AuthLoginSubmitted>(_onLoginSubmitted);
on<AuthLogoutRequested>(_onLogoutRequested);
}
Future<void> _onCheckRequested(
AuthCheckRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthCheckInProgress());
final token = await _storage.getToken();
if (token == null) {
emit(const AuthLoggedOut());
return;
}
try {
final user = await _repository.getCurrentUser(token);
emit(AuthSuccess(user));
} catch (_) {
await _storage.deleteToken();
emit(const AuthLoggedOut());
}
}
Future<void> _onLoginSubmitted(
AuthLoginSubmitted event,
Emitter<AuthState> emit,
) async {
emit(const AuthLoginInProgress());
try {
final result = await _repository.login(event.email, event.password);
await _storage.saveToken(result.token);
emit(AuthSuccess(result.user));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
await _storage.deleteToken();
emit(const AuthLoggedOut());
}
}両実装は同じ機能を異なるスタイルで提供します。BLoCは各遷移を明示し、Riverpodは構文を簡潔にします。
比較サマリー表
| 評価軸 | Riverpod | BLoC | |--------|----------|------| | 学習コスト | 中程度 | より急 | | ボイラープレート | 最小限 | 多い | | 型安全性 | 優秀 | 優秀 | | テスト容易性 | 優秀 | 優秀 | | 追跡性 | DevTools経由 | 明示的なEvents/States | | コンポジション | 自動 | 手動 | | コード生成 | 任意 | 不要 | | チーム規模 | 柔軟 | 大規模チーム |
文脈別の推奨事項
RiverpodとBLoCの選択は複数の文脈的要因に依存します。
Riverpodを選ぶ場合:
- チームが簡潔さと生産性を優先する
- プロジェクトが柔軟な状態の合成を要求する
- 開発者がReactや他のリアクティブフレームワークから来ている
- 自動キャッシュが大きな利点になる
BLoCを選ぶ場合:
- チームが厳格で予測可能なパターンを評価する
- プロジェクトが完全なイベント追跡を要求する
- ジュニアが強制されたアーキテクチャから恩恵を受ける
- デバッグに遷移履歴が必要となる
結論
RiverpodとBLoCはFlutterの状態管理ニーズに効果的に応えます。Riverpodはエルゴノミクスと柔軟性で、BLoCは構造と予測可能性で際立ちます。両ソリューションともに優れたテスト容易性と最適なパフォーマンスを提供します。
意思決定チェックリスト
- ✅ チームの規模と経験を評価する
- ✅ データフローの複雑性を考慮する
- ✅ 追跡性とデバッグの要件を分析する
- ✅ プロトタイプで両ソリューションをテストする
- ✅ 既存アーキテクチャとの整合性を確認する
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
最良の選択は、チームが習熟し効果的に維持できるソリューションです。適用の一貫性が、ソリューションの選択そのものよりも重要です。
タグ
共有
関連記事

2026年のFlutter状態管理:Riverpod vs Bloc vs GetX 徹底比較ガイド
Riverpod 3.0、Bloc 9.0、GetXの3大Flutter状態管理ソリューションを、コード例・パフォーマンス・テスト戦略の観点から徹底比較します。

モバイル開発者向けFlutter面接質問トップ20
Flutter面接で頻出する20問を徹底解説します。ウィジェット、状態管理、Dart言語、アーキテクチャ、ベストプラクティスをコード例とともに紹介します。

Flutter:初めてのクロスプラットフォームアプリを構築する
FlutterとDartによるクロスプラットフォームモバイルアプリケーション構築の完全ガイド。Widget、状態管理、ナビゲーション、初心者向けベストプラクティスを解説。