Le préchauffage des providers asynchrones

Publié le March 5, 2024
4 min read
flutter
provider
riverpod
dependecy-injection
Le préchauffage des providers asynchrones

Riverpod

Commençons par définir Riverpod

L’une des fonctionnalités majeures de Riverpod est de se comporter comme un conteneur, qui permet de faire de l’injection de dépendances. Cependant, on ne peut pas le résumer à cela uniquement ; il peut également être vu comme un gestionnaire d’état réactif. Par “réactif”, j’entends le fait qu’un changement d’état peut être écouté et par exemple induire une reconstruction d’un widget.

Qu’est ce qu’un Provider

Si vous compter utiliser Riverpod vous aller rencontrer cette notion fondamentale que l’on appelle provider. On pourrait définir un provider comme une fonction qui retourne une valeur. Cette valeur retournée peut être une dépendance, un état, ou tout ce que vous voulez.

dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

class WeatherService {
    String getWeatherByLocation(String location) {
        if (location == "Paris") {
            return "9°C";
        }

        return "21°C"
    }
}

final weatherServiceProvider = Provider<WeatherService>((ref) => WetherService());

Pour obtenir l’instance WeatherService depuis un widget, on peut écrire :

dart
class WeatherWidget extends ConsumerWidget {
    
  
  Widget build(BuildContext context, WidgetRef ref) {
    final weatherService = ref.read(weatherServiceProvider);
    return Text(weatherService.getWeatherByLocation("Paris"));
  }
}

FutureProvider

Il existe plusieurs types de providers, et je voudrais m’attarder plus précisément sur le type FutureProvider. Comme son nom l’indique, c’est un provider qui retourne une valeur de type Future.

dart
class LocationFactory {
    Future<LocationInstance> getInstance() async {
        return Future.value(LocationInstance());
    }
}

final locationProvider = FutreProvider<LocationInstance>((ref) => await LocationFactory.getInstance());

Comment lire un FutureProvider depuis un widget ?

dart
class MapsWidget extends ConsumerWidget {
    
  
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue<LocationInstance> locationService = ref.watch(locationProvider);
    final myPosition = locationService.when(
            data: (service) => service.findMyPosition(),
            error: (error, stack) => "Location error loading",
            loading: () => "Loading location service"
        );
    
    return Text(myPosition);
  }
}

Qu’est ce qui se passe ici ?

Au moment où l’on cherche à utiliser le service de géolocalisation, on doit faire appel au conteneur Riverpod pour obtenir une instance de notre service via la méthode watch.

Cependant, celui-ci n’a peut-être jamais encore été chargé depuis le lancement de notre application. Riverpod nous transmet donc un objet AsyncValue<LocationInstance>.

L’obtention de cette instance se fera après un délai d’attente. La tentative d’obtention de l’instance pourrait échouer pour une raison quelconque. On a donc trois états potentiels à gérer.

  • Disponible : L’instance peut être utilisée.
  • En Chargement : L’instance est en cours de chargement:
  • En Erreur : Une erreur s’est produite lors de la demande de l’instance de notre service au conteneur.

Une autre solution consisterait à forcer Riverpod à nous fournir une instance de notre service avec l’extension requireValue comme ceci :

dart
class MapsWidget extends ConsumerWidget {
    
  
  Widget build(BuildContext context, WidgetRef ref) {
    final LocationInstance locationService = ref.read(locationProvider).requireValue;
    final myPosition = locationService.findMyPosition();
    return Text(myPosition);
  }
}

Le risque encouru est que si le service est en cours de chargement, une exception de type StateError sera émise. De même, en cas d’échec lors de la tentative d’obtention, l’exception correspondant au problème sous-jacent sera levée.

Comment gérer cela de manière élégante ?

Préchauffage des Providers Asynchrones

Pour gérer le cas des providers asynchrones, on peut imaginer les appeler en amont du lancement de notre application, dans la méthode main() de notre application. Cette technique peut être appelée "WarmUp" des FutureProvider. Elle consiste plus précisément à vérifier l’état de tous nos providers asynchrones.

Une fois ceux-ci disponibles et marqués comme chargés, on peut poursuivre l’exécution de notre application. Cela implique que tant que nos providers ne sont pas “chauds”, on affiche un loader ou une animation d’attente.

dart
void main() {
    runApp(
      const ProviderScope(
        child: WarmUp(),
      ),
    );
}
dart
class WarmUp extends ConsumerStatefulWidget {
  const WarmUp({super.key});

  
  ConsumerState<WarmUp> createState() => _WarmUpState();
}

class _WarmUpState extends ConsumerState<WarmUp> {
  bool warmedUp = false;

  
  Widget build(BuildContext context) {
    final logger = Logger();
    if (warmedUp) {
      return const MyApp();
    }
    final providers = <ProviderListenable<AsyncValue<Object?>>>[
      locationProvider, // Add here all future provider to be ready before app is started
    ];

    final states = providers.map(ref.watch).toList();

    for (final state in states) {
      if (state is AsyncError) {
        logger.e("warm up async provider failed: $state");
        Error.throwWithStackTrace(
            state.error, state.stackTrace); // TODO show nice error screen
      }
    }

    if (states.every((state) => state is AsyncData)) {
      logger.d("warmed up all async provider done");
      Future(() => setState(() => warmedUp = true));
    }

    return Center(
      child: Loader(),
    );
  }
}

Conclusion

Grâce à la technique du "WarmUp" et à l’utilisation de l’extension requireValue, nous n’avons plus à nous soucier du chargement de nos providers asynchrones.

Modifié le March 6, 2024