Flattern. BlOC, Provider, Async - Shelf Architecture

Einführung


Wenn Sie versuchen, eine Anwendung zu schreiben, stoßen Sie zunächst auf die Organisation der Architektur der Anwendung. Und wenn es um Flutter geht, kann sich der Kopf vollständig um das drehen, was Google herausgibt - Vanille, Scoped Model, BLoC, MVP, MVC, MVVM, MVI usw. Angenommen, Sie entscheiden sich für den modischsten Weg (den von Google im Jahr 2018 empfohlen wurde) und verwenden BLOC. Was ist das? Wie benutzt man es? Oder vielleicht Redux oder RxDart? - obwohl es bei der Haltestelle um das "Andere" geht ... Aber wie geht es weiter? Welche Bibliotheken müssen verbunden werden? Block, Flutter_Block, Block_Muster usw.?

Eine solche Anzahl von Architekturoptionen und -werkzeugen für ihre Implementierung kann die Auswahlphase wirklich lange verzögern.

Für wen der Artikel


Der Artikel ist in erster Linie für diejenigen nützlich, die gerade erst anfangen, Flutter zu lernen und nicht wissen, wo sie anfangen sollen. Ich werde eine der Implementierungsoptionen für Flatteranwendungen zeigen. Auf diese Weise können Sie das Flattern „fühlen“ und dann selbst entscheiden, wie und mit welchen Anwendungen Sie Ihre Anwendungen schreiben.

Muster und Werkzeuge. Kurz und einfach


Also fangen wir an. Das erste, was erwähnenswert ist, ist, dass es eine Anwendungsarchitektur gibt (Muster, Vorlage, ein Konstruktionskonzept) - dies ist genau das gleiche: BLoC, MVP, MVC, MVVM, MVI usw. Viele dieser Architekturen werden nicht nur in Flutter, sondern auch in anderen Programmiersprachen verwendet. Frage - was zur Auswahl? Meiner Meinung nach müssen Sie auswählen, was Sie selbst gut wissen, aber nur, wenn dies Reaktivität und eine strikte Trennung der Geschäftslogik von der Benutzeroberfläche impliziert (ja, ja - „ein Auto kann jede Farbe haben, wenn es schwarz ist“). Die Trennung von Schnittstelle und Geschäftslogik muss meines Erachtens nicht erklärt werden, aber die Reaktivität - versuchen Sie es, wenn Sie es nicht versucht haben - ist am Ende wirklich sehr praktisch und "schön". Wenn Sie es nicht selbst auswählen können, lassen Sie es zu, dass es von nicht den dümmsten Leuten von Google - BLOC - für uns erledigt wird. Wir haben die Architektur herausgefunden.

Nun die Tools - es gibt fertige Bibliotheken - Bloc, Flutter_bloc, bloc_pattern - was ist besser? Ich weiß nicht - jeder ist gut. Sie können lange wählen und vergleichen, aber auch hier, wie in der Armee, ist es besser, vorerst eine falsche Entscheidung zu treffen, als keine zu treffen. Und für den Moment schlage ich vor, nach dem Mod zurückzukehren und Provider zu verwenden (was die gleichen Leute 2019 empfehlen).

All dies ermöglicht es uns, je nach Bedarf sowohl einen globalen als auch einen lokalen Block zu bilden. Es wurde viel über die Architektur von BLoC geschrieben (nämlich ein Muster, keine Bibliotheken). Ich denke, Sie sollten nicht noch einmal im Detail darauf eingehen. Ich stelle nur einen Punkt in diesem Artikel fest, es wird kein klassisches BLoC verwendet, sondern leicht modifiziert - in BLoC werden Aktionen (Ereignisse) nicht über Sinks übertragen, sondern BLoC-Funktionen werden aufgerufen. Im Moment sehe ich einfach nicht die Vorteile der Verwendung von Waschbecken - und da diese nicht vorhanden sind, warum sollten Sie dann Ihr Leben komplizieren?

Asynchronität und paralleles Rechnen in Dart


