[Перевод] Мое знакомство с ReactiveCocoa

Честно говоря, я начала использовать ReactiveCocoa, потому что это модно. Я слышу как iOS разработчики говорят об этом фреймворке все время, и я едва могу вспомнить iOS Meetup без упоминание ReactiveCocoa.

image

Когда я только начала изучать ReactiveCocoa я не знала что это такое. «Реактивный» звучит действительно здорово, и «функциональный» звучит умно. Но после того как я поддалась искушению овладеть Reactive Cocoa я уже не могу себе представить написания кода без его использования.

ReactiveCocoa это фреймворк, который открывает окно в мир функционально реактивного программирования. Он позволяет извлечь выгоду из практического применения этой парадигмы, даже не требуя глубоких теоретических знаний FRP.

Я овладела ReactiveCocoa на реальном проекте, делая некоторые простые вещи, сначала я использовала его для решения двух проблем, о которых я расскажу вам, в этой статье. Я буду говорить «что делать», а не «как сделать», чтобы вы могли получить практическое понимание фреймворка.

1. Связи


Знакосмтво с ReactiveCocoa обычно начинается со связей. В конце концов, они являются самой простой вещью, которую можно понять новичку.

Связи сами по себе являются лишь дополнением к существующему механизму KVO в Objective-C. Есть что-нибудь новое, что ReactiveCocoa приносит в KVO? Это более удобный интерфейс, так же добавляет способность, описать правила связывания состояния модели и состояние на UI в декларативном стиле.

Давайте посмотрим на связи на примере ячейки таблицы.

Обычно ячейка привязывается к модели и отображает ее визуальное состояние (или состояние ViewModel для адептов MVVM). Хотя, ReactiveCocoa часто рассматриваются в едином контексте с MVVM и наоборот, это на самом деле не имеет значения. Связи — это просто способ сделать вашу жизнь проще.

- (void)awakeFromNib {
   [super awakeFromNib];
   RAC(self, titleLabel.text) = RACObserve(self, model.title);
}

Это декларативный стиль. «Я хочу, чтобы текст моей метке всегда равнялся значению Title моей модели» — в методе -awakeFromNib. На самом деле не имеет значения, когда title или модель изменяется.

Когда мы посмотрим на то, как это работает внутри, мы обнаружим, что RACObserve является макросом, который принимает путь (»mode.title» из объекта self в нашем случае) и преобразует его в RACSignal. RACSignal является объектом фреймворка ReactiveCocoa, который представляет и обеспечивает будущие данные. В нашем примере, он будет доставлять данные из »model.title» каждый раз, когда title или модель изменяется.

Мы будем говорить о сигналах чуть позже, так как нет необходимости вдаваться в подробности на этом этапе. В настоящее время, вы можете просто «связать» состояние модели с интерфейсом и наслаждаться результатом.

Довольно часто вы должны будете трансформировать состояние модели для отображения ее состояния на UI. В этом случае вы можете использовать оператор -map:

RAC(self, titleLable.text) = [RACObserve(self, model.title) map:^id(NSString *text) {
   return [NSString stringWithFormat:@”title: %@”, text];
}]

Все операции с UI должны быть выполнены в основном потоке. Но, например, поле title может быть изменено в фоновом потоке (т.е. при обработке данных). Вот то, что вам нужно добавить для того, чтобы новое значение title было доставлено абоненту на главном потоке:
RAC(self, titleLabel.text) = [RACObserve(self, model.title) deliverOnMainThread];

RACObserve это расширеный макрос -rac_valuesForKeyPath: observer: Но вот уловка — этот макрос всегда захватывает self в качестве наблюдателя. Если вы используете RACObserve внутри блока, вы должны убедиться, что вы не создаете цикличность ссылок и используете слабую ссылку. ReactiveCocoa имеет удобные макросы @weakify и @strongify для этих нужд.

