Flutter Riverpod

강정우·2023년 5월 29일
0

Flutter&Dart

목록 보기
41/99

Riverpod

flutter pub add riverpod
  • 위 코드로 라이브러리를 설치하면 된다.

  • 또한 위 라이브러리는 provider를 개발한 개발자가 만든 라이브러리이며 provider보다 조금 더 편리하고 유연한 상태관리를 위한 라이브러리로 업그레이드 됐다.

  • 이때 riverpod(다트), flutter_riverpod가 따로 있으니 반드시 flutter_riverpod로 설치해줘야한다.

  • 이와 또 다른 유명한 라이브러리는 getX정도가 있겠고 마치 vue.js의 상태관리 라이브러리가 vuex와 pinia가 매우 유망하고 pinia로 넘어가는 시대처럼 RiverPod를 많이 쓰지만 최근에는 getX를 더 많이 사용하는 것 같다. (뇌피셜)

  • 일단 기본적인 Riverpod를 공부하고 추후 getX를 공부하여 포스팅하겠다.

  • 공급자라는 객체를 생성할 수 있다.

  • 리버포드 패키지가 제공하는 클래스에 기반하여 객체를 생성하는 것이다.

  • 이제 이 공급자는 동적 값를 제공할 수 있고 결국 그 값을 바꿀 수 있는 메서드도 제공한다.

  • 그리고 우리 앱에서 어떤 위젯에서든 공급자에 연결된 소비자를 설정할 수 있다.

  • 이는 리버포드 패키지가 자동으로 연결하는 것이다.

  • 이 소비자 위젯에서 해당 공급자 값에 대한 변화를 listen할 수 있다.

  • 혹은 공급자가 제공하는 메서드를 호출해 그러한 변화를 트리거할 수도 있다.

1. App에 적용 (root 단에 ProvideScope 배치)

  • 어떤 프레임웤이든 state를 관리하는 라이브러릴 사용할 땐 항상 app에 해당되는 부분에 warp을 하였다.

  • react에서는 <Provider>로 <APP>을 감싸고 vue.js에서는 app.use()로 넣어줬다. 마찬가지이다.

ProviderScope 위젯엔 child 속성이 있는데 여기에 우리가 사용하는 App 위젯을 넣어주면 App wide하게 사용이 가능하다. 즉, ProviderScope는 프로바이더 상태 저장소 역할을 하며 앱 어디서든 동일 Container를 공유한다.

  • 참고로 중첩 ProviderScope로 특정 서브트리만 override도 가능

폴더 생성

  • lib 폴더에 provider 폴더를 생성하고 이제 여기서 app wide하게 전역으로 사용할 수 있는 공급자 객체를 설정하는 곳이다.

2. Provider 작성

Provider목적상태변경
Provider<T>불변 의존성 주입DB·API 클라이언트 등 싱글턴
StateProvider<T>가벼운 가변 값state =내부적으로 StateController<T> 반환
StateNotifierProvider<Notifier,T>로직·이벤트 분리state =, Notifier 메서드UI-로직 분리 권장
FutureProvider<T>Future 기반 비동기자동 재빌드반환형 AsyncValue<T>
StreamProvider<T>스트림 구독자동 재빌드마지막 값 캐시·테스트 Override 용이
NotifierProvider/AsyncNotifierProvider코드 생성 기반 동기·비동기 Notifierbuild()·update()최신 권장 API

a. Provider

import 'package:riverpod/riverpod.dart';

final configProvider = Provider<Config>((ref) {
  return Config(baseUrl: 'https://api.example.com');
});

위 패키지에서 제공하는 공급자 객체를 인스턴스화 하고 이를 변수에 저장해야한다.
값은 읽기 전용. 다른 Provider에서 ref.watch(configProvider)로 주입.
내부적으로 캐시되어 같은 값을 여러 번 요청해도 새로 생성되지 않는다.
단순 값이나 객체를 제공하며, 외부에서 값을 직접 변경할 수 없다.

