美团外卖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文件。整体的设计实现如下:


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

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年加入美团点评,主要负责商家列表页面等相关页面业务。

招聘

美团外卖长期招聘Android、iOS、FE 高级/资深工程师和技术专家,Base 北京、上海、成都,欢迎有兴趣的同学投递简历到wukai05@meituan.com。

一、背景: 美团App、大众点评App都是重运营的应用。对于App里运营资源、基础配置,需要根据城市、版本、平台、渠道等不同的维度进行运营管理。如何在版本快速迭代过程中,保持运营资源能够被高效 ...