주요 콘텐츠로 건너뛰기

Riverpod 3.0의 새로운 기능

Riverpod 3.0에 오신 것을 환영합니다!
이번 업데이트에는 오랫동안 미뤄 왔던 여러 기능과 버그 수정, 그리고 API 단순화가 담겨 있습니다.

이 버전은 더 단순하고 통일된 Riverpod로 나아가는 과도기입니다.

주의

이 버전에는 몇 가지 생명주기 변경이 포함되어 있습니다. 이 변경은 앱을 눈치채기 어려운 방식으로 망가뜨릴 수 있으니 신중하게 업그레이드하세요.
마이그레이션 가이드는 마이그레이션 페이지를 참고하세요.

주요 변경 사항은 다음과 같습니다.

오프라인 영속성 (실험적)​

정보

이 기능은 실험적이며 아직 안정화되지 않았습니다. 사용할 수는 있지만, 메이저 버전 업 없이도 API가 호환되지 않는 방식으로 바뀔 수 있습니다.

오프라인 영속성은 provider를 기기에 로컬로 캐시할 수 있게 해 주는 새로운 기능입니다. 그러면 앱을 종료했다가 다시 열었을 때 캐시에서 provider를 복원할 수 있습니다.
오프라인 영속성은 선택적으로 사용하는 기능이며, 코드 생성 사용 여부와 관계없이 모든 "Notifier" provider에서 지원됩니다.

Riverpod에는 데이터베이스와 상호작용하기 위한 인터페이스만 들어 있을 뿐, 데이터베이스 자체는 포함되어 있지 않습니다. 인터페이스를 구현하기만 하면 원하는 어떤 데이터베이스든 사용할 수 있습니다.
SQLite용 공식 패키지도 관리되고 있습니다: riverpod_sqflite.

간단한 데모로, 오프라인 영속성은 다음과 같이 사용합니다.

// 코드 생성 없이 JsonSqFliteStorage를 사용하는 예제입니다.
final storageProvider = FutureProvider<JsonSqFliteStorage>((ref) async {
// SQFlite를 초기화합니다. Storage 인스턴스는 provider 간에 공유해야 합니다.
return JsonSqFliteStorage.open(
join(await getDatabasesPath(), 'riverpod.db'),
);
});

/// 직렬화 가능한 Todo 클래스입니다.
class Todo {
const Todo({
required this.id,
required this.description,
required this.completed,
});

Todo.fromJson(Map<String, dynamic> json)
: id = json['id'] as int,
description = json['description'] as String,
completed = json['completed'] as bool;

final int id;
final String description;
final bool completed;

Map<String, dynamic> toJson() {
return {
'id': id,
'description': description,
'completed': completed,
};
}
}

final todosProvider =
AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);

class TodosNotifier extends AsyncNotifier<List<Todo>>{

FutureOr<List<Todo>> build() async {
// 'build' 메서드의 시작 부분에서 persist를 호출합니다.
// 그러면 다음과 같이 동작합니다.
// - 이 메서드가 처음 실행될 때 DB를 읽어 영속화된 값으로
// 상태를 갱신합니다.
// - 이 provider의 변경 사항을 구독하고, 변경될 때마다 DB에 씁니다.
persist(
// JsonSqFliteStorage 인스턴스를 전달합니다. Future를 "await"할 필요는 없습니다.
// Riverpod가 알아서 처리합니다.
ref.watch(storageProvider.future),
// 이 상태의 고유 키입니다.
// 다른 provider가 같은 키를 사용해서는 안 됩니다.
key: 'todos',
// 기본적으로 상태는 오프라인에 2일 동안만 캐시됩니다.
// 캐시 기간을 바꾸려면 다음 줄의 주석을 해제하면 됩니다.
// options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever),
encode: jsonEncode,
decode: (json) {
final decoded = jsonDecode(json) as List;
return decoded
.map((e) => Todo.fromJson(e as Map<String, Object?>))
.toList();
},
);

// 서버에서 할 일 목록을 비동기로 가져옵니다.
// await하는 동안에는 영속화된 할 일 목록을 사용할 수 있습니다.
// 네트워크 요청이 완료되면 서버 상태가
// 영속화된 상태보다 우선합니다.
final todos = await fetchTodos();
return todos;
}

Future<void> add(Todo todo) async {
// 상태를 수정할 때 변경 사항을 영속화하기 위한 추가 로직은 필요 없습니다.
// Riverpod가 새 상태를 자동으로 캐시하고 DB에 씁니다.
state = AsyncData([...await future, todo]);
}
}

Mutation (실험적)​

정보

이 기능은 실험적이며 아직 안정화되지 않았습니다. 사용할 수는 있지만, 메이저 버전 업 없이도 API가 호환되지 않는 방식으로 바뀔 수 있습니다.

Riverpod 3.0에는 "mutation"이라는 새로운 기능이 도입되었습니다.
이 기능은 두 가지 문제를 해결합니다.

  • UI가 "부수 효과"(폼 제출, 버튼 클릭 등)에 반응하여 로딩/성공/오류 메시지를 보여 줄 수 있게 해 줍니다. "폼 제출에 성공하면 토스트 띄우기" 같은 경우를 떠올리면 됩니다.
  • onPressed 콜백에서 Ref.read와 자동 폐기를 함께 사용할 때 부수 효과가 아직 진행 중인데도 provider가 폐기될 수 있던 문제를 해결합니다.

요약하자면, 새로운 Mutation 객체가 추가되었습니다. provider처럼 최상위 final 변수로 선언합니다.

final addTodoMutation = Mutation<void>();

그러면 UI에서 ref.listen/ref.watch로 mutation의 상태를 구독할 수 있습니다.

class AddTodoButton extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
// "addTodo" 부수 효과의 상태를 구독합니다
final addTodo = ref.watch(addTodoMutation);

return switch (addTodo) {
// 진행 중인 부수 효과가 없습니다
// 제출 버튼을 보여 줍니다
MutationIdle() => ElevatedButton(
// 클릭하면 부수 효과를 실행합니다
onPressed: () {
// TODO 코드 아래의 설명을 참고하세요
},
child: const Text('Submit'),
),
// 부수 효과가 진행 중입니다. 스피너를 보여 줍니다
MutationPending() => const CircularProgressIndicator(),
// 부수 효과가 실패했습니다. 재시도 버튼을 보여 줍니다
MutationError() => ElevatedButton(
onPressed: () {
// TODO 코드 아래의 설명을 참고하세요
},
child: const Text('Retry'),
),
// 부수 효과가 성공했습니다. 성공 메시지를 보여 줍니다
MutationSuccess() => const Text('Todo added!'),
};
}
}

마지막으로, onPressed 콜백 안에서 다음과 같이 부수 효과를 실행할 수 있습니다.

onPressed: () {
addTodoMutation.run(ref, () async {
// 여기서 부수 효과를 실행합니다.
// Notifier를 직접 읽어서 메서드를 호출할 수 있습니다.
await ref.read(todoListProvider.notifier).addTodo('New Todo');
});
}

자동 재시도​

3.0부터는 초기화 중에 실패한 provider가 자동으로 재시도됩니다. 재시도는 지수 백오프 방식으로 이루어지며, provider가 성공하거나 폐기될 때까지 계속됩니다. 네트워크 연결 끊김처럼 일시적인 문제로 작업이 실패했을 때 유용합니다.

기본 동작은 모든 오류를 재시도하며, 200ms 지연으로 시작해 재시도할 때마다 두 배씩 늘려 최대 6.4초까지 기다립니다.
ProviderContainer/ProviderScope에 retry 매개변수를 전달하면 모든 provider에 대해 이 동작을 바꿀 수 있습니다.

void main() {
runApp(
ProviderScope(
// 특정 오류는 건너뛰거나, 재시도 횟수에 제한을 두거나,
// 지연 시간을 바꾸는 등
// 재시도 로직을 원하는 대로 바꿀 수 있습니다
retry: (retryCount, error) {
if (error is SomeSpecificError) return null;
if (retryCount > 5) return null;

return Duration(seconds: retryCount * 2);
},
child: MyApp(),
),
);
}

또는 provider 생성자에 retry 매개변수를 전달해 provider별로 설정할 수도 있습니다.

final todoListProvider = NotifierProvider<TodoList, List<Todo>>(
TodoList.new,
retry: (retryCount, error) {
if (error is SomeSpecificError) return null;
if (retryCount > 5) return null;

return Duration(seconds: retryCount * 2);
},
);

Ref.mounted​

오랫동안 기다려 온 Ref.mounted가 드디어 추가되었습니다! BuildContext.mounted와 비슷하지만 Ref용입니다.

비동기 작업 이후에 provider가 아직 마운트되어 있는지 확인할 때 사용할 수 있습니다.

class TodoList extends Notifier<List<Todo>> {

List<Todo> build() => [];

Future<void> addTodo(String title) async {
// 새 할 일을 서버에 전송합니다
final newTodo = await api.addTodo(title);
// 비동기 작업 이후에
// provider가 아직 마운트되어 있는지 확인합니다
if (!ref.mounted) return;

// 마운트되어 있다면 상태를 갱신합니다
state = [...state, newTodo];
}
}

이 기능이 동작하려면 생명주기를 꽤 많이 바꿔야 했습니다.
생명주기 변경 섹션을 꼭 읽어 보세요.

제네릭 지원 (코드 생성)​

코드 생성을 사용할 때 이제 생성된 provider에 타입 매개변수를 정의할 수 있습니다. 타입 매개변수는 다른 provider 매개변수와 똑같이 동작하며, provider를 watch할 때 함께 전달해야 합니다.


T multiply<T extends num>(T a, T b) {
return a * b;
}

// ...

int integer = ref.watch(multiplyProvider<int>(2, 3));
double decimal = ref.watch(multiplyProvider<double>(2.5, 3.5));

일시 정지/재개 지원​

2.0에도 어느 정도의 일시 정지/재개 기능이 있었지만 상당히 제한적이었습니다. 3.0에서는 모든 ref.listen 리스너를 필요할 때 직접 일시 정지하거나 재개할 수 있습니다.

final subscription = ref.listen(
todoListProvider,
(previous, next) {
// 새 값으로 원하는 작업을 합니다
},
);

subscription.pause();
subscription.resume();

이와 함께, 이제 Riverpod는 여러 상황에서 provider를 일시 정지합니다.

  • provider가 더 이상 화면에 보이지 않으면 일시 정지됩니다 (TickerMode 기반).
  • provider가 다시 빌드될 때는 다시 빌드가 끝날 때까지 그 provider의 구독이 일시 정지됩니다.
  • provider가 일시 정지되면 그 provider의 모든 구독도 함께 일시 정지됩니다.

자세한 내용은 생명주기 변경 섹션을 참고하세요.

공개 API 통합​

Riverpod 3.0의 목표 중 하나는 API를 단순화하는 것입니다. 여기에는 다음이 포함됩니다.

  • 권장하는 것과 권장하지 않는 것을 명확히 드러내기
  • 불필요하게 중복된 인터페이스 제거하기
  • 모든 기능이 일관된 방식으로 동작하도록 하기

이를 위해 몇 가지가 변경되었습니다.

[StateProvider]/[StateNotifierProvider]와 [ChangeNotifierProvider]는 권장되지 않으며 다른 import로 옮겨졌습니다​

이 provider들은 제거된 것이 아니라 다른 import로 옮겨졌을 뿐입니다. 다음 대신:

import 'package:riverpod/riverpod.dart';

이제 다음을 사용해야 합니다.

import 'package:riverpod/legacy.dart';

이는 이 provider들을 더 이상 권장하지 않는다는 점을 드러내기 위함입니다.
동시에 하위 호환성을 위해 계속 유지됩니다.

AutoDispose 인터페이스가 제거되었습니다​

"auto-dispose" 기능이 제거된 것은 아닙니다. 인터페이스에만 해당하는 이야기입니다. 2.0에서는 auto-dispose를 위해 모든 provider, Ref, Notifier가 중복으로 존재했습니다( Ref와 AutoDisposeRef, Notifier와 AutoDisposeNotifier 등). 일부 엣지 케이스에서 컴파일 오류를 내기 위한 것이었지만, 그 대가로 API가 나빠졌습니다.

3.0에서는 인터페이스가 통합되었고, 이전의 컴파일 오류는 이제 (riverpod_lint를 사용하는) 린트 규칙으로 구현됩니다. 구체적으로는 AutoDisposeNotifier를 참조하는 곳을 모두 Notifier로 바꾸면 됩니다. 코드의 동작은 바뀌지 않습니다.

final provider = NotifierProvider.autoDispose<MyNotifier, int>(
MyNotifier.new,
);

- class MyNotifier extends AutoDisposeNotifier<int> {
+ class MyNotifier extends Notifier<int> {
}

"FamilyNotifier"와 "Notifier"가 하나로 합쳐졌습니다​

앞의 내용과 마찬가지로, FamilyNotifier와 Notifier 인터페이스도 이제 하나로 합쳐졌습니다.

간단히 말해, 다음 대신:

final provider = NotifierProvider.family<CounterNotifier, int, Argument>(
MyNotifier.new,
);

class CounterNotifier extends FamilyNotifier<int, Argument> {

int build(Argument arg) => 0;
}

이제는 이렇게 합니다.

final provider = NotifierProvider.family<CounterNotifier, int, Argument>(
CounterNotifier.new,
);

class CounterNotifier extends Notifier<int> {
CounterNotifier(this.arg);
final Argument arg;

int build() => 0;
}

즉 Notifier+FamilyNotifier+AutoDisposeNotifier+AutoDisposeFamilyNotifier를 구분하지 않고, 항상 Notifier 클래스를 사용합니다.

이 변경은 코드 생성에는 영향이 없습니다.

모든 것을 아우르는 단 하나의 Ref​

Riverpod 2.0에서는 provider마다 자체 Ref 서브클래스(FutureProviderRef, StreamProviderRef 등)가 있었습니다.
어떤 Ref에는 state 프로퍼티가, 어떤 것에는 future나 notifier 등이 있었습니다. 유용하긴 했지만, 얻는 것에 비해 복잡도가 너무 컸습니다. 그 이유 중 하나는 Notifier가 이미 그 추가 프로퍼티들을 갖고 있어서 인터페이스가 중복되었기 때문입니다.

3.0에서는 Ref가 통합되었습니다. Ref<T> 같은 제네릭 매개변수도, FutureProviderRef도 더 이상 없습니다. 오직 Ref 하나뿐입니다. 실제로는 생성된 provider의 문법이 다음과 같이 단순해집니다.

-Example example(ExampleRef ref) {
+Example example(Ref ref) {
return Example();
}
정보

WidgetRef는 이 변경과 무관하며 그대로 유지됩니다.
Ref와 WidgetRef는 서로 다른 것입니다.

이제 모든 updateShouldNotify가 ==를 사용합니다​

updateShouldNotify는 상태가 바뀌었을 때 provider가 리스너에게 알릴지를 결정하는 메서드입니다. 하지만 2.0에서는 이 메서드의 구현이 provider마다 꽤 달랐습니다. 어떤 provider는 ==를, 어떤 provider는 identical을, 또 어떤 provider는 더 복잡한 로직을 사용했습니다.

3.0부터는 모든 provider가 ==로 알림을 걸러냅니다.

이 변경은 다음과 같은 영향을 줄 수 있습니다.

  • 일부 provider가 특정 상황에서 더 이상 리스너에게 알리지 않을 수 있습니다.
  • 일부 리스너가 이전보다 더 자주 알림을 받을 수 있습니다.
  • ==를 오버라이드한 큰 데이터 클래스가 있다면 성능에 약간 영향이 있을 수 있습니다.

가장 흔하게 영향을 받는 경우는 StreamProvider/StreamNotifier를 사용할 때입니다. 이제 스트림의 이벤트가 ==로 걸러지기 때문입니다.

이 변경으로 영향을 받는다면 updateShouldNotify를 오버라이드해 직접 구현할 수 있습니다.

class TodoList extends StreamNotifier<Todo> {

Stream<Todo> build() => Stream(...);


bool updateShouldNotify(AsyncValue<Todo> previous, AsyncValue<Todo> next) {
// 직접 구현한 로직
return true;
}
}

provider 생명주기 변경​

폐기된 Ref와 Notifier는 더 이상 사용할 수 없습니다​

2.0에서는 일부 엣지 케이스에서 Ref나 Notifier 같은 것이 폐기된 뒤에도 여전히 사용할 수 있었습니다. 이는 의도된 동작이 아니었고 여러 심각한 버그의 원인이 되었습니다.

3.0에서는 폐기된 Ref/Notifier를 사용하려고 하면 Riverpod가 오류를 던집니다.

Ref/Notifier를 아직 사용할 수 있는지는 Ref.mounted로 확인할 수 있습니다.

final provider = FutureProvider<int>((ref) async {
await Future.delayed(Duration(seconds: 1));
// await하는 동안 provider가 폐기되었다면 중단합니다.
// 원하는 예외를 던지고, 오류 리포팅 도구에서 이 예외를 무시하면 됩니다.
if (!ref.mounted) throw MyException();
return 42;
});

provider를 읽다가 예외가 발생하면 이제 오류가 ProviderException으로 감싸집니다​

이전에는 provider가 오류를 던지면 Riverpod가 그 오류를 그대로 다시 던지는 경우가 있었습니다.

final exampleProvider = FutureProvider<int>((ref) async {
throw StateError('Error');
});

// ...
ElevatedButton(
onPressed: () async {
// StateError를 다시 던집니다
ref.read(exampleProvider).requireValue;

// 이것도 StateError를 다시 던집니다
await ref.read(exampleProvider.future);
},
child: Text('Click me'),
);

3.0에서는 이 동작이 바뀌었습니다. 이제 오류는 원래 오류와 스택 트레이스를 모두 담은 ProviderException으로 감싸집니다.

정보

AsyncValue.error, ref.listen(..., onError: ...), ProviderObserver는 이 변경의 영향을 받지 않으며, 여전히 원래 오류를 그대로 받습니다.

이 변경에는 여러 이점이 있습니다.

  • 훨씬 나은 스택 트레이스를 얻을 수 있어 디버깅이 쉬워집니다
  • provider 자체가 실패한 것인지, 아니면 실패한 다른 provider에 의존하고 있어서 오류 상태인 것인지 구분할 수 있습니다.

예를 들어 ProviderObserver는 이를 이용해 같은 오류를 두 번 로깅하지 않을 수 있습니다.

class MyObserver extends ProviderObserver {

void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) {
if (error is ProviderException) {
// provider가 직접 실패한 것이 아니라, 실패한 provider에 의존하고 있습니다.
// 따라서 이 오류는 이미 로깅되었습니다.
return;
}

// 오류를 로깅합니다
print('Provider failed: $error');
}
}

Riverpod도 내부적으로 자동 재시도 메커니즘에서 이를 사용합니다. 기본 자동 재시도는 ProviderException을 무시합니다.

ProviderContainer(
// 기본 재시도 동작의 예시
retry: (retryCount, error) {
if (error is ProviderException) return null;

// ...
},
);

이제 화면에 보이지 않는 위젯 안의 리스너가 일시 정지됩니다​

이제 Riverpod에는 리스너를 일시 정지하는 방법이 생겼으므로, 이를 이용해 위젯이 보이지 않을 때 리스너를 자체적으로 일시 정지합니다. 실제로는, 화면에 보이는 위젯 트리에서 사용하지 않는 provider가 일시 정지된다는 뜻입니다.

구체적인 예로, 두 개의 라우트가 있는 앱을 생각해 봅시다.

  • provider를 사용해 웹소켓을 구독하는 홈 페이지
  • 그 웹소켓에 의존하지 않는 설정 페이지

일반적인 앱에서 사용자는 먼저 홈 페이지를 열고 그다음에 설정 페이지를 엽니다. 즉 설정 페이지가 열려 있는 동안 홈 페이지도 열려 있지만 화면에는 보이지 않습니다.

2.0에서는 홈 페이지가 웹소켓을 계속 적극적으로 구독했습니다.
3.0에서는 웹소켓 provider가 대신 일시 정지되므로 리소스를 절약할 수 있습니다.

동작 원리:
Riverpod는 TickerMode를 보고 위젯이 보이는지 판단합니다. 그리고 그 값이 false이면 Consumer의 모든 리스너를 일시 정지합니다.

따라서 TickerMode를 직접 사용해 consumer의 일시 정지 동작을 수동으로 제어할 수도 있습니다. 값을 true/false로 직접 설정해 리스너를 강제로 재개하거나 일시 정지할 수 있습니다.

class MyWidget extends StatelessWidget {

Widget build(BuildContext context) {
return TickerMode(
enabled: false, // 리스너를 일시 정지합니다
child: Consumer(
builder: (context, ref, child) {
// 이 "watch"는 TickerMode가 true로
// 설정될 때까지 일시 정지됩니다
final value = ref.watch(myProvider);
return Text(value.toString());
},
),
);
}
}

일시 정지된 provider만 사용하는 provider는 함께 일시 정지됩니다​

Riverpod 2.0에도 어느 정도의 일시 정지/재개 기능이 있었습니다. 하지만 제한적이어서 일부 엣지 케이스를 처리하지 못했습니다.
다음 코드를 봅시다.

final exampleProvider = Provider<int>((ref) {
ref.onCancel(() => print('paused'));
ref.onResume(() => print('resumed'));
return 0;
});

2.0에서는 이 provider에 ref.read를 한 번 호출하면 provider의 상태는 유지되지만 'paused'가 출력됩니다. ref.read는 provider를 "구독"하지 않기 때문입니다. 그리고 provider를 아무도 "구독"하지 않으므로 일시 정지됩니다.

이는 현재 사용하지 않는 provider를 일시 정지하는 데 유용합니다! 문제는 많은 경우 이 최적화가 제대로 동작하지 않는다는 점입니다.
예를 들어 provider가 다른 provider를 통해 간접적으로 사용될 수 있습니다.

final anotherProvider = Provider<int>((ref) {
return ref.watch(exampleProvider);
});

class MyWidget extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
return Button(
onPressed: () {
ref.read(anotherProvider);
},
child: Text('Click me'),
);
}
}

이 경우 버튼을 한 번 클릭하면 anotherProvider가 exampleProvider를 구독하기 시작합니다. 하지만 anotherProvider는 더 이상 사용되지 않으므로 일시 정지됩니다. 그런데도 exampleProvider는 아직 사용 중이라고 여기기 때문에 일시 정지되지 않습니다.
그래서 버튼을 클릭해도 더 이상 'paused'가 출력되지 않습니다.

3.0에서는 이 문제가 수정되었습니다. 일시 정지된 provider만 사용하는 provider는 함께 일시 정지됩니다.

provider가 다시 빌드될 때, 이전 구독이 다시 빌드가 끝날 때까지 유지됩니다​

2.0에서는 비동기 provider를 'auto-dispose'와 함께 사용할 때 알려진 불편함이 있었습니다.

구체적으로, 비동기 provider가 await 이후에 auto-dispose provider를 watch하면 "auto dispose"가 예기치 않게 발생할 수 있었습니다.

다음 코드를 봅시다.

final autoDisposeProvider = StreamProvider.autoDispose<int>((ref) {
ref.onDispose(() => print('disposed'));
ref.onCancel(() => print('paused'));
ref.onResume(() => print('resumed'));
// 매초 값을 내보내는 스트림
return Stream.periodic(Duration(seconds: 1), (i) => i);
});

