Cache de dados Riverpod e Ciclo de vida de Providers: Guia completo
Se você já usa o Riverpod há algum tempo, provavelmente sabe como declarar provedores e usá-los dentro de seus widgets.
Você também deve saber que os provedores são globais, mas o estado deles não.
Mas como os provedores realmente funcionam nos bastidores?
Você já imaginou:
- Quando os provedores são inicializados?
- Quando e como eles são descartados?
- O que acontece quando um widget escuta um provedor?
- Qual é o ciclo de vida de um provedor?
- Como o Riverpod faz o cache de dados?
Este artigo responderá a todas essas perguntas e ajudará você:
- entender melhor a relação entre provedores e widgets
- aprenda como funciona o cache de dados e como ele está relacionado aos eventos do ciclo de vida do provedor
- escolha o comportamento de cache de dados mais apropriado de acordo com suas necessidades
Também ajudará você a ver o Riverpod como ele é: um cache reativo e uma estrutura de ligação de dados que ajuda a resolver problemas complexos (como cache de dados) com código simples.
O cache de dados é um tópico amplo, por isso abordaremos a invalidação de cache e outras técnicas avançadas em um artigo de acompanhamento.
Mas, por enquanto, já temos muito o que abordar!
Preparar? Vamos! 🚀
Este artigo pressupõe que você já conheça o básico. Se você é novo no Riverpod, leia isto primeiro: Flutter Riverpod 2.x: O Guia Definitivo
Conteudo
Noções básicas do ciclo de vida do Provider: exemplo de aplicativo de contador
Se quisermos compreender profundamente como funcionam os provedores, há muitas coisas a considerar.
Antes de chegarmos ao assunto principal, vamos revisar o básico usando um aplicativo de contador simples como exemplo:
final counterStateProvider = StateProvider<int>((ref) { return 0; });
Como podemos ver, counterStateProvider
é declarado como uma variável global, e dentro do corpo do provedor retornamos seu estado inicial (o valor inteiro 0
).
Então, podemos criar o seguinte ConsumerWidget
:
class CounterWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 1. observe o provedor e reconstrua quando o valor mudar final counter = ref.watch(counterStateProvider); return ElevatedButton( // 2. use o valor child: Text('Value: $counter'), // 3. incrementa o contador quando o botão é pressionado onPressed: () => ref.read(counterStateProvider.notifier).state++, ); } }
Dentro do método build
, observamos o provider
declarado acima e o usamos para mostrar o valor do contador dentro de um widget Text
.
E devemos lembrar de envolver nosso aplicativo com um ProviderScope
:
void main() { runApp(ProviderScope( child: MaterialApp( home: CounterWidget(), ), )); }
O exemplo é simples, mas você consegue descobrir quando o provedor é inicializado?
Reserve um minuto para pensar sobre isso. Vou esperar. ⏱
Quando o provedor é inicializado?
Aqui estão duas respostas plausíveis:
- Quando chamamos
runApp
dentro de main e atribuímos a ele umProviderScope
de nível superior - Quando o widget
CounterWidget
é montado pela primeira vez e chamamosref.watch
dentro do método de construção
Para obter a resposta correta, não precisamos adivinhar.
Na verdade, podemos adicionar algumas instruções de impressão ao nosso código:
import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { if (kDebugMode) { print('Dentro do main'); } runApp(const ProviderScope( child: MaterialApp( home: CounterWidget(), ), )); } final counterStateProvider = StateProvider<int>((ref) { if (kDebugMode) { print('counterStateProvider inicializado'); } return 0; }); class CounterWidget extends ConsumerWidget { const CounterWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { if (kDebugMode) { print('Construção do CounterWidget'); } final counter = ref.watch(counterStateProvider); return ElevatedButton( child: Text('Valor: $counter'), onPressed: () => ref.read(counterStateProvider.notifier).state++, ); } }
Se executarmos este código no Dartpad, veremos esta saída no log do console:
Dentro do main Construção do CounterWidget counterStateProvider inicializado
Isso significa que counterStateProvide
r é inicializado apenas quando chamamos ref.watch(counterStateProvider)
dentro do widget.
E isso ocorre porque todos os provedores Riverpod é lazy-loaded.
Mas vamos nos aprofundar, já que algumas coisas interessantes acontecem nos bastidores.
Usar instruções de impressão e pontos de interrupção de depuração é uma ótima maneira de explorar o comportamento de tempo de execução do seu aplicativo. Diagnosticei e corrigi inúmeros bugs com essas duas ferramentas, portanto, certifique-se de usá-las também. 👍
Registrando um Listener
Vamos dar uma olhada mais de perto no CounterWidget
:
class CounterWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 1. observe o provedor e reconstrua quando o valor mudar final counter = ref.watch(counterStateProvider); return ElevatedButton( // 2. use o valor child: Text('Value: $counter'), // 3. aumente o contador quando o botão for pressionado onPressed: () => ref.read(counterStateProvider.notifier).state++, ); } }
Quando chamamos ref.watch(counterStateProvider)
dentro do método build
, duas coisas acontecem:
- Obtemos o estado do provedor (o valor do contador) para que possamos mostrá-lo na UI
- O
CounterWidget
se torna um ouvinte docounterStateProvider
, para que possa se reconstruir quando o estado do provedor mudar (como quando o incrementamos dentro do retorno de chamadaonPressed
)
Algo interessante também acontece dentro do provedor:
- O estado do provedor é inicializado quando o primeiro ouvinte é registrado
- Cada vez que o estado muda, todos os ouvintes serão notificados para que possam se atualizar/reconstruir
O padrão observável
O aplicativo de contador acima tinha apenas um provedor e um widget de ouvinte.
Mas os provedores podem ter mais de um ouvinte.
E os provedores também podem ouvir outros provedores.
Na verdade, Riverpod se baseia no padrão observável, onde um provedor é observável e outros provedores ou widgets são os observadores (ouvintes):
Tanto
ref.watch()
quantoref.listen()
podem ser usados para registrar-se como ouvinte de um provedor. Isso contrasta comref.read()
, que faz apenas uma leitura única e não registra um ouvinte.
Quando os provedores são descartados?
Até agora, aprendemos que os provedores são lazy-loaded e só são inicializados quando um ouvinte é anexado (via ref.watch
ou ref.listen
).
Mas o que acontece se o CounterWidget
for removido da árvore de widgets?
Bem, isso depende se declaramos o provedor com autoDispose
(ou o novo sinalizador keepAlive
).
Em outras palavras, se declararmos o provedor assim:
// sem autoDispose final counterStateProvider = StateProvider<int>((ref) { return 0; });
Em seguida, o provedor manterá o estado e o manterá na memória até que o ProviderScope
envolvente seja descartado (o que acontece quando o usuário ou sistema operacional mata o aplicativo se tivermos um ProviderScope
de nível superior dentro de main
).
Mas também poderíamos declarar o provedor assim:
// com autoDispose final counterStateProvider = StateProvider.autoDispose<int>((ref) { return 0; });
Nesse caso, o modificador autoDispose
informa ao Riverpod que o estado do provedor deve ser descartado assim que o último ouvinte for removido.
Deixe-me dizer isso novamente:
- Se declararmos um provedor sem
autoDispose
, seu estado permanecerá ativo até que oProviderScope
envolvente seja descartado (a menos que o descartemos explicitamente de alguma outra forma – mais sobre isso abaixo) - Se declararmos um provedor com
autoDispose
, seu estado serádescartado
assim que o último ouvinte forremovido
(normalmente quando o widget for desmontado)
E agora que entendemos o básico, vamos explorar um exemplo mais interessante. 👇
Um exemplo mais complexo: Lista de itens → Página de detalhes
No mobile, é muito comum mostrar uma lista de itens e navegar até uma página de detalhes quando selecionamos um item.
Aqui está um exemplo baseado na API jsonplaceholder:
O código-fonte completo deste exemplo pode ser encontrado neste repositório GitHub.
Para buscar os dados necessários para mostrar a UI, podemos criar um PostsRepository
como este:
// Repositório de posts baseado no cliente http Dio. // Veremos como usar [CancelToken] mais tarde. class PostsRepository { PostsRepository({required this.dio}); final Dio dio; // Busca todas as postagens Future<List<Post>> fetchPosts({CancelToken? cancelToken}) { ... } // Busca uma postagem específica por ID Future<Post> fetchPost(int postId, {CancelToken? cancelToken}) { ... } }
E podemos usar a nova sintaxe @riverpod
para criar algumas funções para buscar os dados:
// usado para gerar um postsRepositoryProvider (como um provedor regular) @riverpod PostsRepository postsRepository(PostsRepositoryRef ref) { return PostsRepository(dio: ref.watch(dioProvider)); } // usado para gerar um fetchPostsProvider (como um FutureProvider) @riverpod Future<List<Post>> fetchPosts(FetchPostsRef ref) { return ref.watch(postsRepositoryProvider).fetchPosts(); } // usado para gerar um fetchPostProvider (como FutureProvider.family) @riverpod Future<Post> fetchPost(FetchPostRef ref, int postId) { return ref.watch(postsRepositoryProvider).fetchPost(postId); }
A sintaxe acima depende do novo pacote Riverpod Generator. Para saber mais, leia: Como gerar automaticamente seus provedores com Flutter Riverpod Generator.
Então, podemos observar o fetchPostsProvider
dentro de um widget PostsList
(estilo omitido para simplificar):
class PostsList extends ConsumerWidget { const PostsList({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // observe o fetchPostsProvider final postsAsync = ref.watch(fetchPostsProvider); // use-o para mostrar uma lista de postagens return postsAsync.when( data: (posts) => ListView.separated( itemCount: posts.length, itemBuilder: (context, index) { final post = posts[index]; return ListTile( leading: Text(post.id.toString(), title: Text(post.title), // ao tocar, navegue até [PostDetailsScreen] onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (context) => PostDetailsScreen(postId: post.id), )), ); }, separatorBuilder: (context, index) => const Divider(), ), loading: () => const Center(child: CircularProgressIndicator()), error: (e, st) => Center(child: Text(e.toString())), ); } }
E quando um item da lista é tocado, podemos navegar para uma nova página onde mostramos os detalhes da postagem:
class PostDetailsScreen extends ConsumerWidget { const PostDetailsScreen({super.key, required this.postId}); final int postId; @override Widget build(BuildContext context, WidgetRef ref) { // observa o fetchPostProvider, passando o postId como argumento final postsAsync = ref.watch(fetchPostProvider(postId)); return Scaffold( appBar: AppBar(title: Text('Post $postId')), body: postsAsync.when( // mostra os detalhes da postagem data: (post) => Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(post.title, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 32), Text(post.body), ], ), loading: () => const Center(child: CircularProgressIndicator()), error: (e, st) => Center(child: Text(e.toString())), ), ); } }
Observe como
PostDetailsScreen
pega opostId
como argumento e o usa ao buscar os dados dofetchPostProvider
. Alternativamente, poderíamos ter passado o objetoPost
diretamente do construtorListView
(sem usarfetchPostProvider
). Isso é mais eficiente, mas não pode ser usado para navegação baseada em URL, pois o URL é apenas uma string e não podemos codificar objetos personalizados dentro do caminho.
Mais uma vez, podemos usar os widgets acima para criar essas duas páginas e navegar para frente e para trás:
A seguir, vamos descobrir quando os provedores são inicializados e descartados.
Quando os provedores Riverpod são descartados?
Para descobrir o que acontece em tempo de execução, vamos adicionar algumas instruções print
ao nosso provedor:
// usado para gerar um fetchPostProvider (como FutureProvider) @riverpod Future<Post> fetchPost(FetchPostRef ref, int postId) { print('init: fetchPost($postId)'); ref.onDispose(() => print('dispose: fetchPost($postId)')); return ref.watch(postsRepositoryProvider).fetchPost(postId); }
E também podemos imprimir algo no método fetchPost
do PostsRepository
:
Future<Post> fetchPost(int postId, {CancelToken? cancelToken}) async { print('dio: fetchPost($postId)'); // aguarda dio.get(...) }
Se executarmos o aplicativo e selecionarmos o primeiro item da lista, este log aparecerá:
flutter: init: fetchPost(1) flutter: dio: fetchPost(1)
E se navegarmos de volta, obteremos isto:
flutter: dispose: fetchPost(1)
Da mesma forma, se selecionarmos outro item e voltarmos novamente, obteremos mais três registros:
flutter: init: fetchPost(2) <-- toque no segundo item flutter: dio: fetchPost(2) flutter: dispose: fetchPost(2) <-- navegar de volta
Se quisermos, podemos selecionar o primeiro item mais uma vez e navegar de volta.
E quando terminarmos, o log completo do console deverá ficar assim:
flutter: init: fetchPost(1) <-- toque no primeiro item flutter: dio: fetchPost(1) flutter: dispose: fetchPost(1) <-- navegar de volta flutter: init: fetchPost(2) <-- toque no segundo item flutter: dio: fetchPost(2) flutter: dispose: fetchPost(2) <-- navegar de volta flutter: init: fetchPost(1) <-- toque no primeiro item flutter: dio: fetchPost(1) flutter: dispose: fetchPost(1) <-- navegar de volta
A saída do console nos diz que:
- cada vez que selecionamos um item, buscamos (e armazenamos em cache) os dados da rede
- assim que navegamos de volta, os dados em cache são descartados
Em outras palavras, os provedores são descartados automaticamente por padrão se usarmos a sintaxe @riverpod
, o que significa que não mantemos o estado quando ele não for mais necessário:
// se usarmos @riverpod, o estado será descartado após a remoção do último ouvinte @riverpod Future<Post> fetchPost(FetchPostRef ref, int postId) { print('init: fetchPost($postId)'); ref.onDispose(() => print('dispose: fetchPost($postId)')); return ref.watch(postsRepositoryProvider).fetchPost(postId); }
Quando assistimos um FutureProvider
acima pela primeira vez, ele buscará os dados da rede e os armazenará em cache para uso posterior. Se observarmos o provedor novamente (antes de ele ser descartado), uma nova solicitação de rede não será feita e os dados armazenados em cache serão retornados.
Mantendo o estado com keepAlive
Se quisermos manter o estado, podemos anotar nosso provedor com keepAlive: true
:
// defina keepAlive: true para manter o estado após a remoção do último ouvinte @Riverpod(keepAlive: true) Future<Post> fetchPost(FetchPostRef ref, int postId) { print('init: fetchPost($postId)'); ref.onDispose(() => print('dispose: fetchPost($postId)')); return ref.watch(postsRepositoryProvider).fetchPost(postId); } // declare também este provedor com keepAlive para evitar um erro de compilação: @Riverpod(keepAlive: true) PostsRepository postsRepository(PostsRepositoryRef ref) { return PostsRepository(dio: ref.watch(dioProvider)); }
Se executarmos o aplicativo agora, podemos navegar até o primeiro item:
flutter: init: fetchPost(1) flutter: dio: fetchPost(1)
Mas se voltarmos à lista de itens, nenhum registro de descarte será impresso no console.
E se selecionarmos o primeiro item novamente, os dados estarão disponíveis imediatamente e nenhuma interface de carregamento será apresentada.
Isso ocorre porque com keepAlive
, Riverpod retém cada objeto Post
que buscamos e pode devolvê-lo imediatamente se solicitarmos novamente o mesmo postId
.
Como funciona o keepAlive?
Usando keepAlive: true
, implementamos um cache que nunca expira (enquanto o aplicativo estiver em execução).
Por outro lado, se usarmos keepAlive: false
(que é o padrão), o Riverpod armazenará os dados em cache apenas enquanto houver ouvintes ativos. Mas assim que o último ouvinte for removido, os dados serão descartados.
Em outras palavras:
- com
keepAlive: true
, os dados permanecem na memória para sempre (mesmo quando não são mais necessários) - com
keepAlive: false
, buscamos novamente os dados da rede todas as vezes (depois que o provedor for descartado)
Estes parecem dois extremos de um amplo espectro:
Mas será que podemos de alguma forma personalizar a estratégia de cache e obter o melhor dos dois mundos?
Cache com tempo limite
Se desejar, podemos chamar ref.keepAlive()
dentro do provedor e usá-lo para definir um cache baseado em tempo limite.
Veja como podemos implementar isso:
@riverpod Future<Post> fetchPost(FetchPostRef ref, int postId) { // alguns logs para fins de monitoramento print('init: fetchPost($postId)'); ref.onCancel(() => print('cancel: fetchPost($postId)')); ref.onResume(() => print('resume: fetchPost($postId)')); ref.onDispose(() => print('dispose: fetchPost($postId)')); // obtenha o [KeepAliveLink] final link = ref.keepAlive(); // um temporizador a ser usado pelos retornos de chamada abaixo Timer? timer; // Um objeto do package:dio que permite cancelar solicitações http final cancelToken = CancelToken(); // Quando o provedor for destruído, cancele a solicitação http e o cronômetro ref.onDispose(() { timer?.cancel(); cancelToken.cancel(); }); // Quando o último ouvinte for removido, inicie um cronômetro para descartar os dados armazenados em cache ref.onCancel(() { // iniciar um cronômetro de 30 segundos timer = Timer(const Duration(seconds: 30), () { // descartar no tempo limite link.close(); }); }); // Se o provedor for escutado novamente após ter sido pausado, cancele o cronômetro ref.onResume(() { timer?.cancel(); }); // Busque nossos dados e passe nosso `cancelToken` para que o cancelamento funcione return ref .watch(postsRepositoryProvider) .fetchPost(postId, cancelToken: cancelToken); }
O código acima usa os retornos de chamada do ciclo de vida onDispose
, onCancel
e onResume
para implementar alguma lógica de tempo limite personalizada e cancelar quaisquer solicitações de rede em andamento se não precisarmos mais da resposta (útil se navegarmos antes que a solicitação seja concluída).
Mais uma vez, vamos usar este aplicativo de exemplo como referência:
Se executarmos o aplicativo com as alterações acima, podemos selecionar o primeiro item para revelar os detalhes da postagem:
flutter: init: fetchPost(1) flutter: dio: fetchPost(1)
Assim que navegamos de volta, obtemos isto:
flutter: cancel: fetchPost(1)
E se selecionarmos o mesmo item novamente antes do tempo limite de 30 segundos, obteremos isto:
flutter: resume: fetchPost(1)
Caso contrário, obtemos isto:
flutter: dispose: fetchPost(1)
Isso significa que não buscaremos os dados da postagem novamente se abrirmos a página de detalhes novamente dentro do limite de 30 segundos.
Retornos de chamada do ciclo de vida do provedor
No exemplo de cache acima, usamos três retornos de chamada do ciclo de vida do provedor para executar a lógica de descarte personalizada em tempo de execução.
No total, existem cinco retornos de chamada diferentes que podemos usar:
ref.onDispose
: acionado logo antes do provedor ser destruídoref.onCancel
: acionado quando o último listener do provedor é removidoref.onResume
: acionado quando um provedor é escutado novamente após ter sido pausadoref.onAddListener
: acionado sempre que um novo listener é adicionado ao provedorref.onRemoveListener
: acionado sempre que um novo listener é removido do provedor
Todos esses retornos de chamada e muitos outros métodos úteis estão documentados na documentação da API da classe Ref.
Para entender melhor quando esses callbacks são acionados, considere este diagrama que representa todos os estados possíveis de um provedor:
Veja como funciona:
Não armazenado em cache
- Quando o aplicativo é iniciado, o estado de cada provedor é “não armazenado em cache” por padrão, pois não há ouvintes ativos.
- Quando um ouvinte é adicionado, o provedor passa para o estado “ativo” e permanece lá enquanto houver pelo menos um ouvinte.
Armazenado em cache (ativo)
- Quando adicionamos mais ouvintes, os dados armazenados em cache são retornados imediatamente
- Quando removemos ouvintes, nada muda, desde que reste um ouvinte
- Quando o último ouvinte é removido, o provedor verifica o sinalizador
keepAlive
. SekeepAlive == false
, ele retorna ao estado “sem cache”. SekeepAlive == true
, ele vai para o estado “pausado”.Em cache (pausado) - Quando o
KeepAliveLink
é fechado, ele retorna ao estado “sem cache”. - Quando um ouvinte é adicionado, ele retorna ao estado “ativo”.
Nota: também é possível que um provedor passe de “não armazenado em cache” para “pausado” se fizermos uma leitura única com ref.read(). Depois disso, o provedor permanece no estado “pausado” (se keepAlive for verdadeiro) ou retorna para “não armazenado em cache” (se keepAlive for falso).
O diagrama acima é um modelo conceitual de como os provedores fazem a transição entre diferentes estados. Isso não leva em conta alguns casos extremos que podem acontecer quando as dependências mudam, mas para nossos propósitos, servirá.
Com este modelo mental, deverá ser mais fácil compreender como os prestadores se comportam.
E se você quiser inspecionar seu comportamento em tempo de execução, adicione algumas instruções print dentro de qualquer provedor que você deseja monitorar:
ref.onCancel(() => print('cancel: fetchPost($postId)')); ref.onResume(() => print('resume: fetchPost($postId)')); ref.onDispose(() => print('dispose: fetchPost($postId)'));
Depois, você pode brincar com seu aplicativo e se divertir olhando os logs do console. 😎
Respostas para perguntas comuns
Até agora, cobrimos muito terreno e exploramos como funciona o cache de dados no Riverpod.
Antes de encerrarmos, vamos tentar responder a algumas perguntas comuns. 👇
Quando devemos definir keepAlive: false?
Podemos usar keepAlive
para personalizar o comportamento de cache dentro de qualquer provedor (não apenas FutureProvider
).
Por padrão, anotamos os provedores com a sintaxe @riverpod
, que é a mesma que @Riverpod(keepAlive: false)
.
Este é um valor padrão sensato porque garante que um provedor seja descartado assim que todos os seus ouvintes forem removidos (sem desperdiçar memória).
Aqui estão alguns outros exemplos onde devemos usar keepAlive
: false (ou o equivalente autoDispose
):
- Quando criamos um notificador para gerenciar o estado de um único widget. Neste cenário, podemos usar a sintaxe padrão
@riverpod
para garantir que oNotifierProvider
seja descartado assim que o widget for desmontado. - Quando usamos um
StreamProvider
e queremos que a conexão do stream seja fechada assim que todos os widgets do ouvinte forem removidos. Podemos fazer isso comautoDispose
.
Para obter mais detalhes sobre as novas classes Notifier no Riverpod 2.0, leia: Como usar Notifier e AsyncNotifier com o novo Flutter Riverpod Generator
Quando devemos definir keepAlive: true?
Se quisermos manter algum estado do aplicativo na memória e tê-lo sempre disponível enquanto o aplicativo estiver em execução, devemos usar @Riverpod(keepAlive: true)
.
Outro exemplo é quando usamos Riverpod para injeção de dependência e criamos provedores de longa duração para objetos que queremos instanciar apenas uma vez (como wrappers para APIs de terceiros como FirebaseAuth
, SharedPreferences
etc.).
Conclusão
Chegamos ao final deste artigo, então vamos fazer um resumo dos pontos mais importantes:
- Os provedores são lazy loaded e são inicializados quando os usamos pela primeira vez
- Quando o último ouvinte de um provedor for removido, o provedor será descartado (se
keepAlive for falso
) ou entrará em estado de pausa (sekeepAlive for verdadeiro
) - Podemos usar retornos de chamada de ciclo de vida como
onDispose
,onCancel
,onResume
para implementar alguma lógica de cache personalizada - Também podemos usar
onDispose
para cancelar quaisquer solicitações de rede em voo que não sejam mais necessárias
A conclusão mais importante é que Riverpod é uma estrutura de cache reativo e ligação de dados (exatamente como dizem os documentos). E ajuda a resolver problemas complexos com código simples usando APIs flexíveis com padrões razoáveis.
Como o cache de dados é um tópico amplo, abordaremos a invalidação do cache e algumas outras técnicas avançadas em um artigo subsequente.