Découverte du pattern BLoC en Flutter avec une application Pokédex

En Flutter, il existe différentes librairies qui permettent une gestion globale d’états dans l’application, telles que Riverpod présenté par notre expert Adrien Bonnin (vous pouvez d'ailleurs retrouver la suite ici).
Aujourd’hui nous allons nous concentrer sur BLoC (Business Logic Component).

L’architecture BLoC se sépare en 3 parties : UI, BLoC et la data.

L’installation de la librairie BLoC pour Flutter se fait via la commande :

$ flutter pub add flutter_bloc

Nous avons utilisé BlocProvider et BlocBuilder dans notre projet afin de gérer efficacement l'état et la logique des blocs de données de manière efficace. BlocProvider fournit le BLoC à travers l'arborescence des widgets, tandis que BlocBuilder permet de reconstruction du widget en fonction des changements d'état du BLoC.

Il existe d’autres exemples sur les pages suivantes : library bloc, documentation bloc, github de bloc

Création d’un BLoC

Pour la création d’un BLoC, nous allons créer 3 fichiers différents :

pokemon_state.dart, pokemon_event.dart et pokemon_bloc.dart.(Grâce à un plugin sur VSCode, la génération des 3 fichiers peut se faire automatiquement).

Dans un premier temps, nous allons définir l’état du BLoC dans la classe PokemonState.

part of 'pokemon_bloc.dart';

class PokemonState extends Equatable {
	const PokemonState({
		this.status = PokemonStatus.initial,
		this.pokemons = const <Pokemon>[],
	});

	final PokemonStatus status;
    final List<Pokemon> pokemons;

    PokemonState copyWith({
    	PokemonStatus? status,
    	List<Pokemon>? pokemons,
    }) {
    	return PokemonState(
       		status: status ?? this.status,
      		pokemons: pokemons ?? this.pokemons,
     	);
	}

    @override
    List<Object> get props => [status, pokemons];
}

enum PokemonStatus { initial, success, failure }

Cette classe va prendre en paramètre un état que l’on définit avec l’enum PokemonStatus mais aussi ce que doit contenir le BLoC, ici une liste de pokémons.

part of 'pokemon_bloc.dart';

abstract class PokemonEvent extends Equatable {
    @override
    List<Object> get props => [];
}

class PokemonFetched extends PokemonEvent {}

Ensuite, on définit une classe abstraite PokemonEvent .

Les events sur BLoC permettent de déclencher des actions ou des changements d'état pour faciliter la gestion réactive de notre application. Chaque événement sera une nouvelle classe qui hérite de PokemonEvent. Nous avons PokemonLike et PokemonFetched comme événement.

Pour résumer, notre PokemonBloc recevra des PokemonEvents et les traitera (côté data) pour mettre à jour notre PokemonState et le diffuser à l'UI.

Regardons maintenant notre PokemonBloc :

import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:pokedex/pokemon/models/pokemon_model.dart';
import 'package:pokedex/pokemon/repository/pokemon_repository.dart';

part 'pokemon_event.dart';
part 'pokemon_state.dart';

class PokemonBloc extends Bloc<PokemonEvent, PokemonState> {
  PokemonBloc({
    required PokemonRepository pokemonRepository,
  })  : _pokemonRepository = pokemonRepository,
        super(const PokemonState()) {
    on<PokemonFetched>(_onPokemonFetched);
    on<PokemonLike>(_onPokemonLiked);
  }

  final PokemonRepository _pokemonRepository;

  Future<void> _onPokemonFetched(
    PokemonFetched event,
    Emitter<PokemonState> emit,
  ) async {
    try {
      final pokemons = await _pokemonRepository.fetchPokemons();
      pokemons.isEmpty
          ? emit(state.copyWith(status: PokemonStatus.failure))
          : emit(
              state.copyWith(
                status: PokemonStatus.success,
                pokemons: List.of(state.pokemons)..addAll(pokemons),
              ),
            );
    } catch (_) {
      emit(
        state.copyWith(status: PokemonStatus.failure),
      );
    }
  }

  void _onPokemonLiked(
    PokemonLike event,
    Emitter<PokemonState> emit,
  ) {
    try {
      final pokemonIndex = state.pokemons.indexOf(event.pokemon);
      final pokemons = List<Pokemon>.from(state.pokemons);
      pokemons[pokemonIndex] = event.pokemon.updateLikeStatus(event.isLiked);

      emit(
        state.copyWith(
          status: PokemonStatus.success,
          pokemons: pokemons,
        ),
      );
    } catch (_) {
      emit(
        state.copyWith(status: PokemonStatus.failure),
      );
    }
  }
}

La classe PokemonBloc hérite la classe Bloc en prenant comme type un PokemonEvent et un PokemonState.

Dans le constructeur du BLoC, on passera les méthodes de traitement des événement spécifiques via le mot-clé on. Ainsi l'événement PokemonFetched appellera la fonction _onPokemonFetched qui va émettre un nouveau state du BLoC ?

Création de la vue

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pokedex/pokemon/bloc/pokemon_bloc.dart';
import 'package:pokedex/pokemon/repository/pokemon_repository.dart';
import 'package:pokedex/pokemon/views/pokemon_list.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Pokedex App')),
      body: RepositoryProvider(
        create: (context) => PokemonRepository(),
        child: BlocProvider(
          create: (_) => PokemonBloc(
              pokemonRepository:
                  RepositoryProvider.of<PokemonRepository>(context))
            ..add(PokemonFetched()),
          child: const PokemonsList(),
        ),
      ),
    );
  }
}

