美團外賣前端容器化演進實踐

  • 2019 年 12 月 6 日
  • 筆記

總第372篇

2019年 第50篇

提單頁在美團外賣交易鏈路中非常重要,但隨着業務不斷發展,提單頁模塊越來越多,邏輯的耦合也越來越重。為了解決這一問題,需要實現提單頁的動態化,而動態化是需要基於容器來實現,所以,美團外賣技術團隊提出了提單頁的容器化方案。希望本文對同樣深受此問題困擾的同學有所幫助,有所啟迪。

背景

提單頁的位置

提單頁是美團外賣交易鏈路中非常關鍵的一個頁面。外賣下單的所有入口,包括首頁商家列表、訂單列表頁再來一單、二級頻道頁的今日推薦等,最終都會進入提單頁,在確認各項信息之後,點擊提交訂單按鈕,完成最終下單操作。

所支撐的業務

雖然提單頁的代碼統一放在外賣代碼倉庫中,但根據業務發展的需要,提單頁上的模塊分別由不同的業務部門去負責維護。主要包括以下業務方:

外賣側業務

  • 提單頁絕大部分模塊的需求開發和日常維護都是由外賣側的研發同學在負責,包括地址模塊、商家商品信息模塊、折扣信息模塊、準時寶、隱私號、發票備註等。

閃購側業務

  • 當從商超等頻道進入提單頁時,提單頁生成的是閃購側訂單,閃購側的訂單在配送方式、紅包、下單路徑上都與外賣訂單有所區別,但又依賴於外賣的基礎功能模塊,因此與外賣側功能存在嚴重的耦合問題。

其他業務

  • 提單頁上的部分模塊對動態化配置能力有着很高的要求,這些模塊使用Mach等動態化模版來實現相關的業務邏輯,由專門的業務組負責開發和維護。

隨着業務的不斷迭代,提單頁的模塊也越來越多,邏輯的耦合也越來越重。現在提單頁的UI展示模塊已經超過30個,這些模塊的展示與否基本上通過服務端的下發數據來決定。在不同的訂單類型下,提單頁所展示元素的差異越來越大,很多模塊的代碼已經不適合統一放在一起維護,代碼拆分的需求十分強烈。此外,客戶端包體積是衡量客戶端性能的重要指標,為了解決業務發展帶來的提單頁代碼量急劇增長的問題,同時實現頁面元素的動態配置,我們希望能夠實現提單頁的動態化,而動態化需要基於容器來實現,所以我們提出了提單頁的容器方案。

問題和挑戰

提單頁的容器化與外賣首頁的動態化有以下幾點不同:

  1. 提單頁整體動態化的需求不是很強烈,並且API改造的成本比較高,因此API接口字段保持不變,需要在客戶端層面去做轉換。
  2. 首頁模塊基本僅作為展示用途,提單頁模塊的交互邏輯要複雜一些,比如發票模塊,進入二級頁面操作完成後還要更新提單頁的數據。
  3. 首頁模塊的UI展示各模塊之間是完全獨立的,而提單頁的模塊是根據功能聚合在一個組,這些模塊條件出現的位置不同,展示的樣式也不一致,如下圖備註發票模塊所示,最上層和最底層的模塊上都帶有圓角,所以提單頁需要外層再添加一個模塊組。

容器化後的提單頁,需要實現模塊之間的互相無感知,根據服務端的下發數據,客戶端可以將閃購代碼倉庫內的模塊和外賣代碼倉庫內的模塊拼接起來組成完整的提單頁展示給用戶。當用戶在提單頁完成一系列操作時,各模塊可以提供必要的參數給服務端。要想實現這一點,我們需要考慮以下幾個問題:

  • 模塊註冊問題,如何在無直接依賴的情況下,讓提單頁獲取頁面可用模塊。
  • API數據分發問題,如何將服務端字段轉換為模塊可用數據,同時不侵入到模塊這一層。
  • 通信問題,模塊之間如何實現聯動效果。
  • 頁面更新和復用問題,在提單頁刷新時如何提交數據給服務端以及如何完成模塊的更新。

設計方案

1. 容器化整體的架構圖設計

