Flutter. BlOC, provedor, assíncrono - arquitetura de prateleira

Introdução


Quando você tenta escrever um aplicativo, a primeira coisa que você encontra é como organizar a arquitetura do aplicativo. E quando se trata de Flutter, a cabeça pode dar uma olhada completa no que o Google fornece - Vanilla, Scoped Model, BLoC, MVP, MVC, MVVM, MVI, etc. Suponha que você decida seguir da maneira mais elegante (a que o Google aconselhou em 2018) e usar o BLOC. O que é isso? Como usá-lo? Ou talvez Redux ou RxDart? - embora a parada seja sobre o "outro" ... Mas ainda assim, o que vem a seguir? Quais bibliotecas conectar? Bloc, Flutter_bloc, bloc_pattern, etc.?

Esse número de opções e ferramentas de arquitetura para sua implementação pode realmente atrasar o estágio de seleção por um longo tempo.

Para quem o artigo


O artigo será útil principalmente para aqueles que estão começando a aprender o Flutter e não sabem por onde começar. Vou mostrar uma das opções para implementar o aplicativo no Flutter. Isso permitirá que você "sinta" o Flutter e, em seguida, decida por si mesmo como e com o qual você escreverá seus aplicativos.

Padrões e ferramentas. Breve e simples


Então, vamos começar. A primeira coisa que vale a pena notar é que existe uma arquitetura de aplicativo (padrão, modelo, algum conceito de construção) - é exatamente o mesmo: BLoC, MVP, MVC, MVC, MVVM, MVI etc. Muitas dessas arquiteturas são usadas não apenas no Flutter, mas também em outras linguagens de programação. Pergunta - o que escolher? Na minha opinião, você precisa escolher o que conhece bem, mas apenas se isso implicar reatividade e uma separação estrita da lógica de negócios da interface (sim, sim - “um carro pode ter qualquer cor se for preto”). Quanto à separação entre interface e lógica de negócios, acho que não há necessidade de explicar, mas quanto à reatividade - tente, se você ainda não experimentou - no final, é realmente muito conveniente e "bonito". Se você não pode escolher, deixe-nos permitir que não seja o mais estúpido do Google - BLOC. Nós descobrimos a arquitetura.

Agora as ferramentas - existem bibliotecas prontas - Bloc, Flutter_bloc, bloc_pattern - qual é melhor? Eu não sei - todo mundo é bom. Você pode escolher e comparar por um longo tempo, mas aqui novamente, como no exército - é melhor tomar uma decisão errada por enquanto do que não tomar nenhuma. E, por enquanto, sugiro voltar na sequência do mod e usar o Provider (o que os mesmos caras recomendam usar em 2019).

Tudo isso nos permitirá criar um bloco global e um bloco local, conforme necessário. Muito foi escrito sobre a arquitetura do BLoC (ou seja, um padrão, não bibliotecas), acho que você não deve insistir nela novamente em detalhes. Observo apenas um ponto neste artigo: não será utilizado o BLoC clássico, mas ligeiramente modificado - as ações (eventos) do BLoC não serão transmitidas por meio de Sinks, mas as funções do BLoC serão chamadas. Simplesmente, no momento, não vejo os benefícios de usar Pias - e como elas não estão lá, por que complicar sua vida?

Assincronia e computação paralela no Dart


Também vale a pena esclarecer um pouco o conceito de assincronia em Dart, já que estamos falando de reatividade. Muitas vezes, nos primeiros estágios do conhecimento de Dart, o significado de funções assíncronas (assíncronas) não é entendido corretamente. Você deve sempre lembrar que “por padrão” o programa é executado em um thread, e a assincronia apenas permite alterar a sequência de comandos, em vez de executá-los em paralelo. Ou seja, se você simplesmente executar a função com grandes cálculos apenas marcando-a como assíncrona, a interface será bloqueada. O Async NÃO inicia um novo thread. Como assíncrono e aguardam o trabalho, há muitas informações na Internet, então também não vou me debruçar sobre isso.

Se você precisar fazer alguns cálculos grandes e, ao mesmo tempo, não bloquear a interface, precisará usar a função de computação (para hardcore especial, você pode usar isolados). Isso realmente iniciará um thread de execução separado, que também terá sua própria área de memória separada (que é muito triste e triste). Você pode se comunicar com esses fluxos apenas através de mensagens que podem conter tipos de dados simples, suas listas.

Vamos começar a praticar


