Я начала изучать Flutter и недавно потратила целый день пытаясь внедрить архитектуру Model-View-ViewModel в свое приложение на Flutter. Обычно я пишу под Андроид на Java, MVVM реализую с помощью AndroidViewModel и LiveData/MutableLiveData. То есть опыт программирования и применения паттерна есть, приложение — простой таймер. Так что ничто не предвещало столь больших временных затрат на простую задачу.
Поиски статей и инструкций по MVVM во Flutter (без использования RxDart) дали один пример причем без ссылки на полный исходник, так что хочу немного облегчить для интересующихся изучение этого паттерна во Flutter.
Проект
Проект без MVVM представляет собой один экран с таймером обратного отсчета. По нажатию на кнопку таймер запускается или ставится на паузу в зависимости от состояния. Когда время заканчивается, выдается уведомление или проигрывается звук.
Описание модели
Приступим к внедрению 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 с одним подписчиком. Основа для кода взята из вот этой статьи. Там как раз есть описание контроллера, который работает по таймеру и его можно приостанавливать и запускать снова. В общем почти идеальное совпадение. Измененный под мою задачу код:
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. То есть модель инициализируется, затем добавляются слушатели, которые уже реагируют на изменения модели.