(оригинал статьи на английском языке опубликован на Medium)


Flutter предоставляет современный реактивный фреймворк, большой набор виджетов и тулов. Но, к сожалению, в документации нет ничего похожего на руководство по рекомендуемой архитектуре приложения для Android.


Не существует идеальной, универсальной архитектуры, которая могла бы подойти под любые мыслимые требования технического задания, но давайте признаем, что большая часть мобильных приложений над которыми мы работаем имеют следующую функциональность:


  1. Запрос и загрузка данных.
  2. Трансформация и подготовка данных для пользователя.
  3. Запись и чтение данных из базы данных или файловой системы.

Учитывая все это, я создал демонстрационное приложение, которое решает одну и ту же задачу используя различные подходы к архитектуре.


Изначально пользователю показывается экран с кнопкой “Load user data” расположенной по центру. Когда пользователь нажимает на кнопку, происходит асинхронная загрузка данных, и кнопка заменяется индикатором загрузки. Когда загрузка данных завершена, индикатор загрузки заменяется данными.


Итак, начнем.



Данные


Чтобы упростить задачу я создал класс Repository, который содержит метод getUser(). Этот метод симулирует асинхронную загрузку данных из сети и возвращает Future<User>.


Если вы не знакомы с Futures и асинхронным программированием в Dart, мы можете подробнее почитать об этом тут и ознакомится с документацией класса Future.


class Repository {
  Future<User> getUser() async {
    await Future.delayed(Duration(seconds: 2));
    return User(name: 'John', surname: 'Smith');
  }
}

class User {
  User({
    @required this.name,
    @required this.surname,
  });

  final String name;
  final String surname;
}

Vanilla


Давайте разработаем приложение, как это сделал бы разработчик, прочитавший документацию по Flutter на официальном сайте.


Открываем экран VanillaScreen с помощью Navigator


Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => VanillaScreen(_repository),
  ),
);

Так как состояние виджета может меняться несколько раз в течении его жизненного циклы, нам необходимо наследоваться от StatefulWidget. Для имплементации своего stateful widget потребуется и класс State. Поля bool _isLoading и User _user в классе _VanillaScreenState представляют состояние виджета. Оба поля инициализируются до того как метод build(BuildContext context) будет вызван первый раз.


class VanillaScreen extends StatefulWidget {
  VanillaScreen(this._repository);
  final Repository _repository;

  @override
  State<StatefulWidget> createState() => _VanillaScreenState();
}

class _VanillaScreenState extends State<VanillaScreen> {
  bool _isLoading = false;
  User _user;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Vanilla'),
      ),
      body: SafeArea(
        child: _isLoading ? _buildLoading() : _buildBody(),
      ),
    );
  }

  Widget _buildBody() {
    if (_user != null) {
      return _buildContent();
    } else {
      return _buildInit();
    }
  }

  Widget _buildInit() {
    return Center(
      child: RaisedButton(
        child: const Text('Load user data'),
        onPressed: () {
          setState(() {
            _isLoading = true;
          });
          widget._repository.getUser().then((user) {
            setState(() {
              _user = user;
              _isLoading = false;
            });
          });
        },
      ),
    );
  }

  Widget _buildContent() {
    return Center(
      child: Text('Hello ${_user.name} ${_user.surname}'),
    );
  }

  Widget _buildLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

После того как объект состояния виджета создан, вызывается метод build(BuildContext context), чтобы сконструировать UI. Все решения о том, какой виджет должен быть показан в данный момент на экране принимаются прямо в коде декларации UI.


body: SafeArea(
  child: _isLoading ? _buildLoading() : _buildBody(),
)

Для того, чтобы отобразить индикатор прогресса, когда пользователь нажимает кнопку “Load user details” мы делаем следующее.


setState(() {
  _isLoading = true;
});

Из документации (перевод):


