Aleteo. BlOC, Proveedor, asíncrono - Arquitectura de estante

Introducción


Cuando intentas escribir una aplicación, lo primero que encuentras es cómo organizar la arquitectura de la aplicación. Y cuando se trata de Flutter, la cabeza puede dar la vuelta completa a lo que Google ofrece: Vanilla, Scoped Model, BLoC, MVP, MVC, MVVM, MVI, etc. Supongamos que decide ir de la manera más moderna (la que Google aconsejó en 2018) y usar BLOC. ¿Qué es? ¿Cómo usarlo? O tal vez Redux o RxDart? - aunque la parada es sobre el "otro" ... Pero aún así, ¿qué sigue? ¿Qué bibliotecas conectar? Bloc, Flutter_bloc, bloc_pattern, etc.

Tal cantidad de opciones de arquitectura y herramientas para su implementación realmente pueden retrasar la etapa de selección durante mucho tiempo.

Para quien el articulo


El artículo será principalmente útil para aquellos que recién comienzan a aprender Flutter y no saben por dónde empezar. Mostraré una de las opciones para implementar la aplicación en Flutter. Esto le permitirá "sentir" Flutter y luego decidir por sí mismo cómo y con qué escribir sus aplicaciones.

Patrones y herramientas. Breve y simple


Vamos a empezar. Lo primero que vale la pena señalar es que hay una arquitectura de aplicación (patrón, plantilla, algún concepto de construcción): esto es exactamente lo mismo: BLoC, MVP, MVC, MVVM, MVI, etc. Muchas de estas arquitecturas se usan no solo en Flutter, sino también en otros lenguajes de programación. Pregunta: ¿qué elegir? En mi opinión, debe elegir lo que usted mismo sabe bien, pero solo si esto implica reactividad y una separación estricta de la lógica comercial de la interfaz (sí, sí, "un automóvil puede ser de cualquier color si es negro"). En cuanto a la separación de la interfaz y la lógica empresarial, creo que no hay necesidad de explicarlo, pero en cuanto a la reactividad, intente, si no lo ha intentado, al final es realmente muy conveniente y "hermoso". Si no puede elegirlo usted mismo, permítanos permitir que lo hagan por nosotros no los tipos más estúpidos de Google: BLOC. Descubrimos la arquitectura.

Ahora las herramientas, hay bibliotecas listas para usar: Bloc, Flutter_bloc, bloc_pattern, ¿cuál es mejor? No lo sé, todos son buenos. Puedes elegir y comparar durante mucho tiempo, pero aquí de nuevo, como en el ejército, es mejor tomar una decisión equivocada por ahora que no tomar ninguna. Y por ahora, sugiero volver después del mod y usar Provider (lo que los mismos chicos recomiendan usar en 2019).

Todo esto nos permitirá hacer tanto el bloque global como el bloque local, según sea necesario. Mucho se ha escrito sobre la arquitectura de BLoC (es decir, un patrón, no bibliotecas), creo que no debería volver a analizarlo en detalle. Observo solo un punto en este artículo, no se utilizará BLoC clásico, sino que se modificará ligeramente: en acciones BLoC (eventos) no se transmitirán a través de Sinks, pero se llamarán las funciones BLoC. Simplemente, por el momento, no veo los beneficios de usar Sinks, y dado que no están allí, ¿por qué complicar tu vida?

Asincronía y computación paralela en Dart


También vale la pena aclarar un poco el concepto de asincronía en Dart, ya que estamos hablando de reactividad. Muy a menudo, en las primeras etapas del conocimiento de Dart, el significado de las funciones asincrónicas (asíncronas) no se entiende correctamente. Siempre debe recordar que "por defecto" el programa se ejecuta en un hilo, y la asincronía solo le permite cambiar la secuencia de comandos, en lugar de ejecutarlos en paralelo. Es decir, si simplemente ejecuta la función con cálculos grandes simplemente marcándola como asíncrona, la interfaz se bloqueará. Async NO inicia un nuevo hilo. La forma en que asincronizan y esperan el trabajo es que hay mucha información en Internet, por lo que tampoco me detendré en esto.

Si necesita hacer grandes cálculos y al mismo tiempo no bloquear la interfaz, debe usar la función de cálculo (para el hardcore especial puede usar aislamientos). Esto realmente comenzará un hilo de ejecución separado, que también tendrá su propia área de memoria separada (que es muy triste y triste). Puede comunicarse con dichas secuencias solo a través de mensajes que pueden contener tipos de datos simples, sus listas.

