Использование MVVM во Flutter (2022)

Если это помогло 🙂

Мы вкратце расскажем:

  1. Что такое MVVM
  2. Использование MVVM во Flutter
  3. Расширение MVVM с помощью репозитория и сервисов

Что такое MVVM

Модель-Вид-Вид-Модель (MVVM) — это архитектурный паттерн программного обеспечения, который поддерживает отделение пользовательского интерфейса (который является видом) от разработки бизнес-логики или логики бэкенда (модель). Модель представления в MVVM — это мост, отвечающий за преобразование данных таким образом, чтобы они вели себя в соответствии с изменениями, происходящими на пользовательском интерфейсе.

Кроме того, чтобы знать об обязанностях трех компонентов, важно также понимать, как эти компоненты взаимодействуют друг с другом. На самом высоком уровне представление «знает о» модели представления, а модель представления «знает о» модели, но модель не знает о модели представления, а модель представления не знает о представлении.

Существует несколько преимуществ использования MVVM:

  • Разделение забот: Это принцип проектирования, позволяющий разделить компьютерную программу на отдельные разделы таким образом, чтобы каждый раздел решал отдельную задачу. Задача — это все, что имеет значение для решения проблемы.
  • Улучшенная тестируемость
  • Определенная структура проекта
  • Параллельная разработка пользовательского интерфейса
  • Абстрагирование представления, что уменьшает количество бизнес-логики, требуемой в коде, стоящем за ним.

Некоторые недостатки использования MVVM:

  • Он имеет немного крутую кривую обучения. Понимание того, как все слои работают вместе, может занять некоторое время.
  • Он добавляет много дополнительных классов, поэтому он не идеален для проектов с низкой сложностью.

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

Компоненты

Модель: Это, по сути, модель домена или модель, которая представляет данные из вашего бэкенда (он же уровень доступа к данным). Модели хранят информацию, но, как правило, не обрабатывают поведение. Они не форматируют информацию и не влияют на отображение данных. Модель в паттерне проектирования MVVM представляет фактические данные, которые будут использоваться при разработке приложения

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

View должен быть настолько тупым, насколько это возможно. Никогда не помещайте свою бизнес-логику в Views.

Модель представления: ViewModel действует как промежуточное звено между View и Model, таким образом, что она предоставляет данные для пользовательского интерфейса. ViewModel также может раскрывать методы, помогающие поддерживать состояние представления, обновлять модель на основе действий над представлением и вызывать события на представлении. Для Flutter у нас есть слушатель ChangeNotifier, который позволяет ViewModel информировать или обновлять View всякий раз, когда данные обновляются.

У ViewModel в основном две обязанности:

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

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

Что такое ChangeNotifier?

ChangeNotifier — это класс, который предоставляет уведомления об изменениях своим слушателям.

Согласно официальной документации

Класс, который может быть расширен или смешан, предоставляющий API уведомлений об изменениях с использованием VoidCallback для уведомлений.

Это O(1) для добавления слушателей и O(N) для удаления слушателей и отправки уведомлений (где N — количество слушателей).

Существует несколько способов использовать уведомление об изменениях во Flutter.

  1. Использование метода .addListener, поскольку ChangeNotifier является типом Listenable.
  2. Используя комбинацию ChangeNotifierProvider, Consumer и Provider. Все эти возможности предоставляются нам пакетом Provider.

Мы будем использовать подход 2

В реальном мире другие классы могут прослушивать объект ChangeNotifier. Когда change notifier получает обновленные значения, он может вызвать метод notifyListeners, и тогда любой из его слушателей получит обновленные значения.

class Person extends ChangeNotifier {
  Person({this.name, this.age});
  final String name;
  int age;

  void increaseAge() {
    this.age++;
    notifyListeners();
  }
}
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => Person(name: "Joe", age: 28),
      child: MyApp(),
    ),
  );
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Внутри приложения любой класс, который слушает этот Person, будет уведомлен в случае изменения age. Внутри приложения notifyListeners вызывает зарегистрированные слушатели.

Использование MVVM во Flutter

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

UI = fn(state)
Вход в полноэкранный режим Выход из полноэкранного режима

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

