(Flutter) Riverpod의 Provider 사용 방법

반응형

이번 글에서는 Provider를 사용 방법에 대해 설명합니다.

ref  객체 가져오기

Provider를 사용하려면 먼저 ref 개체를 가져와야 합니다.
ref 객체를 통해 위젯 또는 다른 Provider와 상호 작용할 수 있습니다.

Provider로 부터 ref 객체 전달 받기

모든 Provider는 ref 객체를 파라미터로 전달 받습니다.

final valueProvider = Provider((ref) {
  // 다른 Provider를 얻으려면 `ref`를 사용합니다.
  final repository = ref.watch(repositoryProvider);
  return repository.get();
});

이 파라미터 ref 객체는 다른 객체로 전달 할 수도 있습니다.

일반적인 사용 사례는 Provider의 "ref"를 StateNotifier에 전달하는 것입니다.

final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  return Counter(ref);
});

class Counter extends StateNotifier<int> {
  Counter(this.ref) : super(0);

  final Ref ref;

  void increment() {
    // Counter는 `ref`를 사용하여 다른 Provider를 읽을 수 있습니다.
    final repository = ref.read(repositoryProvider);
    repository.post('...');
  }
}

이렇게 하면 Counter 클래스에서 Provider를 읽을 수 있습니다.

위젯에서 ref 가져오기

위젯에는 기본적으로 ref 파라미터를 가지고 있지 않습니다. 그러나 Riverpod은 위젯에서 ref를 얻을 수 있는 다양한 방법을 제공합니다.

StatelessWidget 대신 ConsumerWidget를 상속받기

위젯 트리에서 ref를 가져오려면 StatelessWidget 대신 ConsumerWidget를 사용하는 것입니다.

ConsumerWidgetStatelessWidget과 거의 동일합니다. 차이점은 build 메소드의 두 번째 파라미터에 “ref” 객체가 있다는 점입니다.

ConsumerWidget 사용하는 예제는 다음과 같습니다.

class HomeView extends ConsumerWidget {
  const HomeView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // `ref`를 사용하여 Provider를 수신합니다.
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

StatefulWidget+State 대신 ConsumerStateWidget+ConsumerState 상속받기

ConsumerWidget과 마찬가지로, ConsumerStatefulWidget+ConsumerStateStatefulWidget+State와 거의 동일합니다. 유일한 차이점은 State에 “ref” 객체가 있다는 것입니다.

이번에는 "ref"가 빌드 메소드의 파라미터로 전달되지 않고 ConsumerState 객체의 프로퍼티로 전달됩니다:

class HomeView extends ConsumerStatefulWidget {
  const HomeView({super.key});

  @override
  HomeViewState createState() => HomeViewState();
}

class HomeViewState extends ConsumerState<HomeView> {
  @override
  void initState() {
    super.initState();
    // ref"는 StatefulWidget의 모든 라이프사이클에서 사용할 수 있습니다.
    ref.read(counterProvider);
  }

