Моє знайомство з 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 і посилає команду «наступна» абоненту після заданого інтервалу часу.

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 — майбутнє значення/значення;
  • errorNSError*, що означає, що сигнал не може бути успішно завершений;
  • 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. get block called when both signals completed
}];

Варто відзначити, що блок subscribeError буде викликатися в тому випадку, якщо принаймні один із сигналів не спрацює. Якщо перший сигнал завершується з помилкою, другий сигнал не виконатися.

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

[[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> 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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.