Состояние может содержаться в одном виджете, известном как локальное состояние. Flutter предоставляет встроенные классы и методы для работы с автономными состояниями, такие как StatefulWidget и setState.

Однако состояние, которое должно быть разделено между различными виджетами, известно как состояние приложения. Именно в этот момент мы представляем инструменты управления состоянием.

Для управления состоянием мы будем использовать Provider.

Допустим, вам нужно создать приложение, которое включает в себя только нижний экран. Как бы вы поступили?

Подсказка: использование MVVM

  • Каждый экран должен состоять из отдельной папки. Создайте папку home, которая содержит представление home_view.

Соглашение об именовании: Каждый экран называется view, а файл имеет суффикс _view Представление будет слушать изменения, происходящие в модели представления, используя Consumer.

  • Каждое представление должно иметь модель представления, связанную с ним. Создайте файл home_view_model, который будет отвечать за прием пользовательских взаимодействий, их обработку путем выполнения бизнес-логики и, наконец, за ответ.

Соглашение об именовании: Каждый экран имеет модель представления, связанную с ним, и файл имеет суффикс _view_model Модель представления уведомляет об изменениях в пользовательском интерфейсе (если таковые имеются) с помощью notifyListeners.

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

Это основы MVVM, как мы видим на скриншоте выше. Это можно повторить для всех экранов вашего приложения. Теперь давайте посмотрим на небольшое дополнение к этой структуре.

Расширение MVVM с помощью репозитория и сервисов

В реальном мире нашему приложению необходимо взаимодействовать с API или сторонними интегрированными сервисами. Поэтому здесь мы вводим нечто, называемое Repository .

Модель репозитория предоставляет абстракцию данных, чтобы ваше приложение могло работать с простой абстракцией, имеющей интерфейс. Использование этого паттерна может помочь достичь свободной связи. При правильной реализации паттерн Repository может стать отличным способом обеспечить соблюдение принципа единой ответственности для кода доступа к данным.

Некоторые преимущества использования паттерна «Репозиторий»:

  1. Разделение бизнес-логики для доступа к внешним сервисам.
  2. Упрощает мокинг и позволяет проводить модульные тесты.
  3. Мы можем легко переключать источники данных без трудоемких изменений кода.

Некоторые недостатки использования паттерна «Хранилище»:

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

Продолжая предыдущий пример, допустим, нашей кнопке нужно вызвать API, давайте реализуем это с помощью паттерна Repository.

В Dart нет интерфейсов, как в Java, но мы можем создать их с помощью абстрактного класса. Мы начнем с создания абстрактного класса, определяющего интерфейс для нашего home_repo.

abstract class HomeRepository {
  Future<CarouselModel> fetchData();
}
Вход в полноэкранный режим Выход из полноэкранного режима

Этот абстрактный класс помогает создать границу, и мы можем работать по обе стороны от этой границы. Мы можем работать над реализацией домашнего репозитория (рекомендуется), или мы можем просто использовать реализацию непосредственно в нашем приложении (не рекомендуется).

Здесь HomeRepository имеет только один метод, который называется fetchData, и этот метод возвращает ответ в виде модели CarouselModel

Далее, давайте реализуем HomeRepository

class HomeRepositoryImpl extends HomeRepository {
  @override
  Future<CarouselModel> fetchData() async {
    await Future.delayed(const Duration(milliseconds: 1800));
    final resp = await rootBundle.loadString('assets/data/first_screen.json');
    return carouselModelFromJson(resp);
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Внутри метода fetchData мы вводим задержку, а затем загружаем данные из активов, которые представляют собой JSON-файл. Эта задержка, по сути, заменяет вызов API, но я надеюсь, что мне удалось донести свои мысли до читателя.

По мере роста вашего приложения вы можете обнаружить, что добавляете все больше и больше методов к определенному хранилищу. В этом случае стоит подумать о создании нескольких репозиториев и хранении связанных методов вместе.

Итак, до сих пор мы

Зарегистрировать репозиторий

Поскольку наш репозиторий готов, теперь нам нужно понять, как зарегистрировать его и сделать доступным внутри нашего приложения. В это время мы вводим еще одну концепцию под названием DI aka Dependency Injection. Согласно документации, мы используем пакет get_it:

Это простой локатор сервисов для проектов Dart и Flutter с некоторыми дополнительными возможностями, вдохновленными Splat. Его можно использовать вместо InheritedWidget или Provider для доступа к объектам, например, из вашего пользовательского интерфейса.

GetIt работает очень быстро, потому что внутри него используется только Map<Type>, что делает доступ к нему O(1). Сам GetIt является синглтоном, поэтому вы можете обращаться к нему отовсюду, используя его свойство instance (см. ниже).

Мы устанавливаем get_it, включив его в pubspec.yaml как

dependencies:
  get_it: ^7.2.0
Вход в полноэкранный режим Выход из полноэкранного режима

Обычно в начале работы приложения вы регистрируете типы, к которым впоследствии хотите получить доступ из любой точки вашего приложения. После этого вы можете получить доступ к экземплярам зарегистрированных типов, снова вызвав locator.

Самое приятное, что вы можете зарегистрировать интерфейс или абстрактный класс вместе с конкретной реализацией. При обращении к экземпляру вы всегда будете запрашивать тип интерфейса/абстрактного класса. Это позволяет легко менять реализацию, просто переключая конкретный тип во время регистрации.

Мы создаем файл locator.dart, внутри которого инстанцируем объект get_it

final GetIt locator = GetIt.instance;
void setupLocator() {
  locator.registerFactory<HomeRepository>(() => HomeRepoImpl());
  // Alternatively you could write it
  GetIt.I.registerFactory<HomeRepository>(HomeRepoImpl());
}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Хотя GetIt является синглтоном, мы присвоим его экземпляр глобальной переменной locator, чтобы минимизировать код для доступа к GetIt. Любой вызов locator в любом пакете проекта будет получать один и тот же экземпляр GetIt.

Далее мы используем locator и с помощью registerFactory регистрируем наш HomeRepository

Провайдер как альтернатива GetIt

Провайдер является мощной альтернативой GetIt. Но есть некоторые причины, по которым люди используют GetIt для инъекции зависимостей:

  • Провайдеру необходим BuildContext для доступа к зарегистрированным объектам, поэтому вы не можете использовать его внутри бизнес-объектов вне дерева Widget или в чистом пакете dart.
  • Провайдер добавляет свои собственные классы Widget в дерево виджетов, которые не являются элементами GUI, но необходимы для доступа к зарегистрированным в провайдере объектам.

Репозиторий для тестирования

Вы можете реализовать модульное тестирование для различных элементов ваших приложений Flutter, таких как виджеты, контроллеры, модели, сервисы и репозитории. Провести модульное тестирование кодовой базы Flutter на основе репозиториев можно с помощью следующих стратегий:

  • Реализуйте имитацию класса репозитория и протестируйте логику.
  • Вам не нужно самостоятельно реализовывать имитационные классы — пакет Mockito поможет вам создать их быстро и автоматически.

Интегрируйте репозиторий в ViewModel

Теперь пришло время использовать инъекцию зависимостей. Но прежде давайте разберемся, что это такое.

Когда класс A использует некоторую функциональность класса B, то говорят, что класс A имеет зависимость от класса B.

Прежде чем мы сможем использовать методы других классов, нам сначала нужно создать объект этого класса (т.е. классу A нужно создать экземпляр класса B).

Поэтому передача задачи создания объекта кому-то другому и непосредственное использование зависимости называется инъекцией зависимости.

Преимущества использования DI

  1. Поддержка модульного тестирования.
  2. Сокращается количество кода, поскольку инициализация зависимостей выполняется другим компонентом (locator в нашем случае).
  3. Обеспечивает свободное соединение.

Недостатки использования DI

  1. Он сложен в освоении и при чрезмерном использовании может привести к проблемам управления и другим проблемам.
  2. Многие ошибки времени компиляции переносятся на время выполнения.

Возвращаясь к нашему приложению, давайте посмотрим, как мы интегрируемся.

class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
    required this.homeRepo,
  });
  final HomeRepository homeRepo;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы создаем конструктор внутри нашей HomeViewModel и указываем homeRepo в качестве требуемого параметра. Таким образом мы указываем, что тот, кому нужен доступ к нашей модели представления, сначала должен будет передать homeRepo

Инициализация локатора сервисов

Вам необходимо зарегистрировать службы при запуске приложения, поэтому вы можете сделать это в main.dart.

Замените стандартный

void main() => runApp(MyApp());
Вход в полноэкранный режим Выйти из полноэкранного режима

следующим:

import 'locator.dart';

void main() {
  // INIT SERVICE LOCATOR  
  setupLocator();

  runApp(MyApp());
}
Enter fullscreen mode Выйти из полноэкранного режима

Это зарегистрирует все имеющиеся у вас сервисы в GetIt до того, как будет построено дерево виджетов.

Если мы помним, наш homeRepo был зарегистрирован внутри locator Итак, чтобы объявить нашу модель представления, мы выполним следующие действия

Future<void> main() async {

  setupLocator();
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(
          create: (_) => HomeViewModel(repo: locator<HomeRepo>()),
        ),
      ],
      child: MainApp(),
    ),
  );
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Внутри нашего main мы вызываем setupLocator, который является методом, включающим все зарегистрированные зависимости в locator.dart

Далее, внутри нашего MultiProvider, мы указываем HomeViewModel под ChangeNotifierProvider

ChangeNotifierProvider создает ChangeNotifier с помощью create и автоматически утилизирует его, когда он удаляется из дерева виджетов.

Использование ViewModel внутри представления

Мы зарегистрировали наше хранилище и передали его в качестве необходимого параметра нашей модели представления. Давайте посмотрим, как использовать модель представления внутри нашего представления.

Существует два способа доступа к модели представления внутри представления

  1. Используя виджет Consumer<T>.
  2. Используя Provider.of<T>(context).