Еще одна деталь о которой нужно предупредить о связях — это случай, когда ваше состояние модели привязано к некоторым существенным изменениям пользовательского интерфейса, а также к частым изменениям состояния модели. Это может негативно повлиять на производительность приложения и, чтобы избежать этого вы можете использовать оператор -throttle:  — он принимает NSTimeInterval и посылает команду «next» абоненту после заданного интервала времени.

2. Операции над коллекциями (filter, map, reduce)


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

Необработанные данные из сети должны быть преобразованы в обьект или View Models, и отображены пользователю.

В ReactiveCocoa, коллекции представлены как класс RACSequence. Есть категории для всех типов Cocoa коллекций, которые преобразуют Cocoa коллекции в коллекции ReactiveCocoa. После этих преобразований, вы получите несколько функциональных методов, такие как map, filter и reduce.

Вот небольшой пример:

 RACSequence *sequence = [[[matchesViewModels rac_sequence] filter:^BOOL(MatchViewModel *match) {
    return [match hasMessages];
 }] map:^id(MatchViewModel *match) {
    return match.chatViewModel;
 }];

Во-первых, мы фильтруем наши view models, чтобы выбрать те, которые уже имеют сообщения ( — (BOOL)hasMessages). После чего мы должны превратить их в другие view models.

После того как вы закончили с последовательностю, она может быть преобразована обратно в NSArray:

NSArray *chatsViewModels = [sequence array];

Вы заметили, что мы снова используем оператор -map: ? На этот раз, хотя, это и относится к RACSequence, а не RACSignal, как это было со связями.

Самым замечательным в архитектуре RAC является то, что она имеет только два основных класса — RACSignal и RACSequence, которые имеют одного родителя — RACStream. Все поток, а сигнал является толчком приводящи поток в движение (новые значения выталкиваются к подписчикам и не могут быть выведены), а последовательность является выдвижным приводом потока (обеспечивает значения, когда кто-то о них просит).

Еще одна вещь, которую стоит отметить, это как мы связываем операции вместе. Это ключевое понятие в RAC, которое также применяется в RACSignal и RACSequence.

3. Работа с сетью


Следующим шагом в понимании особенностей фреймворка, это использование его для работы в сети. Когда я говорила о связях, я упомянула, что маркос RACObserve создает RACSignal, который представляет данные, которые будут доставлены в будущем. Этот объект идеально подходит для представления сетевого запроса.

Сигналы отправляют три типа событий:

  • next — будущее значение/значения;
  • error — Значение NSError*, что означает, что сигнал не может быть успешно завершен;
  • completed — означает, что сигнал был успешно завершен.

Срок службы сигнала состоит из любого числа next событий, а затем одного error или completed (но не обоих).

Это очень похоже на то, как мы писали наши сетевые запросы, используя блоки. Но в чем разница? Зачем заменять обычные блоки сигналами? Вот некоторые причины:

1) Вы избавляетесь от обратного вызова!

Этот кошмар в коде происходит, когда у вас есть несколько вложенных запросов и каждый последующи использует результат предыдущего.

2) Вы обрабатываете ошибку в одном месте.
Вот небольшой пример:

Предположим, у вас есть два сигнала  — loginUser и fetchUserInfo. Давайте создадим сигнал, который «логинет» пользователя, и затем получает его данные:

RACSignal *signal = [[networkClient loginUser] flattenMap:^RACStream *(User *user) {
   return [networkClient fetchUserInfo:user];
}];

Блок flattenMap будет вызываться, когда сигнал loginUser посылает событие next, и это значение переходит к блоку через параметр user. В блоке flattenMap мы берем это значение из предыдущего сигнала и производим новый сигнал в качестве результата. Теперь, давайте подпишемся на этот сигнал:
[signal subscribeError:^(NSError *error) {
 // error from one of the signals 
 } completed:^{
 // side effects goes here. block get called when both signals completed
 }];

Стоит отметить, что блок subscribeError будет вызываться в том случае если по крайней мере один из сигналов не сработает. Если первый сигнал завершается с ошибкой, второй сигнал не выполниться.

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

[[RACSignal createSignal:^RACDisposable *(id subscriber) {
 __block NSURLSessionDataTask *task = [self GET:url parameters:parameters completion:^(id response, NSError *error) {
   if (!error) {
       [subscriber sendNext:response];
       [subscriber sendCompleted];
   } else {
       [subscriber sendError:error];
   }
 }];

 return [RACDisposable disposableWithBlock:^{
    [task cancel];
 }];
}]];

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

Ссылкой на сигнал можно определить, когда он должен быть отменен:

[[networkClient loadChats] takeUntil:self.rac_willDeallocSignal];

или 
[[networkClient loadChats] takeUntil:[self.cancelButton rac_signalForControlEvents:UIControlEventTouchUpInside]];

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

Конечно, вы также можете отменить сигнал вручную — просто хранить ссылку на объект RACDisposable (который возвращается из метода subsribeNext/Error/Completed) и вызвать непосредственно метод -dispose, когда есть такая необходимость.

Реализация сетевого клиента с использованием сигналов является довольно обширной темой для обсуждения. Вы можете посмотреть на OctoKit — отличный пример того, как использоваться Reactive Cocoa для решения сетевых вопросов. Ash Furrow, также покрыл эту тему в своей книге Функциональное реактивное программирование под iOS.

4. Сигналы в действии


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

Когда мы сформулировали приблизительную цепочку действий, которые должны быть завершены, мы начинаем писать код, и различные части класса или даже несколько классов загрязняются новыми строками кода, операторы if, бесполезные состояния, которые «бродят» вокруг нашего проекта как цыганские караваны.

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

Через некоторое время работы с Reactive Cocoa, ко мне пришло понимание, что основое решения всех упомянутых выше задач (связывание, операции над колекциями, работа с сетью) представляет жизненный цикл приложения в качестве потока данных (RACStream). Затем данные, поступающие от пользователя или с сети должны быть преобразованы определенным способом. Оказывается можно решить поставленные задачи гораздо проще!

Давайте рассмотрим два примера.

Задача #1
Это пример из реального проекта, который мы недавно закончили.

У нас была возможность обмена сообщениями и одной из задач было отобразить правильное количество непрочитах сообщений на иконке приложения. Обычная задача, не так ли?

У нас был класс ChatViewModel с которых хранил логическое свойство unread.

@interface ChatViewModel : NSObject

@property(nonatomic, readonly) BOOL unread

// other public declarations

@end

И где-то в коде, мы имели массив dataSourc, содержащий эти view models.

Что мы хотим сделать? Мы хотим обновлять количество непрочитаных сообщений каждый раз когда меняется unread свойство. Количество елементов должно равняться количеству значений YES, во всех моделях. Давайте сделаем такую выобрку с помощью сигнала:

RACSignal *unreadStatusChanged = [[RACObserve(self, dataSource) map:^id(NSArray *models) {
   RACSequence *sequence = [[models rac_sequence] map:^id(ChatViewModel *model) {
      return RACObserve(model, unread);
 }];

   return [[RACSignal combineLatest:sequence] map:^id(RACTuple *unreadStatuses) {
   return [unreadStatuses.rac_sequence foldLeftWithStart:@0 reduce:^id(NSNumber *accumulator,    NSNumber *unreadStatus) {
   return @(accumulator.integerValue + unreadStatus.integerValue);
 }];
 }];
}] switchToLatest];

Это может выглядеть немного сложно для новичков, но это довольно легко понять.

Во-первых, мы наблюдаем за изменениями в массиве:

RACObserve(self, dataSource)

Это важно, потому что предполагается, что могут быть созданные новые чаты, и старые могут быть удалены. Так RAC нету KVO для изменяемых колекций, DataSource является неизменяемы массивом каждый раз, когда объект добавлен/удален из/в dataSource. RACObserv вернет сигнал, который будет возращать новый массив каждый раз, когда в dataSource будет добавленно новое значение.

