1. 程式人生 > >美團客戶端響應式框架 EasyReact 開源啦

美團客戶端響應式框架 EasyReact 開源啦

前言

EasyReact 是一款基於響應式程式設計正規化的客戶端開發框架,開發者可以使用此框架輕鬆地解決客戶端的非同步問題。

目前 EasyReact 已在美團和大眾點評客戶端的部分業務中實踐,並且持續迭代了一年多的時間。近日,我們決定開源這個專案的 iOS Objective-C 語言部分,希望能夠幫助更多的開發者不斷探索更廣泛的業務場景,也歡迎更多的社群的開發者跟我們一起加強 EasyReact 的功能。Github 的專案地址,參見 https://user-gold-cdn.xitu.io/2018/7/23/164c5645f60acc37

背景

美團 iOS 客戶端團隊在業界比較早地使用響應式來解決專案問題,為此我們引入了 ReactiveCocoa 這個函式響應式框架(相關實踐,參考之前的

系列部落格)。隨著業務的急速擴張和團隊拆分變更,ReactiveCocoa 在解決非同步問題的同時也帶來了新的挑戰,總結起來有以下幾點:

  1. 高學習門檻
  2. 易出錯
  3. 除錯困難
  4. 風格不統一

既然響應式程式設計帶來了這麼多的麻煩,是否我們應該摒棄響應式程式設計,用更通俗易懂的面向物件程式設計來解決問題呢?這要從移動端開發的特點說起。

移動端開發特點

客戶端程式本身充滿非同步的場景。客戶端的主要邏輯就是從檢視中處理控制元件事件,通過網路獲取後端內容再展示到檢視上。這其中事件的處理和網路的處理都是非同步行為。

一般客戶端程式發起網路請求後程序會非同步的繼續執行,等待網路資源的獲取。通常我們還會需要設定一定的標誌位和顯示一些載入指示器來讓檢視進行等待。但是當網路進行獲取的時候,通知、UI 事件、定時器都對狀態產生改變就會導致狀態的錯亂。我們是否也遇到過:忙碌指示器沒有正確隱藏掉,頁面的顯示的欄位被錯誤的顯示成舊的值,甚至一個頁面幾個部分資訊不同步的情況?

單個的問題看似簡單,但是客戶端飛速發展的今年,很多公司包括美團在內的客戶端程式碼行數早已突破百萬。業務邏輯愈發複雜,使得維護狀態本身就成了一個大問題。響應式程式設計正是解決這個問題的一種手段。

響應式程式設計的相關概念

響應式程式設計是基於資料流動程式設計的一種程式設計正規化。做過 iOS 客戶端開發的同學一定了解過 KVO 這一系列的 API。
KVO 幫助我們將屬性的變更和變更後的處理分離開,大大簡化了我們的更新邏輯。響應式程式設計將這一優勢體現得更加淋漓盡致,可以簡單的理解成一個物件的屬性改變後,另外一連串物件的屬性都隨之發生改變。

響應式的最簡單例子莫過於電子表格,Excel 和 Numbers 中單元格公式就是一個響應的例子。我們只需要關心單元格和單元格的關係,而不需要關心當一個單元格發生變化,另外的單元格需要進行怎樣的處理。“程式”的書寫被提前到事件發生之前,所以響應式程式設計是一種宣告式程式設計。它幫助我們將更多的精力集中在描述資料流動的關係上,而不是關注資料變化時處理的動作。

單純的響應式程式設計,比如電子表格中的公式和 KVO 是比較容易理解的,但是為了在 Objective-C 語言中支援響應式特性,ReactiveCocoa 使用了函式響應式程式設計的手段實現了響應式程式設計框架。而函數語言程式設計正是造成大家學習路徑陡峭的主要原因。在函數語言程式設計的世界中, 一切又都複雜起來了。這些複雜的概念,如 Immutable、Side effect、High-order Function、Functor、Applicative、Monad 等等,讓很多開發者望而卻步。

防不勝防的錯誤

函數語言程式設計主要使用高階函式來解決問題,對映到 Objective-C 語言中就是使用 Block 來進行主要的處理。由於 Objective-C 使用自動引用計數(ARC)來管理記憶體,一旦出現迴圈引用,就需要程式設計師主動破除迴圈引用。而 Block 閉包捕獲變數最容易形成迴圈引用。無腦的 weakify-strongify 會引起提早釋放,而無腦的不使用 weakify-strongify 則會引起迴圈引用。即便是“老手”在使用的過程中,也難免出錯。

