Основы архитектуры приложений на Flutter: Vanilla, Scoped Model, BLoC
(оригинал статьи на английском языке опубликован на Medium)
Flutter предоставляет современный реактивный фреймворк, большой набор виджетов и тулов. Но, к сожалению, в документации нет ничего похожего на руководство по рекомендуемой архитектуре приложения для Android.
Не существует идеальной, универсальной архитектуры, которая могла бы подойти под любые мыслимые требования технического задания, но давайте признаем, что большая часть мобильных приложений над которыми мы работаем имеют следующую функциональность:
- Запрос и загрузка данных.
- Трансформация и подготовка данных для пользователя.
- Запись и чтение данных из базы данных или файловой системы.
Учитывая все это, я создал демонстрационное приложение, которое решает одну и ту же задачу используя различные подходы к архитектуре.
Изначально пользователю показывается экран с кнопкой «Load user data» расположенной по центру. Когда пользователь нажимает на кнопку, происходит асинхронная загрузка данных, и кнопка заменяется индикатором загрузки. Когда загрузка данных завершена, индикатор загрузки заменяется данными.
Итак, начнем.
Данные
Чтобы упростить задачу я создал класс Repository
, который содержит метод getUser()
. Этот метод симулирует асинхронную загрузку данных из сети и возвращает Future
.
Если вы не знакомы с Futures и асинхронным программированием в Dart, мы можете подробнее почитать об этом тут и ознакомится с документацией класса Future.
class Repository {
Future 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 createState() => _VanillaScreenState();
}
class _VanillaScreenState extends State {
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;
});
});
Плюсы
- Низкий порог вхождения.
- Не требуются сторонние библиотеки.
Минусы
- При изменении состояния виджета дерево виджетов каждый раз целиком пересоздается.
- Нарушает принцип единственной ответственности. Виджет отвечает не только за создание UI, но и за загрузку данных, бизнес-логику и управление состоянием.
- Решения о том как именно отображать текущее состояние принимаются прямо в 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(
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(
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(),
);
}
}
Первое, что бросается в глаза это то, что UserModelScreen
наследует StatelessWidget
вместо StatefulWidget
. В предыдущем примере, в котором мы использовали StatefulWidget
каждый раз при изменении состояния виджета, дерево виджетов целиком пересоздавалось. Но надо ли нам на самом деле пересоздавать дерево виджетов целиком (весь экран)? Например, AppBar никак не не меняется, и нет никакого смысла его пересоздавать. В идеале, стоит пересоздавать только те виджеты, которые должны меняться в соответствии с изменением состояния. И Scoped Model может нам помочь в решении этой задачи.
Виджет 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(context);
}
Теперь UserModel
содержит и управляет состоянием. Для того, чтобы оповестить слушателей (и пересоздать потомков) о том, что произошло изменение, необходимо вызвать метод notifyListeners()
.
Плюсы
- Управление состоянием, бизнес логика, и загрузка данных отделены от UI кода.
- Низкий порог вхождения.
Минусы
- Зависимость от сторонней библиотеки.
- Если модель станет достаточно сложной, будет тяжело уследить, когда действительно необходимо вызывать метод
notifyListeners()
, чтобы не допускать лишних пересозданий.
BLoC
BLoC (Business Logic Components) это паттерн, рекомендованный разработчиками из компании Google. Для управления состоянием и для уведомления об изменении состояния используются потоки.
Для имплементации паттерна BLoC не нужны сторонние библиотеки. Тем не менее, нам потребуется вспомогательный класс BlocProvider.
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();
Stream 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
для состояния, когда данные уже загружены и показаны на экране.
Передача состояния таким образом позволяет нам полностью избавиться от логики в UI коде. В примере со Scoped Model мы все еще проверяли является ли значение поля _isLoading
true
или false
, чтобы определить какой виджет создавать. В случае с 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(
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(),
);
}
}
Код виджета UserBlocScreen
стал еще проще по сравнению с предыдущими примерами. Для того, чтобы слушать изменения состояния используется StreamBuilder. StreamBuilder
это StatefulWidget
, который создает себя в соответсвии с последним значением (Snapshot) потока (Stream).
Плюсы
- Не требуются сторонние библиотеки.
- Бизнес-логика, управление состоянием, и загрузка данных отделены от UI кода.
- Рективность. Нет необходимости в вызове дополнительных методов, как в примере со Scoped Model
notifyListeners()
.
Минусы
- Порог вхождения чуть выше. Нужен опыт в работе с потоками или rxdart.
Линки
Мы можете ознакомиться с полным кодом, скачав его с моего репозитория на github.
Оригинал статьи опубликован на Medium