Pour commencer la vue, nous allons définir une HomePage contenant un Scaffold.

Dans le body, Nous appelons un BlocProvider du package flutter_bloc.

BlocProvider permet de fournir un BLoC à ses enfants. Il est utilisé comme widget d'injection de dépendances (DI) afin qu'une instance unique d'un BLoC puisse être fournie à plusieurs widgets. Ici nous fournissons le PokemonBloc où l’on ajoute un évènement PokemonFetched grâce à la fonction .add() pour l’initialisation.

Ensuite, on appelle le widget enfant : PokemonList.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pokedex/pokemon/bloc/pokemon_bloc.dart';
import 'package:pokedex/pokemon/widgets/pokemon_list_item.dart';

class PokemonsList extends StatefulWidget {
  const PokemonsList({super.key});

  @override
  State<PokemonsList> createState() => _PokemonsListState();
}

class _PokemonsListState extends State<PokemonsList> {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PokemonBloc, PokemonState>(
      builder: (context, state) {
        switch (state.status) {
          case PokemonStatus.failure:
            return const Center(child: Text('failed to fetch Pokemons'));
          case PokemonStatus.success:
            if (state.pokemons.isEmpty) {
              return const Center(child: Text('no Pokemons'));
            }
            return ListView.builder(
              itemBuilder: (BuildContext context, int index) {
                return PokemonListItem(pokemon: state.pokemons[index]);
              },
              itemCount: state.pokemons.length,
            );
          case PokemonStatus.initial:
            return const Center(child: CircularProgressIndicator());
        }
      },
    );
  }
}

Dans le widget PokemonList on utilise donc un BlocBuilder qui va nous permettre d’adapter la vue en fonction de l’état du bloc.

Le BlocBuilder récupère le BLoC fourni par le BlocProvider ainsi que l’état de ce bloc. On fait donc un switch sur les différents état du BLoC pour renvoyer un widget adapté.

Récupération des données avec le Repository

Ce projet est organisée en plusieurs packages :

  • widgets contient des éléments UI qui pourront être réutilisables dans le code.
  • views représente les écrans de notre application.
  • BLoC gère l’état et la logique de nos éléments dans l’application.
  • models définissent les données de l’application.
  • repository gèrent la récupération et le stockage des données.

Cette architecture favorise la réutilisation de code, la séparation des responsabilités et une gestion structurée de l’état des éléments dans l’application. Voici le plant uml de l’architecture du projet.

Pour récupérer des données de l’API, nous allons créer un repository.

class PokemonRepository {
  final PokemonDataProvider pokemonDataProvider = PokemonDataProvider(
    httpClient: http.Client(),
  );

  Future<List<Pokemon>> fetchPokemons() async {
    final List<Pokemon> pokemons = await pokemonDataProvider.fetchPokemons();
    return pokemons;
  }
}

Dans la classe PokemonRepository, nous allons appeler une instance de PokemonDataProvider en lui donnant en paramètre un httpClient pour lui permettre de faire une requête à l’API.

Ensuite nous définissons simplement une fonction fetchPokemons qui appelle la fonction fetchPokemon du pokemonDataProvider.

class PokemonDataProvider {
  PokemonDataProvider({required this.httpClient});

  final http.Client httpClient;

  Future<List<Pokemon>> fetchPokemons() async {
    final response = await httpClient.get(
      Uri.parse('https://pokebuildapi.fr/api/v1/pokemon/'),
    );
    if (response.statusCode == 200) {
      final body = json.decode(response.body) as List;
      return body.map((dynamic json) {
        final map = json as Map<String, dynamic>;
        return Pokemon(
          id: map['id'] as int,
          name: map['name'] as String,
          pictureUrl: map['image'] as String,
          pokedexId: map['pokedexId'] as int,
        );
      }).toList();
    }
    throw Exception('error fetching pokemons');
  }
}

La classe PokemonDataProvider va contenir la fonction fetchPokemon qui appelle l’API.

Avec le résultat, nous analysons le json pour retourner une liste de d'objets Pokemon.

Pouvoir ajouter aux favoris un pokémon de la liste

Pour ajouter un pokémon à ses favoris, nous allons ajouter une icône cliquable sur la tiles de chaque pokémon

class PokemonListItem extends StatelessWidget {
  const PokemonListItem({super.key, required this.pokemon});

  final Pokemon pokemon;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;
    return ListTile(
      leading: Image.network(pokemon.pictureUrl),
      title: Text(
        pokemon.name,
        style: textTheme.bodyLarge,
      ),
      subtitle: Text(
        pokemon.pokedexId.toString(),
      ),
      trailing: IconButton(
        icon: Icon(pokemon.isLiked ? Icons.favorite : Icons.favorite_border),
        onPressed: () => _onPokemonLiked(context),
      ),
      onTap: () => _onPokemonTap(context),
    );
  }

On crée une method _onPokemonLiked en callback de l’iconButton,

void _onPokemonLiked(BuildContext context) {
 context.read<PokemonBloc>().add(
  PokemonLike(
   isLiked: !pokemon.isLiked,
   pokemon: pokemon,
  ),
 );
}

Celle-ci permet d'ajouter l'event PokemonLike à PokemonBloc, déclenchant le traitement de cet event et la mise à jour de l'état du BLoC.

Suite à la mise à jour de l’état, la partie UI se met à jour également grâce au fait que nos state et event extends de la classe Equatable. Equatable permet de faire une comparaison efficace et précise des changements à travers la variable props. La construction des BLoCs widgets tels que BlocBuilder sera effectuée en fonction des nouveaux states qu'ils recevront. Les blocs widgets comparent les nouveaux state au state actuel qu’il possède pour déterminer s’il faut qu’il se reconstruise. Cette comparaison est basée sur l'égalité entre les classes. Habituellement, hashCode et equals doivent être réécrits, et la classe Equatable utilisera les variables que nous avons définies dans les props pour nous aider. Notez que ces variables doivent être également comparables. Comme nous avons défini le status et la liste de pokémons dans le props, un changement de pokémon de la liste entraîne un changement du state.

On crée donc une nouvelle classe PokemonEvent pour gérer le like.

class PokemonLike extends PokemonEvent {
  final bool isLiked;
  final Pokemon pokemon;

  PokemonLike({
    required this.pokemon,
    required this.isLiked,
  });

  @override
  List<Object> get props => [pokemon, isLiked];
}

Voici le rendu final de la HomePage de l'application

Tester les Bloc de l’application

L’architecture BLoC est également créée pour faciliter énormément les tests: Testing. Dans un premier temps, nous déclarons une classe de test :

@GenerateMocks([PokemonRepository])
void main() {
  late PokemonRepository pokemonRepository;
  late PokemonBloc pokemonBloc;

  setUp(() {
    EquatableConfig.stringify = true;
    pokemonRepository = MockPokemonRepository();
    pokemonBloc = PokemonBloc(pokemonRepository: pokemonRepository);
  });
 }

Ici, on met en place ce dont nous allons avoir besoin pour nos tests dans la partie setUp.

Mock du repository pour avoir des données de test et définition du BloC avec ces données.

Grâce à l’annotation @GenerateMocks et en lançant la commande : flutter pub run build_runner build. On génère automatiquement un mockClient du repository.

Cela vous permet de passer le MockClient au PokemonBloc, et de renvoyer des réponses http différentes dans chaque test.

Ensuite, on déclare un groupe de test dans la classe main qui comprend un test pour chaque état différent du BloC.

group('Pokemon Bloc tests', () {
    blocTest<PokemonBloc, PokemonState>(
      'should succeed in fetching pokemon.',
      setUp: () {
        when(pokemonRepository.fetchPokemons())
            .thenAnswer((_) => Future.value(TestPokemon.pokemons));
      },
      build: () => pokemonBloc,
      act: (bloc) => bloc..add(PokemonFetched()),
      expect: () {
        const state = PokemonState();
        return [
          state.copyWith(
            status: PokemonStatus.success,
            pokemons: TestPokemon.pokemons,
          ),
        ];
      },
    );

    blocTest<PokemonBloc, PokemonState>(
      'should not find pokemon to fetch and fail',
      setUp: () {
        when(pokemonRepository.fetchPokemons())
            .thenAnswer((_) => Future.value([]));
      },
      build: () => pokemonBloc,
      act: (bloc) => bloc..add(PokemonFetched()),
      expect: () {
        const state = PokemonState();
        return [
          state.copyWith(
            status: PokemonStatus.failure,
          ),
        ];
      },
    );

    blocTest<PokemonBloc, PokemonState>(
      'should fail to fetch pokemons after error',
      setUp: () {
        when(pokemonRepository.fetchPokemons())
            .thenThrow(StateError('fail to fetch'));
      },
      build: () => pokemonBloc,
      act: (bloc) => bloc..add(PokemonFetched()),
      expect: () {
        const state = PokemonState();
        return [
          state.copyWith(
            status: PokemonStatus.failure,
          ),
        ];
      },
    );
  });

Conclusion

L’architecture BLoC présente des avantages pour les utilisateurs en améliorant leur expérience globale. Inspirée par Redux, elle offre un flux d’events et la gestion de l’état permettant de faciliter le développement d’applications réactives et prévisibles. Par conséquent les utilisateurs recevront les informations visuelles, de chargement ou d’erreur plus rapidement et lisiblement.

Toutes les sources du projet sont à retrouver sur le repo.