Es lohnt sich auch, das Konzept der Asynchronität in Dart ein wenig zu klären, da es sich um Reaktivität handelt. Sehr oft wird in den ersten Phasen der Bekanntschaft mit Dart die Bedeutung von asynchronen Funktionen (asynchron) nicht richtig verstanden. Sie sollten immer daran denken, dass das Programm „standardmäßig“ in einem Thread ausgeführt wird und Sie durch Asynchronität nur die Reihenfolge der Befehle ändern können, anstatt sie parallel auszuführen. Das heißt, wenn Sie die Funktion einfach mit großen Berechnungen ausführen, indem Sie sie als asynchron markieren, wird die Schnittstelle blockiert. Async startet KEINEN neuen Thread. Wie asynchron und wie es funktioniert, gibt es im Internet viele Informationen, daher werde ich auch nicht darauf eingehen.

Wenn Sie einige große Berechnungen durchführen und gleichzeitig die Schnittstelle nicht blockieren müssen, müssen Sie die Rechenfunktion verwenden (für speziellen Hardcore können Sie Isolate verwenden). Dies wird wirklich einen separaten Ausführungsthread starten, der auch einen eigenen separaten Speicherbereich hat (was sehr traurig und traurig ist). Sie können mit solchen Streams nur über Nachrichten kommunizieren, die einfache Datentypen und deren Listen enthalten können.

Lass uns üben


Formulierung des Problems


Versuchen wir, die einfachste Anwendung zu schreiben - lassen Sie es eine Art Telefonverzeichnis sein. Wir werden Firebase als Speicher verwenden - dies ermöglicht es uns, eine "Cloud" -Anwendung zu erstellen. Ich überspringe, wie Firebase mit dem Projekt verbunden wird (es wurde mehr als ein Artikel zu diesem Thema geschrieben, und ich sehe keinen Sinn darin, ihn zu wiederholen. Hinweis: In diesem Projekt wird Cloud Firestore verwendet.)

Es sollte so sein:





Anwendungsbeschreibung


Unsere Anwendung enthält extern:

  1. Firebase-Autorisierungsfenster (die Logik dieses Fensters ist in MainBloc enthalten).
  2. Informationsfenster - Zeigt Informationen zu dem Benutzer an, unter dem das Programm autorisiert ist (die Logik dieses Fensters ist auch in MainBloc enthalten).
  3. Verzeichnisfenster in Form einer Liste von Telefonen (die Logik dieses Fensters wird in einem separaten PhonebookBloc enthalten sein).
  4. Anwendungsmenü, das die Bildschirme wechselt.

Die interne Anwendung wird wie folgt aufgebaut: Jeder Bildschirm enthält eine Datei mit Bildschirm-Widgets, eine Blockdatei (mit der entsprechenden Blockklasse), eine Aktionsdatei (enthält einfache Klassen, die Ereignisse beschreiben, die den Blockstatus beeinflussen), eine Statusdatei (enthält einfache Klassen, die den Blockstatus widerspiegeln ), die Datei data_model, die die Repository-Klasse (verantwortlich für den Empfang von Daten) und die Datenklasse (speichert Block-Geschäftslogikdaten) enthält.

Die Anwendung funktioniert folgendermaßen: Wenn der Bildschirm geöffnet wird, wird der entsprechende Block mit dem Anfangszustandswert initialisiert, und bei Bedarf wird im Blockkonstruktor eine anfängliche Aktion aufgerufen. Der Bildschirm wird basierend auf dem Status erstellt / neu erstellt, der den Block zurückgibt. Der Benutzer führt einige Aktionen in der Anwendung aus, die über entsprechende Aktionen verfügen. Aktionen werden an die Blockklasse übergeben, wo sie in der Funktion mapEventToState verarbeitet werden und der Block den neuen Status an den Bildschirm zurückgibt, auf dessen Grundlage der Bildschirm neu erstellt wird.

Dateistruktur


Zunächst erstellen wir ein leeres Flutter-Projekt und erstellen die Projektstruktur dieser Art (ich stelle fest, dass im Demo-Projekt einige Dateien möglicherweise leer bleiben):



Autorisierungsfenster. Hauptblock


Jetzt müssen Sie die Autorisierung in Firebase implementieren.
Beginnen wir mit dem Erstellen von Ereignisklassen (es ist praktisch, Daten über Ereignisse im Block zu übertragen) und

Status für den Hauptblock : Datei MainBloc \ Aktionen

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

Datei MainBloc \ Staaten

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

Das Besetzt-Flag in der Statusklasse wird verwendet, um progress_hud in der Schnittstelle anzuzeigen und unnötige Datenlesevorgänge aus der Datenbank auszuschließen, wenn Sie durch die Liste scrollen. Bevor alle Operationen im Block beginnen, wird ein neuer Status des alten Typs mit gesetztem Besetzt-Flag an den Ausgabestream ausgegeben. Auf diese Weise erhält die Schnittstelle eine Benachrichtigung, dass die Operation begonnen hat. Am Ende des Vorgangs wird ein neuer Status an den Stream gesendet, wobei das Besetztzeichen gelöscht ist.

Die Erben der MainBlocState-Klasse beschreiben den Status der Hauptanwendung Bloc. Die Erben von MainBlocAction beschreiben die darin auftretenden Ereignisse.

Die MainBloc-Klasse enthält 4 Hauptelemente - die Funktion zum "Konvertieren" von Ereignissen in Status (Future mapEventToState), der Blockstatus ist _blocState, das Blockstatus-Repository ist das Repo und der Statusstrom "Ausgabe" (welche Schnittstellenelemente verfolgen) ist blocStream. Grundsätzlich sind dies alles Elemente, die eine Block-A-Funktionalität bieten. Manchmal ist es ratsam, zwei Ausgabestreams in einem Block zu verwenden - ein solches Beispiel ist niedriger. Ich werde es hier nicht auflisten - Sie können es sehen, indem Sie das Projekt herunterladen.

Die Block-Repository-Klasse enthält die Logik für die Arbeit mit Firebase und ein Objekt (Daten), in dem die Daten gespeichert sind, die für die von diesem Block implementierte Geschäftslogik erforderlich sind.

Datei 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;
  }
}


Die MainData-Klasse speichert auch den Status, jedoch den Autorisierungsstatus in Firebase und nicht den Blockstatus.

Wir haben die Logik für den Hauptblock geschrieben, jetzt können wir mit der Implementierung des Autorisierungs- / Registrierungsbildschirms beginnen.

MainBloc wird in der Hauptdatei initialisiert:

Die Hauptdatei

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,
        ));
  }
}

Es ist Zeit, einen kleinen Exkurs über StreamBuilder, Provider, StreamProvider, Consumer und Selector zu machen.

Rückzug über Anbieter


Provider - überträgt nur den gespeicherten Wert in den Baum. Und Sie können erst nach dem untergeordneten Build darauf zugreifen, d. H. Sie müssen ein Sub-Widget erstellen. Nicht verantwortlich für die Aktualisierung von Widgets.

StreamBuilder - ein Widget, das den Stream überwacht und vollständig neu erstellt wird, wenn ein neues Objekt vom Stream empfangen wird .

StreamProvider - Ein Widget, das den Stream überwacht und beim Empfang eines neuen Objekts signalisiert, dass die untergeordneten Widgets (die mit der Erstellungsmethode als separate Klasse deklariert werden) neu erstellt werden sollen.

Verbraucher und Selektor sind "syntaktischer Zucker", d.h. Dies ist eigentlich ein "Wrapper", der Build enthält und das Widget darunter verbirgt. In Selector-e können Sie Updates zusätzlich filtern.

Wenn Sie also bei jedem Ereignis den größten Teil des Bildschirms neu erstellen müssen, können Sie die Option mit Provider und StreamBuilder verwenden. Wenn Teile des Widget-Baums in der Nähe der Blätter neu erstellt werden müssen, empfiehlt es sich, StreamProvider in Kombination mit Consumer und Selector zu verwenden, um unnötige Neuerstellungen des Baums auszuschließen.

Genehmigung Fortsetzung


Bei der Eingabe der Anwendung muss der Benutzer das Autorisierungs- / Registrierungsfenster aufrufen. Zu diesem Zeitpunkt sollte das Anwendungsmenü noch nicht für ihn verfügbar sein. Der zweite Punkt - das teilweise Aktualisieren dieses Bildschirms ist wenig sinnvoll, daher können wir StreamBuilder zum Erstellen der Benutzeroberfläche verwenden. Und der dritte Punkt im Projekt ist die Verwendung von Navigator zum Navigieren zwischen Bildschirmen. Nach Erhalt eines Ereignisses erfolgreicher Autorisierung muss der Übergang zum Informationsbildschirm aufgerufen werden. Aber nur in Build StreamBuilder wird dies nicht funktionieren - es wird ein Fehler auftreten. Um dies zu umgehen, können Sie die Auxiliary-Wrapper-Klasse StreamBuilderWithListener (Eugene Brusov - stackoverflow.com ) verwenden.

