Swift Combine
Combine - фреймворк, представленный в 2019 году вместе с SwiftUI.
Вообще говоря, SwiftUI построен на Combine. Если говорить в терминах MVVM, то View's обновляются автоматически при изменении соответствующих данных во ViewModel. При использовании SwiftUI не нужно прописывать обработчики событий, достаточно использовать State Objects. SwiftUI построен на основе взаимодействия publisher'ов и subscriber'ов между данными и их отображением.
Основными понятиями Combine являются:
- publishers (издатель, поставщик информации), то есть некие переменные, которые могут изменять свое значение с течением времени,
- operators (операторы), которые преобразовывают данные и
- subscribers (подписчик), то есть некие переменные, которые эти обновленные данные будут получать.
1. Publishers
Самый простой пример publisher`а - это свойство помеченное @Publishedвнутри ViewModel: ObservableObject:
Простыми словами, Publisher - это некая переменная, которая с течением времени обновляет свое значение и позволяет отслеживать факт изменения своего значения.
2. Operators
Операторы - это методы, которые подписываются на publisher`а, получают данные, и предстают для дальнейшей передачи данных в виде как бы дочерних publisher`ов.
Операторы в коде фреймворка Combine выглядят точно также как модификаторы в SwiftUI, они назначаются элементу с синтаксисом через точку:
Операторы позволяют создать цепочку дочерних publisher`ов, которая заканчивается подписчиком (subscriber). То есть каждый следующий оператор подписывается на предыдущий.
В качестве примере можно рассмотреть оператор .sink(), который создаёт запрос на неограниченное количество обновлений значения publisher`а.
Полный список операторов, которые можно применять к publisher`ам можнопосмотреть по ссылке.
3. Subscribers
Publisher отправляет значения, а подписчик (subscriber) - это тот, кто работает с получаемыми значениями. Publisher не может отправлять значения, пока подписчик не прикрепится у нему и не отправит запрос на получение данных. Подписчик контролирует частоту отправки данных.
Возвращаясь к примеру с MVVM, когда мы создали свойство помеченное@Published внутри ViewModel: ObservableObject - мы создали Publisher'а. Далее в своем коде мы инициализируем экземпляр ViewModel, помечая его @ObservedObject или @StateObject и этим самым мы подписываемся на изменения свойств, обозначенных @Published:
Простыми словами, Subscriber - это некоторая структура или класс (в нашем примере ContentView), которая получает доступ к значениям publisher`а и отслеживает его изменения.
Пример из реальной жизни
По этой ссылке находится регулярно обновляемый json-файл, который содержит информацию о курсе валют центробанка. В нашем примере мы будем делать приложение с курсом валют.
Создаём проект приложения для iOS. Будем использовать MVVM (если не знаете о чем речь, можно посмотреть тут). Первое, что нам нужно, это модель данных.
1. Model
Если перейдём по нашей ссылке, то увидим, структуру данных:
"Date": "2024-06-05T11:30:00+03:00", "PreviousDate": "2024-06-04T11:30:00+03:00", "PreviousURL": "\/\/www.cbr-xml-daily.ru\/archive\/2024\/06\/04\/daily_json.js", "Timestamp": "2024-06-05T12:00:00+03:00", "Valute": { "AUD": { "ID": "R01010", "NumCode": "036", "CharCode": "AUD", "Nominal": 1, "Name": "Австралийский доллар", "Value": 59.1834, "Previous": 59.399 }... и так далее
Создаём новый Swift-файл, и делаем модель данных на основе структуры json-файла:
Лайфхак: чтобы вручную всё это не перепечатывать можно воспользоваться сайтомhttps://app.quicktype.io/, который как раз и делает модели из json-файлов. Просто копируете в него всё содержимое, задаёте язык и получаете результат.
Единственное что, обратите внимание: даты я задал как String (decoder не хочет их расшифровывать как Date), а свойство TimeStamp я переименовал в id, чтобы структура соответствовала протоколу Identifiable.
2. ViewModel
Основной файл, с которым мы будем работать, как вы понимаете, это ViewModel. Создаём новый Swift-файл. Имортируем фреймворк Combine:
Создаём ViewModel и делаем его (или её) ObservableObject.
Далее создаем @Published свойство, в котором мы будем хранить наши данные:
Далее создаем функцию getCurrencyData(link: String), которая будет:
- принимать на вход строку (нашу ссылку https://www.cbr-xml-daily.ru/daily_json.js),
- скачивать данные,
- проверять данные,
- расшифровывать их из json формата,
- сохранять в нашу переменную currencyInfo и
- делать сопутствующие Combine-дела 😀
Первое, что нужно, это преобразовать строку в URL:
Так как процесс конвертации всегда возвращает опционал, сразу обрабатываем его с помощью guard. Напомню функция работает до первого return, поэтому если извлечение опционала даст nil, то на этом выполнении функции getCurrencyData завершится.
Для скачивания json-файла используем метод URLSession.shared.dataTaskPublisher с инициализатором for:
- URLSession - это объект, который координирует выполнение заданий по передаче данных;
- shared - это singleton, который создан по умолчанию для выполнения задач по скачивания данных с заданного URL с использованием упрошенного синтаксиса.
- dataTaskPublisher - собственно сам метод, который не просто скачивает данные, но и создаёт publisher. Метод dataTaskPublisher автоматически ставиться в global() очередь и выполняется в фоновом потоке.
Метод dataTaskPublisher по результатам скачивания возвращает два значения: данные и ответ сервера:
Эти данные мы и будем обрабатывать далее с помощью методов применяемых к нашему publisher'у.
Передаём параметру for переменную url:
Итак, мы создали publisher'у, темперь будем применять к нему операторы. Напомню, каждый оператор как бы создает нового дочернего publisher'а.
Итак, мы создали publisher, темперь будем применять к нему операторы. Напомню, каждый оператор как бы создает нового дочернего publisher'а.
.receive:
выбираем инициализатор с параметром on и задаём очередь, в которой будет выполняться задача (main, так как мы будем использовать полученные данные для обновления интерфейса, а всё что обновляет интерфейс должно быть в главном потоке)
.tryMap:
Далее выбираем метод tryMap с инициализатором _ transform:
Что делает этот метод:
- он принимает данные и ответ сервера (то есть URLSession.DataTaskPublisher.Output) и возвращает задаваемый нами тип данных;
- метод помечен как throws означает, что он может выдать ошибку, собвственно этим tryMap и отличается от просто map, про который мы говорили в обьяснении, что такое операторы;
- также мы видим, что tryMap не имеет обработчика выполнения и возвращает данные. На принтскрине выше мы видим возвращаемое значение → T, что является «заглушкой» и мы сами должны назначить возвращаемый тип данных, и так как получаемые данные еще не декодированы из JSON, то мы указываем просто Data.
Поехали! Нажимает Enter на функцию и она превращается в замыкание. Далее мы должны дать собственные имена получаемым параметрам и возвращаемому значению, назовём их просто: data и response, а возвращаемому значению назначим тип Data :
Далее, в теле замыкания с помощью guard проверим значение ответа сервера и если он не успешный, то выведем информационное сообщение в консоль и выдадим ошибку, если ответ успешный метод возвращает только данные:
Для того, чтобы проверить ответ сервера нам нужно сначала преобразовать наш ответ в тип HTTPURLResponse. URLResponse - это родительский класс типа HTTPURLResponse, поэтому для преобразования можно использовать оператор as. Значение успешного ответа должно быть в диапазоне от 200 до 299, подробнее про коды состояния HTTP можно почитатьтут.
Мы обработали URLResponse и ошибки, осталось обработать сами данные.
.decode
Теперь нужно декодировать данные из json формата в созданный нами тип CurrencyModel. Для этого используем метод .decode:
В качестве типа, который мы хотим получить на выходе, мы указываем созданный нами тип CurrencyModel, а в качестве декодера - указываем JSONDecoder().
.sink
«sink» - переводиться как приём данных. Этот метод создаёт подписчика и запрос на неограниченное количество обновлений значения publisher`а, получает значения и предлагает их обработать задаваемым пользователем замыканием (обработчиком выполнения или Completion Handler).
Важно также отметить, что метод .sink имеет возвращаемое значение типа AnyCancellable.
Что такое AnyCancellable:
Иными словами, с помощью получаемого на выходе экземпляра мы можем отменять подписку.
Инициализируем оператор:
Оператор .sink имеет два параметра (оба обработчики выполнения - Completion Handlers):
- receiveCompletion - замыкание, которое принимает на вход сигнал о завершении отправки данных в виде отчета, успешна она прошла или нет. Вы должны обработать результат. Отчет (Subscribers.Completion) - это перечисление, которое может иметь только два значения: finished и failure;
- receiveValue - замыкание, которое принимает на вход передаваемые данные, а вы должны их применить в своей программе. Если вы не примените внутри замыкания данные, то подписка будет автоматически отменена.
Звучит сложнее, чем оно есть на самом деле, код получается совсем простой:
Так как (Subscribers.Completion) - это перечисление, обрабатываем его с помощью switch и просто указываем вывести в консоль информацию о результате выполнения.
Ну а с самими данными вы знаете, что делать: добавить их в наше свойство currencyInfo, которое и будет хранить данные о курсе валют:
Полностью код оператора sink будет выглядеть так:
Давайте еще раз проговорим, что сделал оператор sink:
- подписался на publisher'а типа Publisher.tryMap.decode, то есть на дочернего publisher'а от исходного URLSession.shared.dataTaskPublisher,
- получил на вход сигнал о завершении отправки данных,
- обработал сигнал о завершении отправки данных,
- получил на вход передаваемые данные (уже декодированные в формате CurrencyModel),
- обработал данные, то есть добавил в свойство currencyInfo,
- создал подписчика, а именно экземпляр класса AnyCancellable, который выполняет протокол Subscriber,
- создал запрос на неограниченное количество обновлений.
Если вы правильно разобрались в коде выше, то у вас должен был остаться только один вопрос: .sink создает подписчика и куда он девается? А вот и ответ:
.store
Этот оператор как раз и сохраняет нашего подписчика в переменную. Для этого нужно создать сначала эту переменную, единственное условие, она дожна представлять из себя множество (Set):
Далее сохраняем подписчика в наше множество подписчиков:
Ниже приводим полный код получившейся функции getCurrencyData, если вы где-то запутались:
Остаётся только передать её в инициализатор класса ViewModel:
3. View
Чтобы завершить приложение, давайте выведем на экран курс доллара:
Если вдруг не поняли цепочку:
- vm - это наша ViewModel,
- currenveInfo - это её единственное свойство, и оно массив,
- first - это мы обращаемся к первому (и единсвенному) элементу массива, который имеет тип CurrencyModel, который мы создавали в начале,
- valute - это свойство типа CurrencyModel, и оно словарь,
- ["USD"] - это мы обращаемся к элементу словаря по ключу ["USD"], а элементы словаря имеют тип Valute, которые содержат уже информацию а валюте,
- value - это текущее значение курса,
- description - это способ преобразовать Double в String
Получаем на экране приложения значение курса:
Мы рассмотрели с вами в первом приближении фреймворк Combine. Надеюсь наша статья и пример добавили вам информации, и время на чтение было потрачено продуктивно. Combine конечно намного шире приведенного нами примера, он позволяет создавать свои собственные publisher'ы и subscriber'ы, задавать параметры подписок, частоту получения данных и т.д. Но: на самом деле это фреймворк уже устарел 🤷🏻♂️, в 2021 был представлен Structured concurrency, который намного нагляднее, функциональнее и удобнее. Combine, конечно, нужно знать, но погружать в детали я бы советовал только в случае конкретных задач, конкретной работы.
Если понравилась статья - ставьте лайк, делитесь с друзьями! Кстати, у нас есть телеграмм-канал, Дзен и группа ВК, будем благодарны за подписку.