Riverpod

Johnny·2024년 8월 17일
0

Riverpod?

Flutter 애플리케이션에서 상태 관리를 쉽게 관리할 수 있도록 도와주는 반응형 캐싱 프레임워크이다.

Riverpod은 상태관리를 위해 사용하는 주요 라이브러리인 Provider의 한계를 극복하고 개선된 기능을 제공하기 위한 목적으로 개발되었다.

Riverpod의 주요 특징은 다음과 같다.

  • 쉬운 전역 상태 관리 구현: Riverpod을 사용하면 전역 상태 관리를 쉽게 구현할 수 있다. 상태는 전역적으로 사용 가능하면서도 필요한 경우에만 초기화되며, 메모리 관리도 자동으로 이루어진다.
  • 의존성 주입: Riverpod은 종속성을 명확하게 관리할 수 있다.
  • AutoDispose: 더 이상 필요하지 않은 provider들을 자동으로 제거하여 메모리 관리를 해준다.
  • 테스트 용이성: 상태 관리 로직과 UI로직을 분리하여 관리하기 때문에 테스트 작성에 용이하다. 또 provider를 모킹할 수도 있어 다양한 테스트 시나리오를 쉽게 구성할 수 있다.
  • 유연성: Riverpod은 다양한 상태 관리 패턴을 지원하기 때문에 간단한 상태 관리부터 복잡한 상태 관리까지 대응할 수 있다.

provider?

Riverpod은 모든 provider를 선언적으로 정의하고, 타입 안정성을 제공하기 때문에 컴파일 타임에 오류를 발견할 수 있다.

provider의 종류로는 다음과 같다.

  • provider: Riverpod의 기본 요소로, 데이터를 제공하고 상태를 관리하며, 종속성을 주입하는 역할을 수행한다.
  • StateProvider: 단순한 상태를 관리하는 provider이다.
  • FutureProvider: 비동기 작업을 관리하고, Future객체를 반환하는 provider이다. API 호출이나, 데이터베이스 쿼리와 같은 비동기 작업에 사용된다.
  • StreamProvider: 스트림 데이터를 관리하는 provider이다. 데이터를 실시간으로 업데이트해야 할 경우에 사용한다.
  • StateNotifierProvider: 상태 변화가 복잡한 경우에 사용할 수 있는 provider이다. StateNotifier를 통해 상태를 관리하고, 변경사항을 전파할 수 있다.

설치

pubspec.yaml에 다음과 같이 의존성을 추가한 후 flutter pub get으로 다운로드한다.

name: my_app_name
environment:
  sdk: ">=3.0.0 <4.0.0"
  flutter: ">=3.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_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

Lint 활성화하기

Riverpod은 riverpod_lint 패키지를 제공한다. lint를 통해 코드 작성에 린트 규칙을 적용할 수 있다.
프로젝트에 analysis_options.yaml파일을 생성하고 다음의 내용을 추가한다.

analyzer:
  plugins:
    - custom_lint

위 내용을 추가하고 나면 코드 작성 시 Riverpod 관련 코드에서 lint가 경고를 안내한다.

lint에 대한 규칙은 아래의 riverpod_lint pub dev 페이지를 참고하자.
https://pub.dev/packages/riverpod_lint

Android Studio Plugins

Flutter Riverpod Snippets를 사용하여 간단한 스니펫으로 Riverpod 기능을 사용할 수 있다.

Hello, world!

Riverpod을 통해 상태 데이터를 사용하는 기본적인 방법은 다음과 같다.

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

part 'main.g.dart';

void main() {
  runApp(
    // 1. root 위젯에서 providers를 관리할 수 있게 전체 앱을 ProviderScope으로 감싼다. 
    // 이렇게하면 애플리케이션 전체에 Riverpod이 활성화된다.
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

// 2. provider를 생성한다.

String helloWorld(HelloWorldRef ref) {
  return "Hello, World!";
}

// 3. Riverpod에서 관리되는 ConsumerWidget을 확장한다.
class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final String word = ref.watch(helloWorldProvider);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text("example"),
        ),
        body: Center(
          child: Text(word),
        ),
      ),
    );
  }
}