late HomeViewModel viewModel;
@override
void initState() {
  viewModel = Provider.of<HomeViewModel>(context, listen: false);
  WidgetsBinding.instance.addPostFrameCallback((_) {
    viewModel.fetchData();
  });
  super.initState();
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы инстанцируем viewModel с помощью Provider.of внутри home_view

Provider.of<T>(context) используется, когда вам нужен доступ к зависимости, но вы не хотите вносить никаких изменений в пользовательский интерфейс. Мы просто устанавливаем listen: false, означающий, что нам не нужно слушать обновления от ChangeNotifier. Параметр listen: false используется для указания всякий раз, когда вы используете Provider для получения экземпляра и вызова метода на этом экземпляре. 

Примечание: Мы также можем использовать следующие параметры

viewModel = context.read<HomeViewModel>();
Войти в полноэкранный режим Выйти из полноэкранного режима

Для реагирования на изменения, происходящие с viewModel, мы используем Consumer, когда хотим перестроить виджеты при изменении значения. Обязательно нужно указать тип, чтобы Провайдер мог понять, на какую зависимость вы ссылаетесь.

Consumer<HomeViewModel>(
    builder: (_, model, child) {
     // YOUR WIDGET                     
    },
    child: // SOME EXPENSIVE WIDGET
)
Вход в полноэкранный режим Выход из полноэкранного режима

Виджет Consumer не выполняет никакой сложной работы. Он просто вызывает Provider.of в новом виджете и делегирует свою реализацию build строителю.

Виджет Consumer принимает два параметра, параметр builder и параметр child (необязательный). Параметр child — это дорогой виджет, на который не влияют никакие изменения в ChangeNotifier.

Этот конструктор может быть вызван несколько раз (например, при изменении предоставленного значения), и именно здесь мы можем перестроить наш пользовательский интерфейс. Виджет Consumer имеет два основных назначения:

  • Он позволяет получить значение от провайдера, когда у нас нет BuildContext, который является потомком этого провайдера, и поэтому мы не можем использовать Provider. of.
  • Он помогает оптимизировать производительность, обеспечивая более детальные перестроения.

Юнит-тесты для модели представления (необязательно)

Вы можете подражать зависимостям, создавая альтернативную реализацию класса с помощью пакета Mockito.

Что такое сервисы

Сервисы — это обычные классы Dart, которые написаны для выполнения какой-либо специализированной задачи в вашем приложении. Цель сервиса — изолировать задачу, особенно сторонние пакеты, которые являются изменчивыми, и скрыть детали ее реализации от остальной части приложения.

Некоторые общие примеры, для которых можно создать службу:

  • Использование стороннего пакета, например, чтение и запись в локальное хранилище (общие предпочтения).
  • использование облачных провайдеров, таких как Firebase или другой пакет стороннего производителя.

Допустим, вы используете package_info для получения сведений о пакете вашего приложения.

Вы используете пакет непосредственно внутри приложения и через некоторое время находите еще более подходящий пакет. Вы просматриваете и заменяете все ссылки package_info на новый пакет some_great_package. Это, несомненно, пустая трата времени и усилий.

Допустим, владельцы продукта обнаружили, что ни один пользователь не использует эту функцию, вместо этого они запрашивают новую функцию. Вы проходите и удаляете все ссылки с some_great_package Это снова была пустая трата времени и сил.

Дело в том, что когда у вас есть тесная связь с какой-то функцией, разбросанной по всему вашему коду, это затрудняет внесение изменений и приводит к ошибкам.

Чистое кодирование требует предварительного времени и усилий, но в долгосрочной перспективе сэкономит вам больше времени и усилий.

Именно здесь на помощь приходят сервисы. Вы создаете новый класс и называете его как-то вроде PackageInfoService. Остальные классы в приложении не знают, как это работает внутри. Они просто вызывают методы сервиса, чтобы получить результат.

abstract class PackageInfoService {
  Future<String> appVersion();
}
class PackageInfoServiceImpl implements PackageInfoService {
  @override
  Future<String> appVersion() async {
    final packageInfo = await PackageInfo.fromPlatform();
    String version = packageInfo.version;
    String build = packageInfo.buildNumber;
    return 'v$version ($build)';
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима
  • Это упрощает процесс изменения. Если вы хотите поменять package_info на some_great_package, просто измените код внутри класса сервиса. Обновление кода сервиса автоматически влияет на все случаи использования сервиса в приложении.
  • Поддерживается замена реализаций. Вы можете создать «фальшивую» реализацию, которая просто возвращает жестко закодированные данные, пока другая команда дорабатывает/разрабатывает реализацию сервиса.
  • Иногда реализация может полагаться на другие сервисы. Например, вы xyzService можете использовать сервис для выполнения сетевого вызова для получения других типов данных.

Зарегистрируйте свой сервис

Использование локатора сервисов, такого как GetIt, является удобным способом предоставления сервисов в вашем приложении.

  • Мы используем локатор для регистрации нашего PackageInfoService.
  • Мы будем регистрировать PackageInfoService как ленивый синглтон. Он инициализируется только при первом использовании. Если вы хотите, чтобы он инициализировался при запуске приложения, то используйте registerSingleton() вместо этого. Поскольку это синглтон, у вас всегда будет один и тот же экземпляр вашей службы.
void setupLocator() {
locator.registerLazySingleton<PackageInfoService>(() =>
PackageInfoServiceImpl());
}
Вход в полноэкранный режим Выход из полноэкранного режима

Использование службы

Поскольку мы зарегистрировали сервис с помощью GetIt, мы можем получить ссылку на сервис из любой точки кода

class MyClass {
  PackageInfoService packageService = locator<PackageInfoService>();
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем вы можете использовать его внутри этого класса, как показано здесь:

  • packageService.getSomeValue()

Модульные тесты для сервиса (необязательно)

Вы можете высмеять зависимости, создав альтернативную реализацию класса службы с помощью пакета Mockito.

Краткое описание:

  • Хранилище repository предназначено для доступа к объектам подобно коллекции.
  • Сервис service — это класс с методами для выполнения бизнес-логики, который может координировать различные другие сервисы (например, несколько хранилищ) для выполнения одного действия или получения одного результата.

Исходный код.

Если это помогло 🙂

Оцените статью
devanswers.ru
Добавить комментарий