Flutter
패키지
Riverpod

Riverpod

How To Install

flutter_riverpod (opens in a new tab)
riverpod_generator (opens in a new tab)
riverpod_annotation (opens in a new tab)
build_runner (opens in a new tab)

dependencies:
  flutter:
    sdk: flutter
  ...
  riverpod: ^2.3.6
  flutter_riverpod: ^2.3.6
  riverpod_annotation: ^2.1.1
 
 
dev_dependencies:
  ...
  riverpod_generator: ^2.2.3
  build_runner: ^2.4.4

Provier의 종류

각각 다른 타입을 반환해주고 사용 목적이 다름, 모든 Provider는 글로벌하게 선언됨.

Provider

  • 가장 기본 베이스가 되는 Provider
  • 아무 타입이나 반환 가능
  • Service, 계산한 값등을 반환할 때 사용
  • 반환값을 캐싱할 때 유용하게 사용 (빌드 횟수 최소화 가능)
  • 여러 Provider의 값들을 묶어서 한 번에 반환 값을 만들어낼 수 있음
Provider 종류반환 값사용 예제
Provider아무 타입데이터 캐싱
StateProvider아무 타입간단한 상태값 관리
StateNotifierProviderStateNotifier를 상속한 값 반환복잡한 상태값 관리
FutureProviderFuture 타입API 요청의 Future 결과값
StreamProviderStream 타입API 요청의 Stream 결과값

Notifier / AsyncNotifier Provider

riverpod이 2점대로 올라오면서 생긴것 같다. 공식 문서에는

ℹ️

NotifierProvider 는 Notifier 를 수신하고 노출하는 데 사용되는 공급자입니다.

와 같이소개가 되어있다. 이 때, 공식 문서에서 말하는 notifier란 프로그래밍에서 흔히 상태가 변할 때마다 모든 Observer에 알리는 객체라고 한다.

Notifier Code Generation

아래와 같이 \_$클래스명을 extends 해주고 @riverpod Annotation을 붙이고 Code Gen을 하면,

@riverpod
class Todos extends _$Todos {
  @override
  List<TodoModel> build() {
    return [];
  }
}

다음과 같이 Code가 생성된다.
Code Gen 결과 👇

// GENERATED CODE - DO NOT MODIFY BY HAND
 
part of 'todo_test.dart';
 
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
 
String _$todosHash() => r'02f02edc471b5c32f00c34fef375c727690da757';
 
/// See also [Todos].
@ProviderFor(Todos)
final todosProvider =
    AutoDisposeNotifierProvider<Todos, List<TodoModel>>.internal(
  Todos.new,
  name: r'todosProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : _$todosHash,
  dependencies: null,
  allTransitiveDependencies: null,
);
 
typedef _$Todos = AutoDisposeNotifier<List<TodoModel>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member

내가 궁금했던 부분은 이 부분이었다. 공식 문서에서 freezed를 사용하고 있길래 freezed에 의존성을 갖는건가? 하고 생각했는데 그건 아니었다.

간단한 사용법을 보자면 Todos class에 add 함수가 있다고 가정할 때,

@riverpod
class Todos extends _$Todos{
  ...
  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  ...
}

Todo를 생성하는 로직을 만들고,

⚠️

여기서 중요한 것은 .notifier로 provider를 통해서 class 내부에 있는 메서드를 동작시켜줘야 한다는 것이다.
그래야 변경사항이 있는 것에 대해서 리빌드 작업이 일어나기 때문이다.

floatingActionButton: FloatingActionButton(
  onPressed: () {
    ref.read(todosProvider.notifier).addTodo(
          TodoModel(title: "Some Todo", description: "Hello Todo"),
        );
  },
  child: const Icon(Icons.plus_one),
),

해당 provider를 build 내에서 watch 해줘야 한다.

@override
Widget build(BuildContext context, WidgetRef ref) {
  List<TodoModel> todos = ref.watch(todosProvider);
  return Scaffold(
    floatingActionButton: FloatingActionButton(
      onPressed: () {
        ...add logic
      },
      child: const Icon(Icons.plus_one),
    ),
    body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(todos[index].title),
            subtitle: Text(todos[index].description),
          );
    }),
  );
}
전체 코드
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
 
part 'todo_test.g.dart';
 
class TodoTest extends ConsumerWidget {
  static final String routeName = "todo_test";
  const TodoTest({super.key});
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    List<TodoModel> todos = ref.watch(todosProvider);
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(todosProvider.notifier).addTodo(
                TodoModel(title: "Some Todo", description: "Hello Todo"),
              );
        },
        child: const Icon(Icons.plus_one),
      ),
      body: ListView.builder(
          itemCount: todos.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(todos[index].title),
              subtitle: Text(todos[index].description),
            );
      }),
    );
  }
}
 
@riverpod
class Todos extends _$Todos {
  @override
  List<TodoModel> build() {
    return [];
  }
 
    void addTodo(TodoModel todo) {
    state = [...state, todo];
  }
 
}
 
class TodoModel {
  final String title;
  final String description;
  final bool isDone;
 
  TodoModel({
    required this.title,
    required this.description,
    this.isDone = false,
  });
 
  TodoModel copyWith({
    String? title,
    String? description,
    bool? isDone,
  }) =>
      TodoModel(
        title: title ?? this.title,
        description: description ?? this.description,
        isDone: isDone ?? this.isDone,
      );
}

StateProvider

  • UI에서 "직접적으로" 데이터를 변경할 수 있도록 하고 싶을 때 사용
  • 단순한 형태의 데이터만 관리 (int, double, String 등)
  • Map, List 등 복잡한 형태의 데이터는 다루지 않음
  • 복잡한 로직이 필요한 경우 사용하지 않음 (number++ 정도의 간단한 로직으로만 한정)
final numberProvider = StateProvider<int>((ref) => 0);

사용 방법

예제 코드
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_sample/layout/default_layout.dart';
import 'package:riverpod_sample/riverpod/state_provider.dart';
 
class StateRiverpodScreen extends ConsumerWidget {
  const StateRiverpodScreen({Key? key}) : super(key: key);
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // watch, read, listen만 씀
    final provider = ref.watch(numberProvider);
 
    return DefaultLayout(
      title: 'State Riverpod Screen', 
      body: SizedBox(
        width: MediaQuery.of(context).size.width,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              provider.toString(),
            ),
            ElevatedButton(
                onPressed: () {
                  ref.read(numberProvider.notifier).update((state) => state + 1);
                },
                child: Text('Up')),
            ElevatedButton(
                onPressed: () {
                  ref.read(numberProvider.notifier).state = ref.read(numberProvider.notifier).state - 1;
                },
                child: Text('Down')),
            ElevatedButton(
                onPressed: () {
                  Navigator.of(context).push(
                      MaterialPageRoute(builder: (context) => _NextScreen()));
                },
                child: Text('Push'))
          ],
        ),
      ),
    );
  }
}
 
class _NextScreen extends ConsumerWidget{
  const _NextScreen({super.key});
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final provider = ref.watch(numberProvider);
    return DefaultLayout(
      title: 'Next Screen', 
      body: SizedBox(
        width: MediaQuery.of(context).size.width,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              provider.toString(),
            ),
            ElevatedButton(
                onPressed: () {
                  ref.read(numberProvider.notifier).update((state) => state + 1);
                },
                child: Text('Up'))
          ],
        ),
      ),
    );
  }
}

StateNotifierProvider

참조
  • StateProvider와 마찬가지로 UI에서 직접적으로 데이터를 변경할 수 있도록 하고 싶을 때 사용
  • 복잡한 형태의 데이터 관리 가능 (클래스의 메소드를 이용한 상태 관리)
  • StateNotifier를 상속한 클래스를 반환

class 형태로 선언해서 사용 함

model 선언

class ShoppingItemModel {
  final String name; // 이름
  final int quantity; // 갯수
  final bool hasBought; // 구매 했는지
  final bool isSpicy; // 매운지;
  ShoppingItemModel(
      {required this.name,
      required this.quantity,
      required this.hasBought,
      required this.isSpicy});
}

StateNotifier 상속받은 class 선언

class ShoppingListNotifier extends StateNotifier<List<ShoppingItemModel>> {
  ShoppingListNotifier()
      : super([
          // ShoppingListProvider 초기화
          // ShoppingListProvider 선언시 해당하는 값들을 처음에 사용할 수 잇음
          ShoppingItemModel(
              name: '김치', quantity: 3, hasBought: false, isSpicy: true),
          ShoppingItemModel(
              name: '라면', quantity: 5, hasBought: false, isSpicy: true),
          ShoppingItemModel(
              name: '삼겹살', quantity: 10, hasBought: false, isSpicy: false),
          ShoppingItemModel(
              name: '수박', quantity: 2, hasBought: false, isSpicy: false),
          ShoppingItemModel(
              name: '카스테라', quantity: 7, hasBought: false, isSpicy: false),
        ]);
 
