Перейти к основному содержимому

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

· 14 мин. чтения

Всем привет! Представляю вам текстовую версию моего доклада на 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;


_FailureScreenState createState() => _FailureScreenState();
}

class _FailureScreenState extends State<FailureScreen> {

void initState() {
super.initState();
context.read<AnalyticsManager>().sendEvent(AnalyticsEvent.checkOutFailed(widget.reason));
}

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


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, не множьте сущности без необходимости и пользуйтесь встроенными средствами.