写点什么

美团外卖 Android 平台化的复用实践

2020 年 2 月 19 日

美团外卖Android平台化的复用实践

美团外卖平台化复用主要是指多端代码复用,正如美团外卖iOS多端复用的推动、支撑与思考 文章所述,多端包含有两层意思:其一是相同业务的多入口,指美团外卖业务需要在美团外卖 App(下文简称外卖 App)和美团 App 外卖频道(下文简称外卖频道)同时上线;其二是指平台上各个业务线,美团外卖不同业务线都依赖外卖基础服务,比如登陆、定位等。


多入口及多业务线给美团外卖平台化复用带来了巨大的挑战,此前我们的一篇博客《美团外卖Android平台化架构演进实践》 (下文简称《架构演进实践》)也提到了这个问题,本文将在“代码复用”这一章节的基础上,进一步介绍平台化复用工作面临的挑战以及相应的解决方案。


美团外卖平台化复用背景

美团外卖 App 和美团 App 外卖频道业务基本一样,但由于历史原因,两端代码差异较大,造成同样的子业务需求在一端上线后,另一端几乎需要重新实现,严重浪费开发资源。在《架构演进实践》一文中,将美团外卖 Android 客户端平台化架构分为平台层、业务层和宿主层,我们希望能够在平台化架构中实现平台层和业务层的多端复用,从而节省子业务需求开发资源,实现多端部署。


难点总结

两端业务虽然基本一致,但是仍旧存在差异,UI、基础服务、需求差异等。这些差异存在于美团外卖平台化架构中的平台层和业务层各个模块中,给平台化复用带来了巨大的挑战。我们总结了两端代码的差异点,主要包括以下几个方面:


  1. 基础服务的差异:包括基础 Activity、网络库、图片库等底层库的差异。

  2. 组件的实现差异:包括基础数据 Model、下拉刷新、页面跳转等基础组件的差异。

  3. 页面的差异:包括两端的 UI、交互、业务和版本发布时间不一致等差异。


前期探索

前期,我们尝试通过一些设计方案来绕过上述差异,从而实现两端的代码复用。我们选择了二级频道页(下文统称金刚页)进行方案尝试,设计如下:



其中,KingKongDelegate 是 Activity 生命周期实现的代理类,包含 onCreate、onResume 等 Activity 生命周期回调方法。在外卖 App 和外卖频道两端分别基于各自的基础 Activity 实现 WMKingKongAcitivity 和 MTKingKongActivity,分别会通过调用 KingKongDelegate 的方法对 Activity 的生命周期进行分发。


KingKongInjector 是两端差异部分的接口集合,包括页面跳转(两端页面差异)、获取页面刷新间隔时间、默认资源等,在外卖 App 和外卖频道分别有对应的接口实现 WMKingKongInjector 和 MTKingKongInjector。


NetworkController 则是用 Retrofit 实现统一的网络请求封装,PageListController 是对列表分页加载逻辑以及页面空白、网络加载失败等异常逻辑处理。


在金刚页设计方案中,我们采用了“代理+继承”的方式,实现了用统一的网络库实现网络请求,定义了统一的基础数据 Model,统一了部分基础服务以及基础数据。通过 KingKongDelegate 屏蔽了两端基础 Acitivity 的差异,同时,通过 KingKongInjector 实现了两端差异部分的处理。但是我们发现这种设计方案存在以下问题:


  1. 虽然这样可以解决网络库和图片的差异,但是不能屏蔽两端基础 Activity 的差异。

  2. KingKongInjector 提供了一种解决两端差异的处理方式,但是 KingKongInjector 会存在很多不相关的方法集合,不易控制其边界。此外,多个子模块需要调用 KingKongInjector,会导致 KingKongInjector不便管理。

  3. 由于两端 Model不同,需要实现这个模块使用的统一 Model,但是并未和其他页面使用的相同含义的 Model 统一。


平台化复用方案设计

通过代码复用初步尝试总结,我们总结出平台化复用,需要考虑四件事情:


  1. 差异化的统一管理。

  2. 基础服务的复用。

  3. 基础组件的复用。

  4. 页面的复用。


整体设计

我们在实现平台化架构的基础上,经过不断的探索,最终形成适合外卖业务的平台化复用设计:整体分为基础服务层-基础组件层-业务层-宿主层。设计图如下:



  1. 基础服务层:包含多端统一的基础服务和有差异的基础服务,其中统一的基础服务包括网络库、图片库、统计、监控等。对于登录、分享、定位等外卖 App 和外卖频道两端有差异的部分,我们通过抽象服务层来屏蔽两端的差异。

  2. 基础组件层:包括统一的两端 Model、埋点、下拉刷新、权限、Toast、A/B 测试、Utils 等两端复用的基础组件。

  3. 业务层:包括外卖的具体业务模块,目前可以分为列表页模块(如首页、金刚页等)、商家模块(如商家页、商品详情页等)和订单模块(如下单页、订单状态页等)。这些业务模块的特点是:模块间复用可能性小,模块内的复用可能性大。

  4. 宿主层:主要是初始化服务,例如 Application 的初始化、dex 加载和其他各种必要的组件的初始化。


分层架构能够实现各层功能的职责分离,同时,我们要求上层不感知下层的多端差异。在各层中进行组件划分,同样,我们也要求实现调用组件方不感知组件的多端差异。通过这样的设计,能够使得整体架构更加清晰明朗,复用率提高的同时,不影响架构的复杂度和灵活度。


差异化管理

需要多端复用的业务相对于普通业务而言,最大的挑战在于差异化管理。首先多端的先天条件就决定了多端复用业务会存在差异;其次,多端复用的业务有个性化的需求。在多端复用的差异化管理方案中,我们总结了以下两种方案:


  1. 差异分支管理方案。

  2. pins 工程+Flavor 管理的方案。


差异分支管理


分支管理常用于多个需求在一端上线后,需要在另一端某一个时间节点跟进的场景,如下图所示:



两端开发 1.0 版本时,分别要在 wm 分支(外卖 App 对应分支)开发 feature1 和 mt 分支(外卖频道对应分支)开发 feature2。开发 2.0 版本时,feature1 需要在外卖频道上线,feature2 需要在外卖 App 上线,则分别将 feature1 分支代码合入 mt 分支,feature2 代码合入 wm 分支。这样通过拉取新需求分支管理的方式,满足了需求的差异化管理。但是这种实现方式存在两个问题:


  1. 两端需求差异太多的话,就会存在很多分支,造成分支管理困难。

  2. 不支持细粒度的差异化管理,比如模块内部的差异化管理。


pins 工程+Flavor 的差异化管理


在 Android 官网《配置构建变体》 章节中介绍了Product Flavor(下文简称 Flavor)可以用于实现 full 版本以及 demo 版本的差异化管理,通过配置 Gradle,可以基于不同的 Flavor 生成不同的 apk 版本。因此,模块内部的差异化管理是通过 Flavor 来实现,其原理如下图所示:



其中 Common 是两端复用的代码,DiffHandler 是两端差异部分接口,WMDiffHandler 是外卖 App 对应的 Flavor 下的 DiffHandler 实现,MTDiffHandler 是外卖频道对应 Flavor 下的 DiffHandler 实现。通过两端分别依赖不同 Flavor 代码实现模块内差异化管理。


对于需求在两端版本差异化管理,也可以通过配置 Flavor 来实现,如下图所示:



在 1.0 版本时,feature1 只在外卖 App 上线,feature2 只在外卖频道上线。当 2.0 版本时,如果 feature1、feature2 需要同时在两端上线,只需要将对应业务代码移动到共用 SourceSet 即可实现 feature1、feature2 代码复用。


综合两种差异代码实现来看,我们选择使用 Flavor 方式来实现代码差异化管理。其优势如下:


  1. 一个功能模块只需要维护一套代码。

  2. 差异代码在业务库不同 Flavor 中实现,方便追溯代码实现历史以及做差异实现对比。

  3. 对于上层来说,只会依赖下层代码的不同 Flavor 版本;下层对上层暴露接口也基本一样,上层不用关心下层差异实现。

  4. 需求版本差异,也只需先在上线一端对应的 Flavor 中实现,当需要复用时移动到共用的 SourceSet 下面,就能实现需求代码复用。


从 Android 工程结构来看,使用 Flavor 只能在 module 内复用,但是以 module 为粒度的复用对于差异化管理来说约束太重。这意味着同个 module 内不同模块的差异代码同时存在于对应 Flavor 目录下,或者说需要将每个子模块都创建成不同的 module,这样管理代码是非常不便的。《微信Android模块化架构重构实践》 一文中提到了一个重要的概念 pins 工程,pins 工程能在 module 之内再次构建完整的多子工程结构。我们通过创造性的使用 pins 工程+Flavor 的方案,将差异化的管理单元从 module 降到了pins 工程。而 pins 工程可以定义到最小的业务单元,例如一个 Java 文件。整体的设计实现如下:



pins+flavor


具体的配置过程,首先需要在 Android Studio 工程里首先要定义两个 Flavor:


productFlavors {     wm {}     mt {}}
复制代码


然后使用 pins 工程结构,把每个子业务作为一个 pins 工程,实现如下 Gradle 配置:



最终的工程目录结构如下:



以名为 base 的 pins 工程为例,src/base/main 是该工程的两端共用代码,src/base/wm 是该工程的外卖 App 使用的代码,src/base/mt 是外卖频道使用的代码。同时,我们做了代码检查,除了base pins 工程可以依赖以外,其他 pins不存在直接依赖关系。通过这样实现了module 内部更细粒度的工程依赖,同时配合 Gradle 配置可以实现只编译部分 pins 工程,使整体代码更加灵活。


通过 pins 工程+Flavor 的差异化管理方式,我们既实现了需求级别的差异化管理,也实现了模块内的功能差异化管理。同时,pins 工程更好的控制了代码粒度以及代码边界,也将差异代码控制在比 module更小的粒度。


基础服务的复用

对于一个 App 来说,基础服务的重要性不言而喻,所以在平台化复用中,往往基础服务的差异最大。由于基础服务的使用范围比较广,如果基础服务的差异得不到有效的处理,让上层感知到差异,就会增加架构层与层之间的耦合,上层本身实现业务的难度也会加大。下文里讲解一个我们在实践过程中遇到的例子,来阐述我们的主要解决思路。


在前期探索章节中,我们提到金刚页由于两端基础 Activity 差异,以致于要使用代理类来实现 Activity 生命周期分发。通过采用统一接口以及 Flavor 方式,我们可以统一两端基础 Activity 组件,如下图所示:



分别将两端 WMBaseActivity 和 MTBaseActivity 的差异接口统一成 DialogController、ToastController 以及 ActionBarController 等通用接口,然后在 wm、mt 两个 Flavor 目录下分别定义全限定名完全相同的 BaseActivity,分别继承 MTBaseActivity 和 MTBaseActivity 并实现统一接口,接口实现尽量保持一致。对于上层来说,如果继承 BaseActivity,其可调用的接口完全一致,从而达到屏蔽两端基础 Activity 差异的目的。


对于一些通用基础组件,由于使用范围比较广,如果不统一或者差异较大,会造成业务层代码实现差异较大,不利于代码复用。所以我们采用的策略是外卖 App 向外卖频道看齐。代码复用前,外卖 App 主要使用的网络库是 Volley,统一切换为外卖频道使用的 MTRetrofit;外卖使用的图片库是 Fresco,统一切换为外卖频道使用的 MTPicasso;其他统一的组件还包括动态加载框架、WebView 加载组件、网络监控 Cat、线上监控 Holmes、日志回捞 Logan 以及降级限流等。两端代码复用时,修复问题、监控数据能力方面保持统一。


对于登录、定位等通用基础服务,我们的原则是能统一尽量统一,这样可以有效的减少多端复用中来带的多端维护成本,多份变成一份。而对于无法统一的服务,抽象出统一的服务接口,让上层不感知差异,从而减少上层的复用成本。


组件复用

组件化可以大大的提高一个 App 的复用率。对于平台化复用的业务而言,也是一样。多个模块之间也是会经常使用相同的功能,例如下拉刷新、分页加载、埋点、样式等功能。将这些常用的功能抽离成组件供上层业务层调用,将可以大大提高复用效果。可以说组件化是平台化复用的必要条件之一。


