Inicializando o gerenciamento de estado do aplicativo Flutter com Riverpod
Considere isso uma introdução à arquitetura de estado em aplicativos Flutter. Mostrarei como inicializar a inicialização do provedor, gerando provedores e notificadores e algumas práticas recomendadas de arquitetura de estado.
Conteudo
O que estou usando
Estes são os pacotes que estou usando:
Flutter Riverpod, este pacote também importa o pacote riverpod e o pacote state notifier.
Riverpod generator
Riverpod Annotation
E o código fonte deste artigo pode ser encontrado neste repo, que é um aplicativo fólio que estou finalizando:
Agora, mostrarei primeiro a lógica de bootstrap que inicializa todos os serviços do aplicativo.
A lógica Bootstrap para inicializar serviços de aplicativos
Então, primeiro precisamos inicializar a lógica dos provedores:
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:demo/src/app_systems/services/dark_mode_service.dart'; import 'package:demo/src/app_systems/services/logger_service.dart'; import 'package:demo/src/app_systems/services/scaffold_key_services.dart'; import 'package:demo/src/app_systems/services/scroll_service_provider.dart'; import 'package:demo/src/app_systems/services/shared_prefs_service.dart'; /// Triggered from bootstrap() to complete futures. Future<void> appInitProviders(ProviderContainer container) async { /// Core await container.read(sharedPrefsServiceProvider.future); container.read(darkModeServiceProvider); container.read(loggerServiceProvider); container.read(scaffoldMessengerKeyServicePod); container.read(scaffoldMessengerServicePod); container.read(scrollServiceProvider); }
Sim, ProviderScope usa isso internamente. Porém, nós o usamos separadamente aqui para que possamos inicializar os serviços do aplicativo. Consulte o documento da API:
Em seguida, precisamos do bootstrap que usa a lógica do provedor de inicialização acima:
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:folio/src/app_systems/logging/application_logger.dart'; import 'package:folio/src/app_systems/providers/app_init_providers.dart'; import 'package:folio/src/app_systems/services/logger_service.dart'; import 'package:folio/src/domain/models/asset_list.dart'; import 'package:url_strategy/url_strategy.dart'; /// Bootstrap a lógica de inicialização incluindo provedores. /// As melhores práticas são usar ProviderContainer apenas para inicialização e usar /// ref.watch e ref.read emparelhados com os widgets do consumidor no nível da tela. // ignore: prefer-static-class Future<ProviderContainer> appBootstrap() async { // Get binding of Flutter Engine for loading hooks final binding = WidgetsFlutterBinding.ensureInitialized(); ApplicationLogger.init(true); // Cache de imagens na pasta assets usando o binding do Flutter Engine // ciclo de vida. binding.deferFirstFrame(); binding.addPostFrameCallback((_) { final Element? context = binding.renderViewElement; if (context != null) { for (final asset in assetList) { precacheImage( AssetImage(asset), context, ); } } binding.allowFirstFrame(); }); setPathUrlStrategy(); final container = ProviderContainer( // apenas para Flutter, aplicações dart requerem ouvir o container para obter mudanças como // ProviderObserver se conecta ao ciclo de vida do widget. observers: [_Logger()], ); await appInitProviders(container); // definir o appLogger global appLogger = container.read(loggerServiceProvider); return container; } class _Logger extends ProviderObserver { @override void didUpdateProvider( ProviderBase<dynamic> provider, Object? previousValue, Object? newValue, ProviderContainer container, ) { // em termos de observabilidade, não queremos mensagens de log do provedor // produção, portanto, denugPrint é usado, pois é removido no modo de liberação debugPrint( ''' { "provider": "${provider.name ?? provider.runtimeType}", "newValue": "$newValue" }''', ); } }
Ele retorna um Future que, por acaso, é do tipo ProviderContainer. Depois que a instância do ProviderContainer é criada, eu a uso para inicializar os provedores que são os serviços do aplicativo. O registrador ProviderObserver usa o método debugPrint, pois só quero registros das alterações de valores do provedor no modo de depuração.
Então, no principal, tenho isso:
import 'dart:async'; import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:folio/src/app_systems/providers/app_bootstrap.dart'; import 'package:folio/src/my_app.dart'; void main() async { runZonedGuarded<Future<void>>( () async { runApp( UncontrolledProviderScope( container: await appBootstrap(), child: MyApp(), ), ); }, ( Object error, StackTrace stack, ) { // Se estiver usando um serviço de erro de terceiros, chamaremos essa API aqui para passar o erro para log( error.toString(), stackTrace: stack, ); }, zoneSpecification: ZoneSpecification( // Interceptar todas as chamadas de impressão print: ( self, parent, zone, line, ) { // Inclua um registro de data e hora e o nome do aplicativo final messageToLog = "[${DateTime.now()}] Folio $line $zone"; // Também imprima a mensagem no "Console de depuração" // mas é apenas uma mensagem informativa e não contém // coisas proibidas pela privacidade parent.print( zone, messageToLog, ); }, ), ); }
O uso de UncontrolledProviderScope insere o ProviderContainer na árvore de widgets, razão pela qual podemos usar o widget Consumer para obter uma referência a qualquer provedor. Consulte o documento da API:
Agora, vou lhe mostrar como gerar notificadores do Riverpod.
Geração de notificadores do Riverpod
Veja a seguir como gerar um notificador de modo escuro:
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'dark_mode_service.g.dart'; @riverpod class DarkModeService extends _$DarkModeService { late SharedPreferences prefs; @override bool build(){ _init(); return false; } /// O uso é /// ``` /// Switch( /// value: darkMode, /// onChanged: (val) { /// ref.read(darkModeProvider.notifier).toggle(); /// }, /// ``` /// Eu acho. Future<void> toggle() async { state = !state; prefs.setBool( "darkMode", state, ); } Future _init() async { prefs = await SharedPreferences.getInstance(); final darkMode = prefs.getBool("darkMode"); state = darkMode ?? false; } }
O método build é o que estaria em um construtor do notificador. O extends _$DarkModeService informa ao Riverpod Generator que estamos falando de um notificador. E isso não apenas gerará um notificador, mas também um provedor:
// GENERATED CODE - DO NOT MODIFY BY HAND part of 'dark_mode_service.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** String _$darkModeServiceHash() => r'59cc692b9031d13be1caad72f33c92262243cdc3'; /// See also [DarkModeService]. @ProviderFor(DarkModeService) final darkModeServiceProvider = AutoDisposeNotifierProvider<DarkModeService, bool>.internal( DarkModeService.new, name: r'darkModeServiceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$darkModeServiceHash, dependencies: null, allTransitiveDependencies: null, ); typedef _$DarkModeService = AutoDisposeNotifier<bool>; // ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
E o outro notificador padrão que estará em cada aplicativo flutter é aquele que lida com localidade, primeiro nosso modelo de localidade:
// ignore_for_file: no_leading_underscores_for_local_identifiers import 'dart:developer'; import 'dart:ui'; import 'package:folio/src/app_systems/json_local_sync.dart'; import 'package:folio/src/domain/models/locale_json_converter.dart'; import 'package:folio/src/domain/models/persistent_state.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'locale_state.freezed.dart'; part 'locale_state.g.dart'; // Local de reserva // ignore: prefer-static-class const Locale fallbackLocale = Locale('en', 'US',); @freezed class LocaleState with _$LocaleState, PersistentState<LocaleState> { static const _localStorageKey = 'persistentLocale'; const factory LocaleState({ @LocaleJsonConverter() @Default(fallbackLocale) @JsonKey() Locale locale, }) = _LocaleState; // Permitir getters / setters personalizados const LocaleState._(); // Para o gerador de código toJson / fromJson json_serializable integrado ao Riverpod factory LocaleState.fromJson(Map<String, dynamic> json) => _$LocaleStateFromJson(json); /// Local Save /// Salva as configurações no armazenamento persistente. @override Future<bool> localSave() async { final Map<String, dynamic> value = toJson(); try { return await JsonLocalSync.save(key: _localStorageKey, value: value,); } catch (e) { log(e.toString()); return false; } } /// Local Delete /// Exclui as configurações do armazenamento persistente. @override Future<bool> localDelete() async { try { return await JsonLocalSync.delete(key: _localStorageKey); } catch (e) { log(e.toString()); return false; } } /// Criar as configurações a partir do armazenamento persistente /// (O Static Factory Method suporta leitura assíncrona do armazenamento). @override Future<LocaleState?> fromStorage() async { try { final _value = await JsonLocalSync.get(key: _localStorageKey); if (_value == null) { return null; } return LocaleState.fromJson(_value); } catch (e) { rethrow; } } }
E uso o Freezed para torná-lo serializável e imutável. Então, para gerar o notificador que usa LocaleState, eu tenho:
import 'dart:developer'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:folio/src/app_systems/services/platform_locale_service.dart'; import 'package:folio/src/app_systems/services/supported_locales_service.dart'; import 'package:folio/src/domain/models/locale_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'locale_state_service.g.dart'; @riverpod class LocaleStateService extends _$LocaleStateService { @override LocaleState build() => const LocaleState(); /// Initialize Locale /// Pode ser executado na inicialização para estabelecer o local inicial a partir do armazenamento ou da plataforma /// 1. Tenta restaurar o locale do armazenamento. /// 2. se não houver locale no armazenamento, tenta definir o local a partir das configurações da plataforma. Future<void> initLocale() async { // Tentativa de restauração a partir do armazenamento final bool fromStorageSuccess = await ref.read(localeStateServiceProvider.notifier).restoreFromStorage(); // Se a restauração do armazenamento não funcionou, configure na plataforma if (!fromStorageSuccess) { ref .read(localeStateServiceProvider.notifier) .setLocale(ref.read(platformLocaleServiceProvider)); } } //ignorar: comentário-formato /// Definir localidade. /// Tenta definir a localidade se ela estiver em nossa lista de localidades suportadas. /// SE NÃO: obtenha o primeiro locale que corresponda ao nosso código de idioma e defina-o. /// ELSE: não faça nada. void setLocale(Locale locale) { final List<Locale> supportedLocales = ref.read(supportedLocalesServiceProvider); // Defina a localidade se ela estiver em nossa lista de localidades compatíveis if (supportedLocales.contains(locale)) { // Atualizar estado state = state.copyWith(locale: locale); // Salvar na persistência state.localSave(); return; } // Obtenha a localidade de idioma mais próxima e defina-a em seu lugar final Locale? closestLocale = supportedLocales.firstWhereOrNull( (supportedLocale) => supportedLocale.languageCode == locale.languageCode, ); if (closestLocale != null) { // Atualizar estado state = state.copyWith(locale: closestLocale); // Salvar na persistência state.localSave(); return; } // Caso contrário, não faça nada e continuaremos com o locale padrão return; } /// Restaurar localidade do armazenamento. Future<bool> restoreFromStorage() async { try { log("Restoring LocaleState from storage."); // Tentativa de obter o usuário do armazenamento final LocaleState? myState = await state.fromStorage(); // Se o usuário for nulo, não há nenhum usuário para restaurar if (myState == null) { return false; } log("State found in storage: ${myState.toJson()}"); // Definir estado state = myState; return true; } catch (e, s) { log("Error$e"); log(s.toString()); return false; } } }
E quando isso é gerado, ele nos fornece o provedor:
// GENERATED CODE - DO NOT MODIFY BY HAND part of 'locale_state_service.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** String _$localeStateServiceHash() => r'843e29a60f9dcd5bc0e5def1d2e0af62dddfb025'; /// See also [LocaleStateService]. @ProviderFor(LocaleStateService) final localeStateServiceProvider = AutoDisposeNotifierProvider<LocaleStateService, LocaleState>.internal( LocaleStateService.new, name: r'localeStateServiceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$localeStateServiceHash, dependencies: null, allTransitiveDependencies: null, ); typedef _$LocaleStateService = AutoDisposeNotifier<LocaleState>; // ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions
Agora vamos ver um exemplo mais complicado, a navegação.
Provedores de navegação
Aqui estão as rotas do aplicativo, tenha isso em mente antes que o construtor do roteador go mude para oferecer suporte ao ShellRoute, que deve estar em vigor em abril:
import 'package:flutter/material.dart'; import 'package:folio/src/books_feature/books_screen.dart'; import 'package:folio/src/home_feature/home_screen.dart'; import 'package:folio/src/info_feature/info_screen.dart'; import 'package:folio/src/lab_feature/lab_screen.dart'; import 'package:folio/src/showcase_feature/showcase_screen.dart'; import 'package:folio/src/uikits_feature/uikits_screen.dart'; import 'package:go_router/go_router.dart'; part 'routes.g.dart'; /// Essa é a declaração de rota shell das rotas /// associadas ao scaffold compartilhado. /// @TypedGoRoute<HomeRoute>( path: "/", routes: [ TypedGoRoute<BooksRoute>(path: BooksRoute.path), TypedGoRoute<InfoRoute>(path: InfoRoute.path), TypedGoRoute<HomeRoute>(path: HomeRoute.path), TypedGoRoute<LabRoute>(path: LabRoute.path), TypedGoRoute<ShowcaseRoute>(path: ShowcaseRoute.path), TypedGoRoute<UIKitsRoute>(path: UIKitsRoute.path), ], ) class HomeRoute extends GoRouteData { static const path = 'home'; const HomeRoute(); // todos os redirecionamentos de registro e login de usuários devem ser feitos aqui // e ser emparelhados com um ouvinte de notificação obrigatório @override Widget build( BuildContext context, GoRouterState state, ) { return HomeScreen(); } } class LabRoute extends GoRouteData { static const path = 'lab'; const LabRoute(); @override Widget build( BuildContext context, GoRouterState state, ) { return LabScreen(); } } class BooksRoute extends GoRouteData { static const path = 'books'; const BooksRoute(); @override Widget build( BuildContext context, GoRouterState state, ) { return BooksScreen(); } } class InfoRoute extends GoRouteData { static const path = 'info'; const InfoRoute(); @override Widget build( BuildContext context, GoRouterState state, ) { return InfoScreen(); } } class ShowcaseRoute extends GoRouteData { static const path = 'showcase'; const ShowcaseRoute(); @override Widget build( BuildContext context, GoRouterState state, ) { return ShowcaseScreen(); } } class UIKitsRoute extends GoRouteData { static const path = 'uikits'; const UIKitsRoute(); @override Widget build( BuildContext context, GoRouterState state, ) { return UIKitsScreen(); } }
Agora geramos o notificador com isto:
import 'package:flutter/material.dart'; import 'package:folio/src/app_systems/router/routes.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'router_service_notifier.g.dart'; /// Esse notificador foi criado para implementar o [Listenable] que nosso [GoRouter] precisa. /// /// Nosso objetivo é acionar redirecionamentos sempre que for necessário. /// Isso é feito chamando nosso (único) listener toda vez que quisermos notificar algo. /// Isso permite centralizar a lógica de redirecionamento global nessa classe. /// Nesse caso simples, apenas ouvimos as alterações de autenticação. /// /// OBSERVAÇÃO. /// Isso pode parecer muito complicado à primeira vista; /// Em vez disso, esse método tem como objetivo seguir algumas boas práticas: /// 1. não requer que enviemos nenhum parâmetro `ref` /// 2. Ele funciona como um substituto completo para o [ChangeNotifier] (é uma implementação do [Listenable]) /// 3. Permite ouvir vários provedores, se necessário (agora temos um [Ref]!). @riverpod class RouterServiceNotifier extends _$RouterServiceNotifier implements Listenable { VoidCallback? routerListener; /// Nossas rotas de aplicativos. Obtidas por meio da geração de código. List<GoRoute> get routes => $appRoutes; @override Future<void> build() async { ref.listenSelf(( _, __, ) { if (state.isLoading) return; routerListener?.call(); }); } /// Adiciona o ouvinte do [GoRouter] conforme especificado por seu [Listenable]. /// [GoRouteInformationProvider] usa esse método na criação para lidar com seu /// interno [ChangeNotifier]. /// Confira a implementação interna de [GoRouter] e /// [GoRouteInformationProvider] para ver isso em ação. @override void addListener(VoidCallback listener) { routerListener = listener; } /// Remove o ouvinte do [GoRouter] conforme especificado por seu [Listenable]. /// [GoRouteInformationProvider] usa esse método quando descarta, /// para que ele remova seu callback quando destruído. /// Confira a implementação interna de [GoRouter] e /// [GoRouteInformationProvider] para ver isso em ação. @override void removeListener(VoidCallback listener) { routerListener = null; } }
Algumas observações: reimplementei o ChangeNotifier, pois todos os ChangeNotifiers são Listenables. A vantagem é que posso ouvir vários provedores. Então, o provedor que usa esse notificador é:
import 'package:flutter/material.dart'; import 'package:folio/src/app_systems/router/app_router_observer.dart'; import 'package:folio/src/app_systems/router/routes.dart'; import 'package:folio/src/app_systems/services/router_service_notifier.dart'; import 'package:folio/src/shared_scaffold.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'router_service.g.dart'; final _rootRouterKey = GlobalKey<NavigatorState>(debugLabel: 'routerKey'); final _shellRouterKey = GlobalKey<NavigatorState>(debugLabel: 'shellRouterKey'); /// Esse provedor simples armazena em cache nosso GoRouter. /// Por padrão, esse provedor nunca será reconstruído. @riverpod // ignore: prefer-static-class GoRouter routerService(RouterServiceRef ref) { final notifier = ref.watch(routerServiceNotifierProvider.notifier); return GoRouter( routes: [ ShellRoute( builder: ( BuildContext context, GoRouterState state, Widget child, ) { return SharedScaffold(child: child); }, observers: [AppRouterObserver()], routes: notifier.routes, navigatorKey: _shellRouterKey, ), ], refreshListenable: notifier, initialLocation: HomeRoute.path, debugLogDiagnostics: true, navigatorKey: _rootRouterKey, ); }
Em seguida, isso é usado no método MaterialApp.router como:
// ignore_for_file: no_leading_underscores_for_local_identifiers import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:folio/src/app_systems/services/dark_mode_service.dart'; import 'package:folio/src/app_systems/services/locale_state_service.dart'; import 'package:folio/src/app_systems/services/router_service.dart'; import 'package:folio/src/app_systems/services/supported_locales_service.dart'; import 'package:folio/src/app_systems/themes/colors/brand_fcs.dart'; /// O Widget que configura seu aplicativo. class MyApp extends ConsumerWidget { @override Widget build( BuildContext context, WidgetRef ref, ) { final router = ref.watch(routerServiceProvider); final appDarkThemeMode = ref.watch(darkModeServiceProvider); final List<Locale> _supportedLocales = ref.read(supportedLocalesServiceProvider); // Observe o local atual e reconstrua-o ao mudar final Locale _locale = ref.watch(localeStateServiceProvider).locale; return MaterialApp.router( routerConfig: router, // O Widget que configura seu aplicativo. theme: brandThemeDataLight, darkTheme: brandThemeDataDark, themeMode: appDarkThemeMode ? ThemeMode.dark : ThemeMode.light, locale: _locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: _supportedLocales, debugShowCheckedModeBanner: false, restorationScopeId: "Folio", ); } }
O que acontece com os provedores que encaminham para outros provedores?
Provedores que fazem referência a outros provedores
O primeiro caso de uso é o Scroll Controller, que por acaso é um ChangeNotifier legado:
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// ChangeNotifierProvider. O uso é esse em um widget de consumidor: /// ``` /// final sc = ref.watch(scrollControllerProvider); /// ``` /// /// Precisa ser implementado manualmente, pois é legado e não pode ser gerado /// pelo gerador riverpod. /// final scrollServiceProvider = ChangeNotifierProvider.autoDispose((ref) => ScrollController());
Usamos apenas o ChangeNotifierProvider no riverpod que dá suporte a essas coisas antigas.
Agora, para o provedor que se refere a outro caso de uso de provedor das chaves scaffold:
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Um provedor cujo valor é a chave global do scaffold messenger passada para /// [MaterialApp]. /// // ignore: prefer-static-class final scaffoldMessengerKeyServicePod = Provider((ref) { return GlobalKey<ScaffoldMessengerState>(); }); /// Retorna o scaffold messenger associado ao [scaffoldMessengerKeyPod]. /// E a chamada será: /// ``` /// final scaffoldMessenger = ref.read(scaffoldMessengerPod); /// ///scaffoldMessenger.showSnackBar( /// SnackBar( /// content: Text('Some message'), /// ), ///); /// ``` // ignore: prefer-static-class final scaffoldMessengerServicePod = Provider((ref) { return ref.watch(scaffoldMessengerKeyServicePod).currentState!; });
À medida que o riverpod avança em direção à versão 3.0.0, espero que em algum momento não seja mais necessário recorrer à criação manual de provedores ou notificadores.
Pensamentos
Essa é a forma de iniciar o gerenciamento de estado do aplicativo flutter com o riverpod. É um pouco mais fácil do que lidar com os vários provedores implementados no pacote Provider, embora o Riverpod seja do mesmo autor do pacote Provider.