Language/Flutter

flutter, riverpod으로 상태 관리하기

jaewpark 2024. 6. 18. 17:26

상태 관리 패키지라고 생각했지만, 문서에서는 Flutter/Dart를 위한 반응형 캐싱 프레임워크라 지칭하고 있습니다.
선언적 프로그래밍과 반응형 프로그래밍을 사용하여 로직의 상당 부분을 처리할 수 있습니다.

 

최신 응용 프로그램에는 사용자 인터페이스를 렌더링하는 데 필요한 모든 정보가 거의 제공되지 않고 대신 데이터를 서버에서 비동기적으로 가져오는 경우가 많습니다. 비동기 코드를 사용하는 것은 어렵기도 하고 상태 변수를 생성하고 변경 시 UI를 갱신하는 것이 제한적이기에 Riverpod이 등장하게 되었습니다.

 

상태 관리를 용이하게 해주는 도구로 어플리케이션 내에서 여러 위젯이 동일한 상태에 접근해야 할 때 유용합니다.

패키지 설치

riverpod 패키지를 사용하기 위해서는 flutter_riverpodriverpod_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

providerswidget이 아니라 일반 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 대신 ConsumerStatefulWidgetConsumerState 확장

ConsumerWidgetStatelessWidget 과 동일한 방식으로 사용되지만 빌드 메서드에 추가 매개 변수(ref 개체)가 있는 점이 다릅니다.
ConsumerStatefulWidgetConsumerState 는 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