另外,ReactiveCocoa 框架為了方便開發者更快的使用響應式程式設計,Hook 了很多 Cocoa 框架中的功能,例如 KVO、Notification Center、Perform Selector。一旦其它框架在 Hook 的過程中與之形成衝突,後續問題的排查就變得十分困難。

除錯的困難性

函式響應式程式設計使用高階函式還帶來了另外一個問題,那就是大量的巢狀閉包函式導致的呼叫棧深度問題。在 ReactiveCocoa 2.5 版本中,進行簡單的 5 次變換,其呼叫棧深度甚至達到了 50 層(見下圖)。

ReactiveCocoa 的呼叫棧

仔細觀察呼叫棧,我們發現整個呼叫棧的內容極為相似,難以從中發現問題所在。

另外非同步場景更是給除錯增加了新的難度。很多時候,資料的變化是由其他佇列派發過來的,我們甚至無法在呼叫棧中追溯資料變化的來源。

風格差異化

業內很多人使用 FRP 框架來解決 MVVM 架構中的繫結問題。在業務實踐中很多操作是高度相似且可被泛化的,這也意味著,可以被腳手架工具自動生成。

但目前業內知名的框架並沒有提供相應的工具,最佳實踐也無法“模板化”地傳遞下去。這就導致了對於 MVVM 和響應式程式設計,大家有了各自不同的理解。

EasyReact的初心

EasyReact 的誕生,其初心是為了解決 iOS 工程實現 MVVM 架構但沒有對應的框架支撐,而導致的風格不統一、可維護性差、開發效率低等多種問題。而 MVVM 中最重要的一個功能就是繫結,EasyReact 就是為了讓繫結和響應式的程式碼變得 Easy 起來。

它的目標就是讓開發者能夠簡單的理解響應式程式設計,並且簡單的將響應式程式設計的優勢利用起來。

EasyReact 依賴庫介紹

EasyReact 先是基於 Objective-C 開發。而 Objective-C 是一門古老的程式語言,在 2014 年蘋果公司推出 Swift 程式語言之後,Objective-C 已經基本不再更新,而 Swift支援的 Tuple 型別和集合型別自帶的 mapfilter 等方法會讓程式碼更清晰易讀。
在 EasyReact Objective-C 版本的開發中,我們還衍生了一些周邊庫以支援這些新的程式碼技巧和語法糖。這些周邊庫現已開源,並且可以獨立於 EasyReact 使用。

EasyTuple

EasyTuple 使用巨集構造出類似 Swift 的 Tuple 語法。使用 Tuple 可以讓你在需要傳遞一個簡單的資料架構的時,不必手動為其建立對應的類,輕鬆的交給框架解決。

EasySequence

EasySequence 是一個給集合型別擴充套件的庫,可以清晰的表達對一個集合型別的迭代操作,並且通過巧妙的手法可以讓這些迭代操作使用鏈式語法拼接起來。同時 EasySequence 也提供了一系列的 執行緒安全weak 記憶體管理的集合型別用以補充系統容器無法提供的功能。

EasyFoundation

用 EasyReact 解決之前的問題

EasyReact 因業務的需要而誕生,首要的任務就是解決業務中出現的那幾點問題。我們來看看建設至今,那幾個問題是否已經解決:

響應式程式設計的學習門檻

前面已經分析過,單純的響應式程式設計並不是特別的難以理解,而函數語言程式設計才是造成高學習門檻的原因。因此 EasyReact 採用大家都熟知的面向物件程式設計進行設計,
想要了解程式碼,相對於函數語言程式設計變得容易很多。

另外響應式程式設計基於資料流動,流動就會產生一個有向的流動網路圖。在函數語言程式設計中,網路圖是使用閉包捕獲來建立的,這樣做非常不利於圖的查詢和遍歷。而 EasyReact 選擇在框架中使用圖的資料結構,將資料流動的有向網路圖抽象成有向有環圖的節點和邊。這樣使得框架在執行過程中可以隨時查詢到節點和邊的關係,詳細內容可以參見 框架概述

另外對於已經熟悉了 ReactiveCocoa 的同學來說,我們也在資料的流動操作上基本實現了 ReactiveCocoa API。詳細內容可以參見 基本操作。更多的功能可以向我們提功能的 ISSUE,也歡迎大家能夠提 Pull Request 來共同建設 EasyReact。

避免不經意的錯誤

前面提到過 ReactiveCocoa 易造成迴圈引用或者提早釋放的問題,那 EasyReact 是怎樣解決這個問題的呢?因為 EasyReact 中的節點和邊以及監聽者都不是使用閉包來進行捕獲,所以刨除轉換和訂閱中存在的副作用(轉換 block 或者訂閱 block 中導致的閉包捕獲),EasyReact 是可以自動管理記憶體的。詳細內容可以參見 記憶體管理

除了記憶體問題,ReactiveCocoa 中的 Hook Cocoa 框架問題,在 EasyReact 上通過規避手段來進行處理。EasyReact 在整個計劃中只是用來完成最基本的資料流驅動的部分,所以本身與 Cocoa 和 CocoaTouch 框架無關,一定程度上避免了與系統 API 和其他庫 Hook 造成衝突。這並不是指 Easy 系列不去解決相應的部分,而是 Easy 系列希望以更規範和加以約束的方式來解決相同問題,後續 Easy 系列的其他開源專案中會有更多這些特定需求的解決方案。

EasyReact 的除錯

EasyReact 利用物件的持有關係和方法呼叫來實現響應式中的資料流動,所以可方便的在呼叫棧資訊中找出資料的傳遞關係。在 EasyReact 中,進行與前面 ReactiveCocoa 同樣的 5 次簡單變換,其呼叫棧只有 15 層(見下圖)。

EasyReact 的呼叫棧

經過觀察不難發現,呼叫棧的順序恰好就是變換的行為。這是因為我們將每種操作定義成一個邊的型別,使得呼叫棧可以通過類名進行簡單的分析。

為了方便除錯,我們提供了一個 - [EZRNode graph] 方法。任意一個節點呼叫這個方法都可以得到一段 GraphViz 程式的 DotDSL 描述字串,開發者可以通過 GraphViz 工具觀察節點的關係,更好的排查問題。

使用方式如下:

  1. macOS 安裝 GraphViz 工具 brew install graphviz

  2. 列印 -[EZRNode graph] 返回的字串或者 Debug 期間在 lldb 呼叫 -[EZRNode graph] 獲取結果字串,並輸出儲存至檔案如 test.dot

  3. 使用工具分析生成影象 circo -Tpdf test.dot -o test.pdf && open test.pdf

結果示例:

節點靜態圖

另外我們還開發了一個帶有錄屏並且可以動態檢視應用程式中所有節點和邊的除錯工具,後期也會開源。開發中的工具是這樣的:

節點動態圖

響應式程式設計風格上的統一

EasyReact 幫助我們解決了不少難題,遺憾的是它也不是“銀彈”。在實際的專案實施中,我們發現僅僅通過 EasyReact 仍然很難讓大家在開發的過程中風格上統一起來。當然它從寫法上要比 ReactiveCocoa 上統一了一些,但是構建資料流仍然有著多種多樣的方式。

所以我們想到通過一個上層的業務框架來統一風格,這也就是後續衍生專案 EasyMVVM 誕生的原因,不久後我們也會將 EasyMVVM 進行開源。

EasyReact 和其他框架的對比

EasyReact 從誕生之初,就不可避免要和已有的其他響應式程式設計框架做對比。下表對幾大響應式框架進行了一個大概的對比:

專案 EasyReact ReactiveCocoa ReactiveX
核心概念 圖論和麵向物件程式設計 函數語言程式設計 函數語言程式設計和泛型程式設計
傳播可變性
基本變換
組合變換
高階變換
遍歷節點 / 訊號
多語言支援 Objective-C (其他語言開源計劃中) Objective-C、Swift 大量語言
效能 較快
中文文件支援
除錯工具 靜態拓撲圖展示和動態除錯工具(開源計劃中) Instrument

效能方面,我們也和同樣是 Objective-C 語言的 ReactiveCocoa 2.5 版本做了相應的 Benchmark。

測試環境

編譯平臺: macOS High Sierra 10.13.5

IDE: Xcode 9.4.1

真機裝置: iPhone X 256G iOS 11.4(15F79)

測試物件

  1. listener、map、filter、flattenMap 等單階操作
  2. combine、zip、merge 等多點聚合操作
  3. 同步操作

其中測試的規模為:

  • 節點或訊號個數 10 個
  • 觸發操作次數 1000 次