  @override
  Widget build(BuildContext context) {
    // 또한 "ref"를 사용하여 빌드 메소드 내에서 Provider를 수신 할 수 있습니다.
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

ref를 사용하여 Provider와 상호 작용하기

ref의 주요 용도는 세 가지입니다:

  • ref.watch: Provider의 값을 가져오고 변경 내용을 모니터링합니다. 이 값이 변경되면 해당 값을 구독하고 있는 위젯이나 Provider를 다시 빌드합니다.
  • ref.listen: Provider에 리스너를 추가하여 해당 Provider가 변경될 때마다 새 페이지로 이동하거나 모달을 표시하는 등의 작업을 수행할 수 있습니다.
  • ref.read: 변경 내용을 무시하면서(모니터링하지 않음) Provider의 값을 가져옵니다. 이는 "on click"과 같은 클릭 이벤트에서 변경 내용을 무시하면서 Provider의 값이 필요할 때 유용합니다.

[!info] 메모
가능하면 기능을 구현할 때는 ref.readref.listen보다 ref.watch를 사용하는 것이 좋습니다.
ref.watch를 사용하면 앱이 reactive(리엑티브)와 declarative(선언형)에 가까워지고 유지 보수가 더 쉬워집니다.

ref.watch를 사용하여 Provider를 모니터링하기

ref.watch는 위젯의 build 메서드 내부 또는 Provider 내부에서 Provider를 수신(listen)하도록 하는 데 사용됩니다.

예를 들어, Provider는 ref.watch를 사용하여 여러 Provider를 결합하여 새로운 값을 생성 할 수 있습니다.

예를 들어 할 일 목록을 필터링할 수 있습니다. 두 개의 공급자가 있을 수 있습니다:

이 기술을 사용하면 할일 목록을 필터링할 수 있습니다. 예를 들어 Todo 앱에 다음과 같은 Provider가 있다고 가정합니다.

  • filterTypeProvider, 현재 필터 유형(없음, 완료된 할일만 표시, …)을 가지는 Provider`
  • todosProvider: 전체 할일 목록을 가지는 Provider
    그리고 ref.watch를 사용하면 두 Provider를 결합하여 필터링된 작업 목록을 생성하는 세 번째 Provider를 만들 수 있습니다:
final filterTypeProvider = StateProvider<FilterType>((ref) => FilterType.none);
final todosProvider =
    StateNotifierProvider<TodoList, List<Todo>>((ref) => TodoList());

final filteredTodoListProvider = Provider((ref) {
  // 할일(todos)목록과 필터(filter) 상태 값을 모두 가져옵니다..
  final FilterType filter = ref.watch(filterTypeProvider);
  final List<Todo> todos = ref.watch(todosProvider);

  switch (filter) {
    case FilterType.completed:
      // 완료된(completed) 할일 목록을 반환합니다.
      return todos.where((todo) => todo.isCompleted).toList();
    case FilterType.none:
      // 필터링되지 않은 할일 목록을 반환합니다.
      return todos;
  }
});

위 코드에서 filteredTodoListProvider는 필터링된 할일 목록을 가지고 있습니다.

필터나 할일 목록이 변경되면 필터링된 할일 목록도 자동으로 업데이트됩니다. 동시에 필터나 할일 목록이 모두 변경되지 않으면 필터링된 할일 목록은 다시 계산되지 않습니다.

마찬가지로 위젯은 ref.watch를 사용하여 프로바이더의 콘텐츠를 표시하고 해당 콘텐츠가 변경될 때마다 UI를 업데이트할 수 있습니다.

final counterProvider = StateProvider((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // `ref`를 사용하여 counterProvider 수신하기.
    final counter = ref.watch(counterProvider);

    return Text('$counter');
  }
}

위 샘플 코드는 counter 숫자를 관리하는 counterProvider를 모니터링합니다. counterProvider 값이 변경되면 위젯이 업데이트되어 새로운 값을 표시하도록 UI를 업데이트합니다.

[!warning] 주의
watch 메서드는 비동기적(asynchronously)으로 호출해서는 안 됩니다. 예를 들면 ElevatedButtononPressed 함수 내부에서, 그리고 initState와 다른 State의 생명주기 내부에서도 watch 메서드를 사용해서는 안됩니다.

이러한 경우에는 ref.read를 사용하는 것이 좋습니다.

ref.listen을 사용하여 Provider 변화에 반응하기

ref.watch와 유사하게 Provider를 관찰하기 위해 ref.listen을 사용할 수 있습니다.

ref.watch와 ref.listen의 큰 차이점은 ref.watch는 Provider 상태값이 변경되면 widget/provider을 다시 빌드하지만 ref.listen은 사용자 정의 함수를 호출한다는 점입니다.

예를 들어 에러가 발생할때 스낵바(snackbar)를 표시하거나 어떠한 반응의 변화에 대응해야 할때 유용하게 사용할 수 있습니다.

ref.listen 메소드는 2개의 위치 인수(positional arguments)가 필요합니다. 첫번째 인수는 Provider이고 두번째 인수는 상태가 변경될 때 실행되는 콜백 함수입니다. 콜백 함수가 호출되면 이전 상태의 값과 새 상태의 값, 두 개의 값이 전달됩니다.

ref.listen 메소드는 Provider 내부에서 사용할 수 있습니다. 아래의 코드를 확인해 볼 수 있습니다.

final counterProvider = StateNotifierProvider<Counter, int>(Counter.new);

final anotherProvider = Provider((ref) {
  ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
    print('The counter changed $newCount');
  });
  // ...
});

위젯의 build 메서드 내부에서도 사용할 수 있습니다.

final counterProvider =
    StateNotifierProvider<Counter, int>(Counter.new);

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();
  }
}

[!warning] 주의
listen 메서드는 비동기적으로 호출해서는 안 됩니다. 예를 들어 ElevatedButtononPressed 내부에서 호출해서는 안 됩니다. 또한 initState 및 기타 상태 수명 주기 내부에서도 사용해서는 안 됩니다.

ref.read를 사용하여 Provider의 상태 가져오기

ref.read 메소드는 Provider를 수신하지 않고도 Provider를 가져오는 방법입니다.

ref.read는 일반적으로 사용자 상호작용으로 발생가능한 트리거 함수 내부에서 주로 사용합니다. 예를 들어 사용자가 버튼을 눌렀을 때  ref.read 메소드를 사용하여 카운터의 값을 증가시킬 수 있습니다.

final counterProvider =
    StateNotifierProvider<Counter, int>(Counter.new);

class HomeView extends ConsumerWidget {
  const HomeView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // `Counter`클래스의 `increment()` 메소드를 호출합니다.
          ref.read(counterProvider.notifier).increment();
        },
      ),
    );
  }
}

[!note] 노트
ref.read는 반응형이 아니므로 가급적 사용하지 않는 것이 좋습니다.

ref.read 메소드는 watch 또는 listen를 사용하면 문제가 발생할 수 있는 경우를 위해 존재합니다. 가능하면 거의 항상 watch/listen을 사용하는 것이 좋으며, 특히 watch를 사용하는 것이 좋습니다.

[중요] ref.read를 build 메소드 내부에서 사용하지 마세요.

ref.read을 사용하여 위젯 성능을 최적화하고 싶을 수도 있습니다.

final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  // provider의 업데이트를 무시하기 위해 `ref.read` 메소드를 사용
  final counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}

그러나 이는 잘못된 사용 사례이며 추적하기 어려운 버그의 원인이 될 수 있습니다.

이러한 방식으로 ref.read를 사용하는 것은 일반적으로 "Provider가 노출한 값은 절대 변경되지 않으므로 'ref.read’를 사용하는 것이 안전하다"라는 생각과 관련이 있습니다. 이 가정의 문제점은 오늘은 해당 Provider가 실제로 값을 업데이트하지 않을 수도 있지만, 내일도 마찬가지일 것이라고는 보장할 수 없다는 것입니다.

소프트웨어는 자주 변경되는 경향이 있으며, 앞으로는 이전에는 변경되지 않았던 값도 변경해야 할 가능성이 높습니다.
ref.read를 사용하는 경우 해당 값을 변경해야 할 때 전체 코드베이스를 검토하여 ref.readref.watch로 변경해야 합니다. 이로 인해 오류가 발생하기 쉽고 일부 변경 사항을 잊어버릴 가능성이 높습니다.

반면에 처음부터 ref.watch를 사용하면 리팩토링할 때 발생하는 문제를 줄일 수 있습니다.

그래도 나는 ref.read를 사용하여 위젯이 다시 빌드되는 횟수를 줄이고 싶습니다.

목표는 칭찬할 만하지만, ref.watch를 사용해도 똑같은 효과(빌드 횟수 감소)를 얻을 수 있습니다.

Provider는 리빌드 횟수를 줄이면서 값을 얻을 수 있는 다양한 방법을 제공합니다.

예를 들면:

final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}

위의 코드 대신에 아래처럼 사용하는 것이 좋습니다.

final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.watch(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}

두 코드 모두 카운터가 증가할 때 버튼 위젯은 다시 빌드되지 않습니다.

하지만 카운터가 리셋되는 경우에는 두 번째 방식을 사용 할 수 있습니다. 예를 들어 앱의 다른 부분에서 호출할 수 있습니다:

ref.refresh(counterProvider);

Counter 객체가 다시 생성됩니다.
여기서 ref.read를 사용하면 버튼은 더 이상 사용되지 않는 이전 Counter 인스턴스를 계속 사용하게 됩니다. 반면에 ref.watch를 사용하면 버튼은 올바르게 다시 빌드되어 새로운 Counter를 사용하게 됩니다.

무엇을 읽을지 결정하기

구독하려는 Provider에 따라 구독 할 수 있는 여러 값이 있을 수 있습니다.

예를 들면, 다음 StreamProvider 살펴보세요.

final userProvider = StreamProvider<User>(...);

userProvider를 읽으려고 할때 다음과 같이 사용할 수 있습니다.

  • userProvider 자체를 구독(listening)하여 동기적(synchronously)으로 현재 상태 값을 읽을 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<User> user = ref.watch(userProvider);

  return user.when(
    loading: () => const CircularProgressIndicator(),
    error: (error, stack) => const Text('Oops'),
    data: (user) => Text(user.name),
  );
}
  • userProvider.stream을 구독하여 연결된 Stream을 가져올 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
  Stream<User> user = ref.watch(userProvider.stream);
}
  • userProvider.future를 구독하여 가장 최근 상태값을 가진 Future를 가져올 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
  Future<User> user = ref.watch(userProvider.future);
}

다른 Provider들은 또 다른 대체 값들(alternative values)을 제공할 수 있습니다. 자세한 내용은 API reference를 참조하여 있는 각 Provider의 설명을 참고하세요.

"select"를 사용하여 리빌드 필터링하기

마지막으로 providers 관련하여 언급할 기능은 위젯/프로바이더가 ref.watch에서 리빌드하는 횟수 또는 ref.listen이 함수를 실행하는 빈도를 줄일 수 있는 기능입니다.

기본적으로 provider를 감시하는 것은 전체 객체 상태를 감시하는 것이므로 이를 명심해야 합니다. 그러나 때로는 위젯/프로바이더가 전체 객체가 아닌 일부 프로퍼티의 변경 사항에만 관심을 가질 수도 있습니다.

예를 들어, 프로바이더가 다음 User 상태값을 가진다고 가정해 봅시다.

abstract class User {
  String get name;
  int get age;
}

그런데 위젯에서는 단순히 User의 name값만 사용하고 있습니다.

Widget build(BuildContext context, WidgetRef ref) {
  User user = ref.watch(userProvider);
  return Text(user.name);
}

순진하게 ref.watch를 사용하게 되면 User의 age 속성이 변경될 때마다 위젯이 다시 빌드됩니다.

해결 방법은 select를 사용하는 것입니다. Riverpod에 User의 특정 속성만 관찰하고 싶다고 명시적으로 알리는 것입니다.

코드를 다음과 같이 개선해 볼 수 있습니다.

Widget build(BuildContext context, WidgetRef ref) {
  String name = ref.watch(userProvider.select((user) => user.name));
  return Text(name);
}

select를 사용하면 관심 있는 속성을 반환하는 함수를 지정할 수 있습니다.

User 값이 변경될 때마다 Riverpod은 이 함수를 호출하여 이전 값과 새로운 값을 비교합니다. 만약 두 결과가 다른 경우(예:  name이 변경된 경우) Riverpod는 위젯을 다시 빌드합니다.
그러나 두 값이 같으면(예: age만 변경된 경우) Riverpod은 위젯을 다시 빌드하지 않습니다.

[!info] 정보
select는 ref.listen과 함께 사용할 수도 있습니다.

ref.listen<String>(  
  userProvider.select((user) => user.name),  
  (String? previousName, String newName) {  
    print('The user name changed $newName');  
  }  
);

이렇게 하면 name이 변경되었을 때만 리스너를 호출합니다.

[!tip] 팁
select는 반드시 객체의 속성을 반환할 필요는 없습니다. ==연산자를 오버라이드(overrides)하는 모든 값에 작동합니다. 예를 들면 다음과 같이 할 수 있습니다.

final label = ref.watch(userProvider.select((user) => 'Mr ${user.name}'));


or

[카카오페이로 후원하기] [토스페이로 후원하기]

반응형

'개발 > 플러터(Flutter)' 카테고리의 다른 글

(Flutter) Riverpod의 Modifiers  (0) 2023.03.28
(Flutter) Riverpod의 Provider 상태 결합하기  (0) 2023.03.27
(Flutter) Riverpod의 Provider란  (0) 2023.03.26
(Flutter) Riverpod 시작하기  (0) 2023.03.26
(Flutter) Riverpod란?  (0) 2023.03.25