final asynchronousExampleProvider = FutureProvider<int>((ref) async {
print('Before async gap');
// provider 안의 비동기 공백. 보통은 API 호출입니다.
// 비동기 작업이 끝나기 전에
// "autoDispose" provider가 폐기됩니다
await null;

print('after async gap');
// 비동기 작업 이후에
// auto-dispose provider를 구독합니다
return ref.watch(autoDisposeProvider.future);
});

void main() {
final container = ProviderContainer();
// 매초 'disposed'를 출력하고,
// 항상 0을 출력합니다
container.listen(asynchronousExampleProvider, (_, value) {
if (value is AsyncData) print('${value.value}\n----');
});
}

이 코드를 Dartpad에서 실행하면 다음과 같이 출력됩니다.

// 첫 번째 출력
Before async gap
after async gap
0
---- // 두 번째 이후 출력
paused
Before async gap
disposed // 비동기 공백 동안 'autoDispose' provider가 폐기되었습니다!
after async gap
0
----
paused
Before async gap
disposed
after async gap
0
----
... // 이렇게 매초 반복됩니다

보다시피 매초 계속 0이 출력됩니다. autoDispose provider가 비동기 공백 동안 매번 폐기되기 때문입니다. ref.watch 호출을 await 문 앞으로 옮기는 우회 방법이 있었습니다. 하지만 이 방법은 실수하기 쉽고, 직관적이지 않으며, 항상 가능한 것도 아니었습니다.

3.0에서는 리스너의 폐기를 늦추는 방식으로 이 문제를 해결했습니다.
provider가 다시 빌드될 때 모든 리스너를 즉시 제거하는 대신, 리스너를 일시 정지합니다.

이제 똑같은 코드가 다음과 같이 출력됩니다.

// 첫 번째 출력
Before async gap
after async gap
0
----
paused
Before async gap
after async gap
resumed
1
----
paused
Before async gap
after async gap
resumed
2
----
... // 이렇게 매초 반복됩니다

provider의 예외가 ProviderException으로 다시 던져집니다.​

"provider가 실패함"과 "provider가 실패한 provider에 의존함"을 구분하기 위해, Riverpod 3.0은 이제 예외를 원래 예외를 담은 ProviderException으로 감쌉니다.

따라서 provider에서 오류를 catch하고 있다면, ProviderException의 내용을 검사하도록 try/catch를 수정해야 합니다.

try {
ref.watch(failingProvider);
} on ProviderException catch (e) {
switch (e.exception) {
case SomeSpecificError():
// 특정 오류를 처리합니다
default:
// 그 밖의 오류를 처리합니다
rethrow;
}
}