그리고 이 공금자 객체는 반드시 최소 1개의 함수를 매개변수로 갖는다.
그리고 또 그 함수는 ProviderRef를 매개변수로 받는다. 이때 이름 그대로 변수의 타입은 ProviderRef 타입이다.
그리고 return 값은 우리가 전역 state로 사용하고 싶은 값을 return하면 된다.

b. StateProvider

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

UI

ConsumerWidget build(...) {
  final count = ref.watch(counterProvider);

  return IconButton(
    onPressed: () => ref.read(counterProvider.notifier).state++,
    // 또는 간결하게 ref.read(counterProvider.notifier).update((state) => state + 1);
    icon: Text('$count'),
  );
}

int, bool, String, enum 등과 같은 단순하고 변경 가능한 상태를 관리할 때 사용한다.
예를 들어, 카운터 값, 체크박스 상태, 라디오 버튼 선택, 간단한 필터링 기준 등 직접적으로 값을 변경해야 할 때 유용하다.

notifier 속성을 통해 StateController에 접근하여 .state를 직접 변경함으로써 상태를 업데이트할 수 있다.
상태가 변경되면 이를 구독하는 위젯이 자동으로 리빌드된다.

1. 상태 모델 (entity)

class Todo {
  final String id;
  final String description;
  final bool completed;

  Todo({required this.id, required this.description, this.completed = false});

  // 새로운 상태를 생성하는 copyWith 메서드 (불변성 유지)
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

2. StateNotifier 정의

Todo 목록을 관리하는 로직

class TodoListNotifier extends StateNotifier<List<Todo>> {
  TodoListNotifier() : super([]); // 초기 상태는 빈 리스트

  void addTodo(String description) {
    state = [...state, Todo(id: DateTime.now().toString(), description: description)];
  }

  void toggleTodo(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id)
          todo.copyWith(completed: !todo.completed)
        else
          todo,
    ];
  }

  void removeTodo(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }
}

3. StateNotifierProvider 정의

final todoListProvider = StateNotifierProvider<TodoListNotifier, List<Todo>>((ref) {
  return TodoListNotifier();
});

4. UI에서 StateNotifierProvider 사용

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    // Todo 목록의 상태를 구독
    final todos = ref.watch(todoListProvider);
    // Notifier 인스턴스를 읽어와 메서드 호출
    final todoListNotifier = ref.read(todoListProvider.notifier);

    return Scaffold(
      appBar: AppBar(title: const Text('Todo List with StateNotifierProvider')),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          final todo = todos[index];
          return ListTile(
            title: Text(
              todo.description,
              style: TextStyle(
                decoration: todo.completed ? TextDecoration.lineThrough : null,
              ),
            ),
            trailing: Checkbox(
              value: todo.completed,
              onChanged: (_) => todoListNotifier.toggleTodo(todo.id),
            ),
            onLongPress: () => todoListNotifier.removeTodo(todo.id),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          todoListNotifier.addTodo('새로운 할 일 ${todos.length + 1}');
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

상태 업데이트는 StateNotifier 클래스 내부의 메서드를 통해 이루어진다.
상태는 불변 객체로 다루어지므로, 상태를 변경할 때는 항상 새로운 상태 객체를 생성하여 state = newState 형태로 할당해야 한다 (Dart의 Collection-if, Spread Operator, copyWith 활용).
StateProvider보다 복잡한 상태와 비즈니스 로직을 효율적으로 관리할 수 있다.

c. StateNotifierProvider

복잡한 비즈니스 로직을 포함하는 변경 가능한 상태를 관리할 때 사용.
상태는 불변(immutable) 객체로 관리하고, 상태 변경은 StateNotifier 클래스 내부의 메서드를 통해서만 이루어지는 패턴을 따른다.

예를 들어 Todo 리스트 관리, 사용자 인증 상태, 장바구니 관리, 복잡한 폼 상태 등 여러 동작을 통해 상태가 변경되고, 상태 변경 로직이 비교적 복잡할 때 적합하다.

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);
  void increment() => state++;
}

final counterNotifierProvider =
    StateNotifierProvider<CounterNotifier, int>(CounterNotifier.new);

로직 캡슐화와 테스트 용이성 향상.

