Главная » Хабрахабр » Основы архитектуры приложений на Flutter: Vanilla, Scoped Model, BLoC

Основы архитектуры приложений на Flutter: Vanilla, Scoped Model, BLoC

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

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

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

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

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

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

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

Данные

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

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

class Repository
}

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

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

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

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

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

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

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

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

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

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

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

Чтобы сделать нашу модель доступной для потомков виджета необходимо обернуть виджет и модель в ScopedModel.

Navigator.push( context, MaterialPageRoute( builder: (context) => ScopedModel<UserModel>( model: UserModel(_repository), child: UserModelScreen(), ), ),
);

Давайте посмотрим на код UserModelScreen и сравним его с предыдущим примером, в котором мы не использовали Scoped Model.

class UserModelScreen extends StatelessWidget { @override Widget build(BuildContext context) { return 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(), ); }
}

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

Он будет автоматически пересоздан каждый раз, когда UserModel оповещает о том, что было изменение. Виджет ScopedModelDescendant<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);
}

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

Плюсы

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

Минусы

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

BLoC

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

Тем не менее, нам потребуется вспомогательный класс BlocProvider. Для имплементации паттерна BLoC не нужны сторонние библиотеки.

Navigator.push( context, MaterialPageRoute( builder: (context) => BlocProvider( bloc: UserBloc(_repository), child: UserBlocScreen(), ), ),
);

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

class UserBloc extends BlocBase { 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)); }); } @override 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 для состояния, когда данные уже загружены и показаны на экране.

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

class UserBlocScreen extends StatelessWidget { @override Widget build(BuildContext context) { final UserBloc userBloc = BlocProvider.of(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(userBloc); } if (snapshot.data is UserDataState) { UserDataState state = snapshot.data; return _buildContent(state.user); } if (snapshot.data is UserLoadingState) { return _buildLoading(); } }, ), ), ); } Widget _buildInit(UserBloc userBloc) { 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(), ); }
}

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

Плюсы

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

Минусы

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

Линки

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Профессиональная IoT-конференция InoThings++ — что было и что будет

Привет, Хабр! Практически ровно год назад — в конце января 2018-го — мы попробовали провести первую профессиональную конференцию для разработчиков устройств, систем и проектов «Интернета вещей» InoThings++ 2018. Помимо того, что она была первой для нас — если не считать ...

[Перевод] Изучаем Python: модуль argparse

Если вы занимаетесь обработкой и анализом данных с использованием Python, то вам, рано или поздно, придётся выйти за пределы Jupyter Notebook, преобразовав свой код в скрипты, которые можно запускать средствами командной строки. Здесь вам и пригодится модуль argparse. Для новичков, ...