새로운 테스트 유틸리티​

ProviderContainer.test​

2.0에서는 테스트 코드에서 흔히 createContainer라는 직접 만든 유틸리티를 사용했습니다.
3.0에서는 이 유틸리티가 Riverpod에 포함되었으며, 이름은 ProviderContainer.test입니다. 새 컨테이너를 만들고, 테스트가 끝나면 자동으로 폐기합니다.

void main() {
test('My test', () {
final container = ProviderContainer.test();
// 컨테이너를 사용합니다
// ...
// 테스트가 끝나면 컨테이너가 자동으로 폐기됩니다
});
}

createContainer를 ProviderContainer.test로 전역 검색-치환해도 안전합니다.

NotifierProvider.overrideWithBuild​

이제 notifier 전체를 모킹하지 않고 Notifier.build 메서드만 모킹할 수 있습니다. notifier를 특정 상태로 초기화하되, notifier의 나머지 구현은 원래 것을 그대로 사용하고 싶을 때 유용합니다.

class MyNotifier extends Notifier<int> {

int build() => 0;

void increment() {
state++;
}
}

final myProvider = NotifierProvider<MyNotifier, int>(MyNotifier.new);

void main() {
final container = ProviderContainer.test(
overrides: [
myProvider.overrideWithBuild((ref, self) {
// build 메서드를 모킹해 42에서 시작하게 합니다.
// "increment" 메서드는 영향을 받지 않습니다.
return 42;
}),
],
);
}

Future/StreamProvider.overrideWithValue​

얼마 전 FutureProvider.overrideWithValue와 StreamProvider.overrideWithValue가 Riverpod에서 "일시적으로" 제거되었습니다.
드디어 다시 돌아왔습니다!

final myFutureProvider = FutureProvider<int>((ref) async {
return 42;
});

void main() {
final container = ProviderContainer.test(
overrides: [
// provider를 값으로 초기화합니다.
// 오버라이드를 바꾸면 값이 갱신됩니다.
myFutureProvider.overrideWithValue(AsyncValue.data(42)),
],
);
}

WidgetTester.container​

위젯 트리의 ProviderContainer에 접근하는 간단한 방법입니다.

void main() {
testWidgets('can access a ProviderContainer', (tester) async {
await tester.pumpWidget(const ProviderScope(child: MyWidget()));
ProviderContainer container = tester.container();
});
}

자세한 내용은 WidgetTester.container 확장을 참고하세요.

커스텀 ProviderListenable​

Riverpod 3.0에서는 커스텀 ProviderListenable을 만들 수 있습니다. CustomProviderListenable을 상속하면 됩니다.

다음 예제는 선택한 값 대신 boolean을 반환하는 콜백을 받는 provider.select의 변형을 구현합니다.

final class Where<T> extends CustomProviderListenable<T, T> {
Where(this.source, this.where);


final ProviderListenable<T> source;
final bool Function(T previous, T value) where;


_WhereTransformer<T> createTransformer() => _WhereTransformer<T>();
}

final class _WhereTransformer<T>
extends SyncProviderTransformer2<T, T, Where<T>> {

T initState() => sourceState.requireValue;


void onEvent(
ProviderTransformer2<T, T, Where<T>> self,
AsyncResult<T> prev,
AsyncResult<T> next,
) {
if (listenable.where(prev.requireValue, next.requireValue)) {
state = next;
}
}
}

extension<T> on ProviderListenable<T> {
ProviderListenable<T> where(
bool Function(T previous, T value) where,
) => Where<T>(this, where);
}

ref.watch(provider.where((previous, value) => value > 0))처럼 사용합니다.

정적으로 안전한 스코핑 (코드 생성 전용)​

이제 Riverpod는 riverpod_lint를 통해 스코핑이 잘못 사용된 경우를 감지할 수 있습니다. 이 린트는 오버라이드가 누락된 경우를 감지해 런타임 오류를 방지합니다.

다음 코드를 봅시다.

// 전형적인 "스코프된 provider"
(dependencies: [])
Future<int> myFutureProvider() => throw UnimplementedError();

