주요 콘텐츠로 건너뛰기

Provider vs Riverpod

이 문서에서는 Provider와 Riverpod의 차이점과 유사점을 요약하여 설명합니다.

정보

한글에는 영어 대/소문자가 없어서 "Provider"와 "provider"를 "프로바이더", "공급자"등으로 번역시 구분할 수 없습니다. 이 문서에서는 pkg:Provider를 "Provider"로 표기하고, pkg:Provider나 pkg:Riverpod에서 제공되는 provider들을 "provider"로 표기합니다.

Provider 정의하기

두 패키지의 가장 큰 차이점은 "providers"를 정의하는 방식입니다.

Provider를 사용하면 providers는 위젯이므로 위젯 트리 안에 배치됩니다, 일반적으로 MultiProvider 안에 배치됩니다:

class Counter extends ChangeNotifier {
...
}

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

Riverpod에서 providers는 위젯이 아닙니다. 대신 일반 Dart 객체입니다.
마찬가지로 providers는 위젯 트리 외부에서 정의되며, 대신 전역 최종(global final) 변수로 선언됩니다.

또한 Riverpod이 작동하려면 전체 애플리케이션 위에 ProviderScope 위젯을 추가해야 합니다. 따라서 Riverpod을 사용하는 것은 Provider 예시와 동일합니다:

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

void main() {
runApp(
// 이 위젯은 전체 프로젝트에서 Riverpod을 사용할 수 있게 합니다
ProviderScope(
child: MyApp(),
),
);
}

provider 정의가 단순히 몇 줄 위로 올라간 것을 주목하세요.

정보

Riverpod providers는 일반 다트 객체이므로 Flutter 없이 Riverpod을 사용할 수 있습니다.
예를 들어, 명령줄 애플리케이션을 작성하는 데 Riverpod을 사용할 수 있습니다.

providers 읽기: BuildContext

Provider에서 providers를 읽는 한 가지 방법은 위젯의 'BuildContext'를 사용하는 것입니다.

예를 들어, provider가 다음과 같이 정의된 경우:

Provider<Model>(...);

그런 다음 Provider를 사용하여 읽는 것은 다음과 같습니다:

class Example extends StatelessWidget {

Widget build(BuildContext context) {
Model model = context.watch<Model>();

}
}

이것은 Riverpod에서 동일합니다. Riverpod의 스니펫은 다음과 같습니다:

final modelProvider = Provider<Model>(...);

class Example extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
Model model = ref.watch(modelProvider);

}
}

방법을 확인하세요:

  • Riverpod의 스니펫은 StatelessWidget 대신 ConsumerWidget을 확장(extend)합니다. 이 다른 위젯 유형은 build 함수에 하나의 추가 매개변수인 WidgetRef를 추가합니다.

  • Riverpod에서는 BuildContext.watch 대신 ConsumerWidget에서 가져온 WidgetRef를 사용하여 WidgetRef.watch를 수행합니다.

  • Riverpod은 제네릭 타입에 의존하지 않습니다. 대신 provider 정의(definition)를 통해 생성된 변수(variable)에 의존합니다.

문구가 얼마나 유사한지도 주목하세요. Provider와 Riverpod은 모두 "watch" 키워드를 사용하여 "이 위젯은 값이 변경되면 다시 빌드되어야 합니다"라고 설명합니다.

정보

Riverpod은 provider 읽기에 대해 Provider와 동일한 용어를 사용합니다.

  • BuildContext.watch -> WidgetRef.watch
  • BuildContext.read -> WidgetRef.read
  • BuildContext.select -> WidgetRef.watch(myProvider.select)

context.watchcontext.read에 대한 규칙은 Riverpod에도 적용됩니다: build 메서드 내부에서는 "watch"를 사용합니다. 클릭 핸들러 및 기타 이벤트 내부에서는 "read"를 사용합니다. 값을 필터링하고 다시 빌드해야 하는 경우 "select"를 사용합니다.

providers 읽기: Consumer

Provider는 선택적으로 Consumer라는 위젯(및 Consumer2와 같은 변형)을 제공합니다.

Consumer는 위젯 트리 보다 세분화하여 재빌드할 수 있으므로, 성능최적화에 도음이 됩니다. - 상태가 변경될 때 관련 위젯만 업데이트합니다:

따라서 provider가 다음과 같이 정의된 경우:

Provider<Model>(...);

Provider는 Consumer를 사용하여 provider를 읽을 수 있게합니다:


Provider allows reading that provider using `Consumer` with:

