Como criar um fluxo robusto de inicialização de aplicativos Flutter com o Riverpod
Quando os usuários iniciam seu aplicativo Flutter, é fundamental causar uma ótima primeira impressão com um processo de integração suave. Uma falha durante a inicialização pode levá-los a excluir seu aplicativo e deixar uma avaliação negativa.
Então, como você pode evitar problemas e garantir que o código de inicialização do seu aplicativo seja robusto e funcione como pretendido?
É isso que vamos descobrir neste artigo.
Começaremos aprendendo a usar um StatefulWidget
para lidar com essas preocupações:
- Mostrar alguma UI de carregamento enquanto o aplicativo está sendo inicializado
- Tratar erros e tentar novamente se algo der errado
Em seguida, daremos um passo adiante e aprenderemos sobre a inicialização ansiosa de provedores com o Riverpod. Essa técnica facilita a inicialização antecipada de nossas dependências para que possamos acessá-las de forma síncrona usando requireValue
posteriormente.
No passado, eu dependia de substituições de provedor para inicializar dependências assíncronas. Mas, como veremos, essa abordagem está desatualizada. A inicialização ansiosa do provedor combinada com
requireValue
oferece uma alternativa superior.
Pronto? Vamos lá! 👇
Conteudo
Tratamento de erros de inicialização de aplicativos: O básico
Para começar, vamos considerar este código:
void main() async { await someAsyncCodeThatMayThrow(); runApp(const MainApp()); }
O que acontecerá se o código acima lançar uma exceção?
A resposta é que o runApp
não será executado, e o aplicativo permanecerá preso na tela inicial – eca! 😱
Como uma pequena melhoria, poderíamos envolver nosso código desta forma:
void main() async { try { await someAsyncCodeThatMayThrow(); runApp(const MainApp()); } catch (e, st) { log(e.toString(), stackTrace: st); runApp(const AppStartupErrorWidget(e)); } }
Ou, para um controle de erros ainda melhor, podemos usar a função runZonedGuarded
:
void main() { runZonedGuarded( () async { await someAsyncCodeThatMayThrow(); return const MainApp(); }, (e, st) { log(e.toString(), stackTrace: st); runApp(const AppStartupErrorWidget(e)); }, ); }
De qualquer forma, estamos chamando runApp(AppStartupErrorWidget(e))
quando ocorre um erro para que possamos mostrar alguma interface de usuário de erro ao usuário quando as coisas dão errado.
No entanto, com essa abordagem, não podemos “tentar novamente” e nos recuperar com elegância. Se o processo de inicialização falhar, nossa única opção é forçar o fechamento do aplicativo e reiniciá-lo.
Então, vamos tentar fazer melhor!
Tratamento aprimorado de erros com um StatefulWidget
Para melhorar a experiência do usuário, vamos considerar alguns requisitos adicionais:
- Exibir uma tela de carregamento durante a inicialização
- Se a inicialização falhar, mostrar uma mensagem de erro com um botão “Retry” (Tentar novamente)
- Se tudo correr bem, mostrar a UI principal do aplicativo
Podemos gerenciar esses cenários – loading, error e success – usando um StatefulWidget
personalizado que se torna responsável pela lógica de inicialização do aplicativo:
class AppStartupWidget extends StatefulWidget { const AppStartupWidget({super.key}); @override State<AppStartupWidget> createState() => _AppStartupWidgetState(); } class _AppStartupWidgetState extends State<AppStartupWidget> { // declare state variables @override void initState() { // handle async initialization super.initState(); } @override Widget build(BuildContext context) { /* * if (success) return MainApp() * if (loading) return AppStartupLoadingWidget() * if (error) return AppStartupErrorWidget(error, onRetry: () { ... }) */ } }
E isso simplifica nosso método main()
para apenas esta linha:
void main() { runApp(const AppStartupWidget()); }
Para desenvolver esse widget, podemos considerar as seguintes etapas:
- Declarar algumas sealed classes para representar os três estados possíveis
- Adicionar o código assíncrono a
initState()
e atualizar o estado em caso de sucesso ou erro - Usar uma switch expression para mapear o estado para a UI no método
build()
- Adicionar a lógica de nova tentativa
Entretanto, essa abordagem exige bastante trabalho.
Além disso, se tivermos de inicializar algumas dependências e passá-las por todo o aplicativo, confiar apenas no AppStartupWidget
será insuficiente. Seria bom integrar uma estrutura de injeção de dependência ou um localizador de serviços.
Com isso em mente, vamos dar uma olhada mais de perto na configuração de dependência assíncrona. 👇
Inicialização assíncrona de dependências com o Riverpod
O código de inicialização que compartilhei anteriormente tem a seguinte aparência:
await someAsyncCodeThatMayThrow();
Mas em um aplicativo do mundo real, você provavelmente terá dependências que precisam estar prontas para uso posterior. Os provedores Riverpod são perfeitos para esse trabalho. Por exemplo:
// Um provedor regular para acessar uma dependência que é inicializada de forma *sincrônica* @Riverpod(keepAlive: true) FirebaseAuth firebaseAuth(FirebaseAuthRef ref) => FirebaseAuth.instance;
No entanto, algumas dependências são inicializadas de forma assíncrona e, para elas, podemos usar um FutureProvider
:
// Um FutureProvider para acessar uma dependência que é inicializada de forma *assíncrona* @Riverpod(keepAlive: true) Future<SharedPreferences> sharedPreferences(SharedPreferencesRef ref) => SharedPreferences.getInstance(); // retorna um futuro
Aqui, queremos que a dependência seja inicializada assim que o aplicativo for iniciado.
Mas lembre-se de que os provedores do Riverpod são inicializados de forma preguiçosa por padrão – elessão criados quando usados pela primeira vez, não quando declarados. E a documentação diz que, se quisermos inicializar um provedor com ansiedade, podemos fazer isso com um widget filho.
Observe como usei
keepAlive: true
nas declarações acima. Isso faz sentido quando queremos que as dependências sejam inicializadas apenas uma vez, durante a inicialização do aplicativo.
Inicialização do provedor Eager com um widget filho
Vamos continuar com nosso exemplo. Criaremos um appStartupProvider
personalizado:
@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // garantir que invalidemos todos os provedores dos quais dependemos ref.invalidate(sharedPreferencesProvider); }); // todo o código de inicialização assíncrona do aplicativo deve estar aqui: await ref.watch(sharedPreferencesProvider.future); }
Alguns aspectos a serem observados:
- Nós invalidamos o provedor
sharedPreferencesProvider
dentro da chamada de retornoonDispose
. Como veremos a seguir, isso é acionado quando invalidamos o próprioappStartupProvider
dentro do nosso widget. - Para garantir que o
sharedPreferencesProvider
seja inicializado, estamos usandoawait
com a sintaxe.future
. Esse truque é explicado aqui.
Agora, vamos redefinir nosso AppStartupWidget
da seguinte forma:
/// Classe de widget para gerenciar a inicialização assíncrona do aplicativo class AppStartupWidget extends ConsumerWidget { const AppStartupWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // 2. inicializar com antecedência o appStartupProvider (e todos os provedores dos quais ele depende) final appStartupState = ref.watch(appStartupProvider); return appStartupState.when( // 3. estado de carregamento loading: () => const AppStartupLoadingWidget(), // 4. estado de erro error: (e, st) => AppStartupErrorWidget( message: e.toString(), // 5. invalidar o appStartupProvider onRetry: () => ref.invalidate(appStartupProvider), ), // 6. sucesso - agora carregue o aplicativo principal data: (_) => MainApp(), ); } } void main() { // 1. carregá-lo quando o aplicativo for iniciado runApp(const AppStartupWidget()); }
Adivinhe só?
Nossa configuração preenche todos os requisitos:
- Assim que o aplicativo é iniciado, o
AppStartupWidget
é carregado - Isso faz com que o
appStartupProvider
seja inicializado (juntamente com todos os provedores dos quais ele depende) - Enquanto o provedor está sendo carregado, mostramos um
AppStartupLoadingWidget
personalizado - Se houver um erro, mostraremos um
AppStartupErrorWidget
com uma opção de nova tentativa - Se a opção retry for selecionada, invalidamos o
appStartupProvider
E se a inicialização for bem-sucedida, o widget MainApp
assume o palco.
Observe que, como
o AppStartupWidget
é um widget de nível superior, tantoo AppStartupLoadingWidget
quantoo AppStartupErrorWidget
precisam retornar umMaterialApp
.
Acesso a provedores ansiosamente inicializados com requireValue
Um detalhe importante a ser observado é que, depois que o MainApp
é carregado, é garantido que o sharedPreferencesProvider
tenha um valor.
Isso significa que sempre que precisarmos dele, podemos fazer isso:
@override Widget build(BuildContext context, WidgetRef ref) { final sharedPrefs = ref.watch(sharedPreferencesProvider).requireValue; }
Em termos simples, ao usar requireValue
, estamos afirmando: “Sei que esse provedor foi configurado de forma assíncrona, mas quando o chamo aqui, ele sempre tem um valor”.
Isso funciona porque o provedor é um FutureProvider
que é inicializado com antecedência antes de o MainApp
ser carregado. Portanto, todo widget descendente do MainApp
pode assumir que ele tem um valor.
Atenção: se você tentar acessar
requireValue
em um provedor que ainda não está pronto, ocorrerá uma exceção. Se isso acontecer, é hora de depurar e rever suas suposições.
A maneira “antiga”: Provider Overrides (não use isso)
A maneira antiga de fazer as coisas era declarar um provedor normal que lançava um UnimplementedError
:
@Riverpod(keepAlive: true) SharedPreferences sharedPreferences(SharedPreferencesRef ref) => throw UnimplementedError();
E então, nós a substituímos na função main
:
void main() async { final sharedPreferences = await SharedPreferences.getInstance(); runApp(ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(sharedPreferences) ], child: const MainApp(), )); }
Se você estiver trabalhando em uma base de código antiga, poderá encontrar essa solução – ou uma variante que usa ProviderContainer
e UncontrollerProviderScope
para inicializar o provedor com antecedência.
Mas, como vimos, não devemos inicializar dependências dentro do main
, pois não podemos nos recuperar se algo der errado. Por esse motivo, a inicialização ansiosa do provedor dentro de um widget filho é mais segura e funciona muito bem em conjunto com requireValue
.
Observação importante sobre navegação por URL e links diretos
Como vimos, podemos inicializar nossas dependências inserindo um AppStartupWidget
no topo da árvore de widgets.
Mas se nosso aplicativo precisar oferecer suporte à navegação por URL ou por meio de links profundos, o widget raiz precisará retornar um MaterialApp.router
configurado com uma instância do GoRouter (ou equivalente), e não é isso que o AppStartupWidget
faz.
Para resolver isso, precisamos dar um passo atrás e restaurar a configuração original:
void main() { runApp(const ProviderScope( // * Use MainApp, not AppStartupWidget child: MainApp(), )); }
Em seguida, podemos garantir que o MainApp
use a API do roteador:
class MainApp extends ConsumerWidget { const MainApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final goRouter = ref.watch(goRouterProvider); return MaterialApp.router( routerConfig: goRouter, ..., ); } }
Por fim, podemos monitorar o appStartupProvider
dentro do provedor GoRouter e usar a chamada de retorno de redirecionamento
para verificar o estado e abrir uma rota /startup
que retornará o AppStartupWidget
desta forma:
@riverpod GoRouter goRouter(GoRouterRef ref) { // Reconstruir o GoRouter quando o estado de inicialização do aplicativo for alterado final appStartupState = ref.watch(appStartupProvider); return GoRouter( ..., redirect: (context, state) { // * Se o aplicativo ainda estiver sendo inicializado, mostre a rota /startup if (appStartupState.isLoading || appStartupState.hasError) { return '/startup'; } ... }, routes: [ GoRoute( path: '/startup', pageBuilder: (context, state) => NoTransitionPage( child: AppStartupWidget( // * Isso é apenas um espaço reservado // * A rota carregada será gerenciada pelo GoRouter na mudança de estado onLoaded: (_) => const SizedBox.shrink(), ), ), ), ... ], ); }
O resultado líquido é que ainda veremos o AppStartupWidget
durante a inicialização do aplicativo, sem perder a capacidade de navegar por URL e processar links diretos.
Perguntas comuns
Depois de nos aprofundarmos no tópico, vamos abordar algumas perguntas que você possa ter.
Como inicializar rapidamente vários provedores?
Se precisarmos inicializar vários provedores em um só lugar, usar o appStartupProvider
é uma medida inteligente:
@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { // todo o código de inicialização assíncrona do aplicativo deve estar aqui: await ref.watch(sharedPreferencesProvider.future); await ref.watch(sembastDatabaseProvider.future); }
Se quiser, você pode até usar o Future.wait
se suas dependências não dependerem umas das outras:
@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { // aguardar que todo o código de inicialização seja concluído antes de retornar await Future.wait([ ref.watch(sharedPreferencesProvider.future), ref.watch(onboardingRepositoryProvider.future) ]); }
Dessa forma, você poderá economizar alguns milissegundos do tempo de inicialização do aplicativo.
E quanto aos erros do programador ou de configuração?
Em geral, faz sentido carregar as dependências avidamente dentro do appStartup
se a inicialização delas puder falhar devido a uma exceção inesperada.
Mas se a inicialização só puder falhar devido a um erro do programador, ainda assim recomendo executá-la dentro do main
.
Um exemplo disso é o código clássico de inicialização do Firebase:
Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); // * Inicializar o Firebase await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // * Ponto de entrada do aplicativo runApp(const ProviderScope( child: MainApp(), )); }
Nesse caso, qualquer erro de configuração pode ser imediatamente detectado (e corrigido) durante a execução do aplicativo, portanto, é melhor manter esse código dentro do main
e garantir que ele funcione como pretendido.
Como implementar a lógica de repetição?
Se estivermos inicializando vários provedores e uma exceção for lançada, como saberemos qual provedor falhou?
A menos que implementemos um fluxo mais complexo de tratamento de erros, simplesmente não saberemos.
Mas para manter as coisas simples, estou invalidando o appStartupProvider
em meu fluxo de repetição:
AppStartupErrorWidget( message: e.toString(), onRetry: () { ref.invalidate(appStartupProvider); }, )
Em seguida, dentro do provedor, invalido todos os provedores com a callback onDispose
:
@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // garantir que os provedores dependentes também sejam descartados ref.invalidate(onboardingRepositoryProvider); ref.invalidate(sembastDatabaseProvider); }); // aguardar que todo o código de inicialização seja concluído antes de retornar await ref.watch(onboardingRepositoryProvider.future); await ref.watch(sembastDatabaseProvider.future); }
Essencialmente, isso diz: “Não sei qual provedor falhou, então recarregue todos eles por precaução”.
Isso provavelmente é bom para a maioria dos aplicativos e é uma abordagem muito melhor do que inicializar as dependências no main
e ignorar os erros. Mas se você quiser ir além, fique à vontade para implementar um fluxo de tratamento de erros mais robusto.
Posso tornar a lógica de inicialização do aplicativo mais reutilizável?
Sim. Uma maneira é aprimorar o AppStartupWidget
adicionando um argumento onLoaded
:
/// Classe de widget para gerenciar a inicialização assíncrona do aplicativo class AppStartupWidget extends ConsumerWidget { const AppStartupWidget({super.key, required this.onLoaded}); final WidgetBuilder onLoaded; @override Widget build(BuildContext context, WidgetRef ref) { final appStartupState = ref.watch(appStartupProvider); return appStartupState.when( data: (_) => onLoaded(context), loading: () => const AppStartupLoadingWidget(), error: (e, st) => AppStartupErrorWidget( message: e.toString(), onRetry: () { ref.invalidate(appStartupProvider); }, ), ); } }
Dessa forma, podemos especificar qual widget deve ser carregado do lado de fora:
runApp(ProviderScope( child: AppStartupWidget( onLoaded: (context) => const MainApp(), ), ));
Qualquer provedor pode ser inicializado rapidamente?
Não. O principal objetivo do appStartupProvider
é inicializar dependências assíncronas que não mudam depois que o aplicativo é iniciado.
Dessa forma, os provedores que podem alterar seu estado não devem ser inicializados com ansiedade, pois isso pode desencadear reconstruções indesejadas.
Como fazer a transição entre as telas Splash, Loading e Main UI?
Até que o runApp
seja chamado, o aplicativo Flutter mostra uma tela inicial nativa (que pode ser configurada com um pacote como o flutter_native_splash).
Se quiser garantir uma transição suave, personalize sua tela de carregamento para que ela corresponda à tela inicial nativa e sobreponha sua UI de carregamento com algumas animações.
Da mesma forma, é possível animar entre a tela de carregamento e a IU da tela principal quando a inicialização estiver concluída.
Conclusão
Quando se trata de aplicativos móveis, oferecer uma experiência de integração agradável é a sua chance de impressionar os usuários.
Para evitar frustrações, a lógica de inicialização do seu aplicativo deve ser robusta e tratar os erros com elegância, e as técnicas abordadas neste artigo devem ajudá-lo com isso.
Aqui estão os pontos principais:
- Inicialize todos os provedores assíncronos dentro de um
appStartupProvider
(usandoawait
e.future
) - Inicializar ansiosamente o
appStartupProvider
dentro de um widget de nível superior - Mostrar alguma interface de carregamento, tratar erros e fornecer um mecanismo de “repetição”.
- Acesse suas dependências assíncronas com
requireValue
- Se o seu aplicativo for compatível com navegação por URL e links diretos, mova a lógica de inicialização relevante para dentro da instância do GoRouter