Всем привет! Представляю вам текстовую версию моего доклада на 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 – это разные классы, нам понадобится по крайней мере два типа тестов:
- Для ViewModel – убедиться, что она дергает правильные методы бизнес-логики и правильно преобразует данные для презентации.
- Для 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, не множьте сущности без необходимости и пользуйтесь встроенными средствами.