Доброго времени суток, дорогие читатели! Меня зовут Сурен, и я разработчик.
Поскольку моя предыдущая статья о том, как бекендер в мобильную кроссплатформу лез, не утонула в минусах, я решил продолжить делиться своим опытом познания данной замечательной технологии =)
Написано немало статей про MVVM, его реализацию на различных технологиях и на Flutter, в частности. Но мне они давались с трудом, и не было понимания, как оно в итоге работает. Возможно, сказывается особенность восприятия “Бекендера” =) Поэтому, если среди читателей есть люди с похожим складом ума, возможно эта статья поможет и Вам понять, что такое MVVM и как его реализовать на Flutter простым способом.
Для начала немного википедии.
MVVM (Model-View-ViewModel) — шаблон проектирования архитектуры приложения. Пришел на смену шаблону MVC|MVP (Model-View-Controller/Presenter). Актуален для платформ, в которых присутствует концепция «связывания данных», позволяющая связывать данные с визуальными элементами в обе стороны. На схеме это выглядит так:
Где
Model представляет из себя слой бизнес-логики, который включает описание фундаментальных данных, необходимых для работы приложения, и логику работы с ними.
View является графическим интерфейсом. Подписан на изменения ViewModel, а при действиях с интерфейсом вызывает команды ViewModel для изменения данных в Модели.
ViewModel же с одной стороны — абстракция Представления, а с другой — обёртка данных из Модели, подлежащих связыванию. То есть она содержит Модель, преобразованную к Представлению, а также команды, которыми может пользоваться Представление, чтобы влиять на Модель.
Перейдем к практике.
Для обеспечения “связывания данных” я использую связку Provider+ChangeNotifier. Provider представляет собой смесь Инъекции Зависимостей (Dependency Injection или DI) и управления состоянием, а ChangeNotifier позволяет отслеживать изменения ViewModel и перестраивать наш UI.
Построим простейший ToDoList на данном подходе и попутно будем разбираться, как он работает.
Внимание! В качестве примера для наглядности и удобства я буду реализовывать каждый виджет в одном файле, то есть и ViewModel, и View(Виджет) и его Model. Но на боевых проектах, особенно если вы работаете в команде, эти сущности нужно разносить по разным файлам.
Для начала создадим новый проект приложения на flutter. Перед нами откроется чистый проект, представляющий из себя приложение счетчик или кликер.
В main.dart у нас реализован основной метод main(), запускающий приложение MyApp, которое состоит из 1 StatefullWidget - MyHomePage, реализующего счетчик нажатий. Приложение можно запустить и проверить, что все работает.
Но мы собрались делать ToDoList, следовательно пока оставляем main.dart. Создаем рядом новый файл todo_list_widget.dart, а в нем классы:
_ViewModel, реализующий ChangeNotifier,
ToDoListWidget, реализующий StatelessWidget,
_ModelState, в котором мы будем хранить состояние нашей модели.
todo_list_widget.dart
class _ModelState {}
class _ViewModel extends ChangeNotifier {}
class ToDoListWidget extends StatelessWidget {
const ToDoListWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
var _viewModel = context.watch<_ViewModel>();
return Container();
}
}
В Dart символ “_” перед именем свойства/класса/метода делает его приватным (доступным только в пределах текущего файла .dart).
Добавим файл todo_item.dart и реализуем в нем класс Модели ToDoItem. Цель — сделать некий список дел, так что свойства нужные нам в данном классе:
name - наименование задачи (по совместительству ключ),
done - флаг о выполнении.
Также, чтобы у нас была возможность “редактировать” элементы списка, добавим метод copyWith. Наш класс модели будет выглядеть следующим образом:
Класс ToDoItem
class ToDoItem {
final String name;
final bool done;
ToDoItem({
required this.name,
this.done = false,
});
ToDoItem copyWith({
String? name,
bool? done,
}) {
return ToDoItem(
name: name ?? this.name,
done: done ?? this.done,
);
}
}
Возвращаемся к основному.
В стейте мы будем хранить список элементов ToDo листа. Для обеспечения иммутабельности список у нас будет “final”, а для редактирования мы будем его копировать, поэтому добавим метод “copyWith”. В нашем случае отдельный стейт выглядит как “бойлерплейт“, но для наглядности и последующего масштабирования он полезен.
После этих манипуляций класс принял следующий вид:
todo_list_widget.dart
class _ModelState {
final List<ToDoItem> items;
_ModelState({
this.items = const <ToDoItem>[],
});
_ModelState copyWith({
List<ToDoItem>? items,
}) {
return _ModelState(
items: items ?? this.items,
);
}
}
Переходим к ViewModel, поскольку задача данного класса — быть прослойкой между интерфейсом и данными. С одной стороны мы должны в нем следить за изменениями модели и дергать перестраивание интерфейса, а с другой — должны иметь методы воздействия на эту модель. Добавим в него private свойство “_state” и дополним публичным сеттером и геттером. Геттер возвращает приватный стейт, а сеттер заменяет его новым значением и уведомляет прослушивающие виджеты при изменении стейта.
_ModelState
var _state = _ModelState();
_ModelState get state => _state;
set state(_ModelState val) {
_state = _state.copyWith(items: val.items);
notifyListeners();
}
“notifyListeners” — метод в классе “ChangeNotifier”, уведомляющий виджетов-слушателей об изменении модели и дающий команду перестроиться.
Для реализации ToDo списка нам нужны следующие методы:
Добавления элемента в список
addItem(ToDoItem item) {
var list = state.items.toList();
if (list.any((element) => element.name == item.name)) {
throw Error();
} else {
list.add(item);
state = state.copyWith(items: list);
}
}
Удаления элемента из списка
dellItem(ToDoItem item) {
var list = state.items.toList();
list.removeWhere((element) => element.name == item.name);
state = state.copyWith(items: list);
}
Переключения состояния элемента списка
toogleItem(ToDoItem item) {
var list = state.items.toList();
var index = list.indexWhere((element) => element.name == item.name);
list[index] = item.copyWith(done: !item.done);
state = state.copyWith(items: list);
}
После каждого действия мы вызываем Сеттер стейта для того, чтобы уведомить об изменении слушателей и перестроить стейт.
На первый взгляд основная логика готова, так что перейдем к View (виджету).
Пришло время добавить в проект пакет Provider. Для этого можно либо отредактировать файл pubspec.yaml, добавив в него зависимость от пакета Provider, либо выполнив команду в консоли:
flutter pub add provider — данная команда, собственно, и добавит зависимость в тот же файл и скачает пакет последней версии.
Чтобы наш виджет мог реагировать на изменение стейта, для начала обернем его в ChangeNotifierProvider. В метод создания мы передаем нашу ViewModel, а в качестве ребенка — наш Виджет. Для таких оберток удобно делать статический метод create внутри виджета, который и инициирует ViewModel и передает ее в виджет ребенка. Теперь для получения доступа к ViewModel внутри билдера виджета, мы можем ее получить через context. Для этого мы и используем пакет Provider.
Виджет ToDoListWidget
class ToDoListWidget extends StatelessWidget {
const ToDoListWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
var _viewModel = context.watch<_ViewModel>();
return Container();
}
static Widget create() => ChangeNotifierProvider(
create: (_) => _ViewModel(),
child: const ToDoListWidget(),
);
}
Метод watch<ClassName>() не только отдает нам через провайдер экземпляр модели, которая была инициализирована выше по дереву виджетов, но и добавляет текущий виджет в качестве слушателя изменений в модели. То есть, когда мы в модели вызываем метод notifyListeners, наш виджет-слушатель перестраивается, за счет чего и достигается “связывание данных” между моделью и представлением.
Теперь набросаем простенький интерфейс, аналогичный базовому виджету MyHomePage, но с той лишь разницей, что в качестве тела виджета у нас будет список. В качестве списка будем использовать ListView.builder, поскольку он рендерит только те элементы, которые видны на экране, и в целом удобен.
ToDoListWidget.build
@override
Widget build(BuildContext context) {
var _viewModel = context.watch<_ViewModel>();
return Scaffold(
appBar: AppBar(
title: const Text("ToDo List"),
),
body: ListView.builder(
itemBuilder: (_, int index) => ToDoItemWidget(
item: _viewModel.state.items[index],
onDelete: _viewModel.dellItem,
onToogle: _viewModel.toogleItem,
),
itemCount: _viewModel.state.items.length,
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showDialog(
context: context,
builder: (_) => AddTaskDialog(onFinish: _viewModel.addItem),
);
},
tooltip: 'Add task',
child: const Icon(Icons.add),
),
);
}
В качестве элемента списка напишем виджет ToDoItemWidget, который отдаст нам CheckboxListTile (элемент списка с чекбоксом), обернутый в Dismissible (для реализации удаления свайпом), и будет пробрасывать 2 делегата: на удаление элемента из списка и на изменение состояния элемента списка (выполнено/не выполнено).
Виджет ToDoItemWidget
class ToDoItemWidget extends StatelessWidget {
final ToDoItem item;
final Function(ToDoItem) onDelete;
final Function(ToDoItem) onToogle;
const ToDoItemWidget({
Key? key,
required this.item,
required this.onDelete,
required this.onToogle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Dismissible(
background: Container(color: Colors.red),
key: Key(item.name),
onDismissed: (_) {
onDelete(item);
},
child: CheckboxListTile(
value: item.done,
onChanged: (_) {
onToogle(item);
},
title: Text(item.name),
));
}
}
Для добавления элемента нам нужно сначала где-то взять название для задачи. Для этого напишем виджет AddTaskDialog. Он представляет из себя AlertDialog с текстовым полем и делегатом, который в случае успеха возвращает нам новенькую задачу, а также сообщает об ошибках через всплывающие уведомления.
AddTaskDialog
class AddTaskDialog extends StatelessWidget {
final ValueChanged<ToDoItem> onFinish;
const AddTaskDialog({
Key? key,
required this.onFinish,
}) : super(key: key);
@override
Widget build(BuildContext context) {
String? text;
return AlertDialog(
title: const Text("Add new Task"),
content: TextField(
autofocus: true,
onChanged: (String _text) {
text = _text;
}),
actions: <Widget>[
TextButton(
child: const Text("Add"),
onPressed: () {
if (text != null) {
try {
onFinish(ToDoItem(name: text!));
Navigator.of(context).pop();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Task ${text!} exist!")));
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Enter Task name")));
}
},
),
TextButton(
child: const Text("Close"),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}
Всё. Теперь можно собрать и посмотреть, что он делает =)
Все заложенные нами кейсы работают =)
В дальнейшем можно прикрутить sqlite, добавить инициализацию и синхронизацию, и вот — простенький задачник готов (шутка).
Я специально не затронул в данной статье редактирование задач. Если захотите попробовать повторить, Вы сможете это сделать сами =)
Ссылка на репозиторий с данным примером.
В сухом остатке.
Я не утверждаю, что MVVM — это лучший шаблон проектирования для мобильных приложений на Flutter. Это пока первое, что я осознал и принял на вооружение после statefullWidget. Самое главное для меня — это его масштабируемость и применимость для достаточно больших приложений. Плюс он отвечает требованиям Чистой архитектуры. Только прошу, не рассматривайте мой пример как Архитектурный. Я показал в коде на примере приложения для чек-листа, как реализовать данный шаблон.