Battement. BlOC, fournisseur, async - Shelf Architecture

introduction


Lorsque vous essayez d'écrire une application, la première chose que vous rencontrez est de savoir comment organiser l'architecture de l'application. Et en ce qui concerne Flutter, la tête peut complètement contourner ce que Google donne - Vanilla, Scoped Model, BLoC, MVP, MVC, MVVM, MVI, etc. Supposons que vous décidiez d'aller de la manière la plus à la mode (celle que Google a conseillé en 2018) et d'utiliser BLOC. Qu'Est-ce que c'est? Comment l'utiliser? Ou peut-être Redux ou RxDart? - bien que l'arrêt concerne «l'autre» ... Mais quoi de neuf? Quelles bibliothèques connecter? Bloc, Flutter_bloc, bloc_pattern, etc.?

Un tel nombre d'options d'architecture et d'outils pour leur mise en œuvre peut vraiment retarder longtemps la phase de sélection.

Pour qui l'article


L'article sera principalement utile à ceux qui commencent tout juste à apprendre Flutter et ne savent pas par où commencer. Je vais montrer une des options pour implémenter l'application sur Flutter. Cela vous permettra de «sentir» Flutter, puis de décider par vous-même comment et à l'aide de quoi vous allez écrire vos applications.

Modèles et outils. Bref et simple


Commençons donc. La première chose à noter est qu'il existe une architecture d'application (modèle, modèle, concept de construction) - c'est exactement la même chose: BLoC, MVP, MVC, MVVM, MVI, etc. Beaucoup de ces architectures sont utilisées non seulement dans Flutter, mais aussi dans d'autres langages de programmation. Question - que choisir? À mon avis, vous devez choisir ce que vous connaissez bien, mais seulement si cela implique une réactivité et une séparation stricte de la logique métier de l'interface (oui, oui - «une voiture peut être de n'importe quelle couleur si elle est noire»). Quant à la séparation de l'interface et de la logique métier, je pense qu'il n'y a pas lieu d'expliquer, mais quant à la réactivité - essayez, si vous n'avez pas essayé - au final c'est vraiment très pratique et «beau». Si vous ne pouvez pas le choisir vous-même, alors laissez-le faire pour nous par les gars les plus stupides de Google - BLOC. Nous avons compris l'architecture.

Maintenant, les outils - il y a des bibliothèques prêtes à l'emploi - Bloc, Flutter_bloc, bloc_pattern - quel est le meilleur? Je ne sais pas - tout le monde est bon. Vous pouvez choisir et comparer pendant longtemps, mais là encore, comme dans l'armée - il vaut mieux prendre une mauvaise décision pour l'instant que de ne pas en prendre. Et pour l'instant, je suggère de revenir dans le sillage du mod et d'utiliser Provider (ce que les mêmes gars recommandent d'utiliser en 2019).

Tout cela nous permettra de faire à la fois bloc global et bloc local, selon les besoins. Beaucoup a été écrit sur l'architecture de BLoC (à savoir un modèle, pas des bibliothèques), je pense que vous ne devriez pas vous y attarder en détail. Je note un seul point dans cet article, le BLoC classique ne sera pas utilisé, mais légèrement modifié - dans les actions BLoC (événements) ne seront pas transmises via Sinks, mais les fonctions BLoC seront appelées. Simplement, pour le moment, je ne vois pas les avantages d'utiliser des éviers - et comme ils ne sont pas là, alors pourquoi compliquer votre vie?

Calcul asynchrone et parallèle dans Dart


Cela vaut également la peine de clarifier le concept d'asynchronie dans Dart, car nous parlons de réactivité. Très souvent, aux premiers stades de la connaissance de Dart, la signification des fonctions asynchrones (async) n'est pas correctement comprise. Vous devez toujours vous rappeler que «par défaut», le programme s'exécute dans un seul thread, et l'asynchronie vous permet uniquement de modifier la séquence de commandes, plutôt que de les exécuter en parallèle. Autrement dit, si vous exécutez simplement la fonction avec des calculs volumineux simplement en la marquant comme asynchrone, l'interface sera bloquée. Async ne démarre PAS un nouveau thread. Comment asynchroniser et attendre le travail, il y a beaucoup d'informations sur Internet, donc je ne m'attarderai pas là-dessus non plus.

Si vous devez faire de gros calculs et en même temps ne pas bloquer l'interface, vous devez utiliser la fonction de calcul (pour le hardcore spécial, vous pouvez utiliser des isolats). Cela va vraiment démarrer un thread d'exécution séparé, qui aura également sa propre zone de mémoire séparée (ce qui est très triste et triste). Vous ne pouvez communiquer avec de tels flux que par le biais de messages pouvant contenir des types de données simples, leurs listes.

Mettons-nous à la pratique


Formulation du problème


Essayons d'écrire l'application la plus simple - que ce soit une sorte d'annuaire téléphonique. Nous utiliserons Firebase comme stockage - cela nous permettra de créer une application "cloud". Je vais ignorer comment connecter Firebase au projet (plus d'un article a été écrit sur ce sujet et je ne vois pas l'intérêt de le répéter. Remarque: Cloud Firestore est utilisé dans ce projet.).

Cela devrait être comme ceci:





Description de l'application


Notre application contiendra en externe:

  1. Fenêtre d'autorisation Firebase (la logique de cette fenêtre sera contenue dans MainBloc).
  2. Fenêtre d'information - affichera des informations sur l'utilisateur sous lequel le programme est autorisé (la logique de cette fenêtre sera également contenue dans MainBloc).
  3. Fenêtre de répertoire sous la forme d'une liste de téléphones (la logique de cette fenêtre sera contenue dans un PhonebookBloc séparé).
  4. Menu d'application qui changera d'écran.

L'application interne sera construite comme suit: chaque écran contiendra un fichier avec des widgets d'écran, un fichier de bloc (avec la classe de bloc correspondante), un fichier d'actions (contient des classes simples décrivant les événements qui affectent l'état du bloc), un fichier d'états (contient des classes simples qui reflètent l'état du bloc ), le fichier data_model contenant la classe de référentiel (responsable de la réception des données) et la classe de données (stocke les données de logique métier de bloc).

L'application fonctionnera comme ceci - lorsque l'écran est ouvert, le bloc correspondant est initialisé avec la valeur d'état initiale et, si nécessaire, une action initiale est appelée dans le constructeur de bloc. L'écran est construit / reconstruit en fonction de l'état, qui renvoie le bloc. L'utilisateur effectue certaines actions dans l'application qui ont des actions correspondantes. Les actions sont transmises à la classe de bloc, où elles sont traitées dans la fonction mapEventToState et le bloc renvoie le nouvel état à l'écran, en fonction duquel l'écran est reconstruit.

Structure des fichiers


Tout d'abord, nous créons un projet Flutter vide et créons la structure du projet de ce type (je note que dans le projet de démonstration, certains fichiers resteront éventuellement vides):



Fenêtre d'autorisation. Mainbloc


Vous devez maintenant implémenter l'autorisation dans Firebase.
Commençons par créer des classes d'événements (il est pratique de transférer des données via des événements en bloc) et des états pour le bloc principal:

fichier MainBloc \ actions

abstract class MainBlocAction{
  String get password => null;
  String get email => null;
}

fichier MainBloc \ states

abstract class MainBlocState{
  bool busy;
  MainBlocState({this.busy = false});
  copy(bool busy) {
    return null;
  }
}

L'indicateur occupé dans la classe d'état est utilisé pour afficher progress_hud dans l'interface et exclure les lectures de données inutiles de la base de données lors du défilement dans la liste. Avant que toutes les opérations du bloc ne commencent, un nouvel état de l'ancien type avec l'indicateur occupé est émis vers le flux de sortie - de cette façon, l'interface reçoit une notification indiquant que l'opération a commencé. À la fin de l'opération, un nouvel état est envoyé au flux avec l'indicateur occupé effacé.

Les héritiers de la classe MainBlocState décrivent l'état du bloc d'application principal. Les héritiers de MainBlocAction décrivent les événements qui s'y produisent.

La classe MainBloc contient 4 éléments principaux - la fonction de "conversion" des événements en états (Future mapEventToState), l'état Bloc est _blocState, le référentiel d'état de bloc est le repo et le flux d'état "de sortie" (dont les éléments d'interface suivent) est blocStream. Fondamentalement, ce sont tous des éléments qui fournissent une fonctionnalité bloc-a. Parfois, il est conseillé d'utiliser 2 flux de sortie dans un bloc - un tel exemple sera plus faible. Je ne l'énumérerai pas ici - vous pouvez le voir en téléchargeant le projet.

La classe de référentiel de bloc contient la logique de travail avec Firebase et un objet (données) qui stocke les données nécessaires à la logique métier que ce bloc implémente.

Fichier MainBloc \ data_model

class MainRepo{

  final MainData data = MainData();

  FirebaseAuth get firebaseInst => MainData.firebaseInst;

  FirebaseUser _currentUser;

  Future<bool> createUserWithEmailAndPassword(
      String email, String password) async {
    var dataUser;
      try {
        dataUser =
            (await firebaseInst.createUserWithEmailAndPassword(
                email: email, password: password))
                .user;
      } catch (e) {
        print(Error.safeToString(e));
        print(e.code);
        print(e.message);
      }
      if (dataUser == null){
        data.setState(IsNotLogged());
        return false;
      }

      _currentUser = dataUser;
      data.setState(IsLogged(),
          uid: _currentUser.uid,
          email: _currentUser.email);
    return true;
  }

  ...}

class MainData {
  static final firebaseInst = FirebaseAuth.instance;
  static MainBlocState _authState = IsNotLogged();
  static MainBlocState get authState => _authState;
  static String _uid;
  static String get uid => _uid;
  static String _email;
  static String get email => _email;

  void setState(MainBlocState newState,
      {String uid = '', String email = ''}) {
    _authState = newState;
    _uid = uid;
    _email = email;
  }
}


La classe MainData stocke également l'état, mais l'état d'autorisation dans Firebase, et non l'état Bloc.

Nous avons écrit la logique du bloc principal, maintenant nous pouvons commencer à implémenter l'écran d'autorisation / enregistrement.

MainBloc est initialisé dans le fichier principal:

Le fichier principal

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return Provider(
        create: (context) => MainBloc(),
        dispose: (context, value) => value.dispose(),
        child: MaterialApp(
          routes: menuRoutes,
        ));
  }
}

Il est temps de faire une petite digression sur StreamBuilder, Provider, StreamProvider, Consumer et Selector.

Retraite sur les fournisseurs


Fournisseur - transfère uniquement la valeur stockée dans l'arborescence. Et vous ne pouvez y accéder qu'après la génération de l'enfant, c'est-à-dire vous devez créer un sous-widget. Pas responsable de la mise à jour des widgets.

StreamBuilder - un widget qui surveille le flux et est complètement reconstruit lorsqu'il reçoit un nouvel objet du flux.

StreamProvider - un widget qui surveille le flux et lorsqu'un nouvel objet est reçu, il signale que les widgets enfants (ceux qui sont déclarés en tant que classe distincte avec la méthode de construction) doivent être reconstruits.

Consumer et Selector sont des «sucres syntaxiques», c'est-à-dire il s'agit en fait d'un «wrapper» qui contient la construction et masque le widget en dessous. Dans Selector-e, vous pouvez effectuer un filtrage supplémentaire des mises à jour.

Ainsi, lorsque vous devez reconstruire la plupart de l'écran à chaque événement, vous pouvez utiliser l'option avec Provider et StreamBuilder. Lorsqu'il est nécessaire de reconstruire des parties de l'arborescence des widgets près des feuilles, il est conseillé d'utiliser StreamProvider en combinaison avec Consumer et Selector pour exclure les reconstructions inutiles de l'arborescence.

Autorisation Continuation


Lors de la saisie de l'application, l'utilisateur doit accéder à la fenêtre d'autorisation / d'enregistrement, et à ce moment le menu de l'application ne devrait pas encore être disponible pour lui. Le deuxième point - pour rafraîchir partiellement cet écran n'a pas beaucoup de sens, nous pouvons donc utiliser StreamBuilder pour construire l'interface. Et le troisième point du projet utilise Navigator pour naviguer entre les écrans. Lors de la réception d'un événement d'autorisation réussie, il est nécessaire d'appeler la transition vers l'écran d'informations. Mais juste à l'intérieur de Build StreamBuilder, cela ne fonctionnera pas - il y aura une erreur. Pour contourner ce problème, vous pouvez utiliser la classe wrapper auxiliaire StreamBuilderWithListener (Eugene Brusov - stackoverflow.com ).

Maintenant, la liste de cet écran est auth_screen lui-même (je vais donner ici en partie):

Fichier auth_screen

Widget build(BuildContext context) {
  var bloc = Provider.of<MainBloc>(context, listen: false);
  return StreamBuilderWithListener<MainBlocState>(
      stream: bloc.blocStream.stream,
      listener: (value) {
        //not allowed call navigator push in build
        if (value is IsLogged) {
          Navigator.of(context).pushReplacementNamed(InfoScreen.nameMenuItem);
        }
      },
      initialData: bloc.state,
      builder: (context, snappShot) {
        if (snappShot.data is IsLoggedOnStart) {
          return LoggedWidget();
        } else if (snappShot.data is IsLogged) {
          //not allowed call navigator push in build
          return ModalProgressHUD(
              inAsyncCall: true,
          child: Text(''),);
        } else if (snappShot.data is IsNotLogged) {
          return SignInAndSignUpWidget();
        }
        return Scaffold(body: Text("                Unknown event"));
      });
}

Tout d'abord, un StreamBuilderWithListener est créé pour écouter le flux depuis le bloc. Et en fonction de l'état actuel, le widget LoggedWidget (si l'utilisateur est déjà connecté) ou SignInAndSignUpWidget (si l'utilisateur n'est pas encore connecté) est appelé. Si bloc renvoie l'état IsLogged, le basculement vers un nouvel écran à l'aide de Navigator ne se produit pas dans le générateur (ce qui entraînerait une erreur), mais dans l'écouteur. Dans les widgets sous-jacents, l'interface est construite sur la base des données renvoyées ici. Ici, le bundle Provider + StreamBuilder est réellement utilisé, car lorsque l'état du bloc change, pratiquement toute l'interface change.

Pour transférer des données vers un bloc, TextEditingController et les paramètres d'action sont utilisés:

fichier auth_screen

class _SignUpWidgetWidgetState extends State {
  String _email, _password;

  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _emailController.addListener(_onEmailChanged);
    _passwordController.addListener(_onPasswordChanged);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TextFormField(
          controller: _emailController,
          decoration: InputDecoration(
              labelText: 'email'),
        ),
        TextFormField(
          controller: _passwordController,
          obscureText: true,
          decoration: InputDecoration(
              labelText: 'password'),
        ),
        RaisedButton(
            child: Text('sign up'),
            onPressed: () {
              Provider.of<MainBloc>(context, listen: false).mapEventToState(
                  Registration(_email, _password));
            })
      ],
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _onEmailChanged() {
    _email = _emailController.text;
  }

  void _onPasswordChanged() {
    _password = _passwordController.text;
  }
}
 

Fenêtre PhoneBookScreen


Et maintenant, parlons un peu de notre fenêtre PhoneBookScreen. C'est la fenêtre la plus intéressante - ici l'interface est construite sur la base de 2 flux de bloc, et il y a aussi une liste avec défilement et pagination (pagination).

PhonebookScreen \ screen File

class PhonebookTopPart extends StatelessWidget {

  StatefulWidget caseWidget(PhonebookState state) {
    if (state is PhonebookListOpened) {
      return PhonebookList();
    //} else if (data is PhonebookCardToViewOpened) {
    }else ModalProgressHUD(
      inAsyncCall: true,
      child: Text(''),);
    return null;
  }

  @override
  Widget build(BuildContext context) {
    var bloc = Provider.of<PhonebookBloc>(context, listen: false);
    return StreamProvider<PhonebookState>(
        create: (context) => bloc.blocStream.stream,
        initialData: bloc.state,
        child: Selector<PhonebookState,PhonebookState>(
            selector: (_,state)=>state,
            shouldRebuild: (previous, next){return (previous.runtimeType!=next.runtimeType);},
            builder: (_, state, __) { return ModalProgressHUD(
                inAsyncCall: state.busy,
                child: Scaffold(
                  appBar: AppBar(
                    title: Text("Phones list"),
                  ),
                  drawer: MenuWidget(),
                  body: caseWidget(state),
                ));}
        ));
  }
}

Le premier StreamProvider est nécessaire pour basculer entre les différents écrans du répertoire - liste, carte de contact, carte de contact pour l'édition, etc. Le widget pour l'écran est sélectionné dans la fonction caseWidget (mais dans cet exemple, seule la vue de la liste est implémentée - vous pouvez essayer d'implémenter la vue pour la carte de visite - c'est très simple et ce ne sera pas un mauvais début.).

Sur cet écran, un tas de StreamProvider + Selector / Consumer est déjà utilisé, car il y a un défilement de la liste et il n'est pas conseillé de reconstruire l'écran entier avec lui (c'est-à-dire que la reconstruction des widgets provient du sélecteur / consommateur correspondant et plus bas dans l'arborescence).

Et voici l'implémentation de la liste elle-même:

PhonebookScreen \ screen file

class _PhonebookListState extends State<PhonebookList> {
  ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
  }

  @override
  Widget build(BuildContext context) {
    var bloc = Provider.of<PhonebookBloc>(context, listen: false);
    var list = bloc.repo.data.list;
    return Container(
        child: StreamProvider<PhonebookState>(
            create: (context) => bloc.scrollStream.stream,
            initialData: bloc.scrollState,
            child: Consumer<PhonebookState>(
              builder: (_, state, __) {
                return ListView.builder(
                    controller: _scrollController,
                    itemCount: list.length,
                    itemBuilder: (BuildContext context, int index) {
                      return ListTile(
                        title: Text(list[index].data['name']),
                        subtitle: Text(list[index].data['phone']),
                      );
                    });
              },
            )));
  }

  void _scrollListener() {
    double delta = MediaQuery
        .of(context)
        .size
        .height * 3;
    double maxScroll = _scrollController.position.maxScrollExtent;
    double currentScroll = _scrollController.position.pixels;
    if (maxScroll - currentScroll <= delta) {
      Provider.of<PhonebookBloc>(context, listen: false)
          .mapEventToState(ScrollPhonebook());
    }
  }

  @override
  void dispose() {
    _scrollController.removeListener(_scrollListener);
    super.dispose();
  }
}

Ici, nous voyons le deuxième StreamProvider, qui surveille le deuxième flux de bloc, qui est responsable du défilement. La pagination est organisée en standard via _scrollListener (contrôleur: _scrollController). Bien que la fenêtre soit intéressante, mais étant donné la description détaillée de la première fenêtre, il n'y a rien de plus à dire ici. Par conséquent, c'est tout aujourd'hui.

L'objectif de cet article n'était pas de montrer le code parfait, c'est-à-dire que vous pouvez trouver ici de nombreux points pour l'optimisation - correctement «divisé» en fichiers, utilisez une instance, des mixins et similaires quelque part. En outre, ce qui "supplie" la prochaine étape - vous pouvez créer une carte de contact. La tâche principale était de structurer les connaissances, de définir un certain vecteur pour la construction de l'application, de donner des explications sur certains moments de la conception d'une application sur Flutter qui n'étaient pas très évidents aux premiers stades de la connaissance.

Le projet peut être téléchargé à (pour l'inscription, vous pouvez utiliser n'importe quel courrier avec un mot de passe d'au moins 6 caractères. Lors de la nouvelle autorisation, le mot de passe doit être le même que celui utilisé lors de l'inscription).

All Articles