위 코드를 작성하면 빨간 밑줄이 나타나는데, 터미널에 다음의 명령어를 입력하여 Code generator를 통해 main.g.dart 파일을 생성한다.

$ fvm flutter pub run build_runner watch
Deprecated. Use `dart run` instead.
Building package executable... 
Built build_runner:build_runner.
[INFO] Generating build script completed, took 159ms
[INFO] Setting up file watchers completed, took 6ms
[INFO] Waiting for all file watchers to be ready completed, took 201ms
[INFO] Reading cached asset graph completed, took 106ms
[INFO] Checking for updates since last build completed, took 607ms
[INFO] Running build completed, took 6ms
[INFO] Caching finalized dependency graph completed, took 37ms
[INFO] Succeeded after 46ms with 0 outputs (0 actions)
[INFO] ------------------------------------------------------------------------
[INFO] Starting Build
[INFO] Updating asset graph completed, took 3ms
[WARNING] source_gen:combining_builder on lib/main.dart:
main.g.dart must be included as a part directive in the input library with:
    part 'main.g.dart';
[INFO] Running build completed, took 8.3s
[INFO] Caching finalized dependency graph completed, took 59ms
[INFO] Succeeded after 8.4s with 519 outputs (1038 actions)
[INFO] ------------------------------------------------------------------------
[INFO] Starting Build
[INFO] Updating asset graph completed, took 2ms
[INFO] Running build completed, took 36ms
[INFO] Caching finalized dependency graph completed, took 67ms
[INFO] Succeeded after 107ms with 2 outputs (2 actions)

이후 프로젝트를 에뮬레이터에 실행할 수 있고, lib디렉토리 하위에 다음과 같이 main.g.dart 파일이 생성된다.

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'main.dart';

// **************************************************************************
// RiverpodGenerator
// **************************************************************************

String _$helloWorldHash() => r'2ed8016b6f9c8762482dd2701393dd3d69c48550';

/// See also [helloWorld].
(helloWorld)
final helloWorldProvider = AutoDisposeProvider<String>.internal(
  helloWorld,
  name: r'helloWorldProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : _$helloWorldHash,
  dependencies: null,
  allTransitiveDependencies: null,
);

typedef HelloWorldRef = AutoDisposeProviderRef<String>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
  • @riverpod:
    • 모든 provider는 @riverpod, @Riverpod()로 어노테이션을 부착해야 한다.
    • 이 어노테이션은 전역 함수나 클래스에 부착할 수 있고, 어노테이션을 통해 provider를 구성할 수 있다.
  • String helloWorld(...) {}:
    • 어노테이션이 부착된 함수의 이름에 따라 provider와 상호작용하는 방식이 결정된다. helloWorld함수의 이름에 따라 Code generator를 통해 helloWorldProvider변수가 생성된다.
    • 어노테이션이 부착된 함수는 첫번째 맥변수로 "ref"를 지정해야 한다. 그 외에도 제네릭을 포함하여 여러개의 매개변수를 정의할 수도 있다.
    • 정의한 함수는 provider를 처음 읽을 때 호출된다. 이후 읽기는 함수를 호출하지 않고, 캐싱된 값을 반환한다. (=캐싱 기능을 포함한다.)
  • Ref:
    • 다른 providers와 상호작용하기 위해e 사용되는 객체이다.
    • 함수명을 prefix로 가지며, 마지막 suffix로 Ref가 붙는다.
      • 예) Future<User> fetchUser(FetchUserRef ref) {}

사용 방법

Riverpod을 사용하여 네트워크 통신을 수행하는 간단한 앱을 만들어보며 각 요소들을 알아보자.

Provider Scope

가장먼저 내 앱에 Riverpod을 구성하기 위해서 root 위젯을 ProviderScope 위젯으로 감싸야 한다.

void main() {
	runApp(
    	ProviderScope(
        	child: MyApp(),
        ),
    );
}

ProviderScope 위젯으로 감싸면 모든 영역에서 provider에 접근할 수 있게 된다.

프로젝트 구조