Vamos a practicar


Formulación del problema


Intentemos escribir la aplicación más simple: que sea una especie de directorio telefónico. Usaremos Firebase como almacenamiento; esto nos permitirá crear una aplicación "en la nube". Omitiré cómo conectar Firebase al proyecto (se ha escrito más de un artículo sobre este tema y no veo el punto de repetirlo. Nota: Cloud Firestore se usa en este proyecto).

Debería ser así:





Descripción de la aplicación


Nuestra aplicación contendrá externamente:

  1. Ventana de autorización de Firebase (la lógica de esta ventana estará contenida en MainBloc).
  2. Ventana de información: mostrará información sobre el usuario con el que está autorizado el programa (la lógica de esta ventana también estará contenida en MainBloc).
  3. Ventana de directorio en forma de una lista de teléfonos (la lógica de esta ventana estará contenida en un PhonebookBloc separado).
  4. Menú de aplicaciones que cambiará las pantallas.

La aplicación interna se construirá de la siguiente manera: cada pantalla contendrá un archivo con widgets de pantalla, un archivo de bloque (con la clase de bloque correspondiente), un archivo de acciones (contiene clases simples que describen eventos que afectan el estado del bloque), un archivo de estados (contiene clases simples que reflejan el estado del bloque ), el archivo data_model que contiene la clase de repositorio (responsable de recibir datos) y la clase de datos (almacena datos de lógica de negocios de bloque).

La aplicación funcionará así: cuando se abre la pantalla, el bloque correspondiente se inicializa con el valor de estado inicial y, si es necesario, se llama a alguna acción inicial en el constructor del bloque. La pantalla se construye / reconstruye según el estado, que devuelve el bloque. El usuario realiza algunas acciones en la aplicación que tienen acciones correspondientes. Las acciones se pasan a la clase de bloque, donde se procesan en la función mapEventToState y el bloque devuelve el nuevo estado a la pantalla, en función del cual se reconstruye la pantalla.

Estructura de archivo


En primer lugar, creamos un proyecto Flutter vacío y hacemos la estructura del proyecto de este tipo (observo que en el proyecto de demostración algunos archivos eventualmente permanecerán vacíos):



Ventana de autorización. Mainbloc


Ahora necesita implementar la autorización en Firebase.
Comencemos creando clases de eventos (es conveniente transferir datos a través de eventos en bloque) y estados para Bloque principal:

archivo MainBloc \ actions

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

archivo MainBloc \ states

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

El indicador de ocupado en la clase de estado se usa para mostrar el progreso_hud en la interfaz y excluye las lecturas innecesarias de datos de la base de datos al desplazarse por la lista. Antes de que comiencen todas las operaciones en el bloque, se emite un nuevo estado del tipo anterior con el indicador ocupado ocupado en la secuencia de salida, de esta manera la interfaz recibe una notificación de que la operación ha comenzado. Al final de la operación, se envía un nuevo estado a la secuencia con el indicador ocupado ocupado borrado.

Los herederos de la clase MainBlocState describen el estado de la aplicación principal Bloc. Los herederos de MainBlocAction describen los eventos que ocurren en él.

La clase MainBloc contiene 4 elementos principales: la función de "convertir" eventos a estados (Future mapEventToState), el estado Bloc es _blocState, el repositorio de estado de bloque es el repositorio y la secuencia de estado de "salida" (que rastrean los elementos de interfaz) es blocStream. Básicamente, estos son todos los elementos que proporcionan funcionalidad de bloque. A veces es aconsejable usar 2 flujos de salida en un bloque, tal ejemplo será menor. No lo enumeraré aquí; puede verlo descargando el proyecto.

La clase de repositorio de bloques contiene la lógica para trabajar con Firebase y un objeto (datos) que almacena los datos necesarios para la lógica de negocios que implementa este bloque.

Archivo 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 clase MainData también almacena el estado, pero el estado de autorización en Firebase y no el estado Bloc.

Escribimos la lógica para el bloque principal, ahora podemos comenzar a implementar la pantalla de autorización / registro.

MainBloc se inicializa en el archivo principal:

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

Es hora de hacer una pequeña digresión sobre StreamBuilder, Provider, StreamProvider, Consumer y Selector.

Retiro sobre proveedores


Proveedor : solo transfiere el valor almacenado hacia abajo en el árbol. Y puede acceder solo después de la compilación secundaria, es decir necesitas construir un sub widget. No nos hacemos responsables de actualizar los widgets.

StreamBuilder : un widget que supervisa la transmisión y se reconstruye por completo cuando recibe un nuevo objeto de la transmisión.

