flutter, riverpod으로 상태 관리하기
상태 관리 패키지라고 생각했지만, 문서에서는 Flutter/Dart를 위한 반응형 캐싱 프레임워크라 지칭하고 있습니다.
선언적 프로그래밍과 반응형 프로그래밍을 사용하여 로직의 상당 부분을 처리할 수 있습니다.
최신 응용 프로그램에는 사용자 인터페이스를 렌더링하는 데 필요한 모든 정보가 거의 제공되지 않고 대신 데이터를 서버에서 비동기적으로 가져오는 경우가 많습니다. 비동기 코드를 사용하는 것은 어렵기도 하고 상태 변수를 생성하고 변경 시 UI를 갱신하는 것이 제한적이기에 Riverpod이 등장하게 되었습니다.
상태 관리를 용이하게 해주는 도구로 어플리케이션 내에서 여러 위젯이 동일한 상태에 접근해야 할 때 유용합니다.
패키지 설치
riverpod 패키지를 사용하기 위해서는 flutter_riverpod
과 riverpod_annotation
을 설치하면 됩니다.
새로운 버전부터는 코드를 생성해 주는 게 추가 되었기에 riverpod_generator
도 설치해야 합니다.
// pubspec.yaml
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
build_runner:
custom_lint:
riverpod_generator: ^2.4.2
riverpod_lint: ^2.3.12
dart pub get
으로 패키지를 설치합니다.dart run build_runner watch
로 코드 생성할 수 있습니다.
그 외 lint 설치는 참고하면 됩니다.
ProviderScrope
providers
는 widget이 아니라 일반 Dart 객체입니다.providers
는 위젯 트리 외부에서 정의되며, global final 변수로 선언됩니다.
루트 위젯을 ProvicerScope
로 감싸서 선언된 providers를 사용할 수 있게 됩니다.ProvicerScope
는 생성한 모든 Provider
의 상태를 저장하는 위젯으로 내부적으로 ProvicerContainer
인스턴스를 생성합니다.
// Provider는 최상위 변수
final counterProvider = ChangeNotifierProvider<Counter>((ref) => Counter());
void main() {
runApp(
// 이 위젯은 전체 프로젝트에서 Riverpod을 사용할 수 있게 합니다
ProviderScope(
child: MyApp(),
),
);
}
provider 읽기
Provider
에서 providers
를 읽는 한 가지 방법은 위젯의 'BuildContext'를 사용하는 것입니다.
final modelProvider = Provider<Model>(...);
class Example extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
Model model = ref.watch(modelProvider);
}
}
ref
ref
는 widget이나 다른 provider로부터 상호 작용할 수 있게 해줍니다.
provider에서 ref
@riverpod
String value(ValueRef ref) {
// use ref to obtain other providers
final repository = ref.watch(repositoryProvider);
return repository.get();
}
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() {
// Counter can use the "ref" to read other providers
final repository = ref.read(repositoryProvider);
repository.post('...');
}
}
widget에서 ref
위젯에는 ref 매개 변수가 없기에 Riverpod은 위젯에서 ref 매개 변수를 검색하기 위한 솔루션을 제공합니다.
StatelessWidget
대신ConsumerWidget
확장StatefulWidgets
대신ConsumerStatefulWidget
및ConsumerState
확장
ConsumerWidget
은 StatelessWidget
과 동일한 방식으로 사용되지만 빌드 메서드에 추가 매개 변수(ref 개체)가 있는 점이 다릅니다.ConsumerStatefulWidget
및 ConsumerState
는 State에 ref 개체가 있습니다.
ref 사용
ref
는 세 가지 용도로 사용됩니다.
- ref.watch
- ref.listen
- ref.read
ref.watch
widget의 build 메서드 또는 provider body의 내부에서 provider를 수신하도록 하는 데 사용됩니다.
여러 providers를 새 값으로 결합 가능합니다.
아래 코드에서 filteredTodoListProvider를 생성하는 부분에서 확인할 수 있습니다.
@riverpod
FilterType filterType(FilterTypeRef ref) {
return FilterType.none;
}
@riverpod
class Todos extends _$Todos {
@override
List<Todo> build() {
return [];
}
}
@riverpod
List<Todo> filteredTodoList(FilteredTodoListRef ref) {
// obtains both the filter and the list of todos
final FilterType filter = ref.watch(filterTypeProvider);
final List<Todo> todos = ref.watch(todosProvider);
switch (filter) {
case FilterType.completed:
// return the completed list of todos
return todos.where((todo) => todo.isCompleted).toList();
case FilterType.none:
// returns the unfiltered list of todos
return todos;
}
}
ref.watch
사용하여 UI를 업데이트 할 수 있습니다.
@riverpod
int counter(CounterRef ref) => 0;
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// use ref to listen to a provider
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}
비동기로 호출해서는 안됩니다. 예를 들어, 버튼 이벤트를 내부에서 호출하거나 initState 및 State 상명 주기에서도 사용해서는 안됩니다. 이럴 경우에는 ref.read 사용하는 것을 고려해야 합니다.
ref.listen
ref.watch
와 마찬가지로 provider를 관찰할 수 있습니다.
동일한 역할을 하는 거 같지만 차이점이 있습니다.
수신 중인 provider가 변경될 경우 widget/provider를 rebuild하는 대신 사용자 정의 함수를 호출합니다.
오류가 발생했을 때 알림을 표시하는 것과 같이 특정 변경 사항이 발생할 때 유용할 수 있습니다.
ref.listen
메서드에는 두 개의 인수가 필요합니다.
- provider
- callback function(상태가 변경될 때 실행)
- old value
- new value
provier/widget에서 사용되는 예제입니다.
@riverpod
void another(AnotherRef ref) {
ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
print('The counter changed $newCount');
});
// ...
}
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
}
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
print('The counter changed $newCount');
});
return Container();
}
}
비동기로 호출해서는 안됩니다. 예를 들어, 버튼 이벤트를 내부에서 호출하거나 initState 및 State 생명 주기에서도 사용해서는 안됩니다. 이럴 경우에는 ref.read 사용하는 것을 고려해야 합니다.
watch vs listen
watch를 사용하는 것이 더 직관적이고 유지 보수가 쉽기에 권장합니다.
watch
는 상태 변경에 따라 UI를 자동으로 다시 빌드하는 경우에 사용listen
은 상태 변경에 따라 UI를 rebuild할 필요가 없는 경우, 특정 상태에 도달할 때 알림 혹은 로그를 남기는 경우에 사용
ref.read
provider의 state를 수신하지 않고 가져오는 방법입니다.
일반적으로 사용자 상호작용에 의해 트리거되는 함수 내부에서 사용됩니다.
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state = state + 1;
}
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
// Call `increment()` on the `Counter` class
ref.read(counterProvider.notifier).increment();
},
),
);
}
}
ref.read
는 반응형이 아니므로 가능한 피해야 합니다.
watch 또는 listen을 사용하면 문제가 발생할 수 있는 경우를 위해 존재합니다.
build 메서드 내에서는 절대 사용하지 마세요
추적하기 어려운 버그를 일으킬 수 있습니다.
일반적으로 "Provier가 노출하는 값은 절대 변하지 않는다는 생각"으로 사용합니다.
실제로 값을 업데이트하지 않을 수도 있지만, 내일도 마찬가지라는 보장이 없습니다.
현재에는 변경되지 않더라도 미래에는 그 값이 변경되어야 할 가능성이 높습니다.ref.read
를 사용하는 경우 해당 값을 변경해야 할 때 전체 코드베이스를 검토해야 합니다.
ref.read
를 사용하여 rebuild 횟수를 줄이려고 사용할 수 있지만ref.watch
를 사용해도 동일한 효과(빌드 횟수 감소)를 얻을 수 있습니다.
// counter가 증가할 때 버튼이 다시 빌드 되지 않는 점에서 동일한 효과
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state = state + 1;
}
Widget build(BuildContext context, WidgetRef ref) {
// Counter counter = ref.read(counterProvider.notifier);
Counter counter = ref.watch(counterProvider.notifier);
return ElevatedButton(
onPressed: () => counter.increment(),
child: const Text('button'),
);
}
다른 부분에서 ref.refresh(counterProvider)
호출하면 counter 객체를 다시 생성할 수 있습니다.ref.read
를 사용하면 버튼이 폐기되어 더이상 사용되지 않는 이전 인스턴스를 사용하는 반면ref.watch
를 사용하면 버튼이 새 카운터를 사용하도록 올바르게 rebuild 됩니다.
watch vs read
read
는 상태를 읽을 수 있지만 변경된다고 rebuild하지 않습니다.
상태를 구독하지 않기에 변경에 따른 업데이트가 필요 없는 경우에 유용합니다.
주로 상태를 한 번만 읽거나, 특정 이벤트가 발생했을 때 상태를 읽고 설정할 때 사용합니다.
select 사용
때떄로 객체 대신 일부 속성의 변경에만 신경쓰는 경우에는 사용합니다.ref.watch
를 재구성하는 횟수 또는 ref.listen
을 얼마나 자주 실행 하는지 줄이는 기능입니다.
user의 나이가 변경되어도 rebuild 됩니다. select를 통해 user의 이름이 변경되는 것만 주시합니다.
abstract class User {
String get name;
int get age;
}
Widget build(BuildContext context, WidgetRef ref) {
// User user = ref.watch(userProvider);
// return Text(user.name);
String name = ref.watch(userProvider.select((user) => user.name));
return Text(name);
}
ref.listen 에서 select를 사용하는 것도 가능합니다.
ref.listen<String>( userProvider.select((user) => user.name), (String? previousName, String newName) { print('The user name changed $newName'); } );
final label = ref.watch(userProvider.select((user) => 'Mr ${user.name}'));
Provider
Provider
는 일반 함수처럼 동작하지만 이점이 있습니다.
- 캐시
- 기본적인 오류/로딩 처리를 제공
- 리스너를 추가 가능
- 데이터가 변경될 때 자동으로 다시 실행
여러 위치에서 state
에 접근할 수 있습니다.
싱글톤, 서비스 로케이터, 종속성 주입 또는 widget 상속과 같은 패턴을 대체할 수 있습니다.
Service Loacator: 객체 인스턴스를 찾기 위해 사용하는 디자인 패턴, 객체의 의존성을 중앙에서 관리하고 필요할 때 객체를 제공하는 역할
어떤 Provider를 사용할 지 결정합니다.
Provider | When use |
---|---|
Provider | 상수 |
StateProvider | 변수 |
StateNotifierProvider | 변수 + 메서드 |
FutureProvider | Future, 나중에 가져온 후 데이터 참조 |
StreamProvider | Stream, snapshot 등을 사용하여 Firebase 등에서 데이터를 가져오는 경우 |
ChangeNotifierProvider | ChangeNotifier 사용 |
(Async)NotifierProvider | 사용자 지정 이벤트에 반응 후, 시간이 지남에 따라 변경될 때 사용 |
최근에는 자동 생성을 사용하여 추천됩니다.
더 나은 syntax, 읽기 쉽고 유연해집니다. 또한 학습 곡선이 줄어듭니다.
- provider의 유형을 걱정할 필요 없이 Riverpod이 적합한 공급자를 선택합니다.
dirty global variable
를 정의하는 것처럼 보이지 않습니다.stateful hot-reload
stateful hot-reload: 코드 변경 시 UI와 로직을 실시간으로 변경하면서 state를 잃지 않고 유지 가능
Provider 생성
아래와 같이 Provider를 생성할 수 있습니다.
const List<Fruit> fruits = [ ... ];
final fruitProvider = Provider((ref) {
return fruits;
});
그리고 자동으로 함수 또는 클래스 형태로 생성할 수도 있습니다.
클래스형 provider는 외부 개체가 provider의 상태를 수정할 수 있도록 하는 메서드를 포함할 수 있습니다.
함수형 provider는 build 메서드만 있는 클래스 기반 provider 작성하기 위한 sugar로 UI에서 수정할 수 없습니다.
문서에서는 @riverpod
annotation 추가하여 자동으로 생성할 수 있도록 권장하는 것 같습니다.
// functional
@riverpod
String example(ExampleRef ref) {
return 'foo';
}
// class-based
@riverpod
class Example extends _$Example {
@override
String build() {
return 'foo';
}
// Add methods to mutate the state
}
자동 생성되는 파일 이름을 정의(?)합니다.
part 'example_provider.g.dart';
그리고 터미널에 아래 명령어를 입력하면, 파일이 생성됩니다.
dart run build_runner watch