Jetzt ist die Auflistung dieses Bildschirms auth_screen selbst (ich werde hier teilweise geben):

Datei 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"));
      });
}

Zunächst wird ein StreamBuilderWithListener erstellt, um den Stream vom Block abzuhören. Und basierend auf dem aktuellen Status wird entweder das Widget LoggedWidget (wenn der Benutzer bereits angemeldet ist) oder SignInAndSignUpWidget (wenn der Benutzer noch nicht angemeldet ist) aufgerufen. Wenn bloc den Status IsLogged zurückgibt, erfolgt der Wechsel zu einem neuen Bildschirm mit dem Navigator nicht im Builder (was zu einem Fehler führen würde), sondern im Listener. In den zugrunde liegenden Widgets wird die Schnittstelle basierend auf den hier zurückgegebenen Daten erstellt. Hier wird das Provider + StreamBuilder-Bundle tatsächlich verwendet, weil Wenn sich der Status des Blocks ändert, ändert sich praktisch die gesamte Schnittstelle.

Zum Übertragen von Daten an den Block werden TextEditingController und Aktionsparameter verwendet:

auth_screen-Datei

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;
  }
}
 

PhoneBookScreen-Fenster


Und jetzt sprechen wir ein wenig über unser PhoneBookScreen-Fenster. Dies ist das interessanteste Fenster - hier basiert die Schnittstelle auf 2 Streams aus dem Block, und es gibt auch eine Liste mit Bildlauf und Paginierung (Paginierung).

PhonebookScreen \ Bildschirmdatei

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),
                ));}
        ));
  }
}

Der erste StreamProvider wird benötigt, um zwischen verschiedenen Bildschirmen des Verzeichnisses zu wechseln - Liste, Kontaktkarte, Kontaktkarte zum Bearbeiten usw. Das Widget für den Bildschirm wird in der caseWidget-Funktion ausgewählt (in diesem Beispiel wird jedoch nur die Ansicht für die Liste implementiert - Sie können versuchen, die Ansicht für die Kontaktkarte zu implementieren - dies ist sehr einfach und kein schlechter Start.).

Auf diesem Bildschirm wird bereits eine Reihe von StreamProvider + Selector / Consumer verwendet, weil Es gibt einen Bildlauf in der Liste, und es ist nicht ratsam, den gesamten Bildschirm neu zu erstellen (d. h. Widgets aus dem entsprechenden Selector / Consumer neu zu erstellen und weiter unten im Baum).

Und hier ist die Implementierung der Liste selbst:

PhonebookScreen \ Bildschirmdatei

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();
  }
}

Hier sehen wir den zweiten StreamProvider, der den zweiten Blockstrom überwacht, der für die Schriftrolle verantwortlich ist. Die Paginierung wird standardmäßig über _scrollListener (Controller: _scrollController) organisiert. Das Fenster ist zwar interessant, aber angesichts der detaillierten Beschreibung des ersten Fensters gibt es hier nichts mehr zu sagen. Deshalb ist das heute alles.

Das Ziel dieses Artikels war es nicht, den idealen Code zu zeigen, das heißt, hier finden Sie viele Punkte für die Optimierung - korrekt nach Dateien „aufgeteilt“, Instanz, Mixins und dergleichen irgendwo verwendet. Auch was "bettelt" der nächste Schritt - Sie können eine Kontaktkarte erstellen. Die Hauptaufgabe bestand darin, das Wissen zu strukturieren, einen bestimmten Vektor für die Erstellung der Anwendung festzulegen und einige der Momente des Entwurfs der Anwendung auf Flutter zu erläutern, die in den ersten Phasen der Bekanntschaft nicht sehr offensichtlich waren.

Das Projekt kann unter heruntergeladen werden (für die Registrierung können Sie jede E-Mail mit einem Passwort von mindestens 6 Zeichen verwenden. Bei der erneuten Autorisierung muss das Passwort mit dem bei der Registrierung verwendeten übereinstimmen).

All Articles