Model-Widget-WidgetModel, или какой архитектурой пользуется Flutter-команда в Surf

от автора

Привет, меня зовут Артём. Я руководитель Flutter-разработки в Surf и со-ведущий FlutterDev подкаста.

Flutter-отделу в Surf уже больше года. За это время мы сделали несколько проектов: от маленьких служебных, до полноценных е-коммерс и банкинга. Как минимум, многие из вас уже могли видеть приложение аптеки «Ригла». В статье я расскажу про недавно вышедший пакет mwwm — архитектуру, на которой построены все наши проекты.

Что такое MWWM?

MWWM — это архитектура и реализация паттерна Model-View-ViewModel, которую мы в Surf переложили на Flutter. Мы заменили слово View на Widget, потому что View не очень часто используется во Flutter и так будет нагляднее для разработчиков. Главное, что она позволяет делать — разделять вёрстку и логику, как бизнесовую, так и презентационного слоя.

Немного истории

Почему именно MWWM мы используем в Surf? Давайте вернёмся к началу 2019 года, когда у нас зародился Flutter отдел. Что было на тот момент?

Flutter зарелизился буквально месяц назад: хайп начал подниматься, активных игроков на рынке и в опенсорсе пока нет. Отличное время чтобы залететь, занять неплохое место в индустрии и завоевать внимание, не правда ли? Фреймворк молод, коммьюнити развивается и это открывает все дороги.

В это время от Android-отдела Surf отделяется Flutter направление. И сразу же стоит задача сначала заложить основу для наших будущих приложений, а потом уже кодить. Конечно же, одним из основных моментов здесь являлся выбор архитектуры.

В начале 2019 года нет явно выделяющегося мнения в комьюнити какую архитектуру использовать (хотя и сейчас ведутся активные холивары). Да, есть основные концепции: BLoC, Redux, Vanilla, MobX и тд. Самыми массовыми являются BLoC и Redux. Замечу, что речь ведётся не о пакетах, а о концепции.
Итак, встал вопрос BLoC или Redux нам взять? Или же придумать нечто своё?

Почему мы не BLoC?

Business Logic Component — отличная концепция. Блоки кода, по факту, «чёрные ящики» с некоторым входным воздействием и выходным потоком, внутри которых крутится бизнес-логика на чистом Dart — это просто потрясающе. Чистый Dart для кросс-платформенного переиспользования с вебом (да, тогда было далеко до Flutter for Web и сайты писали на Angular Dart, при необходимости). Удобство использования и достаточная изоляция самих блоков. Круто одним словом. Но есть одно «но»: где писать логику презентационного слоя? Где писать навигацию? Как работать с чисто UI событиями?

Чуть позже вышел в релиз всем известный Bloc от Felix Angelov. А также flutter_bloc. Многие стали рассматривать блоки как логику презентационного слоя, но это уже не вязалось с аббревиатурой. Сама же библиотека не давала указаний, где писать подобную логику, например, валидировать поля ввода. Это было неприемлемо для выбора хорошей архитектуры.

Redux?

Как я написал выше — мы выходцы из Android разработки. В то время в мире царили Clean Architecture, подходил MVVM. Веб-технологии были чем-то чуждым и непонятным. Redux мы быстро отмели: на него надо было переучиваться довольно долго и отчасти менять мышление, к которому мы привыкли во время Android-разработки с Rx и CleanArchitecture.

Отбросив основные концепции и принимая во внимание реалии Surf, мы поставили задачу создать архитектуру, которая позволит Android-разработчикам из Surf при необходимости быстро конвертироваться во Flutter-разработчиков. И чтобы при необходимости Flutter-разработчики могли быстро перейти на Android, потому что архитектура понятна, знакома, а язык выучить несложно. Так и появился Model – Widget – WidgetModel.

Model-Widget-WidgetModel

Выглядит он примерно вот так.

Эту картинку можно видеть на странице в GitHub. Здесь есть несколько основных частей.

  • Widget-UI — та самая вёрстка.
  • WidgetModel — логика этого самого UI и его состояния.
  • Model — контракт с сервисным слоем. На данный момент это экспериментальная часть. Исторически у нас использовались Interactor’ы напрямую в WM.

Пройдёмся по каждой из этих частей поподробнее.

Widget в нашем контексте — полностью пассивная вёрстка. Максимум что допускается в ней – наличие условного оператора, когда мы пишем там if (условие), покажи мне это, иначе – покажи мне loader. А всё остальное: вычисления этих условий, обработка нажатий и так далее — уходят в WidgetModel. Причём виджетом может быть как целый экран, так и конкретный маленький элемент на экране со своим состоянием.

Сразу замечу, кроме MwwmWidget (буду называть их так, чтобы не путаться) мы также используем обычные Flutter-виджеты. Потому что куда без них? Есть места, где нет необходимости усложнять простенький переключатель ради архитектуры.

