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

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

ges 進行 抽象 iphone and mona func 可靠 next

前言

EasyReact 是一款基於響應式編程範式的客戶端開發框架,開發者可以使用此框架輕松地解決客戶端的異步問題。

技術分享圖片

目前 EasyReact 已在美團和大眾點評客戶端的部分業務中進行了實踐,並且持續叠代了一年多的時間。近日,我們決定開源這個項目的 iOS Objective-C 語言部分,希望能夠幫助更多的開發者不斷探索更廣泛的業務場景,也歡迎更多的社區開發者跟我們一起加強 EasyReact 的功能。

GitHub 的項目地址,參見 https://github.com/meituan/EasyReact

背景

美團 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

技術分享圖片

EasyFoundation 是上述 EasyTuple 和 EasySequence 以及未來底層依賴庫的一個統一封裝。

用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 從誕生之初,就不可避免要和已有的其他響應式編程框架做對比。下表對幾大響應式框架進行了一個大概的對比:

技術分享圖片

性能方面,我們也和同樣是 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 次,得到數據平均值如下:

技術分享圖片

技術分享圖片

結果總結

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 上大量優秀的開源項目,在流程、文檔、規範上力求標準化、國際化。

文檔

除了 中文 README 和 英文 README 以外,我們還提供了中文的說明性質文檔:

  • 框架概述

  • 基本操作

  • 內存管理

和英文的說明性質文檔:

  • Framework Overview

  • Basic Operations

  • Memory Management

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

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

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

行為驅動開發

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

測試覆蓋率

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

技術分享圖片

持續集成

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

展望

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

技術分享圖片

未來開源計劃

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

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

跨平臺與多語言

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

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

結語

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

團隊簡介

成威,項目的發起人,負責美團客戶端新技術調研。國內函數式編程、響應式編程的愛好者,多年宣傳和布道響應式編程實踐並取得一定的成績。

姜沂,項目的主要開發者。

秦宏,項目的主要開發者。

君陽,項目的早期開發者。

思琦,Easy 系列圖標設計者,文檔和代碼翻譯者。

誌宇,參與了大部分的重構設計。

恩生,文檔和代碼翻譯者。

姝琳,文檔和代碼翻譯者。

如果你想近距離與我們的作者溝通、交流,請來GitChat,點擊“免費預訂”即可參與讀者交流,報名鏈接

招聘信息

美團平臺業務研發中心誠招高級 iOS 工程師、技術專家。歡迎投遞簡歷到 zangchengwei#meituan.com。一起寫 Easy 系列。

也許你還想看

用Vue.js開發微信小程序:開源框架mpvue解析

Android熱更新方案Robust開源,新增自動化補丁工具

Shield:支撐美團點評品類最豐富業務的移動端模塊化框架開源了

技術分享圖片

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