Riverpod을 사용하는 주요 목적 중 하나는 복잡해져가는 앱의 비즈니스 로직과 UI로직의 분리와 간편한 상태 관리라 할 수 있다.
비즈니스 로직과 UI 로직을 분리하기 위해 다음과 같이 디렉토리를 생성하자.

  • libs
    • /providers
    • /models
    • /views

이제 각각 디렉토리에 해당하는 파일들을 생성하며 앱을 만들어보자.

provider

provider는 네트워크 통신과 같은 비즈니스 로직을 담당한다.
회원들의 프로필 목록을 불러오는 API가 있다고 가정하고 fetch 기능을 구현한다.

/// user_provider.dart
'package:riverpod_annotation/riverpod_annotation.dart';

part 'user_provider.g.dart';

// provider임을 명시하기 위해 @riverpod 어노테이션을 부착한다.

Future<List<Profile>> fetchProfiles(FetchProfilesRef ref) async {
  // 1. API를 통해 회원들의 프로필 목록을 불러온다.
  final response = await http.get(
    Uri.parse(
      'https://gist.githubusercontent.com/'
      'cafefarm-johnny/'
      'ad6ca270049e3fbd24be0cae2e20fd6d/'
      'raw/'
      'e20114cc476ddb54cdc33bf2223dfc55c74074a3/'
      'chat_app_peoples_data.json',
    ),
  );

  if (kDebugMode) {
    print('status code: ${response.statusCode}');
    print('response body: ${response.body}');
  }

  // 2. 불러온 데이터를 데이터 형식에 맞게 적절한 자료구조로 파싱한다.
  final json = jsonDecode(response.body) as Map<String, dynamic>;
  final rawProfiles = json['Johnny'] as List<dynamic>;

  if (rawProfiles.isEmpty) {
    return List.empty();
  }

  // 3. raw 데이터를 모델 데이터로 변환하여 반환한다.
  return rawProfiles.map((rawProfile) => Profile.fromJson(rawProfile)).toList();
}
  • @riverpod 어노테이션을 부착한 함수에서 반환되는 값에 대해 상태 관리를 시작한다.

@riverpod 어노테이션은 top-level 함수에만 부착이 가능하다. 따라서 top-level이 클래스인 경우 클래스 내부의 메서드에는 부착할 경우 오류가 발생한다.

Model

네트워크로 조회해온 데이터를 바인딩할 모델을 정의한다.

class Profile {
  const Profile({
    required this.url,
    required this.nickname,
    required this.statusMessage,
  });

  final String url;
  final String nickname;
  final String statusMessage;

  factory Profile.fromJson(Map<String, dynamic> json) {
    return Profile(
      url: json['url'] as String,
      nickname: json['nickname'] as String,
      statusMessage: json['statusMessage'] as String,
    );
  }
}

Widget

불러온 정보를 ListView로 간단하게 보여주는 UI를 작성하자.

  • 통신 과정 시에는 로딩 인디케이터를 노출한다.
  • 오류가 발생하면 화면에 오류 메시지를 노출한다.
  • 통신에 성공하여 데이터를 받아오면 프로필 목록을 스크롤 가능한 ListView로 렌더링한다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class UserList extends StatelessWidget {
  const UserList({super.key});

  
  Widget build(BuildContext context) {
    // 1. Consumer 위젯을 통해 Ref에 접근하고
    return Consumer(
      builder: (context, ref, child) {
        // 2. 필요로 하는 provider를 호출한다.
        final AsyncValue<List<Profile>> profiles = ref.watch(
          fetchProfilesProvider,
        );

        return Center(
          // 3. 패턴매칭을 사용하여 각 상태에 따른 위젯을 반환한다.
          child: switch (profiles) {
            // 3-1. API 통신에 성공했다면 ListView를 반환한다.
            AsyncData(:final value) => ListView.builder(
                itemCount: value.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    leading: CircleAvatar(
                      backgroundImage: NetworkImage(value[index].url),
                    ),
                    title: Text(value[index].nickname),
                    subtitle: Text(value[index].statusMessage),
                  );
                },
              ),
            // 3-2. API 통신에 실패했다면 에러 메시지를 반환한다.
            AsyncError() => const Text('Error occur!'),
            // 3-3. 통신 과정 중이라면 로딩 인디케이터를 반환한다.
            _ => const CircularProgressIndicator(),
          },
        );
      },
    );
  }
}

