Однажды я пообещал себе: «Я никогда не напишу статью об архитектуре, потому что это самая банальная тема». Я никогда в жизни так не ошибался. Вот обзор эволюции реактивной архитектуры в моих проектах с первого pod 'RxSwift' до нескольких лет программирования реактивного стека.

Отказ от ответственности

Никакой серебряной пули. Только опыт работы с примерами производства и мнениями по разным случаям.

Примеры кода реализованы с помощью RxSwift, однако все выделенные проблемы актуальны для любых реактивных решений (например, Объединить).

Ознакомьтесь с этим репо с примерами для лучшего понимания. Папки внутри Screen / Search соответствуют темам этой статьи.

Вступление

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

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

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

Второй компонент инкапсулирует вашу бизнес-логику. Возможно, дело в сопоставлении и фильтрации событий, отражающих намерения пользователя. Мы хотим сопоставить намерение пользователя о регистрации с соответствующим сетевым запросом. Обратите внимание, что идиома flatMapLatest & catchError помогает нам предотвратить завершение последовательности. Новый запрос будет отправляться каждый раз при нажатии кнопки.

Это базовые примеры, и, конечно же, вы много раз видели названия этих компонентов. Они широко известны как View и ViewModel. И вместе они являются самой популярной архитектурой для RxSwift или чего-то, что имеет свойства привязки к пользовательскому интерфейсу. Он называется МВВМ.

Мне всегда было абсолютно ясно, куда девать бизнес-логику и как ее реализовать. Единственный сложный вопрос - как создать удобную связь между этими двумя слоями. Подключение, которое будет комфортно для тестирования, рефакторинга и расширения. И, на мой взгляд, это подключение - самое важное место в реактивном MVVM.

Моя команда в scal.io прошла долгий путь, пытаясь найти решение, полностью подходящее для наших нужд, и я собираюсь представить вам краткий обзор. Надеюсь, это сэкономит вам много времени и уменьшит боль :)

Ввод как функции

Честно говоря, это была не первая реализация, которую я использовал. В начальной версии было var dataSource: Observable<[Item]>. Но я быстро понял, что у RxSwift есть мощная система черт, и использовать их на самом деле - хорошая идея. Черты характера помогают создавать осмысленные интерфейсы. Driver здесь означает, что dataSource необходим для работы с пользовательским интерфейсом, возможно, для конфигурации таблицы, поэтому элементы должны наблюдаться в основном потоке.

Пользователь может искать или выбирать элементы, мы формализуем эти намерения как функции. Но быстро понимаем, что это неудобно. Взгляните на эту часть кода, где несколько операторов применяются к searchBar.rx.text потоку.

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

Конечно, мы можем реализовать операторы в императивном стиле… Но я не вижу смысла в этой двойной работе. Методы RxSwift хорошо оптимизированы и покрыты тестами. Они заслуживают доверия, и их лучше предпочесть колесу собственного изобретения. Вот почему функции не должны использоваться в ViewModel контракте.

Ввод в виде потоков

Намерение пользователя - это поток событий, это действительно хорошая идея. У нас уже есть эти концепции в button.rx.tap или searchBar.rx.text, так какой смысл их переосмысливать? Давайте использовать тот же подход для взаимодействия компонентов нашей архитектуры.

Потоки как параметры инициализации

Первая мысль - передать все входные данные из UI в ViewModel через init, как в RxExample project.

Это может показаться хорошей идеей, но я так не думаю. IMO, init должен принимать только аргументы, которые необходимы для создания объекта. Может ли ViewModel жить без потока нажатий кнопок входа? Определенно да. Так что input кортежа здесь быть не должно.

Если я не убедил вас, подумайте еще о внедрении зависимостей. Вы можете получить требуемые потоки элементов пользовательского интерфейса только после загрузки представления и внедрения выходов. Таким образом, вы не можете создать свой ViewModel перед загрузкой представления. viewDidLoad метод будет вызываться с nil viewModel, и только после него вы получите все необходимые входные данные.

Мне такой подход небезопасен. Представление следует загружать только тогда, когда ViewController полностью инициализирован и готов к работе.

Входные темы

Давайте просто объявим свойства ViewModel, которые будут отвечать за входные потоки.

Вот и все. Наш ViewController - это просто простое пространство имен для объявления привязок элементов пользовательского интерфейса к входам бизнес-логики. Вы только посмотрите на эти строки:

Абсолютно декларативно и чисто. Идеально? Нет. На самом деле с контрактной точки зрения стало еще хуже. Вспомним эту картинку из Сюжетной документации.

Тема соответствует Observer протоколу, поэтому может выдавать ошибки или завершаться. Подписки на эту тему будут повторять это поведение. Итак, если мы выдадим ошибку search субъекту, наш пользовательский интерфейс будет полностью мертв. Только перезапуск приложения снова создаст эту подписку.

