Вступление


Когда пытаешься написать приложение, то первое с чем сталкиваешься – это как организовать архитектуру приложения. А когда еще при этом речь идет про Flutter, так голова совсем может пойти кругом от того, что выдает Гугл — Vanilla, Scoped Model, BLoC, MVP, MVC, MVVM, MVI и т.д. Предположим вы решили пойти самым модным путем (тем, что советовал Google в 2018 году) и использовать BLOC. Что это? Как этим пользоваться? А может Redux или RxDart? – хотя стоп – это же про «другое» … А все-таки что дальше? Какие библиотеки подключать? Bloc, Flutter_bloc, bloc_pattern и т.д.?

Такое количество вариантов архитектур и инструментов для их реализации действительно может на долго затянуть стадию выбора.

Для кого статья


Статья в первую очередь будет полезна тем, кто только начинает осваивать Flutter и не знает с чего начать. Я покажу один из вариантов реализации приложения на Flutter. Это позволит вам «пощупать» Flutter, а дальше уже сами решите, как и с использованием чего будете писать свои приложения.

Паттерны и инструменты. Кратко и просто


И так начнем. Первое что стоит отметить, что есть архитектура приложения (паттерн, шаблон, некая концепция построения) – это как раз-таки: BLoC, MVP, MVC, MVVM, MVI и т.д. Многие из этих архитектур используются не только на Flutter, но и в других языках программирования. Вопрос – что из этого выбрать? На мой взгляд нужно выбрать то, что вы сами хорошо знаете, но только если это подразумевает реактивность и жесткое отделение бизнес логики от интерфейса (да-да – «автомобиль может быть любого цвета, если он черный»). Насчет разделения интерфейса и бизнес-логики, думаю, объяснять не надо, а вот насчет реактивности – попробуйте, если не пробовали – в итоге это действительно очень удобно и «красиво». Если сами выбрать не можете, то давайте пока позволим это сделать за нас не самым глупым парням из Google – BLOC. С архитектурой разобрались.

Теперь инструменты – есть уже готовые библиотеки — Bloc, Flutter_bloc, bloc_pattern – какая лучше? Не знаю – все хороши. Можно долго выбирать и сравнивать, но тут опять как в армии – лучше пока принять не правильное решение, чем не принять никакого. И я пока предлагаю пойти опять в след за модой и использовать Provider (то, что те же самые парни, рекомендуют использовать в 2019 году).

Все это позволит нам сделать как глобальный bloc, так и локальные bloc-и по мере необходимости. Про архитектуру BLoC (именно, паттерн, а не библиотеки) уже много написано, думаю, не стоит подробно снова на ней останавливаться. Отмечу только лишь один момент в данной статье будет использоваться не классический BLoC, а немного модифицированный – в BLoC action (события) будут передаваться не через Sink-и, а будут вызываться функции BLoC-а. Просто, на данный момент, не вижу преимущества использования Sink-ов – а раз их нет, то зачем усложнять себе жизнь?

Асинхронность и параллельные вычисления в Dart


Также стоит немного разъяснить понятие асинхронности в Dart, раз уж мы говорим про реактивность. Очень часто на первых этапах знакомства с Dart, не правильно понимается смысл асинхронных функций (async). Надо всегда помнить, что «по-умолчанию» программа выполняется в одном потоке, а асинхронность лишь позволяет изменить последовательность выполнения команд, а не выполнять их параллельно. То есть если просто запустить функцию с большими вычислениями всего лишь пометив ее async, то интерфейс заблокируется. Async НЕ запускает новый поток. Как работает async и await есть много информации в интернете, поэтому останавливаться на этом тоже не буду.

Если же надо произвести какие-то большие вычисления и при этом не заблокировать интерфейс, то надо использовать функцию compute (для особого хардкора можно воспользоваться isolate-ами). Это действительно запустит отдельный поток выполнения, у которого будет также своя отдельная область памяти (что очень грустно и печально). Общаться с такими потоками можно, только через сообщения, которые могут содержать простые типы данных, их списки.

Приступим к практике


Постановка задачи


Давайте попробуем написать простейшее приложение – пусть это будет некий телефонный справочник. В качестве хранилища будем использовать Firebase – это позволит сделать «облачное» приложение. Как подключить Firebase к проекту я пропущу (на эту тему написана не одна статья и повторяться не вижу смысла. Замечание: в данном проекте используется Cloud Firestore.).

Должно получиться так:





Описание приложения


Наше приложение внешне будет содержать:

  1. Окно авторизации Firebase (логика этого окна будет содержаться в MainBloc).
  2. Окно информации — будет показывать информацию о пользователе, под которым авторизована программа (логика этого окна будет так же содержаться в MainBloc).
  3. Окно справочника в виде перечня телефонов (логика этого окна будет содержаться в отдельном PhonebookBloc).
  4. Меню приложения, которое будет переключать экраны.

Внутренне приложение будет построено следующим образом: каждый экран будет содержать файл с виджетами экрана, файл bloc (с классом соответствующего bloc), файл actions (содержит простые классы описывающие события, которые влияют на состояние bloc), файл states (содержит простые классы отражающие состояние bloc), файл data_model содержащий класс репозитория (отвечает за получение данных) и класс data (хранит данные бизнес логики bloc-а).

Функционировать приложение будет так – при открытии экрана соответствующий bloc инициализируется начальным значением состояния (state) и при необходимости в конструкторе bloc вызывается некоторый начальный action. Экран строится/перестраивается на основании state, которое возвращает bloc. Пользователь совершает некоторые действия в приложении, которые имеют соответствующие action-ы. Action-ы передаются в класс bloc, там в функции mapEventToState они обрабатываются и bloc возвращает новое state обратно в экран, на основании которого экран перестраивается.

Структура файлов


Первым делом создаем пустой проект Flutter и сделаем структуру проекта такого вида (отмечу, что в демо-проекте некоторые файлы в итоге останутся пустыми):



Окно авторизации. MainBloc


Теперь необходимо реализовать авторизацию в Firebase.
Начнем с создания классов событий (через события в bloc удобно передавать данные) и состояний для Main bloc-а:

файл MainBloc\actions

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

файл MainBloc\states

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

Флаг busy в классе состояния используется для вывода в интерфейсе progress_hud и исключения лишних считываний данных из базы при скролле списка. Перед началом всех операций в блоке в выходной поток выдается новое состояние старого типа с установленным флагом busy – таким образом интерфейс получает уведомление о том, что операция началась. По окончанию операции в поток подается новое состояние со сброшенным флагом busy.

Наследники класса MainBlocState описывают состояния основного Bloc-а приложения. Наследники MainBlocAction описывают события, возникающие в нем.

Класс MainBloc содержит 4 основных элемента – функцию «преобразования» событий в состояния (Future mapEventToState), состояние Bloc-а — _blocState, репозитарий bloc-а — repo и «выходной» поток состояний (который отслеживают элементы интерфейса) – blocStream. В принципе, это все элементы, обеспечивающие функциональность bloc-a. Иногда целесообразно использовать 2 выходных потока в одном bloc-е – такой пример будет ниже. Приводить его листинг здесь не буду – можно посмотреть, скачав проект.

Класс репозитория bloc-а содержит логику работы с Firebase и объект(data) хранящий данные, необходимые для бизнес логики, которую реализует данный bloc.

Файл 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;
  }
}


В классе MainData тоже хранится состояние, но состояние авторизации в Firebase, а не состояние Bloc.

Логику для основного bloc-а написали, теперь можно приступить к реализации экрана авторизации/регистрации.

MainBloc инициализируется в файле main:

Файл main

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

Самое время сделать небольшое отступление про StreamBuilder, Provider, StreamProvider, Consumer и Selector.

Отступление про Provider-ы


Provider — только передает хранимое значение вниз по дереву. Причем обратиться к нему можно, только после дочернего build, т.е. необходимо построить подчиненный виджет. Никак не отвечает за обновление виджетов.

StreamBuilder — виджет, который следит за потоком и при получении из потока нового объекта перестраивается полностью.

StreamProvider — виджет, который следит за потоком и при получении нового объекта, сигнализирует, что дочерние (те, которые объявлены отдельным классом с методом build) виджеты должны перестроиться.

Consumer и Selector являются «синтаксическим сахаром», т.е. это фактически «обертка», которая содержит build и прячет под собой виджет. В Selector-e можно сделать дополнительную фильтрацию обновлений.

Таким образом, когда при каждом событии надо перестроить большую часть экрана, то можно использовать вариант с Provider и StreamBuilder. Когда же надо перестроить части дерева виджетов близкие к листьям, то для исключения лишних перестроений дерева целесообразно использовать StreamProvider в сочетании с Consumer и Selector.

Авторизация. Продолжение


При входе в приложение пользователь должен попасть на окно авторизации/регистрации, и в этот момент ему еще не должно быть доступно меню приложения. Второй момент – данный экран обновлять частично не имеет особого смысла, поэтому для построения интерфейса мы можем использовать StreamBuilder. И третий момент в проекте используется Navigator для навигации между экранами. При получении события успешной авторизации необходимо вызвать переход на экран информации. Но просто внутри build StreamBuilder-а такое не получится – будет ошибка. Чтобы это обойти можно воспользоваться вспомогательным классом-оберткой StreamBuilderWithListener (Eugene Brusov — stackoverflow.com).

Теперь сам листинг данного экрана auth_screen(приведу тут частично):

Файл 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"));
      });
}

В первую очередь создается StreamBuilderWithListener для прослушивания потока из bloc-а. И на основе текущего состояния вызывается либо виджет LoggedWidget (если пользователь уже авторизован), либо SignInAndSignUpWidget (если пользователь не авторизован еще). В случае если bloc возвращает состояние IsLogged переключение на новый экран посредством Navigator происходит не в builder (что привело бы к ошибке), а в listener. В нижележащих виджетах происходит построение интерфейса на основе данных, возвращаемых здесь. Тут фактически используется связка Provider+ StreamBuilder, т.к. при изменении состояния блока фактически весь интерфейс меняется.

Для передачи данных в bloc используются TextEditingController и параметры action-ов:

Файл 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


И теперь давайте немного поговорим о своем окне PhoneBookScreen. Это самое интересное окно – тут интерфейс строится на основе 2-х потоков из bloc-а, а еще тут есть список со скроллом и пагинацией (пэйджинацией, pagination).

Файл PhonebookScreen\screen

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

Первый StreamProvider нужен для переключения между различными экранами справочника – список, карточка контакта, карточка контакта для редактирования и т.п. Выбор виджета для экрана происходит в функции caseWidget (но в этом примере реализован только вид для списка – можете попробовать реализовать вид для карточки контакта – это очень просто и будет не плохим началом.).

На этом экране уже используется связка StreamProvider + Selector/Consumer, т.к. тут есть скролл списка и при нем перестраивать весь экран не целесообразно (т.е. перестроение виджетов происходит от соответствующего Selector/Consumer и ниже по дереву).

И вот реализация самого списка:

Файл PhonebookScreen\screen

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

Тут видим второй StreamProvider, который следит за вторым потоком bloc-а, который отвечает за скролл. Пагинация организована стандартно через _scrollListener (controller: _scrollController). Хоть окно и интересное, но с учетом подробного описания первого окна — больше ничего нового тут сказать нечего. Поэтому на этом сегодня — все.

Задачей данной статьи не было показать идеальный код, то есть тут можно найти многие моменты для оптимизации — правильно «разбить» по файлам, использовать где-то instance, mixin-ы и тому подобное. Также, что «напрашивается» следующим этапом – можно сделать карточку контакта. Основная задача была структурировать знания, задать некоторый вектор для конструирования приложения, дать разъяснения по некоторым не очень очевидным на первых этапах знакомства моментах проектирования приложения на Flutter.

Проект можно скачать по адресу (для регистрации можно использовать любую почту, с паролем не менее 6 символов. При повторной авторизации пароль должен совпадать с тем, что использовался при регистрации).