SwiftUI & Combine: Вместе лучше.

Это перевод статьи «SwiftUI & Combine: Better Together», в которой на очень простом примере и очень подробно показано, как можно использовать новый фреймворк Combine в содружестве со SwiftUI с целью эффективного создавать приложений с помощью функционального реактивного программирования (Functional Reactive Programming — FRP) .

Одно из самых значимых объявлений, сделанных Apple на конференции разработчиков WWDC 2019, был SwiftUI — его декларативный подход позволяет создавать пользовательские интерфейсы (UI) очень быстро, так что не удивительно, почему разработчики так воодушевлены этим. Однако скрытой жемчужиной WWDC 2019 был фреймворк Combine, который не получил столько фанфар, но я думаю, что он бы это заслужил.

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

В этой статье мы подробно рассмотрим, как совместно использовать SwiftUI и Combine для того, чтобы создавать лучшие приложения и получать от этого удовольствие.
Готовы? Вперед!

Что мы будем изучать?

  • Что собой представляет фреймворк Combine и как его интегрировать с SwiftUI,
  • Что такое «издатели» Publishers, «подписчики» Subscribers и операторы и как ими пользоваться,
  • Как организовать код.

Для рассуждений о SwiftUI и Combine, мы будем использовать простой экран регистрации, который позволяет пользователям ввести имя пользователя username и пароль password для создания нового аккаунта в этом приложении.
Модель данных для экрана регистрации — достаточно простая:

  • Пользователям нужно ввести их желаемое имя username,
  • Они также должны выбрать пароль password.

Требования к имени пользователя username и паролю password также довольно просты:

  • имя пользователя username должно содержать по крайней мере 3 символа, 
  • пароль password должен быть не пустым и достаточно надежным («сильным»).

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

Я решил использовать архитектуру MVVM — это приводит к понятному коду и упрощает добавление новых функций в приложение.
Во-первых. определим ViewModel, у которой будет пара свойств для ввода данных пользователем  (имя пользователя username и пароль password), и на данный момент одно выходное (output) свойство isValid для представления результатов некоторой бизнес-логики проверки введенных пользователем username и password, которую мы собираемся внедрить в ближайшее время:

Для экрана регистрации мы используем в SwiftUI форму Form и несколько секций Section для различных полей ввода TextField, что обеспечит нам прекрасный внешний вид. Конечно, этот экран выполняет свою работу, но выглядит он не очень впечатляюще.

Далее мы это улучшим, чтобы продемонстрировать, как SwiftUI и Combine позволяют вносить изменения в наш пользовательский интерфейс без необходимости изменять основную бизнес-логику.

Обратите внимание, как мы используем привязки (bindings) SwiftUI для доступа к свойствам нашей Модели UserViewModel.

Работоспособность кнопки «Sign up» ограничена выходным (output) свойством isValid нашей UserViewModel. Так как по умолчанию начальное значение этого свойства равно false, то кнопка сначала будет не работоспособна, а именно этого мы и хотим — в конце концов, пользователь не должен иметь возможность создать учетную запись с пустым именем пользователя username и пустым паролем password!

Введение в Combine

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

Согласно документации Apple:

Фреймворк Combine предоставляет декларативный Swift API для обработки значений во времени. Эти значения могут представлять собой множество различных видов асинхронных событий. Combine объявляет, что издатели Publishers выставляют значения, которые могут изменяться с течением времени, и подписчики Subscribers получают эти значения от издателей Publishers.

Давайте подробнее рассмотрим пару ключевых понятий.

Издатели Publishers.

Издатели Publishers посылают значения values одному или более подписчикам Subscribers. Они реализуют протокол Publisher и объявляют ТИП выхода  Output и ТИП любой ошибки Failure, которую они могут создавать:

Издатель Publisher может отправить любое количество значений values  в течение некоторого времени, или сообщение об ошибке Ассоциированный ТИП ВЫХОДА associatedtype  Output определяет, какого ТИПА значения values может посылать издатель Publisher, а ассоциированный ТИП ОШИБКИ associatedtype Failure определяет ТИП ошибки, которая может возникнуть в этом случае. Издатель Publisher может декларировать, что он вообще НИКОГДА не будет ошибаться путем определения ассоциированного ТИП ОШИБКИ associatedtype Failure как Never.

Подписчики Subscribers.

С другой стороны, подписчики Subscribers подписываются (subscribe) на один конкретный экземпляр издателя publisher и получают поток значений values, пока подписка не будет отменена. Подписчики Subscribers реализуют протокол Subscriber. Чтобы подписаться на конкретного издателя publisher, ассоциированный ТИП ВХОДА associatedtype Input подписчика Subscriber  и его ассоциированный ТИП ОШИБКИ associatedtype Failure должны соответствовать ассоциированным ТИПАМ издателя publisher: ассоциированному ТИПУ ВЫХОДА associatedtype Output и ассоциированному ТИПУ ОШИБКИ associatedtype Failure.

Operators