Хорошо, мы получили сигнал… Но это не тот сигнал, который мы хотели, таким образом, мы должны преобразовать его. Оператор -map: прекрасно подойдет для этой задачи.

[RACObserve(self, dataSource) map:^id(NSArray *models) {
}]

Мы получили множество моделей в блоке map. Так как мы хотим знать о каждом изменении свойства unread всех моделий, кажется, что мы все еще нуждаемся в сигнале, или даже массиве сигналов — один сигнал для каждой модели:
 RACSequence *sequence = [[models rac_sequence] map:^id(ChatViewModel *model) {
    return RACObserve(model, unread);
 }];

Ничего нового здесь нет. RACSequence, map, RACObserve.

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

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

+merge, будет пересылать значения от наших сигналов в единый поток. Это точно не соответствует нашим потребностям, в следующем блоке мы увидим только последнее значение (в нашем случае YES или NO).

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

[RACSignal combineLatest:sequence];

Теперь мы можем получать массив последних значений каждый раз, когда изменяется одиночные значения. Почти закончено! Единственной задачей осталось подсчитать, сколько раз встречается значение YES в этом массиве. Мы можем сделать это с помощью простого цикла, но давайте быть функциональными до конца и использовать оператор reduce. reduce является известной функцией в функциональном программировании, которая преобразует сбор данных в единую атомную величину заранее определенным правилом. В RAC эта функция -foldLeftWithStart: reduce: или -foldLeftWithStart: reduce: .
[unreadStatuses.rac_sequence foldLeftWithStart:@0 reduce:^id(NSNumber *accumulator, NSNumber *unreadStatus) {
   return @(accumulator.integerValue + unreadStatus.integerValue);
}];

Последнее, что остается неясным, зачем нам нужен switchToLatest?

Без него мы получим сигнал сигналов (так как мы преобразовуем значение массива в сигнал), и если вы подпишитесь на unreadStatusChanged, вы получите сигнал в следующем блоке, а не значения. Мы можем использовать либо -flatten или -switchToLatest (которая flattened, но с небольшой разницей), чтобы исправить это.

flatten означает, что подписчик, который в настоящее время flattened получит значения, отправленное с помощью сигнала, который возвращается из преобразования. В то время как -flatten принимает сигнал сигналов и объединяет их вместе следующие значения, отправленные любому из них, -switchToLatest делает то же самое, но перенаправляет значения только из последнего сигнала.

В нашем случае это работает лучше, поскольку мы не хотим получать изменения старой версии нашего dataSource. Сигнал готов и мы можем использовать его, давайте сделаем побочный эффект:

RAC([UIApplication sharedApplication], applicationIconBadgeNumber) = unreadStatusChanged;

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

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

Задача #2
Вот еще одна задача, которая демонстрирует возможности фреймворка. У нас был экран списка чатов, и задача была: при открытые экрана с списком чатов, отображать последниее сообщение чата. Вот как выглядит созданный сигнал:

RACSignal *chatReceivedFirstMessage = [[RACObserve(self, dataSource) map:^id(NSArray *chats) {
   RACSequence *sequence = [[[chats rac_sequence] filter:^BOOL(ChatViewModel *chat) {
      return ![chat hasMessages];
   }] map:^id(ChatViewModel *chat) {
      return [[RACObserve(chat, lastMessage) ignore:nil] take:1];
   }] ;

   return [RACSignal merge:sequence];
}] switchToLatest];

Давайте посмотрим, из чего он состоит.

Этот пример очень похож на предыдущий. Мы наблюдаем массив, оператор map конвертирует знавчения в сигнал, и принимая во внимание только последние сигналы.

Сначала мы фильтруем наш dataSource в блоке преобразования, потому что мы не заинтересованы в чатах, которые имеют сообщения.

Тогда мы преобразовываем значения в сигналы, опять же с помощью RACObserve.

return [[RACObserve(chat, lastMessage) ignore:nil] take:1];

Поскольку сигнал, создаваемый RACObserve будет запускатся с начальным значением свойства, которое будет равно nil, мы должны игнорировать его -ignore:. Оператор это то, что нам нужно.

Вторая часть задачи для того чтобы учитывать только первое входящее сообщение -Take: . Будет заботиться об этом. Сигнал будет завершен (и удален) сразу после получения первого значения.

Просто, для того чтобы все прояснить. Есть три новых сигнала, которые мы создали в этом коде. Первый был создан макросом RACObserve, второй по вызову -ignore: оператора на первом вновь созданном сигнале, а третий, по вызову -take: по сигналу, созданному -ignore:

Как и в предыдущем примере, нам нужен один сигнал на основе созданных сигналов. Мы используем -merge: для создания нового объединенного потока, так как мы не заботимся о значениях, как в предыдущем примере.

Время побочного эффекта!

[chatReceivedFirstMessage subscribeNext:^(id x) {
 // switching to chat screen
 }];

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

Теперь давайте немного поговорим о впечатлениях от Reactive Cocoa.

Что мне действительно нравится в Reactive Cocoa

1. Его легко начать использовать в проектах. Фреймворк задокументален, как сумасшедший. На GitHub есть много примеров, с подробным описанием каждого класса и метода, большое количество статей, видео и презентаций.

2. Вам не нужно полностью изменять свой стиль программирования. Во-первых, вы можете использовать существующие решения для проблем, таких как связывания UI, сетевые обертки, и другие решения с GitHub. Затем, шаг за шагом, вы можете понять все возможности Reactive Cocoa.

3. Он действительно меняет способ решения задач от императивниго к декларативному. Теперь, когда функциональный стиль программирования становится все более и более популярным в IOS сообществе, трудно для многих в корне изменить свой образ мышления. Reactive Cocoa помогает внести изменения, потому что он имеет много инструментов, которые помогут вам общаться в стиле «что делать», а не »как сделать».

То, что я не нравиться в Reactive Cocoa

1. Широкое использование макросов RAC () или RACObserve ().

2. Иногда может быть трудно отлаживать код, так как использование RACSignals приводит к глубоким трассировкам стека.

3. Не type-safe (вы никогда не знаете, какой тип ожидать в блоке subscribeNext). Лучшее решение в этом случае, является документирование сигналов в публичном интерфейсе, как пример:

/**
* Returns a signal which will send a User and complete or error.
*/
-(RACSignal *)loginUser;

Я также не могу не упомянуть Swift
Reactive Cocoa написан на Objective-C и специально для Objective-C. Но, конечно, сейчас, когда Swift набирает популярность, разработчики фреймворков не сидят без дела. Они на самом деле пишут Swift API, для использования с Reactive Cocoa (Великий Swiftening близко). Скромненькая мы увидеть новую версию 3.0 с блэкджеком и шлюхами, дженериками и перегрузкой операторов.
Я уверена, что после этого RAC получит еще больше поклонников. Вскоре перфекционисты, которые проклинают макросы и не-безопасность типов не будут иметь аргументов, чтобы защитить себя и не использовать Reactive Cocoa.

Вывод


Функциональный реактивный подход может упростить решения в ваших повседневных задачах. Может быть, во-первых, понятие RAC может показаться слишком сложным, его решения слишком громоздкими, и огромными, и число операторов будет сильно вас путать. Но позже, станет понятно, что все это имеет простую идею.

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

Кроме того, вы должны помнить, что все события доставляются абонентам в потоке, в котором они были созданы. Если вам нужно указать, что, применять данную RACScheduler (класс, похожий на очереди GCD, но с возможностью отмены) с помощью оператора -deliverOn:

Как правило, вы должны явно указать только [RACScheduler mainThreadScheduler] для обновления интерфейса, но вы можете написать свою собственную реализацию RACSceduler, когда вы имеете дело с чем-то конкретным, как CoreData.

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

© Habrahabr.ru