Вызов метода setState() оповещает фреймворк о том, что внутреннее состояние этого объекта изменилось, и может повлиять на пользовательский интерфейс в поддереве. Это является причиной вызова фреймворком метода build у этого объекта состояния.

Это значит, что после вызова метода setState() фреймворк снова вызовет метод build(BuildContext context), что приведет к пересозданию всего дерева виджетов. Так как значение поля _isLoading изменилось на true, то вместо метода _buildBody() будет вызван метод _buildLoading(), и индикатор прогресса будет отображен на экране.
Точно то же самое произойдет, когда мы получим коллбэк от getUser() и вызовем метод
setState(), чтобы присвоить новые значения полям _isLoading и _user.


widget._repository.getUser().then((user) {
  setState(() {
    _user = user;
    _isLoading = false;
  });
});

Плюсы


  1. Низкий порог вхождения.
  2. Не требуются сторонние библиотеки.

Минусы


  1. При изменении состояния виджета дерево виджетов каждый раз целиком пересоздается.
  2. Нарушает принцип единственной ответственности. Виджет отвечает не только за создание UI, но и за загрузку данных, бизнес-логику и управление состоянием.
  3. Решения о том как именно отображать текущее состояние принимаются прямо в UI коде. Если состояние станет более сложным, то читаемость кода сильно понизится.

Scoped Model


Scoped Model это сторонняя библиотека. Вот как разработчики ее описывают:


Набор утилит, которые позволяют передавать Модель данных виджета-предка всем его потомкам. В дополнении к этому, когда данные модели изменяются, все потомки, которые используют модель будут пересозданы. Эта библиотека изначально взята из кода проекта Fuchsia.

Давайте создадим такой же экран как и в прошлом примере, но с использованием Scoped Model. Для начала нам необходимо добавить библиотеку Scoped Model в проект. Добавим зависимость scoped_model в файл pubspec.yaml в секцию dependencies.


scoped_model: ^1.0.1

Давайте посмотрим на код UserModelScreen и сравним его с предыдущим примером, в котором мы не использовали Scoped Model. Чтобы сделать нашу модель доступной для потомков виджета необходимо обернуть виджет и модель в ScopedModel.


class UserModelScreen extends StatefulWidget {
  UserModelScreen(this._repository);
  final Repository _repository;

  @override
  State<StatefulWidget> createState() => _UserModelScreenState();
}

class _UserModelScreenState extends State<UserModelScreen> {
  UserModel _userModel;

  @override
  void initState() {
    _userModel = UserModel(widget._repository);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return ScopedModel(
      model: _userModel,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('Scoped model'),
        ),
        body: SafeArea(
          child: ScopedModelDescendant<UserModel>(
            builder: (context, child, model) {
              if (model.isLoading) {
                return _buildLoading();
              } else {
                if (model.user != null) {
                  return _buildContent(model);
                } else {
                  return _buildInit(model);
                }
              }
            },
          ),
        ),
      ),
    );
  }

  Widget _buildInit(UserModel userModel) {
    return Center(
      child: RaisedButton(
        child: const Text('Load user data'),
        onPressed: () {
          userModel.loadUserData();
        },
      ),
    );
  }

  Widget _buildContent(UserModel userModel) {
    return Center(
      child: Text('Hello ${userModel.user.name} ${userModel.user.surname}'),
    );
  }

  Widget _buildLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

В предыдущем примере, каждый раз при изменении состояния виджета, дерево виджетов целиком пересоздавалось. Но надо ли нам на самом деле пересоздавать дерево виджетов целиком (весь экран)? Например, AppBar никак не не меняется, и нет никакого смысла его пересоздавать. В идеале, стоит пересоздавать только те виджеты, которые должны меняться в соответствии с изменением состояния. И Scoped Model может нам помочь в решении этой задачи.


Виджет ScopedModelDescendant<UserModel> используется для того, чтобы найти UserModel в дереве виджетов. Он будет автоматически пересоздан каждый раз, когда UserModel оповещает о том, что было изменение.


