近几年来,高德地图业务发展迅猛,团队规模迅速扩张,代码体量急剧增加,为了提高团队高效并行作战的能力,端上做了一系列架构升级。2018 年通过双端融合、组件化、研发平台搭建等技术实践,使得发版效率提升 50%, App 崩溃率从万分之八降到十万分之八。本文整理自 ArchSummit 全球架构师峰会(深圳站)2019 峰会 演讲,主要分享在一系列架构升级改进中,高德地图的具体做法、经验和思考。
大家好,我是来自高德地图的郝仁杰,本次分享的主题是“高德地图 App 架构演化与实践”。2018 年,我们通过架构的演进将发版周期缩短至一半,整个 App 的崩溃率从万分之八降低至十万分之八。在正式开始介绍之前,我先来简单介绍下高德。
高德是国内数字地图内容、导航和位置服务解决方案提供商。目前在端上,分为手机和车机两条主线。近年来,高德业务迅猛发展,人员规模迅速扩张,代码体量急剧增加,为了提高团队高效并行作战的能力,端上做了 C++ 多端融合和动态化能力建设。
回顾近几来高德地图 App 架构的演进历程:2014 年,手机端上只有几十个研发, Android 和 iOS 端由原生单体架构实现;2015 年,地图引擎下沉 C++,实现了手机和车机的多端融合;2016 年,端上启动了动态 UI 框架的开发,为未来业务的动态化铺路;2017 年,动态 UI 框架建设完成,具备了运行静态页面的能力;到了 2018 年,手机端已经成长为拥有数百研发规模的团队,双端代码量也已经达到数百万行,架构要如何继续演化来提高团队高效并行作战的能力,来支撑并赋能业务快速发展呢?
为了让业务开发有节奏的进行,项目上每年会制定一些公车计划。公车就是每个 App 版本,版本里带的产品功能就是公车上的货物,公车计划即每年的发版计划。按照计划,公车会在指定的时间把组装好的货物拉走。
2018 年初,由于双端代码差异较大、耦合严重、复用率低、职责不清晰、平台工具简陋等问题,公车无法按照计划拉走货物。工具落后,货物组装慢且质量差,无法如期交货,迫使公车等待,导致整个发版周期长达 3 个月,崩溃率高达万分之八,公车变成了伪公车。
为了解决这些问题,使伪公车变为真公车,需要做到稳定、并行和高效。端上通过以下三种方式达到该目的,一是双端融合,如上图,蓝色部分上漂动态 UI,下沉 C++,以及 Android、iOS 双端拉齐,减少差异,提高可维护性;二是选择组件化方案,分而治之,解除耦合,提高复用率,做到并行、高效;三是搭建研发中台,工具升级,流程自动化以及风险质量管控,提升效率和稳定性。
2015 年,我们通过地图引擎下沉 C++,实现了手机、车机的多端融合,同理,可将部分功能下沉 C++;通过 2017 年建成的动态 UI 框架,可将部分业务上漂到动态 UI;对于既不能上漂也不能下沉的,通过双端拉齐做到融合。
那么,什么样的场景适合下沉到 C++ 呢?一,需要有稳定的逻辑,不经常变化;二是不强依赖原生;三,对性能要求较高。举例来说,导航逻辑,地图从开始建立到现在已经打磨出一套非常核心稳固的逻辑,这部分逻辑可以下沉到 C++。
哪些场景又适合上漂到动态 UI 呢?一,对性能要求不高;二,经常易变的业务代码,比如产品的 UI 需求;三,不强依赖于原生能力。
对于既不能下沉,也不能上漂的功能,选择双端拉齐:对性能有一定要求;强依赖原生的能力;需要支撑一些原生业务。例如,高德地图的页面框架,虽然 Android 和 iOS 端有原生的页面框架,但地图类应用和普通应用不太一样,地图类应用的主要功能是围绕着一张地图进行,这张地图上面的元素非常丰富,数据量非常庞大,内存占用较大,如果采用原生页面框架进行开发,就意味着每切换一个页面就得创建一张新地图,这对手机端这种资源紧缺的环境来说是非常浪费的,对于低端机型来说是不可接受的。
另外,地图应用从一个页面切换到另一个页面,或者从一个场景切换到另一个场景,并不是完全不同的两张图切换,而仅仅是一张地图的不同状态转换,此时,如果额外创建一张新的地图,显然是极大的浪费。所以,对于地图类应用,我们建设了自己的页面框架:以单系统页面控制器多视图切换的方式实现。由于原来都是单体开发,Android 和 iOS 只关注自身特性,两边的实现不太一样,跳转规则、功能特性均有差异,我们通过分析双端的规则、特性,借鉴双端各自的优点,设计了一套统一的规则、特性,实现了双端融合。
下面简单介绍高德地图页面框架的融合方案:
如上图,左边的 Activity 是 Android 的系统页面控制器,右边的 UIViewController 是 iOS 的系统页面控制器,通过虚线连接比较,我们发现两端的页面状态设计基本相同。所以,我们在设计自己的页面框架时沿用了这些系统页面状态,同时从命名上也保持一致,这样可以让 Android 和 iOS 原生开发的同学更容易理解和上手。
此外,我们吸取了双端各自的优点。比如,Android 端页面有四种启动模式,但是 iOS 端并没有这些,我们就把 Android 的四种启动模式运用到了 iOS 端;iOS 端有 Present 特性,但是 Android 端没有,那么也把这种特性融合到 Android 端的页面框架中;最后,还有一些小设计,比如 Android 的 onResult 设计,也可以借鉴融合到 iOS 端。
首先,介绍下四种启动模式,这是安卓特有的。第一种是 Standard 模式,这个模式和栈的行为是一样的,就是标准的 Push 和 Pop;第二种是 SingleTop 模式,当向一个页面栈压入另一个页面时,如果该页面已经在栈顶,那么将不会创建一个新的页面实例 C 放到栈顶,不会变成 ABCC 这样的方式,而仅仅是通知当前栈顶的 C 页面做一个数据更新;第三种是 SingleInstance 模式,当以 SingleInstance 模式 Push 一个页面时,如果该页面已经在栈中,那么就把它从栈中带到栈顶;最后一种是 SingleTask 的模式,这和原生系统略有差别,因为我们目前是基于单页面控制器的方式实现的,当以 SingleTask 的方式 Push 一个页面到页面栈时,如果该页面已经在栈中,页面框架会把其之上的所有页面全部清除出栈,使其成为新的栈顶,这就是四种启动模式。
接下来,简单介绍 iOS 的 Present 特性。当页面栈顶的 C 页面 Present D 页面时,D 页面并没有被加入到 ABC 页面栈中,而是变成了 C 页面的一个附属,当 D 页面要消失时,同样也是通过 C 页面的 dismiss 移除掉。这里有些限制,每个页面仅可以 Present 一个页面,这个页面可以是一个普通页面,也可以是一个导航页面,那么导航页面是什么呢?大家可以理解成一个新的页面栈(功能类似 UINavigationController),其上可以添加其它页面。如果 D 页面是导航页面,就可以在其上 Push 其它页面,如果业务流程有一个主流程,一个分支流程,就可以采用这种方式实现。
最后是 Android 的 onResult 特性,实现了页面间数据返回的解耦,如上示例代码就是大致的实现原理。具体来说,从 A 页面跳转到 B 页面,那么 B 页面执行了一段逻辑之后,A 希望得到执行结果,如果按照原来 iOS 的实现方式,只能通过监听 Listener 或 Delegate 等方式将 B 页面的执行结果返回给 A 页面。当 iOS 的页面框架实现了这个特性, A 页面就不需要额外注册 B 页面的 Listener 或 Delegate 了,只需重写自己的 onResult 方法并处理结果即可,这样既可以实现页面解耦,又方便了业务同学开发。
接下来,举个高德地图手机端上的具体实例。
有这样一个搜索场景,从一个具体地理位置详情页可以跳转到以它为中心的搜周边页,在搜周边页中又可以跳转到另一个具体地理位置详情页,接着可以跳转到新的搜周边页,以此递归循环,但是返回时,产品希望仅返回到之前搜索过的具体地理位置详情页,略去搜周边页。如上右侧图片展示,查询顺序是:7 天优品酒店详情页 -> 7 天优品酒店搜周边页 -> 火驴火烧肉亭详情页 -> 火驴火烧肉亭搜周边页;返回顺序是:火驴火烧肉亭搜周边页 -> 火驴火烧肉亭详情页 -> 7 天优品酒店详情页。中间的 7 天优品酒店搜周边页被去掉了。
在 iOS 页面框架未实现 launch mode 前,火驴火烧肉亭详情页在跳转到火驴火烧肉亭搜周边页前,需要自行遍历当前页面栈,将 7 天优品酒店搜周边页从页面栈中移出后,再跳转到火驴火烧肉亭搜周边页,以此保证产品逻辑的正确性。在实现了 launch mode 之后,火驴火烧肉亭详情页仅需以 SingleInstance 的方式打开火驴火烧肉亭搜周边页即可,页面框架会自动将之前的 7 天优品酒店搜周边页调到栈顶,并将该搜周边页的内容刷新为火驴火烧肉亭搜周边。极大简化了 iOS 端业务同学的开发成本,规范了 iOS 页面跳转规范,结束了由业务自行操作页面栈的混乱时代,同时双端技术能力的融合也为上层动态 UI 业务提供了一致性的体验。
上面,我们介绍了双端融合方案的三种方式,也举例说明了其带来的效果。下沉 C++,实现两套代码合一,解决了一致性问题,提高了性能,但同时也提高了开发门槛,适用于多年沉淀的核心逻辑;上漂动态 UI,同样解决了双端一致性问题,性能会稍有损失,但降低了开发门槛,使得开发速度得到提升,适用于频繁变动的业务场景;双端拉齐则是借鉴了双端优势,做到互相融合。
我们做了一些团队组件化方案的选型和参考,例如手淘的 Atlas、Beehive,网易的 LDBusMediator 等,由于这些组件化方案都比较成熟,这里不再赘述。它们都包含五个概念:容器、模块、生命周期、页面路由和对外服务(通信),我们重新命名了这些概念使其更加形象化。
容器,负责管理模块;模块,是一个独立的功能单元,可以独立编译;微应用,管理模块的生命周期,对于一个手机操作系统,是为每个应用派发生命周期,对于一个单独的应用,是为每个模块派发生命周期,就像一个应用管理着很多微应用一样,因此我们取了这个形象的名字;页面路由,负责进行 URL 的解析和页面跳转;微服务,模块中的逻辑功能,同时提供对外服务。
我们对容器在设计进行了一些改造,如上右半边图,模块被虚化了,被定义成了一个物理概念(即一个独立代码仓库),逻辑上拆分为微应用、微服务和页面路由,容器不再管理模块,而是直接管理这三个元素。之所以这样做,是因为我们希望业务更关注自身需要的服务是什么,而不是它在哪个模块,这些也是借鉴了安卓的组件化思想。
接下来,我们详细介绍下微应用生命周期的设计,如上图,微应用在 iOS 端参考的是 UIApplicationDelegate 的生命周期,而在 Android 端参考的是 Activity 的生命周期。做这样的参考选择,原因有三:一,高德地图内的应用场景大都依赖前后台切换的事件做一些逻辑处理;二,iOS 的 UIApplicationDelegate 作为应用的生命周期,同时支持前后台切换,完全吻合高德地图的场景;三,Android 选择 Activity 是因其组件化的思想,在 Android 的设计中,Application 已经弱化成了一个特殊进程的概念,并不能代表一个应用,且高德地图是基于单 Activity 实现的(上面介绍页面框架时提到过),通过 Activity 的 onStop,onRestart 生命周期中做些逻辑处理,即可判断出应用是否为前后台切换。这样,去除图中虚线框中的生命周期后,双端得到了统一的生命周期,如下左半部分图:
对于虚线中差异化的部分(如上右半部分图),设计为扩展的生命周期,做到抽象相同、扩展差异,既统一了通用生命周期,也支持了双端各自的特性。
对于微服务,我们定义了一个通信规范,只能通过接口方法,不能直接调用实现。定义微服务主要是希望 UI 展现与业务逻辑能够分离,并让业务逻辑服务化,不仅服务于当前页面,也能够服务更多页面,提高代码的复用率,降低维护成本。
有了容器框架,代码便可以抽成一个个独立的模块单元,但模块应该放在那里,上下依赖关系是什么,还需要对模块进行分层、分组,下图为分层、分组后的整体架构:
通过容器建设,架构分层、分组,我们实现了组件化,解除了模块间耦合,提高了代码复用率,为后面的高效并行打好基础。分而治之的思想,组件化的“分”也是为后面的“治”做好铺垫。
研发中台应该有哪些功能,可以结合组件化和公车流程来分解,如下图:
主流程是公车流程,分为:需求收集、需求串讲、开发、合版、提测、灰度发布和正式上线。开发流程可以分解为更细的建立迭代、选择模块、功能开发、模块构建和安装包构建。这里解释下迭代的概念,即一个发版周期内的功能开发。组件化实现了功能解耦,使得不同业务团队可以在开发阶段创建自己的迭代并行开发,开发完成后在规定的时间段进行合版。提测流程可以分成模块集成、安装包构建和集成测试,其中模块集成是以产物的方式进行集成。测试通过后,通过客户端发布流程,进行灰度发布验证,灰度通过后,再进行正式上线,上线之后,我们会对崩溃、性能等维度进行监控。通过流程拆解,我们整理出了研发中台的完整功能:
研发中台建设完成后,我们实现了研发流程、测试流程以及发布流程的自动化,提高了人效。另外,通过质量管控,提高了稳定性;通过流程管控,约束了可能产生的风险。
首先,通过双端融合、组件化、中台建设提升了代码稳定性,实现了流程自动化,做到了开发阶段的并行,使发版周期缩短到原来的一半,从伪公车变成真公车。
其次,通过质量优化,让崩溃率从万分之八降低到十万分之八:双端融合减少了一致性问题;架构合理化提高了可维护性;关键流程管控,减少了风险源头;通过质量扫描,解决了头部质量问题,通过崩溃监控,解决头部崩溃问题。
然后,通过升级编译脚本,支持并行编译;通过模块化,基于产物构建安装包,大大降低编译时长,从原来的 40 多分钟降至现在的 8 分钟。
最后是包大小优化,iOS 端从 146M 减到 123M,纯减量达 48M,这主要是通过编译优化,资源云化,功能合并(分层、分组),svg 替代 png 小图标,删除无用图片和代码实现等手段实现。其中,资源云化主要是指将启动时的非必要资源放在云端,需要时再进行动态加载。
在组件化以后,编译模式发生了一些变化,模块在集成前提前生成了产物,这些变化同时带来了一些问题,比如二进制兼容问题。以枚举功能为例,在模块化后,A 模块依赖 B 模块中定义的枚举,在 A 模块生成产物后,B 模块的枚举定义发生了变化,A 中使用的枚举值含义可能发生变化,如下图:
为了解决该问题,我们制定了一些开发规范:对于枚举的定义,不允许删除任何已定义的枚举值,不允许从中间插入任何枚举值,如果一定要添加,只能在末尾添加,以此来解决二进制兼容性问题。当然,除了枚举的问题,还有宏定义等引起的二进制兼容性问题,此处不一一详述。
此外,Android 端还可能出现代码注解丢失问题。编译期注解仅存在于编译阶段,模块化后,产物中无法保存注解信息,导致产物集成时,由于找不到注解信息而无法进行全局注册。为此,我们做了一些自定义 APT 插件,在注解处 阶段生成 Java 数据类的同时也存储一份注解信息,这样在集成阶段就可以根据注解信息进行全局注册。
2018 年,高德客户端通过一系列架构治理,从伪公车变成了真公车,但这只是近几年架构演进的一个阶段性成果。未来,我们要发挥动态 UI 的优势,让业务真正动态化起来,从公车时代跨入到 Feature Team 时代,让公车变成一条条公路,每个 Feature Team 就是一个小汽车,按照自己的节奏装好货物后,就可以在修好的公路上自由的行驶,更好地做到灵活、并行和高效。
郝仁杰,高德地图无线开发专家。十余年移动客户端开发经验,曾深度参与 Nokia S60 Contacts,YY 语音,360 手机卫士的研发维护工作,对 Symbian、Android、iOS 系统有较深的理解。目前在高德地图负责 Android、iOS 端的基础架构,2018 年,带领团队实现了端上的 Bundle 化等一系列架构升级的开发工作。
除了分享大公司的技术实践之外,会议上还邀请业界关于架构前沿的探索话题,欢迎继续关注 Archsummit 全球架构师峰会北京 2019 ,限时 7 折大力优惠名额,有任何问题欢迎联系票务小姐姐灰灰:15600537884 (微信同号)