  void toggleHasBought({required String name, }) {
    // state는 StateNotifier에 자동으로 제공됨
    state = state
        .map((e) => e.name == name
            ? ShoppingItemModel(
                name: e.name,
                quantity: e.quantity,
                hasBought: !e.hasBought,
                isSpicy: e.isSpicy)
            : e)
        .toList();
  }
}

Provider로 만들어 주기

// Generic에는 어떤 StateNotifier를 상속한 클래스를 쓸건지넣어주고 해당 Class가 관리하는 상태의 type을 두 번째 Generic으로 넣어줌
final shoppingListNotifier = 
    StateNotifierProvider<ShoppingListNotifier, List<ShoppingItemModel>>((ref) => 
        ShoppingListNotifier());

사용방법

stateless 위젯이 상속받고 있는 위젯을 ConsumerWidget으로 변경해주고 BuildContext 옆에 WidgetRef 추가해줘야 함.

class Test extends ConsumerWidget{ // 여기 변경
  const Test({super.key});
 
  @override
  Widget build(BuildContext context, WidgetRef ref) { // 여기 변경
    return Container();
  }
}

사용 방법은 stateProvider랑 크게 다를바는 없음.

// 참조하기 
final List<ShoppingItemModel> state = ref.watch(shoppingListNotifier);
// StateNotifier 안에 있는 함수 사용 ex) onPressed 함수 안에 사용
ref.read(shoppingListNotifier.notifier).toggleHasBought(name: e.name);

FutureProvider

  • Future 타입만 반환가능
  • API 요청의 결과를 반환할 때 자주 사용
  • 복잡한 로직 또는 사용자의 특정 행동뒤에 Future를 재실행하는 기능이 없음

사용 방법

선언 :

final multipleFutureProvider = FutureProvider((ref) async {
  await Future.delayed(Duration(seconds: 2));
 
  //throw Exception('Error 입니다.');
 
  return [1,2,3,4,5];
});

maybeWhen()

AsyncValue().maybeWhen(
  data: (data) {
    데이터가 들어왓을 때 실행될 코드 
    여기서 data는 Future가 벗겨진 값
  }, onElse: () {
    데이터가 아직 들어오지 않았을 때 실행될 코드
  }
)
ℹ️

AsyncValue()는 FutureProviderFamily에 extends 되어 있는 추상 클래스이며, FutureProvider를 watch하거나 read 했을 때 return 되는 타입이다.

사용 :
Provider를 불러오고 .when을 통해서 사용해주는게 특이한데 이는 StreamProvider에서도 똑같음.

class FutureProviderScreen extends ConsumerWidget{
  const FutureProviderScreen({super.key}); // (1)
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(multipleFutureProvider);
 
    return DefaultLayout(
      title: 'FutureProviderScreen', 
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          // date - 로딩이 끝나서 데이터가 있을 때 / error - 에러가 있을 때 / loading - 로딩 중일 때 실행
          state.when(data: (date) {
                  return Text(
                    date.toString(),
                    textAlign: TextAlign.center,
                  );
                },
                error: (err, Stack) => Text(err.toString()),
                loading: (() => const Center(child: CircularProgressIndicator())))
        ],
      )
    );
  }
}

StreamProvider

  • Stream 타입만 반환가능
  • API 요청의 결과를 Stream으로 반환할 때 자주 사용 (Socker 등)
final multipleStreamProvider = StreamProvider<List<int>>((ref) async* {
  for (int i = 0; i < 10; i++) {
    await Future.delayed(Duration(seconds: 2));
 
    yield List.generate(3, (index) => index * i);
  }
});
class StreamProviderScreen extends ConsumerWidget{
  const StreamProviderScreen ({super.key});
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(multipleStreamProvider);
 
    return DefaultLayout(
      title: 'Stream Provier Screen',
      body: Center(
          child: state.when(
        data: (data) {
          return Text(data.toString());
        },
        error: (err, Stack) {
          return Text(err.toString());
        },
        loading: (() => CircularProgressIndicator()),
      )),
    );
  }
}

Family: 인자값 입력받기

선언하는 Provider 뒤에 fmaily 옵션을 붙여서 사용 한다. 이 때 Generic을 원래 선언하던 return type 외에 입력 받을 인자값의 type도 함께 선언함.

final familyModifierProvider = FutureProvider.family<List<int>, int>(
  (ref, data) async { // data는 인자값
    await Future.delayed(Duration(seconds: 1));
    return List.generate(5, (index) => data * index).toList();
  },
);

원래는 **watch / read(프로바이더이름)**과 같은 방법으로 선언을 했다면 familyModifier는 **watch / read(프로바이더이름(인자값))**과 같은 형태로 사용함.

