Riverpod 심층 분석: AsyncNotifier, 코드 생성 및 테스트
Riverpod 3.0에는 글쓰기 방식을 바꾸는 세 가지 개선 사항이 도입되었습니다.
Flutter 앱: 비동기알림자 비동기 상태 관리용
완벽한 유형 안전성을 갖춘 코드 생성 ~와 함께 riverpod_generator
반복적인 상용구를 제거하고 테스트 기반
모의 없이 모든 시나리오를 테스트할 수 있는 공급자 재정의
외부 프레임워크.
이 가이드에서는 검색 기능이 있는 기사 목록, 페이지 매김 및 즐겨찾기 - 저장소부터 위젯까지 단위 테스트를 통해 위젯 테스트 및 강력한 오류 처리. 코드를 생산할 준비가 되었습니다.
무엇을 배울 것인가
- AsyncNotifier: 유형이 안전한 로딩/데이터/오류 상태 관리
- riverpod_generator: @riverpod 주석 및 생성된 코드
- 제품군 공급자: 공급자의 동적 매개변수
- 공급자 구성: 공급자가 서로 의존하는 방식
- ref.watch vs ref.read vs ref.listen: 언제 which를 사용할지
- ProviderContainer로 테스트 및 재정의
- ProviderScope 및 선택적 재정의를 사용한 위젯 테스트
Riverpod 코드 생성을 통한 프로젝트 설정
// pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.0.0
riverpod_annotation: ^3.0.0
dev_dependencies:
riverpod_generator: ^3.0.0
riverpod_lint: ^2.0.0
build_runner: ^2.4.0
flutter_test:
sdk: flutter
// Genera il codice:
// flutter pub run build_runner build --delete-conflicting-outputs
// Oppure in watch mode durante lo sviluppo:
// flutter pub run build_runner watch --delete-conflicting-outputs
// analysis_options.yaml: abilita riverpod_lint
analyzer:
plugins:
- custom_lint
custom_lint:
rules:
- riverpod_lint
AsyncNotifier: 비동기 상태 관리
// Dominio: modelli
class Article {
final String id;
final String title;
final String excerpt;
final String category;
final bool isFavorite;
const Article({
required this.id,
required this.title,
required this.excerpt,
required this.category,
required this.isFavorite,
});
Article copyWith({bool? isFavorite}) => Article(
id: id,
title: title,
excerpt: excerpt,
category: category,
isFavorite: isFavorite ?? this.isFavorite,
);
}
// Repository (interfaccia)
abstract class ArticleRepository {
Future<List<Article>> getArticles({
String? query,
String? category,
int page = 1,
});
Future<void> toggleFavorite(String articleId);
}
// article_repository.dart
part 'article_repository.g.dart';
@riverpod
ArticleRepository articleRepository(Ref ref) {
// In test: override con mock
// In produzione: usa l'implementazione reale
return RemoteArticleRepository(
dio: ref.watch(dioProvider),
);
}
// articles_provider.dart: AsyncNotifier con code gen
part 'articles_provider.g.dart';
@riverpod
class ArticlesController extends _$ArticlesController {
@override
Future<List<Article>> build() async {
// Chiamato automaticamente all'inizializzazione
return _fetchArticles();
}
Future<List<Article>> _fetchArticles({
String? query,
String? category,
}) async {
return ref.read(articleRepositoryProvider).getArticles(
query: query,
category: category,
);
}
// Azione: ricerca
Future<void> search(String query) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => _fetchArticles(query: query),
);
}
// Azione: toggle preferito (ottimistic update)
Future<void> toggleFavorite(String articleId) async {
// 1. Update ottimistico: cambia immediatamente nell'UI
state = state.whenData(
(articles) => articles.map((a) =>
a.id == articleId ? a.copyWith(isFavorite: !a.isFavorite) : a
).toList(),
);
// 2. Persisti sul server
try {
await ref.read(articleRepositoryProvider).toggleFavorite(articleId);
} catch (e) {
// 3. Rollback se fallisce
state = await AsyncValue.guard(_fetchArticles);
}
}
}
제품군 공급자: 매개변수화된 공급자
// article_detail_provider.dart: provider con parametro ID
part 'article_detail_provider.g.dart';
@riverpod
Future<Article> articleDetail(Ref ref, String articleId) async {
// Parametro articleId: ogni ID ha il suo provider separato
// La cache e indipendente per ogni ID
return ref.watch(articleRepositoryProvider).getArticleById(articleId);
}
// Utilizzo nel widget:
class ArticleDetailPage extends ConsumerWidget {
final String articleId;
const ArticleDetailPage({required this.articleId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// articleDetailProvider e un "family" provider
// ref.watch(articleDetailProvider(articleId)) =>
// ogni ID ha il suo AsyncValue separato
final articleAsync = ref.watch(articleDetailProvider(articleId));
return articleAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
children: [
Text('Errore: $error'),
ElevatedButton(
onPressed: () => ref.refresh(articleDetailProvider(articleId)),
child: const Text('Riprova'),
),
],
),
),
data: (article) => ArticleDetailContent(article: article),
);
}
}
ref.watch vs ref.read vs ref.listen
// Le tre modalita di accesso ai provider in Riverpod
class ExampleWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch: REAGISCE ai cambiamenti
// Usa dentro build(): ricostruisce il widget quando cambia
// NON usare in callback asincroni (il widget potrebbe essere smontato)
final articles = ref.watch(articlesControllerProvider);
return Column(
children: [
ElevatedButton(
onPressed: () async {
// ref.read: LEGGE senza ascoltare
// Usa per azioni one-shot (tap, submit form)
// NON usa dentro build() per la logica reattiva
await ref.read(articlesControllerProvider.notifier).search('flutter');
},
child: const Text('Cerca Flutter'),
),
],
);
}
}
// ref.listen: ASCOLTA i cambiamenti per side effects
// Usa per navigazione, snackbar, dialog - non per ricostruire il widget
class ArticleListPage extends ConsumerStatefulWidget {
@override
ConsumerState<ArticleListPage> createState() => _ArticleListPageState();
}
class _ArticleListPageState extends ConsumerState<ArticleListPage> {
@override
void initState() {
super.initState();
// ref.listen: eseguito ogni volta che lo stato cambia
// NON e asincrono: viene chiamato durante il build cycle
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.listenManual(articlesControllerProvider, (previous, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore: ${next.error}')),
);
}
});
});
}
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
Riverpod를 사용한 테스트: Override 및 ProviderContainer
// test/articles_controller_test.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
// Mock del repository
class MockArticleRepository extends Mock implements ArticleRepository {}
void main() {
late MockArticleRepository mockRepo;
late ProviderContainer container;
setUp(() {
mockRepo = MockArticleRepository();
// ProviderContainer: istanza isolata per test (nessun singleton)
container = ProviderContainer(
overrides: [
// Override: sostituisce il repository reale con il mock
articleRepositoryProvider.overrideWithValue(mockRepo),
],
);
});
tearDown(() {
// IMPORTANTE: dispose per liberare risorse
container.dispose();
});
test('inizializza con articoli dal repository', () async {
final testArticles = [
Article(id: '1', title: 'Test', excerpt: 'Ex', category: 'Dev', isFavorite: false),
];
when(mockRepo.getArticles()).thenAnswer((_) async => testArticles);
// Legge il provider: avvia il build() di AsyncNotifier
final result = await container.read(
articlesControllerProvider.future,
);
expect(result, equals(testArticles));
verify(mockRepo.getArticles()).called(1);
});
test('search aggiorna la lista', () async {
final allArticles = [
Article(id: '1', title: 'Flutter', excerpt: '', category: 'Dev', isFavorite: false),
Article(id: '2', title: 'Angular', excerpt: '', category: 'Web', isFavorite: false),
];
final filteredArticles = [allArticles[0]];
when(mockRepo.getArticles()).thenAnswer((_) async => allArticles);
when(mockRepo.getArticles(query: 'Flutter')).thenAnswer((_) async => filteredArticles);
// Aspetta il caricamento iniziale
await container.read(articlesControllerProvider.future);
// Esegui la ricerca
await container.read(articlesControllerProvider.notifier).search('Flutter');
final results = await container.read(articlesControllerProvider.future);
expect(results, equals(filteredArticles));
});
// Widget test con override
testWidgets('mostra loading indicator durante fetch', (tester) async {
// Simula caricamento lento
when(mockRepo.getArticles()).thenAnswer(
(_) async {
await Future.delayed(const Duration(seconds: 1));
return [];
},
);
await tester.pumpWidget(
ProviderScope(
overrides: [
articleRepositoryProvider.overrideWithValue(mockRepo),
],
child: const MaterialApp(home: ArticleListPage()),
),
);
// Primo frame: loading
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Aspetta il completamento
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
});
}
결론
코드 생성 기능을 갖춘 Riverpod 3.0은 해당 주의 최첨단 기술을 나타냅니다. Flutter 관리: AsyncNotifier는 수명 주기를 우아하게 관리합니다. 비동기 상태의 코드 생성기는 희생 없이 상용구를 제거합니다. 유형 안전 및 오버라이드 시스템을 통해 별도의 테스트 없이 신속하게 테스트할 수 있습니다. 복잡한 모의 프레임워크에 의존합니다.