이제 앱을 실행해보면 다음과 같이 확인할 수 있다.

위 과정을 수행하여 앱을 실행해보며 테스트해보면 몇 가지 특징을 알 수 있다.

  • Consumer는 provider가 업데이트될 때 마다 re-build를 수행한다.
  • provider는 Lazy 형태로 동작한다. 따라서 해당 provider를 사용하는 위젯이 없다면 네트워크 요청이 수행되지 않는다.
  • 요청이 수행된 후 결과는 캐싱되어 이후 provider를 호출하더라도 네트워크 요청이 수행되지 않는다.
  • UI가 provider를 사용하지 않으면 캐시는 삭제된다. 즉, 다시 UI가 provider를 사용할 때 네트워크 요청이 수행된다.
  • 통신에 오류가 발생할 경우 provider 내부에서 오류를 처리하고 AsyncError를 반환한다.

Consumer

UI로직에서 provider를 통해 비즈니스 로직을 호출하기 위해서는 WidgetRef라는 객체를 필요로 한다. provider를 정의할 때는 ref 객체를 매개변수로 정의하기 때문에 접근할 수 있다. UI에서는 ref 객체에 어떻게 접근할 수 있을까?

Riverpod은 WidgetRef 객체에 접근하기 위해 Consumer라는 위젯을 제공한다. Consumer 위젯으로 렌더링할 위젯을 감싸면 ref 객체에 접근할 수 있게되고, 이를 통해 provider에 접근할 수 있게 된다.


class Consumer extends ConsumerWidget {
  /// {@macro riverpod.consumer}
  const Consumer({super.key, required ConsumerBuilder builder, Widget? child})
      : _child = child,
        _builder = builder;

  final ConsumerBuilder _builder;
  final Widget? _child;

  
  Widget build(BuildContext context, WidgetRef ref) {
    return _builder(context, ref, _child);
  }
}

위의 View에서 작성했던 코드를 다시 보자.


Widget build(BuildContext context) {
  // 1. Consumer 위젯을 통해 Ref에 접근하고
  return Consumer(
    builder: (context, ref, child) {
      // 2. 필요로 하는 Provider를 호출한다.
      final AsyncValue<List<Profile>> profiles = ref.watch(
          fetchProfilesProvider,
      );

      return Center(...);
    },
  );
}

정리하면

  • 위젯에서 Provider를 호출하기 위해서는 WidgetRef 객체에 접근해야 한다.
  • WidgetRef객체에 접근할 수 있게 Consumer 위젯을 제공한다.

ConsumerWidget

Consumer위젯은 WidgetRef에 접근하기 위해 제공되는 위젯이지만 다른 위젯을 감싸서 사용하기 때문에 들여쓰기가 깊어진다는 문제가 발생한다.
이를 해결하기 위해 ConsumerWidget, ConsumerStatefulWidget을 제공한다. 이 위젯들은 StatelessWidget, StatefulWidgetConsumer 위젯을 결합한 위젯이다.

사용 방법은 다음과 같다.

// 1. StatelessWidget 대신 ConsumerWidget을 확장한다.
class UserList extends ConsumerWidget {
  const UserList({super.key});

  // 2. Consumer를 통해 Ref에 접근했던 방식에서 
  // build 메서드를 오버라이딩하며 접근할 수 있게 되었다.
  
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue<List<Profile>> profiles = ref.watch(
      fetchProfilesProvider,
    );

    // UI 로직은 변경 없이 Consumer만 제거되었다.
    return Center(...);
  }
}

이전과 다르게 extends StatelessWidgetextends ConsumerWidget으로 변경되었고, build()메서드 내부에 Consumer 위젯이 제거되어 들여쓰기가 줄어들었다.

ConsumerStatefulWidget