final state = ref.watch(familyModifierProvider(5));

.autodispose : 캐시 삭제

provider는 최초 실행되고 나면 실행된 값을 가지고 있는데 autodispose는 메모리에서 실행 결과를 자동으로 dispose 해줘서 실행할 때마다 매번 실행되게 해주는 방법임.
사용방법은 다른 provider와 같음.

final autoDisposeModifierProvider = FutureProvider.autoDispose
  <List<int>>((ref) async { 
  await Future.delayed(Duration(seconds: 1));
  return [1,2,3,4,5];
 },
);

ChangeNotifierProvider

Provider 패키지에서 마이그레이션 용도

Riverpod Observer

리버팟 옵저버는 Dio의 interceptor와 같이 Provider가 생성, 변경, 삭제 될 때마다 실행되는 함수를 지정할 수 있음.
사용하기 위해서는 ProviderObserver를 상속받아야 한다.

class Logger extends ProviderObserver {
  @override
  // update 되었을 때 호출되는 Provider
  void didUpdateProvider(ProviderBase provider, Object? previousValue, Object? newValue, ProviderContainer container) {
    debugPrint('[Provider Updated] provider: $provider, previousValue: $previousValue, newValue: $newValue, container: $container');
  }
 
  @override
  // 프로바이더를 추가하면 불리는 함수
  void didAddProvider(ProviderBase provider, Object? value, ProviderContainer container) {
    debugPrint("[Provider Added] provider: $provider, value: $value, container: $container");
  }
  
  @override
  // 프로바이더가 삭제되면 불리는 함수
  void didDisposeProvider(ProviderBase provider, ProviderContainer container) {
    debugPrint("[Provider Disposed] provider: $provider, container: $container");
  }
}

Reference

Riverpod Observer Sample Commit (opens in a new tab)

Riverpod Debugger

Riverpod Debugger (opens in a new tab)

Code Generation

riverpod 공식 문서에는 code generation을 만든 이유를 아래 2가지로 설명한다.

  1. 어떤 프로바이더를 사용할지 고민할 필요가 없도록 하기 위해서 만들어짐
  2. Parameter를 그대로 일반 함수처럼 사용할 수 있도록하기 위해서 만들어짐

pubspec.yaml

Code Generation 기능이 추가 되었기 때문에 build_runnerriverpod_generator, riverpod_annotation을 추가해줘야 한다.

flutter_riverpod (opens in a new tab)
riverpod_generator (opens in a new tab)
riverpod_annotation (opens in a new tab)
build_runner (opens in a new tab)

dependencies:
  flutter:
    sdk: flutter
  ...
  riverpod: ^2.3.6
  flutter_riverpod: ^2.3.6
  riverpod_annotation: ^2.1.1
 
 
dev_dependencies:
  ...
  riverpod_generator: ^2.2.3
  build_runner: ^2.4.4

사용방법

사용 방법은 간단하다. 다음과 같이 일반 함수 위에 @riverpod을 붙여주고 인자에 생성 될(아직 생성 안됨) 예정인 Ref를 인자로 넣어주면 되는데 인자로 넣어줄 Ref의 명은 함수명의 첫 글자 대문자 + Ref으로 결정된다. 이러한 함수를 생성해준 후 code generation을 해주면 된다.

@riverpod
String gState(GStateRef ref) {
  return "Hello Code Generation";
}

그러면 아래와 같이 code generation 된 파일에 provider가 생성되며 해당하는 타입은 우리가 제공한 함수에 따라서 결정된다.

code_generation.g.dart
@ProviderFor(gState)
final gStateProvider = AutoDisposeProvider<String>.internal(
  gState,
  name: r'gStateProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : _$gStateHash,
  dependencies: null,
  allTransitiveDependencies: null,
);
 
typedef GStateRef = AutoDisposeProviderRef<String>;

With Go Router

part 'router.g.dart';
 
@riverpod
GoRouter goRouter(GoRouterRef ref) {
  return GoRouter(initialLocation: '/', 
  
  routes: [
    ShellRoute(
      // Scaffold 없으면 에러 발생
      builder: (context, state, child) => Scaffold(body: child,),
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => const HomeView(),
          routes: [
            GoRoute(
              path: 'detail',
              builder: (context, state) => const HomeDetailView(),
            ),
          ],
        ),
      ],
    ),
  ]);
}

Provider 사용방법

방법 1. update 사용

ref.read([specificProvider].notifier).update((state) => state + 1);

방법 2. state 값 직접 가져와서 변경

ref.read([specificProvider].notifier).state 
    = ref.read([specificProvider].notifier).state - 1;