面对外卖 App 包含复杂众多的业务功能,一个功能可以被拆分成组件的基本原则是不同业务库中不同业务的共用的业务功能或行为功能。然后按照业务实现中相关性的远近,自上而下的依赖性将抽离出来的组件划分为基础通用组件、基础业务组件、UI 公共组件。


基础通用组件指那些变化不大,与业务无关的组件,例如页面加载下拉刷新组件(p_refresh),日志记录相关组件(p_log),异常兜底组件(p_exception)。基础业务组件指以业务为基础的组件:评论通用组件(p_ugc),埋点组件(p_judas),搜索通用组件(p_search),红包通用组件(p_coupon)等。UI 公共组件指公用 View 或者 UI 样式组件,与 View 相关的通用组件(p_widget),与 UI 样式相关的通用组件(p_theme)。


对于抽离出来的基础组件,多端之间的差异怎么处理呢? 例如兜底组件,外卖兜底样式以黄色为主调,而外卖频道中以绿色小团为主调,如图所示:



我们首先将这个组件划分为一个 pins 工程,对于多端的差异,在 pins 工程里面利用 Flavor 管理多端之间的差异。这样的方案,首先组件是一个独立的模块,其次多端的差异在组件内部被统一处理了,上层业务不用感知组件的实现差异。而由于基础服务层已经将差异化管理了,组件层也不用感知基础服务的差异,减少了组件层的复用成本。


页面复用

对两端同一个页面来说,绝大部分的功能模块是可复用的,但是也存在不一致的功能模块。以外卖 App 和美团外卖频道首页为例,中部流量区等业务基本相同,但是顶部导航栏样式功能和中部流量区布局在两端不一样,如下图所示:



针对上述问题,我们页面复用的实现思路是页面模块化:先将页面功能按照业务相似性以及两端差异拆分成高内聚低耦合的功能单元 Block,然后两端页面使用拆分的功能单元 Block 像搭积木似的搭建页面,单个的单元 Block 可以采用 MVP 模式实现。美团点评内部酒旅的 Ripper 和到店综合Shield 页面模块化开发框架也是采用这样的思路。由于我们要实现两端复用,还要考虑页面之间的差异。对于两端页面差异,我们统一使用上文中提到的 Flavor 机制在业务单元内对两端差异化管理,业务单元所在页面不感知业务单元的差异性。对于不同的差异,单元 Block 可以在 MVP不同层做差异化管理。


以首页为例,首页 Block 化复用架构如下图。两端首页头部导航栏 UI 展示、数据、功能不一样,导航栏整个功能就以一个 Flavor 在两端分别实现;商家列表中部流量区部分虽然整体 UI 布局不一样,但是里面单个功能 Block 业务逻辑、整个数据一样,继续将中部流量区里面的业务 Block 化;下方的商家列表项两端一样的功能,用一个公有的 Block 实现。在各个单元 Block 已经实现的基础上,两端首页搭建成首页 Fragment。



页面模块化后,将两端不同的差异在各个单元 Block 以 Flavor 方式处理,业务单元 Block 所在页面不用关心各个 Block 实现差异,不仅实现了页面的复用,各个模块功能职责分离,还提高了可维护性。


总结

美团外卖业务需要在外卖平台和美团平台同时部署,因此,在美团外卖平台化架构过程中就产生了平台化复用的问题。而怎么去实现平台化复用呢?笔者认为需要从不同粒度去考虑:基础服务、组件、页面。对于基础服务,我们需要尽可能的统一,不能统一的就抽象服务层。组件级别,需要分块分层,将依赖梳理好。页面的复用,最重要的是页面模块化和页面内模块做到职责分离。平台化复用最大的难点在于:差异的管理和屏蔽。本文提出使用 pins 工程+Flavor 的方案,可以使得差异代码的管理得到有效的解决。同时利用分层策略,每层都自己处理好自己的差异,使得上层不用关心下层的差异。平台化复用不能单纯的追求复用率,同时要考虑到端的个性化。


