1. 程式人生 > >【iOS架構】iOS ReactiveCocoa函式響應式程式設計

【iOS架構】iOS ReactiveCocoa函式響應式程式設計

宣告式程式設計

宣告式程式設計(declarative programming)是一種程式設計範型,與指令式程式設計相對立。它描述目標的性質,讓電腦明白目標,而非流程。宣告式程式設計不用告訴電腦問題領域,從而避免隨之而來的副作用,大幅簡化了平行計算的編寫難度。而指令式程式設計則需要用演算法來明確的指出每一步該怎麼做。

宣告式語言包括資料庫查詢語言(SQL,XQuery),正則表示式,邏輯程式設計,函數語言程式設計和組態管理系統。

Objective-C和C是指令式程式設計語言(imperative programming language),程式設計師得按計算機執行順序寫好一行行語句,產生的行為就是執行這些指令。如果開發者寫的語句和順序都沒有錯,那麼產生的行為就應該能滿足專案的需要。

然而,這種命令式的程式設計通常會有缺陷,一般我們會用手動或自動化測試來發現並減少這些問題。但有另外一種更好的方法,把這些指令都抽象出來,並將重心放在所需行為上,這就是宣告式程式設計(declarative programming)。

指令式程式設計讓開發者將重心放在如何(how)寫程式來實現需求。而宣告式程式設計讓開發者將重心放在描述需求是什麼(what)。

響應式程式設計

響應式程式設計是一種面向資料流和變化傳播的程式設計正規化。這意味著可以在程式語言中很方便地表達靜態或動態的資料流,而相關的計算模型會自動將變化的值通過資料流進行傳播。

舉個例子

指令式程式設計:

以C語言程式碼舉個例子

int a = 2;
int b = 2;
int c = a + b;
printf("c = %d",c);

顯然輸出結果是“c = 3”。
如果改變一下程式碼,增加一行。

int a = 2;
int b = 2;
int c = a + b;
a++;
printf("c = %d",c);

顯然輸出結果依然是“c = 4”。

如果我們希望c永遠等於a和b的和,那麼目前看來唯一的方法是每次a和b發生變化的時候,重新執行c = a + b

響應式程式設計:

在響應式程式設計中,a的值會隨著b或c的更新而更新。

Excel就是響應式程式設計的一個例子。單元格可以包含字面值或類似”=B1+C1″的公式,而包含公式的單元格的值會依據其他單元格的值的變化而變化 。

iOS開發中,Objective-C提供了KVO機制,而ReactiveCocoa框架利用了這個機制,並且進行了各種各樣的拓展。

什麼是 ReactiveCocoa

ReactiveCocoa(其簡稱為 RAC)是由 Github 在2012年開源的一個應用於 iOS 和 OS X 開發的框架,目前最新版本為v4.0.0-alpha.4,支援OS X 10.9+ and iOS 8.0+。RAC 具有函數語言程式設計和響應式程式設計的特性。它主要吸取了 .Net 的 Reactive Extensions 的設計和實現。

Wikipedia:

Functional reactive programming (FRP) is a programming paradigm for reactive programming (asynchronous dataflow programming) using the building blocks of functional programming (e.g. map, reduce, filter).

FRP has been used for programming graphical user interfaces (GUIs), robotics, and music, aiming to simplify these problems by explicitly modeling time.

Josh Abernathy, GitHub staff, May 5, 2012

RAC is a framework for composing and transforming sequences of values.

ReactiveCocoa gives us a lot of cool stuff:

  1. The ability to compose operations on future data.

  2. An approach to minimize state and mutability.

  3. A declarative way to define behaviors and the relationships between properties.

  4. A unified, high-level interface for asynchronous operations.

  5. A lovely API on top of KVO.

FRP的核心是訊號,訊號在ReactiveCocoa中是通過RACSignal來表示的,訊號是資料流,可以被繫結和傳遞。

可以把訊號想象成水龍頭,只不過裡面不是水,而是玻璃球(value),直徑跟水管的內徑一樣,這樣就能保證玻璃球是依次排列,不會出現並排的情況(資料都是線性處理的,不會出現併發情況)。水龍頭的開關預設是關的,除非有了接收方(subscriber),才會開啟。這樣只要有新的玻璃球進來,就會自動傳送給接收方。

  • 可以在水龍頭上加一個過濾嘴(filter),不符合的不讓通過。
  • 也可以加一個改動裝置,把球改變成符合自己的需求(map)。
  • 也可以把多個水龍頭合併成一個新的水龍頭(combineLatest:reduce:),這樣只要其中的一個水龍頭有玻璃球出來,這個新合併的水龍頭就會得到這個球。

ReactiveCocoa原理

Signal & Subscriber

Signal(訊號)是ReactiveCocoa中的核心。一個signal代表著一系列事件(事件流stream)的一個事件(event)。Subscribing(訂閱)是訪問signal的介面。

對於一個signal來說,剛剛建立的時候,它還是一個冷訊號(Cold signal),只有在有了訂閱者(Subscriber)之後,才會變為熱訊號(Hot signal)。訂閱者就好比水龍頭最下方的水盆,只有放好了水盆,水龍頭才能開啟。不然,水(value)不都浪費了麼?

Subscribers subscribe to signals. Signals send their subscribers ‘next’, ‘error’, and ‘completed’ events.

在一個訊號(Signal)的生命週期中,可以傳送無數次next事件,和唯一一次complete或者error事件。

Signal:


// RACSignal.m
- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock error:(void (^)(NSError *error))errorBlock completed:(void (^)(void))completedBlock {
    NSCParameterAssert(nextBlock != NULL);
    NSCParameterAssert(errorBlock != NULL);
    NSCParameterAssert(completedBlock != NULL);

    RACSubscriber *o = [RACSubscriber subscriberWithNext:nextBlock error:errorBlock completed:completedBlock];
    return [self subscribe:o];
}

Subscriber:

@protocol RACSubscriber <NSObject>
@required
/// Sends the next value to subscribers.
///
/// value - The value to send. This can be `nil`.
- (void)sendNext:(id)value;

/// Sends the error to subscribers.
///
/// error - The error to send. This can be `nil`.
///
/// This terminates the subscription, and invalidates the subscriber (such that
/// it cannot subscribe to anything else in the future).
- (void)sendError:(NSError *)error;

/// Sends completed to subscribers.
///
/// This terminates the subscription, and invalidates the subscriber (such that
/// it cannot subscribe to anything else in the future).
- (void)sendCompleted;

/// Sends the subscriber a disposable that represents one of its subscriptions.
///
/// A subscriber may receive multiple disposables if it gets subscribed to
/// multiple signals; however, any error or completed events must terminate _all_
/// subscriptions.
- (void)didSubscribeWithDisposable:(RACCompoundDisposable *)disposable;
@end


// A simple block-based subscriber.
@interface RACSubscriber : NSObject <RACSubscriber>

// Creates a new subscriber with the given blocks.
+ (instancetype)subscriberWithNext:(void (^)(id x))next error:(void (^)(NSError *error))error completed:(void (^)(void))completed;

@end

- (void)sendNext:(id)value方法為例看一看它的實現:

- (void)sendNext:(id)value {
    @synchronized (self) {
        void (^nextBlock)(id) = [self.next copy];
        if (nextBlock == nil) return;

        nextBlock(value);
    }
}

其實最核心的功能即時呼叫了自己的nextBlock並傳入相應的引數而已。

UITextField建立訊號的原理:

// UITextField (RACSignalSupport)
- (RACSignal *)rac_textSignal {
    @weakify(self);
    return [[[[[RACSignal
        defer:^{
            @strongify(self);
            return [RACSignal return:self];
        }]
        concat:[self rac_signalForControlEvents:UIControlEventAllEditingEvents]]
        map:^(UITextField *x) {
            return x.text;
        }]
        takeUntil:self.rac_willDeallocSignal]
        setNameWithFormat:@"%@ -rac_textSignal", self.rac_description];
}

// UIControl (RACSignalSupport)
- (RACSignal *)rac_signalForControlEvents:(UIControlEvents)controlEvents {
    @weakify(self);

    return [[RACSignal
        createSignal:^(id<RACSubscriber> subscriber) {
            @strongify(self);

            [self addTarget:subscriber action:@selector(sendNext:) forControlEvents:controlEvents];
            [self.rac_deallocDisposable addDisposable:[RACDisposable disposableWithBlock:^{
                [subscriber sendCompleted];
            }]];

            return [RACDisposable disposableWithBlock:^{
                @strongify(self);
                [self removeTarget:subscriber action:@selector(sendNext:) forControlEvents:controlEvents];
            }];
        }]
        setNameWithFormat:@"%@ -rac_signalForControlEvents: %lx", self.rac_description, (unsigned long)controlEvents];
}

UITextField用例:

    [[self.nameTextField.rac_textSignal distinctUntilChanged] subscribeNext:^(NSString *x) {
        @strongify(self);
        self.viewModel.nameText = x;
    }];

我們執行了subscribeNext方法建立了一個訂閱者(Subscriber),這個訂閱者的nextBlcok方法已經被賦值。而rac_textSignal這個訊號的實現中,在每次text發生變化的時候,就會呼叫訂閱者的sendNext方法,從而呼叫nextBlcok中的程式碼。

RACSignal的subscription過程

RACSignal的Subscription過程概括起來可以分為三個步驟:

  1. 通過[RACSignal createSignal]來獲得signal
  2. 通過[signal subscribeNext:]來獲得subscriber,然後進行subscription
  3. 進入didSubscribe,通過[subscriber sendNext:]來執行next block

訊號(Signal)的各種操作

作為一個訊號,我們關注它的兩個方面:

處理邏輯
資料內容

處理邏輯指的是建立訊號的時候,它是如何通知訂閱者(Subscriber)並選擇傳送何種事件的。資料內容指的是訊號會傳遞給訂閱者(Subscriber)什麼樣的資料。

如果我們需要對這些內容進行自定義的修改,那麼修改原訊號顯然是不可行的(訊號已經被建立了)。因此,這就牽涉到訊號之間的轉換(Map)與組合(Combine)。

ReactiveCocoa提供了對訊號的各種操作。這些操作幾乎都用到了FlattenMap方法。意味著返回一個被修改之後的訊號。同時,幾乎每個操作還呼叫了return方法。

//這個return不是我們用於返回一個值的return,只是名字比較像。
+ (RACSignal *)return:(id)value {
    return [RACReturnSignal return:value];
}

該方法的主要作用是,返回一個新的訊號,不過原始訊號傳送事件時的value將被新的value替換。
有了對繫結(Bind)方法、FlattenMap方法和return方法的理解,基本上就可以通過自己閱讀原始碼搞定對訊號(Signal)的各種操作了。這裡列出幾個常用的操作。

filter

filter方法返回一個新的signal。原始訊號的value被替換為了符合要求的value,從而實現了篩選、過濾的目的。是否符合要求是由傳入的block決定的。即原來的訊號的value,如果傳入block中返回YES,則新的訊號也將輸出這個value。

map

map方法返回一個新的signal。原始訊號的value被替換為了經過block處理的value。

distinctUntilChanged

distinctUntilChanged方法返回一個新的signal。這個signal只在value和前一個value不同的時候才會傳送事件。簡記為求異存同。

ignore

這個方法需要傳入一個value,當訊號收到一個value時,會檢查是否和傳入的value相同,如果相同就不會發送事件給訂閱者。

skip & take

顧名思義,就是跳過(只發送)前n條資料。這裡的n就是傳入的引數值。

doNext

建立一個新的訊號,這個訊號和原始訊號一模一樣,不過可以在建立的過程中呼叫傳入的block。

combineLatest: reduce:

合併若干個訊號,得到一個新的訊號。把那些訊號的value進行處理,得到一個處理過後的value作為新的訊號的value。

throttle

throttle方法返回一個新的signal。只有在給定時間原始訊號沒有傳送next事件,這個訊號才會傳送一個原始訊號最近的一次next事件。

通過對訊號的各種操作,我們把若干個水龍頭連在一起,形成了一個水管。filter像是在兩個水龍頭之間加了一個過濾網,只有經過過濾網的水才能出現在下一個水龍頭裡。map像是在水龍頭間加了一個轉換器,前一個水龍頭流出的水經過這個轉換器就變成石油了。combineLatest: reduce:則是把若干個水龍頭的水一起引入一個新的水龍頭……

FRP ReactiveCocoa

ReactiveCocoa 試圖解決什麼問題

ReactiveCocoa 可以解決以下 3 個問題:

  • 傳統 iOS 開發過程中,狀態以及狀態之間依賴過多的問題
  • 傳統 MVC 架構的問題:Controller 比較複雜,可測試性差
  • 提供統一的訊息傳遞機制

傳統 iOS 開發過程中,狀態以及狀態之間依賴過多的問題

我們在開發 iOS 應用時,一個介面元素的狀態很可能受多個其它介面元素或後臺狀態的影響。

例如,在使用者帳戶的登入介面,通常會有 2 個輸入框(分別輸入帳號和密碼)和一個登入按鈕。如果我們要加入一個限制條件:當用戶輸入完帳號和密碼,並且登入的網路請求還未發出時,確定按鈕才可以點選。通常情況下,我們需要監聽這兩個輸入框的狀態變化以及登入的網路請求狀態,然後修改另一個控制元件的enabled狀態。

傳統的寫法如下(該示例程式碼修改自 ReactiveCocoa 官網 ) :

static void *ObservationContext = &ObservationContext;

- (void)viewDidLoad {
    [super viewDidLoad];

    [LoginManager.sharedManager addObserver:self
                                 forKeyPath:@"loggingIn"
                                    options:NSKeyValueObservingOptionInitial
                                    context:&ObservationContext];
    [self.usernameTextField addTarget:self action:@selector(updateLogInButton)
                     forControlEvents:UIControlEventEditingChanged];
    [self.passwordTextField addTarget:self action:@selector(updateLogInButton)
                     forControlEvents:UIControlEventEditingChanged];
}

- (void)updateLogInButton {
    BOOL textFieldsNonEmpty = self.usernameTextField.text.length > 0 && self.passwordTextField.text.length > 0;
    BOOL readyToLogIn = !LoginManager.sharedManager.isLoggingIn && !self.loggedIn;
    self.logInButton.enabled = textFieldsNonEmpty && readyToLogIn;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
                        change:(NSDictionary *)change context:(void *)context {
    if (context == ObservationContext) {
        [self updateLogInButton];
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object
                               change:change context:context];
    }
}

RAC 通過引入訊號(Signal)的概念,來代替傳統 iOS 開發中對於控制元件狀態變化檢查的代理(delegate)模式或 target-action 模式。因為 RAC 的訊號是可以組合(combine)的,所以可以輕鬆地構造出另一個新的訊號出來,然後將按鈕的enabled狀態與新的訊號繫結。如下所示:

RAC(self.logInButton, enabled) = [RACSignal
    combineLatest:@[
        self.usernameTextField.rac_textSignal,
        self.passwordTextField.rac_textSignal,
        RACObserve(LoginManager.sharedManager, loggingIn),
        RACObserve(self, loggedIn)
    ] reduce:^(NSString *username, NSString *password, NSNumber *loggingIn, NSNumber *loggedIn) {
        return @(username.length > 0 && password.length > 0 && !loggingIn.boolValue && !loggedIn.boolValue);
    }];

可以看到,在引入 RAC 之後,以前散落在action-target或 KVO 的回撥函式中的判斷邏輯被統一到了一起,從而使得登入按鈕的enabled狀態被更加清晰地表達了出來。

除了組合(combine)之外,RAC 的訊號還支援鏈式(chaining)和過濾(filter),以方便將訊號進行進一步處理。

[[self.textField.rac_textSignal filter:^BOOL(NSString*value) {  
    return [value length]>= 3;  
}] subscribeNext:^(NSString*value) {  
    NSLog(@"Text field has been updated: %@", value);  
}];  

試圖解決 MVC 框架的問題

對於傳統的 Model-View-Controller (MVC)的框架,Controller 很容易變得比較龐大和複雜。由於 Controller 承擔了 Model 和 View 之間的橋樑作用,所以 Controller 常常與對應的 View 和 Model 的耦合度非常高,這同時也造成對其做單元測試非常不容易,對 iOS 工程的單元測試大多都只在一些工具類或與介面無關的邏輯類中進行。

MVC

RAC 的訊號機制很容易將某一個 Model 變數的變化與介面關聯,所以非常容易應用 Model-View-ViewModel(MVVM) 框架。通過引入 ViewModel 層,然後用 RAC 將 ViewModel 與 View 關聯,View 層的變化可以直接響應 ViewModel 層的變化,這使得 Controller 變得更加簡單,由於 View 不再與 Model 繫結,也增加了 View 的可重用性。

在MVVM體系中,Controller可以被看成View,所以它的主要工作是處理佈局、動畫、接收系統事件、展示UI。ViewModel直接與View繫結,而且對View一無所知。拿做菜打比方的話,ViewModel就是調料,它不關心做的到底是什麼菜。當Model的API有變化,或者由本地儲存變為遠端API呼叫時,ViewModel的public API可以保持不變。

因為引入了 ViewModel 層,所以單元測試可以在 ViewModel 層進行,iOS 工程的可測試性也大大增強了。

MVVM

Github 開源ReactiveViewModel

統一訊息傳遞機制

ReactiveCocoa is inspired by functional reactive programming. Rather than using mutable variables which are replaced and modified in-place, RAC offers “event streams,” represented by the Signal and SignalProducer types, that send values over time.

Event streams unify all of Cocoa’s common patterns for asynchrony and event handling, including:

  • Delegate methods

  • Callback blocks

  • NSNotifications

  • Control actions and responder chain events

  • Futures and promises

  • Key-value observing (KVO)

Because all of these different mechanisms can be represented in the same way, it’s easy to declaratively chain and combine them together, with less spaghetti code and state to bridge the gap.

iOS 開發中有著各種訊息傳遞機制,包括 KVO、Notification、delegation、block 以及 target-action 方式。各種訊息傳遞機制使得開發者在做具體選擇時感到困惑。

RAC 將傳統的 UI 控制元件事件進行了封裝,使得以上各種訊息傳遞機制都可以用 RAC 來完成。示例程式碼如下:


// KVO
[RACObserve(self, username) subscribeNext:^(id x) {
    NSLog(@" 成員變數 username 被修改成了:%@", x);
}];

// target-action
self.button.rac_command = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
    NSLog(@" 按鈕被點選 ");
    return [RACSignal empty];
}];

// Notification
[[[NSNotificationCenter defaultCenter]
    rac_addObserverForName:UIKeyboardDidChangeFrameNotification
                    object:nil]
    subscribeNext:^(id x) {
        NSLog(@" 鍵盤 Frame 改變 ");
    }
];

// Delegate
[[self rac_signalForSelector:@selector(viewWillAppear:)] subscribeNext:^(id x) {
    debugLog(@"viewWillAppear 方法被呼叫 %@", x);
}];

RAC 的RACSignal 類也提供了createSignal方法來讓使用者建立自定義的訊號,如下程式碼建立了一個下載指定網站內容的訊號。

-(RACSignal *)urlResults {
    return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        NSError *error;
        NSString *result = [NSString stringWithContentsOfURL:[NSURL URLWithString:@"http://www.devtang.com"]
                                                    encoding:NSUTF8StringEncoding
                                                       error:&error];
        NSLog(@"download");
        if (!result) {
            [subscriber sendError:error];
        } else {
            [subscriber sendNext:result];
            [subscriber sendCompleted];
        }
        return [RACDisposable disposableWithBlock:^{
            NSLog(@"clean up");
        }];
    }];

}

ReactiveCocoa缺點

InfoQ:使用ReactiveCocoa與直接使用 Cocoa框架相比,效能上(事件的響應速度、回撥速度)是否會有影響?

花瓣網移動開發主管 李忠:

ReactiveCocoa底層的實現是比較複雜的,在效能上確實會有一定的影響。一個簡單的 [signal subscribeNext: ^(id x){}] 就會有造成很深的callback stack(近40次的呼叫),相比純KVO不到10次的呼叫,速度上慢了至少1個數量級。不過儘管如此,只要subscribe的次數不要過多,效能上還是可以接受的。

在事件響應上,RAC比KVO慢了大概5倍,不過問題不大,在iPhone5上測了下,也就1ms多一點,絕大多數的使用場景都不會有問題。

在開發Mac App時,可以使用Cocoa Bindings,但iOS卻不支援,可能也是出於效能上的考慮。既然RAC的效能不如直接使用原生的高,還有必要用它麼?我覺得還是有的,效能是我們選擇框架的一個參考因素,但不是決定性的因素。開發者在足夠了解RAC的情況下,RAC可以提高開發效率並幫助開發者編寫更易維護的程式碼,這兩點就值得我們去研究、使用它。

已知採用ReactiveCocoa的公司:
美團
Bilibili
花瓣網
ReactiveCocoa在花瓣客戶端的實踐

延伸…

ReativeCocoa vs. ReactiveX

ReactiveCocoa was originally inspired, and therefore heavily influenced, by Microsoft’s Reactive Extensions (Rx) library. There are many ports of Rx, including RxSwift, but ReactiveCocoa is intentionally not a direct port.

Where RAC differs from Rx, it is usually to:

  • Create a simpler API
  • Address common sources of confusion
  • More closely match Cocoa conventions

[Languages]

* RxJava
* RxJS
* Rx.NET
* RxScala
* RxClojure
* RxSwift
* Others

程式設計正規化

面向代理
基於元件
    基於流
    渠道
連續式
併發計算
`宣告式`(對比:命令式)
    `函式式`
        資料流
            面向細胞(電子表格)
            `響應式`
    面向圖形
    目標導向
        約束
            邏輯
                回答集程式設計
                約束邏輯
                溯因邏輯
                歸納邏輯
事件驅動
    面向服務
    時間驅動
功能導向
函式級(對比:價值級)
`命令式`(對比:宣告式)
    非結構化
        向量(對比:標量)
        迭代式
    結構化
        過程式
            模組化
            遞迴式
        面向物件
            基於類
            基於原型
            自動機
            根據關注分離:
                `面向方面`
                面向主題
                面向角色
超程式設計
    面向屬性
    自動
        泛型
            模板
                基於原則
        面向語言
            領域特定
            面向語法
                方言化
            意圖
    反射式
不確定
平行計算
    面向過程
大規模程式設計與小規模程式設計
價值級(對比:函式級)