
Мы знаем, что
ObservableObject классы с его @Published свойствами созданы в Combine специально для View Model в SwiftUI. Но в точности ту же самую View Model можно использовать и в UIKit для реализации архитектуры MVVM, хотя в этом случае нам придется вручную «привязать» (bind) UI элементы к @Published свойствам View Model. Вы удивитесь, но с помощью Combine это делается парой строк кода. Кроме того, придерживаясь этой идеологии при проектировании UIKit приложений, вы в дальнейшем безболезненно перейдете на SwiftUI.Цель этой статьи состоит в том, чтобы на примитивно простом примере показать, как можно элегантно реализовать
MVVM архитектуру в UIKit с помощью Combine. Для контраста покажем использование той же самой View Model в SwiftUI.В статье будут рассмотрены два простейших приложения, позволяющих выбирать с сайта OpenWeatherMap самую свежую информацию о погоде для определенного города. Но
UI одного из них будет создан с применением SwiftUI, а другого — с помощью UIKit. Для пользователя эти приложения будут выглядеть почти одинаковыми. 
Код находится на Github.
Пользовательский интерфейс (
UI) будет содержать всего 2 UI элемента: текстовое поле для ввода города и метку для отображения температуры. Текстовое поле для ввода города — это активный ВХОД (Input), а отображающая температуру метка — пассивный ВЫХОД (Output). Роль
View Model в архитектуре MVVM состоит в том, что она берет ВХОД(Ы) с View (или ViewController в UIKit), реализует бизнес-логику приложения и передаёт ВЫХОДЫ назад в View (или ViewController в UIKit), возможно, представляя эти данные в нужном формате.Создать
View Model с помощью Combine независимо от того, какая бизнес-логика — синхронная или асинхронная — очень просто, если использовать ObservableObject класс с его @Published свойствами.Модель данных и API сервиса OpenWeatherMap
Хотя сервис OpenWeatherMap позволяет выбирать очень обширную информацию о погоде, Модель интересующих нас данных будет очень простой, она представляет собой детальную информацию
WeatherDetail о текущей погоде в выбранном городе и находится в файле Model.swift:
Хотя в этой конкретной задаче нас будет интересовать только температура
temp, которая находится в структуре Main, Модель предоставляет полную детальную информацию о текущей погоде в виде корневой структуры WeatherDetail, полагая, что в будущем вы захотите расширить возможности этого приложения. Структура WeatherDetail является Codable, это позволит нам буквально двумя строками кода декодировать JSON данные в Модель. Структура
WeatherDetail должна быть еще и Identifiable, если мы хотим облегчить себе в дальнейшем отображение массива прогнозов погоды [WeatherDetail] на несколько дней вперед в виде списка List в SwiftUI. Это тоже заготовка для будущего более сложного приложения о текущей погоде. Протокол Identifiable требует присутствия свойства id, которое у нас уже есть, так что никаких дополнительных усилий от нас не потребуется.Обычно сервисы, включая и сервис OpenWeatherMap, предлагают всевозможные
URLs для получения тех или иных нужных нам ресурсов. Сервис OpenWeatherMap предлагает нам URLs для выборки детальной информации о текущей погоде или прогноза на 5 дней в некотором городе city. В данном приложении нас будет интересовать только текущая информация о погоде и для этого случая URL рассчитывается с помощью функции absoluteURL (city: String):
API для сервиса OpenWeatherMap мы разместим в файле WeatherAPI.swift. Центральной его частью будет метод выборки детальной информации о погоде WeatherDetail в городе city:fetchWeather (for city: String) -> AnyPublisher<WeatherDetail, Never>
В контексте фреймворка
Combine этот метод возвращает не просто детальную информацию о погоде WeatherDetail, а соответствующего «издателя» Publisher. Наш «издатель» AnyPublisher<WeatherDetail, Never> не возвращают никакой ошибки — Never, а если ошибка выборки или кодирования все-таки имела место, то возвращается заместитель WeatherDetail.placeholder без каких-либо дополнительных сообщений о причине ошибки. Рассмотрим более подробно метод
fetchWeather (for city: String) -> AnyPublisher<WeatherDetail, Never>, который выбирает с сайта OpenWeatherMap детальную информацию о погоде для города city и не возвращает никакой ошибки Never:
- на основе названия города
cityформируемURLс помощью функцииabsoluteURL(city:city)для запроса детальной информации о погодеWeatherDetail, - используем «издателя»
dataTaskPublisher(for:), у которого выходным значениемOutputявляется кортеж(data: Data, response: URLResponse), а ошибкойFailure-URLError, - с помощью
map { }берем из кортежа (data: Data, response: URLResponse)для дальнейшей обработки только данныеdata, - декодируем
JSONданныеdataнепосредственно в Модель, которая представлена структуройWeatherDetail, содержащей детальную информацию о погоде, - при возникновении каких-либо ошибок на предыдущих шагах «ловим» ошибки с помощью
catch (error ... )и возвращаем «заместителя»WeatherDetail.placeholder, - доставляем результат на
mainпоток, так как предполагаем в дальнейшем его использование при проектированииUI, - «стираем» ТИП «издателя» с помощью
eraseToAnyPublisher()и возвращаем экземплярAnyPublisher.
Полученный таким образом асинхронный «издатель»
AnyPublisher сам по себе «не взлетает», он ничего не поставляет до тех пор, пока на него кто-то не «подпишется». Мы будем использовать его в ObservableObject классе, который играет роль View Model как в SwiftUI, так и в UIKit. Создание View Model
Для
View Model создадим очень простой класс TempViewModel, реализующий протокол ObservableObject с двумя @Published свойствами: 
- одно
@Published var city: String— это город ( условно можно назвать его ВХОДОМ, так как его значение регулируется пользователем наView), - второе
@Published var currentWeather = WeatherDetail.placeholder— это погода в этом городе на данный момент ( условно можно назвать это свойство ВЫХОДОМ, так как оно получается путем выборки данных с сайта OpenWeatherMap).
Как только мы поставили
@Published перед свойством city, мы можем начать использовать его и как простое свойство city, и как «издателя» $city.В классе
TempViewModel, можно не просто декларировать интересующие нас свойства, но и прописать бизнес-логику их взаимодействия. С этой целью при инициализации экземпляра класса TempViewModel в init? мы можем создать «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса TempViewModel, и воспроизводить зависимость текущей погоды currentWeather от города city.Для этого в
Combine мы протягиваем цепочку от входного «издателя» $city до выходного «издателя» AnyPublisher<WeatherDetail, Never>, у которого значение — это текущая погода. Впоследствии мы «подпишемся» на него с помощью «подписчика» assign (to: \.currentWeather, on: self) и получим нужное нам значение текущей погоды currentWeather как «выходное» @Published свойство.Мы должны тянуть цепочку НЕ просто от свойств
city, а именно от «издателей» $city, которые будет участвовать в создании UI и именно там мы будем его изменять.Как мы будем это делать?
В нашем арсенале уже есть функция
fetchWeather (for city: String), которая находится в классе WeatherAPI и возвращает «издателя» AnyPublisher<WeatherDetail, Never> с детальной информацией о погоде в зависимости от города city, и нам остаётся только каким-то образом использовать значение «издателя» $city, чтобы превратить его в аргумент этой функции.Перейти к нужному издателю
fetchWeather (for city: String) в Combine нам поможет оператор flatMap:
Оператор
flatMap создает нового «издателя» на основе данных, полученных от предыдущего «издателя».Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика»
assign (to: \.currentWeather, on: self) и присваиваем полученное от «издателя» значение @Published свойству currentWeather:
Мы только что создали в
init( ) АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable «подписку».AnyCancellable «подписка» позволяет вызывающей стороне в любой момент отменить «подписку» и далее не получать значений от «издателя», но более того, как только AnyCancellable «подписка» покидает свою область действия, память, занятая «издателем» освобождается. Поэтому, как только init( ) завершится, эта «подписка» будет удалена системой ARC, так и не успев присвоить полученную с задержкой по времени асинхронную информацию о текущей погоде currentWeather. Для сохранения такой «подписки» необходимо создать ЗА ПРЕДЕЛАМИ init() переменную var cancellableSet, которая сохранит нашу AnyCancellable «подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса TempViewMode. Запоминается
AnyCancellable «подписка» в переменной cancellableSet с помощью оператора store ( in: &self.cancellableSet):
В результате «подписка» будет сохраняться в течение всего “жизненного цикла” экземпляра класса
TempViewModel. Мы можем как угодно менять значение издателя $city, и всегда в нашем распоряжении будет текущая погода currentWeather для данного города.Для того чтобы сократить число обращений к серверу при наборе города
city, мы должны использовать не непосредственно самого «издателя» строки с именем города $city, а его модифицированный вариант c операторами debounce и removeDuplicates:
Оператор
debounce используется для того, чтобы подождать, пока пользователь закончит набирать на клавиатуре необходимую информацию, и только после этого однократно выполнить ресурсозатратное задание.Аналогично, оператор
removeDuplicates будет публиковать значения, только если они отличаются от любых предыдущих значений. Например, если пользователь сначала вводит john, затем joe, а затем снова john, мы получим john только один раз. Это помогает сделать наш UI более эффективным.Создание UI с помощью SwiftUI
Теперь, когда у нас есть
View Model, приступим к созданию UI. Сначала в SwiftUI, а затем — в UIKit.В
Xcode создаём новый проект с SwiftUI и в полученной структуре ContentView размещаем нашу View Model как @ObservedObject переменную model. Заменим Text ("Hello, World!") на заголовок Text ("WeatherApp"), добавим текстовое поле для ввода города TextField ("City", text: self.$model.city) и метку для отображения температуры:
Мы напрямую использовали значения нашей переменной
model: TempViewModel(). В текстовом поле для ввода города мы использовали $model.city, а в метке для отображения температуры — model.currentWeather.main?.temp.Теперь, любые изменения
@Published свойств будут приводить к «перерисовке» View:
Это обеспечивается тем, что наша
View Model является @ObservedObject, то есть осуществляется АВТОМАТИЧЕСКАЯ «привязка» (binding) @Published свойств нашей View Model и элементов пользовательского интерфейса (UI). Такая АВТОМАТИЧЕСКАЯ «привязка» возможна только в SwiftUI.Создание UI с помощью UIKit
Как быть с этим в
UIKit? Ведь там нет @ObservedObject. В UIKit будем выполнять «привязку» (binding) вручную. Есть много способов такой «ручной привязки»:Key-Value ObservingилиKVO: механизм использованияkey pathsдля наблюдения за свойством и получения уведомления о том, что оно изменилось.- Функциональное реактивное программирование или
FRP: использование фреймворкаCombine. Delegation: Использование методов делегата для передачи уведомления о том, что значение свойства изменилось.Boxing: использование Наблюдателя свойстваdidSet { }для уведомления о том, что значение свойства изменилось.
Учитывая заголовок статьи, мы естественно будем работать на «поле»
Combine. В UIKit приложении мы покажем, как просто можно сделать «ручную привязку» с помощью Combine.В
UIKit приложении у нас также будет два UI элемента: UITextField для ввода города и UILabel для отображения температуры. В ViewController у нас естественно будут Outlet для этих элементов:

В виде обычной переменной
viewModel у нас присутствует та же самая View Model, что и в предыдущем разделе:
Прежде, чем выполнить «ручную привязку» с помощью
Combine, давайте сделаем текстовое поле UITextField нашим союзником и «издателем» своего содержимого text:
Это позволит нам очень просто в
viewDidLoad реализовать «ручную привязку» с помощью функции binding ():
Действительно, мы «подписываемся» на «издателя»
cityTextField.textPublisher с помощью очень простого «подписчика» assign (to: \.city, on: viewModel) и присваиваем текст, набираемый пользователем в текстовом поле cityTextField, нашему «входному» @Published свойству city нашей View Model.Кроме этого, мы совершаем изменения и в другом направлении: «подписываемся» на «выходное»
@Published свойство $currentWeather с помощью «подписчика» sink и его замыкания receiveValue, формируем значение температуры и присваиваем его метке temperatureLabel.Полученные в
viewDidLoad «подписки» сохраняем в переменной var cancellableSet. Создав их один раз, мы позволяем им действовать в течении всего “жизненного цикла” экземпляра класса ViewController и вместе с «подпиской» в нашей View Model реализовать всю бизнес-логику приложения.Кстати протокол
ObservableObject не работает с UIKit, но и не мешает. UIKit совершенно равнодушен к протоколу ObservableObject и в принципе, его можно было бы убрать во View Model в UIKit приложений:
Но мы этого делать не будем, так как хотим сохранить неизменной
View Model как для текущего приложения на UIKit, так и для возможно будущих приложений на SwiftUI.На этом всё. Код находится на Github.
Заключение.
Функциональный реактивный фреймворк
Combine позволяет очень просто и лаконично реализовать MVVM архитектуру как в SwiftUI, так в UIKit, в виде понятного и читабельного кода.Ссылки:
Combine + UIKit + MVVM
Using Combine
iOS MVVM Tutorial: Refactoring from MVC
MVVM with Combine Tutorial for iOS
P.S. Если вы хотите увидеть какую-то информацию о погоде, то вам нужно зарегистрироваться на OpenWeatherMap и получить
API key. Этот процесс займет у вас не более 2-х минут.
tikhonov666
Отличная статья, спасибо