Riverpod
Flutter의 패키지 중 하나로, GetX, Provider, BLoC처럼 상태관리를 위한 패키지이다. 쉽게 생각하면 Provider의 확장판(?) 정도로 생각할 수 있는 것 같다. 실제 Provider와 Riverpod의 개발자는 같은 사람인데, 공식 문서에 따르면 Riverpod에서는 Provider에서 발생한 여러가지 문제점을 해결했다고 한다. 다만 Provider를 완전히 대체하는 것은 아직 아니며, 실제 프로덕션 단계에서의 사용은 조금 주의를 기울여야 한다고 적혀있다.
최근 진행하는 프로젝트를 GetX에서 Riverpod로 갈아탔다. 중간에 사람이 나가서 프론트를 혼자 진행하게 되어서 새로운 걸 써보고 싶었다. 사실 지금 사용하고 있는 방법이 맞는지는 모르겠지만 큰 오류 없이(?) 나름대로 잘 사용하고 있다. 이번 글에서는 저번에 정리하지 못한 나머지 부분들과 프로젝트를 진행하면서 실제 사용했던 예시들을 간단하게 바탕으로 정리 해보려고 한다.
Riverpod의 장점 중 하나는 Provider끼리 결합이 쉽다는 것이다. 즉 다른 Provider끼리 state를 쉽게 참조할 수 있다. 다음은 공식 사이트의 예시 코드이다.
final cityProvider = Provider((ref) => 'London');
final weatherProvider = FutureProvider((ref) async {
final city = ref.watch(cityProvider);
return fetchWeather(city: city);
});
예시를 보면 weatherProvider가 cityProvider를 참조하고 있다. (물론 예시코드의 cityProvider는 읽기만 가능하다). 이러한 방식은 api_client를 참조하거나, repository를 참조할 때 사용하면 유용할 것 같다.
final repositoryProvider = Provider((ref) => UserRepositoty());
final userProvider = FutureProvider((ref){
final repository = ref.watch(repositoryProvider);
return repository.getUser();
});
프로젝트를 진행할 때 예시 코드처럼 작성했었다. 예시 코드에서는 간략화 했지만 Repository가 Api_client를 참조하고, 다시 사용계층에서 Repository를 참조하는 식으로 작성했었다. MVVM 패턴의 방식을 참고해서 작성했는데, 나쁘지(?) 않은 것 같다.
final userTokenProvider = StateProvider<String>((ref) => null);
final repositoryProvider = Provider((ref) => Repository(ref.read));
class Repository {
Repository(this.read);
final Reader read;
Future<Catalog> fetchCatalog() async {
String token = read(userTokenProvider).state;
final response = await dio.get('/path', queryParameters: {
'token': token,
});
return Catalog.fromJson(response.data);
}
}
또 다른 방식으로는 read를 넘겨주는 방식이 있다. 이 예제 코드 역시 공식 사이트에 나와있는데, 사실 이 방식은 써보질 않아서 잘은 모르겠다. 요점은 어느 방식이든 Provider간의 자유로운 참조가 가능하다는 것이다.
.family의 경우 Provider를 생성할 때, 외부 값을 참조하기 위한 용도로 사용한다. 예를 들어 특정 id 값의 정보를 가져오는 등이 있다.
final messagesFamily = FutureProvider.family<Message, String>((ref, id) async {
return dio.get('http://my_api.dev/messages/$id');
});
Widget build(BuildContext context, WidgetRef ref) {
final response = ref.watch(messagesFamily('id'));
}
예시 코드를 보면 String값을 Provider의 참조 값으로 넣어주고 있다. 이 경우, Provider를 생성할 때, 예시 코드처럼 해당 값에 맞는 형태로 값을 넣어주면 된다.
final userFutureFamilyProvider =
FutureProvider.family.autoDispose<UserState, User>(
(ref, user) {
return UserState(user);
},
);
객체 역시 참조 값으로 넣어줄 수 있는데, 이 경우 freezed를 사용해서 생성한 객체여야 한다(그 외 tuple, equatable 등을 사용해도 된다). 기본적으로 여러 값을 넘기는 것을 지원하지 않기 때문에 이런 형태로 값을 넣어줄 수 있다.
.autoDispose는 자동으로 해당 Provider를 삭제하는데, FutureProvider, StreamProvider와 함께 사용된다.
final userProvider = StreamProvider.autoDispose<User>((ref) {
});
final userFutureFamilyProvider =
FutureProvider.family.autoDispose<UserState, User>(
(ref, user) {
return UserState(user);
},
);
예시 코드처럼 .family와 같이 사용할 수도 있다. 내 경우, Grid형태의 데이터들에서 해당 Grid를 클릭하면 상세 페이지로 이동하는 구조를 가졌는데, 이 때 .family.autoDispose를 이용해서 해당 id값을 이용해 Provider를 생성하고, 상세 페이지를 떠나면 자동으로 해당 Provider를 삭제하는 식으로 작성했었다.
final shopDetailProvider =
FutureProvider.family.autoDispose<ShopDetailData, int>(
(ref, shopId) {
return ref.watch(shopRepositroyProvider).getShopDetailData(shopId);
},
);
final detailData = ref.watch(shopDetailProvider(widget.shopId));
return SafeArea(
child: detailData.when(
// 로딩 화면
loading: (pre) => const Scaffold(
body: Center(
child: CircularProgressIndicator())),
// 에러 화면
error: (error, stack, pre) => Scaffold(
body: Center(
child: Text(
error.toString(),
style: const TextStyle(fontSize: 32)))),
data: (shopData) {
// 결과 화면 구현
}
실제 작성했던 코드의 일부이다. 상세 페이지로 이동할 때 해당 Provider를 생성해주고, FutureProvider의 when을 이용해 데이터 상태 별 화면을 처리해주었다.
이 부분은 Log를 작성하는데 사용했다. 공식 사이트에서도 로그 찍는 걸 예시로 들고 있다.
import 'dart:async';
import 'dart:developer' as dp;
class Logger extends ProviderObserver {
void didAddProvider(
ProviderBase provider,
Object? value,
ProviderContainer container,
) {
dp.log(
'''
providerAdd: ${provider.name ?? provider.runtimeType}
value: $value
''',
time: DateTime.now(),
zone: Zone.current,
);
super.didAddProvider(provider, value, container);
}
void didUpdateProvider(
ProviderBase provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
dp.log(
'''
providerUpdate: ${provider.name ?? provider.runtimeType}
newValue: $newValue
''',
time: DateTime.now(),
zone: Zone.current,
);
super.didUpdateProvider(provider, previousValue, newValue, container);
}
void didDisposeProvider(
ProviderBase provider,
ProviderContainer containers,
) {
dp.log(
'providerDispose: ${provider.name ?? provider.runtimeType}',
time: DateTime.now(),
zone: Zone.current,
);
super.didDisposeProvider(provider, containers);
}
}
실제 예시 코드를 참고해서 디버깅 로그를 이용해 Provider가 변할 때마다 로그를 찍도록 작성해봤다. 로그를 작성하면서 알았던건데, print와 develop.log의 차이는 릴리즈 모드에서 찍히고 안 찍히고의 차이라고 한다!
최근 GetX만 사용하다가 Riverpod를 사용해서 처음 코드를 작성해봤는데, 지금까지 느낌은 나쁘지 않았다. 오히려 특정 상황에서는 GetX보다 더 편하게 작성이 가능했다. 특히 Provider끼리 큰 제약없이 참조하고, 참조되고, 사용할 수 있는건 큰 장점이었다. 아직은 조금 미숙하지만 조금 더 익숙해지면 GetX보다 더 수월하게 사용할 수 있을 것 같다. 여담이지만 GetX만 쓰다보니 Navigator, Route 등을 모두 GetX에 의지해서 원래 사용하던 방식이 어색할 정도였다. (일부러 Riverpod를 사용하면서 GetX를 사용하지 않고 있다). 문득 GetX가 너무 많은 부분을 차지하고 있지 않았나 싶은 생각도 있었지만 그만큼 똑똑한 사람이 잘 만들어 놓은거라고 생각한다(?).
참고자료