Recursos Adicionais do Riverpod

Tempo de leitura: 10 minutes

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 ou ref.listen). Se usarmos autoDispose, 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 widgets ProviderScope 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 widget ProductItem.
  • Observamos o índice dentro do método build do ProductItem.

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 chamarmos ref.watch() ou ref.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 pacote flutter_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):