美團 EasyReact 源碼剖析:圖論與響應式編程

前言

18 年 7 月美團開源了 EasyReact,告知 iOS 工程師們響應式編程和函數式編程並不是不可分離,彷佛一出來就想將 ReactiveCocoa 踢出神壇。該框架使用圖論來解決響應式編程確實是一個顛覆性的思想,因爲 ReactiveCocoa 的各類弊端讓不少團隊望而卻步,而 EasyReact 的出現無疑讓不少人重拾對響應式編程的但願。html

官方資料: 美團客戶端響應式框架 EasyReact 開源啦 EasyReact GitHubnode

只須要大體看一下官方的介紹,就很容易理解到圖論在響應式編程中扮演的角色,無論如何複雜的響應鏈都能經過有向有環圖來表示,而數據的流動依賴深搜廣搜。單從框架的理解難易程度來看,EasyReact 完勝。react

本文介紹 EasyReact 的源碼技術細節,因爲框架依賴庫代碼量較大,因此只會較爲抽象的介紹比較核心和重要的部分,而且但願讀者能優先閱讀官方資料以下降理解本文的成本。git

1、框架總體認識

首先,咱們須要脫離具體的業務,從圖論的要素來思考框架的構成。github

既然是圖,那必然有節點和邊,框架有兩種節點,一種是EZRNode<T>泛型標準節點,一種是任意對象;框架也有兩種邊,一種EZRTransform可變換的邊,一種是EZRListen監聽邊,固然邊的衍生類不少而且實現了數個協議。編程

在控制器中寫這樣一段代碼:數組

- (void)viewDidLoad {
    [super viewDidLoad];
    
    EZRMutableNode<NSNumber *> *nodeA = [EZRMutableNode new];
    EZRMutableNode<NSNumber *> *nodeB = [EZRMutableNode new];
    [nodeB linkTo:nodeA];
    [[nodeB listenedBy:self] withBlock:^(NSNumber * _Nullable next) {
        NSLog(@"nodeB 改變:%@", next);
    }];
}
複製代碼

建立兩個可變的節點,而且讓nodeB鏈接到nodeA,同時讓self做爲nodeB的監聽者。-linkTo:-listenedBy:都是語法糖暫時不用管具體含義,這段代碼轉換爲一張圖以下:安全

邊有兩個很重要的屬性from (強引用)to (弱引用)fromto的方向就是數據流動的方向。圖中的an EZRTransforman EZRListen分別是可變邊和監聽邊的一個實例,箭頭的方向表示數據流動的方向。當執行了如下代碼事後:bash

nodeA.value = @10;
複製代碼

打印:數據結構

nodeB 改變:10
複製代碼

@10這個對象經過圖中箭頭的方向依次傳遞,最終由self捕獲到並打印出來。這就是框架的通常邏輯,結構是易懂且清晰的,經過對邊的各類邏輯處理來達到控制數據傳遞的目的。更具體的東西請看官方文檔和源碼。

2、內存管理策略

在一個響應鏈中,始終是數據的消費者持有數據的提供者。也就是說,數據流動的方向每每和強引用方向相反,前面那張圖反過來就是強引用關係:

self --> an EZRListen --> nodeB --> an EZRTransform --> nodeA
複製代碼

由於在業務中,監聽者節點每每關係到具體業務,沒有監聽者那麼其它節點就沒有了存在的意義,因此框架的思想是使用監聽者來做爲結點的最終強持有者。

下面經過節點與邊的兩種鏈接方式驗證內存管理策略。

監聽者鏈接實現

[[nodeB listenedBy:self] withBlock:^(NSNumber * _Nullable next) {}];
複製代碼

經過閱讀源碼得知強引用關係如圖(箭頭表示強引用):

圖中已經很明顯了,只要監聽者節點釋放,其它的對象都將不復存在。而其中的引用關係剛好能表示實現監聽的數據結構,使用Dictionary是爲了讓監聽者能響應不一樣節點的監聽,後面使用Array是爲了讓監聽者能對同一節點進行屢次監聽,結合源碼來看應該很容易就理解了。

同時,因爲EZRNode的改變要傳遞到監聽者節點,因此必然會有必要的反向弱引用,這裏就很少說了。

節點鏈接實現

EZRMutableNode<NSNumber *> *nodeA = [EZRMutableNode new];
    EZRMutableNode<NSNumber *> *nodeB = [EZRMutableNode new];
    [nodeB linkTo:nodeA];
複製代碼

經過閱讀源碼得知強引用關係如圖(箭頭表示強引用):

實際上框架的圖結構就是以上兩種鏈接方式的組合,咱們用強引用的關係來分析它們能清晰的理解框架的內存管理策略。

3、數據流動帶來的問題

數據流動循環

有這樣一種場景:

圖中箭頭的方向表示數據流動的方向,這就是比較典型的有向有環圖,這種結構會帶來兩個問題:

  1. 造成引用環,沒法自動釋放內存。
  2. 數據流動會陷入無限循環。

第一個問題實際上很簡單,若是業務中寫了這種結構,只須要手動破除循環引用。把關注點放到第二問題上,數據流動無限循環將會棧溢出帶來災難性的後果,框架是如何避免的呢,官方文檔只說了經過EZRSenderList來避免,下面看看源碼中具體是如何實現的。

EZRMutableNode節點中,數據傳遞必然會走的方法是:

- (void)next:(nullable id)value from:(EZRSenderList *)senderList context:(nullable id)context {
    ...
    [self _next:value from:senderList context:context];
    ...
}
- (void)_next:(nullable id)value from:(EZRSenderList *)senderList context:(nullable id)context {
    ...
    //賦值
    _value = value;
    ...
    //拼接當前節點
    EZRSenderList *newQueue = [senderList appendNewSender:self];
    //遍歷監聽邊發送數據
    for (... item in self.privateListenEdges) {
        [item next:value from:newQueue context:context];
    }
    //遍歷下游可變邊發送數據
    for (... item in self.privateDownstreamTransforms) {
        if (![senderList contains:item.to]) {
             [item next:value from:newQueue context:context];
        }
    }
  ...
}
複製代碼

省去並修改了不少代碼變成了僞代碼,這和源碼是不一致的,便於查看邏輯。能夠看到執行了兩個for循環,self.privateListenEdges是監聽邊集合,self.privateDownstreamTransforms是下游的可變邊集合,它們的元素在構建圖的時候已經準備好了,經過遍歷這兩個集合實現遞歸深搜將數據傳遞下去。

EZRSenderList是一個鏈表,能夠注意到[senderList appendNewSender:self]代碼,將當前節點拼接進鏈表,這個鏈表的生命週期是一次數據流動過程。在遍歷下游可變邊的時候有一個判斷:if (![senderList contains:item.to]) {},實際上這就是阻止無限循環的核心操做,即若數據流動鏈表中包含了當前節點,就截斷,避免無限循環。

nodeA --> nodeB --> nodeC |senderList裏面有nodeA,截斷| --> nodeA
複製代碼

數據流動重入

思考這樣一種場景:

紅色的邊是監聽邊,黑色的邊表示可變邊,此處表示nodeA監聽了nodeB的變化,當nodeB的值變化的時候,會遍歷監聽邊發送數據,也就是會通知到nodeA

須要注意的是,節點只在遍歷下游可變邊時經過EZRSenderList截斷循環,而在遍歷監聽邊時未作處理,這是因爲監聽邊不會讓to對應的節點繼續深搜傳遞數據,而是直接發送一個通知,因此每個由業務工程師建立的監聽都是有意義的。

若出現如下狀況:

nodeA --> nodeB [nodeA監聽到改變: nodeA --> nodeB (執行有限次)] --> nodeC
複製代碼

nodeA值改變,傳遞到nodeB,當nodeA監聽到nodeB值變化值,nodeA又一次改變本身的值向nodeB傳遞數據nodeA --> nodeB,這種狀況會致使這次流動的數據可能會被更改而不安全。監聽回調的操做邏輯一般是業務工程師來寫,在特定的業務場景下這種狀況是可能出現的。

那麼,如何保證一次數據流動不可重入,以此保證數據安全?