Издатели Publishers и подписчики Subscribers являются основой двухсторонней синхронизации в SwiftUI между пользовательским интерфейсом (UI) и Моделью. Я думаю, вы согласитесь со мной, что никогда не было проще синхронизировать ваш UI и Модель, чем со SwiftUI, и все это благодаря фреймворку Combine.
Операторы Operators, между тем, являются непревзойденной силой Combine. Они представляют собой методы, оперирующие с издателем Publisher, они выполняют некоторые вычисления, создают другого издателя Publisher и возвращают его нам.

  • Например, вы можете использовать оператор filter для того, чтобы отказаться от некоторых значений values, которые удовлетворяют определенным условиям, 
  • Или, если вы выполняете некоторое ресурсозатратное задание ( например, такое, как выборка информации из интернета), то вы можете использовать оператор debounce для того, чтобы подождать, пока пользователь закончит набирать на клавиатуре необходимую информацию, и только после этого однократно выполнить  это ресурсозатратное задание,
  • Оператор map позволяет вам преобразовывать входные значения input values определенного ТИПА в выходные значения output values другого ТИПА.

Проверка имени пользователя  username.

Имея ввиду все вышесказанное об издателях Publishers, подписчиках Subscribers, и операторах Operators, давайте реализуем простейшую проверку того, что введенное пользователем имя username
содержит не менее 3-х символов.
Все свойства нашего класса UserViewModel «завернуты» в «обертку свойства» (PropertyWrapper) с именем @Published. Это означает, что каждое свойство имеет своего собственного издателя Publisher, на которого можно подписаться с помощью subscribe.
Чтобы установить, является ли имя пользователя username правильным, мы преобразуем введенное пользователем имя из String в Bool, используя оператор map:

Результат этого преобразования затем используется  подписчиком  assign, который — как следует из названия — назначает полученное значение value выходному (output) свойству isValid нашей Модели UserViewModel.

Благодаря привязке нашей UserViewModel, которую мы настроили ранее в SwiftUI в ContentView (файл ContentView.swift)…

… SwiftUI будет автоматически обновлять UI при каждом изменении этого свойства. Позже мы увидим, почему этот подход немного проблематичен, но сейчас он работает просто отлично.

Вы можете спросить, что такого фантастического делают операторы debounce и removeDuplicate? Что ж, это часть того, почему Combine является таким полезным инструментом для подключения UI к основной бизнес-логике. 

Во всех пользовательских интерфейсах нам приходится иметь дело с тем, что пользователь может печатать быстрее, чем мы можем получить запрашиваемую информацию. Например, при вводе имени пользователя username нет необходимости проверять, является ли имя пользователя правильным для каждой отдельной буквы, которую вводит пользователь. Достаточно выполнить эту проверку только после того, как он прекратит печатать (или остановится на мгновение).
Оператор debounce позволяет нам сообщить системе, что мы хотим дождаться паузы в доставке событий, например, когда пользователь перестает печатать.
Аналогично, оператор removeDuplicates будет публиковать события, только если они отличаются от любых предыдущих событий. Например, если пользователь сначала вводит john, затем joe, а затем снова john, мы получим john только один раз. Это помогает сделать наш UI более эффективным.

Результат этой цепочки вызовов является Cancellable, который мы можем использовать для отмены обработки при необходимости (полезно для длинных цепочек). Мы сохраним его (и все остальные, которые мы создадим позже) в множестве Set <AnyCancellable>, которое мы можем очистить при deinit нашего userViewModel.

Проверка пароля.

Теперь давайте переключимся и посмотрим, как можно выполнить многоступенчатую логику проверки пароля. Это необходимо, поскольку поля паролей password и passwordAgain в нашей форме должны отвечать нескольким требованиям: они не должны быть пустыми, они должны совпадать, и (что наиболее важно) выбранный пароль должен быть достаточно надежным. В дополнение к преобразованию входных значений в Bool, чтобы обнаружить, соответствуют ли пароли нашим требованиям, мы также хотим предоставить пользователю некоторые рекомендации, вернув соответствующее предупреждающее сообщение.
Давайте проделаем это последовательно, шаг за шагом, и начнем с реализации конвейера для проверки паролей, введенных пользователем.

Проверить, является ли пароль password пустым, довольно просто, и вы можете заметить, что этот метод очень похож на нашу предыдущую проверку имени пользователя username. Но на этот раз вместо непосредственного присвоения результата преобразования выходному свойству isValid, мы возвращаем AnyPublisher<Bool, Never>. Мы поступаем так, потому что это промежуточное значение и позже мы будем объединять несколько наших пользовательских издателей Publishers  в многоступенчатую цепочку, прежде чем подпишемся на конечный результат (правильный или нет).

Чтобы проверить, содержат ли два отдельных свойства одинаковые строки String, мы используем оператор CombineLatest. Помните, что свойства, привязанные к соответствующим SecureField, запускаются каждый раз, когда пользователь вводит символ, и мы хотим сравнивать последние значения для каждого из этих полей. Оператор CombineLatest позволяет нам это делать.

Для расчета надежности пароля мы используем фреймворк Navajo Swift и преобразуем результирующее перечисление enum в Bool с помощью цепочки в другом издателе isPasswordStrongEnoughPublisher. В этом коде мы впервые подписываемся (subscribe) на один из наших собственных издателей passwordStrengthPublisher, и это очень наглядно показывает, как мы можем комбинировать множество издателей (наших собственных и встроенных в Combine) для получения требуемого выхода (output).

В случае, если вам интересно, почему мы должны вызывать eraseToAnyPublisher () в конце каждой цепочки, поясняю: это выполняет стирание некоторого ТИПА, оно гарантирует, что мы не получим некоторые сумасшедшие вложенные ТИПЫ возврата и сможем их встроить в любую цепочку.


Отлично — теперь мы много знаем о паролях, введенных пользователем, давайте сведем это к одной вещи, которую мы действительно хотим знать: этот  пароль password — правильный?
Как вы уже догадались, нам нужно будет использовать оператор CombineLatest, но поскольку на этот раз у нас есть три параметра, мы будем использовать CombineLatest3, который принимает три входных параметра:

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

Собираем все вместе.

Чтобы вычислить окончательный результат проверки, нам нужно объединить результат проверки имени пользователя username с результатом проверки пароля password. Однако, прежде чем мы сможем это сделать, нам нужно провести рефакторинг кода проверки имени пользователя username, чтобы он также возвращал издателя, которого мы включим в нашу цепочку проверки.

Сделав это, мы можем реализовать последний этап проверки формы:

Теперь это выглядит довольно знакомым для вас.

Обновление интерфейса

Все, что мы проделали до этого,  совершенно бесполезно, если не подключиться к UI. Чтобы управлять состоянием кнопки «Sign up«, нам нужно обновить выходное свойство isValid в нашей Модели UserViewModel.
Для этого мы просто подписываемся на isFormValidPublisher и присваиваем значения, которые он публикует, свойству isValid

Поскольку этот код взаимодействует с UI, он должен работать на main потоке. Мы можем заставить SwiftUI выполнить этот код на main потоке, потоке пользовательского интерфейса, вызвав метод receive (on: RunLoop.main).
Давайте закончим нашу цепочку привязкой специальных выходных свойств usernameMessage и passwordMessage, связанных с предупреждающими сообщениями…

… к UI. Тем самым мы поможем пользователю при заполнении формы регистрации.
Сначала мы подписываемся subscribe на соответствующих уже полученных издателей Publishers, чтобы узнать, когда свойства username и password неправильные (invalid). Опять же, нам нужно убедиться, что это происходит на main потоке, то есть на потоке пользовательского интерфейса, поэтому мы вызовем метод receive (on 🙂 и передадим туда значение RunLoop.main.

Наконец, нам нужно связать выходные свойства usernameMessage и passwordMessage с UI. Обращаемся к SwiftUI и нашему ContentView. Нижние колонтитулы секций Section — это удобное место для отображения сообщений об ошибках…

… и мы можем выделить их красным цветом:

Кроме этого, мы добавили модальное WelcomeView, чтобы наглядно оценить работоспособность кнопки «Sign up«:

Если кнопка «Sign up» работоспособна, и мы кликаем на ней, то появляется  WelcomeView с единственной текстовой меткой «Welcome! Great to have you on board!«:

Модальное View можно убрать жестом Swipe down (смахнуть вниз).

Заключение.

Создавать пользовательские интерфейсы с помощью SwiftUI очень просто. Apple очень постаралась и предоставила нам более продуктивные инструменты для создания UI, чем когда-либо прежде.  Кроме того, SwiftUI следует   «Руководство по созданию пользовательского интерфейса» компании Apple, автоматически адаптируется к Dark Mode и имеет встроенную Accessibility (доступность для людей с ограниченными возможностями). Все это помогает создавать лучшие приложения, доступные более широкому кругу людей, за меньшее время.

Использование Combine приводит к созданию более понятного и модульного кода, который  более удобен в обслуживании и его легче расширять.
Конечно, как и у каждой новой парадигмы, есть кривая обучения, и потребуется некоторое время, чтобы разобраться с функциональным реактивным программированием (Functional Reactive Programming). Но я убежден, что оно того стоит.

Выпустив SwiftUI и Combine, Apple дала знак одобрения функциональному реактивному программированию, и вскоре это уже не будет технологией, которую используют лишь отдельные группы разработчиков.
Мы увидим все больше и больше учебных ресурсов, чтобы помочь людям начать работать с новой парадигмой. Кроме того (и это было немного больным вопросом в последних бета-версиях XCode), инструменты со временем будут улучшаться, помогая разработчикам быть более продуктивными.
Сейчас самое время начать работу с SwiftUI и Combine — попробуйте использовать их в одном из следующих проектов, чтобы получить преимущество!

Код проекта находится на Github.
Ссылки:

«SwiftUI & Combine: Better Together»

Introducing Combine — WWDC 2019 — Videos — Apple Developer. session 722

(конспект сессии 722 «Введение в Combine» на русском языке)

Combine in Practice — WWDC 2019 — Videos — Apple Developer. session 721

(конспект сессии 721 «Практическое применение Combine» на русском языке)