到目前为止,我们实现了绝大部分外卖 App 和外卖频道代码复用,整体代码复用率达到 88.35%,人效提升 70%以上。未来,我们可能会在外卖平台、美团平台、大众点评平台三个平台进行代码复用,其场景将会更加复杂。当然,我们在做平台化复用的时候,要合理地进行评估,复用带来的“成本节约”和为了复用带来的“成本增加”之间的比率。另外,平台化复用视角不应该局限于业务页面的复用,对于监控、测试、研发工具、运维工具等也可以进行复用,这也是平台化复用理念的核心价值所在。


参考资料

  1. 美团外卖Android平台化架构演进实践

  2. 美团外卖iOS多端复用的推动、支撑与思考

  3. 微信Android模块化架构重构实践

  4. 配置构建变体

  5. Shield—开源的移动端页面模块化开发框架


作者简介

  • 晓飞,美团点评技术专家。2015年加入美团点评,外卖 Android 的早期开发者之一。目前是外卖 Android App 负责人,主要负责版本管理和业务架构。

  • 金光,美团点评高级工程师。2017年加入美团点评,主要负责代码复用及外卖平台化相关工作。

  • 王芳,美团点评高级工程师。2017年加入美团点评,主要负责商家列表页面等相关页面业务。


2020 年 2 月 19 日 20:51217

评论

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

Python 中的数字到底是什么?

Python猫

Python 翻译 PEP

经济适用的企业内外网互动直播方案

fumingwang

音视频 直播 视频会议 企业应用

Apache Pulsar 8 月月报:里程碑一个接一个

Apache Pulsar

大数据 云原生 Apache Pulsar 消息系统 消息中间件

全场景智慧:新工业革命必须拥抱的晨曦

脑极体

macos主流工作开发套件指南

久违

macos Docker 前端 自动化部署

Centos7 mongodb安装全攻略

红泥小壶

mongodb

在5G智慧园区的“保龄球道”上,目标全垒打的征途

脑极体

北京首台区块链政务终端亮相 一键“拉取”链上数据

CECBC区块链专委会

区块链技术

有奖征文火热开赛,万元大奖等你来拿,准备好了吗?

InfoQ写作平台官方

程序员 开发者 音视频 随笔杂谈 RTC征文大赛

Python 函数为什么会默认返回 None?

Python猫

Python 编程

Python 为什么能支持任意的真值判断?

Python猫

Python 编程

oeasy教您玩转linux010204-figlet

o

ARTS Week10

丽子

凤凰交易所 全球首个多元化生态交易平台震撼来袭

InfoQ_967a83c6d0d7

如何进行冥想?给大脑来场清新的SPA!

金龟换酒

自我管理 App 冥想

LeetCode题解:239. 滑动窗口最大值,双循环暴力,JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

区块链+公共安全 大有可为

CECBC区块链专委会

区块链 安全

dubbo应用级服务发现初体验

捉虫大师

dubbo 注册中心

深度解读:Apache DolphinScheduler 新架构与特性,性能提升2~3倍

海豚调度

开源 大数据任务调度 工作流调度 开源社区

Docker 搭建 Redis Cluster 集群环境

哈喽沃德先生

redis Docker 容器 集群 redis cluster

一个在交流群里讨论过两轮的问题,答案竟然跟一个 PEP 有关

Python猫

Python 编程

Python 为什么要在 18 年前引入布尔类型?且与 C、C++ 和 Java 都不同?

Python猫

Python 编程

LeetCode题解:84. 柱状图中最大的矩形,循环+双指针暴力,JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

缓存与数据库一致性问题深度剖析

Zhendong

数据库 缓存 秒杀系统

Flink保存点-17

小知识点

scala 大数据 flink

芯片破壁者(十五):仙童半导体和“八叛逆”所缔造的“硅谷模式”

脑极体

学习笔记丨结构体中的内存管理

Liuchengz.

c Linux 学习

实战中学习浏览器工作原理 — 之 HTTP 请求与解析

三钻

CSS Java 前端 浏览器

从每秒6000写请求谈起

架构师修行之路

程序员 架构师 高并发系统设计

持续集成有什么好处?快来看鸭

清菡

jenkins

Python 为什么没有 void 关键字?

Python猫

Python 编程

编译系统设计赛(华为毕昇杯)技术报告会|5月1日

编译系统设计赛(华为毕昇杯)技术报告会|5月1日

美团外卖Android平台化的复用实践-InfoQ