WidgetModel – это, по сути, состояние виджета и описание логики его работы. Что входит в логику? Это обработка тех или иных действий пользовательского интерфейса. Это обращение к любым другим слоям, которые стоят выше. Это вычисление тех или иных значений, обработка, mapping данных, необходимых для вёрстки. В общем-то, все то, чем должна заниматься стандартная ViewModel.

Рассмотрим на небольшом примере.

Допустим, у нас есть некоторый экран и у него есть некоторое состояние и кнопка, которая входит в сеть. Widget экрана будет содержать только вёрстку. При этом состояние его и реакция на взаимодействия в WidgetModel. Замечу, что WM описывает также некоторые микросостояния на экране. Этими микро-состояниями могут являться Stream’ы.

class SomeWidgetModel{      final _itemsController = StreamController<List<Items>>.broadcast();     get items => itemsController.stream; } 

Внутри команды мы как раз используем стримы (а точнее обёртку над ними) внутри виджет-модели. Поэтому по форме она очень сильно похожа на конценпцию BLoC’а. Это коробка с input/output. Каждый input — это действие пользователя или событие, output — данные, влияющие на UI. Как я уже сказал, каждый стрим — микросостояние внутри виджет-модели. Такими состояниями могут быть маленькие элементы: кнопка, которая дизейблится при каких-то условиях; поток текста с экрана и т.д.

Вспомним о кнопке на экране. Её состояние может быть одним из таких стримов и тогда она просто будет обернута в StreamBuilder на экране. Но надо понимать, что в этом случае логика кнопки должна быть довольно проста.

Stream<bool> get isBtnDisabled => btnController.stream;

А теперь представим, что такая кнопка встречается ещё в 5 местах по всему приложению. И кроме некоторого изменения состояния на, к примеру, дизейбл, она ещё и триггерит запрос в Сеть. Причём запрос один и тот же, у него лишь разные аргументы. В этом случае каждый раз копипастить логику из экрана в экран, прописывать запросы и вообще загрязнять виджет-модель не очень хорошо. Гораздо лучше просто выделить эту кнопку в Widget+WidgetModel и целиком и полностью переиспользовать из экрана в экран, передавая те или иные параметры на вход.
Ещё одно важное замечание, что виджет-модель – единственный способ привести виджет или часть виджета в некоторое состояние. Что под этим надо понимать? В Flutter, например, мы можем передавать некоторые аргументы виджет в конструктор. Это полезно, если вы используете stateless-виджеты.

Но что у нас получается при использовании WM? Самим состоянием виджета должна управлять именно виджет-модель. Это главный источник. Если передаём какие-то данные в виджет, то мы должны передать их в WM, и только из неё их взять. Напрямую эти данные использовать в виджете нельзя, потому что иначе получается два пути приведения виджета в состояние. И это вас в конечном счёте может запутать или привести к очень неочевидным багам, которых лучше не допускать.

Поток данных W-WM

Какой поток данных происходит между виджетом и виджет-моделью? Из виджета идут некоторые действия — события пользовательского интерфейса. Из виджет-модели идут некоторые состояния в виджет. Состояния могут быть в виде потоков (так делаем мы в Surf), могут быть просто переменными. На эти состояния мы подписываем части виджета с помощью StreamBuilder’ов.

//… child: StreamBuilder<Item>(   stream: wm.item,   builder: (ctx, snapshot) => //... ),

Важно заметить, что связь между Widget и WidgetModel явно не прописана внутри пакета. Мы не стали сужать возможности фреймворка и дали свободу пользователям пакета самим определять связь. При этом, тот подход, что работает в нашей компании является отдельным пакетом на pub.dev.

Relation

Мы рекомендуем использовать наш пакет MWWM вместе с модулем Relation. Relation – это связь, которую мы используем между виджетом виджет-моделью. Это просто семантическая обёртка над потоками. У нас есть некоторые потоковые состояния в виде StreamedState и действия под названием Action. С Relation довольно просто работать.

  final toggleAction = Action<int>();    final contentState = StreamedState<int>(0);  //… subscribe(toggleAction.stream, (data) => contentState.accept(data));

Обработка ошибок

В больших проектах очень важно правильно обрабатывать ошибки. В рамках MWWM предусмотрен специальный интерфейс ErrorHandler, который обязательно поставляется в WM. WidgetModel перехватывает ошибки, которые приходят из сервисного слоя (или происходят внутри презентационного), и передаёт их обработчику. Автоматическая обработка происходит при использовании методов WM с постфиксом …HandleError().

subscribeHandleError(someAction, (data) => doOnData());  doFutureHandleError(someFuture, (data) => doOnData());

