본문 바로가기

Development/Flutter

[Riverpod] 1. Provider, Notifier, NotifierProvider, ref

ref. https://riverpod.dev/ko/docs/essentials/side_effects

 

부가 작업 수행(Performing side effects) | Riverpod

지금까지는 데이터를 가져오는 방법(일명 GET HTTP 요청 수행)만 살펴봤습니다.

riverpod.dev

 

The problem is, how do we update the state of a provider from within a consumer?  
Naturally, providers do not expose a way to modify their state. This is by design, to ensure that the state is only modified in a controlled way and promote separation of concerns.  
Instead, providers have to explicitly expose a way to modify their state.

... 

To do that, we will use a new concept: Notifiers. we will need to modify our provider such that they expose a public API for modifying their state. This is done by converting our provider into what we call a "notifier". Notifiers are the "stateful widget" of providers. They require a slight tweak to the syntax for defining a provider.

 

Provider는 데이터 혹은 메서드를 여러 위젯에서 엑세스 가능하도록 노출(expose) 하기 위해 사용된다.

노출된 Provider는 ref 라는 객체로 엑세스 할 수 있다.

 

ref 생성자의 파라미터로 전달받아서 사용하기도 하고,

NotifierProvider 나 Consumer 위젯 처럼 아예  class 의 property 로 내장된 경우도 있다.

 // Notifier & NotifierProvider 조합의 기본 syntax

final name = SomeNotifierProvider.someModifier<MyNotifier, Result>(MyNotifier.new);
 
class MyNotifier extends SomeNotifier<Result> {
  @override
  Result build() {
    <your logic here>
  }
  <your methods here>
}

 

Notifier 를 통한 데이터의 변경

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ButtonNotifier extends AutoDisposeAsyncNotifier<int> {
  late int count;
  @override
  FutureOr<int> build() {
    count = 5;
    return count;
  }

  Future<void> pressBtn() async {
    state = const AsyncValue.loading();
    await Future.delayed(const Duration(seconds: 1));
    count++;
    // print(count);
    state = AsyncData(count);
  }
}

final buttonProvider = AutoDisposeAsyncNotifierProvider<ButtonNotifier, int>(
  () => ButtonNotifier(),
);

// ###################### view ###################################
class RiverpodTestScreen extends ConsumerStatefulWidget {
  static String routePath = "/lab/test";
  static String routeName = "test";
  const RiverpodTestScreen({super.key});

  @override
  ConsumerState<ConsumerStatefulWidget> createState() =>
      _RiverpodTestScreenState();
}

class _RiverpodTestScreenState extends ConsumerState<RiverpodTestScreen> {
  void _countUp() {
    ref.read(buttonProvider.notifier).pressBtn();
  }

  @override
  Widget build(BuildContext context) {
    ref.watch(buttonProvider);
    final state = ref.watch(buttonProvider.notifier).state;
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: TextButton(
          child: Text("state: ${state}"),
          onPressed: () => _countUp(),
        ),
      ),
    );
  }
}

 

위의 코드는 이해를 위해 작성한 전체 코드이다.

Provider 가 정의된 파일과 화면을 렌더링하는 위젯이 정의된 파일을 따로 두는 게 일반적이지만,

편의상 같은 파일로 작성하였다. 

 

TextButton 을 누르면 onPressed 의 콜백함수인 _countUp() 함수가 실행된다.

 

_countUp() 함수는 UI 를 렌더링하는 RiverpodTestScreen 위젯의 private method 로,

ref.read(buttonProvider.notifier) 를 통해 NotifierProvider 인 buttonProvider 가 노출하는 notifier 인 ButtonNotifier 에 엑세스, .pressBtn() 으로 ButtonNotifier 의 pressBtn() 메서드를 호출한다.

 

선언부를 보면 알겠지만 pressBtn()는 비동기함수임을 알 수 있다. 따라서 Notifier 도 AsyncNotifier 를 사용하였다.

pressBtn() 메서드는 Notifier 의 state 를 대기상태로 만들고 1초 기다린 후, state = AsyncData(count); 를 실행한다.

state 가 1초동안 대기상태였다가.  state 에 count 변수의 값을 저장하는 셈이다.

 

state 가 변하고 있기 때문에 ButtonNotifier 를 노출하는 buttonProvider 를 listen 하는 다른 위젯 - listener - 은

state 가 변할 때 state가 변했음을 통보받는다 (event 발생). 

그러면 listener 는 자신의 build 메서드를 재실행시킨다. (rebuild)   (2024-04-17 삭제 및 설명 보완)

state 가 변했다고 해서 listener 의 build 메서드가 재실행되지는 않으며,

해당 state 를 listen 중인 위젯들이 다시 렌더링된다.

build 메서드는 invalidateSelf 등으로 state 값을 초기화 했을 때 다시 실행된다.

 

마찬가지로 state 가 변했다고 하여 Notifier 의 build 메서드도 재실행되지 않고, (해당 Notifier 를 노출시키는) Provider를 listen 하는 위젯이 위젯트리 상에 존재할 경우 Notifier의 build 메서드가 1회 실행되는 듯 하다.

 

참고로 AsyncNotifier 가 아니라 정확히는 AutoDisposeAsyncNotifier 를 사용했는데, 화면을 나갔다 들어왔을 때 다시 count 변수가 5로 초기화되게끔 편의상 추가한 옵션이다.

 

AutoDispose를 간단히 정리하면 더이상 사용이 되지 않으면 자동으로 폐기(dispose)되도록 하는 옵션인데,

지금은 화면을 나가면 AutoDIsposeAsyncNotifier 인 ButtonNotifier가 폐기되어 사라지고,

다시 화면으로 돌아오면 ButtonNotifier 를 사용해야하므로 ButtonNotifier 의  build 함수가 실행되며 ButtonNotifier 의 초기화가 이뤄진다.

dispose 에 대한 자세한 설명은 다음 포스팅에 정리할 예정.

 

 

아래는 위의 코드의 build 메서드에 마우스를 올려놓으면 볼 수 있는 주석 설명문이다.

Initialize an [AsyncNotifier].
It is safe to use [Ref.watch] or [Ref.listen] inside this method.
If a dependency of this [AsyncNotifier] (when using [Ref.watch]) changes, then [build] will be re-executed. On the other hand, the [AsyncNotifier] will not be recreated. Its instance will be preserved between executions of [build].
If this method throws or returns a future that fails, the error will be caught and an [AsyncError] will be emitted.

 

만약, state 를 변경하지 않고 Notifier 의 데이터인 count 변수만 변경하면 어떻게 되는지를 테스트해보았다.

전체 코드에서 다른 부분은 하나도 건드리지 않고 state 를 변경하는 부분만 주석처리로 지웠다.

  Future<void> pressBtn() async {
    // state = const AsyncValue.loading();
    await Future.delayed(const Duration(seconds: 1));
    count++;
    print(count);
    // state = AsyncData(count);
  }

 

정상적으로 동작하는 경우

 

주석처리하기 전에는 UI 업데이트가 count 변수가 변하는 것을 반영하고 있다. 

Notifier 의 build 메서드에서 count 변수를 5로 초기화하여 count 변수가 5에서부터 시작되어

버튼을 누를 때마다 1초 대기 후 1씩 증가한다.

AsyncData<int>(value:5) 라고 보여주고 있긴 한데,

아래처럼 TextButton 위젯을 수정하면 state 가 저장하는 값인 count 변수의 값만 볼 수도 있다.

TextButton(
          child: Text("state: ${state.value}"),
          onPressed: () => _countUp(),
        ),
 

 

state 를 변경하지 않고 count 변수의 값만 변경하는 경우

 

 

애뮬레이터에서 녹화한 화면이라 마우스 포인터가 보이지 않아 버튼을 누르고 있는지 표현이 잘 안되서 아쉽지만, 확실히 누르고는 있다. 하지만 UI 는 변화가 없는 모습.

print 를 통해 count 변수값이 변경되는지 콘솔로 확인해 보면 count 변수값은 정상적으로 1씩 증가하고 있다.

state 가 변하는지 listen은 하고 있지만,

정작 state 를 변화시키는 부분을 구현하지 않아서 listener 들이 rebuild 되지 않기 때문.

StatefulWidget 에서 setState 를 제대로 작성하지 않은 경우와 비슷하다.

Watch & Listen / Read 차이점

ref.watch / ref.listen

`ref.watch` 메서드는 provider를 받아 현재 상태를 반환합니다. 그러면 리스닝된 provider가 변경될 때마다 provider가 무효화(invalidated)되고 다음 프레임 또는 다음 읽기(read) 시 다시 빌드됩니다.

수신 중인 provider가 변경되어 요청이 다시 계산되면 새 요청이 완료될 때까지 이전 상태가 유지됩니다.  
동시에 요청이 보류(pending)되는 동안 "isLoading" 및 "isReloading" 플래그가 설정됩니다.

이를 통해 UI에 이전 상태 또는 로딩 표시기를 표시하거나 둘 다 표시할 수 있습니다.

 

모든 상황에서 .watch 를 사용하는것은 지양
-> 'listener' 의 콜백함수나, notifier 의 메서드 등에서 watch 를 사용할 경우 watch 구문이 실행되지 않을 수도 있다.
("Meaning any code that is possibly not executed during the build phase of the provider")
-> 이를 위한 .watch 의 대체제로 ref.listen 를 사용해야한다.

 

ref.listen state 가 변하는지를 listen 하는 것은 ref.write 와 동일하지만,

state 가 변했을 때, 콜백함수를 실행하기 위한 용도로 사용한다는 차이가 있다.

 

ref.read

watch/listen 과는 달리, Provider 의 state 를 한 번 읽고 그 이후로 state 가 변경되었는지는 리스닝하지 않는다.

notifier 의 메서드에 엑세스하기 위한 용도로 주로 사용한다.

이 옵션은 provider의 현재 상태를 반환한다는 점에서 `ref.watch`와 유사합니다. 하지만 `ref.watch`와 달리 공급자를 수신(listen)하지 않습니다.

따라서 `ref.read`는 Notifier의 메서드 내부와 같이 `ref.watch`를 사용할 수 없는 곳에서만 사용해야 합니다.

provider에서 `ref.read`를 사용할 때는 주의하세요. provider를 수신(listen)하지 않으므로, 해당 provider가 수신(listen)하지 않으면 상태(state)를 파괴(destroy)할 수 있습니다.

상태를 파괴~ 이 부분은

자동으로 폐기되도록 옵션을 넣어서 provider 를 생성했을 시, read 만으로는 자동폐기를 막을 수 없다는 의미이다.

'Development > Flutter' 카테고리의 다른 글

[RiverPod] 2. State disposal  (1) 2024.02.10
[Riverpod] 0. Riverpod overview  (1) 2024.02.10
Flutter 시작하기 (VSCode)  (0) 2023.09.05
Flutter 개발환경 구축 (Ubuntu)  (0) 2023.09.05