Внедрения реактива в архитектуру iOS приложений

в 17:40, , рубрики: ios development, os x development, reactive programming, reactivecocoa, swift, Проектирование и рефакторинг, разработка под iOS, Разработка под OS X

Большинство статей о функционально-реактивном программировании ограничиваются демонстрацией возможностей определенного инструмента в определенной задачи и не дают понимания как использовать всю мощь в рамках целого проекта.

Хотелось бы поделиться опытом проектирования с использованием функционально-реактивного программирования под iOS. Это не зависит от выбранного инструмента, будь то RAC, RxSwift, Interstellar или же что-то еще. Так же это применимо при разработке под MacOS.

В определенных моментах я буду писать, используя Swift + RAC4, поскольку это мои основные инструменты на данный момент. Однако, я не буду использовать в статье терминологию и особенности RAC4.

Может быть вы зря отказывались от реактивного программирования и пора начать его использовать?

Для начала коротко о мифах среди людей, которые о реактиве только слышали и слышали не самое хорошее:

Миф 1 — порог вхождения в реактивное программирование слишком велик

Никто не говорит, что вам надо с первых минут использовать все доступные возможности. Вам нужно всего лишь понять концепцию и основные базовые операторы (ниже я напишу о 4х минимально необходимых операторах, а к остальным вы будете приходить по мере решения различного рода задач).
На это не надо тратить месяцы и года, достаточно нескольких дней/недель (в зависимости от имеющегося бэкграунда), а при наличии опытной команды вхождение будет намного быстрее.

Миф 2 — реактив используется только в UI слое

Реактив удобно использовать в бизнес логике и ниже я покажу, что мы от этого получим.

Миф 3 — реактивный код очень сложно читать и разбирать.

Все сильно зависит от уровня написанного кода. При должном разделении системы это повышает понимание кода.
Более того, это не сильно сложнее, чем использование множества калбеков.
А написать нечитаемый код можно практически всегда.

Концепция реактива

Процесс написания реактивного кода похож на детскую игру в ручеек. Мы создаем путь для воды и только потом запускаем воду. Водой в нашем случае являются вычисления. Сетевой запрос, получение данных из базы, получение координат и многие другие вещи. Путь для воды в данном случае — это сигналы.

Итак, сигнал — это основной строительный блок, содержащий некие вычисления. Над сигналами в свою очередь могут быть совершены определенные операции. Применяя операцию к сигналу, мы получаем новый сигнал, включающий в себя предыдущие конфигурации.

Применяя операции над сигналами и комбинируя с другими сигналами, мы создаем поток данных для вычислений (dataflow). Весь этот поток начинает свое выполнение в момент подписки на сигнал, что похоже на ленивые вычисления. Это дает возможность более тонко контролировать момент старта выполнения и последующие действия. Наш код разделяется на логические части (что увеличивает читаемость), а мы получаем возможности создавать новые «методы» буквально «налету», что повышает переиспользуемость кода в системе. Похоже на функции высшего порядка, не правда ли?

Минимально необходимыми операциями для конфигурирования dataflow на первое время следует выделить map, filter, flatMap и combineLatest.
И напоследок небольшая особенность, dataflow — это данные + ошибки, что дает возможность описывать последовательность действий в 2х направлениях.

Это минимум необходимой теории.

Реактив и модульная архитектура

Я являюсь сторонником SOA, поэтому хотелось бы рассказать о пользе реактива именно в модульной архитектуре. Но разумеется, это никак не ограничивает вас.

Внедрения реактива в архитектуру iOS приложений - 1

Используемая схема может отличаться от других или быть такой же, я не претендую на эталон, как и не претендую на универсальность. В большинстве наших задач мы придерживаемся данного решения, скрывая за сервисом обработку данных (и это вовсе не обязательно должны быть сетевые запросы).

Транспорт

Итак, это наш первый претендент на реактивность. Поэтому я остановлюсь на этом месте более подробно.
Для начала посмотрим на типичные решения данной задачи:

Использование 2х калбеков

    typealias EmptyClosure = () -> ()

    func getReguestJSON(urlPath: String, parameters: [String : AnyObject]?, success: EmptyClosure, failed: EmptyClosure) -> NSURLSessionTask

Использование 1го калбека

    typealias Response = (data: NSData?, code: Int)
    typealias Result = (response: Response, failed: NSError?)
    
    func getReguestJSON(urlPath: String, parameters: [String : AnyObject]?, result: Result) -> NSURLSessionTask

Оба решения имеют свои достоинства и недостатки. Рассмотрим оба решения в рамках задачи: показать лоадер на время выполнения сетевого запроса.
В первом случае мы четко разделяем действия на успех и неудачу действия, однако мы будем дублировать код, сообщающий о завершении действия.
Во втором же случае дублировать код нам не надо, однако мы смешиваем все в одну кучу.

А еще нужна возможность отменить сетевой запрос, да и + нужно бы инкапсулировать работу Транспорта.
Скорее всего, в этом случае наш код будет выглядеть примерно

вот так:

class Disposable {
    private var action: EmptyClosure?
    
    init(action: EmptyClosure?) {
        self.action = action
    }
    
    func dispose() {
        action?()
        action = nil
    }
}

    typealias Response = (data: NSData?, code: Int)
    typealias Result = (response: Response, failed: NSError?)
    
    func getReguestJSON(urlPath: String, parameters: [String : AnyObject]?, result: Result) -> Disposable?
...
...
...    
    func getReguestJSON(urlPath: String, parameters: [String : AnyObject]?, success: EmptyClosure, failed: EmptyClosure) -> Disposable?

А теперь посмотрим на решение с использованием сигналов

    func getRequestJSON(urlPath: String, parameters: [String : String]) -> SignalProducer<Response, NSError> {
        return SignalProducer<Response, NSError> { observer, disposable in
            let task = ... {
                observer.sendNext(data: data, code: code) 
                observer.sendCompleted()
                
                //or observer.sendFailed(error)
            }
            
            disposable.addDisposable {
                task.cancel()
            }
        }
    }

Ранее я сознательно упустил один важный момент — при создании сигнала мы не только пишем что выполнить при подписке на сигнала, но и что делать при отмене сигнала.
Подписка на сигнал возвращает экземпляр класса Disposable (не написанный нами выше, поболее), который позволяет отменить сигнал.

Пример кодом

    let disposable = getRequestJSON(url, parameters: parameters) //создали сигнал
        .startWithNext { data, code in
            ...
            ...
            ...
    } //с момента вызова startWithNext начался выполнятся сигнал
    disposable.dispose() //отменяем выполнение сигнала

Теперь вызываемая сторона может запросто отложить выполнение запроса, объединить результат с другими запросами, написать некоторые действия на события сигнала (из примера выше на завершение запроса), а так же что делать при получении данных и что делать при возникновении ошибки.

Но перед демонстрацией такого кода, я бы хотел рассказать о таком понятии, как

Side Effect

Если даже вы не сталкивались с этим понятием, то 100% его наблюдали (либо вы сюда заглянули случайно).
Простым языком — это когда наш поток вычислений зависит от окружающей его среды и меняет ее.

Внедрения реактива в архитектуру iOS приложений - 2

Мы стараемся писать сигналы, как отдельную деталь кода, тем самым повышая ее возможную переиспользуемость.
Однако сайд эффекты порой необходимы и ничего ужасного в этом нет. Рассмотрим на рисунке, как мы можем использовать Side Effect в реактивном программировании:

Внедрения реактива в архитектуру iOS приложений - 3

Весьма просто, не правда ли? Мы вклиниваемся между выполнениями сигналов и совершаем определенные действия. По сути, мы выполняем действия на определенные события сигнала. Но при этом мы сохраняем сигналы все так же чистыми и готовыми для переиспользования.

Например, из ранее созданной задачи: «Показать лоадер при старте сигнала и убрать при завершении».

Парсинг

Вспомним типичную ситуацию — данные от сервера либо пришли в верном, либо в не верном формате. Варианты решений:
1) Калбеки «данные + ошибка»
2) Подход Apple, используя NSError + &
3) try-catch

А что нам может дать реактив?
Создадим сигнал, в котором будем парсить ответ от сервера и выдавать результат в определенных событиях (next/failed).
Использование сигнала даст возможность более явно видеть работу кода + объединить работу с сигналом сетевого запроса. Но стоит ли?

Пример

class ArticleSerializer {
    
    func deserializeArticles(data: NSData?, code: Int) -> SignalProducer<[Article], NSError> {
        return SignalProducer<[Article], NSError> { observer, _ in
            ...
            ...
            ...
        }
    }

Сервисы

Объединим сетевой запрос, парсинг и добавим возможность сохранить результат парсинга в DAO.

пример кода

class ArticleService {
    ...
    ...
    ...
    func downloadArticles() -> SignalProducer<[Article], NSError> {
        let url = resources.articlesPath
        let request = transport.getRequestJSON(url, parameters: nil)
            .flatMap(.Latest) { [unowned self] data, code in
                return self.serializer.deserializeArticles(data, code: code)
            }.onNext { [unowned self] articles in
                self.dao.save(articles)
        }
        return request
    }

Нет вложенности, все очень просто и легко читается. Вообще весьма последовательный код, не правда ли? И он сохранится таким же простым, даже если сигналы будут выполнятся на разных потоках. Кстати, рассмотрим использование combineLatest:

Синхронизация параллельных запросов

userService.downloadRelationshipd() //сигнал с сетевым запросом
	.combineLatestWith(inviteService.downloadInvitation()) //сигнал с сетевым запросом + запустить параллельно
	.observeOn(UIScheduler()) //результат сигналов вернуть на главный поток (неважно на каком будут выполнятся)
	.startWithNext { users, invitations in
		//работаем с результатом операций
	}

При этом стоит заметить, что код, написанный выше, не начнет выполнятся, пока его не запустят, подписавшись на сигнал. Фактически мы лишь указываем действия.

И теперь сервисы стали еще более прозрачными. Они всего лишь связывают между собой части бизнес логики (включая другие сервисы) и возвращают dataflow этих связей. А человек, использующий полученный сигнал, может очень быстро добавлять реакцию на события или объединять с другими сигналами.

А еще...

Но все это было бы не так интересно, если бы не множество операций над сигналами.
Устанавливать задержку, повторять результат, различные системы комбинирования, установка получения результата на определенный поток, свертки, генераторы… Вы только посмотрите на этот список.

Почему я не стал рассказывать о работе с UI и биндинге, который для многих самый «сок»?
Это тема на отдельную статью, а их и так достаточно много, поэтому я просто приведу пару ссылок и закончу

Лучший мир с ReactiveCocoa
ReactiveCocoa. Concurrency. Multithreading

На этом у меня все. Вместо бесполезного заключения я оставлю несколько практических выводов из последнего проекта:
1) Получение Permission неплохо себя зарекомендовало в качестве сигналов.
2) CLLocationManager отлично повел себя с сигналами. Особенно накопление и редактирование точек.
3) Так же удобно было работать с сигналами на такие действия как: выбор фотографии, отправка SMS и отправка email.

Автор: ajjnix

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js