d. FutureProvider

비동기 작업(Future)으로 값을 가져올 때 사용한다.
주로 네트워크 요청, 데이터베이스 조회 등 시간이 걸리는 작업을 처리하고 그 결과를 UI에 표시해야 할 때 사용한다.

final jokeProvider = FutureProvider<Joke>((ref) async {
  return JokeService().getRandomJoke();
});

UI에서 AsyncValue 처리

final jokeAsync = ref.watch(jokeProvider);
return jokeAsync.when(
  loading: () => CircularProgressIndicator(),
  error: (e, _) => Text('오류: $e'),
  data: (j)   => Text(j.setup),
);

AsyncValue 한 번에 세 가지 상태 표현.

e. StreamProvider

시간에 따라 여러 개의 값을 지속적으로 방출하는 비동기 작업(Stream)을 관리할 때 사용한다.
실시간 데이터 스트림, 채팅 메시지, 타이머, 센서 데이터 등 연속적인 데이터 업데이트가 필요한 경우에 적합하다.

FutureProvider와 마찬가지로 로딩 중, 데이터 준비 완료, 에러 발생 상태를 자동으로 관리한다.

final chatStreamProvider = StreamProvider<List<String>>((ref) {
  return ChatSocket().messagesStream;    // Stream<List<String>>
});

final countdownProvider = StreamProvider<int>((ref) {
  return Stream.periodic(const Duration(seconds: 1), (count) => count)
      .take(10); // 0부터 9까지 10번만 방출
});

Firebase/웹소켓 실시간 데이터에 적합.

final countAsyncValue = ref.watch(countdownProvider);
child: countAsyncValue.when(
          loading: () => const Text('카운트다운 시작 중...'), // 로딩 중 (스트림 시작 전)
          error: (err, stack) => Text('에러 발생: $err'), // 에러 발생 시
          data: (count) => Text(
            '현재 카운트: $count', // 데이터 수신 시
            style: Theme.of(context).textTheme.headlineLarge,
          ),
        ),

지속적으로 새로운 값을 받을 때마다 이를 구독하는 위젯이 업데이된다.

f. 최신 Notifier API (codegen)


class TodoList extends _$TodoList {
  
  List<Todo> build() => [];

  void add(String desc) {
    state = [...state, Todo(desc)];
  }
}
flutter pub run build_runner build

실행 후 todo_list.g.darttodoListProvider 생성.
AsyncNotifier를 상속하면 build를 Future로 정의해 로딩·에러를 자동 관리.

g. NotifierProvider / AsyncNotifierProvider

Riverpod 2.0 버전 이상에서 도입된 최신 Provider들이다.
StateNotifierProviderFutureProvider/StreamProvider의 기능을 통합하고 개선하여, 더욱 강력하고 안전하며, 타입 추론 및 의존성 주입이 용이하도록 설계되었다.

NotifierProvider: 동기적으로 변경되는 복잡한 상태를 관리하며, StateNotifierProvider의 더 현대적인 대안이다.
AsyncNotifierProvider: 비동기적으로 변경되는 복잡한 상태 (AsyncValue)를 관리하며, FutureProviderStreamProvider보다 더 복잡한 비동기 로직(예: 캐싱, 동시 요청 처리, 실시간 데이터 업데이트와 사용자 액션을 결합)을 처리할 때 적합하다.

1. AsyncNotifier 정의 (사용자 정보 로드 및 업데이트 시나리오)

state는 AsyncValue<String> 타입이 된다.

class UserNotifier extends AsyncNotifier<String> {
  // AsyncNotifier 초기화. Future를 반환하여 초기 비동기 상태를 설정.
  
  Future<String> build() async {
    await Future.delayed(const Duration(seconds: 1));
    return '기본 사용자';
  }

  // 사용자 이름을 비동기적으로 변경하는 메서드
  Future<void> updateUserName(String newName) async {
    state = const AsyncLoading(); // 로딩 상태로 변경
    try {
      await Future.delayed(const Duration(seconds: 1)); // 비동기 작업 시뮬레이션
      state = AsyncData(newName); // 성공적으로 데이터 업데이트
    } catch (e) {
      state = AsyncError(e, StackTrace.current); // 에러 발생 시
    }
  }
}

2. AsyncNotifierProvider 정의

final userNotifierProvider = AsyncNotifierProvider<UserNotifier, String>(
  () => UserNotifier(),
);

3. UI에서 AsyncNotifierProvider 사용

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    // Notifier의 상태를 구독 (AsyncValue<String>)
    final userAsyncValue = ref.watch(userNotifierProvider);
    // Notifier 인스턴스를 읽어와 메서드 호출
    final userNotifier = ref.read(userNotifierProvider.notifier);

    return Scaffold(
      appBar: AppBar(title: const Text('AsyncNotifierProvider Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            userAsyncValue.when(
              loading: () => const CircularProgressIndicator(),
              error: (err, stack) => Text('에러 발생: $err'),
              data: (user) => Text(
                '현재 사용자: $user',
                style: Theme.of(context).textTheme.headlineSmall,
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                userNotifier.updateUserName('새로운 사용자 (${DateTime.now().second})');
              },
              child: const Text('사용자 이름 변경'),
            ),
          ],
        ),
      ),
    );
  }
}

build 메서드를 통해 Provider가 초기화될 때의 상태를 정의한다.
state 속성에 직접 AsyncData, AsyncLoading, AsyncError를 할당하여 상태를 수동으로 제어할 수 있다.
NotifierProviderAsyncNotifierProvider와 유사하지만 build 메서드가 Future를 반환하지 않고, stateAsyncValue가 아닌 일반 값을 직접 관리한다.


state 값 사용하기

  • 이떄 가장 큰 변화는 바로 StatefulWidet => ConsumerStatefulWidet 이라고 변환하면 된다. 이때 StatelessWidget => ConsumerWidget 이다.

  • 이렇게 바꿔주면 비로소 provider를 사용할 준비가 되었다.
    바로 build 함수에서 사용가능하다. 마치 StatefulWidet에서 widet 객체값으로 다양한 속성에 접근했던 것 처럼 ref 객체로 provider에 접근가능하다. => CunsumerState 클래스니까

read, watch

  • 그리고 공식문서에서는 읽어오는 기능을 하는 read와 watch 중 watch를 더 강권한다. 왜냐하면 로직이 변하더라도 버그가 더 적기 때문이다.

  • 그리고 read, watch 메서드 둘다 공급자를 매개변수로 넣어줘야하고 이때 import한 provider 객체를 넣어주면 된다.

StateNotifierProvider

  • 앞서 사용했던 그냥 Provider는 state 값이 변하지 않을 때 사용하는 값이다. 만약 state 값이 변한다면 StateNotifierProvider객체를 사용해야한다.

  • 그리고 이 StateNotifierProvider는 항상 StateNotifier와 함께 사용된다. 마치 StatefulWidget이 State class와 함께 사용되던 것 처럼 말이다.

  • 그래서 StateNofifier를 상속받는 클래스는 사실 아무렇게나 이름을 지어도 되지만 전통적으로 Notifier라는 접미어는 반드시 붙어있어야한다.
    그리고 StateNofifier에 들어갈 데이터 타입은 Riverpod에게 어떤 종류의 데이터가 StateNofifier에 의해 관리되고 StateNotifierProvider에 의해 최종적으로 반환되는 값이어야한다.

  • 위 코드에서는 생성자 함수에 아무것도 선언하지 않고 다만 colon : 으로 initializing을 해줬다. 이때 StateNotifier에 StateNotifierProvider에서 반환할 타입을 넣주는데 이때 초기화도 마찬가지로 해당 타입에 맞춰서 초기화를 해주면 된다.

StateNotifier

  • 하지만 위에서 메서드로 값을 임의로 변경할 수 없다. 따라서 2가지 방법이 있는데

  • StateNotifier가 갖는 데이터 타입은 앞으로 해당 state에 다룰 데이터 타입을 넣어주면 된다.

  • 또한 StateNotifier는 기존의 컴포넌트 클래스의 생성자 함수 선언 방식과는 조금 다르기 때문에 유의깊게 살펴봐야한다.

  • 그리고 위 사진에서는 빼먹은 것이 있는데 바로 super 메서드 내부에 초기화해주는 값에 const 키워드를 빼먹었다. 이를 추가해줘야하는 이유는 river pod로 관리되는 state값들은 mutate 되면 안 되기 때문이다. 즉, 직접적으로 수정하면 안 되고 새로운 state 객체를 생성한 후 대체해야한다.
    따라서 혹시나 실수로 직접적으로 수정하려고 할 때 막아줄 수 있는 보험역할로서 const 키워드가 들어가는 것이다.
    이 문장은 밑에 setFilter 메서드가 state를 설정하는 방식을 보면 쉽게 이해할 수 있다.

state

  • 위 state 객체는 마치 widget과 ref와 비슷하며 StateNotifier가 제공하는 객체이다.
    그리고 state객체는 StateNotifier가 들어있는 데이터 타입의 값을 갖는다.
    그리고 이 state를 절대 직접 변환하면 안 된다. 오직 할당 = 연산만 가능하다.

  • 그래서 위 코드는 state에 .contains 메서드를 사용하여 매개변수로 들어온 값이 있다면 빼고 없다면 추가해주는 메서드이다.

  • 앞서 언급 했듯 state객체를 절대 직접변환하면 안 되기에 그래서 remove 메서드 대신 where 메서드를 사용한다.
    where 메서드는 항상 새 배열을 생성한다. => 기존 데이터를 건들지 않는다.
    참고로 where 메서드로 remove 함수와 같은 효과를 내는 메서드는 위 코드와 같다. where 함수는 주어진 배열에서 주어진 조건이 참인 것 만을 반환하기 때문이다.

  • 또한 ... spread 연산자로 기존에 값을 모두 가져오고 추가적으로 매개변수로 들어온 값을 넣어주기 위함이다.

  • 이때 타입이 ary가 아닌 Map이라면?!

  • 마찬가지로 spread 연산자를 하고 그 밑에 매개변수로 들어오는 값을 추가해주면 된다.

  • 이렇게 하면 비로소 edit class가 완성 되었다. 이제 실제 Provier 클래스와 연결하면 된다.

StateNotifierProvider 타입지정

  • 여기 StateNotifierProvider 공급자도 마찬가지로 함수를 매개변수로 받고 그 함수의 매개변수는 ref이다.
    그리고 반환 값은 당연 위에서 data를 edit 한 ~~Notifier 인스턴스가 들어와한다. 위의 Provider와 같은 원리이다. 실제 공급자가 데이터를 들고갈 데이터가 와야한다.

  • 그리고 반환값이 있다면 반환값에 대한 type을 지정해줘야한다.
    첫번째는 인스턴스의 타입이고 두번째는 인스턴스가 반환하는 데이터 타입을 넣어줘야한다.

  • 그리고 이렇게 지정해두면 다른 consumer에서 사용했을 때 보다 나은 경험을 제공할 것이다.

ConsumerWidget

  • 앞서 언급 했듯 StatelessWidget을 대체하는 위젯으로 이때 build 메서드에는 추가적으로 WidgetRef 타입의 ref 변수가 추가되어야한다.

  • 그리고 실행은 .read() 메서드를 사용하여 가져온다. watch는 여기서 적절하지 않다. 메서드를 사용하는데 메서드는 객체의 메서드이고 이를 watch 주시하는 것은 메모리 낭비이기 때문에 read를 공급자를 불러들인 다음 해당 메서드를 사용하면 된다.

  • 또한 initState와 같은 1번 실행되는 함수에서도 watch 함수를 사용할 필요가 없다. 왜? => 코드 자체가 1번만 실행되기 때문에

각 위젯 별 Provider 설정하기

고급 모디파이어 설명

.autoDispose: 리스너가 0개가 되면 상태 파기
.family: 외부 파라미터로 다중 인스턴스 생성
.keepAlive(): autoDispose 환경에서 특정 Provider 오래 유지
.overrideWith: 테스트/하위 Scope에서 Provider 교체

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글