마찬가지로 StatefulWidget에 해당하는 위젯은 ConsumerStatefulWidget을 사용하면 된다.

// 1. StatefulWidget 대신 ConsumerStatefulWidget을 확장한다.
class UserList extends ConsumerStatefulWidget {
  const UserList({super.key});

  
  ConsumerState createState() => _UserListState();
}

// 2. State 대신 ConsumerState를 확장한다.
class _UserListState extends ConsumerState<UserList> {
  
  void initState() {
    super.initState();

    // 3. 라이프사이클 과정에서 ConsumerState의 프로퍼티로써 WidgetRef에 접근할 수 있다. 
    ref.listenManual(fetchProfilesProvider, (previous, next) {
      // provider에 리스너를 등록하여 스낵바 등을 구현할 수 있다.
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('최신 친구 목록을 조회했습니다.'),
        ),
      );
    });
  }

  // 3. ConsumerStatefulWidget에서는 Ref가 build 메서드의 매개변수로 제공되지 않고,
  // ConsumerState의 프로퍼티로써 접근할 수 있다.
  
  Widget build(BuildContext context) {
    final AsyncValue<List<Profile>> profiles = ref.watch(
      fetchProfilesProvider,
    );

    return Center(...);
  }
}

Notifier

지금까지는 외부에서 데이터를 가져오고 상태 데이터를 생성하는 과정이었다. 하지만 앱은 데이터를 가져오기만 할 뿐 만 아니라 수정, 삭제등의 행위를 통해 데이터를 변경하며 UI를 갱신해야 한다.

UI 정보를 갱신하기 위해서는 Consumer위젯이 상태 데이터를 바라보며 상태 데이터의 변경을 감지할 수 있어야 한다.

provider는 관심사 분리를 보장하기 위해 자신의 상태 데이터를 변경할 수 있는 방법을 Consumer로 제공하지 않는다. 대신 Notifier를 통해 상태 데이터를 변경할 수 있는 방법을 제공한다.

이전에 작성한 top-level 함수가 정의되어 있던 provider를 top-level 클래스로 변경하여 Notifier를 확장해야 한다.

part 'user_provider.g.dart';

/// 네트워크 처리는 비즈니스 로직에 해당한다.
/// Riverpod에서 비즈니스 로직은 provider 내부에 작성한다.

// 1. provider를 class로 승격하여 Notifier로 변경한다.

class UserNotifier extends _$UserNotifier {
  // 2. 이전에 있었던 fetchProfilesProvider 데이터 fetch 로직을 build 메서드로 이동한다.
  
  Future<List<Profile>> build() async {
    return await _fetchProfiles();
  }

  Future<List<Profile>> _fetchProfiles() async {
    ...
  }
}
  • Notifier:
    • @riverpod 어노테이션을 클래스 레벨에 부착하면 클래스를 Notifier로 승격된다.
    • 클래스는 반드시 _$Notifier를 확장해야 한다.
    • Notifier는 provider의 상태를 수정하는 메서드를 제공해야 한다.
    • Consumer는 WidgetRef.read(provider.notifier).methodName()를 사용하여 액세스 할 수 있다.
  • build():
    • Notifier는 반드시 build메서드를 override 해야 한다.
    • build 메서드는 절대 직접 호출하면 안된다.

⚠️ 주의사항
notifier 생성자에서는 ref 및 기타 프로퍼티에 접근할 수 없기 때문에 생성자 내부에 로직을 작성하면 예외가 발생한다.

class MyNotifier extends $_MyNotifier {
  MyNotifier() {
    // ❌ 이렇게 하지 마세요.
    // 이 경우 예외가 발생합니다.
    state = AsyncValue.data(42);
  }
  
  Result build() {
    // ✅ 대신 이렇게 하세요.
    state = AsyncValue.data(42);
  }
}

상태 변수 갱신하기

위의 앱에서 floating 버튼을 하나 생성하고 클릭하면 새로운 사용자가 목록에 추가되도록 사용자를 추가하는 기능을 구현하며 상태 변수를 갱신하는 방법을 알아보자.

가장 먼저 Notifier에 프로필 추가 기능을 구현하자.

