Pull to refresh
70.33
Surf
Создаём веб- и мобильные приложения

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

Reading time9 min
Views9.2K

Привет, меня зовут Артём. Я руководитель 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, я в своём pet-проекте работаю с сервисами, которые поставляются в перформеры и там работают. Тут может быть действительно всё, что угодно. Вся суть 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 не собирается останавливаться на этом.


Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!
Tags:
Hubs:
+5
Comments13

Articles

Information

Website
surf.ru
Registered
Founded
Employees
201–500 employees
Location
Россия