Еще одно улучшение заключается в том, что UserModelScreen больше не отвечает за управление состоянием, бизнес-логику и загрузку данных.


Давайте посмотрим на код класса UserModel.


class UserModel extends Model {
  UserModel(this._repository);
  final Repository _repository;

  bool _isLoading = false;
  User _user;

  User get user => _user;
  bool get isLoading => _isLoading;

  void loadUserData() {
    _isLoading = true;
    notifyListeners();
    _repository.getUser().then((user) {
      _user = user;
      _isLoading = false;
      notifyListeners();
    });
  }

  static UserModel of(BuildContext context) =>
      ScopedModel.of<UserModel>(context);
}

Теперь UserModel содержит и управляет состоянием. Для того, чтобы оповестить слушателей (и пересоздать потомков) о том, что произошло изменение, необходимо вызвать метод notifyListeners().


Плюсы


  1. Управление состоянием, бизнес логика, и загрузка данных отделены от UI кода.
  2. Низкий порог вхождения.

Минусы


  1. Зависимость от сторонней библиотеки.
  2. Если модель станет достаточно сложной, будет тяжело уследить, когда действительно необходимо вызывать метод notifyListeners(), чтобы не допускать лишних пересозданий.

BLoC


BLoC (Business Logic Components) это паттерн, рекомендованный разработчиками из компании Google. Для управления состоянием и для уведомления об изменении состояния используются потоки.


Для Android разработчиков: Вы можете представить, что Bloc это ViewModel, а StreamController это LiveData. Это сделает следующий код легким к пониманию, так как вы уже знакомы с основными принципами.


class UserBloc {
  UserBloc(this._repository);

  final Repository _repository;

  final _userStreamController = StreamController<UserState>();

  Stream<UserState> get user => _userStreamController.stream;

  void loadUserData() {
    _userStreamController.sink.add(UserState._userLoading());
    _repository.getUser().then((user) {
      _userStreamController.sink.add(UserState._userData(user));
    });
  }

  void dispose() {
    _userStreamController.close();
  }
}

class UserState {
  UserState();
  factory UserState._userData(User user) = UserDataState;
  factory UserState._userLoading() = UserLoadingState;
}

class UserInitState extends UserState {}

class UserLoadingState extends UserState {}

class UserDataState extends UserState {
  UserDataState(this.user);
  final User user;
}

Из кода видно, что больше нет необходимости вызывать дополнительные методы для уведомления об изменениях состояния.


Я создал 3 класса, для представления возможных состояний:


UserInitState для состояния, когда пользователь открывает экран с кнопкой в центре.


UserLoadingState для состояния, когда отображается индикатор загрузки, в то время пока происходит загрузка данных.


UserDataState для состояния, когда данные уже загружены и показаны на экране.


Передача состояния таким образом позволяет нам полностью избавиться от логики в UI коде. В примере со Scoped Model мы все еще проверяли является ли значение поля _isLoading true или false, чтобы определить какой виджет создавать. В случае с BLoC мы передаем новое состояние в поток, и единственная задача виджета UserBlocScreen создавать UI для текущего состояния.


class UserBlocScreen extends StatefulWidget {
  UserBlocScreen(this._repository);
  final Repository _repository;

  @override
  State<StatefulWidget> createState() => _UserBlocScreenState();
}

class _UserBlocScreenState extends State<UserBlocScreen> {
  UserBloc _userBloc;

  @override
  void initState() {
    _userBloc = UserBloc(widget._repository);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bloc'),
      ),
      body: SafeArea(
        child: StreamBuilder<UserState>(
          stream: _userBloc.user,
          initialData: UserInitState(),
          builder: (context, snapshot) {
            if (snapshot.data is UserInitState) {
              return _buildInit();
            }
            if (snapshot.data is UserDataState) {
              UserDataState state = snapshot.data;
              return _buildContent(state.user);
            }
            if (snapshot.data is UserLoadingState) {
              return _buildLoading();
            }
          },
        ),
      ),
    );
  }

  Widget _buildInit() {
    return Center(
      child: RaisedButton(
        child: const Text('Load user data'),
        onPressed: () {
          _userBloc.loadUserData();
        },
      ),
    );
  }

  Widget _buildContent(User user) {
    return Center(
      child: Text('Hello ${user.name} ${user.surname}'),
    );
  }

  Widget _buildLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }

  @override
  void dispose() {
    _userBloc.dispose();
    super.dispose();
  }
}

Код виджета UserBlocScreen стал еще проще по сравнению с предыдущими примерами. Для того, чтобы слушать изменения состояния используется StreamBuilder. StreamBuilder это StatefulWidget, который создает себя в соответсвии с последним значением (Snapshot) потока (Stream).


Плюсы


  1. Не требуются сторонние библиотеки.
  2. Бизнес-логика, управление состоянием, и загрузка данных отделены от UI кода.
  3. Реактивность. Нет необходимости в вызове дополнительных методов, как в примере со Scoped Model notifyListeners().

Минусы


  1. Порог вхождения чуть выше. Нужен опыт в работе с потоками или rxdart.

Линки


Мы можете ознакомиться с полным кодом, скачав его с моего репозитория на github.


Оригинал статьи опубликован на Medium

Комментарии (5)


  1. xwild
    03.02.2019 12:12

    Делал недавно на flutter приложение, которое работает с СМС на андроид.
    Из того что запомнилось:

    — Пакет для получения разрешений (permission 0.1.1) выдает разрешения которые на самом деле не работают, я до сих пор не понимаю как это возможно, разрешение на СМС стоит, но не работает, даже после перезагрузки, выключишь-включишь в системе — начинает работать. Переделал на java, стало все хорошо.

    — При сборке с target на последний api, при нажатии на поле ввода не появляется клавиатура, пока туда не вставишь текст, оказалось известный баг, надо ставить target предпоследний api.

    — firebase + cloud firestore работает хорошо, вообще все плагины которые поддерживаются гуглом работают нормально, а вот сторонние еще слишком недоделанные, интересно ReactNative вылечился от этого уже или нет.

    — Код на Dart для интерфейса слишком похож на React, я так понимаю flutter и был ответом на ReactNative. В итоге большое количество вложенностей, плохо читается, ну может кто к React привык, ему нормально, по мне лучше бы сделали в стиле Vue.js.

    — Не смог найти как из фоновой задачи вызвать код на dart, самого приложения то нет, пришлось написать все на java. Но вызывать методы java из dart оказалось несложно, наверное это более востребовано.

    — размер приложения получается довольно маленький, у меня с play services, firebase, firestore получилось 9.1 Мб, без них кажется было около 5Мб. Все таки компиляция в native код это хорошо.

    Но flutter судя по всему выстрелил, на stackoverflow trends, рост чуть-ли не вертикальный, на upwork работы стало полно для него.


  1. yanisbiziuk
    04.02.2019 14:33

    Спасибо за статью. Интересен паттерн BLOC.
    Могли бы вы привести пример если нам нужно создать анимацию между переходами из одного состояния в другое?


  1. vitalyErm
    04.02.2019 16:41
    +2

    Если во время загрузки данных и крутящегося спинера повернуть экран, только vanila верно отработала. Block — бесконечно продолжает крутить спинер, Scope — показывает кнопку


    1. savjolovs Автор
      04.02.2019 16:46
      +1

      Спасибо, что написали об ошибке. Model и Scope создавались в build методе класса HomePage. И при вращении экрана каждый раз пересоздавались. Подобную ошибку я уже допускаю не впервые.
      Я переписал код, и сделал его еще проще для понимания. Избавился от BlocProvider так как он на самом деле не нужен для демонстрации самой архитектуры.


      1. savjolovs Автор
        04.02.2019 17:56

        Имел ввиду Model и Bloc конечно же :)