part 'user_provider.g.dart';


class UserNotifier extends _$UserNotifier {
  // 1. 외부 데이터 베이스를 흉내내기 위해 데이터 저장 변수를 선언한다.
  final List<Profile> _db = [];

  
  Future<List<Profile>> build() async {
    return await _fetch();
  }

  Future<List<Profile>> _fetch() async {
    ...

    if (rawProfiles.isEmpty) {
      return List.empty();
    }

    // 3. build 메서드가 re-build될 때 데이터가 갱신될 수 있도록 
    // addAll() 메서드로 신규 데이터를 추가하여 반환한다.
    return rawProfiles
        .map((rawProfile) => Profile.fromJson(rawProfile))
        .toList()
      ..addAll(_db);
  }

  // 2. Create 로직을 추가한다.
  Future<void> addUser(Profile profile) async {
    // (저장) like 네트워크 통신 처리
    Future.delayed(const Duration(seconds: 3));
    _db.add(profile);

    // POST 요청이 완료되면 로컬 캐시를 Dirty 상태로 변경한다.
    // 로컬 캐시가 Dirty 상태로 변경되면 notifier의 build 메서드가 비동기적으로 재호출되며,
    // 리스너들에게 변경사항을 전달한다.
    ref.invalidateSelf();

    // 상태가 새로 갱신될 때 까지 대기한다.
    await future;
  }
}
  • ref.invalidateSelf():
    • 로컬 캐시의 데이터를 Dirty 상태로 변경한다.
    • 로컬 캐시가 Dirty로 변경되면 각 Consumer들은 로컬 캐시를 갱신하기 위해 re-build 라이프사이클을 수행한다.

신규 프로필 정보를 추가하는 Floating 버튼을 구현한다.

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

  
  ConsumerState<AddUserFloatingButton> createState() =>
      _AddUserFloatingButtonState();
}

class _AddUserFloatingButtonState extends ConsumerState<AddUserFloatingButton> {
  int _count = 0;

  void addUserRandomly(WidgetRef ref) {
    _count++;

    Profile newProfile = Profile(
      nickname: 'Jay$_count',
      statusMessage: '반가워요!!',
      url: 'https://via.placeholder.com/200/000000/FFFFFF/?text=Jay$_count',
    );

    // ref.read를 사용하여 notifier에 접근한다.
    // notifier에 접근하여 addUser 메서드를 호출한다.
    ref.read(userNotifierProvider.notifier).addUser(newProfile);
  }

  
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => addUserRandomly(ref),
      child: const Icon(Icons.add),
    );
  }
}

Notifier의 addUser()를 호출할 때 ref.watch가 아닌 ref.read를 호출한다.
ref.watch도 기술적으로는 동작할 수 있지만, onPressed와 같은 이벤트 핸들러에서 로직이 수행되는 경우에는 ref.read를 사용하는 것이 좋다.

build 메서드로 매개변수 전달하기

데이터를 조회해오는 API는 흔히 페이지네이션 매개변수를 가진다.

Consumer에서 Notifier 또는 provider로 매개변수를 전달해야 페이지네이션 처리가 가능해지는데 이를 어떻게 구현하는지 알아보자.

part 'user_provider.g.dart';


class UserNotifier extends _$UserNotifier {
  final List<Profile> _db = [];

  // 매개변수를 받고자 하면 그냥 build 메서드 시그니처에 매개변수를 선언하면 된다.
  
  Future<List<Profile>> build([int size = 0]) async {
    return await _fetch(size);
  }

  Future<List<Profile>> _fetch(int size) async {
    ...
  }
}

Notifier 또는 provider에서는 매개변수를 받고자하는 경우 해당하는 build 메서드나 함수에 매개변수를 정의하면 된다.

이후 UI에서 Notifier 또는 provider를 호출하는 로직에서 매개변수를 전달하면 된다.


Widget build(BuildContext context) {
  final AsyncValue<List<Profile>> profiles = ref.watch(
    userNotifierProvider(5), // 매개변수 전달
  );
}