例如 Listener 方法有 10 個監聽者,重複傳送值 1000 次。

統計時間單位為 ns。

測試資料

重複上面的實驗 10 次,得到資料平均值如下:

name listener map filter flattenMap combine zip merge syncWith
EasyReact 1860665 30285707 7043007 7259761 6234540 63384482 19794457 12359669
ReactiveCocoa 4054261 74416369 45095903 44675757 209096028 143311669 13898969 53619799
RAC:EasyReact 217.89% 245.71% 640.29% 615.39% 3353.83% 226.10% 70.22% 433.83%

效能測試結果

結果總結

ReactiveCocoa 平均耗時是 EasyReact 的 725.41%。

EasyReact 的 Swift 版本即將開源,屆時會和 RxSwift 進行 Benchmark 比較。

EasyReact的最佳實踐

通常我們建立一個類,裡面會包含很多的屬性。在使用 EasyReact 時,我們通常會把這些屬性包裝為 EZRNode 並加上一個泛型。如:


// SearchService.h

#import <Foundation/Foundation.h>
#import <EasyReact/EasyReact.h>

@interface SearchService : NSObject

@property (nonatomic, readonly, strong) EZRMutableNode<NSString *> *param;
@property (nonatomic, readonly, strong) EZRNode<NSDictionary *> *result;
@property (nonatomic, readonly, strong) EZRNode<NSError *> *error;

@end

這段程式碼展示瞭如何建立一個 WiKi 查詢服務,該服務接收一個 param 引數,查詢後會返回 result 或者 error。以下是實現部分:


// SearchService.m

@implementation SearchService

- (instancetype)init {
    if (self = [super init]) {
        _param = [EZRMutableNode new];
        EZRNode *resultNode = [_param flattenMap:^EZRNode * _Nullable(NSString * _Nullable searchParam) {
            NSString *queryKeyWord = [searchParam stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet  URLQueryAllowedCharacterSet]];
            NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"https://en.wikipedia.org/w/api.php?action=query&titles=%@&prop=revisions&rvprop=content&format=json&formatversion=2", queryKeyWord]];
            EZRMutableNode *returnedNode = [EZRMutableNode new];
            [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
                if (error) {
                    returnedNode.value = error;
                } else {
                    NSError *serializationError;
                    NSDictionary *resultDictionary = [NSJSONSerialization JSONObjectWithData:data options:0 error:&serializationError];
                    if (serializationError) {
                        returnedNode.value = serializationError;
                    } else if (!([resultDictionary[@"query"][@"pages"] count] && !resultDictionary[@"query"][@"pages"][0][@"missing"])) {
                        NSError *notFoundError = [NSError errorWithDomain:@"com.example.service.wiki" code:100 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"keyword '%@' not found.", searchParam]}];
                        returnedNode.value = notFoundError;
                    } else {
                        returnedNode.value = resultDictionary;
                    }
                }
            }];
            return returnedNode;
        }];
        EZRIFResult *resultAnalysedNode = [resultNode if:^BOOL(id  _Nullable next) {
            return [next isKindOfClass:NSDictionary.class];
        }];
        _result = resultAnalysedNode.thenNode;
        _error = resultAnalysedNode.elseNode;
    }
    return self;
}

@end

在呼叫時,我們只需要通過 listenedBy 方法關注節點的變化:

self.service = [SearchService new];
[[self.service.result listenedBy:self] withBlock:^(NSDictionary * _Nullable next) {
    NSLog(@"Result: %@", next);
}];
[[self.service.error listenedBy:self] withBlock:^(NSError * _Nullable next) {
    NSLog(@"Error: %@", next);
}];

self.service.param.value = @"mipmap"; //should print search result
self.service.param.value = @"420v"; // should print error, keyword not found.

使用 EasyReact 後,網路請求的引數、結果和錯誤可以很好地被分離。不需要像命令式的寫法那樣在網路請求返回的回撥中寫一堆判斷來分離結果和錯誤。

因為節點的存在先於結果,我們能對暫時還沒有得到的結果構建連線關係,完成整個響應鏈的構建。響應鏈構建之後,一旦有了資料,資料便會自動按照我們預期的構建來傳遞。

在這個例子中,我們不需要顯式地來呼叫網路請求,只需要給響應鏈中的 param 節點賦值,框架就會主動觸發網路請求,並且請求完成之後會根據網路返回結果來分離出 result 和 error 供上層業務直接使用。

對於開源,我們是認真的

EasyReact 專案自立項以來,就勵志打造成一個通用的框架,團隊也一直以開源的高標準要求自己。整個開發的過程中我們始終保證測試覆蓋率在一個高的標準上,對於介面的設計也力求完美。在開源的流程,我們也學習借鑑了 Github 上大量優秀的開源專案,在流程、文件、規範上力求標準化、國際化。

文件

和英文的說明性質文件:

後續幫助理解的文章,也會陸續上傳到專案中供大家學習。

另外也為開源的貢獻提供了標準的 中文貢獻流程英文貢獻流程,其中對於 ISSUE 模板、Commit 模板、Pull Requests 模板和 Apache 協議頭均有提及。

如果你仍然對 EasyReact 有所不解或者流程程式碼上有任何問題,可以隨時通過提 ISSUE 的方式與我們聯絡,我們都會盡快答覆。

行為驅動開發

為了保證 EasyReact 的質量,我們在開發的過程中使用 行為驅動開發。當每個新功能的宣告部分確定後,我們會先編寫大量的測試用例,這些用例模擬使用者的行為。通過模擬使用者的行為,以更加接近使用者的想法,去設計這個新功能的 API。同時大量的測試用例也保證了新的功能完成之時,一定是穩定的。

測試覆蓋率

EasyReact 系列立項之時,就以高質量、高標準的開發原則來要求開發組成員執行。開源之後所有專案使用 codecov.io 服務生成對應的測試覆蓋率報告,Easy 系列的框架覆蓋率均保證在 95% 以上。

name listener
EasyReact codecov
EasyTuple codecov
EasySequence codecov
EasyFoundation codecov

持續整合

為了保證專案質量,所有的 Easy 系列框架都配有持續整合工具 Travis CI。它確保了每一次提交,每一次 Pull Request 都是可靠的。

展望

目前開源的框架元件只是建立起響應式程式設計的基石,Easy 系列的初心是為 MVVM 架構提供一個強有力的框架工具。下圖是 Easy 系列框架的架構簡圖:

Archticture

未來開源計劃

未來我們還有提供更多框架能力,開源給大家:

名稱 描述
EasyDebugToolBox 動態節點狀態除錯工具
EasyOperation 基於行為和操作抽象的響應式庫
EasyNetwork 響應式的網路訪問庫
EasyMVVM MVVM 框架標準和相關工具
EasyMVVMCLI EasyMVVM 專案腳手架工具

跨平臺與多語言

EasyReact 的設計基於面向物件,所以很容易在各個語言中實現,我們也正在積極的在 Swift、Java、JavaScript 等主力語言中實現 EasyReact。

另外動態化作為目前行業的趨勢,Easy 系列自然不會忽視。在 EasyReact 基於圖的架構下,我們可以很輕鬆的讓一個 Objective-C 的上游節點通過一個特殊的橋接邊連線到一個 JavaScript 節點,這樣就可以讓部分的邏輯動態下發過來。

結語

資料傳遞和非同步處理,是大部分業務的核心。EasyReact 從架構上用響應式的方式來很好的解決了這個問題。它有效地組織了資料和資料之間的聯絡,
讓業務的處理流程從指令式程式設計方式,變成以資料流為核心的響應式程式設計方式。用先構建資料流關係再響應觸發的方法,讓業務方更關心業務的本質。使廣大開發者從瑣碎的指令式程式設計的狀態處理中解放出來,提高了生產力。EasyReact 不僅讓業務邏輯程式碼更容易維護,也讓出錯的機率大大下降。

團隊介紹

成威,專案的發起人,負責美團客戶端新技術調研。國內函數語言程式設計、響應式程式設計的愛好者,多年宣傳和佈道響應式程式設計實踐並取得一定的成績。
姜沂,專案的主要開發者。
秦巨集,專案的主要開發者。
君陽,專案的早期開發者。
思琦,Easy 系列圖示設計者,文件和程式碼翻譯者。
志宇,參與了大部分的重構設計。
恩生,文件和程式碼翻譯者。
姝琳,文件和程式碼翻譯者。

招聘

招聘時間~美團平臺業務研發中心誠招高階 iOS 工程師、技術專家。

歡迎投遞簡歷到 zangchengwei#meituan.com。一起共建 Easy 系列。