Pull to refresh

ViewModel во Flutter – это антипаттерн

Reading time11 min
Views12K

Всем привет! Представляю вам текстовую версию моего доклада на DartUP 2021 (сам доклад на английском можно найти здесь). Посвящен он довольно популярному архитектурному паттерну MVVM (он же Model-View-ViewModel), а конкретно той его части, что про ViewModel.

Паттерн этот весьма распространен в мире нативной Android-разработки – во многом благодаря тому, что является официальной рекомендацией Google. А поскольку многие Flutter-девелоперы попали в мир Flutter'а из нативного Android'а, то и подходы они склонны применять те же самые. Как говорится, работает – не трогай и ничего не меняй.

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

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

История MVVM

В начале, как водится, было слово. Сказано оно было Microsoft'ом в 2005 году, чтобы описать паттерн для отделения GUI от бизнес-логики.

Паттерн, как следует из этого слова, состоит из трех компонентов:

  • Model – то, что обычно относится к доменной модели. Про нее мы в рамках этой статьи говорить не будем.

  • View – структура и внешний вид того, что пользователь видит на экране. Ничем, по сути, не отличается от View из MVC или MVP.

  • ViewModel – главный герой этой статьи, абстракция над View и состояние данных из модели.

При этом View и ViewModel формируют презентационный слой, а Model – это уже слой бизнес-логики.

Основной идеей MVVM было буквально убрать весь GUI код из слоя View. Тогда UX-девелоперы смогли бы радостно вооружиться языком разметки и соорудить интерфейс мечты, оставив скучную реализацию логики другим разработчикам.

ViewModel и Android

Прошло 12 лет. На Google I/O 2017 команда разработчиков из Android Framework представила набор Architecture Components. Одним из этих компонентов, собственно, и была ViewModel. Решала она, по крайней мере отчасти, сугубо специфичные проблемы Android'а.

Дело в том, что Android сам управляет жизненным циклом UI-контроллеров, таких как Activity или Fragment. Фреймворк сам решает, когда надо уничтожить, а когда воссоздать UI-контроллер – в зависимости от определенных действий пользователя или в ответ на различные события устройства. Вы, как разработчик, никак повлиять на это не можете. Означает это, что данные в Activity или Fragment'е могут исчезнуть самым непредсказуемым образом и в самый неподходящий момент. Еще это означает, что за всякими фоновыми операциями тоже надо внимательно следить – вовремя освобождать ресурсы и создавать их заново, думать как избежать повторных запросов.

И тут на сцену выходит ViewModel, которая привязана к более лучшему жизненному циклу. Она остается в памяти, переживая взлеты и падения (т.е. уничтожения и создания) Activity, пока последняя окончательно не сгинет:

"Преимущества" MVVM

Почему в кавычках? Потому что никакие это не преимущества, во всяком случае, в мире Flutter'а.

Котлеты (верстка) – отдельно, мухи (код) – отдельно

Выше я уже говорил, что одной из основных задач изначального MVVM'а было убрать GUI-код из слоя View, и позволить писателям этих ваших интерфейсов использовать язык разметки.

Но во Flutter'е нет языка разметки (к счастью). У нас тут верстка – это и есть код! Берешь много-много виджетов и строишь себе UI, используя нормальную человеческую композицию. И уж точно тут нет каких-то отдельных верстальщиков.

Вот посмотрите на самый что ни на есть настоящий production-код. Просто описываем UI (причем исключительно в методе build) и UI-логику.

class FailureScreen extends StatefulWidget {
  const FailureScreen({Key? key, required this.reason}) : super(key: key);

  final CheckOutFailureReason reason;

  @override
  _FailureScreenState createState() => _FailureScreenState();
}

class _FailureScreenState extends State<FailureScreen> {
  @override
  void initState() {
    super.initState();
    context.read<AnalyticsManager>().sendEvent(AnalyticsEvent.checkOutFailed(widget.reason));
  }

  void _onOkPressed() => context.read<SessionBloc>().add(const SessionEvent.initialized());

  @override
  Widget build(BuildContext context) => StepScreenWrapper(
        child: OptimusStack(
          mainAxisAlignment: OptimusStackAlignment.center,
          spacing: OptimusStackSpacing.spacing300,
          children: [
            const OptimusSectionTitle(child: TranslatedText(TranslationKey.PleaseGoToReception)),
            SizedBox(
              width: 500,
              child: OptimusParagraph(child: TranslatedText(widget.reason.message, textAlign: TextAlign.center)),
            ),
            OptimusButton(
              variant: OptimusButtonVariant.primary,
              onPressed: _onOkPressed,
              child: const TranslatedText(TranslationKey.OkGotIt),
            ),
          ],
        ),
      );
}

UI-тесты

В мире нативного Android'а UI-тесты – это те самые благие намерения, которыми известно что и куда выложено.

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

Во-вторых, эти тесты намного медленнее, так что гонять сотни UI-тестов на каждый PR – это то еще удовольствие.

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

Есть, конечно, способы с этими проблемами бороться. Например, есть Firebase App Lab для более простого запуска тестов на эмуляторах и устройствах. Есть фреймворки типа Robolectric, которые позволяют запускать UI-тесты напрямую на JVM, без эмулятора. Но там свои ограничения и проблемы.

В этом случае вытащить UI-логику в отдельный класс действительно может быть хорошей идеей – тогда для этой логики можно написать unit-тесты и не заморачиваться со всей этой сложной инфрастуктурой.

Но в случае с Flutter'ом, UI-тесты запускать легко и приятно, как сказал бы Иешуа:

void main() {
  testWidgets('MyWidget has a title and message', (WidgetTester tester) async {
    await tester.pumpWidget(const MyWidget(title: 'T', message: 'M'));
    final titleFinder = find.text('T');
    final messageFinder = find.text('M');

    expect(titleFinder, findsOneWidget);
    expect(messageFinder, findsOneWidget);
  });
}

Нормальный жизненный цикл

Я уже упоминал, что в Android'е жизненный цикл условного "экрана" довольно запутанный – например, система может пересоздать всю Activity только потому, что пользователь повернул телефон; все данные внутри Activity при этом пропадут. Тут ViewModel, которая привязана к "смысловому" жизненному циклу – это, простите за каламбур, жизненная необходимость.

Во Flutter'е у виджетов нормальный жизненный цикл из коробки. Посмотрите на эту диаграмму (она слегка упрощенная, но смысл передан) – никаких onStart, onPause, onResume и прочих странных колбэков с неконсистентным поведением, всё просто и понятно:

Причем для ViewModel нам бы всё равно понадобился какой-то минимальный набор колбэков – для инициализации, обновления и уничтожения экрана.

Single Responsibility Principle

Я часто слышу от разработчиков, мол, наличие UI-логики в виджете нарушает SRP (он же Single Responsibility Principle, он же принцип единой ответственности). "Класс должен делать одну вещь", – говорят мне в таких случаях, – "UI-логика и верстка в одном классе – это нарушение принципа".

На самом деле, это неправильная интерпретация принципа. Вот что сам Боб Мартин говорит по этому поводу в книге "Чистая архитектура":

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

Не поймите меня неправильно, такой принцип тоже есть. Функция должна делать одну, и только одну, вещь. Мы руководствуемся этим принципом на самом нижнем уровне, когда разбиваем большие функции на маленькие. Но это не один из принципов SOLID, это не SRP.

И дальше он предлагает улучшенную формулировку этого принципа:

Модуль должен отвечать перед одним, и только одним, актором.

Но в большинстве случаев кусок UI и так отвечает перед одним актором – это UI/UX дизайнер. SRP не имеет никакого отношения к отделению UI-логики от UI-элементов.

Переиспользование ViewModel

Говоря о переиспользовании, обычно подразумевают два сценария.

Первый – это когда вы хотите сделать интерфейс, который совершенно не зависит от фреймворка. Например, кросс-платформенное приложение может иметь общую бизнес-логику и общие ViewModel'и, а реальные компоненты интерфейса для каждой платформы (Android и iOS) будут свои. Такой подход, по крайней мере в теории, мог бы иметь смысл для KMM (Kotlin Multiplatform Mobile) – там интерфейс действительно пишется отдельно под каждую платформу. В этом случае можно выделить ViewModel как абстракцию над слоем View, где задать всю логику, а непосредственную реализацию компонентов сделать специфичной для каждой платформы.

