Мы знаем, что
ObservableObjec
t классы с его @Published
свойствами созданы в Combine
специально для View Model
в SwiftUI
. Но в точности ту же самую View Model
можно использовать и в UIKit
для реализации архитектуры MVVM
, хотя в этом случае нам придется вручную «привязать» (bind
) UI
элементы к @Published
свойствам View Mode
l. Вы удивитесь, но с помощью 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
«подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса TempViewModel
. Запоминается
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-х минут.