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

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

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

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

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

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


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

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


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

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


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

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


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

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

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

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

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

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


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



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

Транспорт


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

Использование 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% его наблюдали (либо вы сюда заглянули случайно).
Простым языком — это когда наш поток вычислений зависит от окружающей его среды и меняет ее.



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



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

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

Парсинг



Вспомним типичную ситуацию — данные от сервера либо пришли в верном, либо в не верном формате. Варианты решений:
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.
Поделиться с друзьями
-->

Комментарии (6)


  1. forceLain
    14.06.2016 06:18

    В статье про основы «реактива», мне кажется стоит отдельно упомянуть, что реактив имеет несколько важных контрактов, которые нельзя нарушать:

    • поток заканчивается или с onComplete или с onError, но никак не с двумя одновременно
    • onComplete или onError действительно прекращают поток и после их вызова нельзя эмитить другие элементы

    Для кого-то это может показаться очевидным, однако, если создавать потоки самостоятельно или использовать Subject-ы, этот контракт легко можно сломать. А от сломанного контракта станут неправильно работать многие стандартные операторы типа concat, toList, last и много других.


  1. SparkLone
    14.06.2016 20:02

    Спасибо за статью.
    Я пока подзаморозил написание своей по применению RxSwift, хоть она уже и была написана на половину, т.к. столкнулся с вопросами на которые у меня у самого нет «красивых» ответов.
    Т.к. RxSwift, RAC — это реализации идеи функционального программирования — разумно использовать их мощь на полную.
    Я же в итоге пришел к тому, что Rx отлично вписывается в биндинг с UI, но постоянно попадаются подводные камни.

    Главная загвоздка в том, что идея реактивного программирования является — отсутствие состояния (в идеале). Отсюда идут настоятельные рекомендации подписываться на Observable только из UI (соответственно disposeBag должен быть только в ViewController'ах), т.к. подписки автоматически освободятся когда сменится экран. Т.е. та же ViewModel не должна иметь состояния.

    Классическая схема MVVM если один контроллер создает другой
    ParentViewController -> ParentViewModel -> ParentModel
    ChildViewController -> ChildViewModel -> ChildModel

    Причем ViewController знает о ViewModel, но не создает. А ParentViewModel — создает СhildViewModel
    На все подписки по хорошему мы должны подписываться только в View
    Ну и использовать переменные крайне не рекомендуется, только Observable только хардкоръ.

    Но рассмотрим простую ситуацию.
    Есть ViewController содержащий элементы логина + TableViewController (посредством контейнера).
    Опять таки, по канонам ViewModel для TableViewController должен создавать ViewControllerModel.

    ViewControllerViewModel когфигурируется из UI

    class ViewControllerViewModel {
    	func configure(login login: Observable<String>, password: Observable<String>, moduleId: Observable<String>, buttonParse: Observable<Void>) {
    		...
    		// внутри после всех проверок логина на пустоту и прочего в итоге мы получаем что то вроде этого
    
    		let items: Observable<[DownloadTaskModel]> = parseState
    		            .flatMapLatest{ (state) -> Observable<[DownloadTaskModel]> in
    		                if case .Success(let data) = state {
    		                    return Observable.just(data)
    		                } else {
    		                    return Observable.just([])
    		                }
    		            }.startWith([])
    		        
    		// и тут нам надо передать данные для TableViewModel, и мы не можем подписываться отсюда ни на что, т.к. мы внутри viewModel. Можно вытащить observable наружу, чтобы ViewController этой ViewModel "впустую" на него подписался, но это грабли.
    		        tableViewModel.configure(items)
    
    	}
    }
    


    У TableViewController же получается отрабатывает viewDidLoad раньше чем у его родителя ViewController, поэтому к моменту загрузки таблицы — у нас еще нет для нее данных, и приходится опять таки городить грабли
    
    class TableViewControllerViewModel: NSObject, TableViewControllerViewModelProtocol {
        var session: NSURLSessionProtocol
        
        var disposeBag = DisposeBag()
        
        var dataSourceItems: Driver<[NumberSection]>! {
            didSet {
                completeConfigure.onNext()
            }
        }
        
        let completeConfigure: PublishSubject<Void> = PublishSubject()
        
        init(session: NSURLSessionProtocol = SessionFactory.sharedInstance.backgroundSession()) {
            self.session = session
            super.init()
        }
        
        func configure(items: Observable<[DownloadTaskModel]>) {
            
            dataSourceItems = items.asObservable().doOnNext{ [unowned self] _ in
                self.disposeBag = DisposeBag()
            }
                .map{[unowned self] models -> [NumberSection] in
                    let protocols = models.map{ model -> DownloadableTaskViewModelProtocolThing in
                        let downloadDelegate = Downloader(session: self.session)
                        downloadDelegate.update.subscribe().addDisposableTo(self.disposeBag)
                        let cellViewModel = DownloadTaskViewModel(model: model, downloadDelegate: downloadDelegate)
                        downloadDelegate.updateStatusIfAlreadyDownloaded()
                        return DownloadableTaskViewModelProtocolThing(thing: cellViewModel)
                    }
    
                }.asDriver(onErrorJustReturn: [])
        }
    }
    


    В TableViewController приходится подписываться на completeConfigure, и только после получения сигнала — конфигурировать dataSource для таблицы.

    Есть альтернатива — делать dataSourceItems как переменную, изначально пустую и в configure — присваивать значение этой переменной.
    Но я перечитал все issue где разработчик RxSwift отвечал на вопросы, и он черным по белому говорил — если вам приходится использовать переменную, либо вы что то делаете неправильно, либо у вас нет другого выхода. ViewModel не должна иметь состояния.
    И несколько устаешь каждый раз думать первый это случай или второй.

    Так же мне очень не нравится, что внутри ViewModel есть DisposeBag. А нужен он потому что нам надо подписываться на Observable update контроллера по закачке. Пробрасывать в TableViewController не получится, т.к. при каждом переконфигурировании данных для таблицы надо старую подписку сбрасывать.

    Второй пример.
    Есть DownloadController, его задача реагировать на нажатие кнопки у закачки соответственно,

    В классическом варианте мы имеем что то вроде такого
    
    class DownloadController {
    ...
    func download() {
        switch model.status {
        case .Progress, .Wait:
            model.status = networkTaskManager.pause()
        case .Stopped, .Error:
            model.status = .Wait
            let task = session.downloadTaskWithURL(assetUrl())
            networkTaskManager.startPrepareTask(task)
        case .Paused:
            if let data = networkTaskManager.resumeData {
                model.status = .Wait
                let task = session.downloadTaskWithResumeData(data)
                networkTaskManager.startTask(task)
            } else {
                let task = session.downloadTaskWithURL(assetUrl())
                networkTaskManager.startTask(task)
            }
    }
    }
    


    Model имеет биндинги, поэтому мы спокойно обновляем ее состояние, которое пробрасывается в ViewModel.

    Когда я попробовал избавиться от состояния, то столкнулся с тем что код лишь усложнился. Как видно изменение статуса — не только результат операции — но и может меняться перед и во время операции, поэтому просто представить это как dataFlow уже не получается. Приходится делать постоянные сайд эффекты .doOnNext{ model.status =… } что снова таки по словам разработчика RxSwift — ugly hack, применение которого означает что вы что то делаете не так.
    И в итоге получилось лишь усложнение.

    Были еще моменты и не мало. Написал то что сразу в памяти всплыло.
    Я скорее всего просто не умею готовить правильно FRP, признаю. Наверное это и есть steep learning curve. Но когда я понял, что я трачу 80% времени на то чтобы сделать «правильно по канонам FRP» вместо того чтобы писать полезный код, я отступил назад.
    В итоге вернулся на шаг назад, — сделал самописный Bind, для простых задач связать Model->ViewModel->ViewController его хватает. Я конечно потерял много плюшек от RxSwift. Но признаться меня еще как то внезапно напрягло то, что это все таки не пара классов, это фреймворк, который пока мало кто использует. Как и саму парадигму FRP.
    Т.к. в данный момент я лишь планирую выйти на рынок как фрилансер наверное было бы глупо уходить с головой в FRP, сомневаюсь что это нужно на небольших проектах, а как обьяснить клиенту, зачем я буду подключать ему целый фреймворк я пока не знаю.
    Так что я пока взял паузу в наших отношениях с RxSwift.
    При этом я ценю идею immutable заложенную в основу FRP, и данные сейчас стараюсь храни


    1. ajjnix
      15.06.2016 09:36

      огромный комментарий :D

      1) По мне абсолютно нормальная практика когда ViewModel имеет переменные (уже как минимум если используем биндинги), хотя бы на уровне PropertyType. Потому что это ViewModel и это его задача прямая взять на себя работу с данными для экрана. Либо мы говорим о разных понятиях под ViewModel.
      Взаимодействие Controller — ViewModel будет работать как раз на сигналах, ViewModel будет иметь сигналы, в которые будет сообщать о ситуациях нужных для контроллера. Либо создавать и отдавать их обратно контроллеру на действия, в зависимости от ситуации.
      Конечно, можно разнести все такие сигналы в PropertyType, но жизнь от этого проще не станет.

      2) О создании, кто что создает… намного лучше взять DI фреймворк (Swinject к примеру)

      3) В чем проблема от использования структур и кордаты? Это ведь совершенно разные вещи и между ними взаимодействие только на уровне DAO должно осуществляться. Более того, используя структуру мы можем забить на подход с использованием линз тк это копируемый тип и оставить поля в структурах как мутабельные.

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

      Controller — работа с UI + взаимодействие с ViewModel (подписки на сигналы и получение сигналов для действий)
      —— ниже больше никто не работает с UI компонентами и работают только с данными.
      ViewModel — подготовка данных для Controller + взаимодействие с Service
      Service — инкапсуляция работы, не связанная с экранами на прямую. Прячем сюда всю черную работу попутно убирая возможный повтор действий.
      —— Service взаимодействует с различными компонентами системы, в зависимости от ситуации.

      p.s. реактив должен облегчать работу, а не заставить отказаться от парадигмы вашего языка. К примеру попытка сделать абсолютно все immutable.
      p.p.s. даже в функциональных языках существуют монады.


      1. SparkLone
        15.06.2016 16:26

        Да уж, не маленький, накипело ))
        1) под переменной подразумевается Variable из RxSwift, класс позволяющий напрямую засунуть в нее данные, не из потока Rx. Понятно что по сути все переменные Observable динамически меняют данные в зависимости от того что на входах к которым они подсоединены, но это вписывается в dataFlow. А тут как бы точка выхода в обычный мир, не реактивный.
        2) Да я понимаю, что для всего есть фреймворки. Но изначально хотелось понаступать в pet проекте на все грабельки, чтобы собрать опыт и потом знать чего не хватает для полного счастья.
        3) Проблема не столько из за использования структур, сколько от в принципе попытки делать обертку над NSManagedObject. Проблемы когда у NSManagedObject'ов есть связи, их приходится оставлять в виде objectid, грубо говоря не deep копирование. Иначе при создании одного объекта — он рекурсивно начнет создавать все до чего дотянется. В CoreData то используется Faulting.

        В Service передаете те же data модели что и во viewModel, или вы конвертируете их в облегченные, чтобы не тащить все?
        Между Model и ViewModel есть биндинги? Соответственно когда Service что то меняет — он отдает это в Rx виде или просто кусок данных? Или меняет model внутри себя, а модель благодаря биндингам уведомляет всех включая viewModel?
        Если через биндинги, то на каком этапе фильтруете данные? Если Service что то хочет поменять в Model, но эти данные не валидны — он сам это определяет и в model всегда корректные данные, или один из подписчиков на model, какой нибудь валидатор это разруливает?

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


        1. ajjnix
          17.06.2016 12:05

          Даже если все сделать на переменных (Variable, PropertyType), то в них всеравно придется биндить сигналы (не скажу в этом случае за rx, но в rac это создаст Disposable и его нужно будет положить в composite (bag)). И всеравно ViewModel будет иметь по этому bag, по этому не вижу ничего плохого если будет подписка на сигналы не только во ViewController.
          Кстати, если все перевести в такие переменные, сложно сопоставлять будет действия когда придет новый человек. А вот если сервисы выдают сигналы и на них подписываются из Presentation (ViewModel к примеру и потом выдает результат по необходимости в Controller на обновления) то сложного ничего нет, потому что разделяются обязанности: сервис сконфигурировал, ViewModel подготовил данные и обновился Controller

          Биндинга между моделью и вьюмоделью не делаем, сервис выдает структуры, по этому сохраняется иммутабельность данных.
          Если есть к примеру необходимость при переходе на экран всегда показывать актуальные данные, значит есть смысл каждый раз при показе экрана эти данные просить у сервиса из «кэшируемых». Это накладывает свои ограничения, но явно показывает принцип работы этого экрана. К тому же пропадает расхождение между хранимыми и показываемыми данными (если разумеется их показывают «как есть»).
          Опять же, если полученные данные из сервиса изменить, это будет только локальным изменением.


          1. SparkLone
            17.06.2016 21:13

            Ну у меня была задача обновлять процент скачанного в прогресс барах как основного контроллера так и в отдельном экране, поэтому сделал модель структурой, положил ее в в Variable, и в сервисе отдавал именно в обертке. При изменении сервисом процента в структуре — viewModel подписанные на изменение Variable — обновляет все свои переменные, которые по биндингам уходят во ViewController.
            Это позволило оставить модель «тупой».
            Из минусов подхода.
            Если модель будет классом — придется после каждого изменения в сервисе делать updateModel (переприсваивать саму себя в переменную)
            Если модель будет структурой — изменение любого свойства — автоматом обновит всю модель о чем узнает ViewModel. Но если надо поменять последовательно 10 свойств — каждое изменение будет обновлять всю модель. В качестве обходного пути — сначала достать структуру из переменной, обновить все в ней — и только потом присвоить обратно.

            Кстати не заметно просадки по производительности при использовании RAC? И сколько добавляет в весе программы использование этого фреймворка?