Riverpod을 이용한 상태관리를 Code Generation과 함께 알아보자

Riverpod을 이용한 상태관리를 Code Generation과 함께 알아보자

Tag
Flutter
📌
사용을 위해선 MaterialApp을 ProviderScope 로 감싸주어야 한다!

pubspec.yaml

dependencies: flutter_riverpod: ^2.3.6 riverpod_annotation: ^2.1.1 dev_dependencies: build_runner: ^2.3.3 riverpod_generator: ^2.2.1

Riverpod

riverpod의 v1 기능부터 정리해 보겠다.

StateProvider

  • UI에서 “직접적으로” 데이터를 변경할 수 있도록 하고 싶을 때 사용
  • 단순한 형태의 데이터만 관리 ( int, double, String 등 )
  • Map, List등 복잡한 형태의 데이터는 다루지 않음
  • 복잡한 로직이 필요한 경우 사용하지 않음
 

provider 생성 방식

// lib/riverpod/state_provider.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; final numberProvider = StateProvider<int>((ref) { // 초기 값 return return 0; });
 

사용 예시

class StateProviderScreen extends ConsumerWidget { const StateProviderScreen({super.key}); // Consumer Widget의 경우 build 메서드의 파라미터에 WidgetRef ref를 추가 @override Widget build(BuildContext context, WidgetRef ref) { // UI 관련 값의 경우 ref.watch를 통해 접근 final provider = ref.watch(numberProvider); return SizedBox( width: MediaQuery.of(context).size.width, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(provider.toString()), ElevatedButton( onPressed: () { // 업데이트 방법 1 ref.read(numberProvider.notifier).update((state) => state + 1); }, child: const Text('UP'), ), ElevatedButton( onPressed: () { // 업데이트 방법 2 ref.read(numberProvider.notifier).state = ref.read(numberProvider.notifier).state - 1; }, child: const Text('DOWN'), ), ], ), ); } }

StateNotifierProvider

  • StateProvider와 마찬가지로 UI에서 “직접적으로” 데이터를 변경할 수 있도록 하고싶을 때 사용
  • 복잡한 형태의 데이터 관리가능 ( 클래스의 메소드를 이용한 상태 관리 )
  • StateNotifier를 상속한 클래스를 반환
 

model

// lib/model/shopping_item_model.dart class ShoppingItemModel { /// 이름 final String name; /// 개수 final int quantity; /// 구매했는지 final bool hasBought; /// 매운지 final bool isSpicy; const ShoppingItemModel({ required this.hasBought, required this.isSpicy, required this.name, required this.quantity, }); }
 

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/model/shopping_item_model.dart'; // StateNotifierProvider<{StateNotifier Class}, {Model Class}> => return StateNotifier final shoppingListProvider = StateNotifierProvider<ShoppingListNotifier, List<ShoppingItemModel>>((ref) { return ShoppingListNotifier(); }); class ShoppingListNotifier extends StateNotifier<List<ShoppingItemModel>> { // super constructor 호출 시 초기 값을 넣어 줌 // 예시의 경우 초기 ShoppingItemModel 5개를 가지고 있음 ShoppingListNotifier() : super( const [ ShoppingItemModel( hasBought: false, isSpicy: true, name: '김치', quantity: 3, ), ShoppingItemModel( hasBought: false, isSpicy: true, name: '라면', quantity: 5, ), ShoppingItemModel( hasBought: false, isSpicy: false, name: '삼겹살', quantity: 10, ), ShoppingItemModel( hasBought: false, isSpicy: false, name: '수박', quantity: 2, ), ShoppingItemModel( hasBought: false, isSpicy: false, name: '카스테라', quantity: 5, ), ], ); // hasBought 값을 바꿔줄 수 있는 메서드 void toggleHasBought({required String name}) { state = state .map((e) => e.name == name ? ShoppingItemModel( hasBought: !e.hasBought, isSpicy: e.isSpicy, name: e.name, quantity: e.quantity, ) : e) .toList(); } }
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/state_notifier_provider.dart'; class StateNotifierProviderScreen extends ConsumerWidget { const StateNotifierProviderScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(shoppingListProvider); return DefaultLayout( title: 'StateNotifierProviderScreen', body: ListView( children: state .map( (item) => CheckboxListTile( title: Text(item.name), value: item.hasBought, onChanged: (value) { ref .read(shoppingListProvider.notifier) .toggleHasBought(name: item.name); }, ), ) .toList(), ), ); } }

FutureProvider

  • Future 타입만 반환 가능
  • API 요청의 결과를 반환할 때 자주 사용
  • 복잡한 로직 또는 사용자의 특정 행동 뒤에 Future를 재실행 하는 기능이 없음
    • 필요할 경우 StateNotifierProvider 사용
  • FutureBuilder와는 다르게 캐싱이 되어있음을 확인할 수 있따
 

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; final multiplesProvider = FutureProvider<List<int>>((ref) async { await Future.delayed(const Duration(seconds: 2)); return [1, 2, 3, 4, 5]; });
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/future_provider.dart'; class FutureProviderScreen extends ConsumerWidget { const FutureProviderScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(multiplesProvider); return DefaultLayout( body: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ state.when( data: (data) { return Text( data.toString(), textAlign: TextAlign.center, ); }, error: (err, stackTrace) => Text(err.toString()), loading: () => const Center(child: CircularProgressIndicator()), ), ], ), title: 'FutureProviderScreen', ); } }

StreamProvider

  • Stream 타입만 반환 가능
  • API 요청의 결과를 Stream으로 반환할 때 사용
 

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; final multipleStreamProvider = StreamProvider<List<int>>((ref) async* { for (int i = 0; i < 10; i++) { await Future.delayed(Duration(seconds: 1)); yield List.generate(3, (index) => index + i); } });
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/stream_provider.dart'; class StreamProviderScreen extends ConsumerWidget { const StreamProviderScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(multipleStreamProvider); return DefaultLayout( body: Center( child: state.when( data: (data) => Text(data.toString()), error: (error, stackTrace) => Text(error.toString()), loading: () => const CircularProgressIndicator(), ), ), title: 'StreamProviderScreen', ); } }

Provider

  • 가장 기본 베이스가 되는 Provider
  • 아무 타입이나 반환 가능
  • Service, 계산한 값 등을 반환할때 사용
  • 반환값을 캐싱할 때 유용하게 사용된다
    • 빌드 횟수 최소화 가능
  • 여러 Provider의 값 들을 묶어서 한번에 반환값을 만들어낼 수 있다.
 

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/model/shopping_item_model.dart'; import 'package:flutter_riverpod_v2/riverpod/state_notifier_provider.dart'; final filteredShoppingListProvider = Provider<List<ShoppingItemModel>>((ref) { final filterState = ref.watch(filterProvider); final shoppingListState = ref.watch(shoppingListProvider); if (filterState == FilterState.all) return shoppingListState; return shoppingListState .where( (item) => filterState == FilterState.spicy ? item.isSpicy : !item.isSpicy, ) .toList(); }); enum FilterState { notSpicy, spicy, all, } final filterProvider = StateProvider<FilterState>((ref) => FilterState.all);
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/provider.dart'; import 'package:flutter_riverpod_v2/riverpod/state_notifier_provider.dart'; class ProviderScreen extends ConsumerWidget { const ProviderScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(filteredShoppingListProvider); return DefaultLayout( title: 'ProviderScreen', actions: [ PopupMenuButton<FilterState>( itemBuilder: (context) { return FilterState.values .map( (value) => PopupMenuItem( value: value, child: Text(value.name), ), ) .toList(); }, onSelected: (value) { ref.read(filterProvider.notifier).update((state) => value); }, ), ], body: ListView( children: state .map( (item) => CheckboxListTile( title: Text(item.name), value: item.hasBought, onChanged: (value) { ref .read(shoppingListProvider.notifier) .toggleHasBought(name: item.name); }, ), ) .toList(), ), ); } }
 
Provider 종류
반환값
사용 예제
Provider
아무 타입
데이터 캐싱
StateProvider
아무 타입
간단한 상태값 관리
StateNotifierProvider
StateNotifier를 상속한 값 반환
복잡한 상태값 관리
FutureProvider
Future 타입
API 요청의 Future 결과값
StreamProvider
Stream 타입
API 요청의 Stream 결과값

Modifiers

.family

  • 모든 Provider에 공통으로 사용할 수 있는 modifier
  • provider를 만들어 놓고 호출할 때의 추가 로직이 필요한 경우 사용한다.
    • 추가적으로 값을 넣어줄 수 있다
 
예시는 FutureProvider를 이용.

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; // FamilyProvider.family<{데이터 타입}, {추가로 받을 data의 데이터 타입}> final familyModifierProvider = FutureProvider.family<List<int>, int>((ref, data) async { await Future.delayed(const Duration(seconds: 2)); return List.generate(5, (index) => index * data); });
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/family_modifier_provider.dart'; class FamilyModifierScreen extends ConsumerWidget { const FamilyModifierScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // 보면 3 이라는 데이터를 넣어준 것을 볼 수 있다. final state = ref.watch(familyModifierProvider(3)); return DefaultLayout( title: 'FamilyModifierScreen', body: Center( child: state.when( data: (data) => Text(data.toString()), error: (err, stackTrace) => Text(err.toString()), loading: () => const CircularProgressIndicator(), ), ), ); } }

.autoDispose

  • 다른 provider들은 캐싱이 되어있지만, dispose가 자동으로 되기 때문에 자동으로 캐시를 삭제해줌
 

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; final autoDisposeModifierProvider = FutureProvider.autoDispose<List<int>>((ref) async { await Future.delayed(const Duration(seconds: 2)); return [1, 2, 3, 4, 5]; });
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/auto_dispose_modifier_provider.dart'; class AutoDisposeModifierScreen extends ConsumerWidget { const AutoDisposeModifierScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(autoDisposeModifierProvider); return DefaultLayout( body: Center( child: state.when( data: (data) => Text(data.toString()), error: (err, traceStack) => Text(err.toString()), loading: () => const CircularProgressIndicator(), ), ), title: 'AutoDisposeModifierScreen', ); } }
 

ref 사용법

ref.read vs ref.watch

  • ref.watch는 반환값의 업데이트가 있을 때 지속적으로 build 함수를 다시 실행해준다.
  • ref.watch는 필수적으로 UI 관련 코드에만 사용한다.
  • ref.read는 실행되는 순간 단 한번만 provider 값을 가져 온다.
  • ref.read는 onPressed 콜백처럼 특정 액션 뒤에 실행되는 함수 내부에서 사용된다.
 

ref.listen

ref.listen<TYPE>(provider, (previous, next){});
  • dispose 할 필요 없음

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/listen_provider.dart'; class ListenProviderScreen extends ConsumerStatefulWidget { const ListenProviderScreen({super.key}); @override ConsumerState<ListenProviderScreen> createState() => _ListenProviderScreenState(); } class _ListenProviderScreenState extends ConsumerState<ListenProviderScreen> with SingleTickerProviderStateMixin { late final TabController controller; @override void initState() { super.initState(); controller = TabController( length: 10, vsync: this, initialIndex: ref.read(listenProvider), ); } @override // WidgetRef를 파라미터로 추가로 받지 않는다. Widget build(BuildContext context) { ref.listen<int>(listenProvider, (previous, next) { if (previous != next) controller.animateTo(next); }); return DefaultLayout( body: TabBarView( physics: const NeverScrollableScrollPhysics(), controller: controller, children: List.generate( 10, (index) => Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(index.toString()), ElevatedButton( onPressed: () { ref .read(listenProvider.notifier) .update((state) => state == 10 ? 10 : state + 1); }, child: const Text('다음'), ), ElevatedButton( onPressed: () { ref .read(listenProvider.notifier) .update((state) => state == 0 ? 0 : state - 1); }, child: const Text('뒤로'), ), ], ), ).toList(), ), title: 'ListenProviderScreen', ); } }
 

ref.select

  • StateNotifierProvider를 사용하는 경우 값의 전체가 필요한 것이 아닌, 일부만 필요한 상황이 생길 것이다.
  • 하지만 ref.watch를 통해 provider의 값을 바라보는 경우 필요하지 않은 값이 변경될 때에도 빌드가 다시 되는 상황이 생긴다.
 

provider 생성 방식

import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/model/shopping_item_model.dart'; final selectProvider = StateNotifierProvider<SelectNotifier, ShoppingItemModel>((ref) { return SelectNotifier(); }); class SelectNotifier extends StateNotifier<ShoppingItemModel> { SelectNotifier() : super( const ShoppingItemModel( hasBought: false, isSpicy: true, name: '김치', quantity: 3, ), ); void toggleHasBought() { // copyWith는 당신이 알고 있는 copyWith와 같다 state = state.copyWith(hasBought: !state.hasBought); } void toggleIsSpicy() { state = state.copyWith(isSpicy: !state.isSpicy); } }
 

사용 예시

import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod_v2/layout/default_layout.dart'; import 'package:flutter_riverpod_v2/riverpod/select_provider.dart'; class SelectProviderScreen extends ConsumerWidget { const SelectProviderScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { print('build'); // final state = ref.watch(selectProvider); 인 경우 selectProvider의 어떠한 값이 바뀌어도 빌드가 다시 됨 // 현재의 경우 isSpicy값만 바뀔 때 재빌드 final state = ref.watch(selectProvider.select((value) => value.isSpicy)); ref.listen( selectProvider.select((value) => value.hasBought), (previous, next) { print('next: $next'); }, ); return DefaultLayout( body: SizedBox( width: double.infinity, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Text(state), Text(state.toString()), // Text(state.hasBought.toString()), ElevatedButton( onPressed: () { ref.read(selectProvider.notifier).toggleIsSpicy(); }, child: const Text('Spicy Toggle'), ), ElevatedButton( onPressed: () { ref.read(selectProvider.notifier).toggleHasBought(); }, child: const Text('HasBought Toggle'), ), ], ), ), title: 'SelectProviderScreen', ); } }
 

ProviderObserver

  • provider를 관찰하는 기능
  • ProviderScope 내부에 삽입
 

@override

didUpdateProvider

void didUpdateProvider( ProviderBase<Object?> provider, Object? previousValue, Object? newValue, ProviderContainer container, ) {}
⇒ provider가 update 되면 불리는 메서드
 

didAddProvider

void didAddProvider( ProviderBase<Object?> provider, Object? value, ProviderContainer container, ) {}
⇒ provider가 불리면 호출되는 메서드
 

didDisposeProvider

void didDisposeProvider( ProviderBase<Object?> provider, ProviderContainer container, ) {}
⇒ provider가 삭제 됐을 때 호출되는 메서드
 

Code Generation

지금까지는 v1의 기준으로 알아 보았다.
이제부터 riverpod의 v2 기능들에 대해서 알아보려고 하는데,
codeGeneration 기능이 추가된 것이 주 기능이라고 생각하면 된다.
 
그렇다면 왜 code generation이 추가 되었는가?
1. 어떤 Provider를 사용할지 고민 할 필요가 없도록 StateNotifierProvider는 명시적으로 사용 가능 2. Family ( Parameter )를 일반 함수처럼 사용할 수 있도록
import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'code_generation_provider.g.dart'; // BEFORE final _testProvider = Provider<String>((ref) => 'Hello Code Generation'); // AFTER @riverpod String gState(GStateRef ref) { return 'Hello Code Generation'; } @riverpod Future<int> gStateFuture(GStateFutureRef ref) async { await Future.delayed(const Duration(seconds: 3)); return 10; }
// GENERATED CODE - DO NOT MODIFY BY HAND part of 'code_generation_provider.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** String _$gStateHash() => r'7ccdacb016fab2894413745b936f82987f9f72cf'; /// See also [gState]. @ProviderFor(gState) final gStateProvider = AutoDisposeProvider<String>.internal( gState, name: r'gStateProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$gStateHash, dependencies: null, allTransitiveDependencies: null, ); typedef GStateRef = AutoDisposeProviderRef<String>; String _$gStateFutureHash() => r'eef3e95f799e15b4647a3851f8ee6b4438b05afa'; /// See also [gStateFuture]. @ProviderFor(gStateFuture) final gStateFutureProvider = AutoDisposeFutureProvider<int>.internal( gStateFuture, name: r'gStateFutureProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$gStateFutureHash, dependencies: null, allTransitiveDependencies: null, ); typedef GStateFutureRef = AutoDisposeFutureProviderRef<int>; // ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
📌
generation의 결과를 보면 AutoDispose가 자동으로 걸리는 것을 볼 수 있다.
 

AutoDispose 해제하는 방법

import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'code_generation_provider.g.dart'; @Riverpod(keepAlive: true) Future<int> gStateFuture2(GStateFutureRef ref) async { await Future.delayed(const Duration(seconds: 3)); return 10; }
 

기존 family modifier에서 여러개의 파라미터를 받는 경우

import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'code_generation_provider.g.dart'; class Parameter { final int number1; final int number2; const Parameter({ required this.number1, required this.number2, }); } final _testFamilyProvider = Provider.family<int, Parameter>( (ref, data) => data.number1 * data.number2, );
어쩔 수 없이 family modifier는 한 개의 값만 받기 때문에 class를 사용해야 했다.
 

AFTER

@riverpod int gStateMultiply( GStateMultiplyRef ref, { required int number1, required int number2, }) { return number1 * number2; }
 
 
🔥
v2에서는 StateNotifier가 아닌 Notifier, AsyncNotifier가 적용되었다. 추가로 포스팅을 할 예정이다.
 

기능

invalidate

ref.invalidate(Provider);
초기 값 ( 상태 )으로 돌아가게 됨
 

Consumer

같은 위젯에서 여러개의 provider 값을 가지고 있을 경우, 한 값이 변경되면 빌드가 다 되기 때문에 비효율적이다.
⇒ provider의 값과 관련된 위젯만 rebuild가 이루어지도록 할 수 있는 위젯
📌
즉 상위의 build 메소드가 실행되지 않고, Consumer의 builder가 실행 된다.
  • child
    • Consumer의 builder 함수 내에서도 state와 관련되지 않은 위젯은 한번만 렌더링할 수 있도록 해준다.
 
Consumer( builder: (context, ref, child) { final state = ref.watch(gStateNotifierProvider); return Row( children: [ Text(state.toString()), // child는 아래의 const Text('hello') // gStateNotifierProvider의 값이 바뀌어도 child 위젯은 다시 렌더링되지 않는다 if (child != null) child, ], ); }, child: const Text('hello'), ),