Provider와 Riverpod 비교
이 글에서는 Provider와 Riverpod의 차이점과 공통점을 정리합니다.
provider 정의하기
두 패키지의 가장 큰 차이는 "provider"를 정의하는 방식입니다.
Provider에서 provider는 위젯이므로 위젯 트리 안에,
보통은 MultiProvider 안에 배치됩니다:
class Counter extends ChangeNotifier {
...
}
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider<Counter>(create: (context) => Counter()),
],
child: MyApp(),
)
);
}
Riverpod에서 provider는 위젯이 아닙니다. 평범한 Dart 객체입니다.
또한 provider는 위젯 트리 바깥에서 정의되며,
전역 final 변수로 선언됩니다.
그리고 Riverpod이 동작하려면 애플리케이션 전체를 감싸도록 ProviderScope 위젯을
추가해야 합니다. 따라서 위 Provider 예제를 Riverpod으로 옮기면
다음과 같습니다:
// 이제 provider는 최상위 변수입니다
final counterProvider = ChangeNotifierProvider<Counter>((ref) => Counter());
void main() {
runApp(
// 이 위젯이 프로젝트 전체에서 Riverpod을 활성화합니다
ProviderScope(
child: MyApp(),
),
);
}
provider 정의가 단지 몇 줄 위로 옮겨졌을 뿐이라는 점에 주목하세요.
Riverpod의 provider는 평범한 Dart 객체이므로 Flutter 없이도
Riverpod을 사용할 수 있습니다.
예를 들어 Riverpod으로 커맨드 라인 애플리케이션을 작성할 수도 있습니다.
provider 읽기: BuildContext
Provider에서 provider를 읽는 방법 중 하나는 위젯의 BuildContext를 사용하는 것입니다.
예를 들어 provider가 다음과 같이 정의되어 있다면:
Provider<Model>(...);
Provider에서는 다음과 같이 읽습니다:
class Example extends StatelessWidget {
Widget build(BuildContext context) {
Model model = context.watch<Model>();
}
}
Riverpod에서는 다음과 같습니다:
final modelProvider = Provider<Model>(...);
class Example extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
Model model = ref.watch(modelProvider);
}
}
다음 사항에 주목하세요:
Riverpod 코드는
StatelessWidget대신ConsumerWidget을 상속합니다. 이 위젯 타입 덕분에build함수에 매개변수가 하나 더 생깁니다:WidgetRef입니다.Riverpod에서는
BuildContext.watch대신,ConsumerWidget에서 얻은WidgetRef를 사용해WidgetRef.watch를 호출합니다.Riverpod은 제네릭 타입에 의존하지 않습니다. 대신 provider를 정의할 때 만든 변수에 의존합니다.
용어도 매우 비슷하다는 점에 주목하세요. Provider와 Riverpod 모두 "값이 바뀌면 이 위젯을 다시 빌드해야 한다"는 의미로 "watch"라는 단어를 사용합니다.
Riverpod은 provider를 읽을 때 Provider와 같은 용어를 사용합니다.
BuildContext.watch->WidgetRef.watchBuildContext.read->WidgetRef.readBuildContext.select->WidgetRef.watch(myProvider.select)
context.watch와 context.read에 관한 규칙은 Riverpod에도 똑같이 적용됩니다:
build 메서드 안에서는 "watch"를 사용하세요. 클릭 핸들러나 그 밖의 이벤트 안에서는
"read"를 사용하세요. 값과 다시 빌드를 걸러내야 할 때는 "select"를 사용하세요.
provider 읽기: Consumer
Provider에는 provider를 읽기 위한 Consumer라는 위젯(과 Consumer2 같은 변형)이
선택적으로 제공됩니다.
Consumer는 위젯 트리를 더 세밀하게 다시 빌드할 수 있게 해 주므로
성능 최적화에 유용합니다. 상태가 바뀌면 관련된 위젯만 갱신됩니다:
따라서 provider가 다음과 같이 정의되어 있다면:
Provider<Model>(...);
Provider에서는 Consumer로 그 provider를 다음과 같이 읽을 수 있습니다:
Consumer<Model>(
builder: (BuildContext context, Model model, Widget? child) {
}
)
Riverpod도 같은 원리를 따릅니다. Riverpod에도 정확히 같은 목적의
Consumer라는 위젯이 있습니다.
provider를 다음과 같이 정의했다면:
final modelProvider = Provider<Model>(...);
Consumer를 사용해 다음과 같이 할 수 있습니다:
Consumer(
builder: (BuildContext context, WidgetRef ref, Widget? child) {
Model model = ref.watch(modelProvider);
}
)
Consumer가 WidgetRef 객체를 제공한다는 점에 주목하세요. 이는 앞에서
ConsumerWidget과 관련해 살펴본 것과 같은 객체입니다.
Riverpod에는 ConsumerN에 해당하는 것이 없습니다
pkg:Provider의 Consumer2, Consumer3 같은 위젯은 Riverpod에서는 필요하지도 않고, 아쉽지도 않습니다.
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와 비교하면 위 방식이 훨씬 가볍고 이해하기도 쉬울 것입니다.
provider 조합하기: 상태가 없는 객체와 ProxyProvider
Provider에서 provider를 조합하는 공식적인 방법은 ProxyProvider
위젯(또는 ProxyProvider2 같은 변형)을 사용하는 것입니다.
예를 들어 다음과 같이 정의할 수 있습니다:
class UserIdNotifier extends ChangeNotifier {
String? userId;
}
// ...
ChangeNotifierProvider<UserIdNotifier>(create: (context) => UserIdNotifier()),
여기서 두 가지 선택지가 있습니다. UserIdNotifier를 조합해 "상태가 없는"
새 provider(보통 ==를 오버라이드했을 수도 있는 불변 값)를 만들 수 있습니다.
다음과 같습니다:
ProxyProvider<UserIdNotifier, String>(
update: (context, userIdNotifier, _) {
return 'The user ID of 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 user is ${userIdNotifier.userId}';
});
ref.watch(userIdNotifierProvider)를 호출하는 줄에 주목하세요.
이 코드는 Riverpod에게 userIdNotifierProvider의 내용을 가져오고,
그 값이 바뀔 때마다 labelProvider도 다시 계산하라고 알립니다.
따라서 labelProvider가 내보내는 String은 userId가 바뀔 때마다
자동으로 갱신됩니다.
이 ref.watch 줄은 익숙하게 느껴질 것입니다. 앞에서
위젯 안에서 provider를 읽는 방법을 설명할 때 다룬 패턴이기 때문입니다.
실제로 provider도 이제 위젯과 같은 방식으로
다른 provider를 구독할 수 있습니다.
provider 조합하기: 상태가 있는 객체와 ProxyProvider
provider를 조합할 때의 또 다른 사용 사례는 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가 바뀔 때마다 함수를 실행해 주는 유틸리티입니다.
이 함수의 previous와 next 매개변수는 각각
provider가 바뀌기 전의 마지막 값과 바뀐 후의 새 값에 해당합니다.
provider 스코프 vs .family + .autoDispose
pkg:Provider에서 스코프는 두 가지 용도로 쓰였습니다:
- 페이지를 떠날 때 상태 폐기하기
- 페이지마다 별도의 상태 두기
상태를 폐기하려고 스코프를 사용하는 것은 이상적이지 않습니다.
스코프는 대규모 애플리케이션에서 잘 동작하지 않기 때문입니다.
예를 들어 상태는 한 페이지에서 만들어지지만, 내비게이션 이후 다른 페이지에서 나중에 폐기되는 경우가 많습니다.
이 방식으로는 여러 페이지에 걸쳐 여러 캐시를 동시에 유지할 수 없습니다.
마찬가지로 "페이지마다 별도의 상태"를 두는 방식도, 모달이나 여러 단계로 이루어진 폼처럼 그 상태를 위젯 트리의 다른 부분과 공유해야 하는 경우에는 금세 다루기 어려워집니다.
Riverpod은 다른 접근 방식을 취합니다. 첫째, provider 스코프는 그다지 권장하지 않습니다. 둘째,
.family와 .autoDispose가 이를 완전히 대체합니다.
Riverpod에서 .autoDispose로 표시된 Provider는 더 이상 사용되지 않으면 상태를 자동으로 폐기합니다.
provider를 사용하던 마지막 위젯이 언마운트되면 Riverpod이 이를 감지해 provider를 폐기합니다.
provider에서 다음 두 생명주기 메서드를 사용해 이 동작을 직접 확인해 보세요:
ref.onCancel((){
print("No one listens to me anymore!");
});
ref.onDispose((){
print("If I've been defined as `.autoDispose`, I just got disposed!");
});
이것으로 "상태 폐기" 문제가 자연스럽게 해결됩니다.
또한 Provider를 .family로(동시에 .autoDispose로도) 표시할 수 있습니다.
이렇게 하면 provider에 매개변수를 전달할 수 있으며, 내부적으로 여러 provider가 생성되고 추적됩니다.
다시 말해 매개변수를 전달하면 고유한 매개변수마다 고유한 상태가 만들어집니다.
- riverpod
- riverpod_generator
class ParamsType extends Equatable {
const ParamsType({required this.seed, required this.max});
final int seed;
final int max;
List<Object?> get props => [seed, max];
}
final randomProvider =
Provider.family.autoDispose<int, ParamsType>((ref, params) {
return Random(params.seed).nextInt(params.max);
});
int random(Ref ref, {required int seed, required int max}) {
return Random(seed).nextInt(max);
}
이것으로 "페이지마다 별도의 상태" 문제가 해결됩니다. 사실 이점이 하나 더 있습니다. 이런 상태는 더 이상 특정 페이지에 묶이지 않습니다.
다른 페이지에서 같은 상태에 접근하려 한다면, 같은 매개변수를 다시 사용하기만 하면 됩니다.
여러 면에서 provider에 매개변수를 전달하는 것은 Map의 키와 같습니다.
키가 같으면 같은 값을 얻고, 키가 다르면 다른 상태를 얻습니다.