Но во Flutter'е смысла в этом нет вообще – UI код здесь общий для всех платформ, как и логика.

Второй сценарий – это использование одной ViewModel для разных View:

На моей практике это довольно редкая ситуация – обычно либо переиспользуешь весь компонент (т.е. связку View + ViewModel), либо дробишь ViewModel на несколько независимых. Но если вдруг надо переиспользовать какие-то данные или логику, то это можно прекрасно сделать и без паттерна ViewModel – мы поговорим от этом чуть позже.

Недостатки MVVM

А вот недостатки, в отличие от "достоинств", вполне реальны и ощутимы.

Сильное зацепление (tight coupling)

Есть смысл разделять один модуль на несколько под-модулей в том случае, когда у них разная зона ответственности. В идеале, изменения в одном модуле никак не должны влиять на другой модуль.

На практике, однако, View и ViewModel сильно cцеплены (coupled), а изменения в требованиях к UI приводят к изменениям в обоих классах.

Представьте, что у нас есть экран профиля с кнопкой Log out. В первой итерации всё просто: нажимаем на кнопку – заканчивается сессия. В следующем спринте к вам подходит дизайнер и говорит: "Слушай, надо добавить диалог с подтверждением". Изменения придется вносить как во ViewModel (изменяется логика работы), так и во View (он теперь должен реагировать на запрос диалога).

Сложности с тестированием

Если у нас View и ViewModel – это разные классы, нам понадобится по крайней мере два типа тестов:

  1. Для ViewModel – убедиться, что она дергает правильные методы бизнес-логики и правильно преобразует данные для презентации.

  2. Для View – проверить правильность отображения данных презентационной модели. А выше мы уже обсуждали, что в нативном Андроиде UI-тесты – это очень больно. Так что, скорее всего, это часть кода будет не очень хорошо покрыта тестами.

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

Вы же помните правило для написания хороших комментариев? Не комментируйте, как работает код, пишите, почему это так сделано. То же и с тестами: не надо тестировать, как работает система, проверяйте, выдает ли она ожидаемый результат.

В случае с диалогом подтверждения – мне не важно, вызывает ли нажатие на кнопку метод onLogoutButtonClicked. Мне нужно знать, что если я нажму на кнопку, появится диалог.

Повторюсь, в нативном Андроиде с этим всё сложно, во Flutter'е – гораздо проще.

Больше кода

Естественно, больше сущностей – больше кода (как правило). Само по себе это не страшно, многие хорошие практики вообще-то увеличивают количество кода. Но это имеет смысл лишь в том случае, когда большее количество кода компенсируется лучшей читаемостью или поддерживаемостью. Иначе это просто больше кода.

Что делать?

Итак, мы разобрались с тем, что преимущества не очень-то значимые, а недостатки вполне ощутимые. А делать-то с этим что?

Keep It Simple And Straightforward

Прежде всего, следовать самому, на мой взгляд, важному принципу в разработке (а то и в жизни). Существуют разные вариации этой фразы, я предпочитаю говорить: "Keep it simple and straightforward" – "Делай просто и прямолинейно". Или же, следуя бритве Оккама: "Не следует множить сущее без необходимости".

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

Какие же это инструменты?

Всё является виджетом

Вы наверняка много раз слышали эту фразу: "Во Flutter'е всё является виджетом". Не надо, конечно, воспринимать эту фразу слишком буквально – помимо виджетов во Flutter'е есть на что посмотреть. Но доля правды тут определенно имеется.

Давайте условно сгруппируем виджеты на 3 категории. Я не предлагаю превращать эти категории в базовые классы, или вообще как-то применять названия этих групп в коде – они нужны только для описания ролей, которые могут играть виджеты.

Чистый виджет

По аналогии с чистой функцией из функционального программирования, это виджет, который зависит только от входящих данных. Обычно строится на базе StatelessWidget, но это может быть и StatefulWidget – например чекбокс со своим внутренним состоянием.

Ключевой момент здесь – не наличие или отсутствие внутреннего состояния, а отсутствие любых зависимостей от бизнес-логики.