.listen

Listen Provider는 선언시에 뭔가를 해주는게 아니라 값을 받을 때 .listen을 붙이는데 내가 이해한 바로는 프로바이더가 어떠한 동작이 있을 때 다음에 나올 값을 next에 넣어주고 현재의 값을 previous에 넣어주는 형식임.

final listenProvider = StateProvider<int>((ref) => 0,); // 선언
 
// previous는 현재값, next는 다음에 오는 값임
ref.listen<int>(listenProvider, (previous, next) { 
  if(previous != next) {
    controller.animateTo(next,);
  }
});
전체 예제 코드
class _ListProviederScreenState extends ConsumerState<ListenProviederScreen>
    with TickerProviderStateMixin {
  late final TabController controller;
 
  @override
  void initState() {
    super.initState();
    controller = TabController(
        length: 10, vsync: this, 
        initialIndex: ref.read(listenProvider)); 
  }
 
  @override
  Widget build(BuildContext context) {
    ref.listen<int>(listenProvider, (previous, next) {
      if(previous != next) {
        controller.animateTo(next,);
      }
    });
    
    return DefaultLayout(
      title: 'Listen Provieder Screen',
      body: TabBarView(
        physics: NeverScrollableScrollPhysics(), // scroll로 이동이 안되게 설정
        controller: controller,
        children: List.generate(
          10,
          (index) => Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(index.toString(),textAlign: TextAlign.center,),
              ElevatedButton(onPressed: (){
                ref.read(listenProvider.notifier).update((state) => state == 10 ? 10 : state + 1);
              }, child: Text('Next'),),
              ElevatedButton(onPressed: (){
                ref.read(listenProvider.notifier).update((state) => state == 0 ? 0 : state - 1);
              }, child: Text('Previous'),)
              ],
          ),
        ),
      ),
    );
  }
}

.select

final selectProvider = StateNotifierProvider<SelectNotifier, ShoppingItemModel>(
  (ref) => SelectNotifier(),
);
 
class SelectNotifier extends StateNotifier<ShoppingItemModel> {
  SelectNotifier() : super(
    ShoppingItemModel(name: '김치', quantity: 3, hasBought: false, isSpicy: true)
  );
 
  toggleHasBought() {
    state = state.copyWith(hasBought: !state.hasBought);
  }
 
  toggleIsSpicy() {
    state = state.copyWith(isSpicy: !state.isSpicy);
  }
}

provider 뒤에 .select를 붙여서 사용, 특정한 메서드의 값만 관측하고 싶을 때 사용하는 옵션.

// watch에 활용 
final state = ref.watch(selectProvider.select((value) => value.isSpicy));
// listen과 함께 활용
ref.listen(selectProvider.select((value) => value.hasBought), (previous, next) { 
  print("previoud : $previous, next : $next");
});

Provider 안에 Provider

Provider 안에서 Provider를 호출해서 사용할 수도 있음.

stf widget > consumer widget

State 앞에 Consumer 붙이면 됨.

class Test extends ConsumerStatefulWidget { // 여기 변경
  const Test({Key? key}) : super(key: key);
 
  @override
  //여기 변경
  ConsumerState<Test> createState() => _TestState(); 
}
 
class _TestState extends ConsumerState<Test> { // 여기 변경
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

Consumer Widget

Consumer Widget은 기본적으로 builder이다. 이지만 builder의 callback 함수에 context외에 refchild를 추가적으로 받는다는 점이 다르다.

CupertinoScaffold.showCupertinoModalBottomSheet(
  context: context,
  builder: (context) => Consumer(
    builder: (context, _consumerRef, child) {
      return TransactionDetailView(
        stream: _consumerRef.watch(transactionRepositoryProvider.notifier).getTransactionByPeriod(widget.date, widget.date),
        title: "",
      );
    }
  ),
);

위와 같은 방식으로 사용할 수 있다. ref가 없는 상위 위젯에서 하위 위젯에 ref를 주입시켜야 할 때 사용할 수 있지만 그다지 권고되는 방법은 아니다. (코드 재사용성 측면에서 ref를 사용하는 위젯을 하나의 ConsumerState나 ConsumerWidget으로 만들어서 사용하는 것이 좋다.)

.notifier의 의미

값을 참조할 때 provider 뒤에 .notifier를 붙이면 해당 class가 그대로 옴, 이를 통해서 해당 class 내부에 선언 된 함수에 바로 접근이 가능하게 됨.

Reference

riverpod sample code (opens in a new tab)
riverpod 공식문서 (opens in a new tab)
pvscode riverpod snippet (opens in a new tab)