- PVSM.RU - https://www.pvsm.ru -

Архитектура MVVM в мобильных приложениях на Flutter

Я начала изучать Flutter и недавно потратила целый день пытаясь внедрить архитектуру Model-View-ViewModel в свое приложение на Flutter. Обычно я пишу под Андроид на Java, MVVM реализую с помощью AndroidViewModel и LiveData/MutableLiveData. То есть опыт программирования и применения паттерна есть, приложение — простой таймер. Так что ничто не предвещало столь больших временных затрат на простую задачу.

Поиски статей и инструкций по MVVM во Flutter (без использования RxDart) дали один пример [1] причем без ссылки на полный исходник, так что хочу немного облегчить для интересующихся изучение этого паттерна во Flutter.

Проект

Проект без MVVM [2] представляет собой один экран с таймером обратного отсчета. По нажатию на кнопку таймер запускается или ставится на паузу в зависимости от состояния. Когда время заканчивается, выдается уведомление или проигрывается звук.

Описание модели

Приступим к внедрению MVVM, сначала я описала интерфейс, который мне потребуется для взаимодействия между виджетом и моделью (создан файл timer_view_model.dart):

abstract class TimerViewModel {
  Stream<bool> get timerIsActive;
  Stream<String> get timeTillEndReadable;
  Stream<bool> get timeIsOver;
   void changeTimerState();
}

То есть я хочу получать события изменения состояния кнопки (остановить таймер – продолжить), знать, когда таймер закончился, получать время, которое надо отобразить на экране. Еще хочу останавливать/запускать таймер. Строго говоря, описание этого интерфейса необязательно, здесь я просто хочу показать, что требуется от модели.

Реализация ViewModel

Далее реализация модели – файл timer_view_model_impl.dart

Таймер работает по факту как StreamController с одним подписчиком. Основа для кода взята из вот этой статьи [3]. Там как раз есть описание контроллера, который работает по таймеру и его можно приостанавливать и запускать снова. В общем почти идеальное совпадение. Измененный под мою задачу код:

static Stream<DateTime> timedCounter(Duration interval, Duration maxCount) {
    StreamController<DateTime> controller;
    Timer timer;
    DateTime counter = new DateTime.fromMicrosecondsSinceEpoch(maxCount.inMicroseconds);
     void tick(_) {
      counter = counter.subtract(oneSec);
      controller.add(counter); // Ask stream to send counter values as event.
      if (counter.millisecondsSinceEpoch == 0) {
        timer.cancel();
        controller.close(); // Ask stream to shut down and tell listeners.
      }
    }
     void startTimer() {
      timer = Timer.periodic(interval, tick);
    }
     void stopTimer() {
      if (timer != null) {
        timer.cancel();
        timer = null;
      }
    }
     controller = StreamController<DateTime>(
        onListen: startTimer,
        onPause: stopTimer,
        onResume: startTimer,
        onCancel: stopTimer);
     return controller.stream;
  }

Теперь как работает запуск и остановка таймера через модель:

@override
  void changeTimerState() {
    if (_timeSubscription == null) {
      print("subscribe");
      _timer = timedCounter(oneSec, pomodoroSize);
      _timerIsEnded.add(false);
      _timerStateActive.add(true);
      _timeSubscription = _timer.listen(_onTimeChange);
      _timeSubscription.onDone(_handleTimerEnd);
    } else {
      if (_timeSubscription.isPaused) {
        _timeSubscription.resume();
        _timerStateActive.add(true);
      } else {
        _timeSubscription.pause();
        _timerStateActive.add(false);
      }
    }
  }

Чтобы таймер начал работать, надо на него подписаться _timeSubscription = _timer.listen(_onTimeChange);. Остановить/продолжить реализуются через pause/resume подписки (_timeSubscription.pause();/_timeSubscription.resume();). Здесь же идет запись в поток состояния активности таймера _timerStateActive и поток информации о том, был включен таймер или нет _timerIsEnded.

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

TimerViewModelImpl() {
    _timerStateActive = new StreamController();
    _timerStateActive.add(false);
    _timerIsEnded = new StreamController();
    _timeFormatted = new StreamController();
    DateTime pomodoroTime = new DateTime.fromMicrosecondsSinceEpoch(pomodoroSize.inMicroseconds);
    _timeFormatted.add(DateFormat.ms().format(pomodoroTime));
  }

Получение потоков, как описано в интерфейсе:

@override
  Stream<bool> get timeIsOver => _timerIsEnded.stream;

@override
  Stream<bool> get timerIsActive {
    return _timerStateActive.stream;
  }

@override
  Stream<String> get timeTillEndReadable => _timeFormatted.stream;

То есть, чтобы что-то написать в поток, нужен контроллер. Просто так взять и положить что-либо туда нельзя (исключение — когда поток генерируется в одной функции). А уже виджет забирает готовые потоки, управляют которыми контроллеры модели.

Виджет и состояние

Теперь к виджету. ViewModel инициализируется в конструкторе состояния

_MyHomePageState() {
    viewModel = new TimerViewModelImpl();
  }

Затем в инициализации добавляются слушатели для потоков:

    viewModel.timerIsActive.listen(_setIconForButton);
    viewModel.timeIsOver.listen(informTimerFinished);
    viewModel.timeTillEndReadable.listen(secondChanger);

Слушатели – это почти те же самые функции, которые были до этого, добавилась только проверка на null и немного поменялась _setIconForButton:

Icon iconTimerStart = new Icon(iconStart);
Icon iconTimerPause = new Icon(iconCancel);

void _setIconForButton(bool started) {
    if (started != null) {
      setState(() {
        if (started) {
          iconTimer = iconTimerPause;
        } else {
          iconTimer = iconTimerStart;
        }
      });
    }
  }

Остальные изменения в main.dart – это удаление всей логики таймера – теперь она живет во ViewModel.

Заключение

Мой вариант реализации MVVM не использует дополнительных виджетов (таких как StreamBuilder), состав виджетов остался тем же. Ситуация аналогична тому, как в Android используется ViewModel и LiveData. То есть модель инициализируется, затем добавляются слушатели, которые уже реагируют на изменения модели.

Репозиторий проекта со всеми изменениями [4]

Автор: Yahhi

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/dart/296682

Ссылки в тексте:

[1] один пример: https://quickbirdstudios.com/blog/mvvm-in-flutter/

[2] Проект без MVVM: https://github.com/Yahhi/pomodoro_on_flutter/tree/2c3c123cec67b3ac4445740fafcedac2ebe49f1e

[3] вот этой статьи: https://www.dartlang.org/articles/libraries/creating-streams#honoring-the-pause-state

[4] Репозиторий проекта со всеми изменениями: https://github.com/Yahhi/pomodoro_on_flutter/tree/eaca1c9bad21f1e674584a8df57d21f89777eb06

[5] Источник: https://habr.com/post/427327/?utm_campaign=427327