Como criar um fluxo robusto de inicialização de aplicativos Flutter com o Riverpod

Tempo de leitura: 9 minutes

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á! 👇

 

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 retorno onDispose. Como veremos a seguir, isso é acionado quando invalidamos o próprio appStartupProvider dentro do nosso widget.
  • Para garantir que o sharedPreferencesProvider seja inicializado, estamos usando await 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:

  1. Assim que o aplicativo é iniciado, o AppStartupWidget é carregado
  2. Isso faz com que o appStartupProvider seja inicializado (juntamente com todos os provedores dos quais ele depende)
  3. Enquanto o provedor está sendo carregado, mostramos um AppStartupLoadingWidget personalizado
  4. Se houver um erro, mostraremos um AppStartupErrorWidget com uma opção de nova tentativa
  5. 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, tanto o AppStartupLoadingWidget quanto o AppStartupErrorWidget precisam retornar um MaterialApp.

 

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 (usando await 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