Recursos Adicionais do Riverpod
Conteudo
Recursos Adicionais do Riverpod
Até agora, cobrimos a maioria dos conceitos básicos e os seis principais tipos de provedores.
A seguir, vamos ver alguns recursos adicionais geralmente necessários em projetos do mundo real usando o Riverpod.
O modificador autoDispose
Se estivermos trabalhando com FutureProvider
ou StreamProvider
, desejaremos descartar quaisquer ouvintes quando nosso provedor não estiver mais em uso.
Podemos fazer isso adicionando um modificador autoDispose
ao nosso provedor:
final authStateChangesProvider = StreamProvider.autoDispose<User?>((ref) { // get FirebaseAuth from another provider final firebaseAuth = ref.watch(firebaseAuthProvider); // call method that returns a Stream<User?> return firebaseAuth.authStateChanges(); });
Sob o hood, o Riverpod rastreia todos os ouvintes (widgets ou outros provedores) conectados a qualquer provedor (via
ref.watch
ouref.listen
). Se usarmosautoDispose
, o provedor será descartado assim que todos os ouvintes forem removidos (ou seja, quando os widgets forem desmontados).
Outro caso de uso para autoDispose
é quando estamos usando FutureProvider
como um wrapper para uma solicitação HTTP que é acionada quando o usuário abre uma nova tela.
Se quisermos cancelar a solicitação HTTP quando o usuário sair da tela antes que a solicitação seja concluída, podemos usar ref.onDispose()
para executar alguma lógica de cancelamento personalizada:
final movieProvider = FutureProvider.autoDispose<TMDBMovieBasic>((ref) async { // get the repository final moviesRepo = ref.watch(fetchMoviesRepositoryProvider); // an object from package:dio that allows cancelling http requests final cancelToken = CancelToken(); // when the provider is destroyed, cancel the http request ref.onDispose(() => cancelToken.cancel()); // call method that returns a Future<TMDBMovieBasic> return moviesRepo.movie(movieId: 550, cancelToken: cancelToken); });
Cache com tempo limite
Se desejar, podemos chamar ref.keepAlive()
para preservar o estado para que a solicitação não seja disparada novamente se o usuário sair e entrar novamente na mesma tela:
final movieProvider = FutureProvider.autoDispose<TMDBMovieBasic>((ref) async { // get the repository final moviesRepo = ref.watch(fetchMoviesRepositoryProvider); // an object from package:dio that allows cancelling http requests final cancelToken = CancelToken(); // when the provider is destroyed, cancel the http request ref.onDispose(() => cancelToken.cancel()); // if the request is successful, keep the response ref.keepAlive(); // call method that returns a Future<TMDBMovieBasic> return moviesRepo.movie(movieId: 550, cancelToken: cancelToken); });
O método keepAlive
dirá ao provedor para manter seu estado indefinidamente, fazendo com que ele seja atualizado apenas se o atualizarmos ou invalidarmos.
Podemos até usar um KeepAliveLink
para implementar uma estratégia de cache baseada em tempo limite para descartar o estado do provedor após um determinado período:
// get the [KeepAliveLink] final link = ref.keepAlive(); // start a 30 second timer final timer = Timer(const Duration(seconds: 30), () { // dispose on timeout link.close(); }); // make sure to cancel the timer when the provider state is disposed // (prevents undesired test failures) ref.onDispose(() => timer.cancel());
E se você deseja tornar esse código mais reutilizável, pode criar uma extensão AutoDisposeRef (conforme explicado aqui):
extension AutoDisposeRefCache on AutoDisposeRef { // keeps the provider alive for [duration] since when it was first created // (even if all the listeners are removed before then) void cacheFor(Duration duration) { final link = keepAlive(); final timer = Timer(duration, () => link.close()); onDispose(() => timer.cancel()); } } final myProvider = Provider.autoDispose<int>((ref) { // use like this: ref.cacheFor(const Duration(minutes: 5)); return 42; });
O Riverpod nos ajuda a resolver problemas complexos com código simples e realmente brilha quando se trata de cache de dados.
O modificador family
family
é um modificador que podemos usar para passar um argumento para um provedor.
Ele funciona adicionando uma segunda anotação de tipo e um parâmetro adicional que podemos usar dentro do corpo do provedor:
final movieProvider = FutureProvider.autoDispose // additional movieId argument of type int .family<TMDBMovieBasic, int>((ref, movieId) async { // get the repository final moviesRepo = ref.watch(fetchMoviesRepositoryProvider); // call method that returns a Future<TMDBMovieBasic>, passing the movieId as an argument return moviesRepo.movie(movieId: movieId, cancelToken: cancelToken); });
Então, podemos apenas passar o valor que queremos para o provider quando chamamos ref.watch
no método build
:
final movieAsync = ref.watch(movieProvider(550));
Poderíamos usar isso quando o usuário seleciona um item de um ListView
de filmes e enviamos um MovieDetailsScreen
que recebe o movieId
como um argumento:
class MovieDetailsScreen extends ConsumerWidget { const MovieDetailsScreen({super.key, required this.movieId}); // pass this as a property final int movieId; @override Widget build(BuildContext context, WidgetRef ref) { // fetch the movie data for the given movieId final movieAsync = ref.watch(movieProvider(movieId)); // map to the UI using pattern matching return movieAsync.when( data: (movie) => MovieWidget(movie: movie), loading: (_) => Center(child: CircularProgressIndicator()), error: (e, __) => Center(child: Text(e.toString())), ); } }
Passando vários parâmetros para um family
Em alguns casos, pode ser necessário passar mais de um valor para um xfamily.
Embora Riverpod não suporte isso, você pode passar qualquer objeto personalizado que implemente hashCode
e o operador de igualdade (como objetos gerados com Freezed ou objetos que usam equatable).
Para mais detalhes, leia:
Para superar essa limitação, você pode usar o novo pacote riverpod_generator e passar quantos argumentos nomeados ou posicionais quiser.
Substituições de dependência com Riverpod
Às vezes, queremos criar um Provider
para armazenar um valor ou objeto que não está imediatamente disponível.
Por exemplo, só podemos obter um SharedPreferences
instance com uma API baseada em Future:
final sharedPreferences = await SharedPreferences.getInstance();
Mas não podemos retornar isso dentro de um Provider synchronous:
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) { return SharedPreferences.getInstance(); // The return type Future<SharedPreferences> isn't a 'SharedPreferences', // as required by the closure's context. });
Em vez disso, temos que inicializar esse provedor lançando um UnimplementedError
:
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) { throw UnimplementedError(); });
E quando o objeto que precisamos estiver disponível, podemos definir uma substituição de dependência para nosso provider dentro do widget ProviderScope
:
// asynchronous initialization can be performed in the main method Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final sharedPreferences = await SharedPreferences.getInstance(); runApp(ProviderScope( overrides: [ // override the previous value with the new object sharedPreferencesProvider.overrideWithValue(sharedPreferences), ], child: MyApp(), )); }
A vantagem de inicializar sharedPreferences
antes de chamar runApp()
é que podemos observar o objeto sharedPreferencesProvider
em qualquer lugar sem usar nenhuma API baseada em Future.
Este exemplo usou o
ProviderScope
na raiz da árvore de widgets, mas também podemos criar widgetsProviderScope
aninhados, se necessário.
Combinando Providers com Riverpod
Providers podem depender de outros provedores.
Por exemplo, aqui definimos uma classe SettingsRepository
que recebe um argumento SharedPreferences
explícito:
class SettingsRepository { const SettingsRepository(this.sharedPreferences); final SharedPreferences sharedPreferences; // synchronous read bool onboardingComplete() { return sharedPreferences.getBool('onboardingComplete') ?? false; } // asynchronous write Future<void> setOnboardingComplete(bool complete) { return sharedPreferences.setBool('onboardingComplete', complete); } }
Em seguida, criamos um provedor settingsRepositoryProvider
que depende do sharedPreferencesProvider
que criamos acima.
final settingsRepositoryProvider = Provider<SettingsRepository>((ref) { // watch another provider to obtain a dependency final sharedPreferences = ref.watch(sharedPreferencesProvider); // pass it as an argument to the object we need to return return SettingsRepository(sharedPreferences); });
O uso de
ref.watch()
garante que o provider seja atualizado quando o provider do qual dependemos muda. Como resultado, todos os widgets e providers dependentes também serão reconstruídos.
Passando Ref como um argumento
Como alternativa, podemos passar Ref
como argumento ao criar o SettingsRepository
:
class SettingsRepository { const SettingsRepository(this.ref); final Ref ref; // synchronous read bool onboardingComplete() { final sharedPreferences = ref.read(sharedPreferencesProvider); return sharedPreferences.getBool('onboardingComplete') ?? false; } // asynchronous write Future<void> setOnboardingComplete(bool complete) { final sharedPreferences = ref.read(sharedPreferencesProvider); return sharedPreferences.setBool('onboardingComplete', complete); } }
Dessa forma, o sharedPreferencesProvider
se torna uma dependência implícita e podemos acessá-lo com uma chamada para ref.read()
.
E isso torna nossa declaração settingsRepositoryProvider
muito mais simples:
final settingsRepositoryProvider = Provider<SettingsRepository>((ref) { return SettingsRepository(ref); });
Com o Riverpod, podemos declarar provedores que contenham lógica complexa ou dependam de outros provedores, tudo fora da árvore de widgets. Essa é uma grande vantagem sobre o pacote Provider e facilita a criação de widgets que contêm apenas código de interface do usuário.
Scoping Provedores
Com o Riverpod, podemos scope providers para que eles se comportem de maneira diferente para uma parte específica do aplicativo.
Um exemplo disso é quando temos um ListView
que mostra uma lista de produtos, e cada item precisa saber o id ou índice correto do produto:
class ProductList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder( itemBuilder: (_, index) => ProductItem(index: index), ); } }
No código acima, passamos o índice do construtor como um argumento do construtor para o widget ProductItem
:
class ProductItem extends StatelessWidget { const ProductItem({super.key, required this.index}); final int index; @override Widget build(BuildContext context) { // do something with the index } }
Isso funciona, mas se o ListView
for reconstruído, todos os seus children também serão reconstruídos.
Como alternativa, podemos substituir o valor do provedor dentro de um ProviderScope
aninhado:
// 1. Declare a Provider final currentProductIndex = Provider<int>((_) => throw UnimplementedError()); class ProductList extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.builder(itemBuilder: (context, index) { // 2. Add a parent ProviderScope return ProviderScope( overrides: [ // 3. Add a dependency override on the index currentProductIndex.overrideWithValue(index), ], // 4. return a **const** ProductItem with no constructor arguments child: const ProductItem(), ); }); } } class ProductItem extends ConsumerWidget { const ProductItem({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // 5. Access the index via WidgetRef final index = ref.watch(currentProductIndex); // do something with the index } }
Nesse caso:
- Criamos um
provedor
que lança UnimplementedError por padrão. - Substituímos seu valor adicionando um
ProviderScope
pai ao widgetProductItem
. - Observamos o índice dentro do método
build
doProductItem
.
Isso é melhor para desempenho porque podemos criar ProductItem
como um widget const
no ListView.builder
. Portanto, mesmo que o ListView
seja reconstruído, nosso ProductItem
não será reconstruído, a menos que seu índice seja alterado.
O widget de filtragem é reconstruído com “selecionar”
Às vezes, você tem uma classe de modelo com várias propriedades e deseja reconstruir um widget somente quando uma propriedade específica é alterada.
Por exemplo, considere esta classe Connection
, juntamente com um provedor e uma classe de widget que a lê:
class Connection { Connection({this.bytesSent = 0, this.bytesReceived = 0}); final int bytesSent; final int bytesReceived; } // Using [StateProvider] for simplicity. // This would be a [FutureProvider] or [StreamProvider] in real-world usage. final connectionProvider = StateProvider<Connection>((ref) { return Connection(); }); class BytesReceivedText extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // rebuild when bytesSent OR bytesReceived changes final counter = ref.watch(connectionProvider).state; return Text('${counter.bytesReceived}'); } }
Se chamarmos ref.watch(connectionProvider)
, nosso widget será (incorretamente) reconstruído quando o valor bytesSent
mudar.
Em vez disso, podemos usar select()
para ouvir apenas uma propriedade específica:
class BytesReceivedText extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // only rebuild when bytesReceived changes final bytesReceived = ref.watch(connectionProvider.select( (connection) => connection.state.bytesReceived )); return Text('$bytesReceived'); } }
Então, sempre que o Connection
mudar, o Riverpod irá comparar o valor que estamos retornando (connection.state.bytesReceived
) e só reconstruirá o widget se ele for diferente do anterior.
O método
select
está disponível em todos os provedores Riverpod e pode ser usado sempre que chamarmosref.watch()
ouref.listen()
. Para obter mais informações, leia Usando “select” para filtrar reconstruções nos documentos do Riverpod.
Testando com Riverpod
Como vimos, os provedores Riverpod são globais, mas seu estado não.
O estado de um provedor é armazenado dentro de um ProviderContainer
, um objeto criado implicitamente por ProviderScope
.
Isso significa que testes de widget separados nunca compartilharão nenhum estado, portanto, não há necessidade de métodos setUp
e tearDown
.
Por exemplo, aqui está um aplicativo de contador simples que usa um StateProvider
para armazenar o valor do contador:
final counterProvider = StateProvider((ref) => 0); void main() { runApp(ProviderScope(child: MyApp())); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Consumer(builder: (_, ref, __) { final counter = ref.watch(counterProvider); return ElevatedButton( onPressed: () => ref.read(counterProvider.notifier).state++, child: Text('${counter.state}'), ); }), ); } }
O código acima usa um ElevatedButton
para mostrar o valor do contador e incrementá-lo por meio do retorno de chamada onPressed
.
Ao escrever testes de widget, tudo o que precisamos é isso:
await tester.pumpWidget(ProviderScope(child: MyApp()));
Com esta configuração, vários testes não compartilham nenhum estado porque cada teste tem um ProviderScope
diferente:
void main() { testWidgets('incrementing the state updates the UI', (tester) async { await tester.pumpWidget(ProviderScope(child: MyApp())); // The default value is `0`, as declared in our provider expect(find.text('0'), findsOneWidget); expect(find.text('1'), findsNothing); // Increment the state and re-render await tester.tap(find.byType(ElevatedButton)); await tester.pump(); // The state have properly incremented expect(find.text('1'), findsOneWidget); expect(find.text('0'), findsNothing); }); testWidgets('the counter state is not shared between tests', (tester) async { await tester.pumpWidget(ProviderScope(child: MyApp())); // The state is `0` once again, with no tearDown/setUp needed expect(find.text('0'), findsOneWidget); expect(find.text('1'), findsNothing); }); }
Como simular e substituir dependências em testes
Muitos aplicativos precisam chamar APIs REST ou se comunicar com serviços externos.
Por exemplo, aqui está um MoviesRepository
que podemos usar para obter uma lista de filmes favoritos:
class MoviesRepository { Future<List<Movie>> favouriteMovies() async { // get data from the network or local database } }
E podemos criar um movieProvider
para obter os dados de que precisamos:
final moviesRepositoryProvider = Provider((ref) => MoviesRepository()); final moviesProvider = FutureProvider<List<Movie>>((ref) { // access the provider above final repository = ref.watch(moviesRepositoryProvider); // use it to return a Future return repository.favouriteMovies(); });
Ao escrever testes de widget, queremos substituir nosso MoviesRepository
por uma simulação que retorne uma resposta enlatada em vez de fazer uma chamada de rede.
Como vimos, podemos usar substituições de dependência para alterar o comportamento de um provedor substituindo-o por uma implementação diferente.
Então podemos implementar um MockMoviesRepository
:
class MockMoviesRepository implements MoviesRepository { @override Future<List<Movie>> favouriteMovies() { return Future.value([ Movie(id: 1, title: 'Rick & Morty', posterUrl: 'https://nnbd.me/1.png'), Movie(id: 2, title: 'Seinfeld', posterUrl: 'https://nnbd.me/2.png'), ]); } }
E em nossos testes de widget, podemos substituir o provedor do repositório:
void main() { testWidgets('Override moviesRepositoryProvider', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ moviesRepositoryProvider .overrideWithValue(MockMoviesRepository()) ], child: MoviesApp(), ), ); }); }
Como resultado, o widget MoviesApp carregará os dados do MockMoviesRepository quando os testes forem executados.
Essa configuração também funciona se você usar mocktail em seus testes. Você pode fazer stub de seus métodos fictícios para retornar valores ou lançar exceções e verificar se eles são chamados.
Logging com ProviderObserver
O monitoramento de mudanças de estado é benéfico em muitos aplicativos.
E o Riverpod inclui uma classe ProviderObserver
da qual podemos criar uma subclasse para implementar um Logger:
class Logger extends ProviderObserver { @override void didUpdateProvider( ProviderBase provider, Object? previousValue, Object? newValue, ProviderContainer container, ) { print('[${provider.name ?? provider.runtimeType}] value: $newValue'); } }
Isso nos dá acesso ao valor anterior e ao novo.
Podemos ativar o log para todo o aplicativo adicionando o Logger
à lista de observadores dentro do ProviderScope
:
void main() { runApp( ProviderScope(observers: [Logger()], child: MyApp()), ); }
Para melhorar a saída do logger, podemos adicionar um nome aos nossos provedores:
final counterStateProvider = StateProvider<int>((ref) { return 0; }, name: 'counter');
E, se necessário, podemos ajustar a saída do registrador com base nos valores observados:
class Logger extends ProviderObserver { @override void didUpdateProvider( ProviderBase provider, Object? previousValue, Object? newValue, ProviderContainer container, ) { if (newValue is StateController<int>) { print( '[${provider.name ?? provider.runtimeType}] value: ${newValue.state}'); } } }
ProviderObserver
é versátil e podemos configurar nosso logger para registrar apenas valores que correspondam a um tipo específico ou nome de provedor. Ou podemos usar um ProviderScope aninhado e apenas registrar valores dentro de uma subárvore de widget específica.
Dessa forma, podemos avaliar as mudanças de estado e monitorar as reconstruções do widget sem colocar declarações de impressão em todo lugar.
ProviderObserver
é semelhante ao widget BlocObserver do pacoteflutter_bloc
.
Nota rápida sobre a arquitetura de aplicativos com Riverpod
Ao criar aplicativos complexos, é crucial escolher uma boa arquitetura de aplicativo que possa dar suporte à sua base de código à medida que ela cresce.
Como eu disse anteriormente:
- Manter o estado e a lógica do aplicativo dentro de nossos widgets leva a uma má separação de preocupações.
- Movê-lo para dentro de nossos provedores torna nosso código mais testável e sustentável.
Acontece que o Riverpod é muito adequado para resolver problemas de arquitetura, sem atrapalhar.
Mas como é a arquitetura robusta de um aplicativo Riverpod?
Após muita pesquisa, formalizei uma arquitetura composta por quatro camadas (dados, domínio, aplicação, apresentação):