Да, searchBar.rx.text имеет ControlProperty тип, который никогда не выходит из строя, поэтому можно безопасно привязать его к теме. Однако плохо иметь контракт, позволяющий совершать действия, которых мы не требуем. Таким образом, неправильно использовать Subject для выражения намерений пользователя.

Входные реле

К счастью, RxSwift уже сделал это за нас. Была введена еще одна абстракция вокруг Observer. Relay просто игнорирует .completed события и вызывает фатальную ошибку в режиме отладки, вы можете прочитать подробнее об этом здесь. В версии 5.0.0 реле вынесены в отдельный фреймворк, поэтому их можно использовать даже без RxCocoa.

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

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

Беспорядок ввода / вывода

Все начинается с действительно простого экрана. Вы видели это много раз: вид таблицы, разбиение на страницы, индикатор активности и нажатие для обновления. Что-то подобное:

Даже при такой низкой сложности код может быть неясным. Если честно, мне нравится этот стиль, и мне его легко читать. Но я понимаю, почему это может сбивать с толку нового разработчика, незнакомого с FRP.

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

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

Мне нужно объединить множество входов и внутренних состояний, чтобы создать один выход. Мне нужно разделить цепочку операторов и сохранить промежуточное состояние потока, чтобы привязать его к нескольким выходам. Поэтому есть newSearchRequests и allSearchRequests ненужные переменные. Просто чтобы правильно обрабатывать загрузку и сохранять состояние разбивки на страницы. Поверьте мне, чем сложнее ваша логика, тем более беспорядочный код Rx вы получите.

У меня есть производственный пример, когда содержимое табличного представления зависит от шести разных потоков:

  • Текущая геолокация устройства
  • Пользовательское местоположение, выбранное пользователем
  • Прокручиваемая страница
  • Поисковый запрос
  • События с обновлением по запросу
  • Ответы от двух разных конечных точек

И боже, как сложно было реализовать это в удобочитаемой форме. Однажды я наконец понял, что просто не могу этого сделать с этими ViewModel входами / выходами. И в тот момент я решил последовать совету, который дал мне дедушка, когда я был ребенком.

Если вы ищете решение сложной проблемы, просто зайдите в Интернет. Они уже изобрели все необходимое.

UDF

Ну конечно; естественно. Redux меня спасет. Но я не хочу реализовывать асинхронную работу в промежуточном программном обеспечении. Мне нужно что-то, что тесно интегрировано с Rx, построено с его мощью в корнях.

В настоящее время я знаю только два фреймворка, которые сочетают в себе красоту идеи Action / State с мощью RxSwift: RxFeedback и ReactorKit. Оба они позволяют нам достичь цели:

  • объединить все входные данные в один поток, содержащий перечисление намерений пользователя
  • объединить все выходы в один поток объектов состояния

ReactorKit

Я использовал ReactorKit только в производстве, так что вот его преимущества перед чистой архитектурой RxSwift. В репо есть подробная документация, я приведу лишь краткое изложение преимуществ.

Action enum формализует все возможные намерения пользователя. State - это простая структура, инкапсулирующая состояние экрана. А Mutation enum формализует все возможные изменения, которые можно применить к состоянию.

Мне больше всего нравится то, что сопоставление намерений пользователя с запросами бизнес-логики четко отделено от изменения состояния. Каждый Action должен быть сопоставлен с Observable<Mutation>. Когда излучается поток мутаций, reduce функция вызывается с текущим состоянием и новым изменением. И, боже, еще никогда не было так легко изменить состояние.

Вы только посмотрите на этот код, его стало намного больше:

  • читабельный, больше не нужно сложного комбинирования операторов Rx, больше нет беспорядочных привязок к нескольким выходам
  • проще, гораздо проще применять императивные изменения
  • явный, все мутации теперь собраны в одном месте (а не случайно do(onNext:) на 300-й строке), их легко отлаживать
  • масштабируемый, с возрастающей сложностью бизнес-логики, вы просто добавите сюда еще несколько вариантов.
  • testable, эту функцию очень легко протестировать, потому что она чистая.

Заключение

Хорошего знания реактивного фреймворка недостаточно для создания масштабируемого приложения. Реактивный код должен быть как-то организован, и вы должны сделать его стандартом в своей команде. Это поможет каждому разработчику легко изменять и расширять фрагменты кода.

Сегодня я показал вам стандартный прогресс моей команды. Эта реализация UDF называется MVI и широко используется в мире Android. Я предлагаю вам глубоко задуматься над вопросом Действительно ли мой реактивный код так прост, как я хочу?. А если нет, возможно, вам действительно нужен MVI.

Попробуйте применить его на одном экране, и я обещаю, вам захочется еще. И помните, позвольте Rx войти в ваше сердце.

Я собираюсь поделиться большим опытом работы с RxSwift на Saint AppsСonf. Встретиться со мной там!

Если вам понравился этот совет, хлопните в ладоши (50) и поделитесь, чтобы помочь другим найти его! Следуйте за мной, чтобы узнать больше о Rx. Twitter: M0rtyMerr