MVVM на основе Combine в UIKit и SwiftUI приложениях для UIKit разработчиков



    Мы знаем, что 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:



    1. на основе названия города city формируем URL с помощью функции absoluteURL(city:city) для запроса детальной информации о погоде WeatherDetail,
    2. используем «издателя» dataTaskPublisher(for:), у которого выходным значением Output является кортеж (data: Data, response: URLResponse), а ошибкой Failure - URLError,
    3. с помощью map { } берем из кортежа (data: Data, response: URLResponse) для дальнейшей обработки только данные data
    4. декодируем JSON данные data непосредственно в Модель, которая представлена структурой WeatherDetail, содержащей детальную информацию о погоде,
    5. при возникновении каких-либо ошибок на предыдущих шагах «ловим» ошибки с помощью catch (error ... ) и возвращаем «заместителя» WeatherDetail.placeholder,
    6. доставляем результат на main поток, так как предполагаем в дальнейшем его использование при проектировании UI,
    7. «стираем» ТИП «издателя» с помощью eraseToAnyPublisher() и возвращаем экземпляр AnyPublisher.

    Полученный таким образом асинхронный «издатель» AnyPublisher сам по себе «не взлетает», он ничего не поставляет до тех пор, пока на него кто-то не «подпишется». Мы будем использовать его в ObservableObject классе, который играет роль View Model как в SwiftUI, так и в UIKit

    Создание View Model


    Для View Model создадим очень простой класс TempViewModel, реализующий протокол ObservableObject с двумя @Published свойствами:  



    1. одно @Published var city: String — это город ( условно можно назвать его ВХОДОМ, так как его значение регулируется пользователем на View),  
    2. второе @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-х минут.

    Комментарии 1

      0

      Отличная статья, спасибо

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое