Мы знаем, что 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
свойствами.
Если вы хотите увидеть какую-то информацию о погоде, то вам нужно зарегистрироваться на OpenWeatherMap и получить API
key. Этот процесс займет у вас не более 2-х минут.
Модель данных и 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
.
В SwiftUI
создаём в Xcode
новый проект и в полученной структуре 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
, в виде понятного и читабельного кода.
Ссылки:
Здравствуйте, писал вам на почту, о покупке лекций курсов ios. Без ответа…
На какую почту? Вся почта просматривается ежедневно. Какой у Вас вопрос?
Пишите на tatiana.kornilova@gmail.com
писал на эту почту еще 16 марта ,может в спам попал, хотел купить по пей пал перевод лекций по ios 11 осень 2017
Александр, вашу почту, к сожалению, не нашла: ни в спаме, нигде.
Но если вы в Украине, то Яндекс для вас закрыт.
С Украины обычно платят через PayPal:
https://www.paypal.me/TatianaKornilova/500
либо
на QiWi https://qiwi.com/, указав там
мой телефон +7 9036706589
Но можете попробовать через Яндекс, если у вас там видна форма
http://bestkora.com/IosDeveloper/dostup-k-paketu-ios-11/
через VPN
Если не получится этими 3-мя способами — напишите, что -нибудь придумаем.
получилось через яндекс с vpn
Ваши деньги пришли. Даю вам доступ через alxkutoray@gmail.com к 1-ой части.
Спасибо за поддержку проекта.