StreamProvider : un widget que monitorea el flujo y cuando se recibe un nuevo objeto, indica que los widgets secundarios (aquellos que se declaran como una clase separada con el método de compilación) deben reconstruirse.

El consumidor y el selector son "azúcar sintáctico", es decir esto es en realidad un "contenedor" que contiene compilación y oculta el widget debajo. En Selector-e, puede hacer un filtrado adicional de actualizaciones.

Por lo tanto, cuando necesite reconstruir la mayor parte de la pantalla en cada evento, puede usar la opción con Proveedor y StreamBuilder. Cuando sea necesario reconstruir partes del árbol de widgets cerca de las hojas, es recomendable usar StreamProvider en combinación con Consumer y Selector para excluir reconstrucciones innecesarias del árbol.

Autorización Continuación


Al ingresar a la aplicación, el usuario debe acceder a la ventana de autorización / registro, y en ese momento el menú de la aplicación aún no debe estar disponible para él. El segundo punto: actualizar parcialmente esta pantalla no tiene mucho sentido, por lo que podemos usar StreamBuilder para construir la interfaz. Y el tercer punto en el proyecto es usar Navigator para navegar entre pantallas. Al recibir un evento de autorización exitosa, es necesario llamar a la transición a la pantalla de información. Pero justo dentro de BuildBuilder, esto no funcionará, habrá un error. Para evitar esto, puede usar la clase de contenedor auxiliar StreamBuilderWithListener (Eugene Brusov - stackoverflow.com ).

Ahora la lista de esta pantalla es auth_screen en sí (daré aquí en parte):

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

Primero, se crea un StreamBuilderWithListener para escuchar la transmisión desde el bloque. Y según el estado actual, se llama al widget LoggedWidget (si el usuario ya ha iniciado sesión) o SignInAndSignUpWidget (si el usuario aún no ha iniciado sesión). Si el bloque devuelve el estado IsLogged, el cambio a una nueva pantalla utilizando el navegador no se produce en el generador (lo que provocaría un error), sino en el oyente. En los widgets subyacentes, la interfaz se crea en función de los datos devueltos aquí. Aquí, el paquete Provider + StreamBuilder se usa realmente, porque cuando cambia el estado del bloque, prácticamente cambia toda la interfaz.

Para transferir datos al bloque, se utilizan TextEditingController y parámetros de acción:

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

PhoneBookScreen Window


Y ahora hablemos un poco sobre nuestra ventana PhoneBookScreen. Esta es la ventana más interesante: aquí la interfaz está construida sobre la base de 2 secuencias del bloque, y también hay una lista con desplazamiento y paginación (paginación).

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

Se necesita el primer StreamProvider para cambiar entre diferentes pantallas del directorio: lista, tarjeta de contacto, tarjeta de contacto para editar, etc. El widget para la pantalla se selecciona en la función caseWidget (pero en este ejemplo solo se implementa la vista de la lista; puede intentar implementar la vista para la tarjeta de contacto; esto es muy simple y no será un mal comienzo).

En esta pantalla, ya se usa un montón de StreamProvider + Selector / Consumidor, porque hay un desplazamiento de la lista y no es aconsejable reconstruir toda la pantalla con ella (es decir, la reconstrucción de widgets proviene del Selector / Consumidor correspondiente y más abajo en el árbol).

Y aquí está la implementación de la lista en sí:

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

Aquí vemos el segundo StreamProvider, que monitorea el segundo flujo del bloque, que es responsable del desplazamiento. La paginación se organiza de manera estándar a través de _scrollListener (controlador: _scrollController). Aunque la ventana es interesante, pero dada la descripción detallada de la primera ventana, no hay nada más que decir aquí. Por lo tanto, eso es todo hoy.

El objetivo de este artículo no era mostrar el código perfecto, es decir, aquí puede encontrar muchos puntos para la optimización: "dividirse" correctamente en archivos, usar instancias, mixins y similares en algún lugar. Además, qué "pide" el siguiente paso: puede hacer una tarjeta de contacto. La tarea principal era estructurar el conocimiento, establecer un determinado vector para construir la aplicación, dar explicaciones sobre algunos de los momentos de diseño de una aplicación en Flutter que no eran muy obvios en las primeras etapas del conocimiento.

El proyecto se puede descargar en (para el registro puede usar cualquier correo con una contraseña de al menos 6 caracteres. Al volver a autorizar, la contraseña debe ser la misma que la utilizada durante el registro).

All Articles