Formulação do problema


Vamos tentar escrever a aplicação mais simples - seja algum tipo de lista telefônica. Usaremos o Firebase como armazenamento - isso nos permitirá criar um aplicativo "em nuvem". Vou pular como conectar o Firebase ao projeto (mais de um artigo foi escrito sobre esse tópico e não vejo o sentido de repetir. Observação: o Cloud Firestore é usado neste projeto.)

Deve ser assim:





Descrição da aplicação


Nossa aplicação conterá externamente:

  1. Janela de autorização do Firebase (a lógica desta janela estará contida no MainBloc).
  2. Janela de informações - exibirá informações sobre o usuário sob o qual o programa está autorizado (a lógica desta janela também estará contida no MainBloc).
  3. Janela Diretório na forma de uma lista de telefones (a lógica desta janela estará contida em um PhonebookBloc separado).
  4. Menu do aplicativo que alternará as telas.

O aplicativo interno será construído da seguinte maneira: cada tela conterá um arquivo com widgets de tela, um arquivo de bloco (com a classe de bloco correspondente), um arquivo de ações (contém classes simples que descrevem eventos que afetam o estado do bloco), um arquivo de estados (contém classes simples que refletem o estado do bloco) ), o arquivo data_model que contém a classe do repositório (responsável pelo recebimento de dados) e a classe de dados (armazena dados da lógica de negócios do bloco).

O aplicativo funcionará assim - quando a tela é aberta, o bloco correspondente é inicializado com o valor do estado inicial e, se necessário, alguma ação inicial é chamada no construtor do bloco. A tela é construída / reconstruída com base no estado, que retorna o bloco. O usuário executa algumas ações no aplicativo que possuem ações correspondentes. As ações são passadas para a classe de bloco, onde são processadas na função mapEventToState e o bloco retorna o novo estado de volta à tela, com base no qual a tela é reconstruída.

Estrutura de arquivo


Antes de tudo, criamos um projeto Flutter vazio e criamos a estrutura desse tipo (note que no projeto demo alguns arquivos permanecerão vazios):



Janela de autorização. Mainbloc


Agora você precisa implementar a autorização no Firebase.
Vamos começar criando classes de eventos (é conveniente transferir dados através de eventos no bloco) e estados para o bloco principal:

file MainBloc \ actions

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

arquivo MainBloc \ states

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

O sinalizador de ocupado na classe state é usado para exibir progress_hud na interface e excluir leituras desnecessárias de dados do banco de dados ao rolar pela lista. Antes do início de todas as operações no bloco, um novo estado do tipo antigo com o sinalizador de ocupado definido é emitido para o fluxo de saída - dessa forma, a interface recebe uma notificação de que a operação foi iniciada. No final da operação, um novo estado é enviado ao fluxo com o sinalizador de ocupado limpo.

Os herdeiros da classe MainBlocState descrevem o estado do aplicativo principal Bloc. Os herdeiros do MainBlocAction descrevem os eventos que ocorrem nele.

A classe MainBloc contém 4 elementos principais - a função de "converter" eventos em estados (Future mapEventToState), o estado do Bloc é _blocState, o repositório do estado do bloco é o repositório e o fluxo de estado "output" (que os elementos da interface controlam) é blocStream. Basicamente, esses são todos os elementos que fornecem a funcionalidade de bloco a. Às vezes, é aconselhável usar 2 fluxos de saída em um bloco - esse exemplo será menor. Não vou listá-lo aqui - você pode vê-lo baixando o projeto.

A classe de repositório de blocos contém a lógica para trabalhar com o Firebase e um objeto (dados) que armazena os dados necessários para a lógica de negócios implementada por este bloco.

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


A classe MainData também armazena o estado, mas o estado de autorização no Firebase, e não o estado do Bloc.

Escrevemos a lógica do bloco principal, agora podemos começar a implementar a tela de autorização / registro.

MainBloc é inicializado no arquivo principal:

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

É hora de fazer uma pequena digressão sobre o StreamBuilder, Provider, StreamProvider, Consumer e Selector.

Retiro sobre fornecedores


Fornecedor - apenas transfere o valor armazenado para baixo da árvore. E você pode acessá-lo somente após a criação da criança, ou seja, você precisa construir um sub widget. Não nos responsabilizamos pela atualização de widgets.

StreamBuilder - um widget que monitora o fluxo e é completamente reconstruído quando recebe um novo objeto do fluxo.

StreamProvider - um widget que monitora o fluxo e, quando um novo objeto é recebido, sinaliza que os widgets filhos (aqueles que são declarados como uma classe separada com o método de compilação) devem ser reconstruídos.

Consumidor e Seletor são "açúcar sintático", ou seja, este é realmente um "invólucro" que contém a construção e oculta o widget abaixo. No Seletor-e, você pode fazer uma filtragem adicional de atualizações.

Portanto, quando você precisar reconstruir a maior parte da tela em cada evento, poderá usar a opção com Provider e StreamBuilder. Quando é necessário reconstruir partes da árvore de widgets próximas às folhas, é recomendável usar o StreamProvider em combinação com Consumer e Selector para excluir reconstruções desnecessárias da árvore.

Autorização Continuação


Ao entrar no aplicativo, o usuário deve acessar a janela de autorização / registro e, nesse momento, o menu do aplicativo ainda não deve estar disponível. O segundo ponto - atualizar parcialmente essa tela não faz muito sentido; portanto, podemos usar o StreamBuilder para criar a interface. E o terceiro ponto do projeto é usar o Navegador para navegar entre as telas. Após o recebimento de um evento de autorização bem-sucedida, é necessário chamar a transição para a tela de informações. Mas apenas dentro da construção do StreamBuilder, isso não funcionará - haverá um erro. Para contornar isso, você pode usar a classe auxiliar wrapper StreamBuilderWithListener (Eugene Brusov - stackoverflow.com ).

Agora a listagem dessa tela é o próprio auth_screen (darei aqui em parte):

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

Primeiro, um StreamBuilderWithListener é criado para ouvir o fluxo do bloco. E com base no estado atual, é chamado o widget LoggedWidget (se o usuário já estiver autorizado) ou SignInAndSignUpWidget (se o usuário ainda não estiver autorizado). Se o bloco retornar o estado IsLogged, a mudança para uma nova tela usando o Navigator não ocorrerá no construtor (o que levaria a um erro), mas no ouvinte. Nos widgets subjacentes, a interface é construída com base nos dados retornados aqui. Aqui, o pacote Provider + StreamBuilder é realmente usado, porque quando o estado do bloco muda, praticamente toda a interface muda.

Para transferir dados para o bloco, TextEditingController e parâmetros de ação são usados:

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

Janela da tela do telefone


E agora vamos falar um pouco sobre a nossa janela PhoneBookScreen. Esta é a janela mais interessante - aqui a interface é construída com base em 2 fluxos do bloco, e também há uma lista com rolagem e paginação (paginação).

PhonebookScreen \ screen Arquivo

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

O primeiro StreamProvider é necessário para alternar entre diferentes telas do diretório - lista, cartão de contato, cartão de contato para edição, etc. O widget da tela é selecionado na função caseWidget (mas neste exemplo apenas a visualização da lista é implementada - você pode tentar implementar a visualização do cartão de contato - isso é muito simples e não será um mau começo).

Nesta tela, um monte de StreamProvider + Selector / Consumer já está sendo usado, porque existe um pergaminho da lista e não é aconselhável reconstruir a tela inteira com ele (ou seja, a reconstrução de widgets vem do Seletor / Consumidor correspondente e mais abaixo na árvore).

E aqui está a implementação da própria lista:

PhonebookScreen \ arquivo de tela

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

Aqui vemos o segundo StreamProvider, que monitora o segundo fluxo do bloco, responsável pela rolagem. A paginação é organizada como padrão via _scrollListener (controller: _scrollController). Embora a janela seja interessante, mas dada a descrição detalhada da primeira janela, não há mais nada a dizer aqui. Portanto, isso é tudo hoje.

O objetivo deste artigo não era mostrar o código perfeito, ou seja, aqui você pode encontrar muitos pontos para otimização - "dividir" corretamente em arquivos, usar instância, mixins e afins em algum lugar. Além disso, o que "implora" o próximo passo - você pode fazer um cartão de contato. A principal tarefa era estruturar o conhecimento, definir um determinado vetor para a construção da aplicação, dar explicações sobre alguns dos momentos de criação de uma aplicação no Flutter que não eram muito óbvios nos primeiros estágios do conhecimento.

O projeto pode ser baixado em (para registro, você pode usar qualquer correio com uma senha de pelo menos 6 caracteres. Ao autorizar novamente, a senha deve ser a mesma que a usada durante o registro).

All Articles