이 provider를 사용하는 방법은 두 가지입니다.
둘 중 어느 것도 사용하지 않으면 provider는 런타임에 오류를 던집니다.

  • 사용하기 전에 ProviderScope로 provider를 오버라이드합니다.
    class MyWidget extends StatelessWidget {

    Widget build(BuildContext context) {
    return ProviderScope(
    overrides: [
    myFutureProvider.overrideWithValue(AsyncValue.data(42)),
    ],
    // 오버라이드된 provider에 접근하려면 Consumer가 필요합니다
    child: Consumer(
    builder: (context, ref, child) {
    // provider를 사용합니다
    final value = ref.watch(myFutureProvider);
    return Text(value.toString());
    },
    ),
    );
    }
    }
  • 스코프된 provider를 사용하는 쪽에 @Dependencies를 지정해 그 provider에 의존한다는 것을 표시합니다.
    ([myFuture])
    class MyWidget extends ConsumerWidget {

    Widget build(BuildContext context, WidgetRef ref) {
    // provider를 사용합니다
    final value = ref.watch(myFutureProvider);
    return Text(value.toString());
    }
    }
    @Dependencies를 지정하고 나면, MyWidget을 사용하는 모든 곳에서도 위와 같은 두 가지 방법 중 하나가 필요합니다.
    • MyWidget을 사용하기 전에 ProviderScope로 provider를 오버라이드하거나
      void main() {
      runApp(
      ProviderScope(
      overrides: [
      myFutureProvider.overrideWithValue(AsyncValue.data(42)),
      ],
      child: MyWidget(),
      ),
      );
      }
    • MyWidget을 사용하는 쪽에 @Dependencies를 지정해 그에 의존한다는 것을 표시합니다.
      ([myFuture])
      class MyApp extends ConsumerWidget {

      Widget build(BuildContext context, WidgetRef ref) {
      // MyApp은 MyWidget을 통해 스코프된 provider를 간접적으로 사용합니다
      return MyWidget();
      }
      }

기타 변경 사항​

AsyncValue​

AsyncValue에 여러 변경이 있었습니다.

  • 이제 "sealed" 클래스입니다. 덕분에 망라적인(exhaustive) 패턴 매칭이 가능합니다.
    AsyncValue<int> value;
    switch (value) {
    case AsyncData():
    print('data');
    case AsyncError():
    print('error');
    case AsyncLoading():
    print('loading');
    // default 케이스가 필요 없습니다
    }
  • valueOrNull의 이름이 value로 바뀌었습니다. 기존 value는 오류와 관련된 동작이 이상했기 때문에 제거되었습니다. 마이그레이션하려면 valueOrNull -> value로 전역 검색-치환하세요.
  • AsyncValue.isFromCache가 추가되었습니다.
    이 플래그는 오프라인 영속성을 통해 값을 얻었을 때 설정됩니다. 이를 이용하면 UI에서 데이터베이스에서 온 상태와 서버에서 온 상태를 구분할 수 있습니다.
  • AsyncLoading에 선택적으로 사용할 수 있는 progress 프로퍼티가 추가되었습니다. 이를 통해 provider가 요청의 현재 진행률을 정의할 수 있습니다.
    class MyNotifier extends AsyncNotifier<User> {

    Future<User> build() async {
    // AsyncLoading에 선택적으로 "progress"를 전달할 수 있습니다
    state = AsyncLoading(progress: .0);
    await fetchSomething();
    state = AsyncLoading(progress: 0.5);

    return User();
    }
    }

이제 모든 Ref 리스너가 리스너를 제거하는 수단을 반환합니다​

이제 여러 생명주기 리스너의 "구독을 취소"할 수 있습니다.

final exampleProvider = FutureProvider<int>((ref) {
// onDispose를 비롯한 생명주기 리스너는
// 리스너를 제거하는 함수를 반환합니다.
final removeListener = ref.onDispose(() => print('dispose));
// 그 함수를 호출하기만 하면 리스너가 제거됩니다.
removeListener();

// ...
});

약한 리스너 - auto-dispose를 막지 않고 provider를 구독하기.​

Ref.listen을 사용할 때 선택적으로 weak: true를 지정할 수 있습니다.

final exampleProvider = FutureProvider<int>((ref) {
ref.listen(
anotherProvider,
// 플래그를 지정합니다
weak: true,
(previous, next) {},
);

// ...
});

이 플래그를 지정하면, 구독 중인 provider가 더 이상 사용되지 않을 때 Riverpod가 그 provider를 폐기해도 된다는 뜻이 됩니다.

이 플래그는 여러 "진실 공급원(source of truth)"을 하나의 provider에서 결합하는 것과 관련된 특수한 사용 사례를 위한 고급 기능입니다.