EZRMutableNode.m中,先來看一個相當重要的類(EZTuple3是元祖,不用糾結其實現):

@interface EZRSettingQueue: NSObject
//是不是第一次使用該實例
@property (nonatomic, assign) BOOL firstSetting;
//隊列
@property (nonatomic, strong) NSMutableArray<EZTuple3<id, EZRSenderList *, id> *> *queue;
//入隊
- (void)enqueue:(EZTuple3<id, EZRSenderList *, id> *)tuple;
//出隊
- (EZTuple3<id, EZRSenderList *, id> *)dequeue;
@end
複製代碼

從 API 看就一目瞭然,這個類的做用是封裝了一個隊列,而後有一個屬性firstSetting來判斷是不是第一次使用該實例,接下來看一個方法:

- (EZRSettingQueue *)currentSettingQueue {
    EZRSettingQueue *settingQueue = [NSThread currentThread].threadDictionary[_settingQueueKey];
    if (settingQueue == nil) {
        settingQueue = [EZRSettingQueue new];
        [NSThread currentThread].threadDictionary[_settingQueueKey] = settingQueue;
    }
    return settingQueue;
}
複製代碼

經過一個線程附帶的 hash 容器,保存一個EZRSettingQueue對象,這個_settingQueueKey是當前節點惟一標識。而後接着看下一個方法:

- (void)checkSettingQueue {
    EZRSettingQueue *settingQueue = self.currentSettingQueue;
    if (settingQueue.queue.count) {
        [self settingDequeue];
    } else {
        [NSThread currentThread].threadDictionary[_settingQueueKey] = nil;
    }
}
複製代碼

這個方法判斷了這個線程持有EZRSettingQueue隊列是否爲空,若爲空將它從線程字典中剔除,不然執行下面方法:

- (void)settingDequeue {
    EZTuple3<id, EZRSenderList *, id> *tuple = [self.currentSettingQueue dequeue];
    [self _next:tuple.first from:tuple.second context:tuple.third];
}
複製代碼

取出隊列中的元素,而且調用節點的數據傳送方法-_next...,到這裏其實就能夠猜到EZRSettingQueue是用來存儲數據流動相關數據的。那麼,咱們來看數據流動流程裏面是如何調用這些方法的:

- (void)next:(nullable id)value from:(EZRSenderList *)senderList context:(nullable id)context {
    EZRSettingQueue *settingQueue = self.currentSettingQueue;
    if EZR_LikelyYES(settingQueue.firstSetting) {
        settingQueue.firstSetting = NO;
        [self _next:value from:senderList context:context];
    } else {
        [settingQueue enqueue:EZTuple(value, senderList, context)];
    }
}
- (void)_next:(nullable id)value from:(EZRSenderList *)senderList context:(nullable id)context {
    ...lock {
        _value = value;
    }
    ...
    //深搜發送數據
    ...
    [self checkSettingQueue];
}
複製代碼

能夠看到,在深搜發送數據完畢以後,會調用-checkSettingQueue方法。

狀況一:深搜完成以前不會再次進入-next:...方法,那麼-checkSettingQueue會將線程字典裏面的隊列清空,那麼 if (settingQueue.firstSetting)這個判斷將始終爲true,這種狀況下發現EZRSettingQueue並無起到做用。

狀況二:深搜的過程當中,再次進入了當前節點的-next:...方法,這時if (settingQueue.firstSetting)判斷就爲false了,那麼就會將發送數據必備的參數入隊到EZRSettingQueue隊列中。當深搜發送數據完成事後,調用-checkSettingQueue方法執行在隊列中的任務。如此,經過避免同一個節點的-next:...重入來保證一次數據流動過程的安全。固然,有可能數據流動會無限循環仍然致使棧溢出,但這屬於業務工程師「指定」的邏輯。

值得注意的是,狀況二的分析是創建在同一線程的。延遲執行隊列EZRSettingQueue是放在線程字典中的,意味着在同一線程一次數據流動是不可重複進入的,而不一樣線程的重複進入不作處理(由於不一樣線程擁有不一樣的棧空間,不會相互影響)。而對於多線程狀況,-_next:...方法中對_value = value就好了加鎖操做,保證全局變量的安全,同時避免同一線程的重入也恰巧避免了重複獲取鎖致使的死鎖。

這確實是一個很是巧妙且使人興奮的技巧。

4、邊的變換

EZRTransform有不少衍生類,每個都對應一種變換。什麼叫變換呢?也就是在數據傳到EZRTransform的時候,EZRTransform對數據進行處理,而後再按照特定的邏輯繼續發送。

EasyReact 自帶有很是多的變換處理,好比mapfilterscanmerge等,能夠到 GitHub 查看其使用,也能夠直接查看源碼,大多數的變換的實現都是很簡單易懂的,筆者這裏只列舉並解析幾個稍微比較複雜的實現(主要是經過結構圖來解析,最好是對照源碼理解)。

combine

響應式編程常常會使用 a := b + c 來舉例,意圖是當 b 或者 c 的值發生變化的時候,a 會保持二者的加和。那麼在響應式庫 EasyReact 中,咱們是怎樣體現的呢?就是經過 EZRCombine-mapEach 操做:

EZRMutableNode<NSNumber *> *nodeA = [EZRMutableNode value:@1];
EZRMutableNode<NSNumber *> *nodeB = [EZRMutableNode value:@2];
EZRNode<NSNumber *> *nodeC = [EZRCombine(nodeA, nodeB) mapEach:^NSNumber *(NSNumber *a, NSNumber *b) {
  return @(a.integerValue + b.integerValue);
}];

nodeC.value;           // <- 1 + 2 = 3
nodeA.value = @4;
nodeC.value;           // <- 4 + 2 = 6
nodeB.value = @6;
nodeC.value;           // <- 4 + 6 = 10
複製代碼

上面是官方的描述和例子,實際上 combine 操做就是nodeC的值始終等於nodeA + nodeB

combine

實現 combine 的邊叫作EZRCombineTransform,同時有一個EZRCombineTransformGroup做爲處理器,它持有了全部相關的邊,當數據通過EZRCombineTransform時,交由處理器將全部邊的值相加,而後繼續發送。

zip

拉鍊操做是這樣的一種操做:它將多個節點做爲上游,全部的節點的第一個值放在一個元組裏,全部的節點的第二個值放在一個元組裏……以此類推,以這些元組做爲值的就是下游。它就好像拉鍊同樣一個扣着一個:

EZRMutableNode<NSNumber *> *nodeA = [EZRMutableNode value:@1];
EZRMutableNode<NSNumber *> *nodeB = [EZRMutableNode value:@2];
EZRNode<EZTuple2<NSNumber *, NSNumber *> *> *nodeC = [nodeA zip:nodeB];

[[nodeC listenedBy:self] withBlock:^(EZTuple2<NSNumber *, NSNumber *> *tuple) {
  NSLog(@"接收到 %@", tuple);
}];
nodeA.value = @3;
nodeA.value = @4;
nodeB.value = @5;
nodeA.value = @6;
nodeB.value = @7;
/* 打印以下:
接收到 <EZTuple2: 0x60800002b140>(
  first = 1;
  second = 2;
  last = 2;
)
接收到 <EZTuple2: 0x60800002ac40>(
  first = 3;
  second = 5;
  last = 5;
)
接收到 <EZTuple2: 0x600000231ee0>(
  first = 4;
  second = 7;
  last = 7;
)
 */
複製代碼

zip

zip 的數據結構實現和 combine 一模一樣,不一樣的是,每個EZRZipTransform都維護了一個新值的隊列,當數據流動時,EZRZipTransformGroup會讀取每個邊對應隊列的頂部元素(同時出隊),若某一個邊的隊列未讀取到新值則中止數據傳播。

switch

switch-case-default 變換是經過給出的 block 將每一個上游的值代入,求出惟一標識符,再分離這些標識符的一種操做。咱們舉例一個分離劇本的例子:

EZRMutableNode<NSString *> *node = [EZRMutableNode new];
EZRNode<EZRSwitchedNodeTuple<NSString *> *> *nodes = [node switch:^id<NSCopying> _Nonnull(NSString * _Nullable next) {
  NSArray<NSString *> *components = [next componentsSeparatedByString:@":"];
  return components.count > 1 ? components.firstObject: nil;
}];
EZRNode<NSString *> *liLeiSaid = [nodes case:@"李雷"];
EZRNode<NSString *> *hanMeimeiSaid = [nodes case:@"韓梅梅"];
EZRNode<NSString *> *aside = [nodes default];
[[liLeiSaid listenedBy:self] withBlock:^(NSString *next) {
  NSLog(@"李雷節點接到臺詞: %@", next);
}];
[[hanMeimeiSaid listenedBy:self] withBlock:^(NSString *next) {
  NSLog(@"韓梅梅節點接到臺詞: %@", next);
}];
[[aside listenedBy:self] withBlock:^(NSString *next) {
  NSLog(@"旁白節點接到臺詞: %@", next);
}];
node.value = @"在一個寧靜的下午";
node.value = @"李雷:你們好,我叫李雷。";
node.value = @"韓梅梅:你們好,我叫韓梅梅。";
node.value = @"李雷:你好韓梅梅。";
node.value = @"韓梅梅:你好李雷。";
node.value = @"因而他們幸福的在一塊兒了";
/* 打印以下:
旁白節點接到臺詞: 在一個寧靜的下午
李雷節點接到臺詞: 李雷:你們好,我叫李雷。
韓梅梅節點接到臺詞: 韓梅梅:你們好,我叫韓梅梅。
李雷節點接到臺詞: 李雷:你好韓梅梅。
韓梅梅節點接到臺詞: 韓梅梅:你好李雷。
旁白節點接到臺詞: 因而他們幸福的在一塊兒了
 */
複製代碼

分支的實現幾乎是最複雜的了,node首先經過EZRSwitchMapTransform邊鏈接一個nodes下游節點,而且初始化一個分支劃分規則 (block);而後nodes節點分別經過EZRCaseTransform邊鏈接liLeiSaidhanMeimeiSaidaside下游節點,而且每個下游節點存儲了一個匹配分支的key(也就是例子中的「李雷」、「韓梅梅」等)。

node發送數據過來時,由EZRSwitchMapTransform經過分支劃分規則處理數據,而後將每個分支節點經過 hash 容器裝起來,也就是圖中的藍色節點case node,這個例子發送的數個消息最終會建立三個分支;在建立分支完成事後,EZRSwitchMapTransform向下遊繼續發送數據,在數據到達EZRCaseTransform時,該邊會監聽對應的case node(固然前提是匹配)而不會繼續向下遊發送數據;而後EZRSwitchMapTransform會繼續改變對應case node的值,由此EZRCaseTransform就接收到了數據改變的通知,最終發送給下游節點,即這裏的liLeiSaidhanMeimeiSaidaside

case node 中間節點的意義

貌似沒有case node節點也能實現 switch 功能,通過筆者思考,猜想做者此處設計的深意:由EZRSwitchMapTransform預處理獲得key和最終須要傳遞的數據value;而EZRCaseTransform只需關心key是否對應,若對應纔去監聽對應的case node。如此作法有兩點意義:

  1. 能夠避免EZRCaseTransform接收到與它不匹配的value,也能夠避免鏈接在nodes節點的非EZRCaseTransform邊接收到value,由此保證value的安全。
  2. EZRCaseTransform想要取消對 switch 分支數據的接收,而又要繼續保持上游邊的結構,能夠直接取消對case node的監聽(雖然框架沒有這個功能)。

5、代碼細節及優化

在源碼的閱讀中,發現了幾個有意思的代碼技巧。

自動解鎖

- (void)_next:(nullable id)value from:(EZRSenderList *)senderList context:(nullable id)context {
    {
        EZR_SCOPELOCK(_valueLock);
        _value = value;
    }
    ...
}
複製代碼

EZR_SCOPELOCK()宏的出場率至關高,直接查看實現:

#define EZR_SCOPELOCK(LOCK) /
EZR_LOCK(LOCK);  /
EZR_LOCK_TYPE EZR_CONCAT(auto_lock_, __LINE__) /
__attribute__((cleanup(EZR_unlock), unused)) = LOCK
複製代碼

能夠看到先是對傳進來的鎖進行加鎖操做,後面關鍵的有句代碼:

__attribute__((cleanup(AnyFUNC), unused))
複製代碼

這句代碼加在局部變量後面,將會在局部變量做用域結束以前調用AnyFUNC方法。那麼此處的目的很簡單,看一眼這裏的EZR_unlock幹了什麼:

static inline void EZR_unlock(EZR_LOCK_TYPE *lock) {
    EZR_UNLOCK(*lock);
}
複製代碼

具體的宏能夠看源碼,此處只是作了一個解鎖操做,由此就實現了自動解鎖功能。這就是爲何要用大括號把加鎖的代碼包起來,能夠理解爲限定加鎖的臨界區。

雖然少寫句代碼的意義不大,可是卻比較炫。

分支預測

常常會看到相似的代碼:

if EZR_LikelyNO(value == EZREmpty.empty) {
    ...
}
複製代碼

EZR_LikelyNO系列宏出場率也是極高的:

#define EZR_Likely(x) (__builtin_expect(!!(x), 1))
#define EZR_Unlikely(x) (__builtin_expect(!!(x), 0))
#define EZR_LikelyYES(x) (__builtin_expect(x, YES))
#define EZR_LikelyNO(x) (__builtin_expect(x, NO))
複製代碼

能夠看到實際上就是__builtin_expect()函數的宏,!!(x)是爲了把非 0 變量變爲 1 。

咱們知道 CPU 有流水線執行能力,當處理分支程序時,判斷成功事後可能會產生指令的跳轉,打斷 CPU 對指令的處理,而且直到判斷完成這個過程當中,CPU 可能流水執行了大量的無用邏輯,浪費了時鐘週期。

簡單分析一下:

1 讀取指令 | 執行指令 | 輸出結果   (判斷指令)
2           讀取指令 | 執行指令 | 輸出結果
3                     讀取指令 | 執行指令 | 輸出結果
複製代碼

假設一條指令的執行分爲三個階段,若這裏是一個分支語句判斷,第 1 行是判斷指令,在判斷指令輸出結果時,下面兩條指令已經在執行中了,而判斷結構是走另一個分支,這就必然須要跳轉指令,而放棄 二、3 條指令的執行或結果。

那麼怎樣保證儘可能不跳轉指令呢?

答案就是分支預測,經過工程師對業務的理解,告知編譯器哪一個分支機率更大,好比:

if (__builtin_expect(someValue, NO)) {
    //爲真代碼
} else {
    //爲假代碼
}
複製代碼

那麼在編譯後,可執行文件中「爲假代碼」轉換的指令將會靠前,優先執行。

後語

EasyReact 將圖論與響應式編程結合起來表現很是好,將各類複雜邏輯都用相同的思惟處理,無論從理解上仍是使用上都很是具備親和性。

不過 EasyReact 做爲美團組件庫中的一個組件來講是很合適的,可是若是做爲一個獨立的框架來講卻顯得有點臃腫了。

做爲一個普通的開發者,可能更多的想如何高效且快捷的作一個框架,畢竟少有團隊擁有美團的技術實力。好比框架依賴了 EasySequence,這個東西對於 EasyReact 來講沒有太大意義,弱引用容器也能夠用NSPointerArray替代;EasyTuple 元祖的實現有些複雜了,若是是我的框架的話建議使用 C++ 的 tuple;隊列、鏈表等數據結構也不需本身實現,隊列能夠用 C++ 的queue,鏈表用 Objective-C 數組或 C 數組來表示也更加輕量。

這種從公司剝離的框架老是會有不少限制,好比公司的代碼規範、類庫使用規範,確定遠不及我的框架的自由和隨性。

在 EasyReact 中也體會到了一些設計思惟,從代碼質量來講確實是上乘的,閱讀過程當中很是的流暢,不少看起來簡單的實現,細想事後能發現使人驚喜的做用。

總體來講,收穫頗豐,給美團技術團隊點個贊。

相關文章
相關標籤/搜索