容器化是我們在外賣平台化之後對多方業務能力的支持和擴展,在不改變API數據源等前提下,我們保證其具有動態可配置化的能力。為了更好地支撐業務,我們在業務層面抽離出來容器化框架層,其所提供三個部分的核心功能: 1.功能節點擴展及通信功能;2.可配置化功能;3.數據分發功能。在最上層業務容器中,目前所支持外賣提單頁面模塊、閃購提單頁模塊、提單頁Mach(外賣動態化模板)模塊、提單頁MRN(RN頁面)模塊四種不同的業務。

1.1 概念解釋

在容器化框架設計過程中,我們引入了一些新的定義,比如在Android端引入了Block的概念,這裡的Block是一個功能模塊的簡稱。在提單頁頁面中,我們可以理解為一個Block對應一個功能條目。在iOS端有與之對應的概念Element(由於兩者沒有差異,下文陳述中用Block代指兩者)。

Block有兩種類型:其一是普通的Block,其包含BlockView(視圖層)和BlockViewModel(數據層)。BlockView(視圖層)用來展示具體的視圖以及內部的業務邏輯;BlockViewModel(數據層),用來數據解析。其二是LogicBlock,是沒有視圖的Block,單純地用來做數據業務處理。

1.2 整體概述

在容器化之前,我們的業務大多是模塊化的結構,模塊化宿主類是承載所有模塊化的管理類,各個模塊之間通過宿主類或者控制器進行數據交互。但在容器化改造中,我們將之前宿主類中管理的模塊進行拆解,並重新定義了宿主類的職責。在容器化宿主類中,我們將不再持有各個功能模塊的引用,而只要持有Root Block這一個實例,就可以完成對所有功能模塊的管理。而Root Block Context則用來處理父Block與子Block之間的通信以及子Block之間的通信。

1.3 核心功能

第一部分功能節點擴展及通信功能。主要是目前頁面的集成和通信關係,其中Root Block是Block Tree的根節點,下面會掛載一些SubBlock子節點,Root Block會控制整體的數據流的分發以及整體樣式;Root Block Context可以理解為上下文環境或通信的總線。每個模塊都有自己的Context,來維護自己向外部提供數據以及業務邏輯的能力,這些子Context會統一註冊到Root Context中進行管理維護。

第二部分可配置化功能。在發起數據請求成功之後,客戶端根據註冊的Key以及接收到的數據,動態創建Block的容器化能力。遍歷解析數據以及配置文件,先動態創建viewModel,將創建好的viewModel綁定到生成的Block模塊上,動態添加到Root Block中。多業務方在完全不用相互感知的情況下,完成對新增模塊的開發。

第三部分數據分發。既將解析之後的數據,由Root Block節點進行數據分發到各個子Block,各子Block的BlockViewModel在更新數據之後並回傳到Block中,Block用更新後的數據更新View的展示。其中,數據可以自動完成分發,也可以手動的接管數據流進行相應的處理。

2. Block註冊問題

2.1 Android 註冊的設計方案

Android 是在編譯時期,通過APT(註解處理器)的方式,將在指定模塊上的註解信息和Block類關聯起來,生成Block類對應的工廠類,然後將這些工廠類存在全局的Map集合中,並在運行時進行初始化操作。

@DynamicBinder(nativeId = "block_key_d", viewModel = blockDViewModel.class, modelType = blockDInfo.class)

NativeID是用來標識Block塊的唯一Key,viewModel是用來綁定View視圖的數據層, modelType對應着API的數據Model。

2.2 iOS 註冊的設計方案

iOS使用Kylin註冊,Kylin是美團平台開發的基建庫,利用Clang提供的section()函數,在編譯時Kylin將{kylin_Key,kylin_Data}格式的數據寫入到可執行文件的特定數據段中,運行期就可以通過讀取指定的Key值獲取相應的數據。使用這種方式,註冊代碼分散在每個組件內部。註冊內容:組件native_id、Element名稱、viewModel,其含義同上。

註冊宏:

#define PGA_ELEMENT_REGISTER(NATIVE_ID, PGA_ELEMENT, PGA_VIEW_MODEL)        KLN_STRING_EXPORT("AppKey_"#NATIVE_ID"", "{ ""#PGA_VIEW_MODEL"" : ""#PGA_ELEMENT""}");

3. API數據結構化

由於API下發數據的不規範性,需要將數據按照data_key這種數據模式的方式進行整理,然後在獲取數據之後,按照規則進行數據解析並創建相應的功能Block。

目前API數據返回的格式:

{    "data":{          "xxx_pay_by_friend": true,          "xxx_by_friend_tip": "發給微信好友,讓TA買單",          "xxx_by_friend_bubble_desc": "找微信好友買單",          "xxx_friend_order": false    }    "code":0,    "msg":""  }

由於這種格式是平鋪分散的,沒有將特定功能點的字段聚合在一起表示,不利於我們動態地將數據Model與Block綁定在一起。

需要我們將一個模塊的數據統一在一個JSON對象中,整理之後API數據返回的格式如下:

{    "data":{         "pay_by_friend":{//key            "xxx_pay_by_friend": true,            "xxx_by_friend_tip": "發給微信朋友,讓TA買單",            "xxx_by_friend_bubble_desc": "找微信好友買單",            "xxx_friend_order": false         }    }    "code":0,    "msg":""  }

將平鋪的API數據整理成定製的結構化數據,將Key作為唯一的標識,那麼就可以方便地用來對應指定模塊化Block中所需的數據Model。

布局及位置信息會對應相應的模塊視圖層,這由另外的layoutInfo字段給出。數組中的每條元素對應每一個Block模塊 , 其中 native_id的值是唯一的且與上面Block在註冊時候的Key保持一致,data_key的值映射上面整理之後的API數據的Key,這樣在編譯時期生成Block的時候,就可以動態地關聯相應的ViewModel以及數據模型。

{    "layout_info":[        {"native_id":"order_pay_by_friend","data_key":"pay_by_friend"},        {          "native_id":"block_container_default",//容器組          "children":[            {"native_id":"order_flower_cake","data_key":"flower_cake"}          ]        }      ]  }

當然,這裡可以以組為維度將一些功能相似的模塊聚合在一起,native_id的含義同上,Children是子Block結點的數組。

4. 模塊間通信問題

由於之前模塊化的時候,我們通過中間類的方式承載各個業務模塊的通信邏輯。以Android為例,我們將多個子模塊之間需要通信的邏輯,用接口的方式拋到Activity層,由Activity層進行業務邏輯的實現,但是由於子模塊眾多,最終導致該類的膨脹和模塊的高耦合性,難以進行擴展和維護。

在容器化設計的時候,為了更好地使各個業務之間進行通信,降低耦合性,我們引入了BlockContext,同上所述,理解為通信總線。

每個Block都有自己的BlockContext,各個BlockContext匯總到Root Block Context中去實現,最終,各個Block就可以通過BlockContext進行數據傳遞。

整體的通信分發圖如下:

圖中展示的兩種數據方式

4.1 Command數據交互方式

將所需要的數據包裝成事件,在指定的位置驅動事件的執行進而拿到需要的數據。

//聲明事件容器  private SupplierCommand<Object> mSupplierCommand = new SupplierCommand<>();  @Override  public SupplierCommand<Object> getSupplierCommand() {      return mSupplierCommand;  }  //註冊實現  context().getSupplierCommand().registerCommand(new Supplier() {     @Override      public Object run() {      }  });  //獲取相應的Object對象  context().getSupplierCommand().execute();

4.2 Event數據交互方式

利用觀察者的方式,訂閱相應的事件,通過主動觸發,從而完成數據分發等不同操作。

//聲明事件容器  private SupplierEvent mSupplierEvent = new SupplierEvent();  @Override  public SupplierEvent supplierResponseEvent() {    return mSupplierEvent;  }  //實現訂閱  context().supplierResponseEvent().subscribe(new Action() {     @Override     public void action() {     }  });  //觸發相應的操作  context().supplierResponseEvent().trigger();

5. Block頁面數據分發問題

5.1 數據分發問題

Root Block在接收數據的之後,會按照Block結點進行數據的分發。父Block將數據逐次的分發給子Block。

Block Tree數據分發邏輯簡介圖

Block頁面的刷新流程時序圖

5.2 Block創建的順序

Block創建的順序由API結構化數據中的layoutInfo數組來決定,layoutInfo數組的具體格式如第三節API數據結構化中內容所示。容器化後的提單頁會根據layoutInfo數組的順序,依次創建對應native_id的Block模塊。因此,對於一些基礎公共模塊(比如wm_confirm_order_logical對應的Block),我們可以將其放在layoutInfo數組的最前面讓其提前加載,保證負責UI展示的Block創建時數據可用。

5.3 數據拉取問題

由於提單頁的模塊比較多,在頁面曝光、頁面刷新或提交請求時,需要從指定的模塊獲取相應的數據,作為請求的入參,那麼如何做成在不感知其他業務方模塊的情況下,完成數據的組裝呢?

如上面的通信設計思路,我們利用Event數據交互方式,從各個模塊中將需要的數據取出來,完成數據的拼裝。其中不同業務場景提取數據需要的校驗工作,也分散在各個模塊中進行處理。最終,即使在物理層面上隔離了對Block的感知,但是依然可以完成對請求所需數據的獲取。

6. Block頁面的復用問題

在實際的開發中,有些Block的頁面View大致上相似,但是邏輯上有些細微的差異,為了快速開發,我們在設計上復用了其視圖。Block、BlockView以及ViewModel的關係:一個Block對應一個ViewModel和一個BlockView,一個ViewModel和一個BlockView可以對應多個Block。

計算機界有一句名言:「計算機科學領域的任何問題都可以通過增加一個中間層來解決。」(原始版本出自計算機科學家David Wheeler)相似的,為了視圖層的復用,屏蔽數據層的差異,我們在數據層的邏輯中轉部分引入一個中間層ViewData,ViewData是為了更好地適配數據模型以及區別視圖展示上的差異,這樣就大大提高了代碼的復用率。

收益

在開發過程中,我們將iOS和Android系統的模塊進行了對齊和統一,容器化完成之後,兩端同一NativeID對應的模塊展示着相同的UI數據,也具有完全一樣的業務邏輯。經過容器化後的提單頁,相關代碼被劃分到了33個模塊當中,這些模塊分別承擔著不同的職責。這裡按照模塊的業務功能、所採用的技術棧和所屬業務線將這些容器化後的模塊進行劃分,得到如下的柱狀直方圖:

容器化之後的提單頁完全由各模塊組成,這些模塊可以負責UI展示,也可以不展示任何UI模塊,單純地處理業務邏輯。模塊內部的開發方案也可以根據業務形態自由選擇,相互之間做到了完全無感知。這些優點為後續提單頁的業務迭代和技術優化都提供了很大的空間。

解耦的收益

開發效率提升

容器化之前的提單頁,頁面各部分共享同一個數據模型,服務端接口數據返回後,在提單頁控制器內進行數據的更新、過濾和二次加工之後,再分發給頁面上的各模塊。當不同的RD同時開發提單頁的需求時,這些放置在一起的業務邏輯會提高RD的開發成本,另外很容易出現代碼層面的衝突,在需要RD手動解決的同時,也很容易因為開發流程的不規範出現Bug。

容器化之後的提單頁,開發模式也相應發生了改變,RD在開發過程中,不會感知到別的模塊內業務需求的改動,各業務可以在各自的容器內進行有效的推進迭代,而不用擔心會影響到其他業務,從而讓多人協作開發效率得到一定的提升。

控制器瘦身

在客戶端業務開發的層面,MVC架構得到了廣泛應用。容器化重構之前的提單頁,雖然也以模塊化思想為基礎做過多次重構,但是依然深受MVC思想的影響,在提單頁控制器內保留了大量業務邏輯的代碼。這些業務邏輯的代碼最終會在業務迭代過程中逐漸變得臃腫和不可控,最終成為下一次代碼重構計劃中的業務背景。

本次提單頁的容器化改造徹底解決了這一問題。基於PGA框架,包括接口異常處理、數據模型傳遞和二級頁面跳轉等業務邏輯代碼都被收入到對應的Element和Block中,改造後的提單頁中已經不存在業務邏輯相關的代碼,徹底杜絕再次出現臃腫頁面VC的可能。經統計,iOS側提單頁控制器的代碼行數從2894行減少到289行,控制器類中僅包含Block組裝的業務邏輯。

包體積減少

提單頁承載着美團的外賣業務和閃購業務,在未進行容器化之前,兩個業務方需要同時向訂單庫提交代碼,在訂單庫整體「瘦身」的過程中,我們發現這種開發模式讓包大小優化的工作多次出現反覆,並且統計指標也難以統一和對齊。對提單頁進行容器化改造之後,外賣和閃購分別維護各自模塊內的代碼,相互之間無依賴,閃購側可以直接在自己的代碼倉庫內完成提單頁模塊的新增和修改,不需要再給外賣代碼倉庫提PR,也就不會對外賣側的包大小統計產生影響。

動態化的收益

動態化是整個外賣業務的發展方向。提單頁的動態化建立在容器化的基礎之上,在完成容器化之後,就具備了動態化的基礎。當前提單頁的動態化,所指的主要是模塊層級的動態化,提單頁的各模塊展示順序、展示與否,都可以完全由根據服務端下發的數據決定,各模塊可以自由地進行組合、拼裝,實現提單頁的動態配置。

兩端對齊的收益

之前因為歷史原因,提單頁很多的功能模塊,Android和iOS在實現上大相徑庭,完全不一樣的實現讓兩端在新業務需求到來時,在與服務端接口對接、開發工時和開發方案上都存在很大的差異,這些差異點對產品需求的排期、開發和測試上線上都產生了很多負面的影響。

提單頁在容器化後的另外一項收益,就是Android和iOS在模塊層級的代碼實現,完成了統一。藉助於PGA框架和Element註冊機制,Android和iOS具有大致相同的模塊結構,相同native_id的模塊獲取的API接口返回字段完全一致;在頁面請求接口數據時,相同ID的模塊也提供同樣的數據字段。在後續的開發過程中,兩端對API接口字段的請求趨於一致,可以最大程度地減少因為兩端不一致帶來的合作方開發成本,也可以在一定程度上減輕下游的測試壓力。

總結與展望

外賣客戶端一直在推動核心頁面的標準化,同時一直在探索嘗試讓核心頁面也具備動態化能力。提單頁作為下單路徑上的核心頁面,在PGA框架的基礎上完成了容器化重構。至此,外賣首頁、點菜頁和提單頁在頁面這一層級都統一使用PGA框架實現。統一化和標準化之後,可以讓編程風格趨於一致,代碼結構在不同平台保持統一,在後續的需求開發中,可以有效減少因為兩端實現不一致出現的隱性開發成本。

提單頁在容器化之後,讓區域動態化的技術演進更容易推進。模塊之間的解耦讓不同模塊可以自由選擇模塊內使用的技術棧而不會對其他模塊產生影響。對於提單頁的部分模塊,完全可以通過Mach或者RN等動態化方案來實現,通過區域動態化來進一步減少開發成本,提高業務需求的開發效率。

在提單頁之後,客戶端會繼續推進訂單狀態頁使用PGA框架實現容器化,讓標準化框架對用戶下單路徑上的核心頁面實現100%覆蓋。同時積極在提單頁的商家商品信息展示、放心吃、準時保等模塊探索頁面的部分區域動態化,進一步縮減包大小,提高開發效率。

附錄

1. Mach (馬赫) 是外賣終端組自研的多終端跨平台級的局部動態化技術。

2. MRN 是美團基於React-native 0.54.3進行的二次封裝,抹平了兩端上的差異,並且提供了一些基礎庫和組件庫供業務開發同學使用。

3. Metrics 是美團平台團隊和外賣團隊,開發的新一代App性能採集、監控、統計平台。

4. Hertz(赫茲)是一個自動化的性能採集與監控SDK,可以在開發、測試、灰度、運維各階段,採集性能指標、檢測卡頓、測量頁面加載時間,幫助開發者監控和定位性能問題。

作者簡介

李肖、廷瑞、彥平、同同均為美團外賣團隊工程師。