Flutter. MVVM. Начало

от автора

Доброго времени суток, дорогие читатели! Меня зовут Сурен, и я разработчик.

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

Написано немало статей про 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. Самое главное для меня — это его масштабируемость и применимость для достаточно больших приложений. Плюс он отвечает требованиям Чистой архитектуры. Только прошу, не рассматривайте мой пример как Архитектурный. Я показал в коде на примере приложения для чек-листа, как реализовать данный шаблон.


ссылка на оригинал статьи https://habr.com/ru/company/digdes/blog/660411/


Комментарии

Добавить комментарий

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