Эти виджеты – как маленькие переиспользуемые компоненты, они же являются первыми кандидатами на золотые тесты UI. Поскольку они никак не зависят от бизнес-логики, в них, в первую очередь, нас интересует то, как они выглядят в зависимости от разных параметров.

Как понять, что виджет относится к этой категории? Ориентируйтесь на название – оно не должно быть связано с доменом вашего приложения – например, Checkbox, Button, RoundAvatar – т.е. компоненты, которые могут встретиться в любом интерфейсе.

Виджет с данными

Полная противоположность предыдущему. Он вообще не использует видимых компонентов – он управляет данными.

Этот виджет ближе всех к концепту ViewModel из MVVM, поскольку он взаимодействует с интеракторами из слоя бизнес-логики и репозиториями, передает данные вниз по дереву виджетов.

Обычно такой виджет представляет из себя Builder или StatefulBuilder – если нужно передать данные непосредственно вложенному виджету. Если данные надо пробросить куда-то глубоко, то удобно использовать InheritedWidget или Provider.

Главное отличие от ViewModel (кроме того, что технически это всё еще виджет) в том, что ради одного виджета нет смысла вводить разделение на ViewWidget и ViewModelWidget. Польза от него начинается, когда данные нужно расшарить между несколькими виджетами.

Виджет-компонент

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

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

BusinessLogic-Widget

С учетом всего вышесказанного я предлагаю более простой паттерн. Можем назвать его BusinessLogic-Widget.

BusinessLogic – это слой, который отвечает за логику приложения; логику, которая не зависит от UI, ни в коде, ни по смыслу. Это означает, что такие вещи, как роутинг или диалоги подтверждения, не принадлежат этому слою. Как я обычно говорю, лучший способ определить слой для куска логики – это представить, что надо заменить GUI на CLI: если код после этого теряет смысл, то скорее всего он и не должен являться частью бизнес-логики.

Конкретная реализация этого слоя не важна – это может быть BLoC, Redux, просто Use Case Interactor из чистой архитектуры и т.д. Важно то, что этот слой занимается обработкой и предоставлением данных, не привязанных к UI.

Слой Widget отвечает за получение этих данных и представление их пользователю. Мобильные приложения в целом чаще всего отталкиваются от концепции пользователя, или же от UI, так что этот слой лучше знает, какая нужна область видимости у данных (обычно это некое под-дерево), и когда надо вызвать метод из бизнес-логики. Поскольку у виджетов в любом случае есть доступ к контексту, многие часто задаваемые вопросы просто не имеют смысла, например: "Как осуществлять навигацию без доступа к BuildContext?" Да никак – это ответственность UI, а там есть доступ к BuildContext.


Давайте рассмотрим один экран из нашего приложения:

Не обращайте внимания на верхнюю панель, нас интересует только основное содержимое экрана (то, что на сером фоне). Можно выделить несколько компонентов: текст "Добро пожаловать..." с названием отеля, кнопки Check in / Check out и номер версии в правом нижнем углу.

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

  • Текст сообщения "Добро пожаловать..." содержит название отеля, которое берется из модели Session (часть бизнес-логики).

  • Кнопки Check In и Check Out получают колбэки, осуществляющие переход на другие экраны; кнопка Check Out при этом еще отправляет событие в аналитику – AnalyticsManager является частью бизнес-логики.

  • Текст версии состоит из, собственно, номера версии и номера билда, эти данные берутся из PackageInfo.

  • Сам экран через BlocListener подключается к UpdateManager'у и переходит на другой экран, когда получает уведомление о наличии новой версии.

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

В этом случае мы получим такую структуру:

  • Сам экран больше не содержит логики вообще. Он просто состоит из нескольких "умных" виджетов.

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

  • Другие виджеты знают, какие данные им нужны, как их получить и как обработать.

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

Flutter позволяет легко и эффективно управляться с огромным количеством виджетов. Иногда много маленьких виджетов – это даже лучше с точки зрения производительности, например, когда можно часть под-дерева завернуть в виджет-константу.

Подводя итог: не используйте во Flutter'е ViewModel, не множьте сущности без необходимости и пользуйтесь встроенными средствами.

Tags:
Hubs:
Total votes 24: ↑14 and ↓10+4
Comments74

Articles