```dart
Consumer<Model>(
builder: (BuildContext context, Model model, Widget? child) {

}
)

Riverpod도 같은 원리를 가지고 있습니다. Riverpod에도 똑같은 용도의 'Consumer'라는 위젯이 있습니다.

provider를 다음과 같이 정의했다면:

final modelProvider = Provider<Model>(...);

Consumer를 사용하여 provider를 읽을 수 있습니다:

Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) {
Model model = ref.watch(modelProvider);

}
)

Consumer가 어떻게 WidgetRef 객체를 제공하는지 주목해주세요. 이것은 이전 파트에서 ConsumerWidget과 관련된 것과 동일한 객체입니다.

Riverpod에는 ConsumerN에 해당하는 객체가 없음

Riverpod에서는 pkg:Provider의 Consumer2, Consumer3 등이 필요하지 않고, 누락된 것을 확인할 수 있습니다. (not needed nor missed)

Riverpod을 사용하면, 여러 provider로 부터 값을 읽으려면 다음과 같이 여러 개의 ref.watch 문을 작성하면 됩니다.:

Consumer(
builder: (context, ref, child) {
Model1 model = ref.watch(model1Provider);
Model2 model = ref.watch(model2Provider);
Model3 model = ref.watch(model3Provider);
// ...
}
)

위의 솔루션은 pkg:Provider의 ConsumerN API와 비교할 때 훨씬 덜 무겁게 느껴지고 이해하기 쉬울 것입니다.

providers 결합하기: ProxyProvider 와 stateless objects

Provider를 사용할 때, providers를 결합하는 공식적인 방법은 ProxyProvider 위젯(또는 ProxyProvider2와 같은 변형)을 사용하는 것입니다.

예를 들어 다음과 같이 정의할 수 있습니다:

class UserIdNotifier extends ChangeNotifier {
String? userId;
}

// ...

ChangeNotifierProvider<UserIdNotifier>(create: (context) => UserIdNotifier()),

이제 두가지 옵션이 있습니다. UserIdNotifier를 결합하여 새로운 "stateless" provider(일반적으로 ==를 재정의할 수 있는 불변값)를 만들 수 있습니다. 다음과 같은:

ProxyProvider<UserIdNotifier, String>(
update: (context, userIdNotifier, _) {
return 'The user ID of the the user is ${userIdNotifier.userId}';
}
)

이 provider는 UserIdNotifier.userId가 변경될 때마다 자동으로 새 String을 반환합니다.

Riverpod에서도 비슷한 작업을 수행할 수 있지만 구문이 다릅니다.
먼저, Riverpod에서 UserIdNotifier의 정의는 다음과 같습니다:

class UserIdNotifier extends ChangeNotifier {
String? userId;
}

// ...

final userIdNotifierProvider = ChangeNotifierProvider<UserIdNotifier>(
(ref) => UserIdNotifier(),
);

거기에서, 'userId'를 기반으로 'String'을 생성하면 됩니다:

final labelProvider = Provider<String>((ref) {
UserIdNotifier userIdNotifier = ref.watch(userIdNotifierProvider);
return 'The user ID of the the user is ${userIdNotifier.userId}';
});

ref.watch(userIdNotifierProvider)를 수행하는 줄을 주목하세요.

이 코드 줄은 Riverpod에게 userIdNotifierProvider의 내용을 가져오고, 그 값이 변경될 때마다 labelProvider도 다시 계산하도록 지시합니다. 따라서 labelProvider가 내보내는 StringuserId가 변경될 때마다 자동으로 업데이트됩니다.

ref.watch 줄도 비슷하게 느껴질 것입니다. 이 패턴은 이전에 위젯 내부에서 provider를 읽는 방법을 설명할 때 다뤘던 내용입니다. 실제로 providers는 이제 위젯과 같은 방식으로 다른 providers를 수신할 수 있습니다.

providers 결합하기: ProxyProvider 와 stateful objects

providers를 결합할 때 또 다른 대안적인 사용 사례는 ChangeNotifier 인스턴스와 같은 상태 저장 객체를 노출하는 것입니다.

이를 위해 ChangeNotifierProxyProvider(또는 ChangeNotifierProxyProvider2와 같은 변형)를 사용할 수 있습니다.
예를 들어 다음과 같이 정의할 수 있습니다:

class UserIdNotifier extends ChangeNotifier {
String? userId;
}

// ...

ChangeNotifierProvider<UserIdNotifier>(create: (context) => UserIdNotifier()),

그런 다음 UserIdNotifier.userId를 기반으로 하는 새로운 ChangeNotifier를 정의할 수 있습니다. 예를 들어 다음과 같이 할 수 있습니다:

class UserNotifier extends ChangeNotifier {
String? _userId;

void setUserId(String? userId) {
if (userId != _userId) {
print('The user ID changed from $_userId to $userId');
_userId = userId;
}
}
}

// ...

ChangeNotifierProxyProvider<UserIdNotifier, UserNotifier>(
create: (context) => UserNotifier(),
update: (context, userIdNotifier, userNotifier) {
return userNotifier!
..setUserId(userIdNotifier.userId);
},
);

이 새 provider는 (재구성되지 않는) UserNotifier의 단일 인스턴스를 생성하고 사용자 ID가 변경될 때마다 문자열을 인쇄합니다.

provider에서 동일한 작업을 수행하는 방식은 다릅니다. 먼저, Riverpod에서는 UserIdNotifier의 정의가 다음과 같습니다:

class UserIdNotifier extends ChangeNotifier {
String? userId;
}

// ...

final userIdNotifierProvider = ChangeNotifierProvider<UserIdNotifier>(
(ref) => UserIdNotifier(),
),

이전의 ChangeNotifierProxyProvider에 해당하는 코드는 다음과 같습니다:

class UserNotifier extends ChangeNotifier {
String? _userId;

void setUserId(String? userId) {
if (userId != _userId) {
print('The user ID changed from $_userId to $userId');
_userId = userId;
}
}
}

// ...

final userNotifierProvider = ChangeNotifierProvider<UserNotifier>((ref) {
final userNotifier = UserNotifier();
ref.listen<UserIdNotifier>(
userIdNotifierProvider,
(previous, next) {
if (previous?.userId != next.userId) {
userNotifier.setUserId(next.userId);
}
},
);

return userNotifier;
});

이 스니펫의 핵심은 ref.listen 줄입니다.
ref.listen 함수는 provider를 수신 대기하고, provider가 변경될 때마다 함수를 실행하는 유틸리티입니다.

해당 함수의 previousnext 매개 변수는 provider가 변경되기 전의 마지막 값과 변경된 후의 새 값에 해당합니다.

범위 지정(Scoping) Providers vs .family + .autoDispose

pkg:Provider에서 범위 지정은 두 가지 용도로 사용되었습니다:

  • 페이지 이탈 시 상태 소멸(destroying state)
  • 페이지당 커스텀 상태 보유

상태를 파괴(Destroy)하기 위해 스코핑(Scoping)을 사용하는 것은 이상적이지 않습니다.
문제는 범위 지정(Scoping)이 대규모 애플리케이션에서 제대로 작동하지 않는다는 것입니다.
예를 들어, 상태는 한 페이지에서 생성되지만 탐색 후 다른 페이지에서 나중에 소멸되는 경우가 많습니다.
이렇게 하면 여러 페이지에서 여러 개의 캐시를 활성화할 수 없습니다.

마찬가지로 모달이나 다단계 양식과 같이 해당 상태를 위젯 트리의 다른 부분과 공유해야 하는 경우 '페이지별 사용자 지정 상태(custom state per page)' 접근 방식은 처리하기 어려워집니다.

Riverpod은 다른 접근 방식을 취합니다: 첫째, 범위 지정(Scoping) providers는 권장하지 않으며, 둘째, .family.autoDispose는 이를 완전히 대체하는 솔루션 입니다.

Riverpod 내에서 '.autoDispose'로 표시된 providers는 더 이상 사용되지 않을 때 자동으로 상태를 소멸(destroy)합니다.
provider를 제거하는 마지막 위젯이 언마운트되면, Riverpod은 이를 감지하고 providers를 파기(destroy)합니다. 이 동작을 테스트하려면 제공자에서 이 두 가지 수명 주기 메서드를 사용해 보세요:

ref.onCancel((){
print("더 이상 어떤 것도 나를 Listen하지 않음!");
});
ref.onDispose((){
print("`.autoDispose`로 정의된 경우, 방금 폐기되었음!");
});

이는 본질적으로 "상태 소멸(destroying state)" 문제를 해결합니다.

또한 Provider를 .family로 표시할 수 있습니다. (동시에 .autoDispose로 표시할 수도 있습니다.) 이렇게 하면 providers에게 매개변수를 전달하여 내부적으로 여러 providers를 생성하고 추적할 수 있습니다. 즉, 매개변수를 전달할 때 고유한 매개변수당 고유한 상태가 생성됩니다.



int random(RandomRef ref, {required int seed, required int max}) {
return Random(seed).nextInt(max);
}

이렇게 하면 "페이지별 맞춤 상태(custom state per page)" 문제가 해결됩니다. 사실, 또 다른 이점이 있습니다. 이러한 상태는 더 이상 특정 페이지에 묶여 있지 않습니다.
대신 다른 페이지에서 동일한 상태에 액세스하려고 시도하는 경우 해당 페이지에서 매개변수를 재사용하기만 하면 액세스할 수 있습니다.

여러 가지 면에서 providers에게 매개변수를 전달하는 것은 맵 키와 동일합니다. 키가 같으면 얻어지는 값도 동일합니다. 키가 다르면 다른 상태가 얻어집니다.