Uber的外賣團隊是如何使用React Native的?
對于 UberEATS ,我們的目標是讓用戶從最喜歡的餐廳訂購食物的過程能夠無縫完成,就像通過uberX或uberPOOL約車那樣簡單。與任何新產品的發布一樣,構建一個這樣的食品交付網絡在工程方面也會遇到各種“喜”和“驚”。雖然很美味,但這些新的乘客(食物!)本身也給我們帶來了不小的挑戰。例如,這些“乘客”無法指定自己想走的路線,也不能和司機閑聊,但是在“乘客”上下車過程中確實需要一些額外的步驟。本文我們將重點介紹其中的一個挑戰:Uber工程部門如何在原本只涉及司機和乘客的雙邊關系中引入第三方。
幸運的是,借助Uber現有的技術棧,我們可以在很短的時間里讓UberEATS順利上線并投入運轉。只不過這次,旅途變成了送貨,司機變成了“外賣小哥”,乘客變成了食客。不過餐廳在這其中的角色沒有什么可供類比的,因為過去五年來,我們都已經習慣于假設一段旅途只會涉及兩方人員,更不會有芝士披薩、泰式炒河粉,或者墨西哥雞肉卷這樣的乘客。
構建Restaurant Dashboard
圖1:UberEATS市場包含三方人員:餐廳、送貨人,以及食客。這種新態勢導致Uber傳統的雙邊模式需要做出改變。
餐廳需要通過某種方式與送貨人和食客交流。所有涉及方至少需要能傳遞下列信息:
- 下新訂單
- 接受訂單
- 送貨人抵達餐廳
- 訂單完成
這四個基本需求催生了Restaurant Dashboard(餐廳儀表盤),這是一種基于 React / Flux 的單頁Web應用程序,可通過平板設備訪問。
圖2:顯示有一個活躍訂單的Restaurant Dashboard。
面向未來計劃中的50城,改造Restaurant Dashboard
自該應用的獨立版本于2015年12月在多倫多 首發 后,我們不斷努力打造一種更簡單可靠的接口,餐廳可通過該接口就送貨相關的事務進行協調。多個月來,我們已經明白,為了繼續改進Restaurant Dashboard,有必要進行徹底改造。
我們的Web應用僅能通過設備提供有限的訪問能力,而這是個大問題,因為這種方式限制了我們與餐廳就重要事項進行溝通的能力。例如用戶必須先打開網頁才能聽到通知提示音。餐廳工作繁忙,因此通過聲音通知餐廳員工有用戶下了新訂單,或送貨人已經抵達準備取餐,這一點非常重要。為了解決這個問題,每次頁面載入后,為了吸引用戶注意,我們會顯示一個模態窗口(Modal)。雖然可以通過這種方式播放聲音,但會犧牲用戶體驗。
圖3:Restaurant Dashboard通過顯示模態窗口吸引用戶注意力進而播放聲音。
我們還需要構建一些無法通過網頁瀏覽器實現,或者只能以受到較大約束的方式實現的功能。例如,打印紙質小票是很多餐廳必做的,然而網頁瀏覽器只能通過兼容 AirPrint 的打印機實現這一點。這樣的局限導致餐廳和工程師們感到困惑和沮喪。我們意識到為了克服這些障礙,必須能訪問硬件,使用打印機廠商提供的原生SDK直接與打印機通信。
評估React Native
雖然目前來說,把 React Native 稱作移動應用開發中能解決所有問題的“銀子彈”還有些為時尚早,但該技術確實能很好地滿足UberEATS的需求。由于最初設計的Restaurant Dashboard是面向Web的,我們的團隊針對React,以及面向iOS/Android有限的暴露已經積累了大量經驗。此外大家也非常了解服務中與餐廳有關的組件是如何運作的,我們從UberEATS誕生那天起就在進行各種積累。多方因素考慮,使得React Native這一以Web語言進行移動開發的平臺成為最理想的選擇。該技術為我們提供了“烹飪”出近乎完美的應用程序所需的全部炊具。
對我們來說,多平臺支持很重要。目前Uber正在與餐廳緊密合作,尋找平板設備并安裝Restaurant Dashboard應用,但隨著UberEATS的不斷擴張,這種做法逐漸不那么現實了。早些年當我們過渡至BYOD(自帶設備)模式時,Uber的司機也曾面臨類似的情況。通過以不依賴具體平臺的方式構建UberEATS應用,我們隨后將能靈活地擴展至Android,隨著進一步成長為每個平臺提供完善的支持。
為了順利使用React Native,還必須確保這技術能在我們現有的移動基礎架構中流暢運行,可以支持最初促使我們轉換為原生應用程序方式的各類功能。為此我們構建了一個專門用于驗證各種關鍵功能的“演示”應用程序。借此我們可以直接用Uber其他團隊提供的原生依賴項進行功能測試,例如崩潰報告、用戶身份驗證、分析。由于這些功能涵蓋了原生Objective-C層以及解釋型JavaScript層,借此我們還可以測試要求對兩個環境進行集成的功能的交付能力。
總的來說,這個演示實現了我們的目標。諸如崩潰報告等庫可以獨立于應用程序的業務邏輯運作,并能實現拆箱即用。此外我們發現,通過與JavaScript層進行連接進而提供分析事件等功能,這個過程其實也極為簡單。事后總結發現,由于不存在技術障礙,可能導致我們嚴重依賴原生庫,而原生和JavaScript功能之間的矛盾后來也影響到我們很多架構方面的決策。
構建遷移路徑
我們最初的目標是通過最少的工作量構建一種讓Restaurant Dashboard能夠原生運行的框架。為了實現這一點,我們創建了原生的導航和身份驗證系統,并通過WebView將其指向現有的Web應用。
圖4:上圖展示了Restaurant Dashboard Flux存儲原生和Web組件之間的交互。
來自WebView的網絡請求會使用 NSURLProtocol 進行修改,以便獲得必要的身份驗證頭。窗口中還額外增加了其他鉤子,這樣我們就可以更新基于Web的Restaurant Dashboard的Flux存儲,將JavaScript注入WebView。這種做法在細化的功能遷移工作中為我們提供了極大的靈活性。
完成了這樣一個功能基本完備的最小可行產品(MVP)后,我們可以通過現實中的餐廳快速進行測試。此外在原生功能方面也讓我們獲得了“快速勝出”的機會。我們集成了多個原生打印機SDK,使得打印機方面的支持擴展到了不兼容AirPrint的更多型號。此外我們還禁用了睡眠模式,實現這一改動只需要一行原生代碼,但以往通過Web是根本無法實現的。
隨后需要將應用程序的其他部分逐個遷移至React Native。在可能的情況下,我們希望讓盡可能多的功能遷移后即可使用,而不是為了重寫而重寫。
定義架構
上文提到過,React Native將Web和移動開發融合到了一起,使得我們可以自由選擇使用原生或JavaScript的方式編寫功能。與這種功能相伴的還有移動和Web社區的相關模式和概念。這種想法方面的融合為我們提供了更多選項,但如何選擇最恰當的抽象,也為我們造成了新的挑戰。
最終我們為UberEATS制定的架構與普通的 React/Redux Web應用架構差不多,我們盡可能避開了iOS模式和模塊。幸運的是,對于我們的需求和首選項來說,Web概念和技術從整體來看可以很完美地轉換為原生開發模式。
應用的路線功能是這種輕松轉換的典范之一。在Web端,Restaurant Dashboard使用了流行的React-router庫,借此可用聲明地方式清晰地定義線路,具體方式與View中的做法差不多。然而這種系統會假設URL的存在,但瀏覽器之外通常并沒有URL。React Native提供了我們急需的導航庫,該功能與 UINavigationController 提供的接口非常類似。
考慮到速度,最初MVP完成并開始運行時,我們讓React-router庫取代了Routing框架。不存在的URL問題也很好解決,只需要替換JavaScript中的 HTML5 History API即可,畢竟它無論從目的和用途來看都只是一個棧。
當我們需要將React-router遷移至某個React Native庫,例如Navigator或 NavigationExperimental 時,新的實現相比原本的解決方案似乎沒法提供任何收益。結果我們發現這是因為無論原生或基于瀏覽器,Vanilla react-router就僅僅是一個用來做Routing的好方法。
移植過程中學到的另一個寶貴經驗是,將iOS與JavaScript之間的交互降至最低,將邏輯全部濃縮至JavaScript層,這種做法大有裨益,例如:
- 減少JavaScript與Objective-C之間的上下文切換次數
- 增強可移植性(因為減少了依賴特定平臺的代碼數量)
- 減少了Bug的影響范圍
隨著項目繼續進行,我們開發了一個與原生層進行通信的簡單API。雖然希望讓這一層盡可能薄,但我們也理解需要將多少代碼保留在React Native層。諸如分析和登錄等功能從本質上來看只是網絡調用,可以通過JavaScript相對輕松地實現,然而最開始使用Objective-C編寫的代碼需要移植到Java才能支持Android。然而我們更愿意借助這個機會使用JavaScript重寫這些庫,使其可以跨平臺使用。
自動推送更新
React Native應用程序可通過少量Objective-C/Java代碼實現自舉,隨后即可加載JavaScript包(Bundle)。這些包已包含在應用程序中,和其他類型的資產一樣。但有人建議我們說,如果業務邏輯依然保留在這些包中,應用程序就可以在啟動時加載另一個JavaScript文件,借此進行更新,這個過程非常簡單。在原生層,應用程序可以更改React Native橋所用的包,并在需要時請求進行重新載入。
為了讓更新邏輯不依賴具體平臺,我們選擇更進一步圍繞這個橋建立一個原生包裝,借此讓JavaScript包自己判斷到底要加載哪個包。
圖5:Restaurant Dashboard在任何時間最多可以存儲三個不同的JavaScript包。
Restaurant Dashboard會定期檢查是否有新的包,并會自動下載更新。原生代碼和包代碼都符合語義化版本(Semantic versioning),每個新部署可分配唯一標識符,需要更改“原生-JavaScript”通信接口的變更會被視作“破壞性”的。例如,將Analytics模塊更名為AnalyticsV2會被視作一種“破壞性變更”,因為從JavaScript包到Analytics的現有調用會觸發異常。
當然,就算對語義化版本給予最密切的關注,依然可能產生破壞性的更新。在UberEATS的環境中,破壞性的更新是指會導致包處理邏輯還來不及運行,Restaurant Dashboard就已崩潰的包更新。這個時候出現的崩潰會導致無法通過推送新版本包而修復問題。更新造成這種不穩定的局面注定會出現,因此必須具備某種可靠的系統,能夠檢測到不穩定的構建版本并從中恢復。
避免部署破壞性更新的方法之一是將每個更新視作實驗性的,循序漸進地推出更新,并在必要的時候進行回滾。
圖6:Restaurant Dashboard的回滾流程判斷要加載的包。
為了讓回滾流程能夠正常生效,Restaurant Dashboard需要能識別哪個包是破壞性的,隨后重載“安全”的包(即已知可以正常運行的包,例如應用發布時自帶的包),否則將無從確定到底要將軟件回滾到哪個版本。為了實現這一功能,我們設計了自動重載最初伴隨應用程序一起發布的JavaScript包,隨后從推送的兩個包中擇一加載:最新且安全的包,或最新的包。如果最新的包可以順利加載,那么可以認為這個包也是安全的。如果沒有找到已知安全的包,則不進行任何更新,繼續使用最初的包。
相比傳統的移動應用更新方式,通過這種方式對Restaurant Dashboard進行更新產生沖突的概率更低,因為新構建可以按需發布,借此可將新功能的發布時間由原本的數周縮短為數天。更新可在后臺下載,并在下載完成后自動加載,這一過程無須用戶介入。而因為無須用戶交互,更新的準備過程可以變得更快,并盡可能確保更多設備始終可以使用最新版本。這樣的機制還使得我們可以快速回滾有問題的構建,將軟件問題對餐廳造成的影響降至最低。
雖然通過這種方式推送更新的做法還不能完全取代傳統的應用發布方式(有關iOS或Android原生代碼的變更依然需要通過這種方式進行),但至少降低了傳統方法的使用頻率。隨著項目的原生層逐漸成熟,希望這樣的趨勢能繼續保持下去。
測試和類型檢查
在Uber工程團隊內部,我們的工作進度很快,Web項目通常會在變更推送至代碼庫后立刻發布,而不會等待進行構建。相比通常來說發布流程需要持續數周的移動應用程序,這樣的做法產生了強烈的反差。在開發Restaurant Dashboard的過程中,當我們考慮到需要轉向原生應用時,我們曾擔心由于需要進行如此重大的轉變,應用程序可能會在穩定性方面遇到問題。畢竟如果React Native解釋器崩潰了,現實應用也會崩潰。盡管包推送的方式可以在一定程度上避免這類風險,但距離徹底避免崩潰還有很長的距離。
單元測試和淺渲染(Shallow rendering)已經誕生很久了,但最近在JavaScript社區有越來越多人主張通過 Flow 或TypeScript的方式并入靜態類型檢查。
因此這次更新應用時,我們決定使用Flow進行類型檢查,這一決定使得我們可以對業務邏輯的準確性更自信。實際上,事實證明這是一種在發布至生產環境之前進行代碼測試和獲取錯誤的極為有用的工具。
通過一個簡單的范例來看看Flow在類型檢查Reducer函數方面的強大能力。如下例所示,Reducer獲取了正確的狀態和操作作為輸入,隨后可以返回一個新的狀態作為輸出:
副作用的解決
使用Flow進行類型檢查使得我們可以驗證在該過程之后,狀態依然可以維持正確結果,同時在Flow社區的無私奉獻下,新版本可以在我們的應用程序中找到各種可能的Bug來源。此外由于可選類型只會造成最少量的開銷,因此并不會妨礙到我們的快速迭代和開發工作。
Restaurant Dashboard使用 Redux 管理數據的流動。Redux為我們提供了一種簡單、可預測的方法,幫助我們通過下列幾個關鍵原則對應用程序狀態進行建模:
- 所有狀態位于Store中,而這種Store是一種單一不可變對象;
- View可將Store視作輸入,并負責渲染React Native組件;
- View可以派遣Action,而Active實際上是一種對Store進行修改的請求;
- Reducer接受Action和當前狀態作為輸入,返回一個新的Store。
為了響應諸如網絡請求等異步操作,通常還需要修改Store。Redux并未提供這樣做的方法,但此時比較常用的方式是使用 Thunks ,這是一種面向Redux的中間件,可以讓操作成為一種可以返回承諾的函數,并調派其他操作。
圖7:在Restaurant Dashboard中,數據可通過Redux應用程序流動。
我們最初的做法是使用Thunks,但隨著應用程序邏輯(以及副作用)逐漸變得復雜,很快開始遇到問題。尤其是我們遇到了兩種副作用模式,這兩種模式無法自然地融入Thunk模型:
- 對應用程序狀態的定期更新
- 副作用之間的協調
作為Redux應用一種備選的副作用模型, Sagas 可以借助ES6( ECMAScript 6 )生成器函數提供一種不那么復雜的選項。此時不需要對操作的概念進行擴展,而是可以作為一個單獨的線程進行建模,隨后即可訪問Store,監聽Redux操作,調派新的操作。為了避免與Thunk有關的問題, UberEATS.com 最近已全盤遷移至Sagas,因此我們可以更放心地進行縮放,并確信該技術的成熟度可以更好地滿足自己的需求。(無窮無盡的Saga!)
Sagas最醒目的亮點之一在于對應用程序狀態中定期發生的變更進行管理,例如檢索活躍訂單的最新列表。這一特性也可以通過Thunks實現,但Sagas的做法更優雅。(誰會喜歡用Thunks呢?反正我們不喜歡!)例如組件可以定期調派一個操作來獲取訂單,當然Thunk也可以遞歸地調用自身實現類似目標。然而拋開實現方面的問題不談,不需要使用包含計時器邏輯的組件,也不需要用獨立的Thunk持續觸發自身,這種方式更適合Redux模式。
針對這種問題,Sagas提供了簡明扼要的解決方法,使得我們可以創建收壽命更長的任務,定期獲取新訂單并調派更新Store所需的操作。
使用壽命更長的任務,這會面臨另一個問題:維持任務之間的通信,例如:
以上文獲取訂單的范例為例,只有在具備有效用戶會話的情況下,才能獲取訂單并更新Store。如果不能強制實施這一規則,可能導致一些不易察覺的錯誤,例如餐廳注銷后訂單才能更新的競爭狀態。這種情況會進一步產生觸發崩潰的邊緣案例,或導致界面上顯示一些奇怪的提示,因為有關傳入訂單的代碼很有可能假設一個不存在的餐廳是實際存在的。
避免此類問題的做法很簡單,但找出潛在的競爭狀態并提供必要的檢查,這是一種極為耗費時間并且容易出錯的過程。更重要的是,我們的訂單代碼不應考慮用戶會話的狀態,因為這是兩個不相干的問題。
Sagas提供了一種簡單的方法,供我們監聽與會話有關的操作,進而啟動或停止獲取訂單的后臺任務。例如,在看到登錄事件后,我們可以派生出一個定期獲取訂單的任務,如果發現用戶注銷,則取消該任務。這一切可以在Saga中非常簡單地實現,例如:
派生的任務會成為另一個生成器,該任務會持續運行,直到該任務或其父任務終止。
實際上,我們發現這種將特定操作匯聚在一起的方式非常普遍,有些類似于組件修飾器(Decorator),我們可以將這樣的邏輯放入更高層的訂單生成器函數,例如:
Sagas的本質還簡化了我們的測試過程。通過使用Sagas,針對特定功能進行單元測試的過程可以大幅簡化,只須調用相關Saga并對結果執行深入對比即可。
這種方式需要讓很多小服務通過消息傳遞的方式相互通信,很多后端工程師對這樣的做法已經很熟悉了,但我們生成和使用的是Redux操作,而非 Kafka 事件。從開發者的角度來說,很高興能看到這樣的模式能夠應用于客戶端代碼。
有關UberEATS旅途的反思
開發一個應用程序的完整心得體會幾乎不可能用一篇文章全部概括,尤其是像UberEATS應用程序這樣對餐廳的交互產生如此大影響的應用。希望本文能讓大家更好地了解我們的團隊在決定為UberEATS使用React Native,以及確保為餐廳提供可靠、穩健的用戶體驗過程中所進行的權衡和考慮。
雖然React Native目前在UberEATS的工程生態中只占據很小的一部分,但我們使用該技術重建Restaurant Dashboard的過程中依然獲得了大量寶貴經驗。自從去年上線至今,改頭換面后的Restaurant Dashboard已經成為幾乎所有加盟UberEATS的餐廳不不可少的標準化工具。按照這樣的發展速度,我們可以很樂觀地估計該框架的能力將可以繼續滿足我們對規模的需求,幫助我們將這個市場擴展到更多地區。
來自:http://www.infoq.com/cn/articles/how-does-the-uber-takeaway-team-use-react-native