Реализацию ErrorHandler можно посмотреть в примере проекта.

Model

Model – это та самая экспериментальная и недавно появившаяся фича, которая пока что опциональна в рамках архитектуры. Это описание контракта между виджет-моделью и бизнес-логикой приложения.

Рассмотрим подробнее.

Model — контракт и унифицированное АПИ для взаимодействие с сервисным слоем. По факту это конкретная сущность с двумя методами: perform и listen. При этом в модель передаётся некоторый список Performer’ов, но обо всём по порядку.

Суть взаимодействия основана на том, что WM говорит: «Model, хочу внести Изменение (совершить действие) в сервисный слой. Сделай это» и ждёт результата. Такая конструкция позволяет полностью абстрагироваться друг от друга при написании кода, просто условившись на контракте.

Change

Это первая часть контракта и это класс, который описывает намерение что-либо изменить, получить и тип получаемого результата. Он может содержать данные, но не может содержать никакой логики. Просто data-класс в терминах того же Kotlin.

class Authenticate extends FutureChange<Result> {   final String name;    Authenticate(this.name); }

Performer

Вторая часть контракта – Performer. Performer – это реализация логики. Самый близкий аналог перформера — UseCase. Это такая функциональная часть контракта. Если Change – это параметры, название метода, то Performer – тело метода и, по сути, код, который исполняется по этому Change. В перформер могут поставляться любые сервисы, бизнес-логика, интеракторы и так далее. Такая конструкция полностью отвязывает виджет-модель от реализации этой бизнес-логики.
Идеальный перформер – это только одно действие. То есть это такой очень атомарный кусок кода, который просто выполняет что-то одно и отдаёт результат. Performer однозначно связан с типом Change.

class AuthPerformer extends FuturePerformer<Result, Authenticate> {    final AuthService authService;    AuthPerformer(     // сущности, которые нужны перформеру для работы     this.authService,   );    Future<Result> perform(Authenticate change) {     return authService.login(change.name);   } }

Был один метод, стало два класса

Зачем это надо было разделять? Потому что если у интерактора был один метод, то здесь у нас получается два класса вместо одного метода. Но таким образом из виджет-модели исчезает полностью информация об интеракторах, о реализации сервисного слоя. Всё что нужно – знать, что вы хотите сделать. То есть знать Change. И предоставить Model с необходимым набором перформеров.

Неприятный минус: если вы не предоставили Performer, то узнаете об этом только в рантайме.

Бизнес-логика

Тут полная свобода действий. MWWM не декларирует, как реализовать бизнес-логику вашего приложения. Рекомендуется использовать тот подход, что принят в вашей команде. В Surf мы используем CleanArchitecture, я в своём pe-проекте работаю с сервисами, которые поставляются в перформеры и там работают. Тут может быть действительно всё, что угодно. Вся суть MWWM в том, что он довольно гибок в использовании, и его можно адаптировать под свою команду.

Стек Surf

Наш стек в Surf – это вот такой набор пакетов для архитектуры.

По факту для нас это один пакет под названием surf_mwwm. Если интересно посмотреть подробнее, то можно найти на нашем GitHub.

На диаграмме:

  • injector — пакет для реализации DI. Основан на InheritedWidget. Маленький и простой.
  • relation — связь между слоями.
  • mwwm — герой этой статьи
  • surf_mwwm — всё вместе с небольшой добавочкой, специфичной для нашей команды.

Вместо заключения

Надеюсь, данный подход и пакет поможет командам. Особенно большим, потому что тут можно, описав контракты между слоями, параллельно работать над фичами, и даже никак не сталкиваться в работе над одними и теми же файлами. Данный факт позволяет быть автономными.

Человеку, который пишет проект в одиночку, это может показаться сильным оверхедом и замедлением разработки. Можно написать гораздо быстрее на других фреймворках, где всё поставлено на рельсы, в ущерб гибкости и кастомизации. Здесь же больше свободы и гибкости для адаптации под свои нужды.

Но в любом случае этот подход позволяет полностью разделить все слои, что явно улучшает читаемость и поддержку написанного кода.

Если вкратце, что мы имеем по MWWM:

Плюсы:

  • Разделяет и изолирует все слои (UI, Presentation Logic, Business Logic).
  • Не имеет лишних зависимостей.
  • Гибко настраивается под нужды команды.
  • Позволяет описать контракт и работать параллельно.

Минусы:

  • Слишком сложно, если пишешь «в одного».
  • Многословное описание контракта.

MWWM – это часть нашего большего репозитория. У неё есть свой отдельный репозиторий — SurfGear. Это набор наших стандартов и библиотек, которые мы используем в Surf.

Часть этих библиотек уже в релизе на pub.dev:

И команда Surf не собирается останавливаться на этом.

ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/510308/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *