iOS開(kāi)發(fā)下的函數(shù)響應(yīng)式編程

背景和面臨的問(wèn)題(2016.6.29)

隨著移動(dòng)互聯(lián)網(wǎng)的蓬勃發(fā)展,iOS App的復(fù)雜度呈指數(shù)增長(zhǎng)。美團(tuán)·大眾點(diǎn)評(píng)兩個(gè)App隨著用戶(hù)量不斷增加、研發(fā)工程師數(shù)量不斷增多扰路,可用性的要求也隨之不斷提升。在這樣的一個(gè)背景之下倔叼,我們面臨了很多的問(wèn)題和挑戰(zhàn)汗唱。美團(tuán)和大眾點(diǎn)評(píng)的iOS工程師們面對(duì)挑戰(zhàn),想出了很多的策略和方針來(lái)應(yīng)對(duì)丈攒,引入函數(shù)響應(yīng)式編程就是美團(tuán)App中重要的一環(huán)哩罪。

函數(shù)響應(yīng)式編程簡(jiǎn)介

函數(shù)式編程想必您一定聽(tīng)過(guò),但響應(yīng)式編程的說(shuō)法就不大常見(jiàn)了巡验。與響應(yīng)式編程對(duì)應(yīng)的命令式編程就是大家所熟知的一種編程范式际插,我們先來(lái)看一段代碼:

int a = 3;
int b = 4;
int c = a + b;
NSLog(@"c is %d", c); // => 12
a = 5;
b = 7;
NSLog(@"c is %d", c); // 仍然是12

命令式編程就是通過(guò)表達(dá)式或語(yǔ)句來(lái)改變狀態(tài)量,例如c = a + b就是一個(gè)表達(dá)式显设,它創(chuàng)建了一個(gè)名稱(chēng)為c的狀態(tài)量框弛,其值為a與b的加和。下面的a = 5是另一個(gè)語(yǔ)句捕捂,它改變了a的值瑟枫,但這時(shí)c是沒(méi)有變化的。所以命令式編程中c = a + b只是一個(gè)瞬時(shí)的過(guò)程指攒,而不是一個(gè)關(guān)系描述慷妙。在傳統(tǒng)的開(kāi)發(fā)中,想讓c跟隨a和b的變化而變化是比較困難的允悦。而讓c的值實(shí)時(shí)等于a與b的加和的編程方式就是響應(yīng)式編程膝擂。

實(shí)際上,在日常的開(kāi)發(fā)中我們會(huì)經(jīng)常使用響應(yīng)式編程的思想來(lái)進(jìn)行開(kāi)發(fā)。最典型的例子就是Excel架馋,當(dāng)我們?cè)谝粋€(gè)B1單元格上書(shū)寫(xiě)一個(gè)公式“=A1+5”時(shí)狞山,便聲明了一種對(duì)應(yīng)關(guān)系,每當(dāng)A1單元格發(fā)生變化時(shí)叉寂,單元格B2都會(huì)隨之改變铣墨。

image
圖1 Excel中的響應(yīng)式

iOS開(kāi)發(fā)中也有響應(yīng)式編程的典型例子,例如Autolayout办绝。我們通過(guò)設(shè)置約束描述了各個(gè)視圖的位置關(guān)系,一旦其中一個(gè)變化姚淆,另一個(gè)就會(huì)響應(yīng)其變化孕蝉。類(lèi)似的例子還有很多。

函數(shù)響應(yīng)式編程(英文Functional Reactive Programming腌逢,以下簡(jiǎn)稱(chēng)FRP降淮,)正是在函數(shù)式編程的基礎(chǔ)之上,增加了響應(yīng)式的支持搏讶。

簡(jiǎn)單來(lái)講佳鳖,F(xiàn)RP是基于異步事件流進(jìn)行編程的一種編程范式。針對(duì)離散事件序列進(jìn)行有效的封裝媒惕,利用函數(shù)式編程的思想系吩,滿(mǎn)足響應(yīng)式編程的需要。

區(qū)別于面向過(guò)程編程范式以過(guò)程單元作為核心組成部分妒蔚,面向?qū)ο缶幊谭妒揭詫?duì)象單元作為核心組成部分穿挨,函數(shù)式編程范式以函數(shù)和高階函數(shù)作為核心組成部分。FRP則以離散有序序列作為核心組成部分肴盏,也可將其定義為信號(hào)科盛。其特點(diǎn)是具備可迭代特性并且允許離散事件節(jié)點(diǎn)有時(shí)間聯(lián)系,計(jì)算機(jī)科學(xué)中稱(chēng)為Monad菜皂。

嚴(yán)格意義上來(lái)講贞绵,下文提及的iOS開(kāi)發(fā)下的函數(shù)響應(yīng)式編程,并不能算完全的FRP恍飘,這一點(diǎn)榨崩,本文就不做學(xué)術(shù)上的討論了。

接來(lái)下會(huì)為您介紹iOS相關(guān)的FRP內(nèi)容常侣,我們先從選型開(kāi)始蜡饵。

iOS項(xiàng)目的函數(shù)響應(yīng)式編程選型

很長(zhǎng)一段時(shí)間以來(lái),iOS項(xiàng)目并沒(méi)有很好的FRP支持胳施,直到iOS 4.0 SDK中增加了Block語(yǔ)法才為函數(shù)式編程提供了前置條件溯祸,F(xiàn)RP開(kāi)源庫(kù)也逐步健全起來(lái)。

最先與大家見(jiàn)面的莫過(guò)于ReactiveCocoa這樣一個(gè)庫(kù)了,ReactiveCocoa是Github在制作Github客戶(hù)端時(shí)開(kāi)源的一個(gè)副產(chǎn)物焦辅,縮寫(xiě)為RAC博杖。它是Objective-C語(yǔ)言下FRP思想的一個(gè)優(yōu)秀實(shí)例,后續(xù)版本也支持了Swift語(yǔ)言筷登。

Swift語(yǔ)言的推出為iOS界的函數(shù)式編程愛(ài)好者迎來(lái)了曙光剃根。著名的FRP開(kāi)源庫(kù)Rx系列也新增了RxSwift,保持其接口與ReactiveX.net前方、RxJava狈醉、RxJS接口保持一致。

下面對(duì)不同廠商幾個(gè)版本的FRP庫(kù)進(jìn)行簡(jiǎn)單的對(duì)比:

語(yǔ)言 Objective-C 支持 Swift 支持 Cocoa框架支持 其他
RAC 2.5 × 完善 迭代周期長(zhǎng)惠险,穩(wěn)定
RAC 3.0+ 繼承2.5版本 開(kāi)始全面支持Swift
RxSwift × 不完善 符合Rx標(biāo)準(zhǔn)

表1 iOS下幾種FRP庫(kù)的對(duì)比

美團(tuán)App由于歷史原因仍然沿用ReactiveCocoa 2.5版本苗傅。下文也主要會(huì)針對(duì)ReactiveCocoa 2.5版本做介紹,但各位可以根據(jù)自己項(xiàng)目的需要來(lái)選擇FRP庫(kù)班巩,其思想和主要的API大同小異渣慕。

為什么需要在iOS項(xiàng)目中引入FRP這樣厚重的庫(kù)呢?

iOS的項(xiàng)目主要以客戶(hù)端項(xiàng)目為主抱慌,主要的業(yè)務(wù)場(chǎng)景就是進(jìn)行頁(yè)面交互和與服務(wù)器拉取數(shù)據(jù)逊桦,這里面會(huì)包含多種事件和異步處理邏輯。FRP本身就是面向事件流的一種開(kāi)發(fā)方式抑进,又擅長(zhǎng)處理異步邏輯强经。所以從邏輯上是滿(mǎn)足iOS客戶(hù)端業(yè)務(wù)需要的。

然而能夠把一個(gè)理念融合到實(shí)際的項(xiàng)目中单匣,需要一個(gè)漫長(zhǎng)的過(guò)程夕凝。所以接下來(lái)就根據(jù)美團(tuán)App在FRP上的實(shí)踐,具體講述下融入FRP的過(guò)程户秤。希望能給大家一些參考码秉。

一步一步進(jìn)行函數(shù)響應(yīng)式編程

眾所周知,F(xiàn)RP的學(xué)習(xí)是存在一定門(mén)檻的鸡号,想必這也是大家對(duì)FRP转砖、ReactiveCocoa這些概念比較畏懼的主要原因。美團(tuán)App在推行FRP的過(guò)程中鲸伴,是采用分步驟的形式府蔗,逐步演化的。其演化的過(guò)程可以分為初探汞窗、入門(mén)姓赤、提高、進(jìn)階這樣四個(gè)階段仲吏。

初探

美團(tuán)App是在2014年5月第一次將ReactiveCocoa這個(gè)開(kāi)源庫(kù)納入到工程中的不铆,當(dāng)時(shí)iOS工程師數(shù)量還不是很多蝌焚,但是已經(jīng)遇到了寫(xiě)法不統(tǒng)一、代碼量膨脹等問(wèn)題了誓斥。

寫(xiě)法不統(tǒng)一聚焦在回調(diào)形式的不統(tǒng)一上只洒,iOS中的回調(diào)方式有非常多的種類(lèi):UIKit主要進(jìn)行的事件處理target-action、跨類(lèi)依賴(lài)推薦的delegate模式劳坑、iOS 4.0納入的block毕谴、利用通知中心(Notifcation Center)進(jìn)行松耦合的回調(diào)、利用鍵值觀察(Key-Value Observe距芬,簡(jiǎn)稱(chēng)KVO)進(jìn)行的監(jiān)聽(tīng)涝开。由于場(chǎng)景不同,選用的規(guī)則也不盡相同框仔,并且我們沒(méi)有辦法很好的界定什么場(chǎng)景該寫(xiě)什么樣的回調(diào)忠寻。

這時(shí)我們發(fā)現(xiàn)ReactiveCocoa這樣一個(gè)開(kāi)源庫(kù),恰好能以統(tǒng)一的形式來(lái)解決跨類(lèi)調(diào)用的問(wèn)題存和,也包裝了UIKit事件處理、Notifcation Center衷旅、KVO等常見(jiàn)的場(chǎng)景捐腿。使其代碼風(fēng)格上高度統(tǒng)一。

使用ReactiveCocoa進(jìn)行統(tǒng)一后柿顶,上述的幾種回調(diào)都可以寫(xiě)成如下形式:

// 代替target-action
[[self.confirmButton rac_signalForControlEvents:UIControlEventTouchUpInside]
 subscribeNext:^(id x) {
    // 回調(diào)內(nèi)容寫(xiě)在這里
 }];

// 代替delegate
[[self.scrollView rac_signalForSelector:@selector(scrollViewDidScroll:) fromProtocol:@protocol(UIScrollViewDelegate)]
 subscribeNext:^(id x) {
    // 回調(diào)內(nèi)容寫(xiě)在這里
 }];

// 代替block
[[self asyncProcess]
 subscribeNext:^(id x) {
    // 回調(diào)內(nèi)容寫(xiě)在這里
 } error:^(NSError *error) {
    // 錯(cuò)誤處理寫(xiě)到這里
 }];

// 代替notification center
[[[NSNotificationCenter defaultCenter] rac_valuesForKeyPath:@"Some-key" observer:nil]
 subscribeNext:^(id x) {
    // 回調(diào)內(nèi)容寫(xiě)在這里
 }];

// 代替KVO
[RACObserve(self, userReportString)
 subscribeNext:^(id x) {
    // 回調(diào)內(nèi)容寫(xiě)在這里
 }];

代碼1 回調(diào)統(tǒng)一

通過(guò)觀察代碼不難發(fā)現(xiàn)茄袖,ReactiveCocoa使得不同場(chǎng)景下的代碼樣式上高度統(tǒng)一,使我們?cè)跁?shū)寫(xiě)代碼嘁锯、維護(hù)代碼宪祥、閱讀代碼方面的效率大大提高。

經(jīng)過(guò)一定的研究家乘,我們也發(fā)現(xiàn)使用RAC(target, key)宏可以更好組織代碼形式蝗羊,利用filter:和map:來(lái)代替原有的代碼,達(dá)到更好復(fù)用仁锯,例如下面兩段代碼:

// 舊寫(xiě)法
@weakify(self)
[[self.textField rac_newTextChannel]
 subscribeNext:^(NSString *x) {
     @strongify(self)
     if (x.length > 15) {
         self.confirmButton.enabled = NO;
         [self showHud:@"Too long"];
     } else {
         self.confirmButton.enabled = YES;
     }
     self.someLabel.text = x;
     
 }];

// 新寫(xiě)法
RACSignal *textValue = [self.textField rac_newTextChannel];
RACSignal *isTooLong = [textValue
                        map:^id(NSString *value) {
                            return @(value.length > 15);
                        }];
RACSignal *whenItsTooLongMessage = [[isTooLong
                                     filter:^BOOL(NSNumber *value) {
                                         return @(value.boolValue);
                                     }]
                                    mapReplace:@"Too long"];
[self rac_liftSelector:@selector(showHud:) withSignals:whenItsTooLongMessage, nil];
RAC(self.confirmButton, enabled) = [isTooLong not];
RAC(self.someLabel, text) = textValue;

代碼2 邏輯優(yōu)化

上述代碼修改雖然代碼行數(shù)有一定的增加耀找,但是結(jié)構(gòu)更加清晰,復(fù)用性也做得更好业崖。

綜上所述野芒,在這一階段,我們主要以回調(diào)形式的統(tǒng)一為主双炕,不斷嘗試合適的代碼形式來(lái)表達(dá)綁定這種關(guān)系狞悲,也尋找一些便捷的小技巧來(lái)優(yōu)化代碼。

入門(mén)

image
圖2 美團(tuán)App首頁(yè)

單純解決回調(diào)風(fēng)格的統(tǒng)一和樹(shù)立綁定的思維是遠(yuǎn)遠(yuǎn)不夠的妇斤,代碼中更大的問(wèn)題在于共享變量摇锋、異步協(xié)同以及異常傳遞的處理丹拯。 列舉幾個(gè)簡(jiǎn)單的場(chǎng)景,就拿美團(tuán)App的首頁(yè)來(lái)講乱投,我們可以看到上面包含很多的區(qū)塊咽笼,而各個(gè)區(qū)塊的訪問(wèn)接口不盡相同,但是渲染的邏輯卻又多種多樣:

有的需要幾個(gè)接口都返回后才能統(tǒng)一渲染戚炫。
有的需要一個(gè)接口返回后剑刑,根據(jù)返回的內(nèi)容決定后續(xù)的接口訪問(wèn)炼绘,最終才能渲染充尉。
有的則是幾個(gè)接口按照返回順序依次渲染寒亥。
這就導(dǎo)致我們?cè)谔幚磉@些邏輯的時(shí)候隔盛,需要很多的異步處理手段笆包,利用一些中間變量來(lái)儲(chǔ)存狀態(tài)熟嫩,每次返回的時(shí)候又判斷這些狀態(tài)決定渲染的邏輯片习。

更糟糕的是照卦,有的時(shí)候?qū)τ谕瑫r(shí)請(qǐng)求多個(gè)網(wǎng)絡(luò)接口蔑赘,某些出現(xiàn)了網(wǎng)絡(luò)錯(cuò)誤狸驳,異常處理也變得越來(lái)越復(fù)雜。

隨著對(duì)ReactiveCocoa理解的加深缩赛,我們意識(shí)到使用信號(hào)的組合等“高級(jí)”操作可以幫助我們解決很多的問(wèn)題耙箍。例如merge:操作可以解決依次渲染的問(wèn)題,zip:操作可以解決多個(gè)接口都返回后再渲染的問(wèn)題酥馍,flattenMap:可以解決接口串聯(lián)的問(wèn)題辩昆。大概的示例代碼如下:

// 依次渲染
RACSignal *mergedSignal = [RACSignal merge:@[[self fetchData1],
                                             [self fetchData2]]];

// 接口都返回后一起渲染
RACSignal *zippedSignal = [RACSignal zip:@[[self fetchData1],
                                           [self fetchData3]]];

// 接口串聯(lián)
@weakify(self)
RACSignal *finalSignal = [[self fetchData4]
                          flattenMap:^RACSignal *(NSString *data4Result) {
                              @strongify(self)
                              return [self fetchData5:data4Result];
                          }];

沒(méi)有用到一個(gè)中間狀態(tài)變量,我們通過(guò)這幾個(gè)“魔法接口”神奇地將邏輯描述了出來(lái)旨袒。這樣寫(xiě)的好處還有很多汁针。

FRP具備這樣一個(gè)特點(diǎn),信號(hào)因?yàn)檫M(jìn)行組合從而得到了一個(gè)數(shù)據(jù)鏈砚尽,而數(shù)據(jù)鏈的任一節(jié)點(diǎn)發(fā)出錯(cuò)誤信號(hào)施无,都可以順著這個(gè)鏈條最終交付給訂閱者。這就正好解決了異常處理的問(wèn)題必孤。

image
圖3 錯(cuò)誤傳遞鏈

由于此項(xiàng)特性帆精,我們可以不再關(guān)注錯(cuò)誤在哪個(gè)環(huán)節(jié),只需等待訂閱的時(shí)候統(tǒng)一處理即可隧魄。我們也找到了很多的方法用來(lái)更好地支持異常的處理卓练。例如try:、catch:购啄、catchTo:襟企、tryMap:等。

簡(jiǎn)單列舉下示例:

// 嘗試判斷并捕捉異常
RACSignal *signalForSubscriber =

  [[[[[self fetchData1]
      try:^BOOL(NSString *data1,
                NSError *__autoreleasing *errorPtr) {
          if (data1.length == 0) {
              *errorPtr = [NSError new];
              return YES;
          }
          return NO;
      }]
     flattenMap:^RACStream *(id value) {
         @strongify(self)
         return [self fetchData5:value];
     }]
    try:^BOOL(id value,
              NSError *__autoreleasing *errorPtr) {
        if (![value isKindOfClass:[NSString class]]) {
            *errorPtr = [NSError new];
            return YES;
        }
        return NO;
    }]
   catch:^RACSignal *(NSError *error) {
       return [RACSignal return:error.domain];
   }];

總結(jié)一下狮含,在這個(gè)階段顽悼,我們主要嘗試解決了異步協(xié)同的問(wèn)題曼振,包括了異常的處理。運(yùn)用了異常處理模型來(lái)解決了很多的實(shí)際問(wèn)題蔚龙,同時(shí)繼續(xù)尋找了更多的技巧來(lái)優(yōu)化代碼冰评。

在初探和入門(mén)這兩個(gè)階段,美團(tuán)App還只是謹(jǐn)慎地進(jìn)行小的嘗試木羹,主旨是以代碼簡(jiǎn)化為目的甲雅,使用ReactiveCocoa這個(gè)開(kāi)源框架的一些便利功能來(lái)優(yōu)化代碼。在代碼覆蓋程度上盡量只在模塊內(nèi)部使用坑填,避免跨層使用ReactiveCocoa抛人。

提高

隨著對(duì)ReactiveCocoa這個(gè)開(kāi)源框架的理解不斷加深。美團(tuán)App并不滿(mǎn)足于簡(jiǎn)單的嘗試脐瑰,而是開(kāi)始在更多的場(chǎng)景下使用ReactiveCocoa妖枚,并體現(xiàn)一定的FRP思想。這個(gè)階段最具代表性的實(shí)踐就是與MVVM架構(gòu)的融合了苍在,它就是體現(xiàn)了FRP響應(yīng)式的思想绝页。

Model-View-Controller(簡(jiǎn)稱(chēng)MVC)是蘋(píng)果Cocoa框架默認(rèn)的一個(gè)架構(gòu)。實(shí)際上業(yè)務(wù)場(chǎng)景的復(fù)雜度越來(lái)越高寂恬,而MVC架構(gòu)自身也存在分層不清晰等諸多問(wèn)題抒寂,最終使得MVC這一架構(gòu)在實(shí)際的使用中漸漸走了樣。

Model-View-ViewModel(簡(jiǎn)稱(chēng)MVVM)便是近幾年來(lái)十分推崇的一種架構(gòu)掠剑,它解決了MVC架構(gòu)的一些不足,在層次定義上更為清晰郊愧。在MVVM的架構(gòu)中朴译,最為關(guān)鍵的一環(huán)莫過(guò)于ViewModel層與View層的綁定了,我們的主角FRP恰好可以解決綁定問(wèn)題属铁,同時(shí)還能處理跨層錯(cuò)誤處理的問(wèn)題眠寿。

先來(lái)關(guān)注下綁定,自初探階段開(kāi)始焦蘑,我們就開(kāi)始使用RAC(target, key)這樣的一個(gè)宏來(lái)表述綁定的關(guān)系盯拱,并且使用一些簡(jiǎn)單的信號(hào)轉(zhuǎn)換使得原始信號(hào)滿(mǎn)足視圖渲染的需求。在引入MVVM架構(gòu)后例嘱,我們將之前的經(jīng)驗(yàn)利用起來(lái)狡逢,并且使用了RACChannel、RACCommand等組件來(lái)支持MVVM拼卵。

// 單向綁定
RAC(self.someLabel, text) = RACObserve(self.viewModel, someProperty);
RAC(self.scrollView, hidden) = self.viewModel.someSignal;
RAC(self.confirmButton, frame) = [self.viewModel.someChannel
                                  map:^id(NSString *str) {
                                      CGRect rect = CGRectMake(0, 0, 0, str.length * 3);
                                      return [NSValue valueWithCGRect:rect];
                                  }];

// 雙向綁定
RACChannelTo(self.someLabel, text) = RACChannelTo(self.viewModel, someProperty);
[self.textField.rac_newTextChannel subscribe:self.viewModel.someChannel];
[self.viewModel.someChannel subscribe:self.textField.rac_newTextChannel];
RACChannelTo(self, reviewID) = self.viewModel.someChannel;

// 命令綁定
self.confirmButton.rac_command = self.viewModel.someCommand;

RAC(self.textField, hidden) = self.viewModel.someCommand.executing;
[self.viewModel.someCommand.errors
 subscribeNext:^(NSError *error) {
     // 錯(cuò)誤處理在這里
 }];

綁定只是提供了上層的業(yè)務(wù)邏輯奢浑,更為重要的是,F(xiàn)RP的響應(yīng)式范式恰如其分地體現(xiàn)在MVVM中腋腮。一個(gè)MVVM中View就會(huì)響應(yīng)ViewModel的變化雀彼。我們來(lái)根據(jù)一副簡(jiǎn)單的圖來(lái)分析一下:

image
圖4 MVVM示意圖

上述簡(jiǎn)圖列出了View-ViewModel-Model的大致關(guān)系壤蚜,View和ViewModel間通過(guò)RACSignal來(lái)進(jìn)行單向綁定,通過(guò)RACChannel來(lái)進(jìn)行雙向綁定徊哑,通過(guò)RACCommand進(jìn)行執(zhí)行過(guò)程的綁定袜刷。

ViewModel和Model間通過(guò)RACObserve進(jìn)行監(jiān)聽(tīng),通過(guò)RACSignal進(jìn)行回調(diào)處理莺丑,也可以直接調(diào)用方法著蟹。

Model有自身的數(shù)據(jù)業(yè)務(wù)邏輯,包含請(qǐng)求Web Service和進(jìn)行本地持久化窒盐。

響應(yīng)式的體現(xiàn)就在于View從一開(kāi)始就是“聲明”了與ViewModel間的關(guān)系草则,就如同A3單元格聲明其“=A2+A1”一樣。一旦后續(xù)數(shù)據(jù)發(fā)生變化蟹漓,就按照之前的約定響應(yīng)炕横,彼此之間存在一層明確的定義。View在業(yè)務(wù)層面也得到了極大簡(jiǎn)化葡粒。

具體的數(shù)據(jù)流動(dòng)就如同下圖兩種形式:

image
image
圖5&圖6 MVVM的數(shù)據(jù)流向示意

從兩張圖中可以看出份殿,無(wú)論View收到用戶(hù)修改TextField的文本框內(nèi)容的事件,還是受到用戶(hù)點(diǎn)擊Button的事件嗽交。View層都不需要對(duì)此做特殊的邏輯處理卿嘲,只是將之傳遞給ViewModel。而ViewModel自身維護(hù)邏輯夫壁,并體現(xiàn)在某些綁定關(guān)系上拾枣。這是與MVC中ViewController和Model的關(guān)系是截然不同的。FRP的響應(yīng)式范式很好的幫助我們實(shí)現(xiàn)了此類(lèi)需求盒让。

之前雖然也提到過(guò)錯(cuò)誤處理梅肤,但是也提到美團(tuán)App在初探和入門(mén)階段,只是小規(guī)模的在模塊內(nèi)使用邑茄,對(duì)外并不會(huì)以RACSignal的形式暴露姨蝴。而這個(gè)階段,我們也嘗試了層級(jí)間通過(guò)RACSignal來(lái)進(jìn)行信息的傳遞肺缕。這也自然可以應(yīng)用上FRP異常處理的優(yōu)勢(shì)左医。

image
圖7 MVVM的數(shù)據(jù)流向示意

上圖體現(xiàn)了一個(gè)按鈕綁定了RACCommand收到錯(cuò)誤后的一個(gè)數(shù)據(jù)流向。

除了MVVM框架的應(yīng)用同木,這個(gè)階段美團(tuán)App也利用FRP解決另外的一個(gè)大問(wèn)題浮梢。那就是多線程。

如果你做過(guò)異步拉取數(shù)據(jù)主線程渲染彤路,那么你一定很熟悉子線程的回調(diào)結(jié)果轉(zhuǎn)移到主線程的過(guò)程黔寇。這種操作不但繁瑣,重復(fù)斩萌,關(guān)鍵還容易出錯(cuò)缝裤。

RAC提供了很多便于處理多線程的組件屏轰,核心類(lèi)為RACScheduler,使得可以方便的通過(guò)subscirbeOn:方法憋飞、deliverOn:方法進(jìn)行線程控制霎苗。

// 多線程控制
RACScheduler *backgroundScheduler = [RACScheduler scheduler];
RACSignal *testSignal = [[RACSignal
                         createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
                             // 本例中,這段代碼會(huì)確保運(yùn)行在子線程
                             [subscriber sendNext:@1];
                             [subscriber sendCompleted];
                             return nil;
                         }]
                         subscribeOn:backgroundScheduler];

[[RACScheduler mainThreadScheduler]
 schedule:^{
    // 這段代碼會(huì)在下次Runloop時(shí)在主線程中運(yùn)行
     [[testSignal
      deliverOn:[RACScheduler mainThreadScheduler]]
        subscribeNext:^(id x) {
            // 雖然信號(hào)的發(fā)出者是在子線程中發(fā)出消息的
            // 但是接收者確可以確保在主線程接收消息
            
            // 主線程可以做你想做的渲染工作了榛做!
        }
     ];
 }];

這一個(gè)階段也算是大躍進(jìn)的一個(gè)階段唁盏,隨著MVVM的框架融入,跨層的使用RAC使得代碼整體使用FRP的比重大幅提高检眯,全員對(duì)FRP的熟悉程度和思想的理解也變得深刻了許多厘擂。同時(shí)也真正使用了響應(yīng)式的一些思想和特性來(lái)解決實(shí)際的問(wèn)題。使其不再是紙上空談锰瘸。

我們美團(tuán)App也在這個(gè)階段挖掘了更多的RAC高級(jí)組件刽严,繼續(xù)代碼優(yōu)化的持續(xù)之路。

進(jìn)階

美團(tuán)App的iOS工程師們?cè)诖笠?guī)模使用FRP后避凝,也積蓄了很多的問(wèn)題舞萄。很多小伙伴也問(wèn)起了,既然是叫FRP管削,為什么一直體現(xiàn)的都是響應(yīng)式的思想倒脓,對(duì)于函數(shù)式的思想應(yīng)用體現(xiàn)似乎不是很明顯。雖然FRP是F開(kāi)頭含思,稱(chēng)為函數(shù)響應(yīng)式編程崎弃。但是考慮到函數(shù)式編程的復(fù)雜性,我們也將函數(shù)式編程的優(yōu)化拿到了進(jìn)階這一階段來(lái)嘗試含潘。

這一階段面臨的問(wèn)題是RAC的大規(guī)模應(yīng)用饲做,使得代碼中包含了大量的框架性質(zhì)的代碼。例如下面的代碼:

// 冗余的代碼
[[self fetchData1]
 try:^BOOL(id value,
           NSError *__autoreleasing *errorPtr) {
     if ([value isKindOfClass:[NSString class]]) {
         return YES;
     }
     *errorPtr = [NSError new];
     return NO;
 }];

[[self fetchData2]
 tryMap:^id(NSDictionary *value, NSError *__autoreleasing *errorPtr) {
     if ([value isKindOfClass:[NSDictionary class]]) {
         if (value[@"someKey"]) {
             return value[@"someKey"];
         }
         // 并沒(méi)有一個(gè)名為“someKey”的key
         *errorPtr = [NSError new];
         return nil;
     }
     // 這不是一個(gè)Dictionary
     *errorPtr = [NSError new];
     return nil;
 }];

[[self fetchData3]
 tryMap:^id(NSDictionary *value, NSError *__autoreleasing *errorPtr) {
     if ([value isKindOfClass:[NSDictionary class]]) {
         if (value[@"someOtherKey"]) {
             return value[@"someOtherKey"];
         }
         // 并沒(méi)有一個(gè)名為“someOtherKey”的key
         *errorPtr = [NSError new];
         return nil;
     }
     // 這不是一個(gè)Dictionary
     *errorPtr = [NSError new];
     return nil;
 }];

上述的幾個(gè)代碼段调鬓,我們可以看到功能非常近似,內(nèi)容稍有不同的部分重復(fù)出現(xiàn)酌伊,很多的同學(xué)在實(shí)際的開(kāi)發(fā)中也并沒(méi)有太好地優(yōu)化它們腾窝,甚至很多的同學(xué)表示束手無(wú)策。這時(shí)候函數(shù)式編程就可以派上用場(chǎng)了居砖。

函數(shù)式編程是一種良好的編程范式虹脯,我們?cè)谶@里主要利用它的幾個(gè)特點(diǎn):高階函數(shù)、不變量和迭代奏候。

先來(lái)看高階函數(shù)循集,高階函數(shù)是入?yún)⑹呛瘮?shù)或者返回值是函數(shù)的函數(shù)。說(shuō)起來(lái)雖然有些拗口蔗草,但實(shí)際上在iOS開(kāi)發(fā)中司空見(jiàn)慣咒彤,例如典型的訂閱其實(shí)就是一個(gè)高階函數(shù)的體現(xiàn)疆柔。

// 高階函數(shù)
[[self fetchData1]
 subscribeNext:^(id x) {
    // 這是一個(gè)block,作為一個(gè)參數(shù)
 }];

我們更關(guān)心的是返回值是函數(shù)的函數(shù)镶柱,這是上面冗長(zhǎng)的代碼解決之道旷档。代碼7的代碼中會(huì)發(fā)現(xiàn)一些相同的邏輯,例如類(lèi)型判斷歇拆。我們就可以先做一個(gè)這樣的小函數(shù):

typedef BOOL (^VerifyFunction)(id value);

VerifyFunction isKindOf(Class aClass)
{
    return ^BOOL(id value) {
        return [value isKindOfClass:aClass];
    };
}

瞧鞋屈,很簡(jiǎn)單對(duì)不對(duì)!只要把一個(gè)類(lèi)型傳進(jìn)去故觅,就會(huì)得到一個(gè)用來(lái)判斷某個(gè)對(duì)象是否是這個(gè)類(lèi)型的函數(shù)厂庇。細(xì)心的讀者會(huì)發(fā)現(xiàn)我們實(shí)際要的是一個(gè)入?yún)閷?duì)象和一個(gè)NSError對(duì)象指針的指針類(lèi)型,返回值是布爾類(lèi)型的block输吏,但是這個(gè)只能返回入?yún)⑹菍?duì)象的权旷,顯然不滿(mǎn)足條件。很多人第一個(gè)想到的就是把這個(gè)函數(shù)改成返回參數(shù)為兩個(gè)參數(shù)返回值為布爾類(lèi)型的block评也,但是函數(shù)式的解決方法是新增一個(gè)這樣的函數(shù):

typedef BOOL (^VerifyAndOutputErrorFunction)(id value, NSError **error);

VerifyAndOutputErrorFunction verifyAndOutputError(VerifyFunction verify,
                                              NSError *outputError)
{
    return ^BOOL(id value, NSError **error) {
    if (verify(value)) {
        return YES;
    }
    *error = outputError;
    return NO;
};

}
一個(gè)典型的高階函數(shù)炼杖,入?yún)в幸粋€(gè)block,返回值也是一個(gè)block盗迟,組合起來(lái)就可以把剛才的幾個(gè)try:代碼段優(yōu)化坤邪。可能你會(huì)問(wèn)罚缕,為什么要搞成兩個(gè)呢艇纺,一個(gè)不是更好?搞成兩個(gè)的好處就在于邮弹,我們可以將任意的VerifyFunction類(lèi)型的block與一個(gè)outputError相結(jié)合黔衡,來(lái)返回一個(gè)我們想要的VerifyAndOutputErrorFunction類(lèi)型block,例如增加一個(gè)判斷NSDictionary是否包含某個(gè)Key的VerifyFunction腌乡。下面給出一個(gè)優(yōu)化后的代碼盟劫,大家可以仔細(xì)思考下:

// 可以高度復(fù)用的函數(shù)
typedef BOOL (^VerifyFunction)(id value);

VerifyFunction isKindOf(Class aClass)
{
    return ^BOOL(id value) {
        return [value isKindOfClass:aClass];
    };
}

VerifyFunction hasKey(NSString *key)
{
    return ^BOOL(NSDictionary *value) {
        return value[key] != nil;
    };
}

typedef BOOL (^VerifyAndOutputErrorFunction)(id value, NSError **error);

VerifyAndOutputErrorFunction     verifyAndOutputError(VerifyFunction verify,
                                              NSError *outputError)
{
    return ^BOOL(id value, NSError **error) {
        if (verify(value)) {
            return YES;
        }
        *error = outputError;
        return NO;
    };
}

typedef id (^MapFunction)(id value);

MapFunction dictionaryValueByKey(NSString *key)
{
    return ^id(NSDictionary *value) {
        return value[key];
    };
}

// 與本例關(guān)聯(lián)比較大的函數(shù)
typedef id (^MapAndOutputErrorFunction)(id value, NSError **error);
MapAndOutputErrorFunction transferToKeyChild(NSString *key)
{
return ^id(id value, NSError **error) {
    if (hasKey(key)(value)) {
        return dictionaryValueByKey(key)(value);
    } else {
        *error = [NSError new];
        return nil;
    }
};
};

  - (void)oldStyle
{
// 冗余的代碼
[[self fetchData1]
 try:^BOOL(id value,
           NSError *__autoreleasing *errorPtr) {
     if ([value isKindOfClass:[NSString class]]) {
         return YES;
     }
     *errorPtr = [NSError new];
     return NO;
 }];

[[self fetchData2]
 tryMap:^id(NSDictionary *value, NSError *__autoreleasing *errorPtr) {
     if ([value isKindOfClass:[NSDictionary class]]) {
         if (value[@"someKey"]) {
             return value[@"someKey"];
         }
         // 并沒(méi)有一個(gè)名為“someKey”的key
         *errorPtr = [NSError new];
         return nil;
     }
     // 這不是一個(gè)Dictionary
     *errorPtr = [NSError new];
     return nil;
 }];

[[self fetchData3]
 tryMap:^id(NSDictionary *value, NSError *__autoreleasing *errorPtr) {
     if ([value isKindOfClass:[NSDictionary class]]) {
         if (value[@"someOtherKey"]) {
             return value[@"someOtherKey"];
         }
         // 并沒(méi)有一個(gè)名為“someOtherKey”的key
         *errorPtr = [NSError new];
         return nil;
     }
     // 這不是一個(gè)Dictionary
     *errorPtr = [NSError new];
     return nil;
 }];
}

- (void)newStyle
{

VerifyAndOutputErrorFunction isDictionary = 
  verifyAndOutputError(isKindOf([NSDictionary class]),
                      NSError.new);
VerifyAndOutputErrorFunction isString =      
  verifyAndOutputError(isKindOf([NSString class]),
                     NSError.new);

[[self fetchData1]
 try:isString];

[[[self fetchData2]
  try:isDictionary]
  tryMap:transferToKeyChild(@"someKey")];

[[[self fetchData3]
  try:isDictionary]
 tryMap:transferToKeyChild(@"someOtherKey")];
}

雖然代碼有些多,但是從newStyle函數(shù)的結(jié)果來(lái)看与纽,我們?cè)趯?shí)際的業(yè)務(wù)代碼上非常的簡(jiǎn)潔侣签,而且還抽離出很多可復(fù)用的小函數(shù)。在實(shí)際的業(yè)務(wù)中急迂,我們甚至通過(guò)這種范式在某些業(yè)務(wù)場(chǎng)景簡(jiǎn)化了超過(guò)50%的代碼量影所。

除此之外,我們還嘗試用迭代來(lái)進(jìn)一步減少臨時(shí)變量僚碎。為什么要減少臨時(shí)變量呢猴娩?因?yàn)槲覀兿胍裱蛔兞吭瓌t,這是函數(shù)式編程的一個(gè)特點(diǎn)。試想下如果我們都是使用一些不變量卷中,就不再會(huì)有那么多異步鎖和痛苦的多線程問(wèn)題了矛双。基于以上考慮仓坞,我們要求工程師盡量在開(kāi)發(fā)的過(guò)程中減少使用變量背零,從而鍛煉用更加函數(shù)式的方式來(lái)解決問(wèn)題。

例如下面的簡(jiǎn)單問(wèn)題无埃,實(shí)現(xiàn)一個(gè)每秒發(fā)送值為0 1 2 3 … 100的遞增整數(shù)信號(hào)徙瓶,實(shí)現(xiàn)的方法可以是這樣:

- (void)countSignalOldStyle
{
RACSignal *signal =
[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    RACDisposable *disposable = [RACDisposable new];
    __block int i = 0;
    __block void (^recursion)();
    recursion = ^{
        if (disposable.disposed || i > 100) {
            return;
        }
        [subscriber sendNext:@(i)];
        ++i;
        [[RACScheduler mainThreadScheduler]
         afterDelay:1 schedule:recursion];
    };
    recursion();
    return disposable;
}];
}

這樣的代碼不但用了block自遞歸,還用了一個(gè)閉包的i變量嫉称。i變量也在數(shù)次遞歸中進(jìn)行了修改侦镇。代碼不易理解且block自遞歸會(huì)存在循環(huán)引用。使用迭代和不變量的形式是這樣的:

  - (void)countSignalNewStyle
{
RACSignal *signal =
[[[[[[RACSignal return:@1]
     repeat] take: 100]
   aggregateWithStart:@0 reduce:^id(NSNumber *running,
                                    NSNumber *next) {
       return @(running.integerValue + next.integerValue);
   }]
  map:^id(NSNumber *value) {
      return [[RACSignal return:value]
              delay:1];
  }]
 concat];
}

解法是這樣的织阅,先用固定返回1的信號(hào)生成一個(gè)無(wú)限重復(fù)信號(hào)壳繁,取前100個(gè)值,然后用迭代方法荔棉,產(chǎn)生一個(gè)遞增的迭代闹炉,再將發(fā)送的密集的遞增信號(hào)轉(zhuǎn)成一個(gè)延時(shí)1秒的子信號(hào),最后將子信號(hào)進(jìn)行連接润樱。感興趣的同學(xué)可以自己動(dòng)手嘗試下渣触,也希望大家都去思考不適用變量來(lái)解決問(wèn)題的思路。

這些函數(shù)式的寫(xiě)法不僅解決了業(yè)務(wù)上的問(wèn)題壹若,也給我們美團(tuán)App的iOS工程師們開(kāi)拓了代碼優(yōu)化的新思路嗅钻。

可以看到,到這一階段店展,需要對(duì)FRP的理解要求更高养篓。為了追求更好的代碼體驗(yàn),我們朝著FRP的道路又邁進(jìn)了許多赂蕴,走到這一步是每一個(gè)美團(tuán)App的iOS工程師共同努力的結(jié)果柳弄。這是一個(gè)尚未完結(jié)的階段,我們的工程師仍然在不選找尋更好的FRP范式概说。對(duì)于開(kāi)發(fā)人員來(lái)說(shuō)碧注,優(yōu)化之路永遠(yuǎn)不會(huì)停步。

總結(jié)

單純靠這樣一篇文章來(lái)介紹全部的FRP思想是不可能的席怪,這也僅是起到了拋磚引玉的作用应闯。FRP不僅可以解決項(xiàng)目中實(shí)際遇到的很多問(wèn)題纤控,也能鍛煉更好的工程師素養(yǎng)挂捻。希望大家能夠掌握起來(lái),用FRP的思想來(lái)解決更多實(shí)際的問(wèn)題船万。社區(qū)和開(kāi)源庫(kù)也需要大家的不斷投入刻撒。謝謝大家骨田!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市声怔,隨后出現(xiàn)的幾起案子态贤,更是在濱河造成了極大的恐慌,老刑警劉巖醋火,帶你破解...
    沈念sama閱讀 218,755評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件悠汽,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡芥驳,警方通過(guò)查閱死者的電腦和手機(jī)柿冲,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)兆旬,“玉大人假抄,你說(shuō)我怎么就攤上這事±鲡” “怎么了宿饱?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,138評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)脚祟。 經(jīng)常有香客問(wèn)我谬以,道長(zhǎng),這世上最難降的妖魔是什么愚铡? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,791評(píng)論 1 295
  • 正文 為了忘掉前任蛉签,我火速辦了婚禮,結(jié)果婚禮上沥寥,老公的妹妹穿的比我還像新娘碍舍。我一直安慰自己,他們只是感情好邑雅,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布片橡。 她就那樣靜靜地躺著,像睡著了一般淮野。 火紅的嫁衣襯著肌膚如雪捧书。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,631評(píng)論 1 305
  • 那天骤星,我揣著相機(jī)與錄音经瓷,去河邊找鬼。 笑死洞难,一個(gè)胖子當(dāng)著我的面吹牛舆吮,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,362評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼色冀,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼潭袱!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起锋恬,我...
    開(kāi)封第一講書(shū)人閱讀 39,264評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤屯换,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后与学,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體彤悔,經(jīng)...
    沈念sama閱讀 45,724評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評(píng)論 3 336
  • 正文 我和宋清朗相戀三年索守,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了蜗巧。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,040評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡蕾盯,死狀恐怖幕屹,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情级遭,我是刑警寧澤望拖,帶...
    沈念sama閱讀 35,742評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站挫鸽,受9級(jí)特大地震影響说敏,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜丢郊,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評(píng)論 3 330
  • 文/蒙蒙 一盔沫、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧枫匾,春花似錦架诞、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,944評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至角虫,卻和暖如春沾谓,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背戳鹅。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,060評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工均驶, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人枫虏。 一個(gè)月前我還...
    沈念sama閱讀 48,247評(píng)論 3 371
  • 正文 我出身青樓妇穴,卻偏偏與公主長(zhǎng)得像亮垫,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子伟骨,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容