18 年 7 月美團開源了 EasyReact,告知 iOS 工程師們響應式編程和函數式編程並不是不可分離,彷佛一出來就想將 ReactiveCocoa 踢出神壇。該框架使用圖論來解決響應式編程確實是一個顛覆性的思想,因爲 ReactiveCocoa 的各類弊端讓不少團隊望而卻步,而 EasyReact 的出現無疑讓不少人重拾對響應式編程的但願。html
官方資料: 美團客戶端響應式框架 EasyReact 開源啦 EasyReact GitHubnode
只須要大體看一下官方的介紹,就很容易理解到圖論在響應式編程中扮演的角色,無論如何複雜的響應鏈都能經過有向有環圖來表示,而數據的流動依賴深搜或廣搜。單從框架的理解難易程度來看,EasyReact 完勝。react
本文介紹 EasyReact 的源碼技術細節,因爲框架依賴庫代碼量較大,因此只會較爲抽象的介紹比較核心和重要的部分,而且但願讀者能優先閱讀官方資料以下降理解本文的成本。git
首先,咱們須要脫離具體的業務,從圖論的要素來思考框架的構成。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 (弱引用)
,from
到to
的方向就是數據流動的方向。圖中的an EZRTransform
和an EZRListen
分別是可變邊和監聽邊的一個實例,箭頭的方向表示數據流動的方向。當執行了如下代碼事後:bash
nodeA.value = @10;
複製代碼
打印:數據結構
nodeB 改變:10
複製代碼
@10
這個對象經過圖中箭頭的方向依次傳遞,最終由self
捕獲到並打印出來。這就是框架的通常邏輯,結構是易懂且清晰的,經過對邊的各類邏輯處理來達到控制數據傳遞的目的。更具體的東西請看官方文檔和源碼。
在一個響應鏈中,始終是數據的消費者持有數據的提供者。也就是說,數據流動的方向每每和強引用方向相反,前面那張圖反過來就是強引用關係:
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];
複製代碼
經過閱讀源碼得知強引用關係如圖(箭頭表示強引用):
實際上框架的圖結構就是以上兩種鏈接方式的組合,咱們用強引用的關係來分析它們能清晰的理解框架的內存管理策略。
有這樣一種場景:
圖中箭頭的方向表示數據流動的方向,這就是比較典型的有向有環圖,這種結構會帶來兩個問題:
第一個問題實際上很簡單,若是業務中寫了這種結構,只須要手動破除循環引用。把關注點放到第二問題上,數據流動無限循環將會棧溢出帶來災難性的後果,框架是如何避免的呢,官方文檔只說了經過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
就好了加鎖操做,保證全局變量的安全,同時避免同一線程的重入也恰巧避免了重複獲取鎖致使的死鎖。
這確實是一個很是巧妙且使人興奮的技巧。
EZRTransform
有不少衍生類,每個都對應一種變換。什麼叫變換呢?也就是在數據傳到EZRTransform
的時候,EZRTransform
對數據進行處理,而後再按照特定的邏輯繼續發送。
EasyReact 自帶有很是多的變換處理,好比map
、filter
、scan
、merge
等,能夠到 GitHub 查看其使用,也能夠直接查看源碼,大多數的變換的實現都是很簡單易懂的,筆者這裏只列舉並解析幾個稍微比較複雜的實現(主要是經過結構圖來解析,最好是對照源碼理解)。
響應式編程常常會使用 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 的邊叫作EZRCombineTransform
,同時有一個EZRCombineTransformGroup
做爲處理器,它持有了全部相關的邊,當數據通過EZRCombineTransform
時,交由處理器將全部邊的值相加,而後繼續發送。
拉鍊操做是這樣的一種操做:它將多個節點做爲上游,全部的節點的第一個值放在一個元組裏,全部的節點的第二個值放在一個元組裏……以此類推,以這些元組做爲值的就是下游。它就好像拉鍊同樣一個扣着一個:
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 的數據結構實現和 combine 一模一樣,不一樣的是,每個EZRZipTransform
都維護了一個新值的隊列,當數據流動時,EZRZipTransformGroup
會讀取每個邊對應隊列的頂部元素(同時出隊),若某一個邊的隊列未讀取到新值則中止數據傳播。
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
邊鏈接liLeiSaid
、hanMeimeiSaid
、aside
下游節點,而且每個下游節點存儲了一個匹配分支的key
(也就是例子中的「李雷」、「韓梅梅」等)。
當node
發送數據過來時,由EZRSwitchMapTransform
經過分支劃分規則處理數據,而後將每個分支節點經過 hash 容器裝起來,也就是圖中的藍色節點case node
,這個例子發送的數個消息最終會建立三個分支;在建立分支完成事後,EZRSwitchMapTransform
向下遊繼續發送數據,在數據到達EZRCaseTransform
時,該邊會監聽對應的case node
(固然前提是匹配)而不會繼續向下遊發送數據;而後EZRSwitchMapTransform
會繼續改變對應case node
的值,由此EZRCaseTransform
就接收到了數據改變的通知,最終發送給下游節點,即這裏的liLeiSaid
、hanMeimeiSaid
或aside
。
case node 中間節點的意義
貌似沒有case node
節點也能實現 switch 功能,通過筆者思考,猜想做者此處設計的深意:由EZRSwitchMapTransform
預處理獲得key
和最終須要傳遞的數據value
;而EZRCaseTransform
只需關心key
是否對應,若對應纔去監聽對應的case node
。如此作法有兩點意義:
EZRCaseTransform
接收到與它不匹配的value
,也能夠避免鏈接在nodes
節點的非EZRCaseTransform
邊接收到value
,由此保證value
的安全。EZRCaseTransform
想要取消對 switch 分支數據的接收,而又要繼續保持上游邊的結構,能夠直接取消對case node
的監聽(雖然框架沒有這個功能)。在源碼的閱讀中,發現了幾個有意思的代碼技巧。
- (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 中也體會到了一些設計思惟,從代碼質量來講確實是上乘的,閱讀過程當中很是的流暢,不少看起來簡單的實現,細想事後能發現使人驚喜的做用。
總體來講,收穫頗豐,給美團技術團隊點個贊。