여기서 캐싱과 관련된 몇가지 특징이 나타나는데 그것은 다음과 같다.

  • 매개변수를 provider/Notifier에 전달할 때 매개변수 유형별로 캐시된다.
  • 예를 들어, size = 5 매개변수를 전달하면 네트워크 요청이 캐싱되어 이후 size = 5로 매개변수가 전달되면 캐싱된 응답을 전달한다.

Code Generator?

Code Generator는 내가 작성한 코드를 기반으로 Riverpod에 필요한 부가적인 코드들을 생산하는 도구이다.

예를 들어 아래와 같이 Notifier 구현 코드를 작성하면 Code Generator가 이를 읽고 해당하는 Dart 코드를 생성해준다.

part 'user_provider.g.dart'; // 반드시 Code Generator가 생성한 파일을 part 문법으로 선언해야지만 Riverpod 코드에 대해 컴파일러가 인식한다.

class UserNotifier extends _$UserNotifier {
  final List<Profile> _db = [];

  
  Future<List<Profile>> build([int size = 0]) async {
    return await _fetch(size);
  }

  Future<List<Profile>> _fetch(int size) async {
    ...
  }

  Future<void> addUser(Profile profile) async {
    ...
  }
}

위와 같이 짧게 작성한 코드가 실은 Dart 언어로 작성 시 아래와 같이 매우 긴 코드가 되어야 하는 것이다.

// user_provider.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'user_provider.dart';

// **************************************************************************
// RiverpodGenerator
// **************************************************************************

String _$userNotifierHash() => r'99ab39ac7d8c07e6876affb49c4ed9510089e9cc';

/// Copied from Dart SDK
class _SystemHash {
  _SystemHash._();

  static int combine(int hash, int value) {
    // ignore: parameter_assignments
    hash = 0x1fffffff & (hash + value);
    // ignore: parameter_assignments
    hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
    return hash ^ (hash >> 6);
  }

  static int finish(int hash) {
    // ignore: parameter_assignments
    hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
    // ignore: parameter_assignments
    hash = hash ^ (hash >> 11);
    return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
  }
}

abstract class _$UserNotifier
    extends BuildlessAutoDisposeAsyncNotifier<List<Profile>> {
  late final int size;

  FutureOr<List<Profile>> build([
    int size = 0,
  ]);
}

/// Copied from [UserNotifier].
(UserNotifier)
const userNotifierProvider = UserNotifierFamily();

/// Copied from [UserNotifier].
class UserNotifierFamily extends Family<AsyncValue<List<Profile>>> {
  /// Copied from [UserNotifier].
  const UserNotifierFamily();

  /// Copied from [UserNotifier].
  UserNotifierProvider call([
    int size = 0,
  ]) {
    return UserNotifierProvider(
      size,
    );
  }

  
  UserNotifierProvider getProviderOverride(
    covariant UserNotifierProvider provider,
  ) {
    return call(
      provider.size,
    );
  }

  static const Iterable<ProviderOrFamily>? _dependencies = null;

  
  Iterable<ProviderOrFamily>? get dependencies => _dependencies;

  static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;

  
  Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
      _allTransitiveDependencies;

  
  String? get name => r'userNotifierProvider';
}

/// Copied from [UserNotifier].
class UserNotifierProvider
    extends AutoDisposeAsyncNotifierProviderImpl<UserNotifier, List<Profile>> {
  /// Copied from [UserNotifier].
  UserNotifierProvider([
    int size = 0,
  ]) : this._internal(
          () => UserNotifier()..size = size,
          from: userNotifierProvider,
          name: r'userNotifierProvider',
          debugGetCreateSourceHash:
              const bool.fromEnvironment('dart.vm.product')
                  ? null
                  : _$userNotifierHash,
          dependencies: UserNotifierFamily._dependencies,
          allTransitiveDependencies:
              UserNotifierFamily._allTransitiveDependencies,
          size: size,
        );

  UserNotifierProvider._internal(
    super._createNotifier, {
    required super.name,
    required super.dependencies,
    required super.allTransitiveDependencies,
    required super.debugGetCreateSourceHash,
    required super.from,
    required this.size,
  }) : super.internal();

  final int size;

  
  FutureOr<List<Profile>> runNotifierBuild(
    covariant UserNotifier notifier,
  ) {
    return notifier.build(
      size,
    );
  }

  
  Override overrideWith(UserNotifier Function() create) {
    return ProviderOverride(
      origin: this,
      override: UserNotifierProvider._internal(
        () => create()..size = size,
        from: from,
        name: null,
        dependencies: null,
        allTransitiveDependencies: null,
        debugGetCreateSourceHash: null,
        size: size,
      ),
    );
  }

  
  AutoDisposeAsyncNotifierProviderElement<UserNotifier, List<Profile>>
      createElement() {
    return _UserNotifierProviderElement(this);
  }

  
  bool operator ==(Object other) {
    return other is UserNotifierProvider && other.size == size;
  }

  
  int get hashCode {
    var hash = _SystemHash.combine(0, runtimeType.hashCode);
    hash = _SystemHash.combine(hash, size.hashCode);

    return _SystemHash.finish(hash);
  }
}

mixin UserNotifierRef on AutoDisposeAsyncNotifierProviderRef<List<Profile>> {
  /// The parameter `size` of this provider.
  int get size;
}

class _UserNotifierProviderElement
    extends AutoDisposeAsyncNotifierProviderElement<UserNotifier, List<Profile>>
    with UserNotifierRef {
  _UserNotifierProviderElement(super.provider);

  
  int get size => (origin as UserNotifierProvider).size;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

Code Generator는 이러한 불편한 코드 작성을 편리하게 도와주어 코드 작성 시 개발자가 보일러플레이트를 작성하지 않게 도와준다. 뿐 아니라 아래와 같은 장점도 있다.

  • 상태를 가지는 핫 리로드(stateful hot-reload)를 사용할 수 있다.
  • 원활한 디버깅을 할 수 있도록 추가적인 메타데이터를 생성하고 디버거가 수집한다.
  • 일부 Riverpod 기능은 Code Generator를 통해서만 사용이 가능하다.

provider/Notifier 문법

Sync

Functional (provider)


String example(ExampleRef ref) {
  return 'foo';
}

Class-Based (Notifier)


class Example extends _$ExampleNotifier {
  
  String build() {
    return 'foo';
  }
}

Async/Future

Functional


Future<String> example(ExampleRef ref) async {
  return Future.value('foo');
}

Class-Based


class Example extends _$ExampleNotifier {
  
  Future<String> build() async {
    return Future.value('foo');
  }
}

Async/Stream

Functional


Stream<String> example(ExampleRef ref) async* {
  yield 'foo';
}

Class-Based


class Example extends _$ExampleNotifier {
  
  Stream<String> build() async* {
    yield 'foo';
  }
}

Provider vs Riverpod

정의 방식

pkg:Provider에서는 provider가 위젯이기 때문에 위젯 트리 안에 코드가 배치된다.

class Counter extends ChangeNotifier {
 ...
}

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider<Counter>(create: (context) => Counter()),
      ],
      child: MyApp(),
    )
  );
}

Riverpod에서 provider는 위젯이 아닌 Dart 객체이다. 그래서 위젯 트리 외부에서 정의된다.

// Provider는 최상위 변수
final counterProvider = ChangeNotifierProvider<Counter>((ref) => Counter());

void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

provider 호출 방식

pkg:Provider에서는 provider에 접근하기 위해 BuildContext를 사용한다.

class Example extends StatelessWidget {
  
  Widget build(BuildContext context) {
    Model model = context.watch<Model>();
    ...
  }
}

Riverpod에서는 StatelessWidget 대신 ConsumerWidget을 확장하며, BuildContext가 아닌 WidgetRef라는 별도의 객체를 통해 provider에 접근한다.

class Example extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    Model model = ref.watch(modelProvider);
    ...
  }
}

Riverpod은 제네릭 타입에 의존하지 않고, 정의된 provider를 통해 생성된 변수에 의존한다.

profile
배우면 까먹는 개발자 😵‍💫

0개의 댓글