Pull to refresh

Flutter. MVVM. Начало

Reading time7 min
Views11K

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

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

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

Tags:
Hubs:
+3
Comments9

Articles

Change theme settings

Information

Website
digdes.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия