写点什么

美团外卖前端容器化演进实践

  • 2019-12-11
  • 本文字数:7506 字

    阅读完需:约 25 分钟

美团外卖前端容器化演进实践

背景

提单页的位置

提单页是美团外卖交易链路中非常关键的一个页面。外卖下单的所有入口,包括首页商家列表、订单列表页再来一单、二级频道页的今日推荐等,最终都会进入提单页,在确认各项信息之后,点击提交订单按钮,完成最终下单操作。




所支撑的业务

虽然提单页的代码统一放在外卖代码仓库中,但根据业务发展的需要,提单页上的模块分别由不同的业务部门去负责维护。主要包括以下业务方:


外卖侧业务


  • 提单页绝大部分模块的需求开发和日常维护都是由外卖侧的研发同学在负责,包括地址模块、商家商品信息模块、折扣信息模块、准时宝、隐私号、发票备注等。


闪购侧业务


  • 当从商超等频道进入提单页时,提单页生成的是闪购侧订单,闪购侧的订单在配送方式、红包、下单路径上都与外卖订单有所区别,但又依赖于外卖的基础功能模块,因此与外卖侧功能存在严重的耦合问题。


其他业务


  • 提单页上的部分模块对动态化配置能力有着很高的要求,这些模块使用 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<>();@Overridepublic 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();@Overridepublic 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,可以在开发、测试、灰度、运维各阶段,采集性能指标、检测卡顿、测量页面加载时间,帮助开发者监控和定位性能问题。


作者介绍


李肖、廷瑞、彦平、同同 均为美团外卖团队工程师。


本文转载自公众号美团技术团队(ID:meituantech)。


原文链接


https://mp.weixin.qq.com/s/SAuKvlB0Bfj7dRIBzkhO8w


2019-12-11 10:002705

评论

发布
暂无评论
发现更多内容

基于BuildKit优化Dockerfile的构建

琦彦

Dockerfile 10月月更

Docker层和虚悬镜像(dangling image)介绍

琦彦

Docker 10月月更

陈宗绵|关于研发效能的理想与现实

laofo

DevOps cicd 研发效能 持续集成 持续交付

ESP32-C3 学习测试 蓝牙 篇(七、GATT 数据通信 — 发送自定义数据)

矜辰所致

蓝牙 ESP32-C3 10月月更

Python应用之寻求两个数对之间的最大乘积

芯动大师

Python语法 10月月更 split函数

开发者有话说|以码为梦,心向远方,路在脚下

乌龟哥哥

个人成长 10月月更

JavaScript——JS事件

胖虎不秃头

前端 js 10月月更

什么是光网络,几张图就可以很好的解释!

wljslmz

光纤 10月月更 光网络 通信网络

2022-10-05:在一个 n x n 的整数矩阵 grid 中, 每一个方格的值 grid[i][j] 表示位置 (i, j) 的平台高度。 当开始下雨时,在时间为 t 时,水池中的水位为 t 。

福大大架构师每日一题

算法 rust 福大大

【愚公系列】2022年10月 Go教学课程 019-循环结构之for

愚公搬代码

10月月更

架构实战营模块 2 作业

陌生流云

架构实战营

JavaScript——数据类型

胖虎不秃头

前端 js 10月月更

vue快速入门---高速版

楠羽

笔记 VUE 3.0 源码 10月月更

Qt|控件的事件过滤使用与总结

中国好公民st

c++ qt 10月月更

如何以非root用户运行Docker容器

琦彦

Docker 10月月更 root用户

一文了解 CPython 中的垃圾收集器

宇宙之一粟

Python 垃圾回收算法 垃圾收集器 10月月更

2022 Kubernetes 批处理和HPC发展一览

琦彦

HPC 批处理 KubeCON 10月月更

Python应用之丑数的判断

芯动大师

Python 10月月更 丑数

JVM——垃圾回收算法

琦彦

Java JVM 垃圾回收 10月月更

作为Android Coder,你了解注解吗?

子不语Any

后端 java; 10月月更

JavaScript——关于JavaScript、在HTML中嵌入JS代码的三种方式、变量

胖虎不秃头

前端 js 10月月更

Docker可视化工具Portainer的安装和使用

琦彦

Docker Portainer 10月月更 可视化管理工具

Python应用之拉力赛求最短时间

芯动大师

10月月更 Python语法应用 函数构建

《Rust for Rustaceans》读书笔记2

袁世超

rust

架构师的十八般武艺:领域建模

agnostic

领域建模

二分查找

掘金安东尼

算法 10月月更

【C语言难点突破】指针和数组名的爱恨情仇

Geek_65222d

10月月更

docker-maven-plugin:自动构建Docker镜像,并推送到Docker Registry或阿里云

琦彦

Docker maven 10月月更 docker-maven-plugin

利用Vue自定义指令让你的开发变得更优雅

茶无味的一天

Vue 前端 vue指令

构建Java镜像的10个最佳实践

琦彦

Java应用 Docker 镜像 10月月更

第九期 - 模块二

wuli洋

美团外卖前端容器化演进实践_容器_李肖 廷瑞 彦平_InfoQ精选文章