前端篇

2019年11月07日 阅读数:981
这篇文章主要向大家介绍前端篇,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

用Vue.js开发微信小程序:开源框架mpvue解析
Flutter原理与实践 
Picasso 开启大前端的将来
美团客户端响应式框架 EasyReact 开源啦
Logan:美团点评的开源移动端基础日志库
美团点评移动端基础日志库——Logan
MCI:移动持续集成在大众点评的实践
美团外卖Android Crash治理之路
美团外卖Android平台化的复用实践
美团外卖Android平台化架构演进实践
美团外卖Android Lint代码检查实践
Android动态日志系统Holmes
Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus
Android组件化方案及组件消息总线modular-event实战
Android自动化页面测速在美团的实践
Kotlin代码检查在美团的探索与实践
WMRouter:美团外卖Android开源路由框架
美团外卖客户端高可用建设体系
iOS 覆盖率检测原理与增量代码测试覆盖率工具实现
iOS系统中导航栏的转场解决方案与最佳实践
Category 特性在 iOS 组件化中的应用与管控
美团开源Graver框架:用“雕刻”诠释iOS端UI界面的高效渲染
美团外卖iOS App冷启动治理
美团外卖iOS多端复用的推进、支撑与思考
【基本功】深刻剖析Swift性能优化
前端安全系列(一):如何防止XSS攻击? 
前端安全系列(二):如何防止CSRF攻击?php

Hades:移动端静态分析框架 
Jenkins的Pipeline脚本在美团餐饮SaaS中的实践
MSON,让JSON序列化更快
Toast与Snackbar的那点事
WWDC案例解读:大众点评相机直接扫描支付是怎么实现的
beeshell —— 开源的 React Native 组件库 
前端赶上Go: 静态资源增量更新的新实践 
深刻理解JSCore
深度学习及AR在移动端打车场景下的应用 
美团点评金融平台Web前端技术体系
插件化、热补丁中绕不开的Proguard的坑 
美团扫码付小程序的优化实践
用微前端的方式搭建类单页应用 
构建时预渲染:网页首帧优化实践 
美团扫码付的前端可用性保障实践
ARKit:加强现实技术在美团到餐业务的实践 css

 

用Vue.js开发微信小程序:开源框架mpvue解析

前言

mpvue 是一款使用 Vue.js 开发微信小程序的前端框架。使用此框架,开发者将获得完整的 Vue.js 开发体验,同时为 H5 和小程序提供了代码复用的能力。若是想将 H5 项目改造为小程序,或开发小程序后但愿将其转换为 H5,mpvue 将是十分契合的一种解决方案。html

目前, mpvue 已经在美团点评多个实际业务项目中获得了验证,所以咱们决定将其开源,但愿更多技术同行一块儿开发,应用到更普遍的场景里去。github 项目地址请参见 mpvue 。使用文档请参见 http://mpvue.com/前端

为了帮助你们更好的理解 mpvue 的架构,接下来咱们来解析框架的设计和实现思路。文中主要内容已经发表在《程序员》杂志2017年第9期小程序专题封面报道,内容略有修改。vue

小程序开发特色

微信小程序推荐简洁的开发方式,经过多页面聚合完成轻量的产品功能。小程序以离线包方式下载到本地,经过微信客户端载入和启动,开发规范简洁,技术封装完全,自成开发体系,有 Native 和 H5 的影子,但又毫不雷同。java

小程序自己定位为一个简单的逻辑视图层框架,官方并不推荐用来开发复杂应用,但业务需求却难以作到精简。复杂的应用对开发方式有较高的要求,如组件和模块化、自动构建和集成、代码复用和开发效率等,但小程序开发规范较大的限制了这部分能力。为了解决上述问题,提供更好的开发体验,咱们创造了 mpvue,经过使用 Vue.js 来开发微信小程序。node

mpvue是什么

mpvue 是一套定位于开发小程序的前端开发框架,其核心目标是提升开发效率,加强开发体验。使用该框架,开发者只需初步了解小程序开发规范、熟悉 Vue.js 基本语法便可上手。框架提供了完整的 Vue.js 开发体验,开发者编写 Vue.js 代码,mpvue 将其解析转换为小程序并确保其正确运行。此外,框架还经过 vue-cli 工具向开发者提供 quick start 示例代码,开发者只需执行一条简单命令,便可得到可运行的项目。python

为何作mpvue

在小程序内测之初,咱们计划快速迭代出一款对标 H5 的产品实现,核心诉求是:快速实现、代码复用、低成本和高效率… 随后经历了多个小程序建设,结合业务场景、技术选型和小程序开发方式,咱们整理汇总出了开发阶段面临的主要问题:react

  • 组件化机制不够完善
  • 代码多端复用能力欠缺
  • 小程序框架和团队技术栈没法有机结合
  • 小程序学习成本不够低

组件机制:小程序逻辑和视图层代码彼此分离,公共组件提取后没法聚合为单文件入口,组件需分别在视图层和逻辑层引入,维护性差;组件无命名空间机制,事件回调必须设置为全局函数,组件设计有命名冲突的风险,数据封装不强。开发者须要友好的代码组织方式,经过 ES 模块一次性导入;组件数据有良好的封装。成熟的组件机制,对工程化开发相当重要。android

多端复用:常见的业务场景有两类,经过已有 H5 产品改造为小程序应用或反之。从效率角度出发,开发者但愿经过复用代码完成开发,但小程序开发框架却没法作到。咱们尝试过经过静态代码分析将 H5 代码转换为小程序,但只作了视图层转换,没法带来更多收益。多端代码复用须要更成熟的解决方案。

引入 Vue.js:小程序开发方式与 H5 近似,所以咱们考虑和 H5 作代码复用。沿袭团队技术栈选型,咱们将 Vue.js 肯定为小程序开发规范。使用 Vue.js 开发小程序,将直接带来以下开发效率提高:

  • H5 代码能够经过最小修改复用到小程序
  • 使用 Vue.js 组件机制开发小程序,可实现小程序和 H5 组件复用
  • 技术栈统一后小程序学习成本下降,开发者从 H5 转换到小程序不须要更多学习
  • Vue.js 代码能够让全部前端直接参与开发维护

为何是 Vue.js?这取决于团队技术栈选型,引入新的选型与统一技术栈和提升开发效率相悖,有违开发工具服务业务的初衷。

mpvue 的演进

mpvue的造成,来源于业务场景和需求,最终方案的肯定,经历了三个阶段。

第一阶段:咱们实现了一个视图层代码转换工具,旨在提升代码首次开发效率。经过将H5视图层代码转换为小程序代码,包括 HTML 标签映射、Vue.js 模板和样式转换,在此目标代码上进行二次开发。咱们作到了有限的代码复用,但组件化开发和小程序学习成本并未获得有效改善。

第二阶段:咱们着眼于完善代码组件化机制。参照 Vue.js 组件规范设计了代码组织形式,经过代码转换工具将代码解析为小程序。转换工具主要解决组件间数据同步、生命周期关联和命名空间问题。最终咱们实现了一个 Vue.js 语法子集,但想要实现更多特性或跟随 Vue.js 版本迭代,工做量变得难以估计,有永无止境之感。

第三阶段:咱们的目标是实现对 Vue.js 语法全集的支持,达到使用 Vue.js 开发小程序的目的。并经过引入 Vue.js runtime 实现了对 Vue.js 语法的支持,从而避免了人肉语法适配。至此,咱们完成了使用 Vue.js 开发小程序的目的。较好地实现了技术栈统1、组件化开发、多端代码复用、下降学习成本和提升开发效率的目标。

mpvue设计思路

Vue.js 和小程序都是典型的逻辑视图层框架,逻辑层和视图层之间的工做方式为:数据变动驱动视图更新;视图交互触发事件,事件响应函数修改数据再次触发视图更新,如图1所示。

图1: 小程序实现原理

图1: 小程序实现原理

 

鉴于 Vue.js 和小程序一致的工做原理,咱们思考将小程序的功能托管给 Vue.js,在正确的时机将数据变动同步到小程序,从而达到开发小程序的目的。这样,咱们能够将精力聚焦在 Vue.js 上,参照 Vue.js 编写与之对应的小程序代码,小程序负责视图层展现,全部业务逻辑收敛到 Vue.js 中,Vue.js 数据变动后同步到小程序,如图2所示。如此一来,咱们就得到了以 Vue.js 的方式开发小程序的能力。为此,咱们设计的方案以下:

图2:mpvue 实现原理

图2:mpvue 实现原理

 

Vue代码

  • 将小程序页面编写为 Vue.js 实现
  • 以 Vue.js 开发规范实现父子组件关联

小程序代码

  • 以小程序开发规范编写视图层模板
  • 配置生命周期函数,关联数据更新调用
  • 将 Vue.js 数据映射为小程序数据模型

并在此基础上,附加以下机制

  • Vue.js 实例与小程序 Page 实例创建关联
  • 小程序和 Vue.js 生命周期创建映射关系,能在小程序生命周期中触发 Vue.js 生命周期
  • 小程序事件创建代理机制,在事件代理函数中触发与之对应的 Vue.js 组件事件响应

这套机制总结起来很是简单,但实现却至关复杂。在揭秘具体实现以前,读者可能会有这样一些疑问:

  • 要同时维护 Vue.js 和小程序,是否须要写两个版本的代码实现?
  • 小程序负责视图层展示,Vue.js的视图层是否还须要,若是不须要应该如何处理?
  • 生命周期如何打通,数据同步更新如何实现?

上述问题包含了 mpvue 框架的核心内容,下文将仔细为你道来。首先,mpvue 为提升效率而生,自己提供了自动生成小程序代码的能力,小程序代码根据 Vue.js 代码构建获得,并不须要同时开发两套代码。

Vue.js 视图层渲染由 render 方法完成,同时在内存中维护着一份虚拟 DOM,mpvue 无需使用 Vue.js 完成视图层渲染,所以咱们改造了 render 方法,禁止视图层渲染。熟悉源代码的读者,都知道 Vue runtime 有多个平台的实现,除了咱们常见的 Web 平台,还有 Weex。从如今开始,咱们增长了新的平台 mpvue。

生命周期关联:生命周期和数据同步是 mpvue 框架的灵魂,Vue.js 和小程序的数据彼此隔离,各自有不一样的更新机制。mpvue 从生命周期和事件回调函数切入,在 Vue.js 触发数据更新时实现数据同步。小程序经过视图层呈现给用户、经过事件响应用户交互,Vue.js 在后台维护着数据变动和逻辑。能够看到,数据更新发端于小程序,处理自 Vue.js,Vue.js 数据变动后再同步到小程序。为实现数据同步,mpvue 修改了 Vue.js runtime 实现,在 Vue.js 的生命周期中增长了更新小程序数据的逻辑。

事件代理机制:用户交互触发的数据更新经过事件代理机制完成。在 Vue.js 代码中,事件响应函数对应到组件的 method, Vue.js 自动维护了上下文环境。然而在小程序中并无相似的机制,又由于 Vue.js 执行环境中维护着一份实时的虚拟 DOM,这与小程序的视图层彻底对应,咱们思考,在小程序组件节点上触发事件后,只要找到虚拟 DOM 上对应的节点,触发对应的事件不就完成了么;另外一方面,Vue.js 事件响应若是触发了数据更新,其生命周期函数更新将自动触发,在此函数上同步更新小程序数据,数据同步也就实现了。

mpvue如何使用

mpvue框架自己由多个npm模块构成,入口模块已经处理好依赖关系,开发者只须要执行以下代码便可完成本地项目建立。

# 安装 vue-cli
$ npm install --global vue-cli
# 根据模板项目建立本地项目,目前为内网地址 $ vue init mpvue/mpvue-quickstart my-project # 安装依赖和启动自动构建 $ cd my-project $ npm install $ npm run dev 

执行完上述命令,在当前项目的 dist 子目录将构建出小程序目标代码,使用小程序开发者工具载入 dist 目录便可启动本地调试和预览。示例项目遵循 Vue.js 模板项目规范,经过Vue.js 命令行工具vue-cli建立。代码组织形式与 Vue.js 官方实例保持一致,咱们为小程序定制了 Vue.js runtime 和 webpack 加载器,此部分依赖也已经内置到项目中。

针对小程序开发中常见的两类代码复用场景,mpvue 框架为开发者提供了解决思路和技术支持,开发者只须要在此指导下进行项目配置和改造。咱们内部实践了一个将 H5 转换为小程序的项目,下图为使用 mpvue 框架的转换效果:

图3:H5和小程序转换效果

图3:H5和小程序转换效果

 

将小程序转换为H5:直接使用 Vue.js 规范开发小程序,代码自己与H5并没有不一样,具体代码差别会集中在平台 Api 部分。此外并不需明显改动,改造主要分以下几部分:

  • 将小程序平台的 Vue.js 框架替换为标准 Vue.js
  • 将小程序平台的 vue-loader 加载器替换为标准 vue-loader
  • 适配和改造小程序与 H5 的底层 Api 差别

将H5转换为小程序:已经使用 Vue.js 开发完 H5,咱们须要作的事情以下:

  • 将标准 Vue.js 替换为小程序平台的 Vue.js 框架
  • 将标准 vue-loader 加载器替换为小程序平台的 vue-loader
  • 适配和改造小程序与 H5 的底层 Api 差别

根据小程序开发平台提供的能力,咱们最大程度的支持了 Vue.js 语法特性,但部分功能现阶段暂时还没有实现。

表1:mpvue暂不支持的语法特性

表1:mpvue暂不支持的语法特性

 

项目转换注意事项:框架的目标是将小程序和 H5 的开发方式经过 Vue.js 创建关联,达到最大程度的代码复用。但因为平台差别的客观存在(主要集中在实现机制、底层Api 能力差别),咱们没法作到代码 100% 复用,平台差别部分的改形成本没法避免。对于代码复用的场景,开发者须要重点思考以下问题并作好准备:

  • 尽可能使用平台无的语法特性,这部分特性无需转换和适配成本
  • 避免使用不支持的语法特性,譬如 slot, filter 等,下降改形成本
  • 若是使用特定平台 Api ,考虑抽象好适配层接口,经过切换底层实现完成平台转换

mpvue 最佳实践

在表2中,咱们对微信小程序、mpvue、WePY 这三个开发框架的主要能力和特色作了横向对比,帮助你们了解不一样框架的侧重点,结合业务场景和开发习惯,肯定技术方案。对于如何更好地使用 mpvue 进行小程序开发,咱们总结了一些最佳实践。

  • 使用 vue-cli 命令行工具建立项目,使用Vue 2.x 的语法规范进行开发
  • 避免使用框架不支持的语法特性,部分 Vue.js语法在小程序中没法使用,尽可能使用 mpvue 和 Vue.js 共有特性
  • 合理设计数据模型,对数据的更新和操做作到细粒度控制,避免性能问题
  • 合理使用组件化开发小程序,提升代码复用率

表2:框架使用特色对比

表2:框架使用特色对比

 

结语

mpvue 框架已经在业务项目中获得实践和验证,目前正在美团点评内部大范围使用。mpvue 来源于开源社区,饮水思源,咱们也但愿为开源社区贡献一份力量,为广大小程序开发者提供一套技术方案。mpvue 的初衷是让 Vue.js 的开发者以低成本接入小程序开发,作到代码的低成本迁移和复用,咱们将来会继续扩展示有能力、解决开发者的诉求、优化使用体验、完善周边生态建设,帮助到更多的开发者。

最后,mpvue 基于 Vue.js 源码进行二次开发,新增长了小程序平台的实现,咱们保留了跟随 Vue.js 版本升级的能力,由衷的感谢 Vue.js 框架和微信小程序给业界带来的便利。

 

Flutter原理与实践

Flutter是Google开发的一套全新的跨平台、开源UI框架,支持iOS、Android系统开发,而且是将来新操做系统Fuchsia的默认开发套件。自从2017年5月发布第一个版本以来,目前Flutter已经发布了近60个版本,而且在2018年5月发布了第一个“Ready for Production Apps”的Beta 3版本,6月20日发布了第一个“Release Preview”版本。

初识Flutter

Flutter的目标是使同一套代码同时运行在Android和iOS系统上,而且拥有媲美原生应用的性能,Flutter甚至提供了两套控件来适配Android和iOS(滚动效果、字体和控件图标等等)为了让App在细节处看起来更像原生应用。

在Flutter诞生以前,已经有许多跨平台UI框架的方案,好比基于WebView的Cordova、AppCan等,还有使用HTML+JavaScript渲染成原生控件的React Native、Weex等。

基于WebView的框架优势很明显,它们几乎能够彻底继承现代Web开发的全部成果(丰富得多的控件库、知足各类需求的页面框架、彻底的动态化、自动化测试工具等等),固然也包括Web开发人员,不须要太多的学习和迁移成本就能够开发一个App。同时WebView框架也有一个致命(在对体验&性能有较高要求的状况下)的缺点,那就是WebView的渲染效率和JavaScript执行性能太差。再加上Android各个系统版本和设备厂商的定制,很难保证所在全部设备上都能提供一致的体验。

为了解决WebView性能差的问题,以React Native为表明的一类框架将最终渲染工做交还给了系统,虽然一样使用类HTML+JS的UI构建逻辑,可是最终会生成对应的自定义原生控件,以充分利用原生控件相对于WebView的较高的绘制效率。与此同时这种策略也将框架自己和App开发者绑在了系统的控件系统上,不只框架自己须要处理大量平台相关的逻辑,随着系统版本变化和API的变化,开发者可能也须要处理不一样平台的差别,甚至有些特性只能在部分平台上实现,这样框架的跨平台特性就会大打折扣。

Flutter则开辟了一种全新的思路,从头至尾重写一套跨平台的UI框架,包括UI控件、渲染逻辑甚至开发语言。渲染引擎依靠跨平台的Skia图形库来实现,依赖系统的只有图形绘制相关的接口,能够在最大程度上保证不一样平台、不一样设备的体验一致性,逻辑处理使用支持AOT的Dart语言,执行效率也比JavaScript高得多。

Flutter同时支持Windows、Linux和macOS操做系统做为开发环境,而且在Android Studio和VS Code两个IDE上都提供了全功能的支持。Flutter所使用的Dart语言同时支持AOT和JIT运行方式,JIT模式下还有一个备受欢迎的开发利器“热刷新”(Hot Reload),即在Android Studio中编辑Dart代码后,只须要点击保存或者“Hot Reload”按钮,就能够当即更新到正在运行的设备上,不须要从新编译App,甚至不须要重启App,当即就能够看到更新后的样式。

在Flutter中,全部功能均可以经过组合多个Widget来实现,包括对齐方式、按行排列、按列排列、网格排列甚至事件处理等等。Flutter控件主要分为两大类,StatelessWidget和StatefulWidget,StatelessWidget用来展现静态的文本或者图片,若是控件须要根据外部数据或者用户操做来改变的话,就须要使用StatefulWidget。State的概念也是来源于Facebook的流行Web框架React,React风格的框架中使用控件树和各自的状态来构建界面,当某个控件的状态发生变化时由框架负责对比先后状态差别而且采起最小代价来更新渲染结果。

Hot Reload

在Dart代码文件中修改字符串“Hello, World”,添加一个惊叹号,点击保存或者热刷新按钮就能够当即更新到界面上,仅需几百毫秒:

Flutter经过将新的代码注入到正在运行的DartVM中,来实现Hot Reload这种神奇的效果,在DartVM将程序中的类结构更新完成后,Flutter会当即重建整个控件树,从而更新界面。可是热刷新也有一些限制,并非全部的代码改动均可以经过热刷新来更新:

  1. 编译错误,若是修改后的Dart代码没法经过编译,Flutter会在控制台报错,这时须要修改对应的代码。
  2. 控件类型从StatelessWidgetStatefulWidget的转换,由于Flutter在执行热刷新时会保留程序原来的state,而某个控件从stageless→stateful后会致使Flutter从新建立控件时报错“myWidget is not a subtype of StatelessWidget”,而从stateful→stateless会报错“type ‘myWidget’ is not a subtype of type ‘StatefulWidget’ of ‘newWidget’”。
  3. 全局变量和静态成员变量,这些变量不会在热刷新时更新。
  4. 修改了main函数中建立的根控件节点,Flutter在热刷新后只会根据原来的根节点从新建立控件树,不会修改根节点。
  5. 某个类从普通类型转换成枚举类型,或者类型的泛型参数列表变化,都会使热刷新失败。

热刷新没法实现更新时,执行一次热重启(Hot Restart)就能够全量更新全部代码,一样不须要重启App,区别是restart会将全部Dart代码打包同步到设备上,而且全部状态都会重置。

Flutter插件

Flutter使用的Dart语言没法直接调用Android系统提供的Java接口,这时就须要使用插件来实现中转。Flutter官方提供了丰富的原生接口封装:

在Flutter中,依赖包由Pub仓库管理,项目依赖配置在pubspec.yaml文件中声明便可(相似于NPM的版本声明 Pub Versioning Philosophy),对于未发布在Pub仓库的插件能够使用git仓库地址或文件路径:

dependencies: 
  url_launcher: ">=0.1.2 <0.2.0"
  collection: "^0.1.2"
  plugin1: 
    git: 
      url: "git://github.com/flutter/plugin1.git"
  plugin2: 
    path: ../plugin2/

以shared_preferences为例,在pubspec中添加代码:

dependencies:
  flutter:
    sdk: flutter

  shared_preferences: "^0.4.1"

脱字号“^”开头的版本表示和当前版本接口保持兼容的最新版,^1.2.3 等效于 >=1.2.3 <2.0.0 而 ^0.1.2 等效于 >=0.1.2 <0.2.0,添加依赖后点击“Packages get”按钮便可下载插件到本地,在代码中添加import语句就能够使用插件提供的接口:

import 'package:shared_preferences/shared_preferences.Dart';

class _MyAppState extends State<MyAppCounter> { int _count = 0; static const String COUNTER_KEY = 'counter'; _MyAppState() { init(); } init() async { var pref = await SharedPreferences.getInstance(); _count = pref.getInt(COUNTER_KEY) ?? 0; setState(() {}); } increaseCounter() async { SharedPreferences pref = await SharedPreferences.getInstance(); pref.setInt(COUNTER_KEY, ++_count); setState(() {}); } ... 

Dart

Dart是一种强类型、跨平台的客户端开发语言。具备专门为客户端优化、高生产力、快速高效、可移植(兼容ARM/x86)、易学的OO编程风格和原生支持响应式编程(Stream & Future)等优秀特性。Dart主要由Google负责开发和维护,在2011年10启动项目,2017年9月发布第一个2.0-dev版本。

Dart自己提供了三种运行方式:

  1. 使用Dart2js编译成JavaScript代码,运行在常规浏览器中(Dart Web)。
  2. 使用DartVM直接在命令行中运行Dart代码(DartVM)。
  3. AOT方式编译成机器码,例如Flutter App框架(Flutter)。

Flutter在筛选了20多种语言后,最终选择Dart做为开发语言主要有几个缘由:

  1. 健全的类型系统,同时支持静态类型检查和运行时类型检查。
  2. 代码体积优化(Tree Shaking),编译时只保留运行时须要调用的代码(不容许反射这样的隐式引用),因此庞大的Widgets库不会形成发布体积过大。
  3. 丰富的底层库,Dart自身提供了很是多的库。
  4. 多生代无锁垃圾回收器,专门为UI框架中常见的大量Widgets对象建立和销毁优化。
  5. 跨平台,iOS和Android共用一套代码。
  6. JIT & AOT运行模式,支持开发时的快速迭代和正式发布后最大程度发挥硬件性能。

在Dart中,有一些重要的基本概念须要了解:

  • 全部变量的值都是对象,也就是类的实例。甚至数字、函数和null也都是对象,都继承自Object类。
  • 虽然Dart是强类型语言,可是显式变量类型声明是可选的,Dart支持类型推断。若是不想使用类型推断,能够用dynamic类型。
  • Dart支持泛型,List<int>表示包含int类型的列表,List<dynamic>则表示包含任意类型的列表。
  • Dart支持顶层(top-level)函数和类成员函数,也支持嵌套函数和本地函数。
  • Dart支持顶层变量和类成员变量。
  • Dart没有public、protected和private这些关键字,使用下划线“_”开头的变量或者函数,表示只在库内可见。参考库和可见性

DartVM的内存分配策略很是简单,建立对象时只须要在现有堆上移动指针,内存增加始终是线形的,省去了查找可用内存段的过程:

Dart中相似线程的概念叫作Isolate,每一个Isolate之间是没法共享内存的,因此这种分配策略能够让Dart实现无锁的快速分配。

Dart的垃圾回收也采用了多生代算法,新生代在回收内存时采用了“半空间”算法,触发垃圾回收时Dart会将当前半空间中的“活跃”对象拷贝到备用空间,而后总体释放当前空间的全部内存:

整个过程当中Dart只须要操做少许的“活跃”对象,大量的没有引用的“死亡”对象则被忽略,这种算法也很是适合Flutter框架中大量Widget重建的场景。

Flutter Framework

Flutter的框架部分彻底使用Dart语言实现,而且有着清晰的分层架构。分层架构使得咱们能够在调用Flutter提供的便捷开发功能(预约义的一套高质量Material控件)以外,还能够直接调用甚至修改每一层实现(由于整个框架都属于“用户空间”的代码),这给咱们提供了最大程度的自定义能力。Framework底层是Flutter引擎,引擎主要负责图形绘制(Skia)、文字排版(libtxt)和提供Dart运行时,引擎所有使用C++实现,Framework层使咱们能够用Dart语言调用引擎的强大能力。

分层架构

Framework的最底层叫作Foundation,其中定义的大都是很是基础的、提供给其余全部层使用的工具类和方法。绘制库(Painting)封装了Flutter Engine提供的绘制接口,主要是为了在绘制控件等固定样式的图形时提供更直观、更方便的接口,好比绘制缩放后的位图、绘制文本、插值生成阴影以及在盒子周围绘制边框等等。Animation是动画相关的类,提供了相似Android系统的ValueAnimator的功能,而且提供了丰富的内置插值器。Gesture提供了手势识别相关的功能,包括触摸事件类定义和多种内置的手势识别器。GestureBinding类是Flutter中处理手势的抽象服务类,继承自BindingBase类。Binding系列的类在Flutter中充当着相似于Android中的SystemService系列(ActivityManager、PackageManager)功能,每一个Binding类都提供一个服务的单例对象,App最顶层的Binding会包含全部相关的Bingding抽象类。若是使用Flutter提供的控件进行开发,则须要使用WidgetsFlutterBinding,若是不使用Flutter提供的任何控件,而直接调用Render层,则须要使用RenderingFlutterBinding。

Flutter自己支持Android和iOS两个平台,除了性能和开发语言上的“native”化以外,它还提供了两套设计语言的控件实现Material & Cupertino,能够帮助App更好地在不一样平台上提供原生的用户体验。

渲染库(Rendering)

Flutter的控件树在实际显示时会转换成对应的渲染对象(RenderObject)树来实现布局和绘制操做。通常状况下,咱们只会在调试布局,或者须要使用自定义控件来实现某些特殊效果的时候,才须要考虑渲染对象树的细节。渲染库主要提供的功能类有:

abstract class RendererBinding extends BindingBase with ServicesBinding, SchedulerBinding, HitTestable { ... } abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget { abstract class RenderBox extends RenderObject { ... } class RenderParagraph extends RenderBox { ... } class RenderImage extends RenderBox { ... } class RenderFlex extends RenderBox with ContainerRenderObjectMixin<RenderBox, FlexParentData>, RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData>, DebugOverflowIndicatorMixin { ... } 

RendererBinding是渲染树和Flutter引擎的胶水层,负责管理帧重绘、窗口尺寸和渲染相关参数变化的监听。RenderObject渲染树中全部节点的基类,定义了布局、绘制和合成相关的接口。RenderBox和其三个经常使用的子类RenderParagraphRenderImageRenderFlex则是具体布局和绘制逻辑的实现类。

在Flutter界面渲染过程分为三个阶段:布局、绘制、合成,布局和绘制在Flutter框架中完成,合成则交由引擎负责。

控件树中的每一个控件经过实现RenderObjectWidget#createRenderObject(BuildContext context) → RenderObject方法来建立对应的不一样类型的RenderObject对象,组成渲染对象树。由于Flutter极大地简化了布局的逻辑,因此整个布局过程当中只须要深度遍历一次:

渲染对象树中的每一个对象都会在布局过程当中接受父对象的Constraints参数,决定本身的大小,而后父对象就能够按照本身的逻辑决定各个子对象的位置,完成布局过程。子对象不存储本身在容器中的位置,因此在它的位置发生改变时并不须要从新布局或者绘制。子对象的位置信息存储在它本身的parentData字段中,可是该字段由它的父对象负责维护,自身并不关心该字段的内容。同时也由于这种简单的布局逻辑,Flutter能够在某些节点设置布局边界(Relayout boundary),即当边界内的任何对象发生从新布局时,不会影响边界外的对象,反之亦然:

布局完成后,渲染对象树中的每一个节点都有了明确的尺寸和位置,Flutter会把全部对象绘制到不一样的图层上:

由于绘制节点时也是深度遍历,能够看到第二个节点在绘制它的背景和前景不得不绘制在不一样的图层上,由于第四个节点切换了图层(由于“4”节点是一个须要独占一个图层的内容,好比视频),而第六个节点也一块儿绘制到了红色图层。这样会致使第二个节点的前景(也就是“5”)部分须要重绘时,和它在逻辑上绝不相干可是处于同一图层的第六个节点也必须重绘。为了不这种状况,Flutter提供了另一个“重绘边界”的概念:

在进入和走出重绘边界时,Flutter会强制切换新的图层,这样就能够避免边界内外的互相影响。典型的应用场景就是ScrollView,当滚动内容重绘时,通常状况下其余内容是不须要重绘的。虽然重绘边界能够在任何节点手动设置,可是通常不须要咱们来实现,Flutter提供的控件默认会在须要设置的地方自动设置。

控件库(Widgets)

Flutter的控件库提供了很是丰富的控件,包括最基本的文本、图片、容器、输入框和动画等等。在Flutter中“一切皆是控件”,经过组合、嵌套不一样类型的控件,就能够构建出任意功能、任意复杂度的界面。它包含的最主要的几个类有:

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, RendererBinding, WidgetsBinding { ... } abstract class Widget extends DiagnosticableTree { ... } abstract class StatelessWidget extends Widget { ... } abstract class StatefulWidget extends Widget { ... } abstract class RenderObjectWidget extends Widget { ... } abstract class Element extends DiagnosticableTree implements BuildContext { ... } class StatelessElement extends ComponentElement { ... } class StatefulElement extends ComponentElement { ... } abstract class RenderObjectElement extends Element { ... } ... 

基于Flutter控件系统开发的程序都须要使用WidgetsFlutterBinding,它是Flutter的控件框架和Flutter引擎的胶水层。Widget就是全部控件的基类,它自己全部的属性都是只读的。RenderObjectWidget全部的实现类则负责提供配置信息并建立具体的RenderObjectElementElement是Flutter用来分离控件树和真正的渲染对象的中间层,控件用来描述对应的element属性,控件重建后可能会复用同一个element。RenderObjectElement持有真正负责布局、绘制和碰撞测试(hit test)的RenderObject对象。

StatelessWidgetStatefulWidget并不会直接影响RenderObject的建立,它们只负责建立对应的RenderObjectWidgetStatelessElementStatefulElement也是相似的功能。

它们之间的关系以下图:

若是控件的属性发生了变化(由于控件的属性是只读的,因此变化也就意味着从新建立了新的控件树),可是其树上每一个节点的类型没有变化时,element树和render树能够彻底重用原来的对象(由于element和render object的属性都是可变的):

可是,若是控件树种某个节点的类型发生了变化,则element树和render树中的对应节点也须要从新建立:

外卖全品类页面实践

在调研了Flutter的各项特性和实现原理以后,外卖计划灰度上线Flutter版的全品类页面。对于将Flutter页面做为App的一部分这种集成模式,官方并无提供完善的支持,因此咱们首先须要了解Flutter是如何编译、打包而且运行起来的。

Flutter App构建过程

最简单的Flutter工程至少包含两个文件:

运行Flutter程序时须要对应平台的宿主工程,在Android上Flutter经过自动建立一个Gradle项目来生成宿主,在项目目录下执行flutter create .,Flutter会建立ios和android两个目录,分别构建对应平台的宿主项目,android目录内容以下:

此Gradle项目中只有一个app module,构建产物便是宿主APK。Flutter在本地运行时默认采用Debug模式,在项目目录执行flutter run便可安装到设备中并自动运行,Debug模式下Flutter使用JIT方式来执行Dart代码,全部的Dart代码都会打包到APK文件中assets目录下,由libflutter.so中提供的DartVM读取并执行:

kernel_blob.bin是Flutter引擎的底层接口和Dart语言基本功能部分代码:

third_party/dart/runtime/bin/*.dart
third_party/dart/runtime/lib/*.dart
third_party/dart/sdk/lib/_http/*.dart
third_party/dart/sdk/lib/async/*.dart
third_party/dart/sdk/lib/collection/*.dart third_party/dart/sdk/lib/convert/*.dart third_party/dart/sdk/lib/core/*.dart third_party/dart/sdk/lib/developer/*.dart third_party/dart/sdk/lib/html/*.dart third_party/dart/sdk/lib/internal/*.dart third_party/dart/sdk/lib/io/*.dart third_party/dart/sdk/lib/isolate/*.dart third_party/dart/sdk/lib/math/*.dart third_party/dart/sdk/lib/mirrors/*.dart third_party/dart/sdk/lib/profiler/*.dart third_party/dart/sdk/lib/typed_data/*.dart third_party/dart/sdk/lib/vmservice/*.dart flutter/lib/ui/*.dart 

platform.dill则是实现了页面逻辑的代码,也包括Flutter Framework和其余由pub依赖的库代码:

flutter_tutorial_2/lib/main.dart
flutter/packages/flutter/lib/src/widgets/*.dart
flutter/packages/flutter/lib/src/services/*.dart flutter/packages/flutter/lib/src/semantics/*.dart flutter/packages/flutter/lib/src/scheduler/*.dart flutter/packages/flutter/lib/src/rendering/*.dart flutter/packages/flutter/lib/src/physics/*.dart flutter/packages/flutter/lib/src/painting/*.dart flutter/packages/flutter/lib/src/gestures/*.dart flutter/packages/flutter/lib/src/foundation/*.dart flutter/packages/flutter/lib/src/animation/*.dart .pub-cache/hosted/pub.flutter-io.cn/collection-1.14.6/lib/*.dart .pub-cache/hosted/pub.flutter-io.cn/meta-1.1.5/lib/*.dart .pub-cache/hosted/pub.flutter-io.cn/shared_preferences-0.4.2/*.dart 

kernel_blob.bin和platform.dill都是由flutter_tools中的bundle.dart中调用KernelCompiler生成。

在Release模式(flutter run --release)下,Flutter会使用Dart的AOT运行模式,编译时将Dart代码转换成ARM指令:

kernel_blob.bin和platform.dill都不在打包后的APK中,取代其功能的是(isolate/vm)_snapshot_(data/instr)四个文件。snapshot文件由Flutter SDK中的flutter/bin/cache/artifacts/engine/android-arm-release/darwin-x64/gen_snapshot命令生成,vm_snapshot_*是Dart虚拟机运行所须要的数据和代码指令,isolate_snapshot_*则是每一个isolate运行所须要的数据和代码指令。

Flutter App运行机制

Flutter构建出的APK在运行时会将全部assets目录下的资源文件解压到App私有文件目录中的flutter目录下,主要包括处理字符编码的icudtl.dat,还有Debug模式的kernel_blob.bin、platform.dill和Release模式下的4个snapshot文件。默认状况下Flutter在Application#onCreate时调用FlutterMain#startInitialization来启动解压任务,而后在FlutterActivityDelegate#onCreate中调用FlutterMain#ensureInitializationComplete来等待解压任务结束。

Flutter在Debug模式下使用JIT执行方式,主要是为了支持广受欢迎的热刷新功能:

触发热刷新时Flutter会检测发生改变的Dart文件,将其同步到App私有缓存目录下,DartVM加载而且修改对应的类或者方法,重建控件树后当即能够在设备上看到效果。

在Release模式下Flutter会直接将snapshot文件映射到内存中执行其中的指令:

在Release模式下,FlutterActivityDelegate#onCreate中调用FlutterMain#ensureInitializationComplete方法中会将AndroidManifest中设置的snapshot(没有设置则使用上面提到的默认值)文件名等运行参数设置到对应的C++同名类对象中,构造FlutterNativeView实例时调用nativeAttach来初始化DartVM,运行编译好的Dart代码。

打包Android Library

了解Flutter项目的构建和运行机制后,咱们就能够按照其需求打包成AAR而后集成到现有原生App中了。首先在andorid/app/build.gradle中修改:

  APK AAR
修改android插件类型 apply plugin: ‘com.android.application’ apply plugin: ‘com.android.library’
删除applicationId字段 applicationId “com.example.fluttertutorial” applicationId “com.example.fluttertutorial”
建议添加发布全部配置功能,方便调试 - defaultPublishConfig ‘release’
publishNonDefault true

简单修改后咱们就能够使用Android Studio或者Gradle命令行工具将Flutter代码打包到aar中了。Flutter运行时所须要的资源都会包含在aar中,将其发布到maven服务器或者本地maven仓库后,就能够在原生App项目中引用。

但这只是集成的第一步,为了让Flutter页面无缝衔接到外卖App中,咱们须要作的还有不少。

图片资源复用

Flutter默认将全部的图片资源文件打包到assets目录下,可是咱们并非用Flutter开发全新的页面,图片资源原来都会按照Android的规范放在各个drawable目录,即便是全新的页面也会有不少图片资源复用的场景,因此在assets目录下新增图片资源并不合适。

Flutter官方并无提供直接调用drawable目录下的图片资源的途径,毕竟drawable这类文件的处理会涉及大量的Android平台相关的逻辑(屏幕密度、系统版本、语言等等),assets目录文件的读取操做也在引擎内部使用C++实现,在Dart层面实现读取drawable文件的功能比较困难。Flutter在处理assets目录中的文件时也支持添加多倍率的图片资源,并可以在使用时自动选择,可是Flutter要求每一个图片必须提供1x图,而后才会识别到对应的其余倍率目录下的图片:

flutter:
  assets:
    - images/cat.png
    - images/2x/cat.png
    - images/3.5x/cat.png
new Image.asset('images/cat.png');

这样配置后,才能正确地在不一样分辨率的设备上使用对应密度的图片。可是为了减少APK包体积咱们的位图资源通常只提供经常使用的2x分辨率,其余分辨率的设备会在运行时自动缩放到对应大小。针对这种特殊的状况,咱们在不增长包体积的前提下,一样提供了和原生App同样的能力:

  1. 在调用Flutter页面以前将指定的图片资源按照设备屏幕密度缩放,并存储在App私有目录下。
  2. Flutter中使用时经过自定义的WMImage控件来加载,实际是经过转换成FileImage并自动设置scale为devicePixelRatio来加载。

这样就能够同时解决APK包大小和图片资源缺失1x图的问题。

Flutter和原生代码的通讯

咱们只用Flutter实现了一个页面,现有的大量逻辑都是用Java实现,在运行时会有许多场景必须使用原生应用中的逻辑和功能,例如网络请求,咱们统一的网络库会在每一个网络请求中添加许多通用参数,也会负责成功率等指标的监控,还有异常上报,咱们须要在捕获到关键异常时将其堆栈和环境信息上报到服务器。这些功能不太可能当即使用Dart实现一套出来,因此咱们须要使用Dart提供的Platform Channel功能来实现Dart→Java之间的互相调用。

以网络请求为例,咱们在Dart中定义一个MethodChannel对象:

import 'dart:async';
import 'package:flutter/services.dart';
const MethodChannel _channel = const MethodChannel('com.sankuai.waimai/network'); Future<Map<String, dynamic>> post(String path, [Map<String, dynamic> form]) async { return _channel.invokeMethod("post", {'path': path, 'body': form}).then((result) { return new Map<String, dynamic>.from(result); }).catchError((_) => null); } 

而后在Java端实现相同名称的MethodChannel:

public class FlutterNetworkPlugin implements MethodChannel.MethodCallHandler { private static final String CHANNEL_NAME = "com.sankuai.waimai/network"; @Override public void onMethodCall(MethodCall methodCall, final MethodChannel.Result result) { switch (methodCall.method) { case "post": RetrofitManager.performRequest(post((String) methodCall.argument("path"), (Map) methodCall.argument("body")), new DefaultSubscriber<Map>() { @Override public void onError(Throwable e) { result.error(e.getClass().getCanonicalName(), e.getMessage(), null); } @Override public void onNext(Map stringBaseResponse) { result.success(stringBaseResponse); } }, tag); break; default: result.notImplemented(); break; } } } 

在Flutter页面中注册后,调用post方法就能够调用对应的Java实现:

loadData: (callback) async {
    Map<String, dynamic> data = await post("home/groups");
    if (data == null) { callback(false); return; } _data = AllCategoryResponse.fromJson(data); if (_data == null || _data.code != 0) { callback(false); return; } callback(true); }), 

SO库兼容性

Flutter官方只提供了四种CPU架构的SO库:armeabi-v7a、arm64-v8a、x86和x86-64,其中x86系列只支持Debug模式,可是外卖使用的大量SDK都只提供了armeabi架构的库。虽然咱们能够经过修改引擎src根目录和third_party/dart目录下build/config/arm.gnithird_party/skia目录下的BUILD.gn等配置文件来编译出armeabi版本的Flutter引擎,可是实际上市面上绝大部分设备都已经支持armeabi-v7a,其提供的硬件加速浮点运算指令能够大大提升Flutter的运行速度,在灰度阶段咱们能够主动屏蔽掉不支持armeabi-v7a的设备,直接使用armeabi-v7a版本的引擎。作到这点咱们首先须要修改Flutter提供的引擎,在Flutter安装目录下的bin/cache/artifacts/engine下有Flutter下载的全部平台的引擎:

咱们只须要修改android-arm、android-arm-profile和android-arm-release下的flutter.jar,将其中的lib/armeabi-v7a/libflutter.so移动到lib/armeabi/libflutter.so便可:

cd $FLUTTER_ROOT/bin/cache/artifacts/engine
for arch in android-arm android-arm-profile android-arm-release; do pushd $arch cp flutter.jar flutter-armeabi-v7a.jar # 备份 unzip flutter.jar lib/armeabi-v7a/libflutter.so mv lib/armeabi-v7a lib/armeabi zip -d flutter.jar lib/armeabi-v7a/libflutter.so zip flutter.jar lib/armeabi/libflutter.so popd done 

这样在打包后Flutter的SO库就会打到APK的lib/armeabi目录中。在运行时若是设备不支持armeabi-v7a可能会崩溃,因此咱们须要主动识别并屏蔽掉这类设备,在Android上判断设备是否支持armeabi-v7a也很简单:

public static boolean isARMv7Compatible() { try { if (SDK_INT >= LOLLIPOP) { for (String abi : Build.SUPPORTED_32_BIT_ABIS) { if (abi.equals("armeabi-v7a")) { return true; } } } else { if (CPU_ABI.equals("armeabi-v7a") || CPU_ABI.equals("arm64-v8a")) { return true; } } } catch (Throwable e) { L.wtf(e); } return false; } 

灰度和自动降级策略

Horn是一个美团内部的跨平台配置下发SDK,使用Horn能够很方便地指定灰度开关:

在条件配置页面定义一系列条件,而后在参数配置页面添加新的字段flutter便可:

由于在客户端作了ABI兜底策略,因此这里定义的ABI规则并无启用。

Flutter目前仍然处于Beta阶段,灰度过程当中不免发生崩溃现象,观察到崩溃后再针对机型或者设备ID来作降级虽然能够尽可能下降影响,可是咱们能够作到更迅速。外卖的Crash采集SDK同时也支持JNI Crash的收集,咱们专门为Flutter注册了崩溃监听器,一旦采集到Flutter相关的JNI Crash就当即中止该设备的Flutter功能,启动Flutter以前会先判断FLUTTER_NATIVE_CRASH_FLAG文件是否存在,若是存在则表示该设备发生过Flutter相关的崩溃,颇有多是不兼容致使的问题,当前版本周期内在该设备上就再也不使用Flutter功能。

除了崩溃之外,Flutter页面中的Dart代码也可能发生异常,例如服务器下发数据格式错误致使解析失败等等,Dart也提供了全局的异常捕获功能:

import 'package:wm_app/plugins/wm_metrics.dart';

void main() {
  runZoned(() => runApp(WaimaiApp()), onError: (Object obj, StackTrace stack) { uploadException("$obj\n$stack"); }); } 

这样咱们就能够实现全方位的异常监控和完善的降级策略,最大程度减小灰度时可能对用户带来的影响。

分析崩溃堆栈和异常数据

Flutter的引擎部分所有使用C/C++实现,为了减小包大小,全部的SO库在发布时都会去除符号表信息。和其余的JNI崩溃堆栈同样,咱们上报的堆栈信息中只能看到内存地址偏移量等信息:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys'
Revision: '0'
Author: collect by 'libunwind' ABI: 'arm64-v8a' pid: 28937, tid: 29314, name: 1.ui >>> com.sankuai.meituan.takeoutnew <<< signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0  backtrace: r0 00000000 r1 ffffffff r2 c0e7cb2c r3 c15affcc r4 c15aff88 r5 c0e7cb2c r6 c15aff90 r7 bf567800 r8 c0e7cc58 r9 00000000 sl c15aff0c fp 00000001 ip 80000000 sp c0e7cb28 lr c11a03f9 pc c1254088 cpsr 200c0030 #00 pc 002d7088 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #01 pc 002d5a23 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #02 pc 002d95b5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #03 pc 002d9f33 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #04 pc 00068e6d /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #05 pc 00067da5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #06 pc 00067d5f /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #07 pc 003b1877 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #08 pc 003b1db5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so #09 pc 0000241c /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr 

单纯这些信息很难定位问题,因此咱们须要使用NDK提供的ndk-stack来解析出具体的代码位置:

ndk-stack -sym PATH [-dump PATH]
Symbolizes the stack trace from an Android native crash. -sym PATH sets the root directory for symbols -dump PATH sets the file containing the crash dump (default stdin) 

若是使用了定制过的引擎,必须使用engine/src/out/android-release下编译出的libflutter.so文件。通常状况下咱们使用的是官方版本的引擎,能够在flutter_infra页面直接下载带有符号表的SO文件,根据打包时使用的Flutter工具版本下载对应的文件便可。好比0.4.4 beta版本:

$ flutter --version # version命令能够看到Engine对应的版本 06afdfe54e
Flutter 0.4.4 • channel beta • https://github.com/flutter/flutter.git
Framework • revision f9bb4289e9 (5 weeks ago) • 2018-05-11 21:44:54 -0700 Engine • revision 06afdfe54e Tools • Dart 2.0.0-dev.54.0.flutter-46ab040e58 $ cat flutter/bin/internal/engine.version # flutter安装目录下的engine.version文件也能够看到完整的版本信息 06afdfe54ebef9168a90ca00a6721c2d36e6aafa 06afdfe54ebef9168a90ca00a6721c2d36e6aafa 

拿到引擎版本号后在 https://console.cloud.google.com/storage/browser/flutter_infra/flutter/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/ 看到该版本对应的全部构建产物,下载android-arm-release、android-arm64-release和android-x86目录下的symbols.zip,并存放到对应目录:

执行ndk-stack便可看到实际发生崩溃的代码和具体行数信息:

ndk-stack -sym flutter-production-syms/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/armeabi-v7a -dump flutter_jni_crash.txt 
********** Crash dump: **********
Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys'
pid: 28937, tid: 29314, name: 1.ui >>> com.sankuai.meituan.takeoutnew <<< signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 Stack frame #00 pc 002d7088 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::WordBreaker::setText(unsigned short const*, unsigned int) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/WordBreaker.cpp:55 Stack frame #01 pc 002d5a23 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::LineBreaker::setText() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/LineBreaker.cpp:74 Stack frame #02 pc 002d95b5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::ComputeLineBreaks() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:273 Stack frame #03 pc 002d9f33 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::Layout(double, bool) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:428 Stack frame #04 pc 00068e6d /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine blink::ParagraphImplTxt::layout(double) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/lib/ui/text/paragraph_impl_txt.cc:54 Stack frame #05 pc 00067da5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine tonic::DartDispatcher<tonic::IndicesHolder<0u>, void (blink::Paragraph::*)(double)>::Dispatch(void (blink::Paragraph::*)(double)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:150 Stack frame #06 pc 00067d5f /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine void tonic::DartCall<void (blink::Paragraph::*)(double)>(void (blink::Paragraph::*)(double), _Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:198 Stack frame #07 pc 003b1877 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::AutoScopeNativeCallWrapperNoStackCheck(_Dart_NativeArguments*, void (*)(_Dart_NativeArguments*)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:198 Stack frame #08 pc 003b1db5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::LinkNativeCall(_Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:348 Stack frame #09 pc 0000241c /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr 

Dart异常则比较简单,默认状况下Dart代码在编译成机器码时并无去除符号表信息,因此Dart的异常堆栈自己就能够标识真实发生异常的代码文件和行数信息:

FlutterException: type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'num' in type cast #0 _$CategoryGroupFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:29) #1 new CategoryGroup.fromJson (package:wm_app/all_category/model/category_model.dart:51) #2 _$CategoryListDataFromJson.<anonymous closure> (package:wm_app/lib/all_category/model/category_model.g.dart:5) #3 MappedListIterable.elementAt (dart:_internal/iterable.dart:414) #4 ListIterable.toList (dart:_internal/iterable.dart:219) #5 _$CategoryListDataFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:6) #6 new CategoryListData.fromJson (package:wm_app/all_category/model/category_model.dart:19) #7 _$AllCategoryResponseFromJson (package:wm_app/lib/all_category/model/category_model.g.dart:19) #8 new AllCategoryResponse.fromJson (package:wm_app/all_category/model/category_model.dart:29) #9 AllCategoryPage.build.<anonymous closure> (package:wm_app/all_category/category_page.dart:46) <asynchronous suspension> #10 _WaimaiLoadingState.build (package:wm_app/all_category/widgets/progressive_loading_page.dart:51) #11 StatefulElement.build (package:flutter/src/widgets/framework.dart:3730) #12 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:3642) #13 Element.rebuild (package:flutter/src/widgets/framework.dart:3495) #14 BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2242) #15 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding&PaintingBinding&RendererBinding&WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:626) #16 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding&PaintingBinding&RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:208) #17 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:990) #18 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:930) #19 _WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:842) #20 _rootRun (dart:async/zone.dart:1126) #21 _CustomZone.run (dart:async/zone.dart:1023) #22 _CustomZone.runGuarded (dart:async/zone.dart:925) #23 _invoke (dart:ui/hooks.dart:122) #24 _drawFrame (dart:ui/hooks.dart:109) 

Flutter和原生性能对比

虽然使用原生实现(左)和Flutter实现(右)的全品类页面在实际使用过程当中几乎分辨不出来:

可是咱们还须要在性能方面有一个比较明确的数据对比。

咱们最关心的两个页面性能指标就是页面加载时间和页面渲染速度。测试页面加载速度能够直接使用美团内部的Metrics性能测试工具,咱们将页面Activity对象建立做为页面加载的开始时间,页面API数据返回做为页面加载结束时间。从两个实现的页面分别启动400屡次的数据中能够看到,原生实现(AllCategoryActivity)的加载时间中位数为210ms,Flutter实现(FlutterCategoryActivity)的加载时间中位数为231ms。考虑到目前咱们尚未针对FlutterView作缓存和重用,FlutterView每次建立都须要初始化整个Flutter环境并加载相关代码,多出的20ms还在预期范围内:

由于Flutter的UI逻辑和绘制代码都不在主线程执行,Metrics原有的FPS功能没法统计到Flutter页面的真实状况,咱们须要用特殊方法来对比两种实现的渲染效率。Android原生实现的界面渲染耗时使用系统提供的FrameMetrics接口进行监控:

public class AllCategoryActivity extends WmBaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { getWindow().addOnFrameMetricsAvailableListener(new Window.OnFrameMetricsAvailableListener() { List<Integer> frameDurations = new ArrayList<>(100); @Override public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) { frameDurations.add((int) (frameMetrics.getMetric(TOTAL_DURATION) / 1000000)); if (frameDurations.size() == 100) { getWindow().removeOnFrameMetricsAvailableListener(this); L.w("AllCategory", Arrays.toString(frameDurations.toArray())); } } }, new Handler(Looper.getMainLooper())); } super.onCreate(savedInstanceState); // ... } } 

Flutter在Framework层只能取到每帧中UI操做的CPU耗时,GPU操做都在Flutter引擎内部实现,因此须要修改引擎来监控完整的渲染耗时,在Flutter引擎目录下的src/flutter/shell/common/rasterizer.cc文件中添加:

void Rasterizer::DoDraw(std::unique_ptr<flow::LayerTree> layer_tree) {
  if (!layer_tree || !surface_) {
    return; } if (DrawToSurface(*layer_tree)) { last_layer_tree_ = std::move(layer_tree); #if defined(OS_ANDROID) if (compositor_context_->frame_count().count() == 101) { std::ostringstream os; os << "["; const std::vector<TimeDelta> &engine_laps = compositor_context_->engine_time().Laps(); const std::vector<TimeDelta> &frame_laps = compositor_context_->frame_time().Laps(); size_t i = 1; for (auto engine_iter = engine_laps.begin() + 1, frame_iter = frame_laps.begin() + 1; i < 101 && engine_iter != engine_laps.end(); i++, engine_iter++, frame_iter++) { os << (*engine_iter + *frame_iter).ToMilliseconds() << ","; } os << "]"; __android_log_write(ANDROID_LOG_WARN, "AllCategory", os.str().c_str()); } #endif } } 

便可获得每帧绘制时真正消耗的时间。测试时咱们将两种实现的页面分别打开100次,每次打开后执行两次滚动操做,使其绘制100帧,将这100帧的每帧耗时记录下来:

for (( i = 0; i < 100; i++ )); do
	openWMPage allcategory
	sleep 1 adb shell input swipe 500 1000 500 300 900 adb shell input swipe 500 1000 500 300 900 adb shell input keyevent 4 done 

将测试结果的100次启动中每帧耗时取平均値,获得每帧平均耗时状况(横坐标轴为帧序列,纵坐标轴为每帧耗时,单位为毫秒):

Android原生实现和Flutter版本都会在页面打开的前5帧超过16ms,刚打开页面时原生实现须要建立大量View,Flutter也须要建立大量Widget,后续帧中能够重用大部分控件和渲染节点(原生的RenderNode和Flutter的RenderObject),因此启动时的布局和渲染操做都是最耗时的。

10000帧(100次×100帧每次)中Android原生总平均値为10.21ms,Flutter总平均値为12.28ms,Android原生实现总丢帧数851帧8.51%,Flutter总丢帧987帧9.87%。在原生实现的触摸事件处理和过分绘制充分优化的前提下,Flutter彻底能够媲美原生的性能。

总结

Flutter目前仍处于早期阶段,也尚未发布正式的Release版本,不过咱们看到Flutter团队一直在为这一目标而努力。虽然Flutter的开发生态不如Android和iOS原生应用那么成熟,许多经常使用的复杂控件还须要本身实现,有的甚至会比较困难(好比官方还没有提供的ListView.scrollTo(index)功能),可是在高性能和跨平台方面Flutter在众多UI框架中仍是有很大优点的。

开发Flutter应用只能使用Dart语言,Dart自己既有静态语言的特性,也支持动态语言的部分特性,对于Java和JavaScript开发者来讲门槛都不高,3-5天能够快速上手,大约1-2周能够熟练掌握。在开发全品类页面的Flutter版本时咱们也深入体会到了Dart语言的魅力,Dart的语言特性使得Flutter的界面构建过程也比Android原生的XML+JAVA更直观,代码量也从原来的900多行减小到500多行(排除掉引用的公共组件)。Flutter页面集成到App后APK体积至少会增长5.5MB,其中包括3.3MB的SO库文件和2.2MB的ICU数据文件,此外业务代码1300行编译产物的大小有2MB左右。

Flutter自己的特性适合追求iOS和Android跨平台的一致体验,追求高性能的UI交互效果的场景,不适合追求动态化部署的场景。Flutter在Android上已经能够实现动态化部署,可是因为Apple的限制,在iOS上实现动态化部署很是困难,Flutter团队也正在和Apple积极沟通。

 

Picasso 开启大前端的将来

Picasso是大众点评移动研发团队自研的高性能跨平台动态化框架,通过两年多的孕育和发展,目前在美团多个事业群已经实现了大规模的应用。

Picasso源自咱们对大前端实践的从新思考,以简洁高效的架构达成高性能的页面渲染目标。在实践中,甚至能够把Native技术向Picasso技术的迁移当作一种性能优化手段;与此同时,Picasso在跨越小程序端和Web端方面的工做已经取得了突破性进展,有望在四端(Android、iOS、H五、微信小程序)统一大前端实践的基础之上,达成高性能大前端实践,同时配合Picasso布局DSL强表达能力和Picasso代码生成技术,能够进一步提高生产力。

客户端动态化

2007年,苹果公司第一代iPhone发布,它的出现“从新定义了手机”,并开启了移动互联网蓬勃发展的序幕。Android、iOS等移动技术,打破了Web应用开发技术即将一统江湖的局面,以后海量的应用如雨后春笋般涌现出来。移动开发技术给用户提供了更好的移动端使用和交互体验,但其“静态”的开发模式却给须要快速迭代的互联网团队带来了沉重的负担。

客户端“静态”开发模式

客户端开发静态模式

客户端开发静态模式

 

客户端开发技术与Web端开发技术相比,天生带有“静态”的特性,咱们能够从空间和时间两个维度来看。

从空间上看须要集成发布,美团App承载业务众多,是跨业务合流,横向涉及开发人员最多的公司,虽然开发人员付出了巨大的心血完成了业务间的组件化解耦拆分,但依然无可避免的形成了如下问题:

  1. 编译时间过长。 随着代码复杂度的增长,集成编译的时间愈来愈长。研发力量被等待编译大量消耗,集成检查也变成了一个巨大的挑战。
  2. App包体增加过快。 这与迅猛发展的互联网势头相符,但与新用户拓展和业务迭代进化造成了尖锐矛盾。
  3. 运行时耦合严重。 在集成发布的包体内,任何一个功能组件产生的Crash、内存泄漏等异常行为都会致使整个App可用性降低,带来较大的损失。
  4. 集成难度大。 业务线间代码复用耦合,业务层、框架层、基础服务层错综复杂,须要拆分出至关多的兼容层代码,影响总体开发效率。

从时间上看须要集中发布,线上Bug修复须发版或热修复,成本高昂。新功能的添加也必须等待统一的发版周期,这对快速成长的业务来讲是不可接受的。App开发还面临严重的长尾问题,没法为使用老版本的用户提供最新的功能,严重损害了用户和业务方的利益。

这种“静态”的开发模式,会对研发效率和运营质量产生负面影响。对于传统的桌面应用软件开发而言,静态的研发模式也许是相对能够接受的。但对于业务蓬勃发展的移动互联网行业来讲,静态开发模式和敏捷迭代发布需求的矛盾日益突出。

客户端动态化的趋势

如何解决客户端“静态”开发模式带来的问题?

业界最先给出的答案是使用Web技术

但Web技术与Native平台相比存在性能和交互体验上的差距。在一些性能和交互体验能够妥协的场景,Web技术能够在定制容器、离线化等技术的支持下,承载运营性质的须要快速迭代试错的页面。

另外一个业界给出的思路是优化Web实现

利用移动客户端技术的灵活性与高性能,再造一个“标准Web浏览器”,使得“Web技术”同时具备高性能、良好的交互体验以及Web技术的动态性。此次技术浪潮中Facebook再次成为先驱,推出了React Native技术(简称RN)。不过RN的设计取向有些奇怪,RN不兼容标准Web,甚至不为Android、iOS双端行为对齐作努力。产生的后果就是全部“吃螃蟹”的公司都须要作二次开发才能基本对齐双端的诉求。同时还须要尽最大努力为RN的兼容性问题、稳定性问题甚至是性能问题买单。

而咱们给出的答案是Picasso

客户端开发静态模式

客户端开发静态模式

 

Picasso另辟蹊径,在实现高性能动态化能力的同时,还以较强的适应能力,以动态页面、动态模块甚至是动态视图的形式融入到业务开发代码体系中,赢得了许多移动研发团队的认同。

Picasso框架跨Web端和小程序端的实践也已经取得了突破性进展,除了达成四端统一的大前端融合目标,Picasso的布局理念有望支持四端的高性能渲染,同时配合Picasso代码生成技术以及Picasso的强表达能力,生产力在大前端统一的基础之上获得了进一步的提高。

Picasso动态化原理

Picasso应用程序开发者使用基于通用编程语言的布局DSL代码编写布局逻辑。布局逻辑根据给定的屏幕宽高和业务数据,计算出精准适配屏幕和业务数据的布局信息、视图结构信息和文本、图片URL等必要的业务渲染信息,咱们称这些视图渲染信息为PModel。PModel做为Picasso布局渲染的中间结果,和最终渲染出的视图结构一一对应;Picasso渲染引擎根据PModel的信息,递归构建出Native视图树,并完成业务渲染信息的填充,从而完成Picasso渲染过程。须要指出的是,渲染引擎不作适配计算,使用布局DSL表达布局需求的同时完成布局计算,既所谓“表达即计算”。

从更大的图景上看,Picasso开发人员用TypeScript在VSCode中编写Picasso应用程序;提交代码后能够经过Picasso持续集成系统自动化的完成Lint检查和打包,在Picasso分发系统进行灰度发布,Picasso应用程序最终以JavaScript包的形式下发到客户端,由Picasso SDK解释执行,达成客户端业务逻辑动态化的目的。

在应用程序开发过程当中,TypeScript的静态类型系统,搭配VSCode以及Picasso Debug插件,能够得到媲美传统移动客户端开发IDE的智能感知和断点调试的开发体验。Picasso CI系统配合TypeScript的类型系统,能够避免低级错误,助力多端和多团队的配合;同时能够经过“兼容计算”有效的解决能力支持的长尾问题。

Picasso布局DSL

Picasso针对移动端主流的布局引擎和系统作了系统的对比分析,这些系统包括:

  1. Android开发经常使用的LinearLayout
  2. 前端及Picasso同类动态化框架使用的FlexBox
  3. 苹果公司主推的AutoLayout

其中苹果官方推出的AutoLayout缺少一个好用的DSL,因此咱们直接将移动开发者社区贡献的AutoLayout DSL方案列入对比。

首先从性能上看,AutoLayout系统是表现最差的,随着需求复杂度的增长“布局计算”耗时成指数级的增加。FlexBox和LinearLayout相比较AutoLayout而言会在性能表现上有较大优点。可是LinearLayout和FlexBox会让开发者为了布局方面须要的概念增长没必要要的视图层级,进而带来渲染性能问题。

从灵活性上看,LinearLayout和FlexBox布局有很强的概念约束。一个强调线性排布,一个强调盒子模式、伸缩等概念,这些模型在布局需求和模型概念不匹配时,就不得不借助编程语言进行干预。而且因为布局系统的隔离,这样的干预并不容易作,必定程度上影响了布局的灵活性和表达能力。而配合基于通用编程语言设计的DSL加上AutoLayout的布局逻辑,能够得到理论上最强的灵活性。可是这三个布局系统都在试图解决“用声明式的方式表达布局逻辑的问题”,基于编程语言的DSL的引入让布局计算引擎变得多余。

Picasso布局DSL的核心在于:

  1. 基于通用编程语言设计。
  2. 支持锚点概念(如上图)。

使用锚点概念能够简单清晰的设置非同一个坐标轴方向的两个锚点“锚定”好的视图位置。同时锚点能够提供描述“相对”位置关系语义支持。事实上,针对布局的需求更符合人类思惟的描述是相似于“B位于A的右边,间距10,顶对齐”,而不该该是“A和B在一个水平布局容器中……”。锚点概念经过极简的实现消除了需求描述和视图系统底层实现之间的语义差距。

下面举几个典型的例子说明锚点的用法:

1 居中对齐:

    view.centerX = bgView.width / 2
    view.centerY = bgView.height /2

2 右对齐:

    view.right = bgView.width - 10
    view.centerY = bgView.height / 2

3 相对排列:

    viewB.top = viewA.top
    viewB.left = viewA.right + 10

4 “花式”布局:

    viewB.top = viewA.centerY
    viewB.left = viewA.centerX

Picasso锚点布局逻辑具备理论上最为灵活的的表达能力,能够作到“所想即所得”的表达布局需求。可是有些时候咱们会发如今特定的场景下这样的表达能力是“过剩的”。相似于下图的布局需求,须要水平排布4个视图元素、间距十、顶对齐;可能会有以下的锚点布局逻辑代码:

    v1.top = 10
    v1.left = 10
    v2.top = v1.top v3.top = v2.top v4.top = v3.top v2.left = v1.right + 10 v3.left = v2.right + 10 v4.left = v3.right + 10 

显然这样的代码不是特别理想,其中有较多可抽象的重复的逻辑,针对这样的需求场景,Picasso提供了hlayout布局函数,完美的解决了水平排布的问题:

    hlayout([v1, v2, v3, v4],
           { top: 10, left: 10, divideSpace: 10 }) 

有心人能够发现,这和Android平台经典的LinearLayout一模一样。对应hlayout函数的还有vlayout,这一对几乎完整实现Android LinearLayout语义的兄弟函数,实现逻辑不足300行,这里强调的重点其实不在于两个layout函数,而是Picasso布局DSL无限制的抽象表达能力。若是业务场景中须要相似于Flexbox或其余的概念模型,业务应用方均可以按需快速的作出实现。

在性能方面,Picasso锚点布局系统避免了“声明式到命令式”的计算过程,彻底无需布局计算引擎的介入,达成了“需求表达即计算”的效果,具备理论上最佳性能表现。

因而可知,Picasso布局DSL,不管在性能潜力和表达能力方面都优于以上布局系统。Picasso布局DSL的设计是Picasso得以构建高性能四端动态化框架的基石。

同时得益于Picasso布局DSL的表达能力和扩展能力,Picasso在自动化生成布局代码方面也具备得天独厚的优点,生成的代码更具备可维护性和扩展性。伴随着Picasso的普及,当前前端研发过程当中“视觉还原”的过程会成为历史,前端开发者的经历也会从“复制”视觉稿的重复劳动中解脱出来。

Picasso高性能渲染

业界对于动态化方案的期待一直是“接近原生性能”,可是Picasso却作到了等同于原生的渲染效率,在复杂业务场景能够达成超越原生技术基本实践的效果。就目前Picasso在美团移动团队实践来看,同一个页面使用Picasso技术实现会得到更好的性能表现。

Picasso实现高性能的基础是宿主端高效的原生渲染,但实现“青出于蓝而胜于蓝”的效果却有些反直觉,在这背后是有理论上的必然性的:

  • Picasso的锚点布局让 布局表达和布局计算同时发生。避免了冗余反复的布局计算过程。

  • Picasso的布局理念使 视图层级扁平。全部的视图都各自独立,没有为了布局逻辑表达所产生的冗余层级。

  • Picasso设计支持了 预计算的过程。本来须要在主线程进行计算的部分过程能够在后台线程进行。

在常规的原生业务编码中,很难将这些优化作到最好,由于对比每一个小点所带来的性能提高而言,应用逻辑复杂度的提高是不能接受的。而Picasso渲染引擎,将传统原生业务逻辑开发所能作的性能优化作到了“统一复用”,实现了一次优化,全线受益的目标。

Picasso在美团内部的应用

Picasso跨平台高性能动态化框架在集团内部发布后,获得了普遍关注,集团内部对于客户端动态化的方向也十分承认,积极的在急需敏捷发布能力的业务场景展开Picasso应用实践;通过大概两年多的内部迭代使用,Picasso的可靠性、性能、业务适应能力受到的集团内部的确定,Picasso动态化技术获得了普遍的应用。

经过Picasso的桥接能力,基于Picasso的上层应用程序仍然能够利用集团内部移动技术团队积累的高质量基础建设,同时已经造成初步的公司内部大生态,多个部门已经向Picasso生态贡献了动画能力、动态模块能力、复用Web容器桥接基建能力、大量业务组件和通用组件。

Picasso团队除了持续维护Picasso SDK,Picasso持续集成系统、包括基于VSCode的断点调试,Liveload等核心开发工具链,还为集团提供了统一的分发系统,为集团内部大前端团队开展Picasso动态化实践奠基了坚实的基础。

到发稿时,集团内部Picasso应用领先的BG已经实现Picasso动态化技术覆盖80%以上的业务开发,相信通过更长时间的孵化,Picasso会成为美团移动开发技术的“神兵利器”,助力公司技术团队实现高速发展。

列举Picasso在美团的部分应用案例:

Picasso开启大前端将来

Picasso在实践客户端动态化的方向取得了成功,解决了传统客户端“静态”研发模式致使的种种痛点。总结下来:

  1. 若是想要 敏捷发布,使用Picasso。
  2. 若是想要 高交付质量,使用Picasso。
  3. 若是想要 优秀用户体验,使用Picasso。
  4. 若是想要 高性能表现,使用Picasso。
  5. 若是想要 自动化生成布局代码,使用Picasso。
  6. 若是想要 高效生产力,使用Picasso。

至此Picasso并无中止持续创新的脚步,目前Picasso在Web端和微信小程序端的适配工做已经有了突破性进展,正如Picasso在移动端取得的成就同样,Picasso会在完成四端统一(Android、iOS、Web、小程序)的同时,构建出更快、更强的大前端实践。

业界对大前端融合的将来有不少想象和憧憬,Picasso动态化实践已经开启大前端将来的一种新的可能。

 

美团客户端响应式框架 EasyReact 开源啦

EasyReact

EasyReact

 

前言

EasyReact 是一款基于响应式编程范式的客户端开发框架,开发者能够使用此框架轻松地解决客户端的异步问题。

目前 EasyReact 已在美团和大众点评客户端的部分业务中实践,而且持续迭代了一年多的时间。近日,咱们决定开源这个项目的 iOS Objective-C 语言部分,但愿可以帮助更多的开发者不断探索更普遍的业务场景,也欢迎更多的社区的开发者跟咱们一块儿增强 EasyReact 的功能。Github 的项目地址,参见 https://github.com/meituan-dianping/EasyReact

背景

美团 iOS 客户端团队在业界比较早地使用响应式来解决项目问题,为此咱们引入了 ReactiveCocoa 这个函数响应式框架(相关实践,参考以前的 系列博客)。随着业务的急速扩张和团队拆分变动,ReactiveCocoa 在解决异步问题的同时也带来了新的挑战,总结起来有如下几点:

  1. 高学习门槛
  2. 易出错
  3. 调试困难
  4. 风格不统一

既然响应式编程带来了这么多的麻烦,是否咱们应该摒弃响应式编程,用更通俗易懂的面向对象编程来解决问题呢?这要从移动端开发的特色提及。

移动端开发特色

客户端程序自己充满异步的场景。客户端的主要逻辑就是从视图中处理控件事件,经过网络获取后端内容再展现到视图上。这其中事件的处理和网络的处理都是异步行为。

通常客户端程序发起网络请求后程序会异步的继续执行,等待网络资源的获取。一般咱们还会须要设置必定的标志位和显示一些加载指示器来让视图进行等待。可是当网络进行获取的时候,通知、UI 事件、定时器都对状态产生改变就会致使状态的错乱。咱们是否也遇到过:忙碌指示器没有正确隐藏掉,页面的显示的字段被错误的显示成旧的值,甚至一个页面几个部分信息不一样步的状况?

单个的问题看似简单,可是客户端飞速发展的今年,不少公司包括美团在内的客户端代码行数早已突破百万。业务逻辑愈发复杂,使得维护状态自己就成了一个大问题。响应式编程正是解决这个问题的一种手段。

响应式编程的相关概念

响应式编程是基于数据流动编程的一种编程范式。作过 iOS 客户端开发的同窗必定了解过 KVO 这一系列的 API。

KVO 帮助咱们将属性的变动和变动后的处理分离开,大大简化了咱们的更新逻辑。响应式编程将这一优点体现得更加淋漓尽致,能够简单的理解成一个对象的属性改变后,另一连串对象的属性都随之发生改变。

响应式的最简单例子莫过于电子表格,Excel 和 Numbers 中单元格公式就是一个响应的例子。咱们只须要关心单元格和单元格的关系,而不须要关心当一个单元格发生变化,另外的单元格须要进行怎样的处理。“程序”的书写被提早到事件发生以前,因此响应式编程是一种声明式编程。它帮助咱们将更多的精力集中在描述数据流动的关系上,而不是关注数据变化时处理的动做。

单纯的响应式编程,好比电子表格中的公式和 KVO 是比较容易理解的,可是为了在 Objective-C 语言中支持响应式特性,ReactiveCocoa 使用了函数响应式编程的手段实现了响应式编程框架。而函数式编程正是形成你们学习路径陡峭的主要缘由。在函数式编程的世界中, 一切又都复杂起来了。这些复杂的概念,如 Immutable、Side effect、High-order Function、Functor、Applicative、Monad 等等,让不少开发者望而却步。

防不胜防的错误

函数式编程主要使用高阶函数来解决问题,映射到 Objective-C 语言中就是使用 Block 来进行主要的处理。因为 Objective-C 使用自动引用计数(ARC)来管理内存,一旦出现循环引用,就须要程序员主动破除循环引用。而 Block 闭包捕获变量最容易造成循环引用。无脑的 weakify-strongify 会引发提前释放,而无脑的不使用 weakify-strongify 则会引发循环引用。即使是“老手”在使用的过程当中,也不免出错。

另外,ReactiveCocoa 框架为了方便开发者更快的使用响应式编程,Hook 了不少 Cocoa 框架中的功能,例如 KVO、Notification Center、Perform Selector。一旦其它框架在 Hook 的过程当中与之造成冲突,后续问题的排查就变得十分困难。

调试的困难性

函数响应式编程使用高阶函数还带来了另一个问题,那就是大量的嵌套闭包函数致使的调用栈深度问题。在 ReactiveCocoa 2.5 版本中,进行简单的 5 次变换,其调用栈深度甚至达到了 50 层(见下图)。

ReactiveCocoa 的调用栈

ReactiveCocoa 的调用栈

 

仔细观察调用栈,咱们发现整个调用栈的内容极为类似,难以从中发现问题所在。

另外异步场景更是给调试增长了新的难度。不少时候,数据的变化是由其余队列派发过来的,咱们甚至没法在调用栈中追溯数据变化的来源。

风格差别化

业内不少人使用 FRP 框架来解决 MVVM 架构中的绑定问题。在业务实践中不少操做是高度类似且可被泛化的,这也意味着,能够被脚手架工具自动生成。

但目前业内知名的框架并无提供相应的工具,最佳实践也没法“模板化”地传递下去。这就致使了对于 MVVM 和响应式编程,你们有了各自不一样的理解。

EasyReact的初心

EasyReact 的诞生,其初心是为了解决 iOS 工程实现 MVVM 架构但没有对应的框架支撑,而致使的风格不统1、可维护性差、开发效率低等多种问题。而 MVVM 中最重要的一个功能就是绑定,EasyReact 就是为了让绑定和响应式的代码变得 Easy 起来。

它的目标就是让开发者可以简单的理解响应式编程,而且简单的将响应式编程的优点利用起来。

EasyReact 依赖库介绍

EasyReact 先是基于 Objective-C 开发。而 Objective-C 是一门古老的编程语言,在 2014 年苹果公司推出 Swift 编程语言以后,Objective-C 已经基本再也不更新,而 Swift支持的 Tuple 类型和集合类型自带的 mapfilter 等方法会让代码更清晰易读。 在 EasyReact Objective-C 版本的开发中,咱们还衍生了一些周边库以支持这些新的代码技巧和语法糖。这些周边库现已开源,而且能够独立于 EasyReact 使用。

EasyTuple

EasyTuple

 

EasyTuple 使用宏构造出相似 Swift 的 Tuple 语法。使用 Tuple 能够让你在须要传递一个简单的数据架构的时,没必要手动为其建立对应的类,轻松的交给框架解决。

EasySequence

EasySequence

 

EasySequence 是一个给集合类型扩展的库,能够清晰的表达对一个集合类型的迭代操做,而且经过巧妙的手法能够让这些迭代操做使用链式语法拼接起来。同时 EasySequence 也提供了一系列的 线程安全 和 weak 内存管理的集合类型用以补充系统容器没法提供的功能。

EasyFoundation

EasyFoundation

 

EasyFoundation 是上述 EasyTuple 和 EasySequence 以及将来底层依赖库的一个统一封装。

用 EasyReact 解决以前的问题

EasyReact 因业务的须要而诞生,首要的任务就是解决业务中出现的那几点问题。咱们来看看建设至今,那几个问题是否已经解决:

响应式编程的学习门槛

前面已经分析过,单纯的响应式编程并非特别的难以理解,而函数式编程才是形成高学习门槛的缘由。所以 EasyReact 采用你们都熟知的面向对象编程进行设计, 想要了解代码,相对于函数式编程变得容易不少。

另外响应式编程基于数据流动,流动就会产生一个有向的流动网络图。在函数式编程中,网络图是使用闭包捕获来创建的,这样作很是不利于图的查找和遍历。而 EasyReact 选择在框架中使用图的数据结构,将数据流动的有向网络图抽象成有向有环图的节点和边。这样使得框架在运行过程当中能够随时查询到节点和边的关系,详细内容能够参见 框架概述

另外对于已经熟悉了 ReactiveCocoa 的同窗来讲,咱们也在数据的流动操做上基本实现了 ReactiveCocoa API。详细内容能够参见 基本操做。更多的功能能够向咱们提功能的 ISSUE,也欢迎你们可以提 Pull Request 来共同建设 EasyReact。

避免不经意的错误

前面提到过 ReactiveCocoa 易形成循环引用或者提前释放的问题,那 EasyReact 是怎样解决这个问题的呢?由于 EasyReact 中的节点和边以及监听者都不是使用闭包来进行捕获,因此刨除转换和订阅中存在的反作用(转换 block 或者订阅 block 中致使的闭包捕获),EasyReact 是能够自动管理内存的。详细内容能够参见 内存管理

除了内存问题,ReactiveCocoa 中的 Hook Cocoa 框架问题,在 EasyReact 上经过规避手段来进行处理。EasyReact 在整个计划中只是用来完成最基本的数据流驱动的部分,因此自己与 Cocoa 和 CocoaTouch 框架无关,必定程度上避免了与系统 API 和其余库 Hook 形成冲突。这并非指 Easy 系列不去解决相应的部分,而是 Easy 系列但愿以更规范和加以约束的方式来解决相同问题,后续 Easy 系列的其余开源项目中会有更多这些特定需求的解决方案。

EasyReact 的调试

EasyReact 利用对象的持有关系和方法调用来实现响应式中的数据流动,因此可方便的在调用栈信息中找出数据的传递关系。在 EasyReact 中,进行与前面 ReactiveCocoa 一样的 5 次简单变换,其调用栈只有 15 层(见下图)。

EasyReact 的调用栈

EasyReact 的调用栈

 

通过观察不难发现,调用栈的顺序刚好就是变换的行为。这是由于咱们将每种操做定义成一个边的类型,使得调用栈能够经过类名进行简单的分析。

为了方便调试,咱们提供了一个 - [EZRNode graph] 方法。任意一个节点调用这个方法均可以获得一段 GraphViz 程序的 DotDSL 描述字符串,开发者能够经过 GraphViz 工具观察节点的关系,更好的排查问题。

使用方式以下:

  1. macOS 安装 GraphViz 工具 brew install graphviz

  2. 打印 -[EZRNode graph] 返回的字符串或者 Debug 期间在 lldb 调用 -[EZRNode graph] 获取结果字符串,并输出保存至文件如 test.dot

  3. 使用工具分析生成图像 circo -Tpdf test.dot -o test.pdf && open test.pdf

结果示例:

节点静态图

节点静态图

 

另外咱们还开发了一个带有录屏而且能够动态查看应用程序中全部节点和边的调试工具,后期也会开源。开发中的工具是这样的:

节点动态图

节点动态图

 

响应式编程风格上的统一

EasyReact 帮助咱们解决了很多难题,遗憾的是它也不是“银弹”。在实际的项目实施中,咱们发现仅仅经过 EasyReact 仍然很难让你们在开发的过程当中风格上统一块儿来。固然它从写法上要比 ReactiveCocoa 上统一了一些,可是构建数据流仍然有着多种多样的方式。

因此咱们想到经过一个上层的业务框架来统一风格,这也就是后续衍生项目 EasyMVVM 诞生的缘由,不久后咱们也会将 EasyMVVM 进行开源。

EasyReact 和其余框架的对比

EasyReact 从诞生之初,就不可避免要和已有的其余响应式编程框架作对比。下表对几大响应式框架进行了一个大概的对比:

项目 EasyReact ReactiveCocoa ReactiveX
核心概念 图论和面向对象编程 函数式编程 函数式编程和泛型编程
传播可变性
基本变换
组合变换
高阶变换
遍历节点 / 信号
多语言支持 Objective-C (其余语言开源计划中) Objective-C、Swift 大量语言
性能 较快
中文文档支持
调试工具 静态拓扑图展现和动态调试工具(开源计划中) Instrument

性能方面,咱们也和一样是 Objective-C 语言的 ReactiveCocoa 2.5 版本作了相应的 Benchmark。

测试环境

编译平台: macOS High Sierra 10.13.5

IDE: Xcode 9.4.1

真机设备: iPhone X 256G iOS 11.4(15F79)

测试对象

  1. listener、map、filter、flattenMap 等单阶操做
  2. combine、zip、merge 等多点聚合操做
  3. 同步操做

其中测试的规模为:

  • 节点或信号个数 10 个
  • 触发操做次数 1000 次

例如 Listener 方法有 10 个监听者,重复发送值 1000 次。

统计时间单位为 ns。

测试数据

重复上面的实验 10 次,获得数据平均值以下:

name listener map filter flattenMap combine zip merge syncWith
EasyReact 1860665 30285707 7043007 7259761 6234540 63384482 19794457 12359669
ReactiveCocoa 4054261 74416369 45095903 44675757 209096028 143311669 13898969 53619799
RAC:EasyReact 217.89% 245.71% 640.29% 615.39% 3353.83% 226.10% 70.22% 433.83%

性能测试结果

性能测试结果

 

结果总结

ReactiveCocoa 平均耗时是 EasyReact 的 725.41%。

EasyReact 的 Swift 版本即将开源,届时会和 RxSwift 进行 Benchmark 比较。

EasyReact的最佳实践

一般咱们建立一个类,里面会包含不少的属性。在使用 EasyReact 时,咱们一般会把这些属性包装为 EZRNode 并加上一个泛型。如:


// SearchService.h

#import <Foundation/Foundation.h> #import <EasyReact/EasyReact.h> @interface SearchService : NSObject @property (nonatomic, readonly, strong) EZRMutableNode<NSString *> *param; @property (nonatomic, readonly, strong) EZRNode<NSDictionary *> *result; @property (nonatomic, readonly, strong) EZRNode<NSError *> *error; @end 

这段代码展现了如何建立一个 WiKi 查询服务,该服务接收一个 param 参数,查询后会返回 result 或者 error。如下是实现部分:


// SearchService.m

@implementation SearchService - (instancetype)init { if (self = [super init]) { _param = [EZRMutableNode new]; EZRNode *resultNode = [_param flattenMap:^EZRNode * _Nullable(NSString * _Nullable searchParam) { NSString *queryKeyWord = [searchParam stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"https://en.wikipedia.org/w/api.php?action=query&titles=%@&prop=revisions&rvprop=content&format=json&formatversion=2", queryKeyWord]]; EZRMutableNode *returnedNode = [EZRMutableNode new]; [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { if (error) { returnedNode.value = error; } else { NSError *serializationError; NSDictionary *resultDictionary = [NSJSONSerialization JSONObjectWithData:data options:0 error:&serializationError]; if (serializationError) { returnedNode.value = serializationError; } else if (!([resultDictionary[@"query"][@"pages"] count] && !resultDictionary[@"query"][@"pages"][0][@"missing"])) { NSError *notFoundError = [NSError errorWithDomain:@"com.example.service.wiki" code:100 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"keyword '%@' not found.", searchParam]}]; returnedNode.value = notFoundError; } else { returnedNode.value = resultDictionary; } } }]; return returnedNode; }]; EZRIFResult *resultAnalysedNode = [resultNode if:^BOOL(id _Nullable next) { return [next isKindOfClass:NSDictionary.class]; }]; _result = resultAnalysedNode.thenNode; _error = resultAnalysedNode.elseNode; } return self; } @end 

在调用时,咱们只须要经过 listenedBy 方法关注节点的变化:

self.service = [SearchService new];
[[self.service.result listenedBy:self] withBlock:^(NSDictionary * _Nullable next) {
    NSLog(@"Result: %@", next); }]; [[self.service.error listenedBy:self] withBlock:^(NSError * _Nullable next) { NSLog(@"Error: %@", next); }]; self.service.param.value = @"mipmap"; //should print search result self.service.param.value = @"420v"; // should print error, keyword not found. 

使用 EasyReact 后,网络请求的参数、结果和错误能够很好地被分离。不须要像命令式的写法那样在网络请求返回的回调中写一堆判断来分离结果和错误。

由于节点的存在先于结果,咱们能对暂时尚未获得的结果构建链接关系,完成整个响应链的构建。响应链构建以后,一旦有了数据,数据便会自动按照咱们预期的构建来传递。

在这个例子中,咱们不须要显式地来调用网络请求,只须要给响应链中的 param 节点赋值,框架就会主动触发网络请求,而且请求完成以后会根据网络返回结果来分离出 result 和 error 供上层业务直接使用。

对于开源,咱们是认真的

EasyReact 项目自立项以来,就励志打形成一个通用的框架,团队也一直以开源的高标准要求本身。整个开发的过程当中咱们始终保证测试覆盖率在一个高的标准上,对于接口的设计也力求完美。在开源的流程,咱们也学习借鉴了 Github 上大量优秀的开源项目,在流程、文档、规范上力求标准化、国际化。

文档

除了 中文 README 和 英文 README 之外,咱们还提供了中文的说明性质文档:

和英文的说明性质文档:

后续帮助理解的文章,也会陆续上传到项目中供你们学习。

另外也为开源的贡献提供了标准的 中文贡献流程 和 英文贡献流程,其中对于 ISSUE 模板、Commit 模板、Pull Requests 模板和 Apache 协议头均有说起。

若是你仍然对 EasyReact 有所不解或者流程代码上有任何问题,能够随时经过提 ISSUE 的方式与咱们联系,咱们都会尽快答复。

行为驱动开发

为了保证 EasyReact 的质量,咱们在开发的过程当中使用 行为驱动开发。当每一个新功能的声明部分肯定后,咱们会先编写大量的测试用例,这些用例模拟使用者的行为。经过模拟使用者的行为,以更加接近使用者的想法,去设计这个新功能的 API。同时大量的测试用例也保证了新的功能完成之时,必定是稳定的。

测试覆盖率

EasyReact 系列立项之时,就以高质量、高标准的开发原则来要求开发组成员执行。开源以后全部项目使用 codecov.io 服务生成对应的测试覆盖率报告,Easy 系列的框架覆盖率均保证在 95% 以上。

name listener
EasyReact
EasyTuple
EasySequence
EasyFoundation

持续集成

为了保证项目质量,全部的 Easy 系列框架都配有持续集成工具 Travis CI。它确保了每一次提交,每一次 Pull Request 都是可靠的。

展望

目前开源的框架组件只是创建起响应式编程的基石,Easy 系列的初心是为 MVVM 架构提供一个强有力的框架工具。下图是 Easy 系列框架的架构简图:

Archticture

Archticture

 

将来开源计划

将来咱们还有提供更多框架能力,开源给你们:

名称 描述
EasyDebugToolBox 动态节点状态调试工具
EasyOperation 基于行为和操做抽象的响应式库
EasyNetwork 响应式的网络访问库
EasyMVVM MVVM 框架标准和相关工具
EasyMVVMCLI EasyMVVM 项目脚手架工具

跨平台与多语言

EasyReact 的设计基于面向对象,因此很容易在各个语言中实现,咱们也正在积极的在 Swift、Java、JavaScript 等主力语言中实现 EasyReact。

另外动态化做为目前行业的趋势,Easy 系列天然不会忽视。在 EasyReact 基于图的架构下,咱们能够很轻松的让一个 Objective-C 的上游节点经过一个特殊的桥接边链接到一个 JavaScript 节点,这样就能够让部分的逻辑动态下发过来。

结语

数据传递和异步处理,是大部分业务的核心。EasyReact 从架构上用响应式的方式来很好的解决了这个问题。它有效地组织了数据和数据之间的联系, 让业务的处理流程从命令式编程方式,变成以数据流为核心的响应式编程方式。用先构建数据流关系再响应触发的方法,让业务方更关心业务的本质。使广大开发者从琐碎的命令式编程的状态处理中解放出来,提升了生产力。EasyReact 不只让业务逻辑代码更容易维护,也让出错的概率大大降低。

 

Logan:美团点评的开源移动端基础日志库

前言

Logan是美团点评集团移动端基础日志组件,这个名称是Log和An的组合,表明个体日志服务。同时Logan也是“金刚狼”大叔的名号,固然咱们更但愿这个产品能像金刚狼大叔同样犀利。

Logan已经稳定迭代了一年多的时间。目前美团点评绝大多数App已经接入并使用Logan进行日志收集、上传、分析。近日,咱们决定开源Logan生态体系中的存储SDK部分(Android/iOS),但愿可以帮助更多开发者合理的解决移动端日志存储收集的相关痛点,也欢迎更多社区的开发者和咱们一块儿共建Logan生态。Github的项目地址参见:https://github.com/Meituan-Dianping/Logan

背景

随着业务的不断扩张,移动端的日志也会不断增多。但业界对移动端日志并无造成相对成体系的处理方式,在大多数状况下,仍是针对不一样的日志进行单一化的处理,而后结合这些日志处理的结果再来定位问题。然而,当用户达到必定量级以后,不少“疑难杂症”却没法经过以前的定位问题的方式来进行解决。移动端开发者最头疼的事情就是“为何我使用和用户如出一辙的手机,如出一辙的系统版本,仿照用户的操做却复现不出Bug”。特别是对于Android开发者来讲,手机型号、系统版本、网络环境等都很是复杂,即便拿到了如出一辙的手机也复现不出Bug,这并不奇怪,固然不少时候并不能彻底拿到真正彻底如出一辙的手机。相信不少同窗见到下面这一幕都似曾相识:

用(lao)户(ban):我发现咱们App的XX页面打不开了,UI展现不出来,你来跟进一下这个问题。

你:好的。

因而,咱们检查了用户反馈的机型和系统版本,而后找了一台同型号同版本的手机,试着复现却发现一切正常。咱们又给用户打个电话,问问他究竟是怎么操做的,再问问网络环境,继续尝试复现依旧未果。最后,咱们查了一下Crash日志,网络日志,再看看埋点日志(发现还没报上来)。

你心里OS:奇怪了,也没产生Crash,网络也是通的,可是为何UI展现不出来呢?

几个小时后……

用(lao)户(ban):这问题有结果了吗?

你:我用了各类办法复现不出来……暂时查不到是什么缘由致使的这个问题。

用(lao)户(ban):那怪我咯?

你:……

若是把一次Bug的产生看做是一次“凶案现场”,开发者就是破案的“侦探”。案发以后,侦探须要经过各类手段搜集线索,推理出犯案过程。这就比如开发者须要经过查询各类日志,分析这段时间App在用户手机里都经历了什么。通常来讲,传统的日志搜集方法存在如下缺陷:

  • 日志上报不及时。因为日志上报须要网络请求,对于移动App来讲频繁网络请求会比较耗电,因此日志SDK通常会积累到必定程度或者必定时间后再上报一次。
  • 上报的信息有限。因为日志上报网络请求的频次相对较高,为了节省用户流量,日志一般不会太大。尤为是网络日志等这种实时性较高的日志。
  • 日志孤岛。不一样类型的日志上报到不一样的日志系统中,相对孤立。
  • 日志不全。日志种类愈来愈多,有些日志SDK会对上报日志进行采样。

面临挑战

美团点评集团内部,移动端日志种类已经超过20种,并且随着业务的不断扩张,这一数字还在持续增长。特别是上文中提到的三个缺陷,也会被无限地进行放大。

查问题是个苦力活,不必定全部的日志都上报在一个系统里,对于开发者来讲,可能须要在多个系统中查看不一样种类的日志,这大大增长了开发者定位问题的成本。若是咱们天天上班都看着疑难Bug挂着没法解决,确实会很难受。这就像一个侦探遇到了疑难的案件,当他用尽各类手段收集线索,依然一无所得,那种心情可想而知。咱们收集日志复现用户Bug的思路和侦探破案的思路很是类似,经过搜集的线索尽量拼凑出相对完整的犯案场景。若是按照这个思路想下去,目前咱们并无什么更好的方法来处理这些问题。

不过,虽然侦探破案和开发者查日志解决问题的思路很像,但实质并不同。咱们处理的是Bug,不是真实的案件。换句话说,由于咱们的“死者”是可见的,那么就能够从它身上获取更多信息,甚至和它进行一次“灵魂的交流”。换个思路想,以往的操做都是经过各类各样的日志拼凑出用户出现Bug的场景,那可不能够先获取到用户在发生Bug的这段时间产生的全部日志(不采样,内容更详细),而后聚合这些日志分析出(筛除无关项)用户出现Bug的场景呢?

个案分析

新的思路重心从“日志”变为“用户”,咱们称之为“个案分析”。简单来讲,传统的思路是经过搜集散落在各系统的日志,而后拼凑出问题出现的场景,而新的思路是从用户产生的全部日志中聚合分析,寻找出现问题的场景。为此,咱们进行了技术层面的尝试,而新的方案须要在功能上知足如下条件:

  • 支持多种日志收集,统一底层日志协议,抹平日志种类带来的差别。
  • 日志本地记录,在须要时上报,尽量保证日志不丢失。
  • 日志内容要尽量详细,不采样。
  • 日志类型可扩展,可由上层自定义。

咱们还须要在技术上知足如下条件:

  • 轻量级,包体尽可能小
  • API易用
  • 没有侵入性
  • 高性能

最佳实践

在这种背景下,Logan横空出世,其核心体系由四大模块构成:

  • 日志输入
  • 日志存储
  • 后端系统
  • 前端系统

日志输入

常见的日志类型有:代码级日志、网络日志、用户行为日志、崩溃日志、H5日志等。这些都是Logan的输入层,在不影响原日志体系功能的状况下,可将内容往Logan中存储一份。Logan的优点在于:日志内容能够更加丰富,写入时能够携带更多信息,也没有日志采样,只会等待合适的时机进行统一上报,可以节省用户的流量和电量。

以网络日志为例,正常状况下网络日志只记录端到端延时、发包大小、回包大小字段等等,同时存在采样。而在Logan中网络日志不会被采样,除了上述内容还能够记录请求Headers、回包Headers、原始Url等信息。

日志存储

Logan存储SDK是这个开源项目的重点,它解决了业界内大多数移动端日志库存在的几个缺陷:

  • 卡顿,影响性能
  • 日志丢失
  • 安全性
  • 日志分散

Logan自研的日志协议解决了日志本地聚合存储的问题,采用“先压缩再加密”的顺序,使用流式的加密和压缩,避免了CPU峰值,同时减小了CPU使用。跨平台C库提供了日志协议数据的格式化处理,针对大日志的分片处理,引入了MMAP机制解决了日志丢失问题,使用AES进行日志加密确保日志安全性。Logan核心逻辑都在C层完成,提供了跨平台支持的能力,在解决痛点问题的同时,也大大提高了性能。

为了节约用户手机空间大小,日志文件只保留最近7天的日志,过时会自动删除。在Android设备上Logan将日志保存在沙盒中,保证了日志文件的安全性。

详情请参考:美团点评移动端基础日志库——Logan

后端系统

后端是接收和处理数据中心,至关于Logan的大脑。主要有四个功能:

  • 接收日志
  • 日志解析归档
  • 日志分析
  • 数据平台

接收日志

客户端有两种日志上报的形式:主动上报和回捞上报。主动上报能够经过客服引导用户上报,也能够进行预埋,在特定行为发生时进行上报(例如用户投诉)。回捞上报是由后端向客户端发起回捞指令,这里再也不赘述。全部日志上报都由Logan后端进行接收。

日志解析归档

客户端上报的日志通过加密和压缩处理,后端须要对数据解密、解压还原,继而对数据结构化归档存储。

日志分析

不一样类型日志由不一样的字段组合而成,携带着各自特有信息。网络日志有请求接口名称、端到端延时、发包大小、请求Headers等信息,用户行为日志有打开页面、点击事件等信息。对全部的各种型日志进行分析,把获得的信息串连起来,最终聚集造成一个完整的我的日志。

数据平台

数据平台是前端系统及第三方平台的数据来源,由于我的日志属于机密数据,因此数据获取有着严格的权限审核流程。同时数据平台会收集过往的Case,抽取其问题特征记录解决方案,为新Case提供建议。

前端系统

一个优秀的前端分析系统能够快速定位问题,提升效率。研发人员经过Logan前端系统搜索日志,进入日志详情页查看具体内容,从而定位问题,解决问题。

目前集团内部的Logan前端日志详情页已经具有如下功能:

  • 日志可视化。全部的日志都通过结构化处理后,按照时间顺序展现。
  • 时间轴。数据可视化,利用图形方式进行语义分析。
  • 日志搜索。快速定位到相关日志内容。
  • 日志筛选。支持多类型日志,可选择须要分析的日志。
  • 日志分享。分享单条日志后,点开分享连接自动定位到分享的日志位置。

Logan对日志进行数据可视化时,尝试利用图形方式进行语义分析简称为时间轴。

每行表明着一种日志类型。同一日志类型有着多种图形、颜色,他们标识着不一样的语义。

例如时间轴中对代码级日志进行了日志类别的区分:

利用颜色差别,能够轻松区分出错误的日志,点击红点便可直接跳转至错误日志详情。

个案分析流程

  • 用户遇到问题联系客服反馈问题。
  • 客服收到用户反馈。记录Case,整理问题,同时引导用户上报Logan日志。
  • 研发同窗收到Case,查找Logan日志,利用Logan系统完成日志筛选、时间定位、时间轴等功能,分析日志,进而还原Case“现场”。
  • 最后,结合代码定位问题,修复问题,解决Case。

定位问题

结合用户信息,经过Logan前端系统查找用户的日志。打开日志详情,首先使用时间定位功能,快速跳转到出问题时的日志,结合该日志上下文,可获得当时App运行状况,大体推断问题发生的缘由。接着利用日志筛选功能,查找关键Log对可能出问题的地方逐一进行排查。最后结合代码,定位问题。

固然,在实际上排查中问题比这复杂多,咱们要反复查看日志、查看代码。这时还可能要借助一下Logan高级功能,如时间轴,经过时间轴可快速找出现异常的日志,点击时间轴上的图标可跳转到日志详情。经过网络日志中的Trace信息,还能够查看该请求在后台服务详细的响应栈状况和后台响应值。

将来规划

  • 机器学习分析。首先收集过往的Case及解决方案,提取分析Case特征,将Case结构化后入库,而后经过机器学习快速分析上报的日志,指出日志中可能存在的问题,并给出解决方案建议;
  • 数据开放平台。业务方能够经过数据开放平台获取数据,再结合自身业务的特性研发出适合本身业务的工具、产品。

平台支持

Platform iOS Android Web Mini Programs
Support

目前Logan SDK已经支持以上四个平台,本次开源iOS和Android平台,其余平台将来将会陆续进行开源,敬请期待。

测试覆盖率

因为Travis、Circle对Android NDK环境支持不够友好,Logan为了兼容较低版本的Android设备,目前对NDK的版本要求是16.1.4479499,因此咱们并无在Github仓库中配置CI。开发者能够本地运行测试用例,测试覆盖率可达到80%或者更高。

开源计划

在集团内部已经造成了以Logan为中心的个案分析生态系统。本次开源的内容有iOS、Android客户端模块、数据解析简易版,小程序版本、Web版本已经在开源的路上,后台系统,前端系统也在咱们开源计划之中。

将来咱们会提供基于Logan大数据的数据平台,包含机器学习、疑难日志解决方案、大数据特征分析等高级功能。

最后,咱们但愿提供更加完整的一体化个案分析生态系统,也欢迎你们给咱们提出建议,共建社区。

Module Open Source Processing Planning
iOS    
Android    
Web    
Mini Programs    
Back End    
Front End    

 

 

美团点评移动端基础日志库——Logan

背景

对于移动应用来讲,日志库是必不可少的基础设施,美团点评集团旗下移动应用天天产生的众多种类的日志数据已经达到几十亿量级。为了解决日志模块广泛存在的效率、安全性、丢失日志等问题,Logan基础日志库应运而生。

现存问题

目前,业内移动端日志库大多都存在如下几个问题:

  • 卡顿,影响性能
  • 日志丢失
  • 安全性
  • 日志分散

首先,日志模块做为底层的基础库,对上层的性能影响必须尽可能小,可是日志的写操做是很是高频的,频繁在Java堆里操做数据容易致使GC的发生,从而引发应用卡顿,而频繁的I/O操做也很容易致使CPU占用太高,甚至出现CPU峰值,从而影响应用性能。

其次,日志丢失的场景也很常见,例如当用户的App发生了崩溃,崩溃日志还来不及写入文件,程序就退出了,但本次崩溃产生的日志就会丢失。对于开发者来讲,这种状况是很是致命的,由于这类日志丢失,意味着没法复现用户的崩溃场景,不少问题依然得不到解决。

第三点,日志的安全性也是相当重要的,绝对不能随意被破解成明文,也要防止网络被劫持致使的日志泄漏。

最后一点,对于移动应用来讲,日志确定不止一种,通常会包含端到端日志1、代码日志、崩溃日志、埋点日志这几种,甚至会更多。不一样种类的日志都具备各自的特色,会致使日志比较分散,查一个问题须要在各个不一样的日志平台查不一样的日志,例如端到端日志还存在日志采样,这无疑增长了开发者定位问题的成本。

面对美团点评几十亿量级的移动端日志处理场景,这些问题会被无限放大,最终可能致使日志模块不稳定、不可用。然而,Logan应运而生,漂亮地解决了上述问题。

简介

Logan,名称是Log和An的组合,表明个体日志服务的意思,同时也是金刚狼大叔的大名。通俗点说,Logan是美团点评移动端底层的基础日志库,能够在本地存储各类类型的日志,在须要时能够对数据进行回捞和分析。

Logan具有两个核心能力:本地存储和日志捞取。做为基础日志库,Logan已经接入了集团众多日志系统,例如端到端日志、用户行为日志、代码级日志、崩溃日志等。做为移动应用的幕后英雄,Logan天天都会处理几十亿量级的移动端日志。

设计

做为一款基础日志库,在设计之初就必须考虑如何解决日志系统现存的一些问题。

卡顿,影响性能

I/O是比较耗性能的操做,写日志须要大量的I/O操做,为了提高性能,首先要减小I/O操做,最有效的措施就是加缓存。先把日志缓存到内存中,达到必定大小的时候再写入文件。为了减小写入本地的日志大小,须要对数据进行压缩,为了加强日志的安全性,须要对日志进行加密。然而这样作的弊端是:

  • 对Android来讲,对日志加密压缩等操做所有在Java堆里面。因为日志写入是一个高频的动做,频繁地堆内存操做,容易引起Java的GC,致使应用卡顿;
  • 集中压缩会致使CPU短期飙高,出现峰值;
  • 因为日志是内存缓存,在杀进程、Crash的时候,容易丢失内存数据,从而致使日志丢失。

Logan的解决方案是经过Native方式来实现日志底层的核心逻辑,也就是C编写底层库。这样作不光能解决Java GC问题,还作到了一份代码运行在Android和iOS两个平台上。同时在C层实现流式的压缩和加密数据,能够减小CPU峰值,使程序运行更加顺滑。并且先压缩再加密的方式压缩率比较高,总体效率较高,因此这个顺序不能变。

日志丢失

加缓存以后,异常退出丢失日志的问题就必须解决,Logan为此引入了MMAP机制。MMAP是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系。MMAP机制的优点是:

  • MMAP使用逻辑内存对磁盘文件进行映射,操做内存就至关于操做文件;
  • 通过测试发现,操做MMAP的速度和操做内存的速度同样快,能够用MMAP来作数据缓存;
  • MMAP将日志回写时机交给操做系统控制。如内存不足,进程退出的时候操做系统会自动回写文件;
  • MMAP对文件的读写操做不须要页缓存,只须要从磁盘到用户主存的一次数据拷贝过程,减小了数据的拷贝次数,提升了文件读写效率。

引入MMAP机制以后,日志丢失问题获得了有效解决,同时也提高了性能。不过这种方式也不能百分百解决日志丢失的问题,MMAP存在初始化失败的状况,这时候Logan会初始化堆内存来作日志缓存。根据咱们统计的数据来看,MMAP初始化失败的状况仅占0.002%,已是一个小几率事件了。

安全性

日志文件的安全性必须获得保障,不能随意被破解,更不能明文存储。Logan采用了流式加密的方式,使用对称密钥加密日志数据,存储到本地。同时在日志上传时,使用非对称密钥对对称密钥Key作加密上传,防止密钥Key被破解,从而在网络层保证日志安全。

日志分散

针对日志分散的状况,为了保证日志全面,须要作本地聚合存储。Logan采用了自研的日志协议,对于不一样种类的日志都会按照Logan日志协议进行格式化处理,存储到本地。当须要上报的时候进行集中上报,经过Logan日志协议进行反解,还原出不一样日志的本来面貌。同时Logan后台提供了聚合展现的能力,全面展现日志内容,根据协议综合各类日志进行分析,使用时间轴等方式展现不一样种日志的重要信息,使得开发者只须要经过Logan平台就能够查询到某一段时间App到底产生了哪些日志,能够快速复现问题场景,定位问题并处理。

关于Logan平台是如何展现日志的,下文会再进行说明。

架构

首先,看一下Logan的总体架构图:

Logan的总体架构图

Logan的总体架构图

 

Logan自研的日志协议解决了日志本地聚合存储的问题,采用先压缩再加密的顺序,使用流式的加密和压缩,避免了CPU峰值,同时减小了CPU使用。跨平台C库提供了日志协议数据的格式化处理,针对大日志的分片处理,引入了MMAP机制解决了日志丢失问题,使用AES进行日志加密确保日志安全性,而且提供了主动上报接口。Logan核心逻辑都在C层完成,提供了跨平台支持的能力,在解决痛点问题的同时,也大大提高了性能。

日志分片

Logan做为日志底层库,须要考虑上层传入日志过大的状况。针对这样的场景,Logan会作日志分片处理。以20k大小作分片,每一个切片按照Logan的协议进行存储,上报到Logan后台的时候再作反解合并,恢复日志原本的面貌。

那么Logan是如何进行日志写入的呢?下图为Logan写日志的流程:

Logan写日志的流程

Logan写日志的流程

 

性能

为了检测Logan的性能优化效果,咱们专门写了测试程序进行对比,读取16000行的日志文本,间隔3ms,依次调用写日志函数。

首先对比Java实现和C实现的内存情况:

Java:

C:

能够看出Java实现写日志,GC频繁,而C实现并不会出现这种状况,由于它不会占用Java的堆内存。那么再对比一下Java实现和C实现的CPU使用状况:

C实现没有频繁的GC,同时采用流式的压缩和加密避免了集中压缩加密可能产生的CPU峰值,因此CPU平均使用率会下降,如上图所示。

特点功能

日志回捞

开发者可能都会遇到相似的场景:某个用户手机上装了App,出现了崩溃或者其它问题,日志还没上报或者上报过程当中被网络劫持发生日志丢失,致使有些问题一直查不清缘由,或者无法及时定位到问题,影响处理进程。依托集团PushSDK强大的推送能力,Logan能够确保用户的本地日志在发出捞取指令后及时上传。经过网络类型和日志大小上限选择,能够为用户最大可能的节省移动流量。

回馈机制能够确保捞取日志任务的进度获得实时展示。

日志回捞平台有着严格的审核机制,确保开发者不会侵犯用户隐私,只关注问题场景。

主动上报

Logan日志回捞,依赖于Push透传。客户端被唤醒接收Push消息,受到一些条件影响:

  • Android想要后台唤醒App,须要确保Push进程在后台存活;
  • iOS想要后台唤醒APP,须要确保用户开启后台刷新开关;
  • 网络环境太差,Android上Push长连创建不成功。

若是没法唤醒App,只有在用户再次进入App时,Push通道创建后才能收到推送消息,以上是致使Logan日志回捞会有延迟或收不到的根本缘由,从分析能够看出,Logan系统回捞的最大瓶颈在于Push系统。那么可否抛开Push系统获得Logan日志呢?先来看一下使用日志回捞方式的典型场景:

其中最大的障碍在于Push触达用户。那么主动上报的设计思路是怎样的呢?

经过在App中主动调用上报接口,用户直接上报日志的方式,称之为Logan的主动上报。主动上报的优点很是明显,跳过了Push系统,让用户在须要的时候主动上报Logan日志,开发者不再用为不能及时捞到日志而烦恼,在用户投诉以前就已经拿到日志,便于更高效地分析解决问题。

线上效果

Logan基础日志库自2017年9月上线以来,运行很是稳定,大大提升了集团移动开发工程师分析日志、定位线上问题的效率。

Logan平台时间轴日志展现:

Logan日志聚合详情展现:

做为基础日志库,Logan目前已经接入了集团众多日志系统:

  • CAT端到端日志
  • 埋点日志
  • 用户行为日志
  • 代码级日志
  • 网络内部日志
  • Push日志
  • Crash崩溃日志

如今,Logan已经接入美团、大众点评、美团外卖、猫眼等众多App,日志种类也更加丰富。

展望将来

H5 SDK

目前,Logan只有移动端版本,支持Android/iOS系统,暂不支持H5的日志上报。对于纯JS开发的页面来讲,一样有日志分散、问题场景复现困难等痛点,也迫切须要相似的日志底层库。咱们计划统一H5和Native的日志底层库,包括日志协议、聚合等,Logan的H5 SDK也在筹备中。

日志分析

Logan平台的日志展现方式,咱们还在探索中。将来计划对日志作初步的机器分析与处理,能针对某些关键路径给出一些分析结果,让开发者更专一于业务问题的定位与分析,同时但愿分析出用户的行为是否存在风险、恶意请求等。

思考题

本文给你们讲述了美团点评移动端底层基础日志库Logan的设计、架构与特点,Logan在解决了许多问题的同时,也会带来新的问题。日志文件不能无限大,目前Logan日志文件最大限制为10M,遇到大于10M的状况,应该如何处理最佳?是丢掉前面的日志,仍是丢掉追加的日志,仍是作分片处理呢?这是一个值得深思的问题。

 

MCI:移动持续集成在大众点评的实践

1、背景

美团是全球最大的互联网+生活服务平台,为3.2亿活跃用户和500多万的优质商户提供一个链接线上与线下的电子商务服务。秉承“帮你们吃得更好,生活更好”的使命,咱们的业务覆盖了超过200个品类和2800个城区县网络,在餐饮、外卖、酒店旅游、丽人、家庭、休闲娱乐等领域具备领先的市场地位。

随着各业务的蓬勃发展,大众点评移动研发团队从当初各自为战的“小做坊”已经发展成为能够协同做战的、拥有千人规模的“正规军”。咱们的移动项目架构为了适应业务发展也发生了天翻地覆的变化,这对移动持续集成提出更高的要求,而整个移动研发团队也迎来了新的机遇和挑战。

2、问题与挑战

当前移动客户端的组件库超过600个,多个移动项目的代码量达到百万行级别,天天有几百次的发版集成需求。保证近千名移动研发人员顺利进行开发和集成,这是咱们部门的重要使命。可是,前进的道路历来都不是平坦的,在通向目标的大道上,咱们还面临着不少问题与挑战,主要包括如下几个方面:

项目依赖复杂

上图仅仅展现了咱们移动项目中一小部分组件间的依赖关系,能够想象一下,这600多个组件之间的依赖关系,就如同一个城市复杂的道路交通网让人眼花缭乱。这种组件间错综复杂的依赖关系也必然会致使两个严重的问题,第一,若是某个业务须要修改代码,极有可能会影响到其它业务,牵一发而动全身,进而会让不少研发同窗工做时战战兢兢,作项目更加畏首畏尾;第二,管理这些组件间繁琐的依赖关系也是一件使人头疼的事情,如今平均每一个组件的依赖数有70多个,最多的甚至达到了270多个,若是依靠人工来维护这些依赖关系,难如登天。

研发流程琐碎

移动研发要完成一个完整功能需求,除了代码开发之外,须要经历组件发版、组件集成、打包、测试。若是测试发现Bug须要进行修复,而后再次经历组件发版、组件集成、打包、测试,直到测试经过交付产品。研发同窗在整个过程当中须要手动提交MR、手动升级组件、手动触发打包以及人工实时监控流程的状态,如此研发会被频繁打断来跟踪处理过程的衔接,势必严重影响开发专一度,下降研发生产力。

构建速度慢

目前大众点评的iOS项目构建时间,从两年前的20分钟已经增加到如今的60分钟以上,Android项目也从5分钟增加到11分钟,移动项目构建时间的增加,已经严重影响了移动端开发集成的效率。并且随着业务的快速扩张,项目代码还在持续不断的增加。为了适应业务的高速发展,寻求行之有效的方法来加快移动项目的构建速度,已经变得刻不容缓。

App质量保证

评价App的性能质量指标有不少,例如:CPU使用率、内存占用、流量消耗、响应时间、线上Crash率、包体等等。其中线上Crash直接影响着用户体验,当用户使用App时若是发生闪退,他们颇有可能会给出“一星”差评;而包体大小是影响新用户下载App的重要因素,包体过大用户颇有可能会对你的App失去兴趣。所以,下降App线上Crash率以及控制App包体大小是每一个移动研发都要追求的重要目标。

项目依赖复杂、研发流程琐碎、构建速度慢、App质量保证是每一个移动项目在团队、业务发展壮大过程当中都会遇到的问题,本文将根据大众点评移动端多年来积累的实践经验,一步步阐述咱们是如何在实战中解决这些问题的。

3、MCI架构

MCI(Mobile continuous integration)是大众点评移动端团队多年来实践总结出来的一套行之有效的架构体系。它能实际解决移动项目中依赖复杂、研发流程琐碎、构建速度慢的问题,同时接入MCI架构体系的移动项目能真正有效实现App质量的提高。

MCI完整架构体系以下图所示:

MCI架构体系包含移动CI平台、流程自动化建设、静态检查体系、日志监控&分析、信息管理配置,另外MCI还采起二进制集成等措施来提高MCI的构建速度。

构建移动CI平台

咱们经过构建移动CI平台,来保证移动研发在项目依赖极其复杂的状况下,也能互不影响完成业务研发集成;其次咱们设计了合理的CI策略,来帮助移动研发人员走出使人望而生畏的依赖关系管理的“泥潭”。

流程自动化建设

在构建移动CI平台的基础上,咱们对MCI流程进行自动化建设来解决研发流程琐碎问题,从而解放移动研发生产力。

提高构建速度

在CI平台保证集成正确性的状况下,咱们经过依赖扁平化以及优化集成方式等措施来提高MCI的构建速度,进一步提高研发效率。

静态检查体系

咱们创建一套完整自研的静态检查体系,针对移动项目的特色,MCI上线全方位的静态检查来促进App质量的提高。

日志监控&分析

咱们对MCI体系的完整流程进行日志落地,方便问题的追溯与排查,同时经过数据分析来进一步优化MCI的流程以及监控移动App项目的健康情况。

信息管理配置

最后,为了方便管理接入MCI的移动项目,咱们建设了统一的项目信息管理配置平台。

接下来,咱们将依次详细探讨MCI架构体系是如何一步步创建,进而解决咱们面临的各类问题。

4、构建移动CI平台

4.1 搭建移动CI平台

咱们对目前业内流行的CI系统,如:Travis CI、 CircleCI、Jenkins、Gitlab CI调研后,针对移动项目的特色,综合考虑代码安全性、可扩展性及页面可操做性,最终选择基于Gitlab CI搭建移动持续集成平台,固然咱们也使用Jenkins作一些辅助性的工做。MCI体系的CI核心架构以下图所示:

名词解释:

  • Gitlab CI:Gitlab CI是GitLab Continuous Integration(Gitlab持续集成)的简称。
  • Runner:Runner是Gitlab CI提供注册CI服务器的接口。
  • Pipeline:能够理解为流水线,包含CI不一样阶段的不一样任务。
  • Trigger:触发器,Push代码或者提交Merge Request等操做会触发相应的触发器以进入下一流程。

该架构的优点是可扩展性强、可定制、支持并发。首先CI服务器能够任意扩展,除了专用的服务器能够做为CI服务器,普通我的PC机也能够做为CI服务器(缺点是性能比服务器差,任务执行时间较长);其次每一个集成任务的Pipeline是支持可定制的,托管在MCI的集成项目能够根据自身需求定制与之匹配的Pipeline;最后,每一个集成项目的任务执行是可并发的,所以各业务线间能够互不干扰的进行组件代码集成。

4.2 CI流程设计

一次完整的组件集成流程包含两个阶段:组件库发版和向目标App工程集成。以下图所示:

第一阶段,在平常功能开发完毕后,研发提PR到指定分支,在对代码进行Review、组件库编译及静态检查无误后,自动发版进入组件池中。全部进入组件池中的组件都可以在不一样App项目中复用。

第二阶段,研发根据须要将组件合入指定App工程。组件A自己的正确性已经在第一阶段的组件库发版中验证,第二阶段是检查组件A的改变是否对目标App中原有依赖它的其它组件形成影响。因此首先须要分析组件A被目标App中哪些组件所依赖,目标App工程按照各自的准入标准,对合入的组件库进行编译和静态分析,待检查无误后,最终合入发布分支。

经过组件发版和集成两阶段的CI流程,组件将被正确集成到目标项目中。而对于存在问题的组件则会阻挡在项目以外,所以不会影响其它业务的正常开发和发版集成,各业务研发流程独立可控。

4.3 设计合理的CI策略

组件的发版和集成可否经过CI检查,取决于组件当前的依赖以及组件自己是否与目标项目兼容。移动研发须要对组件当前依赖有足够的了解才能顺利完成发版集成,为了减少组件依赖管理的复杂度,咱们设计了合理的发版集成策略来帮助移动研发走出繁琐的版本依赖管理的困境。

组件集成策略

每一个组件都有本身的依赖项,不一样组件可能会依赖同一个组件,组件向目标项目集成过程当中会面临以下一些问题:

  • 版本集成冲突:组件在集成过程当中某个依赖项与目标项目中现有依赖的版本号存在冲突。
  • App测试包不稳定:组件依赖项的版本发生变化致使在不一样时刻打出不一样依赖项的App测试包。

频繁的版本集成冲突会致使业务协同开发集成效率低下,App测试包的不稳定性会给研发追踪问题带来极大的困扰。问题的根源在于目标项目使用每一个组件的依赖项来进行集成。所以咱们经过在集成项目中显示指定组件版本号以及禁止动态依赖的方式,保证了App测试包的稳定性和可靠性,同时也解决了组件版本集成冲突问题。

组件发版策略

组件向组件池发版也同样会涉及依赖项的管理,简单粗暴的方法是指定全部依赖项的版本号,这样作的好处是直观明了,但研发须要对不一样版本依赖项的功能有足够的了解。正如组件集成策略中所述,集成项目中每一个组件的版本都是显示指定而且惟一肯定的,组件中指定依赖项的版本号在集成项目中并不起做用。因此咱们在组件发版时采用自动依赖组件池中最新版本的方式。这样设计的好处在于:

  • 避免移动研发对版本依赖关系的处理。
  • 给基础组件的变动迭代提供了强有力的推进机制。

当基础组件库的接口和设计发生较大变化时,能够强有力的推进业务层组件作相应适配,保证了在高度解耦的项目架构下保持高度的敏捷性。但这种能力不能滥用,须要根据业务迭代周期合理安排,并作好提早通知动员工做。

5、流程自动化建设

研发流程琐碎的主要缘由是研发须要人工参与持续集成中每一步过程,一旦咱们把移动研发从持续集成过程当中解放出来,天然就能提升研发生产力。咱们经过项目集成发布流程自动化以及优化测试包分发来优化MCI流程。

项目集成流程托管

研发流程中的组件发版、组件集成与App打包都是持续集成中的标准化流程,咱们经过流程托管工具来完成这几个步骤的自动衔接,研发同窗只需关注代码开发与Bug修复。

流程托管工具实现方案以下:

  • 自动化流程执行:经过托管队列实现任务自动化顺序执行,webhook实现流程状态的监听。
  • 关键节点通知:在关键性节点流程执行成功后发送通知,让研发对流程状态了然于胸。
  • 流程异常通知:一旦持续集成流程执行异常,例如项目编译失败、静态检查没经过等,第一时间通知研发及时处理。

打包发布流程托管

不管iOS仍是Android,在发布App包到市场前都须要作一系列处理,例如iOS须要导出ipa包进行备份,保存符号表来解析线上Crash,以及上传ipa包到iTC(iTunes Connect);而Android除了包备份,保存Mapping文件解析线上Crash外,还要发布App包到不一样的渠道,整个打包发布流程更加复杂繁琐。

在没有MCI流程托管之前,每到App发布日,研发同窗就如临大敌守在打包机器前,披荆斩棘,过五关斩六将,直到全部App包被“运送”到指定地点,搞得十分疲惫。如同项目集成流程托管同样,咱们把整个打包发布流程作了全流程托管,无人值守的自动打包发布方式解放了研发同窗,研发同窗不再用每次都披星戴月,早出晚归,跪键盘了(捂脸)。

包分发流程建设

对于QA和研发而言,上面的场景是否似曾相识。Bug是QA与研发之间沟通的桥梁,但因为缺少统一的包管理和分发,这种模糊的沟通致使难以快速定位和追溯发生问题的包。为了减小QA和研发之间的无效沟通以及优化包分发流程,咱们亟需一个平台来统一管理分发公司内部的App包,因而MCI App应运而生。

MCI App提供以下功能:

  • 查看下载安装不一样类型不一样版本的App。
  • 查看App包的基础信息(打包者、打包耗时、包版本、代码提交commit点等)。
  • 查看App包当前版本集成的全部组件库信息。
  • 查看App包体占用状况。
  • 查询App发版时间计划。
  • 分享安装App包下载连接。

将来MCI App还会支持查询项目集成状态以及App发布提醒、问题反馈,整合移动研发全流程。

6、提高构建速度

移动项目在构建过程当中最为耗时的两个步骤分别为组件依赖计算和工程编译。

组件依赖计算

组件依赖计算是根据项目中指定的集成组件计算出全部相关的依赖项以及依赖版本,当项目中集成组件较多的时候,递归计算依赖项以及依赖版本是一件很是耗时的操做,特别是还要处理相关的依赖冲突。

工程编译

工程编译时间是跟项目工程的代码量成正比的,集团业务在快速发展,代码量也在快速的膨胀。

为了提高项目构建速度,咱们经过依赖扁平化的方法来完全去掉组件依赖计算耗时,以及经过优化项目集成方式的手段来减小工程编译时间。

依赖扁平化

依赖扁平化的核心思想是事先把依赖项以及依赖版本号进行显示指定,这样经过固定依赖项以及依赖版本就完全去掉了组件依赖计算的耗时,极大的提升了项目构建速度。与此同时,依赖扁平化还额外带来了下面的好处:

  • 减轻研发依赖关系维护的负担。
  • App项目更加稳定,不会由于依赖项的自动升级出现问题。

优化集成方式

一般组件代码都是以源码方式集成到目标工程,这种集成方式的最大缺点是编译速度慢,对于上百万行代码的App,若是采用源码集成的方式,工程编译时间将超过40分钟甚至更长,这个时间,显然会使人崩溃。

使用源码集成

使用二进制集成

实际上组件代码还能够经过二进制的方式集成到目标工程:

相比源码方式集成,组件的二进制包都是预先编译好的,在集成过程当中只须要进行连接无需编译,所以二进制集成的方式能够大幅提高项目编译速度。

二进制集成优化

为了进一步提升二进制集成效率,咱们还作了几件小事:

(1)多线程下载

尽管二进制集成的方式能减小工程编译时间,但二进制包仍是得从远端下载到CI服务器上。咱们修改了默认单线程下载的策略,经过多线程下载二进制包提高下载效率。

(2)二进制包缓存

研发在MCI上触发不一样的集成任务,这些集成任务间除了升级的组件,其它使用的组件二进制包大部分是相同的,所以咱们在CI服务器上对组件二进制包进行缓存以便不一样任务间进行共享,进一步提高项目构建速度。

二进制集成成果

咱们在MCI中采用二进制集成而且通过一系列优化后,iOS项目工程的编译时间比原来减小60%,Android项目也比原来减小接近50%,极大地提高了项目构建效率。

7、静态检查体系

除了完成平常需求开发,提升代码质量是每一个研发的必修课。若是每一位移动研发在平时开发中能严格遵照移动编程规范与最佳实践,那不少线上问题彻底能够提早避免。事实上仅仅依靠研发自觉性,难以长期有效的执行,咱们须要把这些移动编程规范和最佳实践切实落地成为静态检查强制执行,才能有效的将问题扼杀在摇篮之中。

静态检查基础设施

静态检查最简单的方式是文本匹配,这种方式检查逻辑简单,但存在局限性。好比编写的静态检查代码维护困难,再者文本匹配能力有限对一些复杂逻辑的处理无能为力。现有针对Objective-C和Java的静态分析工具也有很多,常见的有:OCLint、FindBugs、CheckStyle等等,但这些工具定制门槛较高。为了下降静态检查接入成本,咱们自主研发了一个适应MCI需求的静态分析框架–Hades。

Hades的特色:

  • 彻底代码语义理解
  • 具有全局分析能力
  • 支持增量分析
  • 接入成本低

Hades的核心思想是对源码生成的AST(Abstract Syntax Tree)进行结构化数据的语义表达,在此基础上咱们就能够创建一系列静态分析工具和服务。做为一个静态分析框架,Hades并不局限于Lint工具的制做,咱们也但愿经过这种结构化的语义表达来对代码有更深层次的理解。所以,咱们能够借助文档型数据库(如:CouchDB、MongoDB等)创建项目代码的语义模型数据库,这样咱们可以经过JS的Map-Reduce创建视图从而快速检索咱们须要查找的内容。关于Hades的技术实现原理咱们将在后续的技术Blog中进行详细阐述,敬请期待。

MCI静态检查现状

目前MCI已经上线了覆盖代码基本规范、非空特性、多线程最佳实践、资源合法性、启动流程管控、动态行为管控等20多项静态检查,这些静态检查切实有效地促进了App代码质量的提升。

8、日志监控&分析

MCI做为大众点评移动端持续集成的重要平台,稳定高效是要达成的第一目标,日志监控是推进MCI走向稳定高效的重要手段。咱们对MCI全流程的日志进行落地,方便问题追溯与排查,如下是部分线上监控项。

流程时间监控分析

经过监控分析MCI流程中每一步的执行时间,咱们能够进行针对性的优化以提升集成速度。

异常流程监控分析

咱们会对异常流程进行监控而且通知流程发起者,同时咱们会对失败次数较多的Job分析缘由。一部分CI环境或者网络问题MCI能够自动解决,而其它因为代码错误引发的异常MCI会引导移动研发进行问题的排查与解决。

包体监控分析

咱们对包体总大小、可执行文件以及图片进行全方面的监控,包体变化的趋势一目了然,对于包体的异常变化咱们能够第一时间感知。

除此以外,咱们还对MCI集成成功率、二进制覆盖率等方面作了监控,作到对MCI全流程了然于胸,让MCI稳定高效的运行。

9、信息管理配置

目前MCI平台已经接入公司多个移动项目,为了接入MCI的项目进行统一方便的信息管理,咱们建设了MCI信息管理平台——摩卡(Mocha)。Mocha平台的功能包含项目信息管理、配置静态检查项以及组件发版集成查询。

项目信息管理

Mocha平台负责注册接入MCI项目的基本信息,包含项目地址、项目负责人等,同时对各个项目的成员进行权限管理。

配置静态检查项

MCI支持不一样项目自定义不一样的静态检查项,在Mocha平台上能够完成项目所需静态检查项的定制,同时支持静态检查白名单的配置审核。

组件发版集成查询

Mocha平台支持组件历史发版集成的记录查询,方便问题的排查与追溯。

做为移动集成项目的可视化配置系统,Mocha平台是MCI的一个重要补充。它使得移动项目接入MCI变得简单快捷,将来Mocha平台还会加入更多的配置项。

10、总结与展望

本文从大众点评移动项目业务复杂度出发,详细介绍了构建稳定高效的移动持续集成系统的思路与最佳实践方案,解决项目依赖复杂所带来的问题,经过依赖扁平化以及二进制集成提高构建速度。在此基础上,经过自研的静态检查基础设施Hades下降静态检查准入的门槛,帮助提高App质量;最后MCI提供的全流程托管能力能显著提升移动研发生产力。

目前MCI为iOS、Android原生代码的项目集成已经提供了至关完善的支持。此外,MCI还支持Picasso项目的持续集成,Picasso是大众点评自研的高性能跨平台动态化框架,专一于横跨iOS、Android、Web、小程序四端的动态化UI构建。固然移动端原生项目的持续集成和动态化项目的持续集成有共通也有不少不一样之处。将来MCI将在移动工程化领域进一步探索,为移动端业务蓬勃发展保驾护航。

 

美团外卖Android Crash治理之路

Crash率是衡量一个App好坏的重要指标之一,若是你忽略了它的存在,它就会愈演愈烈,最后形成大量用户的流失,进而给公司带来没法估量的损失。本文讲述美团外卖Android客户端团队在将App的Crash率从千分之三作到万分之二过程当中所作的大量实践工做,抛砖引玉,但愿可以为其余团队提供一些经验和启发。

面临的挑战和成果

面对用户使用频率高,外卖业务增加快,Android碎片化严重这些问题,美团外卖Android App如何持续的下降Crash率,是一项极具挑战的事情。经过团队的全力全策,美团外卖Android App的平均Crash率从千分之三降到了万分之二,最优值万一左右(Crash率统计方式:Crash次数/DAU)。

美团外卖自2013年建立以来,业务就以指数级的速度发展。美团外卖承载的业务,从单一的餐饮业务,发展到餐饮、超市、生鲜、果蔬、药品、鲜花、蛋糕、跑腿等十多个大品类业务。目前美团外卖日完成订单量已突破2000万,成为美团点评最重要的业务之一。美团外卖客户端所承载的业务模块愈来愈多,产品复杂度愈来愈高,团队开发人员日益增长,这些都给App下降Crash率带来了巨大的挑战。

Crash的治理实践

对于Crash的治理,咱们尽可能遵照如下三点原则:

  • 由点到面。一个Crash发生了,咱们不能只针对这个Crash的去解决,而要去考虑这一类Crash怎么去解决和预防。只有这样才能使得这一类Crash真正被解决。
  • 异常不能随便吃掉。随意的使用try-catch,只会增长业务的分支和隐蔽真正的问题,要了解Crash的本质缘由,根据本质缘由去解决。catch的分支,更要根据业务场景去兜底,保证后续的流程正常。
  • 预防胜于治理。当Crash发生的时候,损失已经形成了,咱们再怎么治理也只是减小损失。尽量的提早预防Crash的发生,能够将Crash消灭在萌芽阶段。

常规的Crash治理

常规Crash发生的缘由主要是因为开发人员编写代码不当心致使的。解决这类Crash须要由点到面,根据Crash引起的缘由和业务自己,统一集中解决。常见的Crash类型包括:空节点、角标越界、类型转换异常、实体对象没有序列化、数字转换异常、Activity或Service找不到等。这类Crash是App中最为常见的Crash,也是最容易反复出现的。在获取Crash堆栈信息后,解决这类Crash通常比较简单,更多考虑的应该是如何避免。下面介绍两个咱们治理的量比较大的Crash。

NullPointerException

NullPointerException是咱们遇到最频繁的,形成这种Crash通常有两种状况:

  • 对象自己没有进行初始化就进行操做。
  • 对象已经初始化过,可是被回收或者手动置为null,而后对其进行操做。

针对第一种状况致使的缘由有不少,多是开发人员的失误、API返回数据解析异常、进程被杀死后静态变量没初始化致使,咱们能够作的有:

  • 对可能为空的对象作判空处理。
  • 养成使用@NonNull和@Nullable注解的习惯。
  • 尽可能不使用静态变量,万不得已使用SharedPreferences来存储。
  • 考虑使用Kotlin语言。

针对第二种状况大部分是因为Activity/Fragment销毁或被移除后,在Message、Runnable、网络等回调中执行了一些代码致使的,咱们能够作的有:

  • Message、Runnable回调时,判断Activity/Fragment是否销毁或被移除;加try-catch保护;Activity/Fragment销毁时移除全部已发送的Runnable。
  • 封装LifecycleMessage/Runnable基础组件,并自定义Lint检查,提示使用封装好的基础组件。
  • 在BaseActivity、BaseFragment的onDestory()里把当前Activity所发的全部请求取消掉。

IndexOutOfBoundsException

这类Crash常见于对ListView的操做和多线程下对容器的操做。

针对ListView中形成的IndexOutOfBoundsException,常常是由于外部也持有了Adapter里数据的引用(如在Adapter的构造函数里直接赋值),这时若是外部引用对数据更改了,但没有及时调用notifyDataSetChanged(),则有可能形成Crash,对此咱们封装了一个BaseAdapter,数据统一由Adapter本身维护通知, 同时也极大的避免了The content of the adapter has changed but ListView did not receive a notification,这两类Crash目前获得了统一的解决。

另外,不少容器是线程不安全的,因此若是在多线程下对其操做就容易引起IndexOutOfBoundsException。经常使用的如JDK里的ArrayList和Android里的SparseArray、ArrayMap,同时也要注意有一些类的内部实现也是用的线程不安全的容器,如Bundle里用的就是ArrayMap。

系统级Crash治理

众所周知,Android的机型众多,碎片化严重,各个硬件厂商可能会定制本身的ROM,更改系统方法,致使特定机型的崩溃。发现这类Crash,主要靠云测平台配合自动化测试,以及线上监控,这种状况下的Crash堆栈信息很难直接定位问题。下面是常见的解决思路:

  1. 尝试找到形成Crash的可疑代码,看是否有特异的API或者调用方式不当致使的,尝试修改代码逻辑来进行规避。
  2. 经过Hook来解决,Hook分为Java Hook和Native Hook。Java Hook主要靠反射或者动态代理来更改相应API的行为,须要尝试找到能够Hook的点,通常Hook的点多为静态变量,同时须要注意Android不一样版本的API,类名、方法名和成员变量名均可能不同,因此要作好兼容工做;Native Hook原理上是用更改后方法把旧方法在内存地址上进行替换,须要考虑到Dalvik和ART的差别;相对来讲Native Hook的兼容性更差一点,因此用Native Hook的时候须要配合降级策略。
  3. 若是经过前两种方式都没法解决的话,咱们只能尝试反编译ROM,寻找解决的办法。

咱们举一个定制系统ROM致使Crash的例子,根据Crash平台统计数据发现该Crash只发生在vivo V3Max这类机型上,Crash堆栈以下:

java.lang.RuntimeException: An error occured while executing doInBackground()
  at android.os.AsyncTask$3.done(AsyncTask.java:304)
  at java.util.concurrent.FutureTask.finishCompletion(FutureTask.java:355) at java.util.concurrent.FutureTask.setException(FutureTask.java:222) at java.util.concurrent.FutureTask.run(FutureTask.java:242) at android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:231) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1112) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:587) at java.lang.Thread.run(Thread.java:818) Caused by: java.lang.NullPointerException: Attempt to invoke interface method 'int java.util.List.size()' on a null object reference at android.widget.AbsListView$UpdateBottomFlagTask.isSuperFloatViewServiceRunning(AbsListView.java:7689) at android.widget.AbsListView$UpdateBottomFlagTask.doInBackground(AbsListView.java:7665) at android.os.AsyncTask$2.call(AsyncTask.java:292) at java.util.concurrent.FutureTask.run(FutureTask.java:237) ... 4 more 

咱们发现原生系统上对应系统版本的AbsListView里并无UpdateBottomFlagTask类,所以能够判定是vivo该版本定制的ROM修改了系统的实现。咱们在定位这个Crash的可疑点无果后决定经过Hook的方式解决,经过源码发现AsyncTask$SerialExecutor是静态变量,是一个很好的Hook的点,经过反射添加try-catch解决。由于修改的是final对象因此须要先反射修改accessFlags,须要注意ART和Dalvik下对应的Class不一样,代码以下:

  public static void setFinalStatic(Field field, Object newValue) throws Exception { field.setAccessible(true); Field artField = Field.class.getDeclaredField("artField"); artField.setAccessible(true); Object artFieldValue = artField.get(field); Field accessFlagsFiled = artFieldValue.getClass().getDeclaredField("accessFlags"); accessFlagsFiled.setAccessible(true); accessFlagsFiled.setInt(artFieldValue, field.getModifiers() & ~Modifier.FINAL); field.set(null, newValue); } 
private void initVivoV3MaxCrashHander() { if (!isVivoV3()) { return; } try { setFinalStatic(AsyncTask.class.getDeclaredField("SERIAL_EXECUTOR"), new SafeSerialExecutor()); Field defaultfield = AsyncTask.class.getDeclaredField("sDefaultExecutor"); defaultfield.setAccessible(true); defaultfield.set(null, AsyncTask.SERIAL_EXECUTOR); } catch (Exception e) { L.e(e); } } 

美团外卖App用上述方法解决了对应的Crash,可是美团App里的外卖频道由于平台的限制没法经过这种方式,因而咱们尝试反编译ROM。 Android ROM编译时会将framework、app、bin等目录打入system.img中,system.img是Android系统中用来存放系统文件的镜像 (image),文件格式通常为yaffs2或ext。但Android 5.0开始支持dm-verity后,system.img再也不提供,而是提供了三个文件system.new.dat,system.patch.dat,system.transfer.list,所以咱们首先须要经过上述的三个文件获得system.img。但咱们将vivo ROM解压后发现厂商将system.new.dat进行了分片,以下图所示:

通过对system.transfer.list中的信息和system.new.dat 1 2 3 … 文件大小对比研究,发现一些共同点,system.transfer.list中的每个block数*4KB 与对应的分片文件的大小大体相同,故大胆猜想,vivo ROM对system.patch.dat分片也只是单纯的按block前后顺序进行了分片处理。因此咱们只须要在转化img前将这些分片文件合成一个system.patch.dat文件就能够了。最后根据system.img的文件系统格式进行解包,拿到framework目录,其中有framework.jar和boot.oat等文件,由于Android4.4以后引入了ART虚拟机,会预先把system/framework中的一些jar包转换为oat格式,因此咱们还须要将对应的oat文件经过ota2dex将其解包得到dex文件,以后经过dex2jarjd-gui查看源码。

OOM

OOM是OutOfMemoryError的简称,在常见的Crash疑难排行榜上,OOM绝对能够名列前茅而且经久不衰。由于它发生时的Crash堆栈信息每每不是致使问题的根本缘由,而只是压死骆驼的最后一根稻草。

致使OOM的缘由大部分以下:

  • 内存泄漏,大量无用对象没有被及时回收致使后续申请内存失败。
  • 大内存对象过多,最多见的大对象就是Bitmap,几个大图同时加载很容易触发OOM。

内存泄漏

内存泄漏指系统未能及时释放已经再也不使用的内存对象,通常是由错误的程序代码逻辑引发的。在Android平台上,最多见也是最严重的内存泄漏就是Activity对象泄漏。Activity承载了App的整个界面功能,Activity的泄漏同时也意味着它持有的大量资源对象都没法被回收,极其容易形成OOM。

常见的可能会形成Activity泄漏的缘由有:

  • 匿名内部类实现Handler处理消息,可能致使隐式持有的Activity对象没法回收。
  • Activity和Context对象被混淆和滥用,在许多只须要Application Context而不须要使用Activity对象的地方使用了Activity对象,好比注册各种Receiver、计算屏幕密度等等。
  • View对象处理不当,使用Activity的LayoutInflater建立的View自身持有的Context对象其实就是Activity,这点常常被忽略,在本身实现View重用等场景下也会致使Activity泄漏。

对于Activity泄漏,目前已经有了一个很是好用的检测工具:LeakCanary,它能够自动检测到全部Activity的泄漏状况,而且在发生泄漏时给出十分友好的界面提示,同时为了防止开发人员的疏漏,咱们也会将其上报到服务器,统一检查解决。另外咱们能够在debug下使用StrictMode来检查Activity的泄露、Closeable对象没有被关闭等问题。

大对象

在Android平台上,咱们分析任一应用的内存信息,几乎均可以得出一样的结论:占用内存最多的对象大都是Bitmap对象。随着手机屏幕尺寸愈来愈大,屏幕分辨率也愈来愈高,1080p和更高的2k屏已经占了大半份额,为了达到更好的视觉效果,咱们每每须要使用大量高清图片,同时也为OOM埋下了祸根。

对于图片内存优化,咱们有几个经常使用的思路:

  • 尽可能使用成熟的图片库,好比Glide,图片库会提供不少通用方面的保障,减小没必要要的人为失误。
  • 根据实际须要,也就是View尺寸来加载图片,能够在分辨率较低的机型上尽量少地占用内存。除了经常使用的BitmapFactory.Options#inSampleSize和Glide提供的BitmapRequestBuilder#override以外,咱们的图片CDN服务器也支持图片的实时缩放,能够在服务端进行图片缩放处理,从而减轻客户端的内存压力。 分析App内存的详细状况是解决问题的第一步,咱们须要对App运行时到底占用了多少内存、哪些类型的对象有多少个有大体了解,并根据实际状况作出预测,这样才能在分析时作到有的放矢。Android Studio也提供了很是好用的Memory Profiler堆转储分配跟踪器功能能够帮咱们迅速定位问题。

AOP加强辅助

AOP是面向切面编程的简称,在Android的Gradle插件1.5.0中新增了Transform API以后,编译时修改字节码来实现AOP也由于有了官方支持而变得很是方便。

在一些特定状况下,能够经过AOP的方式自动处理未捕获的异常:

  • 抛异常的方法很是明确,调用方式比较固定。
  • 异常处理方式比较统一。
  • 和业务逻辑无关,即自动处理异常后不会影响正常的业务逻辑。典型的例子有读取Intent Extras参数、读取SharedPreferences、解析颜色字符串值和显示隐藏Window等等。

这类问题的解决原理大体相同,咱们以Intent Extras为例详细介绍一下。读取Intent Extras的问题在于咱们很是经常使用的方法 Intent#getStringExtra 在代码逻辑出错或者恶意攻击的状况下可能会抛出ClassNotFoundException异常,而咱们平时在写代码时又不太可能给全部调用都加上try-catch语句,因而一个更安全的Intent工具类应运而生,理论上只要全部人都使用这个工具类来访问Intent Extras参数就能够防止此类型的Crash。可是面对庞大的旧代码仓库和诸多的业务部门,修改现有代码须要极大成本,还有更多的外部依赖SDK基本不可能使用咱们本身的工具类,此时就须要AOP大展身手了。

咱们专门制做了一个Gradle插件,只须要配置一下参数就能够将某个特定方法的调用替换成另外一个方法:

WaimaiBytecodeManipulator {
     replacements(
         "android/content/Intent.getIntExtra(Ljava/lang/String;I)I=com/waimai/IntentUtil.getInt(Landroid/content/Intent;Ljava/lang/String;I)I",
         "android/content/Intent.getStringExtra(Ljava/lang/String;)Ljava/lang/String;=com/waimai/IntentUtil.getString(Landroid/content/Intent;Ljava/lang/String;)Ljava/lang/String;",
         "android/content/Intent.getBooleanExtra(Ljava/lang/String;Z)Z=com/waimai/IntentUtil.getBoolean(Landroid/content/Intent;Ljava/lang/String;Z)Z", ...) } } 

上面的配置就能够将App代码(包括第三方库)里全部的Intent.getXXXExtra调用替换成IntentUtil类中的安全版实现。固然,并非全部的异常都只须要catch住就万事大吉,若是真的有逻辑错误确定须要在开发和测试阶段及时暴露出来,因此在IntentUtil中会对App的运行环境作判断,Debug下会将异常直接抛出,开发同窗能够根据Crash堆栈分析问题,Release环境下则在捕获到异常时返回对应的默认值而后将异常上报到服务器。

依赖库的问题

Android App常常会依赖不少AAR, 每一个AAR可能有多个版本,打包时Gradle会根据规则肯定使用的最终版本号(默认选择最高版本或者强制指定的版本),而其余版本的AAR将被丢弃。若是互相依赖的AAR中有不兼容的版本,存在的问题在打包时是不能发现的,只有在相关代码执行时才会出现,会形成NoClassDefFoundError、NoSuchFieldError、NoSuchMethodError等异常。如图所示,order和store两个业务库都依赖了platform.aar,一个是1.0版本,一个是2.0版本,默认最终打进APK的只有platform 2.0版本,这时若是order库里用到的platform库里的某个类或者方法在2.0版本中被删除了,运行时就可能发生异常,虽然SDK在升级时会尽可能作到向下兼容,但不少时候尤为是第三方SDK是无法获得保证的,在美团外卖Android App v6.0版本时由于这个缘由致使热修复功能丧失,所以为了提早发现问题,咱们接入了依赖检查插件Defensor。

Defensor在编译时经过DexTask获取到全部的输入文件(也就是被编译过的class文件),而后检查每一个文件里引用的类、字段、方法等是否存在。

除此以外咱们写了一个Gradle插件SVD(strict version dependencies)来对那些重要的SDK的版本进行统一管理。插件会在编译时检查Gradle最终使用的SDK版本是否和配置中的一致,若是不一致插件会终止编译并报错,并同时会打印出发生冲突的SDK的全部依赖关系。

Crash的预防实践

单纯的靠约定或规范去减小Crash的发生是不现实的。约定和规范受限于组织架构和具体执行的我的,很容易被忽略,只有靠工程架构和工具才能保证Crash的预防长久的执行下去。

工程架构对Crash率的影响

在治理Crash的实践中,咱们每每忽略了工程架构对Crash率的影响。Crash的发生大部分缘由是源于程序员的不合理的代码,而程序员工做中最直接的接触的就是工程架构。对于一个边界模糊,层级混乱的架构,程序员是更加容易写出引发Crash的代码。在这样的架构里面,即便程序员意识到致使某种写法存在问题,想要去改善这样不合理的代码,也是很是困难的。相反,一个层级清晰,边界明确的架构,是可以大大减小Crash发生的几率,治理和预防Crash也是相对更容易。这里咱们能够举几个咱们实践过的例子阐述。

业务模块的划分

原来咱们的Crash基本上都是由个别同窗关注解决的,团队里的每一个同窗都会提交可能引发Crash的代码,若是负责Crash的同窗由于某些事情,暂时没有关注App的Crash率,那么形成Crash的同窗也不会知道他的代码引发了Crash。

对于这个问题,咱们的作法是App的业务模块化。业务模块化后,每一个业务都有都有惟一包名和对应的负责人。当某个模块发生了Crash,能够根据包名提交问题给这个模块的负责人,让他第一时间进行处理。业务模块化自己也是工程架构优先须要考虑的事情之一。

页面跳转路由统一处理页面跳转

对外卖App而言,使用过程当中最多的就是页面间的跳转,而页面间跳转常常会形成ActivityNotFoundException,例如咱们配了一个scheme,但对方的scheme路径已经发生了变化;又例如,咱们调用手机上相册的功能,而相册应用已被用户本身禁用或移除了。解决这一类Crash,其实也很简单,只须要在startActivity增长ActivityNotFoundException异常捕获便可。但一个App里,启动Activity的地方,几乎是随处可见,没法预测哪一处会形成ActivityNotFoundException。

咱们的作法是将页面的跳转,都经过咱们封装的scheme路由去分发。这样的好处是,经过scheme路由,在工程架构上全部业务都是解耦,模块间不须要相互依赖就能够实现页面的跳转和基本类型参数的传递;同时,因为全部的页面跳转都会走scheme路由,咱们只须要在scheme路由里一处加上ActivityNotFoundException异常捕获便可解决这种类型的Crash。路由设计示意图以下:

网络层统一处理API脏数据

客户端的很大一部分的Crash是由于API返回的脏数据。好比当API返回空值、空数组或返回不是约定类型的数据,App收到这些数据,就极有可能发生空指针、数组越界和类型转换错误等Crash。并且这样的脏数据,特别容易引发线上大面积的崩溃。

最先咱们的工程的网络层用法是:页面监听网络成功和失败的回调,网络成功后,将JSON数据传递给页面,页面解析Model,初始化View,如图所示。这样的问题就是,网络虽然请求成功了,可是JSON解析Model这个过程可能存在问题,例如没有返回数据或者返回了类型不对的数据,而这个脏数据致使问题会出如今UI层,直接反应给用户。

根据上图,咱们能够看到因为网络层只承担了请求网络的职责,没有承担数据解析的职责,数据解析的职责交给了页面去处理。这样使得咱们一旦发现脏数据致使的Crash,就只能在网络请求的回调里面增长各类判断去兼容脏数据。咱们有几百个页面,补漏彻底补不过来。经过几个版本的重构,咱们从新划分了网络层的职责,如图所示:

从图上能够看出,重构后的网络层负责请求网络和数据解析,若是存在脏数据的话,在网络层就会发现问题,不会影响到UI层,返回给UI层的都是校验成功的数据。这样改造后,咱们发现这类的Crash率有了极大的改善。

大图监控

上面讲到大对象是致使OOM的主要缘由之一,而Bitmap是App里最多见的大对象类型,所以对占用内存过大的Bitmap对象的监控就颇有必要了。

咱们用AOP方式Hook了三种常见图片库的加载图片回调方法,同时监控图片库加载图片时的两个维度:

  1. 加载图片使用的URL。外卖App中除静态资源外,全部图片都要求发布到专用的图片CDN服务器上,加载图片时使用正则表达式匹配URL,除了限定CDN域名以外还要求全部图片加载时都要添加对应的动态缩放参数。
  2. 最终加载出的图片结果(也就是Bitmap对象)。咱们知道Bitmap对象所占内存和其分辨率大小成正比,而通常状况下在ImageView上设置超过自身尺寸的图片是没有意义的,因此咱们要求显示在ImageView中的Bitmap分辨率不容许超过View自身的尺寸(为了下降误报率也能够设定一个报警阈值)。

开发过程当中,在App里检测到不合规的图片时会当即高亮出错的ImageView所在的位置并弹出对话框提示ImageView所在的Activity、XPath和加载图片使用的URL等信息,以下图,辅助开发同窗定位并解决问题。在Release环境下能够将报警信息上报到服务器,实时观察数据,有问题及时处理。

Lint检查

咱们发现线上的不少Crash其实能够在开发过程当中经过Lint检查来避免。Lint是Google提供的Android静态代码检查工具,能够扫描并发现代码中潜在的问题,提醒开发人员及早修正,提升代码质量。

可是Android原生提供的Lint规则(如是否使用了高版本API)远远不够,缺乏一些咱们认为有必要的检测,也不能检查代码规范。所以咱们开始开发自定义Lint,目前咱们经过自定义Lint规则已经实现了Crash预防、Bug预防、提高性能/安全和代码规范检查这些功能。如检查实现了Serializable接口的类,其成员变量(包括从父类继承的)所声明的类型都要实现Serializable接口,能够有效的避免NotSerializableException;强制使用封装好的工具类如ColorUtil、WindowUtil等能够有效的避免由于参数不正确产生的IllegalArgumentException和由于Activity已经finish致使的BadTokenException。

Lint检查能够在多个阶段执行,包括在本地手动检查、编码实时检查、编译时检查、commit时检查,以及在CI系统中提Pull Request时检查、打包时检查等,以下图所示。更详细的内容可参考《美团外卖Android Lint代码检查实践》

资源重复检查

在以前的文章《美团外卖Android平台化架构演进实践》中讲述了咱们的平台化演进过程,在这个过程当中你们很大的一部分工做是下沉,可是下沉不彻底就会致使一些类和资源的重复,类由于有包名的限制不会出现问题。可是一些资源文件如layout、drawable等若是同名则下层会被上层覆盖,这时layout里view的id发生了变化就可能致使空指针的问题。为了不这种问题,咱们写了一个Gradle插件经过hook MergeResource这个Task,拿到全部library和主库的资源文件,若是检查到重复则会中断编译过程,输出重复的资源名及对应的library name,同时避免有些资源由于样式等缘由确实须要覆盖,所以咱们设置了白名单。同时在这个过程当中咱们也拿到了全部的的图片资源,能够顺手作图片大小的本地监控,以下图所示:

Crash的监控&止损的实践

监控

在通过前面提到的各类检查和测试以后,应用便开始发布了。咱们创建了以下图的监控流程,来保证异常发生时可以及时获得反馈并处理。首先是灰度监控,灰度阶段是增量Crash最容易暴露的阶段,若是这个阶段没有很好的把握住,会使得增量变存量,从而致使Crash率上升。若是条件容许的话,能够在灰度期间制定一些灰度策略去提升这个阶段Crash的暴露。例如分渠道灰度、分城市灰度、分业务场景灰度、新装用户的灰度等等,尽可能覆盖全部的分支。灰度结束以后便开始全量,在全量的过程当中咱们还须要一些平常Crash监控和Crash率的异常报警来防止突发状况的发生,例如由于后台上线或者运营配置错误致使的线上Crash。除此以外还须要一些其余的监控,例如,以前提到的大图监控,来避免由于大图致使的OOM。具体的输出形式主要有邮件通知、IM通知、报表。

止损

尽管咱们在前面作了那么多,可是Crash仍是没法避免的,例如,在灰度阶段由于量级不够,有些Crash没有被暴露出来;又或者某些功能客户端比后台更早上线,而这些功能在灰度阶段没有被覆盖到;这些状况下,若是出现问题就须要考虑如何止损了。

问题发生时首先须要评估重要性,若是问题不是很严重并且修复成本较高能够考虑在下个版本再修复,相反若是问题比较严重,对用户体验或下单有影响时就必需要修复。修复时首先考虑业务降级,主要看该部分异常的业务是否有兜底或者A/B策略,这样是最稳妥也是最有效的方式。若是业务不能降级就须要考虑热修复了,目前美团外卖Android App接入的热修复框架是自研的Robust,能够修复90%以上的场景,热修成功率也达到了99%以上。若是问题发生在热修复没法覆盖的场景,就只能强制用户升级。强制升级由于覆盖周期长,同时影响用户的体验,只在万不得已的状况下才会使用。

展望

Crash的自我修复

咱们在作新技术选型时除了要考虑是否能知足业务需求、是否比现有技术更优秀和团队学习成本等因素以外,兼容性和稳定性也很是重要。但面对国内非富多彩的Android系统环境,在体量百万级以上的的App中几乎不可能实现毫无瑕疵的技术方案和组件,因此通常状况下若是某个技术实现方案能够达到0.01‰如下的崩溃率,而其余方案也没有更好的表现,咱们就认为它是能够接受的。可是哪怕仅仅十万分之一的崩溃率,也表明还有用户受到影响,而咱们认为Crash对用户来讲是最糟糕的体验,尤为是涉及到交易的场景,因此咱们必须本着每一单都很重要的原则,尽最大努力保证用户顺利执行流程。

实际状况中有一些技术方案在兼容性和稳定性上作了必定妥协的场景,每每是由于考虑到性能或扩展性等方面的优点。这种状况下咱们其实能够再多作一些,进一步提升App的可用性。就像不少操做系统都有“兼容模式”或者“安全模式”,不少自动化机械机器都配套有手动操做模式同样,App里也能够实现备用的降级方案,而后设置特定条件的触发策略,从而达到自动修复Crash的目的。

举例来说,Android 3.0中引入了硬件加速机制,虽然能够提升绘制帧率而且下降CPU占用率,可是在某些机型上仍是会有绘制错乱甚至Crash的状况,这时咱们就能够在App中记录硬件加速相关的Crash问题或者使用检测代码主动检测硬件加速功能是否正常工做,而后主动选择是否开启硬件加速,这样既能够让绝大部分用户享受硬件加速带来的优点,也能够保障硬件加速功能不完善的机型不受影响。

还有一些相似的能够作自动降级的场景,好比:

  • 部分使用JNI实现的模块,在SO加载失败或者运行时发生异常则能够降级为Java版实现。
  • RenderScript实现的图片模糊效果,也能够在失败后降级为普通的Java版高斯模糊算法。
  • 在使用Retrofit网络库时发现OkHttp3或者HttpURLConnection网络通道失败率高,能够主动切换到另外一种通道。

这类问题都须要根据具体状况具体分析,若是能够找到准确的断定条件和稳定的修复方案,就能够让App稳定性再上一个台阶。

特定Crash类型日志自动回捞

外卖业务发展迅速,即便咱们在开发时使用各类工具、措施来避免Crash的发生,但Crash仍是不可避免。线上某些怪异的Crash发生后,咱们除了分析Crash堆栈信息以外,还能够使用离线日志回捞、下发动态日志等工具来还原Crash发生时的场景,帮助开发同窗定位问题,可是这两种方式都有它们各自的问题。离线日志顾名思义,它的内容都是预先记录好的,有时候可能会漏掉一些关键信息,由于在代码中加日志通常只是在业务关键点,在大量的普通方法中不可能都加上日志。动态日志(Holmes)存在的问题是每次下发只能针对已知UUID的一个用户的一台设备,对于大量线上Crash的状况这种操做并不合适,由于咱们并不能知道哪一个发生Crash的用户还会再次复现此次操做,下发配置充满了不肯定性。

咱们能够改造Holmes使其支持批量甚至全量下发动态日志,记录的日志等到发生特定类型的Crash时才上报,这样一来能够减小日志服务器压力,同时也能够极大提升定位问题的效率,由于咱们能够肯定上报日志的设备最后都真正发生了该类型Crash,再来分析日志就能够作到事半功倍。

总结

业务的快速发展,每每不可能给团队充足的时间去治理Crash,而Crash又是App最重要的指标之一。团队须要由一个个Crash个例,去探究每个Crash发生的最本质缘由,找到最合理解决这类Crash的方案,创建解决这一类Crash的长效机制,而不能饮鸩止渴。只有这样,随着版本的不断迭代,咱们才能在Crash治理之路上离目标愈来愈近。

参考资料

  1. Crash率从2.2%降至0.2%,这个团队是怎么作到的?
  2. Android运行时ART加载OAT文件的过程分析
  3. Android动态日志系统Holmes
  4. Android Hook技术防范漫谈
  5. 美团外卖Android Lint代码检查实践

 

美团外卖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版本时,若是feature一、feature2须要同时在两端上线,只须要将对应业务代码移动到共用SourceSet便可实现feature一、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

pins+flavor

 

具体的配置过程,首先须要在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—开源的移动端页面模块化开发框架

 

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

美团外卖自2013年建立以来,业务一直高速发展。目前美团外卖日完成订单量已突破1800万,成为美团点评最重要的业务之一。美团外卖的用户端入口,从单一的外卖独立App,拓展为外卖、美团、点评等多个App入口。美团外卖所承载的业务,也从单一的餐饮业务,发展到餐饮、超市、生鲜、果蔬、药品、鲜花、蛋糕、跑腿等十多个大品类业务。业务的快速发展对客户端架构不断提出新的挑战。

平台化背景

很早以前,外卖做为孵化中的项目只有美团外卖App(下文简称外卖App)一个入口,后来外卖做为一个子频道接入到美团App(下文简称外卖频道),两端业务并行迭代开发。早期为了快速上线,开发同窗直接将外卖App的代码拷贝出一份到外卖频道,作了简单的适配就很快接入到美团App了。

早期外卖App和外卖频道由两个团队分别维护,而在随后一段时间里,两端代码体系差别愈来愈来大。最后演变成了从网络、图片等基础库到UI控件、类的命名等都不尽相同的两套代码。尽管后来两个团队合并到一块儿,但历史的差别已经造成,为了优先知足业务需求,很长一段时间内,咱们只能在两套代码的基础上不断堆积更多的功能。维护两套代码的成本可想而知,而业务的迅猛发展又使得这一问题愈加不可忍受。

在咱们探索解决两端代码复用的同时,业务的发展又对咱们提出新的挑战。随着团队成员扩充了数倍,商超生鲜等垂直品类的拆分,以及异地研发团队的创建,外卖客户端的平台化被提上日程。而在此以前,外卖App和外卖频道基本保持单工程开发,这样的模式显然是没法支持多团队协做开发的。所以,咱们须要快速将代码重构为支持平台化的多工程模式,同时还要考虑业务模块的解耦,使得新业务能够拷贝现有的代码快速上线。此外,在实施平台化的过程当中,两端代码复用的问题尚未解决,若是两端的代码没有统一而直接作平台化业务拆库,必然会致使问题的复杂化。

在这样的背景下,能够看出咱们面临的问题相较于其余平台型App更为特殊和复杂:既要解决外卖业务平台化的问题,又要解决外卖App和外卖频道两端代码复用的问题。

多次探索

在实施平台化和两端代码复用的道路上并不是一路顺风,不少方案只有在尝试以后才知道问题所在。咱们屡次遇到这样的状况:设计方案完成后,团队已经全身心投入到开发之中,可是因为业务形态发生变化,原有的设计也被迫更改。在不断的探索和实践过程当中,咱们经历了多个中间阶段。虽然有很多失败的案例,可是也积累了不少架构设计上的宝贵经验,整个团队对业务和架构也有了更深的理解。

搜索库拆分实践

早期美团外卖App和美团外卖频道两个团队的合并,带来的最大痛点是代码复用,而非平台化,而在很长的一段时间内,咱们也没有想过从平台化的角度去解决两端代码复用的问题。然而代码复用的一些失败尝试,给后续平台化的架构带来了很多宝贵的经验。当时是怎么解决代码复用问题的呢?咱们经过和产品、设计同窗的沟通,约定了将来的需求,会从需求内容、交互、样式上,两端尽量的保持一致。通过屡次讨论后,团队发起了两端代码复用的技术方案尝试,咱们决定将搜索模块从主工程拆分出来,并实现两端代码复用。然而两端的搜索模块代码底层差别很大,BaseActivity和BaseFragment不统一,UI样式不统一,数据Model不统一,图片、网络、埋点不统一,而且两端发版周期也不一致。针对这些问题的解决方案是:

  1. 经过代理屏蔽Activity和Fragment基类不统一的问题;
  2. 两端主工程style覆盖搜索库的UI样式;
  3. 搜索库使用独立的数据Model,上层去作数据适配;
  4. 其余差别统统抛出接口让上层实现;
  5. 和PM沟通尽可能使产品需求和发版周期一致。

架构大体如图:

虽然搜索库在短时间内拆分为独立的工程,并实现了绝大部分的两端代码复用,可是好景不长,仅仅更新过几个版本后,因为需求和版本发布周期的差别,搜索库开始变为两个分支,而且两个分支的差别愈来愈大,最后代码没法合并而不得不永久维护两个搜索库。搜索库事实上是一次失败的拆分,其中的问题总结起来有三个:

  1. 在两端底层差别巨大的状况下自上而下的强行拆分,致使大量实现和适配留在了两端主工程实现,这样的设计层级混乱,边界模糊,而且极大的增长了业务开发的复杂性;
  2. 寄但愿于两端需求和发版周期彻底一致这个想法不切实际,若是在架构上不为两端的差别性预留可伸缩的空间,复用最终是难以持续的;
  3. 约定或规范,受限于组织架构和具体执行的我的,不肯定性过高。

页面组件化实践

在经历过搜索库的失败拆分后,你们认为目前还不具有实现模块总体拆分和复用的条件,所以咱们走向了另外一个方向,即实现页面的组件化以达成部分组件复用的目标。页面组件化的设计思路是:

  1. 将页面拆分为粒度更小的组件,组件内部除了包含UI实现,还包含数据层和逻辑层;
  2. 组件提供个性化配置知足两端差别需求,若是没法知足再经过代理抛到上层处理。

页面组件化是一个良好的设计,但它主要适用于解决Activity巨大化的问题。因为底层差别巨大的状况,使得页面组件化很难实现大规模的复用,复用效率低。另外一方面,页面组件化也没有为2端差别性预留可伸缩的空间。

MVP分层复用实践

咱们还尝试过运用设计模式解决两端代码复用的问题。想法是将代码分为易变的和稳定的两部分,易变部分在两端上层实现差别化处理,稳定部分能够在下层实现复用。方案的主要设计思路是:

  1. 借鉴Clean MVP架构,根据职责将代码拆分为Presenter,Data Repository,Use Case,View,Model等角色;
  2. UI、动画、数据请求等逻辑在下层仅保留接口,在上层实现并注入到下层;
  3. 对于两端不一致的数据Model,经过转换器适配为下层统一的模型。

架构大体如图:

这是一种灵活、优雅的设计,可以实现部分代码的复用,并能解决两端基础库和UI等差别。这个方案在首页和二级频道页的部分模块使用了一段时间,可是由于学习成本较高等缘由推广比较缓慢。另外,这个时期平台化已被提上日程,业务痛点决定了咱们必须快速实施模块总体的拆分和复用,而优雅的设计模式并不适合解决这一类问题。即便从复用性的角度来看,这样的设计也会使得业务开发变得更为复杂、调试困难,对于新人来讲难以胜任,最终推广落地困难。

中间层实践

经过屡次实践,咱们认识到要实现两端代码复用,基础库的统一是必然的工做,是其余一切工做的基础。不然必然致使复杂和难以维护的设计,最终致使两端复用没法快速推动下去。

计算机界有一句名言:“计算机科学领域的任何问题均可以经过增长一个中间层来解决。”(原始版本出自计算机科学家David Wheeler)咱们固然有想过经过中间层设计屏蔽两端的基础库差别。例如网络库,外卖App基于Volley实现,外卖频道基于Retrofit实现。咱们曾经在Volley和Retrofit之上封装了一层网络框架,对外暴露统一的接口,上层能够切换底层依赖Volley或是Retrofit。但这个中间层并无上线,最终咱们将两端的网络库统一成了Retrofit。这里面有多个缘由:首先Retrofit自己就是较高层次的封装,而且拥有优雅的设计模式,理论上咱们很难封装一套扩展性更强的接口;其次长期来看底层网络框架变动的风险极低,而且适配网络层的各类插件也是一件费时费力的事情,所以保持网络中间层的性价比极低;此外将两端的网络请求都替换为中间层接口,显然工做量远大于只保留一端的依赖。

经过实践咱们认识到,中间层设计是一把双刃剑。若是基础框架自己的扩展性足够强,中间层设计就显得画蛇添足,甚至丧失了原有框架的良好特性。

平台化实践

好的架构源于不停地衍变,而非设计。对于外卖Android客户端的平台化架构构建也是经历了一样的过程。咱们从考虑如何解决代码复用的问题,逐渐的衍变成如何去解决代码复用和平台化的两个问题。而实际上外卖平台化正是解决两端代码复用的一剂良药。咱们经过创建外卖平台,将现有的外卖业务降级为一个频道,将外卖业务以aar的形式分别接入到外卖平台和美团平台,这样在解决外卖平台化的同时,代码复用的问题也将获得完美的解决。

平台化架构

通过了整整一年的艰苦奋斗,造成了如图所示的美团外卖Android客户端平台化架构:

从底层到高层依次为平台层、业务层和宿主层。

  1. 平台层的内容包括,承载上层的数据通讯和页面跳转;提供外卖核心服务,例如商品管理、订单管理、购物车管理等;提供配置管理服务;提供统一的基础设施能力,例如网络、图片、监控、报警、定位、分享、热修、埋点、Crash上报等;提供其余管理能力,例如生命周期管理、组件化等。
  2. 业务层的内容包括,外卖业务和垂直业务。
  3. 宿主层的内容包括,Waimai App壳和美团外卖频道Waimai-channel壳,这一层用于Application的初始化、dex加载和其余各类必要的组件或基础库的初始化。

在构建平台化架构的过程当中,咱们遇到这样一个问题,如何长久的维持咱们平台化架构的层级边界。试想,若是全部的代码都在一个工程里面开发,经过包名、约定去规范层级边界,任何一个紧急的需求均可能破坏层级边界。维持层级边界的最好办法是什么?咱们的经验是工程隔离。平台化的每一层都去作工程隔离,业务层的每一个业务都创建本身的工程库,实现工程隔离。同时,配套编译脚本,检查业务库之间是否存在相互依赖关系。工程隔离的好处是显而易见的:

  1. 每一个工程均可以独立编译、独立打包;
  2. 每一个工程内部的修改,不会影响其余工程;
  3. 业务库工程能够快速拆分出来,集成到其余App中。

但工程隔离带来的另外一个问题是,同层间的业务库须要通讯怎么办?这时候就须要提供业务库通讯框架来解决这个问题。

业务库通讯框架

在拆分外卖商家业务库的时候,咱们就发这样一个案例:在商家页有一个业务,当发现当前商家是打烊的,就会弹出一个浮层,推荐类似的商家列表,而在咱们以前划分的外卖子业务库里面,类似商家列表应该是属于页面库里面的内容。那怎么让商家业务库访问到页面库里面的代码呢。若是咱们将商家库去依赖页面库,那咱们的层级边界就会被打破,咱们的依赖关系也会变得复杂。所以咱们须要在架构中提供同层间的通讯框架,它去解决不打破层级边界的状况下,完成同层间的通讯。

汇总同层间通讯的场景,大体上能够划分为:页面的跳转、基本数据类型的传递(包括可序列化的共有类对象的传递)、模块内部自定义方法和类的调用。针对上述状况,在咱们的架构里面提供了二种平级间的通讯方式:scheme路由和美团自建的ServiceLoaders sdk。scheme路由本质上是利用Android的scheme原理进行通讯,ServiceLoader本质上是利用的Java反射机制进行通讯。

scheme路由的调用如图所示:

最终效果:全部业务页面的跳转,都须要经过平台层的scheme路由去分发。经过scheme路由,全部业务都获得解耦,再也不须要相互依赖而能够实现页面的跳转和基本数据类型的传递。

serviceloader的调用如图所示:

提供方和使用方经过平台层的一个接口做为双方交互的约束。使用方经过平台层的ServiceLoader完成提供方的实现对象获取。这种方式能够解决模块内部自定义方法和类的调用,例如咱们以前提到了商家库须要调用页面库代码的问题就能够经过ServiceLoader解决。

外卖内核模块设计

在实践的过程当中,咱们也遇到业务自己上就很差划分层级边界的业务。你们能够从美团外卖三层架构图上,看出外卖业务库,像商家、订单等,是和外卖的垂类业务库是同级的。而实际上外卖业务的子业务是否应该和垂类业务保持同层是一个目前没法肯定的事情。

目前,外卖接入的垂类业务商超业务,是隶属于外卖业务的子频道,它依然依赖着外卖的核心model、核心服务,包括商品管理、订单管理、购物车管理等,所以目前它和外卖业务的商家、订单这样的子业务库同层是没有问题的。但随着商超业务的发展,商超业务将来可能会建设本身的商品管理、订单管理、购物车管理的服务,那么到时商超业务就会上升到和外卖业务同样同层的业务。这时候,外卖核心管理服务,处在平台层,就会致使架构的层级边界变得再也不清晰。

咱们的解决办法是经过设计一个属于外卖业务的内核模块来适应将来的变化,内核模块的设计如图:

  1. 内圈为基础模型类,这些模型类构成了外卖核心业务(从门店→点菜→购物车→订单)的基础;
  2. 中间圈为依赖基础模型类构建的基础服务(CRUD);
  3. 最外圈为外卖的各维度业务,向内依赖基础模型圈和外卖基础服务圈。

若是将来肯定外卖平台须要接入更多和外卖平级的业务,且最内圈都彻底不同,咱们将把外卖内核模块上移,在外卖业务子库下创建对内核模块的依赖;若是将来只是有更多的外卖子业务的接入,那就继续保留咱们如今的架构;若是将来接入的业务基础模型类同样,但本身的业务服务须要分化,那么咱们将对保留内核模块最核心的内圈,并抽象出服务层由外卖和商超上层本身实现真正的服务。

业务库拆分

在拆分业务库的时候,咱们面临着这样的问题:业务之间的关系是较为复杂的,如何去拆分业务库,才是较为合理的呢?一开始咱们准备根据外卖业务核心流程:页面→商家→下单,去拆分外卖业务。可是随着外卖子频道业务的快速发展,子频道业务也创建了本身的研发团队,在页面、商家、下单等环节,也开始创建本身的页面。若是咱们仍然按照外卖下单的流程去拆分库,那在同一个库之间,就会有外卖团队和外卖子频道团队共同开发的状况,这样职责边界很不清晰,在实际的开发过程当中,确定会出现理不清的状况。

咱们都知道软件工程领域有所谓的康威定律

Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations. - Melvin Conway(1967)

翻译成中文的大概意思是:设计系统的组织,其产生的设计等同于组织以内、组织之间的沟通结构。

在康威定理的指导下:咱们认为技术架构应该反映出团队的组织结构,同时,组织结构的变迁,也应该致使技术架构的演进。美团外卖平台下包含外卖业务和垂直品类业务,对于在咱们团队中已经有了组织结构,优先组织结构,去拆出独立的业务库,方便子业务库的同窗内部沟通协做,减小他们跨组织沟通的成本。同时,咱们将负责外卖业务的大团队,再进一步细化成页面小组、商家小组和订单小组,由这些小组的同窗去在外卖业务下完成更细维度的外卖子业务库拆分。根据组织结构划分的业务库,自然的存在业务边界,每一个同窗都会按照本身业务的目标去继续完善本身的业务库。这样的拆库对内是高内聚,对外是低耦合的,有效的下降了内外沟通协做的成本。

工程内代码隔离

在实现工程隔离以后,咱们发现工程内部的代码仍是能够相互引用的。工程内部若是也不能实现代码的隔离,那么工程内部的边界就是模糊的。咱们但愿工程内至少可以实现页面级别的代码隔离,由于Activity是组成一个App的页面单元,围绕这个Activity,一般会有大量的代码及资源文件,咱们但愿这些代码和资源文件是被集中管理的。

一般咱们想到的作法是以module工程为单位的相互隔离,但在module是相对比较重的一个约束,难道每一个Activity都要建一个module吗?这样代码结构会变得很复杂,并且针对一些大的业务体,又会造成巨大化的module。

那咱们又想到规范代码,用包名去人为约定,但靠包名约束的代码,边界模糊,时不时的紧急需求,就把包名约定打破了,并且资源文件的摆放也是任意的,迁移成本高。

那怎么去解决工程内部的边界问题呢?《微信的模块化架构重构实践》一文中提到了一个重要的概念p(pins)工程,p工程可谓是工程内约束代码边界的重要法宝。经过在Gradle里面配置sourceSets,就能够改变工程内的代码结构目录,完成代码的隔离,配置示例:

sourceSets {
    main {
        def dirs = ['p_widget', 'p_theme', 'p_shop', 'p_shopcart', 'p_submit_order','p_multperson','p_again_order', 'p_location', 'p_log','p_ugc','p_im','p_share'] dirs.each { dir -> java.srcDir("src/$dir/java") res.srcDir("src/$dir/res") } } } 

效果如图所示:

从图上能够能够看出,这个业务库被以页面为单元拆分红了多个p工程,每一个p工程的边界都是清楚的,实现了工程内的代码隔离。工程内代码隔离带来的好处显而易见:

  1. p工程实现了最小粒度的代码边界约束;
  2. 工程内模块职责清晰;
  3. 业务模块能够被快速的拆分出来。

代码复用

p工程知足了工程内代码隔离的需求,可是别忘了,咱们每一个模块在外卖两个终端上(外卖App&美团App)上可能存在差别,若是能在模块内部实现两端差别,咱们的目标才算达成。基于上述考虑,咱们想到了使用Gradle提供的productFlavors来实现两端的差别化。为此,咱们须要定义两个flavor:wm和mt。

productFlavors {
     wm {}
     mt {} } 

可是,这样生成的p工程是并列的,也就是说,各个p工程中全部的差别化代码都须要被存放在这两个flavor对应的SourceSet下,这岂不是跟模块间代码隔离的理念相违背?理想的结构是在p工程内部进行flavor划分,由p工程内部包容差别化,继续改为Gradle脚本以下:

productFlavors {
    wm {}
    mt {}
}
sourceSets {
    def dirs = ['p_restaurant', 'p_goods_detail', 'p_comment', 'p_compose_order', 'p_shopping_cart', 'p_base', 'p_product_set'] main { manifest.srcFile 'src/p_restaurant/main/AndroidManifest.xml' dirs.each { dir -> java.srcDir("src/${dir}/main/java") res.srcDir("src/${dir}/main/res") } } wm { dirs.each { dir -> java.srcDir("src/${dir}/wm/java") res.srcDir("src/${dir}/wm/res") } } mt { dirs.each { dir -> java.srcDir("src/${dir}/mt/java") res.srcDir("src/${dir}/mt/res") } } } 

最终工程结构变成以下:

经过p工程和flavor的灵活应用,咱们最终将业务库配置成以p工程为维度的模块单元,并在p工程内部兼容两端的共性及差别,代码复用被很好的解决了。同时,两端差别的问题是归属在p工程内部本身处理的,并无创建中间层,或将差别抛给上层壳工程去完成,这样的设计遵照了边界清晰,向下依赖的原则。

可是,工程内隔离也存在与工程隔离同样的问题:同层级p工程须要通讯怎么办?咱们在拆分商家库的时候,就面临这这样的问题,商品活动页和商品详情页,能够根据页面维度,去拆分红2个p工程,这两个页面都会用到同一个商品样式的item。如何让同层间商品活动页p工程和商品详情页p工程访问到商品样式item呢?在实际拆库的实践中,咱们逐渐的探索出三级工程结构。三级工程结构不只能够解决工程内p工程通讯的问题,并且能够保持架构的灵活性。

三级工程结构

三级工程结构,指的是工程→module→p工程的三级结构。咱们能够将任何一个很是复杂的业务工程内部划分红若干个独立单元的module工程,同时独立单元的module工程,咱们能够继续去划分它内部的独立p工程。由于module是具有编译时的代码隔离的,边界是不容易被打破的,它能够随时升级为一个工程。须要通讯的p工程依赖module的主目录,base目录,经过base目录实现通讯。工程和module具备编译上隔离代码的能力,p工程具备最小约束代码边界的能力,这样的设计能够使得工程内边界清晰,向下依赖。设计如图所示:

三级工程结构的最大好处就是,每级均可按照须要灵活的升级或降级,这样灵活的升降级,能够随时适应团队组织结构的变化,保持架构拆分合并的灵活性,从而动态的知足了康威定理。

工程化建设

平台化一个直观的结果就是产生了不少子库,如何对这些子库进行有效的工程化管理将是一个影响团队研发效率的问题。目前为止,咱们从如下两个方面作了改进。

一键切源码

主工程集成业务库时,有两种依赖模式:aar依赖和源码依赖。默认是aar依赖,可是在平时开发时,常常须要从aar依赖切换到源码依赖,好比新需求开发、bugfix及排查问题等。正常状况咱们须要在各个工程的build.

中将compile aar手动改成compile project,若是业务库也须要依赖平台库源码,也要作相似的操做。以下图所示:

这样手动操做会带来两个问题:

  1. build.gradle改动频繁,若是开发人员不当心push上去了,将会形成各类冲突。
  2. 当业务库愈来愈多时,这种改动的成本就愈来愈大了。

鉴于这种需求具有通用性,咱们开发了一个Gradle插件,经过主工程的一个配置文件(被git ignore),可一键切换至源码依赖。例如须要源码依赖商家库,那么只须要在主工程中将该库的源码依赖开关打开便可。商家库还依赖平台库,默认也是aar依赖,若是想改为源码依赖,也只需把开关打开便可。

一键打包

业务库增多之后,构建流程也变得复杂起来,咱们交付的产物有两种:外卖App的apk和外卖频道的aar。外卖App的状况会简单一些,在Jenkins上关联各个业务库指定分支的源码,直接打包便可。而外卖频道的状况则比较复杂,由于受到美团平台的一些限制,频道打包不能直接关联各个业务库的源码,只能依赖aar。按照传统作法,须要逐个打业务库的aar,而后统一在频道工程中集成,最后再打频道aar,这样效率实在过低。为此,咱们改进了频道的打包流程。以下图所示:

先打平台库aar,打完后自动提PR到各个业务库去修改平台库的版本号,接着再逐个触发业务库去打aar,业务库打完aar以后再自动提PR到频道主库去修改业务库的版本号,等所有业务库aar打完后最后再自动触发打频道主库的aar,至此一键打包完毕。

平台化总结

从搜索库拆分的第一次尝试算起,外卖Android客户端在架构上的持续探索和实践已经经历了2年多的时间。起初为了解决两端代码复用的问题,咱们尝试过自上而下的强行拆分和复用,但很快就暴露出层次混乱、边界模糊带来的问题,而且认识到若是不能提供两端差别化的解决方案,代码复用是很难持续的。后来咱们又尝试过运用设计模式约束边界,先实现解耦再进行复用,但在推广落地过程当中认识到复杂的设计很难快速推动下去。

在平台化开始的时候,团队已经造成了设计简单、边界清晰的架构理念。咱们将总体结构划分为宿主层、业务层、平台层,并严格约束层次间的依赖关系。在业务模块拆分的过程当中,咱们借鉴微信的工程结构方案,按照三级工程结构划分业务边界,实现灵活的代码隔离,并下降了后续模块迁出和迁入成本,使得架构动态知足康威定律。

在两端代码复用的问题上,咱们认识到要实现可持续的代码复用,必须自下向上的逐步统一两端底层的基础依赖,同时又能容易的支持两端上层业务的差别化处理。使用Flavor管理两端的差别代码,尽可能减小向上依赖,在具体实施时应用以前积累的解耦设计的经验,从而知足了架构的可伸缩性。

没有一个方案能得到每一个人的赞同。在平台化的实施过程当中,团队成员屡次对方案选型发生过针锋相对的讨论。这时咱们会抛开技术方案,回到问题自己,去从新审视业务的痛点,列出要解决的问题,再回过头来看哪个方案可以解决问题。虽然咱们并不经常这么作,但某些时刻也会强制决策和实施,遇到问题再复盘和调整。

任何一种设计理念都有其适用场景。咱们在不断关注业内一些优秀的架构和设计理念,以及公司内部美团App、点评App团队的平台化实践经验,学习和借鉴了许多优秀的设计思想,但也因为盲目滥用踩过很多坑。咱们认识到架构的选择正如其余技术问题同样,应该是面向问题的,而不是面向技术自己。架构的演进必须在理论和实践中交替前行,脱离了其中一个谈论架构,都将是个悲剧。

展望

平台化以后,各业务团队的协做关系和开发流程都发生了很大转变。在如何提高平台支持能力,如何保持架构的稳定性,如何使得各业务进一步解耦等问题上,咱们又将面对新的问题和挑战。其中有三个问题是亟待咱们解决的:

  1. 要确保在长期的业务迭代中架构不被破坏,除了流程规范以外,还须要在本地编译、远程提交、代码合并、打包提测等各个阶段创建更健全的检查工具来约束,而目前这些工具链还并不完善。
  2. 插件化架构是平台型App集成的最好方式,不只使得子业务具有动态发布的能力,还能够解决使人头疼的编译速度问题。目前美团平台已经在部分业务上较好的实现了插件化集成,外卖正在跟进。
  3. 统一页面级开发的标准化框架,能够解决代码的可维护性、可测试性,和更细粒度的可复用性,而且有利于各类自动化方案的实施。目前咱们正在部分业务尝试,后续会持续推动。

参考资料

  1. MVP + Clean Architecture
  2. 58同城沈剑:好的架构源于不停地衍变,而非设计
  3. 每一个架构师都应该研究下康威定理
  4. 微服务架构的理论基础 - 康威定律
  5. 架构的本质是管理复杂性,微服务自己也是架构演化的结果
  6. 微信Android模块化架构重构实践
  7. 配置构建变体
  8. 美团App 插件化实践

 

美团外卖Android Lint代码检查实践

概述

Lint是Google提供的Android静态代码检查工具,能够扫描并发现代码中潜在的问题,提醒开发人员及早修正,提升代码质量。除了Android原生提供的几百个Lint规则,还能够开发自定义Lint规则以知足实际须要。

为何要使用Lint

在美团外卖Android App的迭代过程当中,线上问题频繁发生。开发时很容易写出一些问题代码,例如Serializable的使用:实现了Serializable接口的类,若是其成员变量引用的对象没有实现Serializable接口,序列化时就会Crash。咱们对一些常见问题的缘由和解决方法作分析总结,并在开发人员组内或跟测试人员一块儿分享交流,帮助相关人员主动避免这些问题。

为了进一步减小问题发生,咱们逐步完善了一些规范,包括制定代码规范,增强代码Review,完善测试流程等。但这些措施仍然存在各类不足,包括代码规范难以实施,沟通成本高,特别是开发人员变更频繁致使反复沟通等,所以其效果有限,类似问题仍然不时发生。另外一方面,愈来愈多的总结、规范文档,对于组内新人也产生了不小的学习压力。

有没有办法从技术角度减小或减轻上述问题呢?

咱们调研发现,静态代码检查是一个很好的思路。静态代码检查框架有不少种,例如FindBugs、PMD、Coverity,主要用于检查Java源文件或class文件;再例如Checkstyle,主要关注代码风格;但咱们最终选择从Lint框架入手,由于它有诸多优点:

  1. 功能强大,Lint支持Java源文件、class文件、资源文件、Gradle等文件的检查。
  2. 扩展性强,支持开发自定义Lint规则。
  3. 配套工具完善,Android Studio、Android Gradle插件原生支持Lint工具。
  4. Lint专为Android设计,原生提供了几百个实用的Android相关检查规则。
  5. 有Google官方的支持,会和Android开发工具一块儿升级完善。

在对Lint进行了充分的技术调研后,咱们根据实际遇到的问题,又作了一些更深刻的思考,包括应该用Lint解决哪些问题,怎么样更好的推广实施等,逐步造成了一套较为全面有效的方案。

Lint API简介

为了方便后文的理解,咱们先简单看一下Lint提供的主要API。

主要API

Lint规则经过调用Lint API实现,其中最主要的几个API以下:

  1. Issue:表示一个Lint规则。
  2. Detector:用于检测并报告代码中的Issue,每一个Issue都要指定Detector。
  3. Scope:声明Detector要扫描的代码范围,例如JAVA_FILE_SCOPECLASS_FILE_SCOPERESOURCE_FILE_SCOPEGRADLE_SCOPE等,一个Issue可包含一到多个Scope。
  4. Scanner:用于扫描并发现代码中的Issue,每一个Detector能够实现一到多个Scanner。
  5. IssueRegistry:Lint规则加载的入口,提供要检查的Issue列表。

举例来讲,原生的ShowToast就是一个Issue,该规则检查调用Toast.makeText()方法后是否漏掉了Toast.show()的调用。其Detector为ToastDetector,要检查的Scope为JAVA_FILE_SCOPE,ToastDetector实现了JavaPsiScanner,示意代码以下:

public class ToastDetector extends Detector implements JavaPsiScanner { public static final Issue ISSUE = Issue.create( "ShowToast", "Toast created but not shown", "...", Category.CORRECTNESS, 6, Severity.WARNING, new Implementation( ToastDetector.class, Scope.JAVA_FILE_SCOPE)); // ... } 

IssueRegistry的示意代码以下:

public class MyIssueRegistry extends IssueRegistry { @Override public List<Issue> getIssues() { return Arrays.asList( ToastDetector.ISSUE, LogDetector.ISSUE, // ... ); } } 

Scanner

Lint开发过程当中最主要的工做就是实现Scanner。Lint中包括多种类型的Scanner以下,其中最经常使用的是扫描Java源文件和XML文件的Scanner。

  • JavaScanner / JavaPsiScanner / UastScanner:扫描Java源文件
  • XmlScanner:扫描XML文件
  • ClassScanner:扫描class文件
  • BinaryResourceScanner:扫描二进制资源文件
  • ResourceFolderScanner:扫描资源文件夹
  • GradleScanner:扫描Gradle脚本
  • OtherFileScanner:扫描其余类型文件

值得注意的是,扫描Java源文件的Scanner前后经历了三个版本。

  1. 最开始使用的是JavaScanner,Lint经过Lombok库将Java源码解析成AST(抽象语法树),而后由JavaScanner扫描。
  2. 在Android Studio 2.2和lint-api 25.2.0版本中,Lint工具将Lombok AST替换为PSI,同时弃用JavaScanner,推荐使用JavaPsiScanner。 PSI是JetBrains在IDEA中解析Java源码生成语法树后提供的API。相比以前的Lombok AST,PSI能够支持Java 1.八、类型解析等。使用JavaPsiScanner实现的自定义Lint规则,能够被加载到Android Studio 2.2+版本中,在编写Android代码时实时执行。
  3. 在Android Studio 3.0和lint-api 25.4.0版本中,Lint工具将PSI替换为UAST,同时推荐使用新的UastScanner。 UAST是JetBrains在IDEA新版本中用于替换PSI的API。UAST更加语言无关,除了支持Java,还能够支持Kotlin。

本文目前仍然基于PsiJavaScanner作介绍。根据UastScanner源码中的注释,能够很容易的从PsiJavaScanner迁移到UastScanner。

Lint规则

咱们须要用Lint检查代码中的哪些问题呢?

开发过程当中,咱们比较关注App的Crash、Bug率等指标。经过长期的整理总结发现,有很多发生频率很高的代码问题,其原理和解决方案都很明确,可是在写代码时却很容易遗漏且难以发现;而Lint刚好很容易检查出这些问题。

Crash预防

Crash率是App最重要的指标之一,避免Crash也一直是开发过程当中比较头疼的一个问题,Lint能够很好的检查出一些潜在的Crash。例如:

  • 原生的NewApi,用于检查代码中是否调用了Android高版本才提供的API。在低版本设备中调用高版本API会致使Crash。
  • 自定义的SerializableCheck。实现了Serializable接口的类,若是其成员变量引用的对象没有实现Serializable接口,序列化时就会Crash。咱们制定了一条代码规范,要求实现了Serializable接口的类,其成员变量(包括从父类继承的)所声明的类型都要实现Serializable接口。
  • 自定义的ParseColorCheck。调用Color.parseColor()方法解析后台下发的颜色时,颜色字符串格式不正确会致使IllegalArgumentException,咱们要求调用这个方法时必须处理该异常。

Bug预防

有些Bug能够经过Lint检查来预防。例如:

  • SpUsage:要求全部SharedPrefrence读写操做使用基础工具类,工具类中会作各类异常处理;同时定义SPConstants常量类,全部SP的Key都要在这个类定义,避免在代码中分散定义的Key之间冲突。
  • ImageViewUsage:检查ImageView有没有设置ScaleType,加载时有没有设置Placeholder。
  • TodoCheck:检查代码中是否还有TODO没完成。例如开发时可能会在代码中写一些假数据,但最终上线时要确保删除这些代码。这种检查项比较特殊,一般在开发完成后提测阶段才检查。

性能/安全问题

一些性能、安全相关问题能够使用Lint分析。例如: - ThreadConstruction:禁止直接使用new Thread()建立线程(线程池除外),而须要使用统一的工具类在公用线程池执行后台操做。 - LogUsage:禁止直接使用android.util.Log,必须使用统一工具类。工具类中能够控制Release包不输出Log,提升性能,也避免发生安全问题。

代码规范

除了代码风格方面的约束,代码规范更多的是用于减小或防止发生Bug、Crash、性能、安全等问题。不少问题在技术上难以直接检查,咱们经过封装统一的基础库、制定代码规范的方式间接解决,而Lint检查则用于减小组内沟通成本、新人学习成本,并确保代码规范的落实。例如:

  • 前面提到的SpUsage、ThreadConstruction、LogUsage等。
  • ResourceNaming:资源文件命名规范,防止不一样模块之间的资源文件名冲突。

代码检查的实施

当检查出代码问题时,如何提醒开发者及时修正呢?

早期咱们将静态代码检查配置在Jenkins上,打包发布AAR/APK时,检查代码中的问题并生成报告。后来发现虽然静态代码检查能找出来很多问题,可是不多有人主动去看报告,特别是报告中还有过多可有可无的、优先级很低的问题(例如过于严格的代码风格约束)。

所以,一方面要肯定检查哪些问题,另外一方面,什么时候、经过什么样的技术手段来执行代码检查也很重要。咱们结合技术实现,对此作了更多思考,肯定了静态代码检查实施过程当中的主要目标:

  1. 重点关注高优先级问题,屏蔽低优先级问题。正如前面所说,若是代码检查报告中夹杂了大量可有可无的问题,反而影响了关键问题的发现。
  2. 高优问题的解决,要有必定的强制性。当检查发现高优先级的代码问题时,给开发者明确直接的报错,并经过技术手段约束,强制要求开发者修复。
  3. 某些问题尽量作到在第一时间发现,从而减小风险或损失。有些问题发现的越早越好,例如业务功能开发中使用了Android高版本API,经过Lint原生的NewApi能够检查出来。若是在开发期间发现,当时就能够考虑其余技术方案,实现困难时能够及时和产品、设计人员沟通;而若是到提代码、提测,甚至发版、上线时才发现,可能为时已晚。

优先级定义

每一个Lint规则均可以配置Sevirity(优先级),包括Fatal、Error、Warning、Information等,咱们主要使用Error和Warning,以下。

  • Error级别:明确须要解决的问题,包括Crash、明确的Bug、严重性能问题、不符合代码规范等,必须修复。
  • Warning级别:包括代码编写建议、可能存在的Bug、一些性能优化等,适当放松要求。

执行时机

Lint检查能够在多个阶段执行,包括在本地手动检查、编码实时检查、编译时检查、commit检查,以及在CI系统中提Pull Request时检查、打包发版时检查等,下面分别介绍。

手动执行

在Android Studio中,自定义Lint能够经过Inspections功能(Analyze - Inspect Code)手动运行。

在Gradle命令行环境下,可直接用./gradlew lint执行Lint检查。

手动执行简单易用,但缺少强制性,容易被开发者遗漏。

编码阶段实时检查

编码时检查即在Android Studio中写代码时在代码窗口实时报错。其好处很明显,开发者能够第一时间发现代码问题。但受限于Android Studio对自定义Lint的支持不完善,开发人员IDE的配置不一样,须要开发者主动关注报错并修复,这种方式不能彻底保证效果。

IDEA提供了Inspections功能和相应的API来实现代码检查,Android原生Lint就是经过Inspections集成到了Android Studio中。对于自定义Lint规则,官方彷佛没有给出明确说明,但实际研究发现,在Android Studio 2.2+版本和基于JavaPsiScanner开发的条件下(或Android Studio 3.0+和JavaPsiScanner/UastScanner),IDE会尝试加载并实时执行自定义Lint规则。

技术细节:

  1. 在Android Studio 2.x版本中,菜单Preferences - Editor - Inspections - Android - Lint - Correctness - Error from Custom Lint Check(avaliable for Analyze|Inspect Code)中指出,自定义Lint只支持命令行或手动运行,不支持实时检查。

    Error from Custom Rule When custom (third-party) lint rules are integrated in the IDE, they are not available as native IDE inspections, so the explanation text (which must be statically registered by a plugin) is not available. As a workaround, run the lint target in Gradle instead; the HTML report will include full explanations.

  2. 在Android Studio 3.x版本中,打开Android工程源码后,IDE会加载工程中的自定义Lint规则,在设置菜单的Inspections列表里能够查看,和原生Lint效果相同(Android Studio会在打开源文件时触发对该文件的代码检查)。

  3. 分析自定义Lint的IssueRegistry.getIssues()方法调用堆栈,能够看到Android Studio环境下,是由org.jetbrains.android.inspections.lint.AndroidLintExternalAnnotator调用LintDriver加载执行自定义Lint规则。

    参考代码: https://github.com/JetBrains/android/tree/master/android/src/org/jetbrains/android/inspections/lint

在Android Studio中的实际效果如图:

本地编译时自动检查

配置Gradle脚本可实现编译Android工程时执行Lint检查。好处是既能够尽早发现问题,又能够有强制性;缺点是对编译速度有必定的影响。

编译Android工程执行的是assemble任务,让assemble依赖lint任务,便可在编译时执行Lint检查;同时配置LintOptions,发现Error级别问题时中断编译。

在Android Application工程(APK)中配置以下,Android Library工程(AAR)把applicationVariants换成libraryVariants便可。

android.applicationVariants.all { variant ->
    variant.outputs.each { output ->
        def lintTask = tasks["lint${variant.name.capitalize()}"]
        output.assemble.dependsOn lintTask
    }
}

LintOptions的配置:

android.lintOptions {
	abortOnError true
}

本地commit时检查

利用git pre-commit hook,能够在本地commit代码前执行Lint检查,检查不经过则没法提交代码。这种方式的优点在于不影响开发时的编译速度,但发现问题相对滞后。

技术实现方面,能够编写Gradle脚本,在每次同步工程时自动将hook脚本从工程拷贝到.git/hooks/文件夹下。

提代码时CI检查

做为代码提交流程规范的一部分,发Pull Request提代码时用CI系统检查Lint问题是一个常见、可行、有效的思路。可配置CI检查经过后代码才能被合并。

CI系统经常使用Jenkins,若是使用Stash作代码管理,能够在Stash上配置Pull Request Notifier for Stash插件,或在Jenkins上配置Stash Pull Request Builder插件,实现发Pull Request时触发Jenkins执行Lint检查的Job。

在本地编译和CI系统中作代码检查,均可以经过执行Gradle的Lint任务实现。能够在CI环境下给Gradle传递一个StartParameter,Gradle脚本中若是读取到这个参数,则配置LintOptions检查全部Lint问题;不然在本地编译环境下只检查部分高优先级Lint问题,减小对本地编译速度的影响。

Lint生成报告的效果如图所示:

打包发布时检查

即便每次提代码时用CI系统执行Lint检查,仍然不能保证全部人的代码合并后必定没有问题;另外对于一些特殊的Lint规则,例如前面提到的TodoCheck,还但愿在更晚的时候检查。

因而在CI系统打包发布APK/AAR用于测试或发版时,还须要对全部代码再作一次Lint检查。

最终肯定的检查时机

综合考虑多种检查方式的优缺点以及咱们的目标,最终肯定结合如下几种方式作代码检查:

  1. 编码阶段IDE实时检查,第一时间发现问题。
  2. 本地编译时,及时检查高优先级问题,检查经过才能编译。
  3. 提代码时,CI检查全部问题,检查经过才能合代码。
  4. 打包阶段,完整检查工程,确保万无一失。

配置文件支持

为了方便代码管理,咱们给自定义Lint建立了一个独立的工程,该工程打包生成一个AAR发布到Maven仓库,而被检查的Android工程依赖这个AAR(具体开发过程能够参考文章末尾连接)。

自定义Lint虽然在独立工程中,但和被检查的Android工程中的代码规范、基础组件等存在较多耦合。

例如咱们使用正则表达式检查Android工程的资源文件命名规范,每次业务逻辑变更要新增资源文件前缀时,都要修改Lint工程,发布新的AAR,再更新到Android工程中,很是繁琐。另外一方面,咱们的Lint工程除了在外卖C端Android工程中使用,也但愿能直接用在其余端的其余Android工程中,而不一样工程之间存在差别。

因而咱们尝试使用配置文件来解决这一问题。以检查Log使用的LogUsage为例,不一样工程封装了不一样的Log工具类,报错时提示信息也应该不同。定义配置文件名为custom-lint-config.json,放在被检查Android工程的模块目录下。在Android工程A中的配置文件是:

{
	"log-usage-message": "请勿使用android.util.Log,建议使用LogUtils工具类"
}

而Android工程B的配置文件是:

{
	"log-usage-message": "请勿使用android.util.Log,建议使用Logger工具类"
}

从Lint的Context对象可获取被检查工程目录从而读取配置文件,关键代码以下:

import com.android.tools.lint.detector.api.Context;

public final class LintConfig { private LintConfig(Context context) { File projectDir = context.getProject().getDir(); File configFile = new File(projectDir, "custom-lint-config.json"); if (configFile.exists() && configFile.isFile()) { // 读取配置文件... } } } 

配置文件的读取,能够在Detector的beforeCheckProject、beforeCheckLibraryProject回调方法中进行。LogUsage中检查到错误时,根据配置文件定义的信息报错。

public class LogUsageDetector extends Detector implements Detector.JavaPsiScanner { // ... private LintConfig mLintConfig; @Override public void beforeCheckProject(@NonNull Context context) { // 读取配置 mLintConfig = new LintConfig(context); } @Override public void beforeCheckLibraryProject(@NonNull Context context) { // 读取配置 mLintConfig = new LintConfig(context); } @Override public List<String> getApplicableMethodNames() { return Arrays.asList("v", "d", "i", "w", "e", "wtf"); } @Override public void visitMethod(JavaContext context, JavaElementVisitor visitor, PsiMethodCallExpression call, PsiMethod method) { if (context.getEvaluator().isMemberInClass(method, "android.util.Log")) { // 从配置文件获取Message String msg = mLintConfig.getConfig("log-usage-message"); context.report(ISSUE, call, context.getLocation(call.getMethodExpression()), msg); } } } 

模板Lint规则

Lint规则开发过程当中,咱们发现了一系列类似的需求:封装了基础工具类,但愿你们都用起来;某个方法很容易抛出RuntimeException,有必要作处理,但Java语法上RuntimeException并不强制要求处理从而常常遗漏……

这些类似的需求,每次在Lint工程中开发一样会很繁琐。咱们尝试实现了几个模板,能够直接在Android工程中经过配置文件配置Lint规则。

以下为一个配置文件示例:

{
  "lint-rules": {
    "deprecated-api": [{
      "method-regex": "android\\.content\\.Intent\\.get(IntExtra|StringExtra|BooleanExtra|LongExtra|LongArrayExtra|StringArrayListExtra|SerializableExtra|ParcelableArrayListExtra).*",
      "message": "避免直接调用Intent.getXx()方法,特殊机型可能发生Crash,建议使用IntentUtils", "severity": "error" }, { "field": "java.lang.System.out", "message": "请勿直接使用System.out,应该使用LogUtils", "severity": "error" }, { "construction": "java.lang.Thread", "message": "避免单首创建Thread执行后台任务,存在性能问题,建议使用AsyncTask", "severity": "warning" }, { "super-class": "android.widget.BaseAdapter", "message": "避免直接使用BaseAdapter,应该使用统一封装的BaseListAdapter", "severity": "warning" }], "handle-exception": [{ "method": "android.graphics.Color.parseColor", "exception": "java.lang.IllegalArgumentException", "message": "Color.parseColor须要加try-catch处理IllegalArgumentException异常", "severity": "error" }] } } 

示例配置中定义了两种类型的模板规则:

  • DeprecatedApi:禁止直接调用指定API
  • HandleException:调用指定API时,须要加try-catch处理指定类型的异常

问题API的匹配,包括方法调用(method)、成员变量引用(field)、构造函数(construction)、继承(super-class)等类型;匹配字符串支持glob语法或正则表达式(和lint.xml中ignore的配置语法一致)。

实现方面,主要是遍历Java语法树中特定类型的节点并转换成完整字符串(例如方法调用android.content.Intent.getIntExtra),而后检查是否有模板规则与其匹配。匹配成功后,DeprecatedApi规则直接输出message报错;HandleException规则会检查匹配到的节点是否处理了特定Exception(或Exception的父类),没有处理则报错。

按Git版本检查新增文件

随着Lint新规则的不断开发,咱们又遇到了一个问题。Android工程中存在大量历史代码,不符合新增Lint规则的要求,但也没有致使明显问题,这时接入新增Lint规则要求修改全部历史代码,成本较高并且有必定风险。例如新增代码规范,要求使用统一的线程工具类而不容许直接用Handler以免内存泄露等。

咱们尝试了一个折中的方案:只检查指定git commit以后新增的文件。在配置文件中添加配置项,给Lint规则配置git-base属性,其值为commit ID,只检查这次commit以后新增的文件。

实现方面,执行git rev-parse --show-toplevel命令获取git工程根目录的路径;执行git ls-tree --full-tree --full-name --name-only -r <commit-id>命令获取指定commit时已有文件列表(相对git根目录的路径)。在Scanner回调方法中经过Context.getLocation(node).getFile()获取节点所在文件,结合git文件列表判断是否须要检查这个节点。须要注意的是,代码量较大时要考虑Lint检查对电脑的性能消耗。

总结

通过一段时间的实践发现,Lint静态代码检查在解决特定问题时的效果很是好,例如发现一些语言或API层面比较明确的低级错误、帮助进行代码规范的约束。使用Lint前,很多这类问题刚好对开发人员来讲又很容易遗漏(例如原生的NewApi检查、自定义的SerializableCheck);相同问题反复出现;代码规范的执行,特别是有新人参与开发时,须要很高的学习和沟通成本,还常常出现新人提交代码时因为没有遵照代码规范反复被要求修改。而使用Lint后,这些问题都能在第一时间获得解决,节省了大量的人力,提升了代码质量和开发效率,也提升了App的使用体验。

参考资料与扩展阅读

Lint和Gradle相关技术细节还能够阅读我的博客:

 

Android动态日志系统Holmes

背景

美团是全球领先的一站式生活服务平台,为6亿多消费者和超过450万优质商户提供链接线上线下的电子商务网络。美团的业务覆盖了超过200个丰富品类和2800个城区县网络,在餐饮、外卖、酒店旅游、丽人、家庭、休闲娱乐等领域具备领先的市场地位。平台大,责任也大。在移动端,如何快速定位并解决线上问题提升用户体验给咱们带来了极大挑战。线上偶尔会发生某一个页面打不开、新活动抢单按钮点击没响应、登陆不了、不能下单等现象,因为Android碎片化、网络环境、机型ROM、操做系统版本、本地环境复杂多样,这些个性化的使用场景很难在本地复现,加上问题反馈的时候描述的每每都比较模糊,快速定位并解决问题难度不小。为此,咱们开发了动态日志系统Holmes,但愿它能像大侦探福尔摩斯那样帮咱们顺着线上bug的蛛丝马迹,发现背后真相。

现有的解决办法

  • 发临时包用户安装
  • QA尝试去复现问题
  • 在线debug调试工具
  • 预先手动埋点回捞

现有办法的弊端

  • 临时发包:用户配合过程繁琐,并且解决问题时间很长
  • QA复现:尝试已有机型发现个性化场景很难复现
  • 在线debug:网络环境不稳定,代码混淆调试成本很高,占用用户过多时间用户难以接受
  • 手动埋点:覆盖范围有限,没法提早预知,并且因为业务量大、多地区协做开发、业务类型多等形成很难统一埋点方案,而且在排查问题时大量的手动埋点会产生不少冗余的上报数据,寻找一条有用的埋点信息犹如大海捞针

目标诉求

  • 快速拿到线上日志
  • 不须要大量埋点甚至不埋点
  • 精准的问题现场日志

实现

针对难定位的线上问题,动态日志提供了一套快速定位问题的方案。预先在用户手机自动产生方法执行的日志信息,当须要排查用户问题时,经过信令下发精准回捞用户日志,再现用户操做路径;动态日志系统也支持动态下发代码,从而实现动态分析运行时对象快照、动态增长埋点等功能,可以分析复杂使用场景下的用户问题。

自动埋点

自动埋点是线上App自动产生日志,怎么样自动产生日志呢?咱们对方法进行了插桩来记录方法执行路径(调用堆栈),在方法的开头插入一段桩代码,当方法运行的时候就会记录方法签名、进程、线程、时间等造成一条完整的执行信息(这里咱们叫TraceLog),将TraceLog存入DB等待信令下发回捞数据。

 public void onCreate(Bundle bundle) { //插桩代码 if (Holmes.isEnable(....)) { Holmes.invoke(....); return; } super.onCreate(bundle); setContentView(R.layout.main); } 

历史数据

Tracelog造成的是代码的历史执行路径,一旦线上出现问题就能够回捞用户历史数据来排查问题,而且Tracelog有如下几个优势:

  1. Tracelog是自动产生的无需开发者手动埋点
  2. 插桩覆盖了全部的业务代码,并且这里Tracelog不受Proguard内联方法的限制,插桩在Proguard以前因此方法被内联以后桩代码也会被内联,这样就会记录下来对照原始代码的完整执行路径信息
  3. 回捞日志能够基于一个方法为中心点向前或者向后采集日志(例如:点击下单按钮无响应只须要回捞点击下单按钮事件以后的代码执行路径来分析问题),这样能够避免上报一堆无用日志,减小咱们排查问题的时间和下降复杂度

Tracelog工做的流程

方法运行产生方法调用日志首先会通过checker进行检测,checker包含线程检测和方法检测(减小信息干扰),线程检测主要过滤相似于定时任务这种一直在不断的产生日志的线程,方法检测会在必定时间内检测方法调用的频率,过滤掉频繁调用的方法,方法若是不会被过滤就会进行异步处理,其次向对象池获取一个Tracelog对象,Tracelog对象进入生产队列组装时间、线程、序列号等信息,完成后进入消费队列,最后消费队列到达固定数量以后批量处理存入DB。

Tracelog数据展现

日志回捞到Trace平台上按时间顺序排列展现结果:

问题总结

咱们的平台部署实施了几个版本,总结了不少的案例。通过实战的考验发现多数的场景下用户回捞Tracelog分析问题只能把问题的范围不断的缩小,可是不少的问题肯定了是某一个方法的异常,这个时候是须要知道方法的执行信息好比:入参、当前对象字段、返回值等信息来肯定代码的执行逻辑,只有Tracelog在这里的感受就比如只差临门一脚了,怎么才能获取方法运行时产生的内存快照呢?这正是体现动态日志的动态性能力。

动态下发

对目标用户下发信令,动态执行一段代码并将结果上报,咱们利用Lua脚本在方法运行的时候去获取对象的快照信息。为何选择Lua?Lua运行时库很是小而且能够调用Java代码并且语言精简易懂。动态执行Lua有三个重要的时机:当即执行、方法前执行、方法后执行。

  • 当即执行:接受到信令以后就会立马去执行并上报结果
  • 方法前执行:在某一个方法执行以前执行Lua脚本,动态获取入参、对象字段等信息
  • 方法后执行:在某一个方法执行以后执行Lua脚本,动态获取返回值、入参变化、对象字段变化等信息

在方法后执行Lua脚本遇到了一些问题,咱们只在方法前插桩,若是在方法后也插桩这样能解决在方法后执行的问题,可是这样增长代码体积和影响proguard内联方法数,如何解决这个问题以下:

咱们利用反射执行当前方法,当进入方法前面的插桩代码不会直接执行本方法的方法体会在桩代码里经过反射调用本身,这样就作到了一个动态AOP的功能就能够在方法以后执行脚本,一样这种方法也存在一个问题,就是会出现死循环,解决这个问题的办法只须要在执行反射的时候标记是反射调用进来的就能够避免死循环的问题。

咱们还能够让脚本作些什么呢?除了能够获取对象的快照信息外,还增长了DB查询、上报普通文本、ShardPreferences查询、获取Context对象、查询权限、追加埋点到本地、上传文件等综合能力,并且Lua脚本的功能远不只如此,能够利用Lua脚本调用Java的方法来模拟代码逻辑,从而实现更深层次的动态能力。

动态下发数据展现

对象数据

对象数据

 

权限信息

权限信息

 

DB数据

DB数据

 

技术挑战

动态日志在开发的过程中遇到了不少的技术难点,咱们在实施方案的时候遇到不少的问题,下面来回顾一下问题及解决方案。

数据量大的问题

  • 主线程卡顿

    • 1. 因为同时会有多个线程产生日志,因此要考虑到线程同步安全的问题。使用synchronized或者lock能够保证同步安全问题,可是同时也带来多线程之间锁互斥的问题,形成主线程等待并卡顿,这里使用CAS技术方案来实现自定义数据结构,保证线程同步安全的状况下并解决了多线程之间锁互斥的问题。
    • 2. 因为数据产生太多,因此在存储DB的时候就会产生大量的IO,致使CPU占用时间过长从而影响其余线程使用CPU的时间。针对这个问题,首先是采起线程过滤和方法过滤来减小产生无用的日志,而且下降处理线程的级别不与主线程争抢CPU时间,而后对数据进行批量处理来减小IO的频率,并在数据库操做上将原来的Delete+insert的操做改成update+insert。Tracelog固定存储30万条数据(大约美团App使用6次以上的记录),若是满30万就删除早期的一部分数据再写入新的数据。操做越久,delete操做越多,CPU资源占比越大。通过数据库操做的实际对比发现,直接改成满30万以后使用update来更新数据效率会更高一些(这里就不作太多的详细说明)。咱们的优化成果从起初的CPU占比40%多下降到了20%左右再降到10%之内,这是在中低端的机器上测试的结果。
  • 建立对象过多致使频繁GC

    • 日志产生就会生成一个Tracelog对象,大量的日志会形成频繁的GC,针对这个问题咱们使用了对象池来使对象复用,从而减小建立对象减低GC频率,对象池是相似于android.os.Message.obtain()的工做原理。
  • 干扰日志太多影响分析问题

    • 咱们已通过滤掉了大部分的干扰日志,但仍是会有一些代码执行比较频繁的方法会产生干扰日志。例如:自定义View库、日志类型的库、监控类型的库等,这些方法的日志会影响咱们DB的存储空间,形成保留不了太多的正常方法执行路径,这种状况下颇有可能会出现开发这关心的日志其实已经被冲掉了。怎么解决这个问题那?在插桩的时候可以让开发者配置一些过滤或者识别的规则来认定是否要处理这个方法,在插桩的方法上增长一个二进制的参数,而后根据配置的规则会在相应的位上设置成0或者1,方法执行的时候只须要一个异或操做就能知道是否须要记录这个方法,这样增长的识别判断几乎对原来的方法执行耗时不会产生任何影响,使用这种方案产生的日志就是开发者所指望的日志,通过几番测试以后咱们的日志也能保留住用户6次以上的完整行为,并且CPU的占用时间也下降到了5%之内。

性能影响

对每个方法进行插桩记录日志,会对代码会形成方法耗时的影响吗?最终咱们在中低端机型上分别测试了方法的耗时和CPU的使用占比。

  • 方法耗时影响的测试,100万次耗时平均值在55~65ms之间,方法执行一次的耗时就微乎其微了
  • CPU的耗时测试在5%之内,以下图所示:

  • 内存的使用测试在56kB左右,以下图:

对象快照

在方法运行时获取对象快照保留现场日志,提取对象快照就须要对一个对象进行深度clone(为了防止在尚未完整记录下来信息以前对象已经被改变,影响最终判断代码执行的结果),在Java中clone对象有如下几种方法:

  • 实现一个clone接口
  • 实现一个序列化接口
  • 使用Gson序列化

clone接口和序列化接口都有一样的一个问题,有可能这个对象没有实现相应的接口,这样是没法进行深度clone的,并且实现clone接口也作不到深度clone,Java序列化有IO问题执行效率很低。最后可能只有Gson序列化这个方法还可行,可是Gson也有不少的坑,若是一个对象中有和父类同样的字段,那么Gson在作序列的时候把父类的字段覆盖掉;若是两个对象有相互引用的场景,那么在Gson序列化的时候直接会死循环。

怎么解决以上的这些问题呢?最后咱们参照一些开源库的方案和Java系统的一些API,开发出了一个深度clone的库,再加上本身定义数据对象和使用Gson来解决对象快照的问题。深度clone实现主要利用了Java系统API,先建立出来一个目标对象的空壳对象,而后利用反射将原对象上的全部字段都复制到这个空壳对象上,最后这个空壳对象会造成跟原有对象彻底同样的东西,同时对Android增长的一些类型进行了特殊处理,在提升速度上对基本类型、集合、map等系统自带类型作了快速处理,clone完成的对象直接进行快照处理。

总结

动态日志对业务开发零成本,对用户使用无打扰。在排查线上问题时,方法执行路径可能直接就会反映出问题的缘由,至少也能缩小问题代码的范围,最终锁定到某一个方法,这时再使用动态下发Lua脚本,最终肯定问题代码的位置。动态日志的动态下发功能也能够作为一种基础的能力,提供给其余须要动态执行代码或动态获取数据的基础库,例如:遇到一些难解决的崩溃场景,除了正常的栈信息外,同时也能够根据不一样的崩溃类型,动态采集一些其余的辅助信息来帮助排查问题。

 

Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus

背景

对于Android系统来讲,消息传递是最基本的组件,每个App内的不一样页面,不一样组件都在进行消息传递。消息传递既能够用于Android四大组件之间的通讯,也可用于异步线程和主线程之间的通讯。对于Android开发者来讲,常用的消息传递方式有不少种,从最先使用的Handler、BroadcastReceiver、接口回调,到近几年流行的通讯总线类框架EventBus、RxBus。Android消息传递框架,总在不断的演进之中。

从EventBus提及

EventBus是一个Android事件发布/订阅框架,经过解耦发布者和订阅者简化Android事件传递。EventBus能够代替Android传统的Intent、Handler、Broadcast或接口回调,在Fragment、Activity、Service线程之间传递数据,执行方法。

EventBus最大的特色就是:简洁、解耦。在没有EventBus以前咱们一般用广播来实现监听,或者自定义接口函数回调,有的场景咱们也能够直接用Intent携带简单数据,或者在线程之间经过Handler处理消息传递。但不管是广播仍是Handler机制远远不能知足咱们高效的开发。EventBus简化了应用程序内各组件间、组件与后台线程间的通讯。EventBus一经推出,便受到广大开发者的推崇。

如今看来,EventBus给Android开发者世界带来了一种新的框架和思想,就是消息的发布和订阅。这种思想在其后不少框架中都获得了应用。

图片摘自EventBus GitHub主页

图片摘自EventBus GitHub主页

 

发布/订阅模式

订阅发布模式定义了一种“一对多”的依赖关系,让多个订阅者对象同时监听某一个主题对象。这个主题对象在自身状态变化时,会通知全部订阅者对象,使它们可以自动更新本身的状态。

RxBus的出现

RxBus不是一个库,而是一个文件,实现只有短短30行代码。RxBus自己不须要过多分析,它的强大彻底来自于它基于的RxJava技术。响应式编程(Reactive Programming)技术这几年特别火,RxJava是它在Java上的实做。RxJava天生就是发布/订阅模式,并且很容易处理线程切换。因此,RxBus凭借区区30行代码,就敢挑战EventBus江湖老大的地位。

RxBus原理

在RxJava中有个Subject类,它继承Observable类,同时实现了Observer接口,所以Subject能够同时担当订阅者和被订阅者的角色,咱们使用Subject的子类PublishSubject来建立一个Subject对象(PublishSubject只有被订阅后才会把接收到的事件马上发送给订阅者),在须要接收事件的地方,订阅该Subject对象,以后若是Subject对象接收到事件,则会发射给该订阅者,此时Subject对象充当被订阅者的角色。

完成了订阅,在须要发送事件的地方将事件发送给以前被订阅的Subject对象,则此时Subject对象做为订阅者接收事件,而后会马上将事件转发给订阅该Subject对象的订阅者,以便订阅者处理相应事件,到这里就完成了事件的发送与处理。

最后就是取消订阅的操做了,RxJava中,订阅操做会返回一个Subscription对象,以便在合适的时机取消订阅,防止内存泄漏,若是一个类产生多个Subscription对象,咱们能够用一个CompositeSubscription存储起来,以进行批量的取消订阅。

RxBus有不少实现,如:

AndroidKnife/RxBus(https://github.com/AndroidKnife/RxBus) Blankj/RxBus(https://github.com/Blankj/RxBus)

其实正如前面所说的,RxBus的原理是如此简单,咱们本身均可以写出一个RxBus的实现:

基于RxJava1的RxBus实现:

public final class RxBus { private final Subject<Object, Object> bus; private RxBus() { bus = new SerializedSubject<>(PublishSubject.create()); } private static class SingletonHolder { private static final RxBus defaultRxBus = new RxBus(); } public static RxBus getInstance() { return SingletonHolder.defaultRxBus; } /* * 发送 */ public void post(Object o) { bus.onNext(o); } /* * 是否有Observable订阅 */ public boolean hasObservable() { return bus.hasObservers(); } /* * 转换为特定类型的Obserbale */ public <T> Observable<T> toObservable(Class<T> type) { return bus.ofType(type); } } 

基于RxJava2的RxBus实现:

public final class RxBus2 { private final Subject<Object> bus; private RxBus2() { // toSerialized method made bus thread safe bus = PublishSubject.create().toSerialized(); } public static RxBus2 getInstance() { return Holder.BUS; } private static class Holder { private static final RxBus2 BUS = new RxBus2(); } public void post(Object obj) { bus.onNext(obj); } public <T> Observable<T> toObservable(Class<T> tClass) { return bus.ofType(tClass); } public Observable<Object> toObservable() { return bus; } public boolean hasObservers() { return bus.hasObservers(); } } 

引入LiveDataBus的想法

从LiveData谈起

LiveData是Android Architecture Components提出的框架。LiveData是一个能够被观察的数据持有类,它能够感知并遵循Activity、Fragment或Service等组件的生命周期。正是因为LiveData对组件生命周期可感知特色,所以能够作到仅在组件处于生命周期的激活状态时才更新UI数据。

LiveData须要一个观察者对象,通常是Observer类的具体实现。当观察者的生命周期处于STARTED或RESUMED状态时,LiveData会通知观察者数据变化;在观察者处于其余状态时,即便LiveData的数据变化了,也不会通知。

LiveData的优势

  • UI和实时数据保持一致 由于LiveData采用的是观察者模式,这样一来就能够在数据发生改变时得到通知,更新UI。
  • 避免内存泄漏 观察者被绑定到组件的生命周期上,当被绑定的组件销毁(destroy)时,观察者会马上自动清理自身的数据。
  • 不会再产生因为Activity处于stop状态而引发的崩溃

例如:当Activity处于后台状态时,是不会收到LiveData的任何事件的。

  • 不须要再解决生命周期带来的问题 LiveData能够感知被绑定的组件的生命周期,只有在活跃状态才会通知数据变化。
  • 实时数据刷新 当组件处于活跃状态或者从不活跃状态到活跃状态时老是能收到最新的数据。
  • 解决Configuration Change问题 在屏幕发生旋转或者被回收再次启动,马上就能收到最新的数据。

谈一谈Android Architecture Components

Android Architecture Components的核心是Lifecycle、LiveData、ViewModel 以及 Room,经过它能够很是优雅的让数据与界面进行交互,并作一些持久化的操做,高度解耦,自动管理生命周期,并且不用担忧内存泄漏的问题。

  • Room 一个强大的SQLite对象映射库。
  • ViewModel 一类对象,它用于为UI组件提供数据,在设备配置发生变动时依旧能够存活。
  • LiveData 一个可感知生命周期、可被观察的数据容器,它能够存储数据,还会在数据发生改变时进行提醒。
  • Lifecycle 包含LifeCycleOwer和LifecycleObserver,分别是生命周期全部者和生命周期感知者。

Android Architecture Components的特色

  • 数据驱动型编程 变化的永远是数据,界面无需更改。
  • 感知生命周期,防止内存泄漏。
  • 高度解耦 数据,界面高度分离。
  • 数据持久化 数据、ViewModel不与UI的生命周期挂钩,不会由于界面的重建而销毁。

重点:为何使用LiveData构建数据通讯总线LiveDataBus

使用LiveData的理由

  • LiveData具备的这种可观察性和生命周期感知的能力,使其很是适合做为Android通讯总线的基础构件。
  • 使用者不用显示调用反注册方法。

因为LiveData具备生命周期感知能力,因此LiveDataBus只须要调用注册回调方法,而不须要显示的调用反注册方法。这样带来的好处不只能够编写更少的代码,并且能够彻底杜绝其余通讯总线类框架(如EventBus、RxBus)忘记调用反注册所带来的内存泄漏的风险。

为何要用LiveDataBus替代EventBus和RxBus

  • LiveDataBus的实现及其简单 相对EventBus复杂的实现,LiveDataBus只须要一个类就能够实现。
  • LiveDataBus能够减少APK包的大小 因为LiveDataBus只依赖Android官方Android Architecture Components组件的LiveData,没有其余依赖,自己实现只有一个类。做为比较,EventBus JAR包大小为57kb,RxBus依赖RxJava和RxAndroid,其中RxJava2包大小2.2MB,RxJava1包大小1.1MB,RxAndroid包大小9kb。使用LiveDataBus能够大大减少APK包的大小。
  • LiveDataBus依赖方支持更好 LiveDataBus只依赖Android官方Android Architecture Components组件的LiveData,相比RxBus依赖的RxJava和RxAndroid,依赖方支持更好。
  • LiveDataBus具备生命周期感知 LiveDataBus具备生命周期感知,在Android系统中使用调用者不须要调用反注册,相比EventBus和RxBus使用更为方便,而且没有内存泄漏风险。

LiveDataBus的设计和架构

LiveDataBus的组成

  • 消息 消息能够是任何的Object,能够定义不一样类型的消息,如Boolean、String。也能够定义自定义类型的消息。
  • 消息通道 LiveData扮演了消息通道的角色,不一样的消息通道用不一样的名字区分,名字是String类型的,能够经过名字获取到一个LiveData消息通道。
  • 消息总线 消息总线经过单例实现,不一样的消息通道存放在一个HashMap中。
  • 订阅 订阅者经过getChannel获取消息通道,而后调用observe订阅这个通道的消息。
  • 发布 发布者经过getChannel获取消息通道,而后调用setValue或者postValue发布消息。

LiveDataBus原理图

LiveDataBus原理图

LiveDataBus原理图

 

LiveDataBus的实现

第一个实现:

public final class LiveDataBus { private final Map<String, MutableLiveData<Object>> bus; private LiveDataBus() { bus = new HashMap<>(); } private static class SingletonHolder { private static final LiveDataBus DATA_BUS = new LiveDataBus(); } public static LiveDataBus get() { return SingletonHolder.DATA_BUS; } public <T> MutableLiveData<T> getChannel(String target, Class<T> type) { if (!bus.containsKey(target)) { bus.put(target, new MutableLiveData<>()); } return (MutableLiveData<T>) bus.get(target); } public MutableLiveData<Object> getChannel(String target) { return getChannel(target, Object.class); } } 

短短二十行代码,就实现了一个通讯总线的所有功能,而且还具备生命周期感知功能,而且使用起来也及其简单:

注册订阅:

LiveDataBus.get().getChannel("key_test", Boolean.class)
        .observe(this, new Observer<Boolean>() {
            @Override
            public void onChanged(@Nullable Boolean aBoolean) { } }); 

发送消息:

LiveDataBus.get().getChannel("key_test").setValue(true);

咱们发送了一个名为”key_test”,值为true的事件。

这个时候订阅者就会收到消息,并做相应的处理,很是简单。

问题出现

对于LiveDataBus的初版实现,咱们发现,在使用这个LiveDataBus的过程当中,订阅者会收到订阅以前发布的消息。对于一个消息总线来讲,这是不可接受的。不管EventBus或者RxBus,订阅方都不会收到订阅以前发出的消息。对于一个消息总线,LiveDataBus必需要解决这个问题。

问题分析

怎么解决这个问题呢?先分析下缘由:

当LifeCircleOwner的状态发生变化的时候,会调用LiveData.ObserverWrapper的activeStateChanged函数,若是这个时候ObserverWrapper的状态是active,就会调用LiveData的dispatchingValue。

在LiveData的dispatchingValue中,又会调用LiveData的considerNotify方法。

在LiveData的considerNotify方法中,红框中的逻辑是关键,若是ObserverWrapper的mLastVersion小于LiveData的mVersion,就会去回调mObserver的onChanged方法。而每一个新的订阅者,其version都是-1,LiveData一旦设置过其version是大于-1的(每次LiveData设置值都会使其version加1),这样就会致使LiveDataBus每注册一个新的订阅者,这个订阅者马上会收到一个回调,即便这个设置的动做发生在订阅以前。

问题缘由总结

对于这个问题,总结一下发生的核心缘由。对于LiveData,其初始的version是-1,当咱们调用了其setValue或者postValue,其vesion会+1;对于每个观察者的封装ObserverWrapper,其初始version也为-1,也就是说,每个新注册的观察者,其version为-1;当LiveData设置这个ObserverWrapper的时候,若是LiveData的version大于ObserverWrapper的version,LiveData就会强制把当前value推送给Observer。

如何解决这个问题

明白了问题产生的缘由以后,咱们来看看怎么才能解决这个问题。很显然,根据以前的分析,只须要在注册一个新的订阅者的时候把Wrapper的version设置成跟LiveData的version一致便可。

那么怎么实现呢,看看LiveData的observe方法,他会在步骤1建立一个LifecycleBoundObserver,LifecycleBoundObserver是ObserverWrapper的派生类。而后会在步骤2把这个LifecycleBoundObserver放入一个私有Map容器mObservers中。不管ObserverWrapper仍是LifecycleBoundObserver都是私有的或者包可见的,因此没法经过继承的方式更改LifecycleBoundObserver的version。

那么能不能从Map容器mObservers中取到LifecycleBoundObserver,而后再更改version呢?答案是确定的,经过查看SafeIterableMap的源码咱们发现有一个protected的get方法。所以,在调用observe的时候,咱们能够经过反射拿到LifecycleBoundObserver,再把LifecycleBoundObserver的version设置成和LiveData一致便可。

对于非生命周期感知的observeForever方法来讲,实现的思路是一致的,可是具体的实现略有不一样。observeForever的时候,生成的wrapper不是LifecycleBoundObserver,而是AlwaysActiveObserver(步骤1),并且咱们也没有机会在observeForever调用完成以后再去更改AlwaysActiveObserver的version,由于在observeForever方法体内,步骤3的语句,回调就发生了。

那么对于observeForever,如何解决这个问题呢?既然是在调用内回调的,那么咱们能够写一个ObserverWrapper,把真正的回调给包装起来。把ObserverWrapper传给observeForever,那么在回调的时候咱们去检查调用栈,若是回调是observeForever方法引发的,那么就不回调真正的订阅者。

LiveDataBus最终实现

public final class LiveDataBus { private final Map<String, BusMutableLiveData<Object>> bus; private LiveDataBus() { bus = new HashMap<>(); } private static class SingletonHolder { private static final LiveDataBus DEFAULT_BUS = new LiveDataBus(); } public static LiveDataBus get() { return SingletonHolder.DEFAULT_BUS; } public <T> MutableLiveData<T> with(String key, Class<T> type) { if (!bus.containsKey(key)) { bus.put(key, new BusMutableLiveData<>()); } return (MutableLiveData<T>) bus.get(key); } public MutableLiveData<Object> with(String key) { return with(key, Object.class); } private static class ObserverWrapper<T> implements Observer<T> { private Observer<T> observer; public ObserverWrapper(Observer<T> observer) { this.observer = observer; } @Override public void onChanged(@Nullable T t) { if (observer != null) { if (isCallOnObserve()) { return; } observer.onChanged(t); } } private boolean isCallOnObserve() { StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); if (stackTrace != null && stackTrace.length > 0) { for (StackTraceElement element : stackTrace) { if ("android.arch.lifecycle.LiveData".equals(element.getClassName()) && "observeForever".equals(element.getMethodName())) { return true; } } } return false; } } private static class BusMutableLiveData<T> extends MutableLiveData<T> { private Map<Observer, Observer> observerMap = new HashMap<>(); @Override public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) { super.observe(owner, observer); try { hook(observer); } catch (Exception e) { e.printStackTrace(); } } @Override public void observeForever(@NonNull Observer<T> observer) { if (!observerMap.containsKey(observer)) { observerMap.put(observer, new ObserverWrapper(observer)); } super.observeForever(observerMap.get(observer)); } @Override public void removeObserver(@NonNull Observer<T> observer) { Observer realObserver = null; if (observerMap.containsKey(observer)) { realObserver = observerMap.remove(observer); } else { realObserver = observer; } super.removeObserver(realObserver); } private void hook(@NonNull Observer<T> observer) throws Exception { //get wrapper's version Class<LiveData> classLiveData = LiveData.class; Field fieldObservers = classLiveData.getDeclaredField("mObservers"); fieldObservers.setAccessible(true); Object objectObservers = fieldObservers.get(this); Class<?> classObservers = objectObservers.getClass(); Method methodGet = classObservers.getDeclaredMethod("get", Object.class); methodGet.setAccessible(true); Object objectWrapperEntry = methodGet.invoke(objectObservers, observer); Object objectWrapper = null; if (objectWrapperEntry instanceof Map.Entry) { objectWrapper = ((Map.Entry) objectWrapperEntry).getValue(); } if (objectWrapper == null) { throw new NullPointerException("Wrapper can not be bull!"); } Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass(); Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion"); fieldLastVersion.setAccessible(true); //get livedata's version Field fieldVersion = classLiveData.getDeclaredField("mVersion"); fieldVersion.setAccessible(true); Object objectVersion = fieldVersion.get(this); //set wrapper's version fieldLastVersion.set(objectWrapper, objectVersion); } } } 

注册订阅

LiveDataBus.get()
        .with("key_test", String.class)
        .observe(this, new Observer<String>() {
            @Override
            public void onChanged(@Nullable String s) { } }); 

发送消息:

LiveDataBus.get().with("key_test").setValue(s);

源码说明

LiveDataBus的源码能够直接拷贝使用,也能够前往做者的GitHub仓库查看下载: https://github.com/JeremyLiao/LiveDataBus 。

总结

本文提供了一个新的消息总线框架——LiveDataBus。订阅者能够订阅某个消息通道的消息,发布者能够把消息发布到消息通道上。利用LiveDataBus,不只能够实现消息总线功能,并且对于订阅者,他们不须要关心什么时候取消订阅,极大减小了由于忘记取消订阅形成的内存泄漏风险。

 

 

Android组件化方案及组件消息总线modular-event实战

背景

组件化做为Android客户端技术的一个重要分支,近年来一直是业界积极探索和实践的方向。美团内部各个Android开发团队也在尝试和实践不一样的组件化方案,而且在组件化通讯框架上也有不少高质量的产出。最近,咱们团队对美团零售收银和美团轻收银两款Android App进行了组件化改造。本文主要介绍咱们的组件化方案,但愿对从事Android组件化开发的同窗能有所启发。

为何要组件化

近年来,为何这么多团队要进行组件化实践呢?组件化究竟能给咱们的工程、代码带来什么好处?咱们认为组件化可以带来两个最大的好处。

提升组件复用性

可能有些人会以为,提升复用性很简单,直接把须要复用的代码作成Android Module,打包AAR并上传代码仓库,那么这部分功能就能被方便地引入和使用。可是咱们以为仅仅这样是不够的,上传仓库的AAR库是否方便被复用,须要组件化的规则来约束,这样才能提升复用的便捷性。

下降组件间的耦合

咱们须要经过组件化的规则把代码拆分红不一样的模块,模块要作到高内聚、低耦合。模块间也不能直接调用,这须要组件化通讯框架的支持。下降了组件间的耦合性能够带来两点直接的好处:第一,代码更便于维护;第二,下降了模块的Bug率。

组件化以前的状态

咱们的目标是要对团队的两款App(美团零售收银、美团轻收银)进行组件化重构,那么这里先简单地介绍一下这两款应用的架构。

总的来讲,这两款应用的构架比较类似,主工程Module依赖Business Module,Business Module是各类业务功能的集合,Business Module依赖Service Module,Service Module依赖Platform Module,Service Module和Platform Module都对上层提供服务。

有所不一样的是Platform Module提供的服务更为基础,主要包括一些工具Utils和界面Widget,而Service Module提供各类功能服务,如KNB、位置服务、网络接口调用等。这样的话,Business Module就变得很是臃肿和繁杂,各类业务模块相互调用,耦合性很强,改业务代码时容易“牵一发而动全身”,即便改一小块业务代码,可能要连带修改不少相关的地方,不只在代码层面不利于进行维护,并且对一个业务的修改很容易形成其余业务产生Bug。

组件化以前的状态

组件化以前的状态

 

组件化方案调研

为了获得最适合咱们业态和构架的组件化方案,咱们调研了业界开源的一些组件化方案和公司内部其余团队的组件化方案,在此作个总结。

开源组件化方案调研

咱们调研了业界一些主流的开源组件化方案。

号称业界首个支持渐进式组件化改造的Android组件化开源框架。不管页面跳转仍是组件间调用,都采用CC统一的组件调用方式完成。

获得的方案采用路由 + 接口下沉的方式,全部接口下沉到base中,组件中实现接口并在IApplicationLike中添加代码注册到Router中。

组件间调用需指定同步实现仍是异步实现,调用组件时统一拿到RouterResponse做为返回值,同步调用的时候用RouterResponse.getData()来获取结果,异步调用获取时须要本身维护线程。

阿里推出的路由引擎,是一个路由框架,并非完整的组件化方案,可做为组件化架构的通讯引擎。

聚美的路由引擎,在此基础上也有聚美的组件化实践方案,基本思想是采用路由 + 接口下沉的方式实现组件化。

美团其余团队组件化方案调研

美团收银ComponentCenter

美团收银的组件化方案支持接口调用和消息总线两种方式,接口调用的方式须要构建CCPData,而后调用ComponentCenter.call,最后在统一的Callback中进行处理。消息总线方式也须要构建CCPData,最后调用ComponentCenter.sendEvent发送。美团收银的业务组件都打包成AAR上传至仓库,组件间存在相互依赖,这样致使mainapp引用这些组件时须要当心地exclude一些重复依赖。在咱们的组件化方案中,咱们采用了一种巧妙的方法来解决这个问题。

美团App ServiceLoader

美团App的组件化方案采用ServiceLoader的形式,这是一种典型的接口调用组件通讯方式。用注解定义服务,获取服务时取得一个接口的List,判断这个List是否为空,若是不为空,则获取其中一个接口调用。

WMRouter

美团外卖团队开发的一款Android路由框架,基于组件化的设计思路。主要提供路由、ServiceLoader两大功能。以前美团技术博客也发表过一篇WMRouter的介绍:《WMRouter:美团外卖Android开源路由框架》。WMRouter提供了实现组件化的两大基础设施框架:路由和组件间接口调用。支持和文档也很充分,能够考虑做为咱们团队实现组件化的基础设施。

组件化方案

组件化基础框架

在前期的调研工做中,咱们发现外卖团队的WMRouter是一个不错的选择。首先,WMRouter提供了路由+ServiceLoader两大组件间通讯功能,其次,WMRouter架构清晰,扩展性比较好,而且文档和支持也比较完备。因此咱们决定了使用WMRouter做为组件化基础设施框架之一。然而,直接使用WMRouter有两个问题:

  1. 咱们的项目已经在使用一个路由框架,若是使用WMRouter,须要把以前使用的路由框架改为WMRouter路由框架。
  2. WMRouter没有消息总线框架,咱们调研的其余项目也没有适合咱们项目的消息总线框架,所以咱们须要开发一个可以知足咱们需求的消息总线框架,这部分会在后面详细描述。

组件化分层结构

在参考了不一样的组件化方案以后,咱们采用了以下分层结构:

  1. App壳工程:负责管理各个业务组件和打包APK,没有具体的业务功能。
  2. 业务组件层:根据不一样的业务构成独立的业务组件,其中每一个业务组件包含一个Export Module和Implement Module。
  3. 功能组件层:对上层提供基础功能服务,如登陆服务、打印服务、日志服务等。
  4. 组件基础设施:包括WMRouter,提供页面路由服务和ServiceLoader接口调用服务,以及后面会介绍的组件消息总线框架:modular-event。

总体架构以下图所示:

分层结构

分层结构

 

业务组件拆分

咱们调研其余组件化方案的时候,发现不少组件方案都是把一个业务模块拆分红一个独立的业务组件,也就是拆分红一个独立的Module。而在咱们的方案中,每一个业务组件都拆分红了一个Export Module和Implement Module,为何要这样作呢?

1. 避免循环依赖

若是采用一个业务组件一个Module的方式,若是Module A须要调用Module B提供的接口,那么Module A就须要依赖Module。同时,若是Module B须要调用Module A的接口,那么Module B就须要依赖Module A。此时就会造成一个循环依赖,这是不容许的。

循环依赖

循环依赖

 

也许有些读者会说,这个好解决:能够把Module A和Module B要依赖的接口放到另外一个Module中去,而后让Module A和Module B都去依赖这个Module就能够了。这确实是一个解决办法,而且有些项目组在使用这种把接口下沉的方法。

可是咱们但愿一个组件的接口,是由这个组件本身提供,而不是放在一个更加下沉的接口里面,因此咱们采用了把每一个业务组件都拆分红了一个Export Module和Implement Module。这样的话,若是Module A须要调用Module B提供的接口,同时Module B须要调用Module A的接口,只须要Module A依赖Module B Export,Module B依赖Module A Export就能够了。

组件结构

组件结构

 

2. 业务组件彻底平等

在使用单Module方案的组件化方案中,这些业务组件其实不是彻底平等,有些被依赖的组件在层级上要更下沉一些。可是采用Export Module+Implement Module的方案,全部业务组件在层级上彻底平等。

3. 功能划分更加清晰

每一个业务组件都划分红了Export Module+Implement Module的模式,这个时候每一个Module的功能划分也更加清晰。Export Module主要定义组件须要对外暴露的部分,主要包含:

  • 对外暴露的接口,这些接口用WMRouter的ServiceLoader进行调用。
  • 对外暴露的事件,这些事件利用消息总线框架modular-event进行订阅和分发。
  • 组件的Router Path,组件化以前的工程虽然也使用了Router框架,可是全部Router Path都是定义在了一个下沉Module的公有Class中。这样致使的问题是,不管哪一个模块添加/删除页面,或是修改路由,都须要去修改这个公有的Class。设想若是组件化拆分以后,某个组件新增了页面,还要去一个外部的Java文件中新增路由,这显然难以接受,也不符合组件化内聚的目标。所以,咱们把每一个组件的Router Path放在组件的Export Module中,既能够暴露给其余组件,也能够作到每一个组件管理本身的Router Path,不会出现全部组件去修改一个Java文件的窘境。

Implement Module是组件实现的部分,主要包含:

  • 页面相关的Activity、Fragment,而且用WMRouter的注解定义路由。
  • Export Module中对外暴露的接口的实现。
  • 其余的业务逻辑。

组件功能划分

组件功能划分

 

组件化消息总线框架modular-event

前文提到的实现组件化基础设施框架中,咱们用外卖团队的WMRouter实现页面路由和组件间接口调用,可是却没有消息总线的基础框架,所以,咱们本身开发了一个组件化消息总线框架modular-event。

为何须要消息总线框架

以前,咱们开发过一个基于LiveData的消息总线框架:LiveDataBus,也在美团技术博客上发表过一篇文章来介绍这个框架:《Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus》。关于消息总线的使用,老是伴随着不少争论。有些人以为消息总线很好用,有些人以为消息总线容易被滥用。

既然已经有了ServiceLoader这种组件间接口调用的框架,为何还须要消息总线这种方式呢?主要有两个理由。

1. 更进一步的解耦

基于接口调用的ServiceLoader框架的确实现了解耦,可是消息总线可以实现更完全的解耦。接口调用的方式调用方须要依赖这个接口而且知道哪一个组件实现了这个接口。消息总线方式发送者只须要发送一个消息,根本不用关心是否有人订阅这个消息,这样发送者根本不须要了解其余组件的状况,和其余组件的耦合也就越少。

2. 多对多的通讯

基于接口的方式只能进行一对一的调用,基于消息总线的方式可以提供多对多的通讯。

消息总线的优势和缺点

总的来讲,消息总线最大的优势就是解耦,所以很适合组件化这种须要对组件间进行完全解耦的场景。然而,消息总线被不少人诟病的重要缘由,也确实是由于消息总线容易被滥用。消息总线容易被滥用通常体如今几个场景:

1. 消息难以溯源

有时候咱们在阅读代码的过程当中,找到一个订阅消息的地方,想要看看是谁发送了这个消息,这个时候每每只能经过查找消息的方式去“溯源”。致使咱们在阅读代码,梳理逻辑的过程不太连贯,有种被割裂的感受。

2. 消息发送比较随意,没有强制的约束

消息总线在发送消息的时候通常没有强制的约束。不管是EventBus、RxBus或是LiveDataBus,在发送消息的时候既没有对消息进行检查,也没有对发送调用进行约束。这种不规范性在特定的时刻,甚至会带来灾难性的后果。好比订阅方订阅了一个名为login_success的消息,编写发送消息的是一个比较随意的程序员,没有把这个消息定义成全局变量,而是定义了一个临时变量String发送这个消息。不幸的是,他把消息名称login_success拼写成了login_seccess。这样的话,订阅方永远接收不到登陆成功的消息,并且这个错误也很难被发现。

组件化消息总线的设计目标

1. 消息由组件本身定义

之前咱们在使用消息总线时,喜欢把全部的消息都定义到一个公共的Java文件里面。可是组件化若是也采用这种方案的话,一旦某个组件的消息发生变更,都会去修改这个Java文件。因此咱们但愿由组件本身来定义和维护消息定义文件。

2. 区分不一样组件定义的同名消息

若是消息由组件定义和维护,那么有可能不一样组件定义了重名的消息,消息总线框架须要可以区分这种消息。

3. 解决前文提到的消息总线的缺点

解决消息总线消息难以溯源和消息发送没有约束的问题。

基于LiveData的消息总线

以前的博文《Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus》详细阐述了如何基于LiveData构建消息总线。组件化消息总线框架modular-event一样会基于LiveData构建。使用LiveData构建消息总线有不少优势:

  1. 使用LiveData构建消息总线具备生命周期感知能力,使用者不须要调用反注册,相比EventBus和RxBus使用更为方便,而且没有内存泄漏风险。
  2. 使用普通消息总线,若是回调的时候Activity处于Stop状态,这个时候进行弹Dialog一类的操做就会引发崩溃。使用LiveData构建消息总线彻底没有这个风险。

组件消息总线modular-event的实现

解决不一样组件定义了重名消息的问题

其实这个问题仍是比较好解决的,实现的方式就是采用两级HashMap的方式解决。第一级HashMap的构建以ModuleName做为Key,第二级HashMap做为Value;第二级HashMap以消息名称EventName做为Key,LiveData做为Value。查找的时候先用组件名称ModuleName在第一级HashMap中查找,若是找到则用消息名EventName在第二级HashName中查找。整个结构以下图所示:

消息总线结构

消息总线结构

 

对消息总线的约束

咱们但愿消息总线框架有如下约束:

  1. 只能订阅和发送在组件中预约义的消息。换句话说,使用者不能发送和订阅临时消息。
  2. 消息的类型须要在定义的时候指定。
  3. 定义消息的时候须要指定属于哪一个组件。

如何实现这些约束

  1. 在消息定义文件上使用注解,定义消息的类型和消息所属Module。
  2. 定义注解处理器,在编译期间收集消息的相关信息。
  3. 在编译器根据消息的信息生成调用时须要的interface,用接口约束消息发送和订阅。
  4. 运行时构建基于两级HashMap的LiveData存储结构。
  5. 运行时采用interface+动态代理的方式实现真正的消息订阅和发送。

整个流程以下图所示:

实现流程

实现流程

 

消息总线modular-event的结构

  • modular-event-base:定义Anotation及其余基本类型
  • modular-event-core:modular-event核心实现
  • modular-event-compiler:注解处理器
  • modular-event-plugin:Gradle Plugin

Anotation

  • @ModuleEvents:消息定义
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface ModuleEvents {
    String module() default ""; } 
  • @EventType:消息类型
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.FIELD)
public @interface EventType {
    Class value(); } 

消息定义

经过@ModuleEvents注解一个定义消息的Java类,若是@ModuleEvents指定了属性module,那么这个module的值就是这个消息所属的Module,若是没有指定属性module,则会把定义消息的Java类所在的包的包名做为消息所属的Module。

在这个消息定义java类中定义的消息都是public static final String类型。能够经过@EventType指定消息的类型,@EventType支持java原生类型或自定义类型,若是没有用@EventType指定消息类型,那么消息的类型默认为Object,下面是一个消息定义的示例:

//能够指定module,若不指定,则使用包名做为module名
@ModuleEvents()
public class DemoEvents { //不指定消息类型,那么消息的类型默认为Object public static final String EVENT1 = "event1"; //指定消息类型为自定义Bean @EventType(TestEventBean.class) public static final String EVENT2 = "event2"; //指定消息类型为java原生类型 @EventType(String.class) public static final String EVENT3 = "event3"; } 

interface自动生成

咱们会在modular-event-compiler中处理这些注解,一个定义消息的Java类会生成一个接口,这个接口的命名是EventsDefineOf+消息定义类名,例如消息定义类的类名为DemoEvents,自动生成的接口就是EventsDefineOfDemoEvents。消息定义类中定义的每个消息,都会转化成接口中的一个方法。使用者只能经过这些自动生成的接口使用消息总线。咱们用这种巧妙的方式实现了对消息总线的约束。前文提到的那个消息定义示例DemoEvents.java会生成一个以下的接口类:

package com.sankuai.erp.modularevent.generated.com.meituan.jeremy.module_b_export;

public interface EventsDefineOfDemoEvents extends com.sankuai.erp.modularevent.base.IEventsDefine { com.sankuai.erp.modularevent.Observable<java.lang.Object> EVENT1(); com.sankuai.erp.modularevent.Observable<com.meituan.jeremy.module_b_export.TestEventBean> EVENT2( ); com.sankuai.erp.modularevent.Observable<java.lang.String> EVENT3(); } 

关于接口类的自动生成,咱们采用了square/javapoet来实现,网上介绍JavaPoet的文章不少,这里就再也不累述。

使用动态代理实现运行时调用

有了自动生成的接口,就至关于有了一个壳,然而壳下面的全部逻辑,咱们经过动态代理来实现,简单介绍一下代理模式和动态代理:

  • 代理模式: 给某个对象提供一个代理对象,并由代理对象控制对于原对象的访问,即客户不直接操控原对象,而是经过代理对象间接地操控原对象。
  • 动态代理: 代理类是在运行时生成的。也就是说Java编译完以后并无实际的class文件,而是在运行时动态生成的类字节码,并加载到JVM中。

在动态代理的InvocationHandler中实现查找逻辑:

  1. 根据interface的typename获得ModuleName。
  2. 调用的方法的methodname即为消息名。
  3. 根据ModuleName和消息名找到相应的LiveData。
  4. 完成后续订阅消息或者发送消息的流程。

消息的订阅和发送能够用链式调用的方式编码:

  • 订阅消息
ModularEventBus
        .get()
        .of(EventsDefineOfModuleBEvents.class)
        .EVENT1()
        .observe(this, new Observer<TestEventBean>() {
            @Override
            public void onChanged(@Nullable TestEventBean testEventBean) { Toast.makeText(MainActivity.this, "MainActivity收到自定义消息: " + testEventBean.getMsg(), Toast.LENGTH_SHORT).show(); } }); 
  • 发送消息
ModularEventBus
        .get()
        .of(EventsDefineOfModuleBEvents.class)
        .EVENT1()
        .setValue(new TestEventBean("aa"));

订阅和发送的模式

  • 订阅消息的模式

    1. observe:生命周期感知,onDestroy的时候自动取消订阅。
    2. observeSticky:生命周期感知,onDestroy的时候自动取消订阅,Sticky模式。
    3. observeForever:须要手动取消订阅。
    4. observeStickyForever:须要手动取消订阅,Sticky模式。
  • 发送消息的模式

    1. setValue:主线程调用。
    2. postValue:后台线程调用。

总结

本文介绍了美团行业收银研发组Android团队的组件化实践,以及强约束组件消息总线modular-event的原理和使用。咱们团队很早以前就在探索组件化改造,前期有些方案在落地的时候遇到不少困难。咱们也研究了不少开源的组件化方案,以及公司内部其余团队(美团App、美团外卖、美团收银等)的组件化方案,学习和借鉴了不少优秀的设计思想,固然也踩过很多的坑。咱们逐渐意识到:任何一种组件化方案都有其适用场景,咱们的组件化架构选择,应该更加面向业务,而不只仅是面向技术自己。

后期工做展望

咱们的组件化改造工做远远没有结束,将来可能会在如下几个方向继续进行深刻的研究:

  1. 组件管理:组件化改造以后,每一个组件是个独立的工程,组件也会迭代开发,如何对这些组件进行版本化管理。
  2. 组件重用:如今看起来对这些组件的重用是很方便的,只须要引入组件的库便可,可是若是一个新的项目到来,需求有些变化,咱们应该怎样最大限度的重用这些组件。
  3. CI集成:如何更好的与CI集成。
  4. 集成到脚手架:集成到脚手架,让新的项目从一开始就以组件化的模式进行开发。

参考资料

  1. Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus
  2. WMRouter:美团外卖Android开源路由框架
  3. 美团外卖Android平台化架构演进实践

 

Android自动化页面测速在美团的实践

背景

随着移动互联网的快速发展,移动应用愈来愈注重用户体验。美团技术团队在开发过程当中也很是注重提高移动应用的总体质量,其中很重要的一项内容就是页面的加载速度。若是发生冷启动时间过长、页面渲染时间过长、网络请求过慢等现象,就会直接影响到用户的体验,因此,如何监控整个项目的加载速度就成为咱们部门面临的重要挑战。

对于测速这个问题,不少同窗首先会想到在页面中的不一样节点加入计算时间的代码,以此算出某段时间长度。然而,随着美团业务的快速迭代,会有愈来愈多的新页面、愈来愈多的业务逻辑、愈来愈多的代码改动,这些不肯定性会使咱们测速部分的代码耦合进业务逻辑,而且须要手动维护,进而增长了成本和风险。因而经过借鉴公司先前的方案Hertz(移动端性能监控方案Hertz),分析其存在的问题并结合自身特性,咱们实现了一套无需业务代码侵入的自动化页面测速插件,本文将对其原理作一些解读和分析。

现有解决方案Hertz(移动端性能监控方案Hertz) * 手动在 Application.onCreate() 中进行SDK的初始化调用,同时计算冷启动时间。

  • 手动在Activity生命周期方法中添加代码,计算页面不一样阶段的时间。
  • 手动为 Activity.setContentView() 设置的View上,添加一层自定义父View,用于计算绘制完成的时间。
  • 手动在每一个网络请求开始前和结束后添加代码,计算网络请求的时间。

  • 本地声明JSON配置文件来肯定须要测速的页面以及该页面须要统计的初始网络请求API, getClass().getSimpleName() 做为页面的key,来标识哪些页面须要测速,指定一组API来标识哪些请求是须要被测速的。

现有方案问题:

  • 冷启动时间不许:冷启动起始时间从 Application.onCreate() 中开始算起,会使得计算出来的冷启动时间偏小,由于在该方法执行前可能会有 MultiDex.install() 等耗时方法的执行。
  • 特殊状况未考虑:忽略了ViewPager+Fragment延时加载这些常见而复杂的状况,这些状况会形成实际测速时间很是不许。
  • 手动注入代码:全部的代码都须要手动写入,耦合进业务逻辑中,难以维护而且随着新页面的加入容易遗漏。
  • 写死配置文件:如需添加或更改要测速的页面,则须要修改本地配置文件,进行发版。

目标方案效果:

  • 自动注入代码,无需手动写入代码与业务逻辑耦合。
  • 支持Activity和Fragment页面测速,并解决ViewPager+Fragment延迟加载时测速不许的问题。
  • 在Application的构造函数中开始冷启动时间计算。
  • 自动拉取和更新配置文件,能够实时的进行配置文件的更新。

实现

咱们要实现一个自动化的测速插件,须要分为五步进行:

  1. 测速定义:肯定须要测量的速度指标并定义其计算方式。
  2. 配置文件:经过配置文件肯定代码中须要测量速度指标的位置。
  3. 测速实现:如何实现时间的计算和上报。
  4. 自动化实现:如何自动化实现页面测速,不须要手动注入代码。
  5. 疑难杂症:分析并解决特殊状况。

测速定义

咱们把页面加载流程抽象成一个通用的过程模型:页面初始化 -> 初次渲染完成 -> 网络请求发起 -> 请求完成并刷新页面 -> 二次渲染完成。据此,要测量的内容包括如下方面:

  • 项目的冷启动时间:从App被建立,一直到咱们首页初次绘制出来所经历的时间。
  • 页面的初次渲染时间:从Activity或Fragment的 onCreate() 方法开始,一直到页面View的初次渲染完成所经历的时间。
  • 页面的初始网络请求时间:Activity或Fragment指定的一组初始请求,所有完成所用的时间。
  • 页面的二次渲染时间:Activity或Fragment全部的初始请求完成后,到页面View再次渲染完成所经历的时间。

须要注意的是,网络请求时间是指定的一组请求所有完成的时间,即从第一个请求发起开始,直到最后一个请求完成所用的时间。根据定义咱们的测速模型以下图所示:

配置文件

接下来要知道哪些页面须要测速,以及页面的初始请求是哪些API,这须要一个配置文件来定义。

<page id="HomeActivity" tag="1"> <api id="/api/config"/> <api id="/api/list"/> </page> <page id="com.test.MerchantFragment" tag="0"> <api id="/api/test1"/> </page> 

咱们定义了一个XML配置文件,每一个 <page/> 标签表明了一个页面,其中 id 是页面的类名或者全路径类名,用以表示哪些Activity或者Fragment须要测速; tag 表明是否为首页,这个首页指的是用以计算冷启动结束时间的页面,好比咱们想把冷启动时间定义为从App建立到HomeActivity展现所须要的时间,那么HomeActivity的tag就为1;每个 <api/> 表明这个页面的一个初始请求,好比HomeActivity页面是个列表页,一进来会先请求config接口,而后请求list接口,当list接口回来后展现列表数据,那么该页面的初始请求就是config和list接口。更重要的一点是,咱们将该配置文件维护在服务端,能够实时更新,而客户端要作的只是在插件SDK初始化时拉取最新的配置文件便可。

测速实现

测速须要实现一个SDK,用于管理配置文件、页面测速对象、计算时间、上报数据等,项目接入后,在页面的不一样节点调用SDK提供的方法完成测速。

冷启动开始时间

冷启动的开始时间,咱们以Application的构造函数被调用为准,在构造函数中进行时间点记录,并在SDK初始化时,将时间点传入做为冷启动开始时间。

//Application
public MyApplication(){ super(); coldStartTime = SystemClock.elapsedRealtime(); } //SDK初始化 public void onColdStart(long coldStartTime) { this.startTime = coldStartTime; } 

这里说明几点:

  • SDK中全部的时间获取都使用 SystemClock.elapsedRealtime() 机器时间,保证了时间的一致性和准确性。
  • 冷启动初始时间以构造函数为准,能够算入MultiDex注入的时间,比在 onCreate() 中计算更为准确。
  • 在构造函数中直接调用Java的API来计算时间,以后传入SDK中,而不是直接调用SDK的方法,是为了防止MultiDex注入以前,调用到未注入的Dex中的类。

SDK初始化

SDK的初始化在 Application.onCreate() 中调用,初始化时会获取服务端的配置文件,解析为 Map<String,PageObject> ,对应配置中页面的id和其配置项。另外还维护了一个当前页面对象的 MAP<Integer, Object> ,key为一个int值而不是其类名,由于同一个类可能有多个实例同时在运行,若是存为一个key,可能会致使同一页面不一样实例的测速对象只有一个,因此在这里咱们使用Activity或Fragment的 hashcode() 值做为页面的惟一标识。

页面开始时间

页面的开始时间,咱们以Activtiy或Fragment的 onCreate() 做为时间节点进行计算,记录页面的开始时间。

public void onPageCreate(Object page) { int pageObjKey = Utils.getPageObjKey(page); PageObject pageObject = activePages.get(pageObjKey); ConfigModel configModel = getConfigModel(page);//获取该页面的配置 if (pageObject == null && configModel != null) {//有配置则须要测速 pageObject = new PageObject(pageObjKey, configModel, Utils.getDefaultReportKey(page), callback); pageObject.onCreate(); activePages.put(pageObjKey, pageObject); } } //PageObject.onCreate() void onCreate() { if (createTime > 0) { return; } createTime = Utils.getRealTime(); } 

这里的 getConfigModel() 方法中,会使用页面的类名或者全路径类名,去初始化时解析的配置Map中进行id的匹配,若是匹配到说明页面须要测速,就会建立测速对象 PageObject 进行测速。

网络请求时间

一个页面的初始请求由配置文件指定,咱们只需在第一个请求发起前记录请求开始时间,在最后一个请求回来后记录结束时间便可。

boolean onApiLoadStart(String url) {
    String relUrl = Utils.getRelativeUrl(url);
    if (!hasApiConfig() || !hasUrl(relUrl) || apiStatusMap.get(relUrl.hashCode()) != NONE) {
        return false;
    }
    //改变Url的状态为执行中 apiStatusMap.put(relUrl.hashCode(), LOADING); //第一个请求开始时记录起始点 if (apiLoadStartTime <= 0) { apiLoadStartTime = Utils.getRealTime(); } return true; } boolean onApiLoadEnd(String url) { String relUrl = Utils.getRelativeUrl(url); if (!hasApiConfig() || !hasUrl(relUrl) || apiStatusMap.get(relUrl.hashCode()) != LOADING) { return false; } //改变Url的状态为执行结束 apiStatusMap.put(relUrl.hashCode(), LOADED); //所有请求结束后记录时间 if (apiLoadEndTime <= 0 && allApiLoaded()) { apiLoadEndTime = Utils.getRealTime(); } return true; } private boolean allApiLoaded() { if (!hasApiConfig()) return true; int size = apiStatusMap.size(); for (int i = 0; i < size; ++i) { if (apiStatusMap.valueAt(i) != LOADED) { return false; } } return true; } 

每一个页面的测速对象,维护了一个请求url和其状态的映射关系 SparseIntArray ,key就为请求url的hashcode,状态初始为 NONE 。每次请求发起时,将对应url的状态置为 LOADING ,结束时置为 LOADED 。当第一个请求发起时记录起始时间,当全部url状态为 LOADED 时说明全部请求完成,记录结束时间。

渲染时间

按照咱们对测速的定义,如今冷启动开始时间有了,还差结束时间,即指定的首页初次渲染结束时的时间;页面的开始时间有了,还差页面初次渲染的结束时间;网络请求的结束时间有了,还差页面的二次渲染的结束时间。这一切都是和页面的View渲染时间有关,那么怎么获取页面的渲染结束时间点呢?

由View的绘制流程可知,父View的 dispatchDraw() 方法会执行其全部子View的绘制过程,那么把页面的根View当作子View,是否是能够在其外部增长一层父View,以其 dispatchDraw() 做为页面绘制完毕的时间点呢?答案是能够的。

class AutoSpeedFrameLayout extends FrameLayout { public static View wrap(int pageObjectKey, @NonNull View child) { ... //将页面根View做为子View,其余参数保持不变 ViewGroup vg = new AutoSpeedFrameLayout(child.getContext(), pageObjectKey); if (child.getLayoutParams() != null) { vg.setLayoutParams(child.getLayoutParams()); } vg.addView(child, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return vg; } private final int pageObjectKey;//关联的页面key private AutoSpeedFrameLayout(@NonNull Context context, int pageObjectKey) { super(context); this.pageObjectKey = pageObjectKey; } @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); AutoSpeed.getInstance().onPageDrawEnd(pageObjectKey); } } 

咱们自定义了一层 FrameLayout 做为全部页面根View的父View,其 dispatchDraw() 方法执行super后,记录相关页面绘制结束的时间点。

测速完成

如今全部时间点都有了,那么何时算做测速过程结束呢?咱们来看看每次渲染结束后的处理就知道了。

//PageObject.onPageDrawEnd()
void onPageDrawEnd() {
    if (initialDrawEndTime <= 0) {//初次渲染尚未完成
        initialDrawEndTime = Utils.getRealTime();
        if (!hasApiConfig() || allApiLoaded()) {//若是没有请求配置或者请求已完成,则没有二次渲染时间,即初次渲染时间即为页面总体时间,且能够上报结束页面了 finalDrawEndTime = -1; reportIfNeed(); } //页面初次展现,回调,用于统计冷启动结束 callback.onPageShow(this); return; } //若是二次渲染没有完成,且全部请求已经完成,则记录二次渲染时间并结束测速,上报数据 if (finalDrawEndTime <= 0 && (!hasApiConfig() || allApiLoaded())) { finalDrawEndTime = Utils.getRealTime(); reportIfNeed(); } } 

该方法用于处理渲染完毕的各类状况,包括初次渲染时间、二次渲染时间、冷启动时间以及相应的上报。这里的冷启动在 callback.onPageShow(this) 是如何处理的呢?

//初次渲染完成时的回调
void onMiddlePageShow(boolean isMainPage) { if (!isFinish && isMainPage && startTime > 0 && endTime <= 0) { endTime = Utils.getRealTime(); callback.onColdStartReport(this); finish(); } } 

还记得配置文件中 tag 么,他的做用就是指明该页面是否为首页,也就是代码段里的 isMainPage 参数。若是是首页的话,说明首页的初次渲染结束,就能够计算冷启动结束的时间并进行上报了。

上报数据

当测速完成后,页面测速对象 PageObject 里已经记录了页面(包括冷启动)各个时间点,剩下的只须要进行测速阶段的计算并进行网络上报便可。

//计算网络请求时间
long getApiLoadTime() {
    if (!hasApiConfig() || apiLoadEndTime <= 0 || apiLoadStartTime <= 0) {
        return -1; } return apiLoadEndTime - apiLoadStartTime; } 

自动化实现

有了SDK,就要在咱们的项目中接入,并在相应的位置调用SDK的API来实现测速功能,那么如何自动化实现API的调用呢?答案就是采用AOP的方式,在App编译时动态注入代码,咱们实现一个Gradle插件,利用其Transform功能以及Javassist实现代码的动态注入。动态注入代码分为如下几步:

  • 初始化埋点:SDK的初始化。
  • 冷启动埋点:Application的冷启动开始时间点。
  • 页面埋点:Activity和Fragment页面的时间点。
  • 请求埋点:网络请求的时间点。

初始化埋点

在 Transform 中遍历全部生成的class文件,找到Application对应的子类,在其 onCreate() 方法中调用SDK初始化API便可。

CtMethod method = it.getDeclaredMethod("onCreate")
method.insertBefore("${Constants.AUTO_SPEED_CLASSNAME}.getInstance().init(this);") 

最终生成的Application代码以下:

public void onCreate() {
    ...
    AutoSpeed.getInstance().init(this);
}

冷启动埋点

同上一步,找到Application对应的子类,在其构造方法中记录冷启动开始时间,在SDK初始化时候传入SDK,缘由在上文已经解释过。

//Application
private long coldStartTime;
public MobileCRMApplication() { coldStartTime = SystemClock.elapsedRealtime(); } public void onCreate(){ ... AutoSpeed.getInstance().init(this,coldStartTime); } 

页面埋点

结合测速时间点的定义以及Activity和Fragment的生命周期,咱们可以肯定在何处调用相应的API。

Activity

对于Activity页面,如今开发者已经不多直接使用 android.app.Activity 了,取而代之的是 android.support.v4.app.FragmentActivity 和 android.support.v7.app.AppCompatActivity ,因此咱们只需在这两个基类中进行埋点便可,咱们先来看FragmentActivity。

protected void onCreate(@Nullable Bundle savedInstanceState) {
    AutoSpeed.getInstance().onPageCreate(this); ... } public void setContentView(View var1) { super.setContentView(AutoSpeed.getInstance().createPageView(this, var1)); } 

注入代码后,在FragmentActivity的 onCreate 一开始调用了 onPageCreate() 方法进行了页面开始时间点的计算;在 setContentView() 内部,直接调用super,并将页面根View包装在咱们自定义的 AutoSpeedFrameLayout 中传入,用于渲染时间点的计算。 然而在AppCompatActivity中,重写了setContentView()方法,且没有调用super,调用的是 AppCompatDelegate 的相应方法。

public void setContentView(View view) {
    getDelegate().setContentView(view); } 

这个delegate类用于适配不一样版本的Activity的一些行为,对于setContentView,无非就是将根View传入delegate相应的方法,因此咱们能够直接包装View,调用delegate相应方法并传入便可。

public void setContentView(View view) {
    AppCompatDelegate var2 = this.getDelegate();
    var2.setContentView(AutoSpeed.getInstance().createPageView(this, view));
}

对于Activity的setContentView埋点须要注意的是,该方法是重载方法,咱们须要对每一个重载的方法作处理。

Fragment

Fragment的 onCreate() 埋点和Activity同样,没必要多说。这里主要说下 onCreateView() ,这个方法是返回值表明根View,而不是直接传入View,而Javassist没法单独修改方法的返回值,因此没法像Activity的setContentView那样注入代码,而且这个方法不是 @CallSuper 的,意味着不能在基类里实现。那么怎么办呢?咱们决定在每一个Fragment的该方法上作一些事情。

//Fragment标志位
protected static boolean AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = true;
//利用递归包装根View
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { if(AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG) { AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = false; View var4 = AutoSpeed.getInstance().createPageView(this, this.onCreateView(inflater, container, savedInstanceState)); AUTO_SPEED_FRAGMENT_CREATE_VIEW_FLAG = true; return var4; } else { ... return rootView; } } 

咱们利用一个boolean类型的标志位,进行递归调用 onCreateView() 方法:

  1. 最初调用时,会将标志位置为false,而后递归调用该方法。
  2. 递归调用时,因为标志位为false因此会调用原有逻辑,即获取根View。
  3. 获取根View后,包装为 AutoSpeedFrameLayout 返回。

而且因为标志位为false,因此在递归调用时,即便调用了 super.onCreateView() 方法,在父类的该方法中也不会走if分支,而是直接返回其根View。

请求埋点

关于请求埋点咱们针对不一样的网络框架进行不一样的处理,插件中只须要配置使用了哪些网络框架便可实现埋点,咱们拿如今用的最多的 Retrofit 框架来讲。

开始时间点

在建立Retrofit对象时,须要 OkHttpClient 对象,能够为其添加 Interceptor 进行请求发起前 Request 的拦截,咱们能够构建一个用于记录请求开始时间点的Interceptor,在 OkHttpClient.Builder() 调用时,插入该对象。

public Builder() { this.addInterceptor(new AutoSpeedRetrofitInterceptor()); ... } 

而该Interceptor对象就是用于在请求发起前,进行请求开始时间点的记录。

public class AutoSpeedRetrofitInterceptor implements Interceptor { public Response intercept(Chain var1) throws IOException { AutoSpeed.getInstance().onApiLoadStart(var1.request().url()); return var1.proceed(var1.request()); } } 

结束时间点

使用Retrofit发起请求时,咱们会调用其 enqueue() 方法进行异步请求,同时传入一个 Callback 进行回调,咱们能够自定义一个Callback,用于记录请求回来后的时间点,而后在enqueue方法中将参数换为自定义的Callback,而原Callback做为其代理对象便可。

public void enqueue(Callback<T> callback) { final Callback<T> callback = new AutoSpeedRetrofitCallback(callback); ... } 

该Callback对象用于在请求成功或失败回调时,记录请求结束时间点,并调用代理对象的相应方法处理原有逻辑。

public class AutoSpeedRetrofitCallback implements Callback { private final Callback delegate; public AutoSpeedRetrofitMtCallback(Callback var1) { this.delegate = var1; } public void onResponse(Call var1, Response var2) { AutoSpeed.getInstance().onApiLoadEnd(var1.request().url()); this.delegate.onResponse(var1, var2); } public void onFailure(Call var1, Throwable var2) { AutoSpeed.getInstance().onApiLoadEnd(var1.request().url()); this.delegate.onFailure(var1, var2); } } 

使用Retrofit+RXJava时,发起请求时内部是调用的 execute() 方法进行同步请求,咱们只须要在其执行先后插入计算时间的代码便可,此处再也不赘述。

疑难杂症

至此,咱们基本的测速框架已经完成,不过通过咱们的实践发现,有一种状况下测速数据会很是不许,那就是开头提过的ViewPager+Fragment而且实现延迟加载的状况。这也是一种很常见的状况,一般是为了节省开销,在切换ViewPager的Tab时,才首次调用Fragment的初始加载方法进行数据请求。通过调试分析,咱们找到了问题的缘由。

等待切换时间

该图红色时间段反映出,直到ViewPager切换到Fragment前,Fragment不会发起请求,这段等待的时间就会延长整个页面的加载时间,但其实这块时间不该该算在内,由于这段时间是用户无感知的,不能做为页面耗时过长的依据。

那么如何解决呢?咱们都知道ViewPager的Tab切换是能够经过一个 OnPageChangeListener 对象进行监听的,因此咱们能够为ViewPager添加一个自定义的Listener对象,在切换时记录一个时间,这样能够经过用这个时间减去页面建立后的时间得出这个多余的等待时间,上报时在总时间中减去便可。

public ViewPager(Context context) {
    ...
    this.addOnPageChangeListener(new AutoSpeedLazyLoadListener(this.mItems));
}

mItems 是ViewPager中当前页面对象的数组,在Listener中能够经过他找到对应的页面,进行切换时的埋点。

//AutoSpeedLazyLoadListener
public void onPageSelected(int var1) { if(this.items != null) { int var2 = this.items.size(); for(int var3 = 0; var3 < var2; ++var3) { Object var4 = this.items.get(var3); if(var4 instanceof ItemInfo) { ItemInfo var5 = (ItemInfo)var4; if(var5.position == var1 && var5.object instanceof Fragment) { AutoSpeed.getInstance().onPageSelect(var5.object); break; } } } } } 

AutoSpeed的 onPageSelected() 方法记录页面的切换时间。这样一来,在计算页面加载速度总时间时,就要减去这一段时间。

long getTotalTime() {
    if (createTime <= 0) {
        return -1;
    }
    if (finalDrawEndTime > 0) {//有二次渲染时间 long totalTime = finalDrawEndTime - createTime; //若是有等待时间,则减掉这段多余的时间 if (selectedTime > 0 && selectedTime > viewCreatedTime && selectedTime < finalDrawEndTime) { totalTime -= (selectedTime - viewCreatedTime); } return totalTime; } else {//以初次渲染时间为总体时间 return getInitialDrawTime(); } } 

这里减去的 viewCreatedTime 不是Fragment的 onCreate() 时间,而应该是 onViewCreated() 时间,由于从onCreate到onViewCreated之间的时间也是应该算在页面加载时间内,不该该减去,因此为了处理这种状况,咱们还须要对Fragment的onViewCreated方法进行埋点,埋点方式同 onCreate() 的埋点。

渲染时机不固定

此外经实践发现,因为不一样View在绘制子View时的绘制原理不同,有可能会致使如下状况的发生:

  • 没有切换至Fragment时,Fragment的View初次渲染已经完成,即View不可见的状况下也调用了 dispatchDraw()
  • 没有切换至Fragment时,Fragment的View初次渲染未完成,即直到View初次可见时 dispatchDraw() 才会调用。
  • 没有延迟加载时,当ViewPager没有切换到Fragment,而是直接发送请求后,请求回来时更新View,会调用 dispatchDraw() 进行二次渲染。
  • 没有延迟加载时,当ViewPager没有切换到Fragment,而是直接发送请求后,请求回来时更新View,不会调用 dispatchDraw() ,即直到切换到Fragment时才会进行二次渲染。

上面的问题总结来看,就是初次渲染时间和二次渲染时间中,可能会有个等待切换的时间,致使这两个时间变长,而这个切换时间点并非 onPageSelected() 方法调用的时候,由于该方法是在Fragment彻底滑动出来以后才会调用,而这个问题里的切换时间点,应该是指View初次展现的时候,也就是刚一滑动,ViewPager露出目标View的时间点。因而类比延迟加载的切换时间,咱们利用Listener的 onPageScrolled() 方法,在ViewPager滑动时,找到目标页面,为其记录一个滑动时间点 scrollToTime 。

public void onPageScrolled(int var1, float var2, int var3) { if(this.items != null) { int var4 = Math.round(var2); int var5 = var2 != (float)0 && var4 != 1?(var4 == 0?var1 + 1:-1):var1; int var6 = this.items.size(); for(int var7 = 0; var7 < var6; ++var7) { Object var8 = this.items.get(var7); if(var8 instanceof ItemInfo) { ItemInfo var9 = (ItemInfo)var8; if(var9.position == var5 && var9.object instanceof Fragment) { AutoSpeed.getInstance().onPageScroll(var9.object); break; } } } } } 

那么这样就能够解决两次渲染的偏差:

  • 初次渲染时间中, scrollToTime - viewCreatedTime 就是页面建立后,到初次渲染结束之间,由于等待滚动而产生的多余时间。
  • 二次渲染时间中, scrollToTime - apiLoadEndTime 就是请求完成后,到二次渲染结束之间,由于等待滚动而产生的多余时间。

因而在计算初次和二次渲染时间时,能够减去多余时间获得正确的值。

long getInitialDrawTime() {
    if (createTime <= 0 || initialDrawEndTime <= 0) {
        return -1; } if (scrollToTime > 0 && scrollToTime > viewCreatedTime && scrollToTime <= initialDrawEndTime) {//延迟初次渲染,须要减去等待的时间(viewCreated->changeToPage) return initialDrawEndTime - createTime - (scrollToTime - viewCreatedTime); } else {//正常初次渲染 return initialDrawEndTime - createTime; } } long getFinalDrawTime() { if (finalDrawEndTime <= 0 || apiLoadEndTime <= 0) { return -1; } //延迟二次渲染,须要减去等待时间(apiLoadEnd->scrollToTime) if (scrollToTime > 0 && scrollToTime > apiLoadEndTime && scrollToTime <= finalDrawEndTime) { return finalDrawEndTime - apiLoadEndTime - (scrollToTime - apiLoadEndTime); } else {//正常二次渲染 return finalDrawEndTime - apiLoadEndTime; } } 

总结

以上就是咱们对页面测速及自动化实现上作的一些尝试,目前已经在项目中使用,并在监控平台上能够获取实时的数据。咱们能够经过分析数据来了解页面的性能进而作优化,不断提高项目的总体质量。而且经过实践发现了一些测速偏差的问题,也都逐一解决,使得测速数据更加可靠。自动化的实现也让咱们在后续开发中的维护变得更容易,不用维护页面测速相关的逻辑,就能够作到实时监测全部页面的加载速度。

参考文献

 

 

Kotlin代码检查在美团的探索与实践

背景

Kotlin有着诸多的特性,好比空指针安全、方法扩展、支持函数式编程、丰富的语法糖等。这些特性使得Kotlin的代码比Java简洁优雅许多,提升了代码的可读性和可维护性,节省了开发时间,提升了开发效率。这也是咱们团队转向Kotlin的缘由,可是在实际的使用过程当中,咱们发现看似写法简单的Kotlin代码,可能隐藏着不容忽视的额外开销。本文剖析了Kotlin的隐藏开销,并就如何避免开销进行了探索和实践。

Kotlin的隐藏开销

伴生对象

伴生对象经过在类中使用companion object来建立,用来替代静态成员,相似于Java中的静态内部类。因此在伴生对象中声明常量是很常见的作法,但若是写法不对,可能就会产生额外开销。好比下面这段声明Version常量的代码:

class Demo { fun getVersion(): Int { return Version } companion object { private val Version = 1 } } 

表面上看还算简洁,可是将这段Kotlin代码转化成等同的Java代码后,却显得晦涩难懂:

public class Demo { private static final int Version = 1; public static final Demo.Companion Companion = new Demo.Companion(); public final int getVersion() { return Companion.access$getVersion$p(Companion); } public static int access$getVersion$cp() { return Version; } public static final class Companion { private static int access$getVersion$p(Companion companion) { return companion.getVersion(); } private int getVersion() { return Demo.access$getVersion$cp(); } } } 

与Java直接读取一个常量不一样,Kotlin访问一个伴生对象的私有常量字段须要通过如下方法:

  • 调用伴生对象的静态方法
  • 调用伴生对象的实例方法
  • 调用主类的静态方法
  • 读取主类中的静态字段

为了访问一个常量,而多花费调用4个方法的开销,这样的Kotlin代码无疑是低效的。

咱们能够经过如下解决方法来减小生成的字节码:

  1. 对于基本类型和字符串,能够使用const关键字将常量声明为编译时常量。
  2. 对于公共字段,能够使用@JvmField注解。
  3. 对于其余类型的常量,最好在它们本身的主类对象而不是伴生对象中来存储公共的全局常量。

Lazy()委托属性

lazy()委托属性能够用于只读属性的惰性加载,可是在使用lazy()时常常被忽视的地方就是有一个可选的model参数:

  • LazyThreadSafetyMode.SYNCHRONIZED:初始化属性时会有双重锁检查,保证该值只在一个线程中计算,而且全部线程会获得相同的值。
  • LazyThreadSafetyMode.PUBLICATION:多个线程会同时执行,初始化属性的函数会被屡次调用,可是只有第一个返回的值被当作委托属性的值。
  • LazyThreadSafetyMode.NONE:没有双重锁检查,不该该用在多线程下。

lazy()默认状况下会指定LazyThreadSafetyMode.SYNCHRONIZED,这可能会形成没必要要线程安全的开销,应该根据实际状况,指定合适的model来避免不须要的同步锁。

基本类型数组

在Kotlin中有3种数组类型:

  • IntArrayFloatArray,其余:基本类型数组,被编译成int[]float[],其余
  • Array<T>:非空对象数组
  • Array<T?>:可空对象数组

使用这三种类型来声明数组,能够发现它们之间的区别:

等同的Java代码:

后面两种方法都对基本类型作了装箱处理,产生了额外的开销。

因此当须要声明非空的基本类型数组时,应该使用xxxArray,避免自动装箱。

For循环

Kotlin提供了downTostepuntilreversed等函数来帮助开发者更简单的使用For循环,若是单一的使用这些函数确实是方便简洁又高效,但要是将其中两个结合呢?好比下面这样:

上面的For循环中结合使用了downTostep,那么等同的Java代码又是怎么实现的呢?

重点看这行代码:

IntProgression var10000 = RangesKt.step(RangesKt.downTo(10, 1), 2);

这行代码就建立了两个IntProgression临时对象,增长了额外的开销。

Kotlin检查工具的探索

Kotlin的隐藏开销不止上面列举的几个,为了不开销,咱们须要实现这样一个工具,实现Kotlin语法的检查,列出不规范的代码并给出修改意见。同时为了保证开发同窗的代码都是通过工具检查的,整个检查流程应该自动化。

再进一步考虑,Kotlin代码的检查规则应该具备扩展性,方便其余使用方定制本身的检查规则。

基于此,整个工具主要包含下面三个方面的内容:

  1. 解析Kotlin代码
  2. 编写可扩展的自定义代码检查规则
  3. 检查自动化

结合对工具的需求,在通过思考和查阅资料以后,肯定了三种可供选择的方案:

ktlint

ktlint 是一款用来检查Kotlin代码风格的工具,和咱们的工具定位不一样,须要通过大量的改造工做才行。

detekt

detekt 是一款用来静态分析Kotlin代码的工具,符合咱们的需求,可是不太适合Android工程,好比没法指定variant(变种)检查。另外,在整个检查流程中,一份kt文件只能检查一次,检查结果(当时)只支持控制台输出,不便于阅读。

改造Lint

改造Lint来增长Lint对Kotlin代码检查的支持,一方面Lint提供的功能彻底能够知足咱们的需求,同时还能支持资源文件和class文件的检查,另外一方面改造后的Lint和Lint很类似,学习上手的成本低。

相对于前两种方案,方案3的成本收益比最高,因此咱们决定改造Lint成Kotlin Lint(KLint)插件。

先来大体了解下Lint的工做流程,以下图:

很显然,上图中的红框部分须要被改造以适配Kotlin,主要工做有如下3点:

  • 建立KotlinParser对象,用来解析Kotlin代码
  • 从aar中获取自定义KLint规则的jar包
  • Detector类须要定义一套新的接口方法来适配遍历Kotlin节点回调时的调用

Kotlin代码解析

和Java同样,Kotlin也有本身的抽象语法树。惋惜的是目前尚未解析Kotlin语法树的单独库,只能经过Kotlin编译器这个库中的相关类来解析。KLint用的是kotlin-compiler-embeddable:1.1.2-5库。

public KtFile parseKotlinToPsi(@NonNull File file) {
        try {
        org.jetbrains.kotlin.com.intellij.openapi.project.Project ktProject = KotlinCoreEnvironment.Companion.createForProduction(() -> {
        }, new CompilerConfiguration(), CollectionsKt.emptyList()).getProject();
		this.psiFileFactory = PsiFileFactory.getInstance(ktProject); return (KtFile) psiFileFactory.createFileFromText(file.getName(), KotlinLanguage.INSTANCE, readFileToString(file, "UTF-8")); } catch (IOException e) { e.printStackTrace(); } return null; } //可忽视,只是将文件转成字符流 public static String readFileToString(File file, String encoding) throws IOException { FileInputStream stream = new FileInputStream(file); String result = null; try { result = readInputStreamToString(stream, encoding); } finally { try { stream.close(); } catch (IOException e) { // ignore } } return result; } 

以上这段代码能够封装成KotlinParser类,主要做用是将.Kt文件转化成KtFile对象。

在检查Kotlin文件时调用KtFile.acceptChildren(KtVisitorVoid)后,KtVisitorVoid便会屡次回调遍历到的各个节点(Node)的方法:

KtVisitorVoid visitorVoid = new KtVisitorVoid(){
	@Override
	public void visitClass(@NotNull KtClass klass) { super.visitClass(klass); } @Override public void visitPrimaryConstructor(@NotNull KtPrimaryConstructor constructor) { super.visitPrimaryConstructor(constructor); } @Override public void visitProperty(@NotNull KtProperty property) { super.visitProperty(property); } ... }; ktPsiFile.acceptChildren(visitorVoid); 

自定义KLint规则的实现

自定义KLint规则的实现参考了Android自定义Lint实践这篇文章。

上图展现了aar中容许包含的文件,aar中能够包含lint.jar,这也是Android自定义Lint实践这篇文章采用的实现方式。可是klint.jar不能直接放入aar中,固然更不该该将klint.jar重命名成lint.jar来实现目的。

最后采用的方案是:

  1. 经过建立klintrules这个空的aar,将klint.jar放入assets中;
  2. 修改KLint代码实现从assets中读取klint.jar
  3. 项目依赖klintrulesaar时使用debugCompile来避免把klint.jar带到release包。

Detector类中接口方法的定义

既然是对Kotlin代码的检查,天然Detector类要定义一套新的接口方法。先来看一下Java代码检查规则提供的方法:

相信写过Lint规则的同窗对上面的方法应该很是熟悉。为了尽可能下降KLint检查规则编写的学习成本,咱们参照JavaPsiScanner接口,定义了一套很是类似的接口方法:

KLint的实现

经过对上述3个主要方面的改造,完成了KLint插件。

因为KLint和Lint的类似,KLint插件简单易上手:

  1. 和Lint类似的编写规范(参考最后一节的代码);
  2. 支持@SuppressWarnings("")等Lint支持的注解;
  3. 具备和Lint的Options相同功能的klintOptions,以下:
mtKlint {
    klintOptions {
        abortOnError false
        htmlReport true htmlOutput new File(project.getBuildDir(), "mtKLint.html") } } 

检查自动化

  • 关于自动检查有两个方案:

    1. 在开发同窗commit/push代码时,触发pre-commit/push-hook进行检查,检查不经过不容许commit/push;
    2. 在建立pull request时,触发CI构建进行检查,检查不经过不容许merge。

    这里更偏向于方案2,由于pre-commit/push-hook能够经过--no-verify命令绕过,咱们但愿全部的Kotlin代码都是经过检查的。

KLint插件自己支持经过./gradlew mtKLint命令运行,可是考虑到几乎全部的项目在CI构建上都会执行Lint检查,把KLint和Lint绑定在一块儿能够省去CI构建脚本接入KLint插件的成本。

经过如下代码,将lint task依赖klint task,实如今执行Lint以前先执行KLint检查:

//建立KLint task,并设置被Lint task依赖
KLint klintTask = project.getTasks().create(String.format(TASK_NAME, ""), KLint.class, new KLint.GlobalConfigAction(globalScope, null, KLintOptions.create(project))) Set<Task> lintTasks = project.tasks.findAll { it.name.toLowerCase().equals("lint") } lintTasks.each { lint -> klintTask.dependsOn lint.taskDependencies.getDependencies(lint) lint.dependsOn klintTask } //建立Klint变种task,并设置被Lint变种task依赖 for (Variant variant : androidProject.variants) { klintTask = project.getTasks().create(String.format(TASK_NAME, variant.name.capitalize()), KLint.class, new KLint.GlobalConfigAction(globalScope, variant, KLintOptions.create(project))) lintTasks = project.tasks.findAll { it.name.startsWith("lint") && it.name.toLowerCase().endsWith(variant.name.toLowerCase()) } lintTasks.each { lint -> klintTask.dependsOn lint.taskDependencies.getDependencies(lint) lint.dependsOn klintTask } } 

检查实时化

虽然实现了检查的自动化,可是能够发现执行自动检查的时机相对滞后,每每是开发同窗准备合代码的时候,这时再去修改代码成本高而且存在风险。CI上的自动检查应该是做为是否有“漏网之鱼”的最后一道关卡,而问题应该暴露在代码编写的过程当中。基于此,咱们开发了Kotlin代码实时检查的IDE插件。

经过这款工具,实如今Android Studio的窗口实时报错,帮助开发同窗第一时间发现问题及时解决。

Kotlin代码检查实践

KLint插件分为Gradle插件和IDE插件两部分,前者在build.gradle中引入,后者经过Android Studio安装使用。

KLint规则的编写

针对上面列举的lazy()中未指定mode的case,KLint实现了对应的检查规则:

public class LazyDetector extends Detector implements Detector.KtPsiScanner { public static final Issue ISSUE = Issue.create( "Lazy Warning", "Missing specify `lazy` mode ", "see detail: https://wiki.sankuai.com/pages/viewpage.action?pageId=1322215247", Category.CORRECTNESS, 6, Severity.ERROR, new Implementation( LazyDetector.class, EnumSet.of(Scope.KOTLIN_FILE))); @Override public List<Class<? extends PsiElement>> getApplicableKtPsiTypes() { return Arrays.asList(KtPropertyDelegate.class); } @Override public KtVisitorVoid createKtPsiVisitor(KotlinContext context) { return new KtVisitorVoid() { @Override public void visitPropertyDelegate(@NotNull KtPropertyDelegate delegate) { boolean isLazy = false; boolean isSpeifyMode = false; KtExpression expression = delegate.getExpression(); if (expression != null) { PsiElement[] psiElements = expression.getChildren(); for (PsiElement psiElement : psiElements) { if (psiElement instanceof KtNameReferenceExpression) { if ("lazy".equals(((KtNameReferenceExpression) psiElement).getReferencedName())) { isLazy = true; } } else if (psiElement instanceof KtValueArgumentList) { List<KtValueArgument> valueArguments = ((KtValueArgumentList) psiElement).getArguments(); for (KtValueArgument valueArgument : valueArguments) { KtExpression argumentValue = valueArgument.getArgumentExpression(); if (argumentValue != null) { if (argumentValue.getText().contains("SYNCHRONIZED") || argumentValue.getText().contains("PUBLICATION") || argumentValue.getText().contains("NONE")) { isSpeifyMode = true; } } } } } if (isLazy && !isSpeifyMode) { context.report(ISSUE, expression,context.getLocation(expression.getContext()), "Specify the appropriate thread safety mode to avoid locking when it’s not needed."); } } } }; } } 

检查结果

Gradle插件和IDE插件共用一套规则,因此上面的规则编写一次,就能够同时在两个插件中使用:

  • CI上自动检查对应的检测结果的HTML页面:

  • Android Studio上对应的实时报错信息:

总结

借助KLint插件,编写检查规则来约束不规范的Kotlin代码,一方面避免了隐藏开销,提升了Kotlin代码的性能,另外一方面也帮助开发同窗更好的理解Kotlin。

参考资料

 

WMRouter:美团外卖Android开源路由框架

WMRouter是一款Android路由框架,基于组件化的设计思路,功能灵活,使用也比较简单。

WMRouter最初用于解决美团外卖C端App在业务演进过程当中的实际问题,以后逐步推广到了美团其余App,所以咱们决定将其开源,但愿更多技术同行一块儿开发,应用到更普遍的场景里去。Github项目地址与使用文档详见 https://github.com/meituan/WMRouter

本文先简单介绍WMRouter的功能和适用场景,而后详细介绍WMRouter的发展背景和过程。

功能简介

WMRouter主要提供URI分发、ServiceLoader两大功能。

URI分发功能可用于多工程之间的页面跳转、动态下发URI连接的跳转等场景,特色以下:

  1. 支持多scheme、host、path
  2. 支持URI正则匹配
  3. 页面配置支持Java代码动态注册,或注解配置自动注册
  4. 支持配置全局和局部拦截器,可在跳转前执行同步/异步操做,例如定位、登陆等
  5. 支持单次跳转特殊操做:Intent设置Extra/Flags、设置跳转动画、自定义StartActivity操做等
  6. 支持页面Exported控制,特定页面不容许外部跳转
  7. 支持配置全局和局部降级策略
  8. 支持配置单次和全局跳转监听
  9. 彻底组件化设计,核心组件都可扩展、按需组合,实现灵活强大的功能

基于SPI (Service Provider Interfaces) 的设计思想,WMRouter提供了ServiceLoader模块,相似Java中的java.util.ServiceLoader,但功能更加完善。经过ServiceLoader能够在一个App的多个模块之间经过接口调用代码,实现模块解耦,便于实现组件化、模块间通讯,以及和依赖注入相似的功能等。其特色以下:

  1. 使用注解自动配置
  2. 支持获取接口的全部实现,或根据Key获取特定实现
  3. 支持获取Class或获取实例
  4. 支持无参构造、Context构造,或自定义Factory、Provider构造
  5. 支持单例管理
  6. 支持方法调用

其余特性:

  1. 优化的Gradle插件,对编译耗时影响很小
  2. 编译期和运行时配置检查,避免配置冲突和错误
  3. 编译期自动添加Proguard混淆规则,免去手动配置的繁琐
  4. 完善的调试功能,帮助及时发现问题

适用场景

WMRouter适用但不限于如下场景:

  1. Native+H5混合开发模式,须要进行页面之间的互相跳转,或进行灵活的运营跳转连接下发。能够利用WMRouter统一页面跳转逻辑,根据不一样的协议(HTTP、HTTPS、用于Native页面的自定义协议)跳转对应页面,且在跳转过程当中能够使用UriInterceptor对跳转连接进行修改,例如跳转H5页面时在URL中加参数。

  2. 统一管理来自App外部的URI跳转。来自App外部的URI跳转,若是使用Android原生的Manifest配置,会直接启动匹配的Activity,而不少时候但愿先正常启动App打开首页,完成常规初始化流程(例如登陆、定位等)后再跳转目标页面。此时能够使用统一的Activity接收全部外部URI跳转,到首页时再用WMRouter启动目标页面。

  3. 页面跳转有复杂判断逻辑的场景。例如多个页面都须要先登陆、先定位后才容许打开,若是使用常规方案,这些页面都须要处理相同的业务逻辑;而利用WMRouter,只须要开发好UriInterceptor并配置到各个页面便可。

  4. 多工程、组件化、平台化开发。多工程开发要求各个工程之间能互相通讯,也可能遇到和外卖App相似的代码复用、依赖注入、编译等问题,这些问题均可以利用WMRouter的URI分发和ServiceLoader模块解决。

  5. 对业务埋点需求较强的场景。页面跳转做为最多见的业务逻辑之一,经常须要埋点。给每一个页面配置好URI,使用WMRouter统一进行页面跳转,并在全局的OnCompleteListener中埋点便可。

  6. 对App可用性要求较高的场景。一方面,能够对页面跳转失败进行埋点监控上报,及时发现线上问题;另外一方面,页面跳转时能够执行判断逻辑,发现异常(例如服务端异常、客户端崩溃等)则自动打开降级后的页面,保证关键功能的正常工做,或给用户友好的提示。

  7. 页面A/B测试、动态配置等场景。在WMRouter提供的接口基础上进行少许开发配置,就能够实现:根据下发的A/B测试策略跳转不一样的页面实现;根据不一样的须要动态下发一组路由表,相同的URI跳转到不一样的一组页面(实现方面能够自定义UriInterceptor,对匹配的URI返回301的UriResult使跳转重定向)。

基本概念解释

下面开始介绍WMRouter的发展背景和过程。为了方便后文的理解,咱们先简单了解和回顾几个基本概念。

路由

根据维基百科的解释,路由(routing)能够理解成在互联的网络经过特定的协议把信息从源地址传输到目的地址的过程。一个典型的例子就是在互联网中,路由器能够根据IP协议将数据发送到特定的计算机。

URI

URI(Uniform Resource Identifier,统一资源标识符)是一个用于标识某一互联网资源名称的字符串。URI的组成以下图所示。

一些常见的URI举例以下,包括平时常常用到的网址、IP地址、FTP地址、文件、打电话、发邮件的协议等。

在Android中也提供了android.net.Uri工具类用于处理URI,Android中URI经常使用的几个部分主要是scheme、host、path和query。

Android中的Intent跳转

在Android中的Intent跳转,分为显式跳转和隐式跳转两种。

显式跳转即指定ComponentName(类名)的Intent跳转,通常经过Bundle传参,示例代码以下:

Intent intent = new Intent(context, TestActivity.class);
intent.putExtra("param", "value")
startActivity(intent);

隐式跳转即不指定ComponentName的Intent跳转,经过IntentFilter找到匹配的组件,IntentFilter支持action、category和data的匹配,其中data就是URI。例以下面的代码,会启动系统默认的浏览器打开网页:

Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("http://www.meituan.com"))
startActivity(intent);

Activity经过Manifest配置IntentFilter,例以下面的配置能够匹配全部形如demo_scheme://demo_host/***的URI。

<activity android:name=".app.UriProxyActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW"/> <category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.BROWSABLE"/> <data android:scheme="demo_scheme" android:host="demo_host"/> </intent-filter> </activity> 

URI跳转

在美团外卖C端早期开发过程当中,产品但愿经过后台下发URI控制客户端跳转指定页面,从而实现灵活的运营配置。外卖App采用了Native+H5的混合开发模式,Native页面定义了专用的URI,而H5页面则使用HTTP/HTTPS连接在专门的WebView容器中加载,两种连接的跳转逻辑不一样,实现起来比较繁琐。

Native页面的URI跳转最开始使用的是Android原生的IntentFilter,经过隐式跳转启动,可是这种方式存在灵活性差、功能缺失、Bug多等问题。例如:

  1. 从外部(浏览器、微信等)跳转外卖的URI时,系统会直接打开相应的Activity,而没有通过欢迎页的正常启动流程,一些代码逻辑可能没有执行,例如定位逻辑。

  2. 有不少页面在打开前须要确保用户先登陆或先定位,每一个页面都写一遍判断登陆、定位的逻辑很是麻烦,提升了开发维护成本。

  3. 运营人员可能会配错URI,页面跳转失败,有些跳转的地方没有作try-catch处理,会产生Crash;有些地方虽然加了try-catch,但跳转失败后没有任何响应,用户体验差;跳转失败没有监控,不能及时发现和解决线上业务异常。

为了解决上述问题,咱们但愿有一个Android的URI分发组件,能够根据URI中不一样的scheme、host、path,进行不一样的处理,同时可以在页面跳转过程当中进行更灵活的干预。调研发现,现有的一些Android路由组件主要都是在解决多工程之间解耦的问题,而URI每每只支持经过path分发,页面跳转的配置也不够灵活,难以知足实际须要。因而咱们决定自行设计实现。

核心设计思路

下图展现了WMRouter中URI分发机制的核心设计思路。借鉴网络请求的机制,WMRouter中的每次URI跳转视为发起一个UriRequest;URI跳转请求被WMRouter逐层分发给一系列的UriHandler进行处理;每一个UriHandler处理以前能够被UriInterceptor拦截,并插入一些特殊操做。

页面跳转来源

常见的页面跳转来源以下:

  1. 来自App内部Native页面的跳转
  2. 来自App内Web容器的跳转,即H5页面发起的跳转
  3. 从App外经过URI唤起App的跳转,例如来自浏览器、微信等
  4. 从通知中心Push唤起App的跳转

对于来自App内部和Web容器的跳转,咱们把全部跳转代码统一改为调用WMRouter处理,而来自外部和Push通知的跳转则所有使用一个独立的中转Activity接收,再调用WMRouter处理。

UriRequest

UriRequest中包含Context、URI和Fields,其中Fields为HashMap<string, object="">,能够经过Key存听任意数据。简单起见,UriRequest类同时承担了Response的功能,跳转请求的结果,也会被保存到Fields中。Fields能够根据须要自定义,其中一些常见字段举例以下:

  • Intent的Extra参数,Bundle类型
  • 用于startActivityForResult的RequestCode,int类型
  • 用于overridePendingTransition方法的页面切换动画资源,int[]类型
  • 本次跳转结果的监听器,OnCompleteListener类型

每次URI跳转请求会有一个ResultCode(相似HTTP请求的ResponseCode),表示跳转结果,也存放在Fields中。常见Code以下,用户也能够自定义Code:

  • 200:跳转成功
  • 301:重定向到其余URI,会再次跳转
  • 400:请求错误,一般是Context或URI为空
  • 403:禁止跳转,例如跳转白名单之外的HTTP连接、Activity的exported为false等
  • 404:找不到目标(Activity或UriHandler)
  • 500:发生错误

总结来讲,UriRequest用于实现一次URI跳转中全部组件之间的通讯功能。

UriHandler

UriHandler用于处理URI跳转请求,能够嵌套从而逐层分发和处理请求。UriHandler是异步结构,接收到UriRequest后处理(例如跳转Activity等),若是处理完成,则调用callback.onComplete()并传入ResultCode;若是没有处理,则调用callback.onNext()继续分发。下面的示例代码展现了一个只处理HTTP连接的UriHandler的实现:

public interface UriCallback { /** * 处理完成,继续后续流程。 */ void onNext(); /** * 处理完成,终止分发流程。 * * @param resultCode 结果 */ void onComplete(int resultCode); } public class DemoUriHandler extends UriHandler { public void handle(@NonNull final UriRequest request, @NonNull final UriCallback callback) { Uri uri = request.getUri(); // 处理HTTP连接 if ("http".equalsIgnoreCase(uri.getScheme())) { try { // 调用系统浏览器 Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setData(uri); request.getContext().startActivity(intent); // 跳转成功 callback.onComplete(UriResult.CODE_SUCCESS); } catch (Exception e) { // 跳转失败 callback.onComplete(UriResult.CODE_ERROR); } } else { // 非HTTP连接不处理,继续分发 callback.onNext(); } } // ... } 

UriInterceptor

UriInterceptor为拦截器,不作最终的URI跳转操做,但能够在最终的跳转前进行各类同步/异步操做,常见操做举例以下:

  • URI跳转拦截,禁止特定的URI跳转,直接返回403(例如禁止跳转非meituan域名的HTTP连接)
  • URI参数修改(例如在HTTP连接末尾添加query参数)
  • 各类中间处理(例如打开登陆页登陆、获取定位、发网络请求)
  • ……

每一个UriHandler均可以添加若干UriInterceptor。在UriHandler基类中,handle()方法先调用抽象方法shouldHandle()判断是否要处理UriRequest,若是须要处理,则逐个执行Interceptor,最后再调用handleInternal()方法进行跳转操做。

public abstract class UriHandler { // ChainedInterceptor将多个UriInterceptor合并成一个 protected ChainedInterceptor mInterceptor; public UriHandler addInterceptor(@NonNull UriInterceptor interceptor) { if (interceptor != null) { if (mInterceptor == null) { mInterceptor = new ChainedInterceptor(); } mInterceptor.addInterceptor(interceptor); } return this; } public void handle(@NonNull final UriRequest request, @NonNull final UriCallback callback) { if (shouldHandle(request)) { if (mInterceptor != null) { mInterceptor.intercept(request, new UriCallback() { @Override public void onNext() { handleInternal(request, callback); } @Override public void onComplete(int result) { callback.onComplete(result); } }); } else { handleInternal(request, callback); } } else { callback.onNext(); } } /** * 是否要处理给定的uri。在Interceptor以前调用。 */ protected abstract boolean shouldHandle(@NonNull UriRequest request); /** * 处理uri。在Interceptor以后调用。 */ protected abstract void handleInternal(@NonNull UriRequest request, @NonNull UriCallback callback); } 

URI的分发与降级

在外卖C端App中的URI分发示意以下图。全部URI跳转都会分发到RootUriHandler,而后根据不一样的scheme分发到不一样的子Handler。例如waimai协议分发到WmUriHandler,而后进一步根据不一样的path分发到子Handler,从而启动相应的Activity;HTTP/HTTPS协议分发到HttpHandler,启动WebView容器;对于其余类型URI(tel、mailto等),前面的几个Handler都没法处理,则会分发到StartUriHandler,尝试使用Android原生的隐式跳转启动系统应用。

每一个UriHandler均可以根据实际须要实现降级策略,也能够不做处理继续分发给其余UriHandler。RootUriHandler中提供了一个全局的分发完成事件监听器,当UriHandler处理失败返回异常ResultCode或全部子UriHandler都没有处理时,执行全局降级策略。

平台化与两端复用

随着外卖C端业务的演进,团队成员扩充了数倍,商超生鲜等垂直品类的拆分,以及异地研发团队的创建,客户端的平台化被提上日程。关于外卖平台化更详细的内容,可参考团队以前已经发布的文章 美团外卖Android平台化架构演进实践

为了知足实际开发须要,在长时间的探索后,逐步造成了如图所示的三层工程结构。

原有的单个工程拆分红多个工程,就不可避免的涉及到多工程之间的耦合问题,主要包括通讯问题、复用问题、依赖注入、编译问题,下面详细介绍。

通讯问题

当原先的一个工程拆分到各个业务库后,业务库之间的页面须要进行通讯,最主要的场景就是页面跳转并经过Intent传递参数。

原先的页面跳转使用显式跳转,Activity之间存在强引用,当Activity被拆分到不一样的业务库,业务库不能直接互相依赖,所以须要进行解耦。

利用WMRouter的URI分发机制,恰好能够很容易的解决这个问题。将将全部业务库的Activity注册到WMRouter,各个业务库之间就能够进行页面跳转了。

此时WMRouter已经承载了两项功能:

  1. 后台下发的运营URI跳转 (waimai://*)
  2. 内部页面跳转,用于代替原有的显式跳转,实现工程解耦 (wm_router://page/*)

因为后台下发的URI是和产品、运营、H五、iOS等各端统一制定的协议,支持的页面、格式、参数等都不能随意改动,而内部页面跳转使用的URI,则须要根据实际开发须要进行配置,两套URI协议不能兼容,所以使用了不一样的scheme。

复用问题与ServiceLoader模块

业务库之间常常须要复用代码。一些通用代码逻辑能够下沉到平台层从而复用,例如业务无关的通用View组件;而有些代码不适合下沉到平台层,例如业务库A要使用业务库B中的某个界面模块,而这个界面模块和业务库B的耦合很紧密。具体到外卖实际业务场景中,商家页在商家休息时会展现推荐商家列表,其样式和首页相同(如图),而两个页面不在一个工程中,商家页但愿能直接从首页业务库中获取商家列表的实现。

为了解决上述问题,咱们调研了解到Java中SPI (Service Provider Interfaces) 的设计思想与java.util.ServiceLoader工具类,还学习到美团平台为了解决相似问题而开发的ServiceLoader组件。

考虑到实际需求差别,咱们从新开发了本身的ServiceLoader实现。相比Java中的实现,WMRouter的实现借鉴了美团平台的设计思路,不只支持经过接口获取全部实现类,还支持经过接口和惟一的Key获取特定的实现类。另外WMRouter的实现还支持直接加载实现类的Class、用Factory和Provider建立对象、单例管理、方法调用等功能。在Gradle插件的实现思路上,借鉴了美团平台的ServiceLoader并作了性能优化,给平台提出了改进建议。

业务库之间代码复用的需求示意如图,业务库A须要复用业务库B中的ServiceImpl但又不能直接引用,所以经过WMRouter加载:

  1. 抽取接口(或父类,后面再也不重复说明)下沉到平台层,实现类ServiceImpl实现该接口,并声明一个Key(字符串类型)。
  2. 调用方经过接口和Key,由ServiceLoader加载实现类,经过接口访问实现类。

URI跳转和ServiceLoader看起来彷佛没有关联,但通讯和复用需求的本质均可以理解成路由,页面经过URI分发跳转时的协议是Activity+URI,在这里ServiceLoader的协议是Interface+Key。

依赖注入

为了兼容外卖独立App和美团App外卖频道的两端差别,平台层的一些接口要在两个主工程分别实现,并注入到底层。常规Java代码注入的方式写起来很繁琐,而使用WMRouter的ServiceLoader功能能够更简单的实现和依赖注入相似的效果。

对于WMRouter来讲,全部依赖它的工程(包括主工程、业务库、平台库)都是同样的,任何一个库想要调用其余库中的代码,均可以经过WMRouter路由转发。前面的通讯和复用问题,是同级的业务库之间经过WMRouter调用,而依赖注入则是底层库经过WMRouter调用上层库,其本质和实现都是相同的。

ServiceLoader模块在加载实现类时提供了单例管理功能,可用于管理一些全局的Service/Manager,例如用户帐户管理类UserManager

编译问题

因为历史缘由,主工程做为一个没有业务逻辑的壳工程,对业务库却有较多依赖,特别是对业务库的初始化配置繁琐,和各业务库耦合紧密。另外一方面,WMRouter跳转的页面、加载的实现类,须要在Application初始化时注册到WMRouter中,也会增长主工程和业务库的耦合。开发过程当中各业务库频繁更新,常常出现没法编译的问题,严重影响开发。

为了解决这个问题,一方面WMRouter增长了注解支持,在Activity类、ServiceLoader实现类上配置注解,就能够在编译期间自动生成代码注册到WMRouter中。

// 没有注解时,须要在Application初始化时代码注册各个页面,耦合严重
register("/home", HomeActivity.class);
register("/order", OrderListActivity.class);
register("/shop", ShopActivity.class)
register("/account", MyAccountActivity.class); register("/address", MyAddressActivity.class); // ... 
// 增长注解后,只须要在各个Activity上经过注解配置便可
@RouterUri(path = "/shop")
public class ShopActivity extends BaseActivity { } 

另外一方面,ServiceLoader还支持指定接口加载全部实现类,主工程能够经过统一接口,加载各个业务库中全部实现类并进行初始化,最终实现和业务库的完全隔离。

开发过程当中,各个业务库能够像插件同样按需集成到主工程,能大幅减小不能编译的问题,同时因为编译时能够跳过不须要的业务库,编译速度也能获得提升。

WMRouter的推广

在WMRouter解决了外卖C端App的各类问题后,发现公司内甚至公司外的其余App也遇到了类似的问题和需求,因而决定对WMRouter进行推广和开源。

因为WMRouter是一个开放式组件化框架,UriRequest能够存听任意数据,UriHandler、UriInterceptor能够彻底自定义,不一样的UriHandler能够任意组合,具备很大的灵活性。但过于灵活容易致使易用性的降低,即便对于最常规最简单的应用,也须要复杂的配置才能完成功能。

为了在灵活性与易用性之间平衡,一方面,WMRouter对包结构进行了合理的划分,核心接口和实现类提供基础通用能力,尽量保留最大的灵活性;另外一方面,封装了一系列通用实现类,并组合成一套默认实现,从而知足绝大多数常规使用场景,尽量下降其余App的接入成本,方便推广。

总结

目前业界已有的一些Android路由框架,不能知足外卖C端App在开发过程当中的实际须要,所以咱们开发了WMRouter路由框架。借鉴网络请求的思想,设计了基于UriRequest、UriHandler、UriInterceptor的URI分发机制,在保证功能灵活强大的同时,又尽量的下降了使用难度;另外一方面,借鉴SPI的设计思想、Java和美团平台的ServiceLoader实现,开发了本身的ServiceLoader模块,解决外卖平台化过程当中的四个问题(通讯问题、复用问题、依赖注入、编译问题)。在通过了近一年的不断迭代完善后,WMRouter已经成为美团多个App中的核心基础组件之一。

参考资料

  1. Routing - Wikipedia
  2. 统一资源标志符 - 维基百科
  3. RFC 3966 - The tel URI for Telephone Numbers
  4. RFC 6068 - The ‘mailto’ URI Scheme
  5. Intent 和 Intent 过滤器
  6. Introduction to the Service Provider Interfaces
  7. 美团外卖Android平台化架构演进实践

 

美团外卖客户端高可用建设体系

背景

美团外卖从2013年11月开始起步,通过数年的高速发展,一直在不断地刷新着记录。2018年5月19日,日订单量峰值突破2000万单,已经成为全球规模最大的外卖平台。业务的快速发展对系统稳定性提出了更高的要求,如何为线上用户提供高稳定的服务体验,保障全链路业务和系统高可用运行,不只须要后端服务支持,更须要在端上提供全面的技术保障。而相对服务端而言,客户端运行环境千差万别,不可控因素多,面对突发问题应急能力差。所以,构建客户端的高可用建设体系,保障服务稳定高可用,不只是对工程师的技术挑战,也是外卖平台的核心竞争力之一。

高可用建设体系的思路

一个设计良好的大型客户端系统每每是由一系列各自独立的小组共同开发完成的,每个小组都应当具备明肯定义的的职责划分。各业务模块之间推行“松耦合”开发模式,让业务模块拥有隔离式变动的能力,是一种能够同时提高开发灵活性和系统健壮性的有效手段。这是美团外卖总体的业务架构,总体上以商品交易链路(门店召回,商品展现,交易)为核心方向进行建设,局部上依据业务特色和团队分工分红多个可独立运维单元单独维护。可独立运维单元的简单性是可靠性的前提条件,这使得咱们可以持续关注功能迭代,不断完成相关的工程开发任务。

咱们将问题依照生命周期划分为三个阶段:发现、定位、解决,围绕这三个阶段的持续建设,构成了美团外卖高可用建设体系的核心。

美团外卖质量保障体系全景图

这是美团外卖客户端总体质量体系全景图。总体思路:监控报警,日志体系,容灾。

经过采集业务稳定性,基础能力稳定性,性能稳定性三大类指标数据并上报,衡量客户端系统质量的标准得以完善;经过设立基线,应用特定业务模型对这一系列指标进行监控报警,客户端具有了分钟级感知核心链路稳定性的能力;而经过搭建日志体系,整个系统有了提取关键线索能力,多维度快速定位问题。当问题一旦定位,咱们就能经过美团外卖的线上运维规范进行容灾操做:降级,切换通道或限流,从而保证总体的核心链路稳定性。

监控&报警

监控系统,处于整个服务可靠度层级模型的最底层,是运维一个可靠的稳定系统必不可少的重要组成部分。为了保障全链路业务和系统高可用运行,须要在用户感知问题以前发现系统中存在的异常,离开了监控系统,咱们就没有能力分辨客户端是否是在正常提供服务。

按照监控的领域方向,能够分红系统监控与业务监控。

系统监控,主要用于基础能力如端到端成功率,服务响应时长,网络流量,硬件性能等相关的监控。系统监控侧重在无业务侵入和定制系统级别的监控,更多侧重在业务应用的底层,多属于单系统级别的监控。

业务监控,侧重在某个时间区间,业务的运行状况分析。业务监控系统构建于系统监控之上,能够基于系统监控的数据指标计算,并基于特定的业务介入,实现多系统之间的数据联合与分析,并根据相应的业务模型,提供实时的业务监控与告警。按照业务监控的时效性,能够继续将其细分红实时业务监控与离线业务监控。

  • 实时业务监控,经过实时的数据采集分析,帮助快速发现及定位线上问题,提供告警机制及介入响应(人工或系统)途径,帮助避免发生系统故障。
  • 离线的业务监控,对必定时间段收集的数据进行数据挖掘、聚合、分析,推断出系统业务可能存在的问题,帮助进行业务上的从新优化或改进的监控。

美团外卖的业务监控,大部分属于实时业务监控。借助美团统一的系统监控建设基础,美团外卖联合公司其余部门将部分监控基础设施进行了改造、共建和整合复用,并打通造成闭环(监控,日志,回捞),咱们构建了特定符合外卖业务流程的实时业务监控; 而离线的业务监控,主要经过用户行为的统计与业务数据的挖掘分析,来帮助产品设计,运营策略行为等产生影响,目前这部分监控主要由美团外卖数据组提供服务。值得特别说明的是单纯的信息汇总展现,无需或没法当即作出介入动做的业务监控,能够称之为业务分析,如特定区域的活动消费状况、区域订单数量、特定路径转换率、曝光点击率等,除非这些数据用来决策系统实时状态健康状况,帮助产生系统维护行为,不然这部分监控由离线来处理更合适。

咱们把客户端稳定性指标分为3类维度:业务稳定性指标,基础能力稳定性指标,性能稳定性指标。对不一样的指标,咱们采用不一样的采集方案进行提取上报,汇总到不一样系统;在设定完指标后,咱们就能够制定基线,并依照特定的业务模型制定报警策略。美团外卖客户端拥有超过40项度量质量指标,其中25项指标支持分钟级别报警。报警通道依据紧急程度支持邮件,IM和短信三条通道。所以,咱们团队具有及时发现影响核心链路稳定性的关键指标变化能力。

一个完善的监控报警系统是很是复杂的,所以在设计时必定要追求简化。如下是《Site Reliability Engineering: How Google Runs Production Systems》一书中提到的告警设置原则:

最能反映真实故障的规则应该可预测性强,很是可靠,而且越简单越好 不经常使用的数据采集,汇总以及告警配置应该定时清除(某些SRE团队的标准是一季度未使用即删除) 没有暴露给任何监控后台、告警规则的采集数据指标应该定时清除

经过监控&报警系统,2017年下半年美团外卖客户端团队共发现影响核心链路稳定性超过20起问题:包括爬虫、流量、运营商403问题、性能问题等。目前,全部问题均已所有改造完毕。

日志体系

监控系统的一个重要特征是生产紧急告警。一旦出现故障,须要有人来调查这项告警,以决定目前是否存在真实故障,是否须要采起特定方法缓解故障,直至查出致使故障的问题根源。

简单定位和深刻调试的过程必需要保持很是简单,必须可以被团队中任何一我的所理解。日志体系,在简化这一过程当中起到了决定性做用。

美团外卖的日志体系整体分为3大类:即全量日志系统,个体日志系统,异常日志系统。全量日志系统,主要负责采集总体性指标,如网络可用性,埋点可用性,咱们能够经过他了解到系统总体大盘,了解总体波动,肯定问题影响范围;异常日志系统,主要采集异常指标,如大图问题,分享失败,定位失败等,咱们经过他能够迅速获取异常上下文信息,分析解决问题;而个体日志系统,则用于提取个体用户的关键信息,从而针对性的分析特定客诉问题。这三类日志,构成了完整的客户端日志体系。

日志的一个典型使用场景是处理单点客诉问题,解决系统潜在隐患。个体日志系统,用于简化工程师提取关键线索步骤,提高定位分析问题效率。在这一领域,美团外卖使用的是点评平台开发的Logan服务。做为美团移动端底层的基础日志库,Logan接入了集团众多日志系统,例如端到端日志、用户行为日志、代码级日志、崩溃日志等,而且这些日志所有都是本地存储,且有多重加密机制和严格的权限审核机制,在处理用户客诉时才对数据进行回捞和分析,保证用户隐私安全。

经过设计和实施美团外卖核心链路日志方案,咱们打通了用户交易流程中各系统如订单,用户中心,Crash平台与Push后台之间的底层数据同步;经过输出标准问题分析手册,针对常见个体问题的分析和处理得以标准化;经过制定日志捞取SOP并按期演练,线上追溯能力大幅提高,平常客诉绝大部分可在30分钟内定位缘由。在这一过程当中,经过个体暴露出影响核心链路稳定性的问题也均已所有改进/修复。

故障排查是运维大型系统的一项关键技能。采用系统化的工具和手段而不只仅依靠经验甚至运气,这项技能是能够自我学习,也能够内部进行传授。

容灾备份

针对不一样级别的服务,应该采起不一样的手段进行有效止损。非核心依赖,经过降级向用户提供可伸缩的服务;而核心依赖,采用多通道方式进行依赖备份容灾保证交易路径链路的高可用;异常流量,经过多维度限流,最大限度保证业务可用性的同时,给予用户良好的体验。总结成三点,即:非核心依赖降级、核心依赖备份、过载保护限流。接下来咱们分别来阐述这三方面。

降级

在这里选取美团外卖客户端总体系统结构关系图来介绍非核心依赖降级建设概览。图上中间红色部分是核心关键节点,即外卖业务的核心链路:定位,商家召回,商品展现,下单;蓝色部分,是核心链路依赖的关键服务;黄色部分,是可降级服务。咱们经过梳理依赖关系,改造先后端通信协议,实现了客户端非核心依赖可降级;然后端服务,经过各级缓存,屏蔽隔离策略,实现了业务模块内部可降级,业务之间可降级。这构成了美团外卖客户端总体的降级体系。

右边则是美团外卖客户端业务/技术降级开关流程图。经过推拉结合,缓存更新策略,咱们可以分钟级别同步降级配置,快速止损。

目前,美团外卖客户端有超过20项业务/能力支持降级。经过有效降级,咱们避开了1次S2级事故,屡次S三、S4级事故。此外,降级开关总体方案产出SDK horn,推广至集团酒旅、金融等其余核心业务应用。

备份

核心依赖备份建设上,在此重点介绍美团外卖多网络通道。网络通道,做为客户端的最核心依赖,倒是整个全链路体系最不可控的部分,常常出现问题:网络劫持,运营商故障,甚至光纤被物理挖断等大大小小的故障严重影响了核心链路的稳定性。所以,治理网络问题,必需要建设可靠的多通道备份。

这是美团外卖多网络通道备份示意图。美团外卖客户端拥有Shark、HTTP、HTTPS、HTTP DNS等四条网络通道:总体网络以Shark长连通道为主通道,其他三条通道做为备份通道。配合完备的开关切换流程,能够在网络指标发生骤降时,实现分钟级别的分城市网络通道切换。而经过制定故障应急SOP并不断演练,提高了咱们解决问题的能力和速度,有效应对各种网络异常。咱们的网络通道开关思路也输出至集团其余部门,有效支持了业务发展。

限流

服务过载是另外一类典型的事故。究其缘由大部分状况下都是因为少数调用方调用的少数接口性能不好,致使对应服务的性能恶化。若调用端缺少有效降级容错,在某些正常状况下可以下降错误率的手段,如请求失败后重试,反而会让服务进一步性能恶化,甚至影响原本正常的服务调用。

美团外卖业务在高峰期订单量已达到了至关高的规模量级,业务系统也及其复杂。根据以往经验,在业务高峰期,一旦出现异常流量疯狂增加从而致使服务器宕机,则损失不可估量。

所以,美团外卖先后端联合开发了一套“流量控制系统”,对流量实施实时控制。既能平常保证业务系统稳定运转,也能在业务系统出现问题的时候提供一套优雅的降级方案,最大限度保证业务的可用性,在将损失降到最低的前提下,给予用户良好的体验。

整套系统,后端服务负责识别打标分类,经过统一的协议告诉前端所标识类别;而前端,经过多级流控检查,对不一样流量进行区分处理:弹验证码,或排队等待,或直接处理,或直接丢弃。

面对不一样场景,系统支持多级流控方案,可有效拦截系统过载流量,防止系统雪崩。此外,整套系统拥有分接口流控监控能力,可对流控效果进行监控,及时发现系统异常。整套方案在数次异常流量增加的故障中,经受住了考验。

发布

随着外卖业务的发展,美团外卖的用户量和订单量已经达到了至关的量级,在线直接全量发布版本/功能影响范围大,风险高。

版本灰度和功能灰度是一种可以平滑过渡的发布方式:即在线上进行A/B实验,让一部分用户继续使用产品(特性)A,另外一部分用户开始使用产品(特性)B。若是各项指标平稳正常,结果符合预期,则扩大范围,将全部用户都迁移到B上来,不然回滚。灰度发布能够保证系统的稳定,在初试阶段就能够发现问题,修复问题,调整策略,保证影响范围不被扩散。

美团外卖客户端在版本灰度及功能灰度已较为完善。

版本灰度 iOS采用苹果官方提供的分阶段发布方式,Android则采用美团自研的EVA包管理后台进行发布。这两类发布均支持逐步放量的分发方式。

功能灰度 功能发布开关配置系统依据用户特征维度(如城市,用户ID)发布,而且整个配置系统有测试和线上两套不一样环境,配合固定的上线窗口,保证上线的规范性。

对应的,相应的监控基础设施也支持分用户特征维度(如城市,用户ID)监控,避免了那些没法在总体大盘体现的灰度异常。此外,不管版本灰度或功能灰度,咱们均有相应最小灰度周期和回滚机制,保证整个灰度发布过程可控,最小化问题影响。

线上运维

在故障来临时如何应对,是整个质量保障体系中最关键的环节。没有人天生就能完美的处理紧急状况,面对问题,恰当的处理须要平时不断的演练。

围绕问题的生命周期,即发现、定位、解决(预防),美团外卖客户端团队组建了一套完备的处理流程和规范来应对影响链路稳定性的各种线上问题。总体思路:创建规范,提早建设,有效应对,过后总结。在不一样阶段用不一样方式解决不一样问题,事前肯定完整的事故流程管理策略,并确保平稳实施,常常演练,问题的平均恢复时间大大下降,美团外卖核心链路的高稳定性才可以得以保障。

将来展望

当前美团外卖业务仍然处于快速增加期。伴随着业务的发展,背后支持业务的技术系统也日趋复杂。在美团外卖客户端高可用体系建设过程当中,咱们但愿可以经过一套智能化运维系统,帮助工程师快速、准确的识别核心链路各子系统异常,发现问题根源,并自动执行对应的异常解决预案,进一步缩短服务恢复时间,从而避免或减小线上事故影响。

诚然,业界关于自动化运维的探索有不少,但多数都集中在后台服务领域,前端方向成果较少。咱们外卖技术团队目前也在同步的探索中,正处于基础性建设阶段,欢迎更多业界同行跟咱们一块儿讨论、切磋。

参考资料

  1. Site Reliability Engineering: How Google Runs Production Systems
  2. 美团移动端基础日志库——Logan
  3. 美团移动网络优化实践

 

iOS 覆盖率检测原理与增量代码测试覆盖率工具实现

背景

对苹果开发者而言,因为平台审核周期较长,客户端代码致使的线上问题影响时间每每比较久。若是在开发、测试阶段可以提早暴露问题,就有助于避免线上事故的发生。代码覆盖率检测正是帮助开发、测试同窗提早发现问题,保证代码质量的好帮手。

对于开发者而言,代码覆盖率能够反馈两方面信息:

  1. 自测的充分程度。
  2. 代码设计的冗余程度。

尽管代码覆盖率对代码质量有着上述好处,但在 iOS 开发中却使用的很少。咱们调研了市场上经常使用的 iOS 覆盖率检测工具,这些工具主要存在如下四个问题:

  1. 第三方工具备时生成的检测报告文件会出错甚至会失败,开发者对覆盖率生成原理不了解,遇到这类问题容易弃用工具。
  2. 第三方工具每次展现全量的覆盖率报告,会分散开发者的不少精力在未修改部分。而在绝大多数状况下,开发者的关注重点在本次新增和修改的部分。
  3. Xcode 自带的覆盖率检测只适用于单元测试场景,因为需求变动频繁,业务团队开发单元测试的成本很高。
  4. 已有工具很难和现有开发流程结合起来,须要额外进行测试,运行覆盖率脚本才能获取报告文件。

为了解决上述问题,咱们深刻调研了覆盖率报告的生成逻辑,并结合团队的开发流程,开发了一套嵌入在代码提交流程中、基于单次代码提交(git commit)生成报告、对开发者透明的增量代码测试覆盖率工具。开发者只须要正常开发,经过模拟器测试开发代码,commit 本次代码(commit 和测试顺序可交换),推送(git push)到远端,就能够在本地看到此次提交代码的详细覆盖率报告了。

本文分为两部分,先从介绍通用覆盖率检测的原理出发,让读者对覆盖率的收集、解析有直观的认识。以后介绍咱们增量代码测试覆盖率工具的实现。

覆盖率检测原理

生成覆盖率报告,首先须要在 Xcode 中配置编译选项,编译后会为每一个可执行文件生成对应的 .gcno 文件;以后在代码中调用覆盖率分发函数,会生成对应的 .gcda 文件。

其中,.gcno 包含了代码计数器和源码的映射关系, .gcda 记录了每段代码具体的执行次数。覆盖率解析工具须要结合这两个文件给出最后的检测报表。接下来先看看 .gcno 的生成逻辑。

.gcno

利用 Clang 分别生成源文件的 AST 和 IR 文件,对比发现,AST 中不存在计数指令,而 IR 中存在用来记录执行次数的代码。搜索 LLVM 源码能够找到覆盖率映射关系生成源码。覆盖率映射关系生成源码是 LLVM 的一个 Pass,(下文简称 GCOVPass)用来向 IR 中插入计数代码并生成 .gcno 文件(关联计数指令和源文件)。

下面分别介绍IR插桩逻辑和 .gcno 文件结构。

IR 插桩逻辑

代码行是否执行到,须要在运行中统计,这就须要对代码自己作一些修改,LLVM 经过修改 IR 插入了计数代码,所以咱们不须要改动任何源文件,仅需在编译阶段增长编译器选项,就能实现覆盖率检测了。

从编译器角度看,基本块(Basic Block,下文简称 BB)是代码执行的基本单元,LLVM 基于 BB 进行覆盖率计数指令的插入,BB 的特色是:

  1. 只有一个入口。
  2. 只有一个出口。
  3. 只要基本块中第一条指令被执行,那么基本块内全部指令都会顺序执行一次。

覆盖率计数指令的插入会进行两次循环,外层循环遍历编译单元中的函数,内层循环遍历函数的基本块。函数遍历仅用来向 .gcno 中写入函数位置信息,这里再也不赘述。

一个函数中基本块的插桩方法以下:

  1. 统计全部 BB 的后继数 n,建立和后继数大小相同的数组 ctr[n]。
  2. 之后继数编号为序号将执行次数依次记录在 ctr[i] 位置,对于多后继状况根据条件判断插入。

举个例子,下面是一段猜数字的游戏代码,当玩家猜中了咱们预设的数字10的时候会输出Bingo,不然输出You guessed wrong!。这段代码的控制流程图如图1所示(猜数字游戏 )。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSLog(@"Welcome to the game");
    if (guessNumber == 10) {
        NSLog(@"Bingo!"); } else { NSLog(@"You guess is wrong!"); } } 

这段代码若是开启了覆盖率检测,会生成一个长度为 6 的 64 位数组,对照插桩位置,方括号中标记了桩点序号,图 1 中代码前数字为所在行数。

图 1 桩点位置

图 1 桩点位置

 

.gcno计数符号和文件位置关联

.gcno 是用来保存计数插桩位置和源文件之间关系的文件。GCOVPass 在经过两层循环插入计数指令的同时,会将文件及 BB 的信息写入 .gcno 文件。写入步骤以下:

  1. 建立 .gcno 文件,写入 Magic number(oncg+version)。
  2. 随着函数遍历写入文件地址、函数名和函数在源文件中的起止行数(标记文件名,函数在源文件对应行数)。
  3. 随着 BB 遍历,写入 BB 编号、BB 起止范围、BB 的后继节点编号(标记基本块跳转关系)。
  4. 写入函数中BB对应行号信息(标注基本块与源码行数关系)。

从上面的写入步骤能够看出,.gcno 文件结构由四部分组成:

  • 文件结构
  • 函数结构
  • BB 结构
  • BB 行结构

经过这四部分结构能够彻底还原插桩代码和源码的关联,咱们以 BB 结构 / BB 行结构为例,给出结构图 2 (a) BB 结构,(b) BB 行信息结构,在本章末尾覆盖率解析部分,咱们利用这个结构图还原代码执行次数(每行等高格表明 64bit):

图2 BB 结构和 BB 行信息结构

图2 BB 结构和 BB 行信息结构

 

.gcda

入口函数

关于 .gcda 的生成逻辑,可参考覆盖率数据分发源码。这个文件中包含了 __gcov_flush() 函数,这个函数正是分发逻辑的入口。接下来看看 __gcov_flush() 如何生成 .gcda 文件。

经过阅读代码和调试,咱们发如今二进制代码加载时,调用了 llvm_gcov_init(writeout_fn wfn, flush_fn ffn) 函数,传入了 _llvm_gcov_writeout(写 gcov 文件),_llvm_gcov_flush(gcov 节点分发)两个函数,而且根据调用顺序,分别创建了以文件为节点的链表结构。(flush_fn_node * ,writeout_fn_node *

__gcov_flush() 代码以下所示,当咱们手动调用 __gcov_flush()进行覆盖率分发时,会遍历flush_fn_node *这个链表(即遍历全部文件节点),并调用分发函数_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函数类型)。

void __gcov_flush() {
    struct flush_fn_node *curr = flush_fn_head; while (curr) { curr->fn(); curr = curr->next; } } 

具体的分发逻辑

观察__llvm_gcov_flush的 IR 代码,能够看到:

图3 __llvm_gcov_flush 代码示例

图3 __llvm_gcov_flush 代码示例

 

  1. __llvm_gcov_flush先调用了__llvm_gcov_writeout,来向 .gcda 写入覆盖率信息。
  2. 最后将计数数组清零__llvm_gcov_ctr.xx

而 __llvm_gcov_writeout 逻辑为:

  1. 生成对应源文件的 .gcda 文件,写入 Magic number。
  2. 循环执行
    • llvm_gcda_emit_function: 向 .gcda 文件写入函数信息。
    • llvm_gcda_emit_arcs: 向 .gcda 文件写入BB执行信息,若是已经存在 .gcda 文件,会和以前的执行次数进行合并。
  3. 调用llvm_gcda_summary_info,写入校验信息。
  4. 调用llvm_gcda_end_file,写结束符。

感兴趣的同窗能够本身生成 IR 文件查看更多细节,这里再也不赘述。

.gcda 的文件/函数结构和 .gcno 基本一致,这里再也不赘述,统计插桩信息结构如图 4 所示。定制化的输出也能够经过修改上述函数完成。咱们的增量代码测试覆盖率工具解决代码 BB 结构变更后合并到已有 .gcda 文件不兼容的问题,也是修改上述函数实现的。

图4 计数桩输出结构

图4 计数桩输出结构

 

覆盖率解析

在了解了如上所述 .gcno ,.gcda 生成逻辑与文件结构以后,咱们以例 1 中的代码为例,来阐述解析算法的实现。

例 1 中基本块 B0,B1 对应的 .gcno 文件结构以下图所示,从图中能够看出,BB 的主结构彻底记录了基本块之间的跳转关系。

图5 B0,B1 对应跳转信息

图5 B0,B1 对应跳转信息

 

B0,B1 的行信息在 .gcno 中表示以下图所示,B0 块由于是入口块,只有一行,对应行号能够从 B1 结构中获取,而 B1 有两行代码,会依次把行号写入 .gcno 文件。

图6 B0,B1 对应行信息

图6 B0,B1 对应行信息

 

在输入数字 100 的状况下,生成的 .gcda 文件以下:

图7 输入 100 获得的 .gcda 文件

图7 输入 100 获得的 .gcda 文件

 

经过控制流程图中节点出边的执行次数能够计算出 BB 的执行次数,核心算法为计算这个 BB 的全部出边的执行次数,不存在出边的状况下计算全部入边的执行次数(具体实现能够参考 gcov 工具源码),对于 B0 来讲,即看 index=0 的执行次数。而 B1 的执行次数即 index=1,2 的执行次数的和,对照上图中 .gcda 文件能够推断出,B0 的执行次数为 ctr[0]=1,B1 的执行次数是 ctr[1]+ctr[2]=1, B2 的执行次数是 ctr[3]=0,B4 的执行次数为 ctr[4]=1,B5 的执行次数为 ctr[5]=1。

通过上述解析,最终生成的 HTML 以下图所示(利用 lcov):

图8 覆盖率检测报告

图8 覆盖率检测报告

 

以上是 Clang 生成覆盖率信息和解析的过程,下面介绍美团到店餐饮 iOS 团队基于以上原理作的增量代码测试覆盖率工具。

增量代码覆盖率检测原理

方案权衡

因为 gcov 工具(和前面的 .gcov 文件区分,gcov 是覆盖率报告生成工具)生成的覆盖率检测报告可读性不佳,如图 9 所示。咱们作的增量代码测试覆盖率工具是基于 lcov 的扩展,报告展现如上节末尾图 8 所示。

图9 gcov 输出,行前数字表明执行次数,##### 表明没执行

图9 gcov 输出,行前数字表明执行次数,##### 表明没执行

 

比 gcov 直接生成报告多了一步,lcov 的处理流程是将 .gcno 和 .gcda 文件解析成一个以 .info 结尾的中间文件(这个文件已经包含所有覆盖率信息了),以后经过覆盖率报告生成工具生成可读性比较好的 HTML 报告。

结合前两章内容和覆盖率报告生成步骤,覆盖率生成流程以下图所示。考虑到增量代码覆盖率检测中代码增量部分须要经过 Git 获取,比较天然的想法是用 git diff 的信息去过滤覆盖率的内容。根据过滤点的不一样,存在如下两套方案:

  1. 经过 GCOVPass 过滤,只对修改的代码进行插桩,每次修改后需从新插桩。
  2. 经过 .info 过滤,一次性为全部代码插桩,获取所有覆盖率信息,过滤覆盖率信息。

图10 覆盖率生成流程

图10 覆盖率生成流程

 

分析这两个方案,第一个方案须要自定义 LLVM 的 Pass,进而会引入如下两个问题:

  • 只能使用开源 Clang 进行编译,不利于接入正常的开发流程。
  • 每次从新插桩会丢失以前的覆盖率信息,屡次运行只能获得最后一次的结果。

而第二个方案相对更加轻量,只须要过滤中间格式文件,不只能够解决咱们在文章开头提到的问题,也能够避免上述问题:

  • 能够很方便地加入到日常代码的开发流程中,甚至对开发者透明。
  • 未修改文件的覆盖率能够叠加(有修改的那些控制流程图结构可能变化,没法叠加)。

所以咱们实际开发选定的过滤点是在 .info 。在选定了方案 2 以后,咱们对中间文件 .info 进行了一系列调研,肯定了文件基本格式(函数/代码行覆盖率对应的文件的表示),这里再也不赘述,具体能够参考 .info 生成文档

增量代码测试覆盖率工具的实现

前一节是实现增量代码覆盖率检测的基本方案选择,为了更好地接入现有开发流程,咱们作了如下几方面的优化。

下降使用成本

在接入方面,接入增量代码测试覆盖率工具只需一次接入配置,同步到代码仓库后,团队中成员无需配置便可使用,下降了接入成本。

在使用方面,考虑到插桩在编译时进行,对所有代码进行插桩会很大程度下降编译速度,咱们经过解析 Podfile(iOS 开发中较为经常使用的包管理工具 CocoaPods 的依赖描述文件),只对 Podfile 中使用本地代码的仓库进行插桩(可配置指定仓库),下降了团队的开发成本。

对开发者透明

接入增量代码测试覆盖率工具后,开发者无需特殊操做,也不须要对工程作任何其余修改,正常的 git commit 代码,git push 到远端就会自动生成并上传此次 commit 的覆盖率信息了。

为了作到这一点,咱们在接入 Pod 的过程当中,自动部署了 Git 的 pre-push 脚本。熟悉 Git 的同窗知道,Git 的 hooks 是开发者的本地脚本,不会被归入版本控制,如何经过一次配置就让这个仓库的全部使用成员都能开启,是作好这件事的一个难点。

咱们考虑到 Pod 自己会被归入版本控制,所以利用了 CocoaPods 的一个属性 script_phase,增长了 Pod 编译后脚本,来帮助咱们把 pre-push 插入到本地仓库。利用 script_phase 插入还带来了另一个好处,咱们能够直接获取到工程的缓存文件,也避免了 .gcno / .gcda 文件获取的不肯定性。整个流程以下:

图11 pre-push 分发流程

图11 pre-push 分发流程

 

覆盖率累计

在实现了覆盖率的过滤后,咱们在实际开发中遇到了另一个问题:修改分支/循环结构后生成的 .gcda 文件没法和以前的合并。 在这种状况下,__gcov_flush会直接返回,再也不写入 .gcda 文件了致使覆盖率检测失败,这也是市面上已有工具的通用问题。

而这个问题在开发过程当中很常见,好比咱们给例 1 中的游戏增长一些提示,当输入比预设数字大时,咱们就提示出来,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSInteger targetNumber = 10;
    NSLog(@"Welcome to the game");
    if (guessNumber == targetNumber) {
        NSLog(@"Bingo!"); } else if (guessNumber > targetNumber) { NSLog(@"Input number is larger than the given target!"); } else { NSLog(@"Input number is smaller than the given target!"); } } 

这个问题困扰了咱们好久,也推进了对覆盖率检测原理的调研。结合前面覆盖率检测的原理能够知道,不能合并的缘由是生成的控制流程图比原来多了两条边( .gcno 和旧的 .gcda 也不能匹配了),反映在 .gcda 上就是数组多了两个数据。考虑到代码变更后,原有的覆盖率信息已经没有意义了,当发生边数不一致的时候,咱们会删除掉旧的 .gcda 文件,只保留最新 .gcda 文件(有变更状况下 .gcno 会从新生成)。以下图所示:

图12 覆盖率冲突解决算法

图12 覆盖率冲突解决算法

 

总体流程图

结合上述流程,咱们的增量代码测试覆盖率工具的总体流程如图 13 所示。

开发者只需进行接入配置,再次运行时,工程中那些做为本地仓库进行开发的代码库会被自动插桩,并在 .git 目录插入 hooks 信息;当开发者使用模拟器进行需求自测时,插桩统计结果会被自动分发出去;在代码被推到远端前,会根据插桩统计结果,生成仅包含本次代码修改的详细增量代码测试覆盖率报告,以及向远端推送覆盖率信息;同时若是测试覆盖率小于 80% 会强制拒绝提交(可配置关闭,百分比可自定义),保证只有通过充分自测的代码才能提交到远端。

图13 增量代码测试覆盖率生成流程图

图13 增量代码测试覆盖率生成流程图

 

总结

以上是咱们在代码开发质量方面作的一些积累和探索。经过对覆盖率生成、解析逻辑的探究,咱们揭开了覆盖率检测的神秘面纱。开发阶段的增量代码覆盖率检测,能够帮助开发者聚焦变更代码的逻辑缺陷,从而更好地避免线上问题。

 

 

iOS系统中导航栏的转场解决方案与最佳实践

背景

目前,开源社区和业界内已经存在一些 iOS 导航栏转场的解决方案,但对于历史包袱沉重的美团 App 而言,这些解决方案并不完美。有的方案不能知足复杂的页面跳转场景,有的方案迁移成本较大,为此咱们提出了一套解决方案并开发了相应的转场库,目前该转场库已经成为美团点评多个 App 的基础组件之一。

在美团 App 开发的早期,涉及到导航栏样式改变的需求时,常常会遇到转场效果不佳或者与预期样式不符的“小问题”。在业务体量较小的状况下,为了知足快速的业务迭代,一般会使用硬编码的方式来解决这一类“小问题”。但随着美团 App 业务的高速发展,这种硬编码的方式遇到了如下的挑战:

  1. 业务模块的不断增长,致使使用硬编码方式编写的代码维护成本增长,代码质量迅速降低。
  2. 大型 App 的路由系统使得页面间的跳转变得更加自由和灵活,也使得导航栏相关的问题激增,不但增长了问题的排查难度,还下降了总体的开发效率。
  3. App 中的导航栏属于各个业务方的公用资源,因为缺少相应的约束机制和最佳实践,致使业务方之间的代码耦合程度不断增长。

从各个角度来看,硬编码的方式已经不能很好的解决此类问题,美团 App 须要一个更加合理、更加持久、更加简单易行的解决方案来处理导航栏转场问题。

本文将从导航栏的概念入手,经过讲解转场过程当中的状态管理、转换时机和样式变化等内容,引出了在大型应用中导航栏转场的三种常看法决方案,并对美团点评的解决方案进行剖析。

从新认识导航栏

导航栏里的 MVC

在 iOS 系统中, 苹果公司不只建议开发者遵循 MVC 开发框架,在它们的代码里也能够看到 MVC 的影子,导航栏组件的构成就是一个相似 MVC 的结构,让咱们先看看下面这张图:

在这张图里,咱们能够将 UINavigationController 看作是 C,UINavigationBar 看作是 V,而 UIViewController 和 UINavigationItem 组成的 Stack 能够看作是 M。这里要说明的是,每一个 UIViewController 都有一个属于本身的 UINavigationItem,也就是说它们是一一对应的。

UINavigationController 经过驱动 Stack 中的 UIViewController 的变化来实现 View 层级的变化,也就是 UINavigationBar 的改变。而 UINavigationBar 样式的数据就存储在 UIViewController 的 UINavigationItem 中。这也就是为何咱们在代码里只要设置 self.navigationItem 的相关属性就能够改变 UINavigationBar 的样式。

不少时候,国内的开发者会将 UINavigationBar 和 UINavigationController 混在一块儿叫导航栏,这样的作法不只增长了开发者之间的沟通成本,也容易致使误解。毕竟它们是两个彻底不同的东西。

因此本文为了更好的阐明问题,会采用英文区分不一样的概念,当须要描述笼统的导航栏概念时,会使用导航栏组件一词。

经过这一节的回顾,咱们应该明确了 NavigationItem、ViewController、NavigationBar 和 NavigationController 在 MVC 框架下的角色。下面咱们会从新梳理一下导航栏的生命周期和各个相关方法的调用顺序。

导航栏组件的生命周期

你们能够经过下图得到更为直观的感觉,进而了解到导航栏组件在 push 过程当中各个方法的调用顺序。

值得注意的地方有两点:

第一个是 UINavigationController 做为 UINavigationBar 的代理,在没有特殊需求的状况下,不该该修改其代理方法,这里是经过符号断点获取它们的调用顺序。若是咱们建立了一个自定义的导航栏组件系统,它的调用顺序可能会与此不一样。

第二个是用虚线圈起来的方法,它们也有可能不被调用,这与 ViewController 里的布局代码相关,假设跳转到新页面后,新旧页面中的控件位置会发生变化,或者因为数据改变驱动了控件之间的约束关系发生变化,这就会带来新一轮的布局,进而触发 viewWillLayoutSubview 和 viewDidLayoutSubview 这两个方法。固然,具体的调用顺序会与业务代码紧密相关,若是咱们发现顺序有所不一样,也没必要惊慌。

下面这张图展现了导航栏在 pop 过程当中各个方法的调用顺序:

除了上面说到的两点,pop 过程当中还须要注意一点,那就是从 B 返回到 A 的过程当中,A 视图控制器的 viewDidLoad 方法并不会被调用。关于这个问题,只要提醒一下,大多数人都会反应过来是为何。不过在实际开发过程当中,总会有人忘记这一点。

经过这两个图,咱们已经基本了解了导航栏组件的生命周期和相关方法的调用顺序,这也是后面章节的理论基础。

导航栏组件的改变与革新

导航栏组件在 iOS 11 发布时,得到了重大更新,这个更新可不是增长了一个大标题样式(Large Title Display Mode)那么简单,须要注意的地方大概有两点:

  1. 导航栏全面支持 Auto Layout 且 NavigationBar 的层级发生了明显的改变,关于这一点能够阅读 UIBarButtonItem 在 iOS 11 上的改变及应对方案 。
  2. 因为引进了 Safe Area 等概念,topLayoutGuide 和 bottomLayoutGuide 等属性会逐渐废弃,虽然变化不大,但若是咱们的导航栏在转场过程当中老是出现视图上下移动的现象,不妨从这个方面思考一下,若是想深究能够查看 WWDC 2017 Session 412

导航栏组件到底怎么了?

常常有人说 iOS 的原生导航栏组件很差使用,抱怨主要集中在导航栏组件的状态管理和控件的布局问题上。

控件的布局问题随着 iOS 11 的到来已经变得相对容易处理了很多,但导航栏组件的状态管理仍然让开发者头疼不已。

可能已经有朋友在思考导航栏组件的状态管理究竟是什么东西?不要着急,下面的章节就会作相关的介绍。

导航栏的状态管理

虽然导航栏组件的 push 和 pop 动画给人一种每次操做后都会建立一遍导航栏组件的错觉,但实际上这些 ViewController 都是由一个 NavigationController 所管理,因此你看到的 NavigationBar 是惟一的。

在 NavigationController 的 Stack 存储结构下,每当 Stack 中的 ViewController 修改了导航栏,势必会影响其余 ViewController 展现的效果。

例以下图所示的场景,若是 NavigationBar 原先的颜色是绿色,但以后进入 Stack 里的 ViewController 将 NavigationBar 颜色修改成紫色后,在此以后 push 的 ViewController 会从默认的绿色变为紫色,直到有新的 ViewController 修改导航栏颜色才会发生变化。

虽然在 push 过程当中,NavigationBar 的变化听起来合情合理,但若是你在 NavigationBar 为绿色的 ViewController 里设置不当的话,那么当你 pop 回这个 ViewController 时,NavigationBar 可就不必定是绿色了,它还会保持为紫色的状态。

经过这个例子,咱们大概会意识到在导航栏里的 Stack 中,每一个 ViewController 均可以永久的影响导航栏样式,这种全局性的变化要求咱们在实际开发中必须坚持“谁修改,谁复原”的原则,不然就会形成导航栏状态的混乱。这不只仅是样式上的混乱,在一些极端情况下,还有可能会引发 Stack 混乱,进而形成 Crash 的状况。

导航栏样式转换的时机

咱们刚才提到了“谁修改,谁复原”的原则,但什么时候修改,什么时候复原呢?

对于那些存储在 Stack 中的 ViewController 而言,它其实就是在不断的经历 appear 和 disappear 的过程,结合 ViewController 的生命周期来看,viewWillAppear: 和 viewWillDisappear: 是两个完美的时间节点,但不少人却对这两个方法的调用存在疑惑。

苹果公司在它的 API 文档中专门用了一段文字来解答你们的疑惑,这段文字的标题为《Handling View-Related Notifications》,在这里咱们直接引用原文:

When the visibility of its views changes, a view controller automatically calls its own methods so that subclasses can respond to the change. Use a method like viewWillAppear: to prepare your views to appear onscreen, and use the viewWillDisappear: to save changes or other state information. Use other methods to make appropriate changes.

Figure 1 shows the possible visible states for a view controller’s views and the state transitions that can occur. Not all ‘will’ callback methods are paired with only a ‘did’ callback method. You need to ensure that if you start a process in a ‘will’ callback method, you end the process in both the corresponding ‘did’ and the opposite ‘will’ callback method.

这里很好的解释了全部的 will 系列方法和 did 系列方法的对应关系,同时也给咱们吃了一个定心丸,那就是在 appearing 和 disappearing 状态之间会由 will 系列方法进行衔接,避免了状态中断。这对于连续 push 或者连续 pop 的状况是及其重要的,不然咱们没法作到 “谁修改,谁复原”的原则。

一般来讲,若是只是一个简单的导航栏样式变化,咱们的代码结构大致会以下所示:

- (void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];
    // MARK: change the navigationbar style 
}

- (void)viewWillDisappear:(BOOL)animated{ [super viewWillDisappear:animated]; // MARK: restore the navigationbar style } 

如今,咱们明确了修改时机,接下来要明确的就是导航栏的样式会进行怎样的变化。

导航栏的样式变化

对于不一样 ViewController 之间的导航栏样式变化,大多能够总结为两种状况:

  1. 导航栏的显示与否。
  2. 导航栏的颜色变化。

导航栏的显示与否

对于显示与否的问题,能够在上一节提到的两个方法里调用 setNavigationBarHidden:animated: 方法,这里须要提醒的有两点:

  1. 在导航栏转场的过程当中,不要天真的觉得 setNavigationBarHidden: 和 setNavigationBarHidden:animated: 的效果是同样的,直接使用 setNavigationBarHidden: 会形成导航栏转场过程当中的闪现、背景错乱等问题,这一现象在使用手势驱动转场的场景中十分常见,因此正确的方式是使用带有 animated 参数的 API。
  2. 在 push 和 pop 的方法里也会带有 animated 参数,尽可能保证与 setNavigationBarHidden:animated: 中的 animated 参数一致。

导航栏的颜色变化

颜色变化的问题就稍微复杂一些,在 iOS 7 后,导航栏增长了 translucent 效果,这使得导航栏背景色的变化出现了两种状况:

  1. translucent 属性值为 YES 的前提下,更改导航栏的背景色。
  2. translucent 属性值为 NO 的前提下,更改导航栏的背景色。

对于第一种状况,咱们须要调用 UINavigationBar 的 setBackgroundColor: 方法。

对于第二种状况咱们须要调用 UINavigationBar 的 setBackgroundImage:forBarMetrics: 方法。

对于第二种状况,这里有三点须要提示:

  1. 在设置透明效果时,咱们一般能够直接设置一个 [UIImage new] 建立的对象,无须建立一个颜色为透明色的图片。
  2. 在使用 setBackgroundImage:forBarMetrics: 方法的过程当中,若是图像里存在 alpha 值小于 1.0 的像素点,则 translucent 的值为 YES,反之为 NO。也就是说,若是咱们真的想让导航栏变成纯色且没有 translucent 效果,请保证全部像素点的 alpha 值等于 1。
  3. 若是设置了一个彻底不透明的图片且强行将 NavigationBar 的 translucent 属性设置为 YES 的话,系统会自动修正这个图片并为它添加一个透明度,用于模拟 translucent 效果。
  4. 若是咱们使用了一个带有透明效果的图片且导航栏的 translucent 效果为 NO 的话,那么系统会在这个带有透明效果的图片背后,添加一个不透明的纯色图片用于总体效果的合成。这个纯色图片的颜色取决于 barStyle 属性,当属性为 UIBarStyleBlack 时为黑色,当属性为 UIBarStyleDefault 时为白色,若是咱们设置了 barTintColor,则以设置的颜色为基准。

分清楚 transparenttranslucentopaquealpha 和 opacity 也挺重要

在刚接触导航栏 API 时,许多人常常会把文档里的这些英文词搞混,也不太明白带有这些词的变量为何有的是布尔型,有的是浮点型,总之一切都让人很困惑。

在这里将作了一个总结,这对于理解 Apple 的 API 设计原则十分有帮助。

transparent, translucent, opaque 三个词常常会用在一块儿,它用于描述物体的透光强度,为了让你们更好的理解这三个词,这里作了三个比喻:

  • transparent 是指透明,就比如咱们能够透过一面干净的玻璃清楚的看到外面的风景。
  • translucent 是指半透明,就比如咱们能够透过一面有点磨砂效果的塑料墙看外面的风景,不能说看不见,但咱们确定看不清。
  • opaque 是指不透明,就比如咱们透过一个堵石墙是看不见任何外面的东西,眼前看到的只有这面墙。

这三个词更多的是用来表述一种状态,不须要量化,因此这与这三个词相关的属性,通常都是 BOOL 类型。

alpha 和 opacity 常常会在一块儿使用,它要表示的就是透明度,在 Web 端这两个属性有着明显的区别。

在 Web 端里,opacity 是设定整个元素的透明值,而 alpha 通常是放在颜色设置里面,因此咱们能够作到对特定对元素的某个属性设定 alpha,好比背景、边框、文字等。

div {
  width: 100px;
  height: 100px; background: rgba(0,0,0,0.5); border: 1px solid #000000; opacity: 0.5; } 

这一律念一样适用于 iOS 里的概念,好比咱们能够经过 alpha 通道单独的去设置 backgroudColorborderColor,它们互不影响,且有着独立的 alpha 通道,咱们也能够经过 opacity 统一设置整个 view 的透明度。

但与 Web 端不一致的是,iOS 里面的 view 不光拥有独立的 alpha 属性,同时也是基于 CALayer,因此咱们能够看到任意 UIView 对象下面都会有一个 layer 的属性,用于代表 CALayer 对象。view 的 alpha 属性与 layer 里面的 opacity 属性是一个相等的关系,须要注意的是 view 上的 alpha 属性是 Web 端并不具有的一个能力,因此笔者认为:在 iOS 中去说 alpha 时,要区分是在说 view 上的属性,仍是在说颜色通道里的 alpha

因为这两个词都是在描述程度,因此咱们看到它们都是 CGFloat 类型:

转场过程当中须要注意的问题和细节

说完了导航栏的转场时机和转场方式,其实大致上你已经能处理好不一样样式间的转换,但还有一些细节须要你去考虑,下面咱们来讲说其中须要你关注的两点。

translucent 属性带来的布局改变

translucent 会影响导航栏组件里 ViewController 的 View 布局,这里须要你们理清 5 个 API 的使用场景:

  1. edgesForExtendedLayout
  2. extendedLayoutIncluedsOpaqueBars
  3. automaticallyAdjustScrollViewInsets
  4. contentInsetAdjustmentBehavior
  5. additionalSafeAreaInsets

前三个 API 是 iOS 11 以前的 API,它们之间的区别和联系在 Stack Overflow 上有一个比较精彩的回答 - Explaining difference between automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, edgesForExtendedLayout in iOS7,我在这里就不作详细阐述,总结一下它的观点就是:

若是咱们先定义一个 UINavigationController,它里面包含了多个 UIViewController,每一个 UIViewController 里面包含一个 UIView 对象:

  • 那么 edgesForExtendedLayout 是为了解决 UIViewController 与 UINavigationController 的对齐问题,它会影响 UIViewController 的实际大小,例如 edgesForExtendedLayout 的值为 UIRectEdgeAll 时,UIViewController 会占据整个屏幕的大小。
  • 当 UIView 是一个 UIScrollView 类或者子类时,automaticallyAdjustsScrollViewInsets 是为了调整这个 UIScrollView 与 UINavigationController 的对齐问题,这个属性并不会调整 UIViewController 的大小。
  • 对于 UIView 是一个 UIScrollView 类或者子类且导航栏的背景色是不透明的状态时,咱们会发现使用 edgesForExtendedLayout 来调整 UIViewController 的大小是无效的,这时候你必须使用 extendedLayoutIncludesOpaqueBars 来调整 UIViewController 的大小,能够认为 extendedLayoutIncludesOpaqueBars 是基于 automaticallyAdjustsScrollViewInsets 诞生的,这也是为何常常会看到这两个 API 会同时使用。

这些调整布局的 API 背后是一套基于 topLayoutGuide 和 bottomLayoutGuide 的计算而已,在 iOS 11 后,Apple 提出了 Safe Area 的概念,将原先分裂开来的 topLayoutGuide 和 bottomLayoutGuide 整合到一个统一的 LayoutGuide 中,也就是所谓的 Safe Area,这个改变看起来彷佛不是很大,但它的出现确实方便了开发者。

若是想对 Safe Area 带来的改变有更全面的认识,十分推荐阅读 Rosberry 的工程师 Evgeny Mikhaylov 在 Medium 上的文章 iOS Safe Area,这篇文章基本涵盖了 iOS 11 中全部与 Safe Area 相关的 API 并给出了真正合理的解释。

这里只说一下 contentInsetAdjustmentBehavior 和 additionalSafeAreaInsets 两个 API。

对于 contentInsetAdjustmentBehavior 属性而言,它的诞生也意味着 automaticallyAdjustsScrollViewInsets 属性的失效,因此咱们在那些已经适配了 iOS 11 的工程里能看到以下相似的代码:

if (@available(iOS 11.0, *)) {
    self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else { self.automaticallyAdjustsScrollViewInsets = NO; } 

此处的代码片断只是一个示例,并不适用全部的业务场景,这里须要着重说明几个问题:

  1. 关于 contentInsetAdjustmentBehavior 中的 UIScrollViewContentInsetAdjustmentAutomatic 的说明一直很“模糊”,经过 Evgeny Mikhaylov 的文章,咱们能够了解到他在大多数状况下会与 UIScrollViewContentInsetAdjustmentScrollableAxes 一致,当且仅当知足如下全部条件时才会与 UIScrollViewContentInsetAdjustmentAlways 类似:
    • UIScrollView 类型的视图在水平轴方向是可滚动的,垂直轴是不可滚动的。
    • ViewController 视图里的第一个子控件是 UIScrollView 类型的视图。
    • ViewController 是 navigation 或者 tab 类型控制器的子视图控制器。
    • 启用 automaticallyAdjustsScrollViewInsets
  2. iOS 11 后,经过 contentInset 属性获取的偏移量与 iOS 10 以前的表现形式并不一致,须要获取 adjustedContentInset 属性才能保证与以前的 contentInset 属性一致,这样的改变须要咱们在代码里对不一样的版本进行适配。

对于 additionalSafeAreaInsets 而言,若是系统提供的这几种行为并不能知足咱们的布局要求,开发者还能够考虑使用 additionalSafeAreaInsets 属性作调整,这样的设定使得开发者能够更加灵活,更加自由的调整视图的布局。

backIndicator 上的动画

苹果提供了许多修改导航栏组件样式的 API,有关于布局的,有关于样式的,也有关于动画的。backIndicatorImage 和 backIndicatorTransitionMaskImage 就是其中的两个 API。

backIndicatorImage 和 backIndicatorTransitionMaskImage 操做的是 NavigationBar 里返回按钮的图片,也就是下图红色圆圈所标注的区域。

想要成功的自定义返回按钮的图标样式,咱们须要同时设置这两个 API ,从字面上来看,它们一个是返回图片自己,另外一个是返回图片在转场时用到的 mask 图片,看起来不怎么难,咱们写一段代码试试效果:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrowMask"]; 

代码里的图片以下所示:

也许大多数人在这里会都认为,mask 图片会遮挡住文字使其在遇到返回按钮右边缘的时候就消失。但实际的运行效果是怎么样子的呢?咱们来看一下:

在上面的图片中,咱们能够看到返回按钮的文字从返回按钮的图片下面穿过而且文字被图片所遮挡,这种动画看起来十分奇怪,这是没法接受的。咱们须要作点修改:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrow"]; 

这一次咱们将 backIndicatorTransitionMaskImage 改成 indicatorImage 所用的图片。

到这里,可能大多数人都会好奇,这代码也能行?让咱们看下它实际的效果:

在上面的图中,咱们看到文字在到达图片的右边缘时就从下方穿过并被彻底遮盖住了,这种动画效果虽然比上面好一些,但仍然有改进的空间,不过这里咱们先不继续优化了,咱们先来讨论一下它们背后的运做原理。

iOS 系统会将 indicatorImage 中不透明的颜色绘制成返回按钮的图标, indicatorTransitionMaskImage 与 indicatorImage 的做用不一样。indicatorTransitionMaskImage 将自身不透明的区域像 mask 同样做用在 indicatorImage 上,这样就保证了返回按钮中的文字像左移动时,文字只出如今被 mask 的区域,也就是 indicatorTransitionMaskImage 中不透明的区域。

掌握了原理,咱们来解释下刚才的两种现象:

在第一种实现中,咱们提供的 indicatorTransitionMaskImage 覆盖了整个返回按钮的图标,因此咱们在转场过程当中能够清晰的看到返回按钮的文字。

在第二种实现中,咱们使用 indicatorImage 做为 indicatorTransitionMaskImage,记住文字是只能出如今 indicatorTransitionMaskImage 里不透明的区域,因此显然返回按钮中的文字会在图标的最右边就已经被遮挡住了,由于那片区域是透明的。

那么前面提到的进一步优化指的是什么呢?

让咱们来看一下下面这个示例图,为了更好的区分,咱们将 indicatorTransitionMaskImage 用红色进行标注。黑色仍然是 indicatorImage。

按照刚才介绍的原理,咱们应该能够理解,如今文字只会出如今红色区域,那么它的实际效果是什么样子的呢,咱们能够看下图:

如今,一个完美的返回动画,诞生啦!

此节所用的部分效果图出自 Ray Wenderlich 的文章 UIAppearance Tutorial: Getting Started

导航栏的跳转或许能够这么玩儿…

前两章的铺垫就是为了这一章的内容,因此如今让咱们开始今天的大餐吧。

这样真的好么?

刚才咱们说了两个页面间 NavigationBar 的样式变化须要在各自的 viewWillAppear: 和 viewWillDisappear: 中进行设置。那么问题就来了:这样的设置会带来什么问题呢?

试想一下,当咱们的页面会跳到不一样的地方时,咱们是否是要在 viewWillAppear: 和 viewWillDisappear: 方法里面写上一堆的判断呢?若是应用里还有 router 系统的话,那么页面间的跳转将变得更加不可预知,这时候又该如何在 viewWillAppear: 和 viewWillDisappear: 里作判断呢?

如今咱们的问题就来了,如何让导航栏的转场更加灵活且相互独立呢?

常见的解决方案以下所示:

  1. 从新实现一个相似 UINavigationController 的容器类视图管理器,这个容器类视图管理器作好不一样 ViewController 间的导航栏样式转换工做,而每一个 ViewController 只须要关心自身的样式便可。

  2. 将系统原有导航栏的背景设置为透明色,同时在每一个 ViewController 上添加一个 View 或者 NavigationBar 来充当咱们实际看到的导航栏,每一个 ViewController 一样只须要关心自身的样式便可。

  3. 在转场的过程当中隐藏原有的导航栏并添加假的 NavigationBar,当转场结束后删除假的 NavigationBar 并恢复原有的导航栏,这一过程能够经过 Swizzle 的方式完成,而每一个 ViewController 只须要关心自身的样式便可。

这三种方案各有优劣,咱们在网上也能够看到不少关于它们的讨论。

例如方案一,虽然看起来工做量大且难度高,可是这个工做一旦完成,咱们就会将处理导航栏转场的主动权紧紧抓在手里。但这个方案的一个弊端就是,若是苹果修改了导航栏的总体风格,就比如 iOS 11 的大标题特效,那么工做量就来了。

对于方案二而言,虽然看起来简单易用,但这须要一个良好的继承关系,若是整个工程里的继承关系混乱或者是历史包袱比较重,后续的维护就像“打补丁”同样,另外这个方案也须要良好的团队代码规范和完善的技术文档来作辅助。

对于方案三而言,它不须要所谓的继承关系,使用起来也相对简单,这对于那些继承关系和历史包袱比较重的工程而言,这一个不错的解决方案,但在解决 Bug 的时候,Swizzle 这种方式无疑会增长解决问题的时间成本和学习成本。

咱们的解决方案

在美团 App 的早期,各个业务方都想充分利用导航栏的能力,但对于导航栏的状态维护缺少理解与关注,随着业务方的增长和代码量的上升,与导航栏相关的问题逐渐暴露出来,此时咱们才意识到这个问题的严重性。

大型 App 的导航栏问题就像一个典型的“公地悲剧”问题。在软件行业,公用代码的全部权能够被视做“公地”,由于不注重长期需求而容易遭到消耗。若是开发人员倾向于交付“价值”,而以可维护性和可理解性为代价,那么这个问题就特别广泛了。若是是这种状况,每次代码修改将大大减小其整体质量,最终致使软件的不可维护。

因此解决这个问题的核心在于:明确公用代码的全部权,并在开发期施加约束。

明确公用代码的全部权,能够理解为将导航栏相关的组件抽离成一个单独的组件,并交由特定的团队维护。而在开发期施加约束,则意味着咱们要提供一套完整的解决方案让各个业务方遵照。

这一节咱们会以美团内部的解决方案为例,讲解如何实现一个流畅的导航栏跳转过程和相关使用方法。

设计理念

使用者只用关心当前 ViewController 的 NavigationBar 样式,而不用在 push 或者 pop 的时候去处理 NavigationBar 样式。

举个例子来讲,当从 A 页面 push 到 B 页面的时候,转场库会保存 A 页面的导航栏样式,当 pop 回去后就会还原成之前的样式,所以咱们不用考虑 pop 后导航栏样式会改变的状况,同时咱们也没必要考虑 push 后的状况,由于这个是页面 B 自己须要考虑的。

使用方法

转场库的使用十分简单,咱们不须要 import 任何头文件,由于它在底层经过 Method Swizzling 进行了处理,只须要在使用的时候遵循下面 4 点便可:

  • 当须要改变导航栏样式的时候,在视图控制器的 viewDidLoad 或者 viewWillAppear: 方法里去设置导航栏样式。
  • 用 setBackgroundImage:forBarMetrics: 方法和 shadowImage 属性去修改导航栏的背景样式。
  • 不要在 viewWillDisappear: 里添加针对导航栏样式修改的代码。
  • 不要随意修改 translucent 属性,包括隐式的修改和显示的修改。

隐式修改是指使用 setBackgroundImage:forBarMetrics: 方法时,若是 image 里的像素点没有 alpha 通道或者 alpha 所有等于 1 会使得 translucent 变为 NO 或者 nil。

基本原理

以上,咱们讲完了设计理念和使用方法,那么咱们来看看美团的转场库到底作了什么?

从大方向上来看,美团使用的是前面所说的第三种方案,不过它也有一些本身独特的地方,为了更好的让你们理解整个过程,咱们设计这样一个场景,从页面 A push 到页面 B,结合以前探讨过的方法调用顺序,咱们能够知道几个核心方法的调用顺序大体以下:

  1. 页面 A 的 pushViewController:animated:
  2. 页面 B 的 viewDidLoad or viewWillAppear:
  3. 页面 B 的 viewWillLayoutSubviews
  4. 页面 B 的 viewDidAppear:

在 push 过程的开始,转场库会在页面 A 自身的 view 上添加一个与导航栏如出一辙的 NavigationBar 并将真的导航栏隐藏。以后这个假的导航栏会一直存在页面 A 上,用于保留 A 离开时的导航栏样式。

等到页面 B 调用 viewDidLoad 或者 viewWillAppear: 的时候,开发者在这里自行设置真的导航栏样式。转场库在这里会对页面布局作一些修正和辅助操做,但不会影响导航栏的样式。

等到页面 B 调用 viewWillLayoutSubviews 的时候,转场库会在页面 B 自身的 view 上添加一个与真的导航栏如出一辙的 NavigationBar,同时将真的导航栏隐藏。此时不论真的导航栏,仍是假的导航栏都已经与 viewDidLoad 或者 viewWillAppear: 里设置的同样的。

固然,这一步也能够放在 viewWillAppear: 里并在 dispatch main queue 的下一个 runloop 中处理。

等到页面 B 调用 viewDidAppear: 的时候,转场库会将假的导航栏样式设置到真的导航栏中,并将假的导航栏从视图层级中移除,最终将真的导航栏显示出来。

为了让你们更好地理解上面的内容,请参考下图:

说完了 push 过程,咱们再来讲一下从页面 B pop 回页面 A 的过程,几个核心方法的调用顺序以下:

  1. 页面 B 的 popViewControllerAnimated:
  2. 页面 A 的 viewWillAppear:
  3. 页面 A 的 viewDidAppear:

在 pop 过程的开始,转场库会在页面 B 自身的 view 上添加一个与导航栏如出一辙的 NavigationBar 并将真的导航栏隐藏,虽然这个假的导航栏会一直存在于页面 B 上,但它自身会随着页面 B 的 dealloc 而消亡。

等到页面 A 调用 viewWillAppear: 的时候,开发者在这里自行设置真的导航栏样式。固然咱们也能够不设置,由于这时候页面 A 还持有一个假的导航栏,这里还保留着咱们以前在 viewDidLoad 里写的导航栏样式。

等到页面 A 调用 viewDidAppear: 的时候,转场库会将假的导航栏样式设置到真的导航栏中,并将假的导航栏从视图层级中移除,最终将真的导航栏显示出来。

一样,咱们能够参考下面的图来理解上面所说的内容:

如今,你们应该对咱们美团的解决方案有了必定的认识,但在实际开发过程当中,还须要考虑一些布局和适配的问题。

最佳实践

在维护这套转场方案的时间里,咱们总结了一些此类方案的最佳实践。

判断导航栏问题的基本准则

若是发现导航栏在转场过程当中出现了样式错乱,能够遵循如下几点基本原则:

  • 检查相应 ViewController 里是否有修改其余 ViewController 导航栏样式的行为,若是有,请作调整。
  • 保证全部对导航栏样式变化的操做出如今 viewDidLoad 和 viewWillAppear: 中,若是在 viewWillDisappear: 等方法里出现了对导航栏的样式修改的操做,若是有,请作调整。
  • 检查是否有改动 translucent 属性,包括显示修改和隐式修改,若是有,请作调整。

只关心当前页面的样式

永远记住每一个 ViewController 只用关心本身的样式,设置的时机点在 viewWillAppear: 或者 viewDidLoad 里。

透明样式导航栏的正确设置方法

若是须要一个透明效果的导航栏,能够使用以下代码实现:

[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
self.navigationController.navigationBar.shadowImage = [UIImage new]; 

导航栏的颜色渐变效果

若是须要导航栏实现随滚动改变总体 alpha 值的效果,能够经过改变 setBackgroundImage:forBarMetrics: 方法里 image 的 alpha 值来达到目标,这里通常是使用监听 scrollView.contentOffset 的手段来作。请避免直接修改 NavigationBar 的 alpha 值。

还有一点须要注意的是,在页面转场的过程当中,也会触发 contentOffset 的变化,因此请尽可能在 disappear 的时候取消监听。不然会容易出现导航栏透明度的变化。

导航栏背景图片的规范

请避免背景图里的像素点没有 alpha 通道或者 alpha 所有等于 1,容易触发 translucent 的隐式改变。

若是真的要隐藏导航栏

若是咱们须要隐藏导航栏,请保证全部的 ViewController 能坚持以下原则:

  1. 每一个 ViewController 只须要关心当前页面下的导航栏是否被隐藏。
  2. 在 viewWillAppear: 中,统一设置导航栏的隐藏状态。
  3. 使用 setNavigationBarHidden:animated: 方法,而不是 setNavigationBarHidden:

转场动画与导航栏隐藏动画的一致性

若是在转场的过程当中还会显示或者隐藏导航栏的话,请保证两个方法的动画参数一致。

- (void)viewWillAppear:(BOOL)animated{
    [self.navigationController setNavigationBarHidden:YES animated:animated];
}

viewWillAppear: 里的 animated 参数是受 push 和 pop 方法里 animated 参数影响。

导航栏固有的系统问题

目前已知的有两个系统问题以下:

  1. 当先后两个 ViewController 的导航栏都处于隐藏状态,而后在后一个 ViewController 中使用返回手势 pop 到一半时取消,再连续 push 多个页面时会形成导航栏的 Stack 混乱或者 Crash。
  2. 当页面的层级结构大致以下所示时,在红色导航栏的 Stack 中,返回手势会大几率的出现跨层级的跳转,屡次后会致使整个导航栏的 Stack 错乱或者 Crash。

导航栏内置组件的布局规范

导航栏里的组件布局在 iOS 11 后发生了改变,原有的一些解决方案已经失效,这些内容不在本篇文章的讨论范围以内,推荐阅读UIBarButtonItem 在 iOS 11 上的改变及应对方案,这篇文章详细的解释了 iOS 11 里的变化和可行的应对方案。

总结

本文涉及内容较多,从 iOS 系统下的导航栏概念到大型应用里的最佳实践,这里咱们总结一下整篇文章的核心内容:

  • 理解导航栏组件的结构和相关方法的生命周期。
    • 导航栏组件的结构留有 MVC 架构的影子,在解决问题时,要去相应的层级处理。
    • 转场问题的关键点是方法的调用顺序,因此了解生命周期是解决此类问题的基础。
  • 状态管理,转换时机和样式变化是导航栏里常见问题的三种表现形式,遇到实际问题时须要区分清楚。
    • 状态管理要坚持“谁修改,谁复原”的原则。
    • 转换时机的设定要作到连续可执行。
    • 样式变化的核心点是导航栏的显示与否与颜色变化。
  • 为了更好的配合大型应用里的路由系统,导航栏转场的常看法决方案有三种,各有利弊,须要根据自身的业务场景和历史包袱作取舍。
    • 解决方案1:自定义导航栏组件。
    • 解决方案2:在原有导航栏组件里添加 Fake Bar。
    • 解决方案3:在导航栏转场过程当中添加 Fake Bar。
  • 美团在实际开发过程当中采用了第三种方案,并给出了适合美团 App 的最佳实践。

特别感谢莫洲骐在此项目里的贡献与付出。

参考连接

 

 

Category 特性在 iOS 组件化中的应用与管控

背景

iOS Category功能简介

Category 是 Objective-C 2.0以后添加的语言特性。

Category 就是对装饰模式的一种具体实现。它的主要做用是在不改变原有类的前提下,动态地给这个类添加一些方法。在 Objective-C(iOS 的开发语言,下文用 OC 代替)中的具体体现为:实例(类)方法、属性和协议。

除了引用中提到的添加方法,Category 还有不少优点,好比将一个类的实现拆分开放在不一样的文件内,以及能够声明私有方法,甚至能够模拟多继承等操做,具体可参考官方文档Category

若 Category 添加的方法是基类已经存在的,则会覆盖基类的同名方法。本文将要提到的组件间通讯都是基于这个特性实现的,在本文的最后则会提到对覆盖风险的管控。

组件通讯的背景

随着移动互联网的快速发展,不断迭代的移动端工程每每面临着耦合严重、维护效率低、开发不够敏捷等常见问题,所以愈来愈多的公司开始推行“组件化”,经过解耦重组组件来提升并行开发效率。

可是大多数团队口中的“组件化”就是把代码分库,主工程使用 CocoaPods 工具把各个子库的版本号聚合起来。但能合理的把组件分层,而且有一整套工具链支撑发版与集成的公司较少,致使开发效率很难有明显地提高。

处理好各个组件之间的通讯与解耦一直都是组件化的难点。诸如组件之间的 Podfile 相互显式依赖,以及各类联合发版等问题,若处理不当可能会引起“灾难”性的后果。

目前作到 ViewController (指iOS中的页面,下文用VC代替)级别解耦的团队较多,维护一套 mapping 关系并使用 scheme 进行跳转,可是目前仍然没法作到更细粒度的解耦通讯,依然知足不了部分业务的需求。

实际业务案例

例1:外卖的首页的商家列表(WMPageKit),在进入一个商家(WMRestaurantKit)选择5件商品返回到首页的时候,对应的商家cell须要显示已选商品“5”。

例2:搜索结果(WMSearchKit)跳转到商超的容器页(WMSupermarketKit),须要传递一个通用Domain(也有的说法叫模型、Model、Entity、Object等等,下文统一用Domain表示)。

例3:作一键下单需求(WMPageKit),须要调用下单功能的一个方法(WMOrderKit)入参是一个订单相关 Domain 和一个 VC,不须要返回值。

这几种场景基本涵盖了组件通讯所需的的基本功能,那么怎样才能够实现最优雅的解决方案?

组件通讯的探索

模型分析

对于上文的实际业务案例,很容易想到的应对方案有三种,第一是拷贝共同依赖代码,第二是直接依赖,第三是下沉公共依赖。

对于方案一,会维护多份冗余代码,逻辑更新后代码不一样步,显然是不可取的。对于方案二,对于调用方来讲,会引入较多无用依赖,且可能形成组件间的循环依赖问题,致使组件没法发布。对于方案三,实际上是可行解,可是开发成本较大。对于下沉出来的组件来讲,其实很难找到一个明确的定位,最终沦为多个组件的“大杂烩”依赖,从而致使严重的维护性问题。

那如何解决这个问题呢?根据面向对象设计的五大原则之一的“依赖倒置原则”(Dependency Inversion Principle),高层次的模块不该该依赖于低层次的模块,二者(的实现)都应该依赖于抽象接口。推广到组件间的关系处理,对于组件间的调用和被调用方,从本质上来讲,咱们也须要尽可能避免它们的直接依赖,而但愿它们依赖一个公共的抽象层,经过架构工具来管理和使用这个抽象层。这样咱们就能够在解除组件间在构建时没必要要的依赖,从而优雅地实现组件间的通信。

图1-1 模型设计

图1-1 模型设计

 

业界现有方案的几大方向

实践依赖倒置原则的方案有不少,在 iOS 侧,OC 语言和 Foundation 库给咱们提供了数个可用于抽象的语言工具。在这一节咱们将对其中部分实践进行分析。

1.使用依赖注入

表明做品有 Objection 和 Typhoon,二者都是 OC 中的依赖注入框架,前者轻量级,后者较重并支持 Swift。

比较具备通用性的方法是使用「协议」 <-> 「类」绑定的方式,对于要注入的对象会有对应的 Protocol 进行约束,会常常看到一些RegisterClass:ForProtocol:classFromProtocol的代码。在须要使用注入对象时,用框架提供的接口以协议做为入参从容器中得到初始化后的所需对象。也能够在 Register 的时候直接注册一段 Block-Code,这个代码块用来初始化本身,做为id类型的返回值返回,能够支持一些编译检查来确保对应代码被编译。

美团内推行将一些运行时加载的操做前移至编译时,好比将各项注册从 +load 改成在编译期使用__attribute((used,section("__DATA,key"))) 写入 mach-O 文件 Data 的 Segment 中来减小冷启动的时间消耗。

所以,该方案的局限性在于:代码块存取的性能消耗较大,而且协议与类的绑定关系的维护须要花费更多的时间成本。

2.基于SPI机制

全称是 Service Provider Interfaces,表明做品是 ServiceLoader。

实现过程大体是:A库与B库之间无依赖,但都依赖于P平台。把B库内的一个接口I下沉到平台层(“平台层”也叫作“通用能力层”,下文统一用平台层表示),入参和返回值的类型须要平台层包含,接口I的实现放在B库里(由于实如今B库,因此实现里能够正常引用B库的元素)。而后A库经过P平台的这个接口I来实现功能。A能够调用的到接口I,可是在B的库中进行实现。

在A库须要经过一个接口I实例化出一个对象,使用ServiceLoader.load(接口,key),经过注册过的key使用反射找到这个接口imp的文件路径而后获得这个实例对象调用对应接口。

这个操做在安卓中使用较为普遍,大体至关于用反射操做来替代一次了 import 这样的耦合引用。但实际上iOS中若使用反射来实现功能则彻底没必要这么麻烦。

关于反射,Java能够实现相似于ClassFromString的功能,可是没法直接使用 MethodFromString的功能。而且ClassFromString也是经过字符串map到这个类的文件路径,相似于 com.waimai.home.searchImp,从而能够得到类型而后实例化,而OC的反射是经过消息机制实现。

3.基于通知中心

以前和一个作读书类App的同窗交流,发现行业内有些公司的团队在使用 NotificationCenter 进行一些解耦的通讯,由于通知中心自己支持传递对象,而且通知中心的功能也原生支持同步执行,因此也能够达到目的。

通知中心在iOS 9以后有一次比较大的升级,将通知支持了 request 和 response 的处理逻辑,并支持获取到通知的发送者。比以往的通知群发但不感知发送者和是否收到,进步了不少。

字符串的约定也能够理解为一个简化的协议,可设置成宏或常量放在平台层进行统一的维护。

比较明显的缺陷是开发的统一范式难以约束,风格迥异,且字符串相较于接口而言仍是难以管理。

4.使用objc_msgSend

这是iOS原生消息机制中最万能的方法,编写时会有一些硬编码。核心代码以下:

id s = ((id(*)(id, SEL))objc_msgSend)(ClassName,@selector(methodName)); 

这种方法的特色是即插即用,在开发者能100%肯定整条调用链没问题的时候,能够快速实现功能。

此方案的缺陷在于编写十分随意,检查和校验的逻辑还不够,满屏的强转。对于 int、Integer、NSNumber 这样的很容易发生类型转换错误,结果虽然不报错,但数字会有错误。

方案对比

接下来,咱们对这几个大方向进行一些性能对比。

考虑到在公司内的实际用法与限制,可能比常规方法增长了若干步骤,结果也可能会与常规裸测存在必定的误差。

例如依赖注入经常使用作法是存在单例(内存)里,可是咱们为了优化冷启动时间都写入 mach-O 文件 Data 的 Segment 里了,因此在咱们的统计口径下存取时间会相对较长。

// 为了避免暴露类名将业务属性用“some”代替,并隐藏初始化、循环100W次、差值计算等代码,关键操做代码以下

// 存取注入对象
xxConfig = [[WMSomeGlueCore sharedInstance] createObjectForProtocol:@protocol(WMSomeProtocol)]; // 通知发送 [[NSNotificationCenter defaultCenter]postNotificationName:@"nixx" object:nil]; // 原生接口调用 a = [WMSomeClass class]; // 反射调用 b = objc_getClass("WMSomeClass"); 

运行结果显示

运行结果显示

 

图1-2 性能消耗检测

图1-2 性能消耗检测

 

能够看出原生的接口调用明显是最高效的用法,反射的时长比原生要多一个数量级,不过100W次也就是多了几十毫秒,还在能够接受的范围以内。通知发送相比之下性能就很低了,存取注入对象更低。

固然除了性能消耗外,还有不少很差量化的维度,包括规范约束、功能性、代码量、可读性等,笔者按照实际场景客观评价给出对比的分值。

下面,咱们用五种维度的能力值图来对比每一种方案优缺点:

  • 各维度的的评分考虑到了必定的实际场景,可能和常规结果稍有误差。

  • 已经作了转化,看图面积越大越优。可读性的维度越长表明可读性越高,代码量的维度越长表明代码成本越少。

图2-1 各方案优缺点对比

图2-1 各方案优缺点对比

 

如图2所示,能够看出上图的四种方式或多或少都存在一些缺点:

  1. 依赖注入是由于美团的实际场景问题,因此在性能消耗上存在明显的短板,而且代码量和可读性都不突出,规范约束这里是亮点。
  2. SPI机制的范围图很大,但使用了反射,而且代码开发成本较高,实践上来看,对协议管理有必定要求。
  3. 通知中心看上去挺方便,但发送与接收大多成对出现,还附带绑定方法或者Block,代码量并很多。
  4. 而msgsend功能强大,代码量也少,可是在规范约束和可读性上几乎为零。

综合看来 SPI 和 objc_msgSend 二者的特色比较明显,颇有潜力,若是针对这两种方案分别进行必定程度的完善,应该能够实现一个综合评分更高的方案。

从现有方案中完善或衍生出的方案

5.使用Category+NSInvocation

此方案从 objc_msgSend 演化而来。NSInvocation 的调用方式的底层仍是会使用到 objc_msgSend,可是经过一些方法签名和返回值类型校验,能够解决不少类型规范相关的问题,而且这种方式没有繁琐的注册步骤,任何一次新接口的添加,均可以直接在低层的库中进行完成。

为了更进一步限制调用者可以调用的接口,建立一些 Category 来提供接口,内部包装下层接口,把返回值和入参都限制实际的类型。业界比较接近的例子有 casatwy 的 CTMediator。

6.原生CategoryCoverOrigin方式

此方案从 SPI 方式演化而来。两个的共同点是都在平台层提供接口供业务方调用,不一样点是此方式彻底规避了各类硬编码。并且 CategoryCoverOrigin 是一个思想,没有任何框架代码,能够说 OC 的 Runtime 就是这个方案的框架支撑。此方案的核心操做是在基类里汇总全部业务接口,在上层的业务库中建立基类的 Category 中对声明的接口进行覆盖。整个过程没有任何硬编码与反射。

演化出的这两种方案能力评估以下(绿色部分),图中也贴了和演化前方案(桔色部分)的对比:

图2-2 两种演化方案对比

图2-2 两种演化方案对比

 

上文对这两种方案描述的很是归纳,可能有同窗会对能力评估存在质疑。接下来会分别进行详解的介绍,并描述在实际操做值得注意的细节。这两种方案组合成了外卖内部的组件通讯框架 WMScheduler。

WMScheduler组件通讯

外卖的 WMScheduler 主要是经过对 Category 特性的运用来实现组件间通讯,实际操做中有两种的应用方案:Category+NSInvocation 和 Category CoverOrigin。

1.Category+NSInvocation方案

方案简介:

这个方案将其对 NSInvocation 功能容错封装、参数判断、类型转换的代码写在下层,提供简易万能的接口。并在上层建立通讯调度器类提供经常使用接口,在调度器的的 Category 里扩展特定业务的专用接口。全部的上层接口均有规范约束,这些规范接口的内部会调用下层的简易万能接口便可经过NSInvocation 相关的硬编码操做调用任何方法。

UML图:

图3-1 Category+NSInvocation的UML图

图3-1 Category+NSInvocation的UML图

 

如图3-1所示,代码的核心在 WMSchedulerCore 类,其包含了基于 NSInvocation 对 target 与 method 的操做、对参数的处理(包括对象,基本数据类型,NULL类型)、对异常的处理等等,最终开放了简洁的万能接口,接口参数有 target、method、parameters等等,而后内部帮咱们完成调用。但这个接口并非让上层业务直接进行调用,而是须要建立一个 WMSchedule r的 Category,在这个 Category 中编写规范的接口(前缀、入参类型、返回值类型都是肯定的)。

值得一提的是,提供业务专用接口的 Category 没有以 WMSchedulerCore 为基类,而是以 WMScheduler 为基类。看似画蛇添足,其实是为了作权限的隔离。 上层业务只能访问到 WMScheduler.h 及其 Category 的规范接口。并不能访问到 WMSchedulerCore.h 提供的“万能但不规范”接口。

例如:在UML图中能够看到 外界只能够调用到wms_getOrderCountWithPoiid(规范接口),并不能使用wm_excuteInstance Method(万能接口)。

为了更好地理解实际使用,笔者贴一个组件调用周期的完整代码:

图3-2 Category+NSInvocation的示例图

图3-2 Category+NSInvocation的示例图

 

如图3-2,在这种方案下,“B库调用A库方法”的需求只须要改两个仓库的代码,须要改动的文件标了下划线,请仔细看下示例代码。

示例代码:

平台(通用功能)库三个文件:

// WMScheduler+AKit.h
#import "WMScheduler.h" @interface WMScheduler(AKit) /** * 经过商家id查到当前购物车已选e的小红点数量 * @param poiid 商家id * @return 实际的小红点数量 */ + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID; @end 

// WMScheduler+AKit.m
#import "WMSchedulerCore.h" #import "WMScheduler+AKit.h" #import "NSObject+WMScheduler.h" @implementation WMScheduler (AKit) + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID{ if (nil == poiid) { return 0; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" id singleton = [wm_scheduler_getClass("WMXXXSingleton") wm_executeMethod:@selector(sharedInstance)]; NSNumber* orderFoodCount = [singleton wm_executeMethod:@selector(calculateOrderedFoodCountWithPoiID:) params:@[poiID]]; return orderFoodCount == nil ? 0 : [orderFoodCount integerValue]; #pragma clang diagnostic pop } @end 

// WMSchedulerInterfaceList.h
#ifndef WMSchedulerInterfaceList_h
#define WMSchedulerInterfaceList_h
// 这个文件会被加到上层业务的pch里,因此下文不用import本文件
#import "WMScheduler.h" #import "WMScheduler+AKit.h" #endif /* WMSchedulerInterfaceList_h */ 

BKit (调用方)一个文件:

// WMHomeVC.m
@interface WMHomeVC () <UITableViewDataSource, UITableViewDelegate> @end @implementation WMHomeVC ... NSUInteger *foodCount = [WMScheduler wms_getOrderedFoodCountWithPoiID:currentPoi.poiID]; NSLog(@"%ld",foodCount); ... @end 

代码分析:

上文四个文件完成了一次跨组件的调用,在 WMScheduler+AKit.m 中的第30、31行,调用的都是AKit(提供方)的现有方法,由于 WMSchedulerCore 提供了 NSInvocation 的调用方式,因此能够直接向上调用。WMScheduler+AKit 中提供的接口就是上文说的“规范接口”,这个接口在WMHomeVC(调用方)调用时和调用本仓库内的OC方法,并无区别。

延伸思考:

  • 上文的例子中入参和返回值都是基本数据类型,Domain 也是支持的,前提是这个 Domain 是放在平台库的。咱们能够将工程中的 Domain 分为BO(Business Object)、VO(View Object)与TO(Transfer Object),VO 常常出如今 view 和 cell,BO通常仅在各业务子库内部使用,这个TO则是须要放在平台库是用于各个组件间的通讯的通用模型。例如:通用 PoiDomain,通用 OrderDomain,通用 AddressDomain 等等。这些称为 TO 的 Domain 能够做为规范接口的入参类型或返回值类型。

  • 在实际业务场景中,跳转页面时传递 Domain 的需求也是一个老生常谈的问题,大多数页面级跳转框架仅支持传递基本数据类型(也有 trick 的方式传 Domain 内存地址但很不优雅)。在有了上文支持的能力,咱们能够在规范接口内经过万能接口获取目标页面的VC,并调用其某个属性的 set 方法将咱们想传递的Domain赋值过去,而后将这个 VC 对象做为返回值返回。调用方得到这个 VC 后在当前的导航栈内push便可。

  • 上文代码中咱们用 WMScheduler 调用了 Akit 的一个名为calculateOrderedFoodCount WithPoiID:的方法。那么有个争议点:在组件通讯须要调用某方法时,是容许直接调用现有方法,仍是复制一份加上前缀标注此方法专门用于提供组件通讯? 前者的问题点在于现有方法可能会被修改,扩充参数会直接致使调用方找不到方法,Method 字符串的不会编译报错(上文平台代码 WMScheduler+AKit.m 中第31行)。后者的问题在于大大增长了开发成本。权衡后咱们仍是使用了前者,加了些特殊处理,若现有方法被修改了,则会在isReponseForSelector这里检查出来,并走到 else 的断言及时发现。

阶段总结:

Category+NSInvocation 方案的优势是便捷,由于 Category 的专用接口放在平台库,之后有除了 BKit 之外的其余调用方也能够直接调用,还有更多强大的功能。

可是,不优雅的地方咱们也列举一下:

  • 当这个跨组件方法内部的代码行数比较多时,会写不少硬编码。

  • 硬编码method字符串,在现有方法被修改时,编译检测不报错(只能靠断言约束)。

  • 下层库向上调用的设计会被诟病。

接下来介绍的 CategoryCoverOrigin 的方案,能够解决这三个问题。

2.CategoryCoverOrigin方案

方案简介:

首先说明下这个方案和 NSInvocation 没有任何关系,此方案与上一方案也是彻底不一样的两个概念,不要将上一个方案的思惟带到这里。

此方案的思路是在平台层的 WMScheduler.h 提供接口方法,接口的实现只写空实现或者兜底实现(兜底实现中可根据业务场景在 Debug 环境下增长 toast 提示或断言),上层库的提供方实现接口方法并经过 Category 的特性,在运行时进行对基类同名方法的替换。调用方则正常调用平台层提供的接口。在 CategoryCoverOrigin 的方案中 WMScheduler 的 Category 在提供方仓库内部,所以业务逻辑的依赖能够在仓库内部使用常规的OC调用。

UML图:

图4-1 CategoryCover 的 UML 图

图4-1 CategoryCover 的 UML 图

 

从图4-1能够看出,WMScheduler 的 Category 被移到了业务仓库,而且 WMScheduler 中有全部接口的全集。

为了更好地理解 CategoryCover 实际应用,笔者再贴一个此方案下的完整完整代码:

图4-2 CategoryCover的示例图

图4-2 CategoryCover的示例图

 

如图4-2,在这种方案下,“B库调用A库方法”的需求须要修改三个仓库的代码,但除了这四个编辑的文件,没有其余任何的依赖了,请仔细看下代码示例。

示例代码:

平台(通用功能库)两个文件

//  WMScheduler.h
@interface WMScheduler : NSObject // 这个文件是全部组件通讯方法的汇总 #pragma mark - AKit /** * 经过商家id查到当前购物车已选e的小红点数量 * @param poiid 商家id * @return 实际的小红点数量 */ + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID; #pragma mark - CKit // ... #pragma mark - DKit // ... @end 

// WMScheduler.m
#import "WMScheduler.h" @implementation WMScheduler #pragma mark - Akit + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID { return 0; // 这个.m里只要求一个空实现 做为兜底方案。 } #pragma mark - Ckit // ... #pragma mark - Dkit // ... @end 

AKit(提供方)一个 Category 文件:

// WMScheduler+AKit.m
#import "WMScheduler.h" #import "WMAKitBusinessManager.h" #import "WMXXXSingleton.h" // 直接导入了不少AKit相关的业务文件,由于自己就在AKit仓库内 @implementation WMScheduler (AKit) // 这个宏能够屏蔽分类覆盖基类方法的警告 #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation" // 在平台层写过的方法,这边是是自动补全的 + (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID { if (nil == poiid) { return 0; } // 全部AKIT相关的类都能直接接口调用,不须要任何硬编码,能够和以前的写法对比下。 WMXXXSingleton *singleton = [WMXXXSingleton sharedInstance]; NSNumber *orderFoodCount = [singleton calculateOrderedFoodCountWithPoiID:poiID]; return orderFoodCount == nil ? 0 : [orderFoodCount integerValue]; } #pragma clang diagnostic pop @end 

BKit(调用方) 一个文件写法不变:

// WMHomeVC.m
@interface WMHomeVC () <UITableViewDataSource, UITableViewDelegate> @end @implementation WMHomeVC ... NSUInteger *foodCount = [WMScheduler wms_getOrderedFoodCountWithPoiID:currentPoi.poiID]; NSLog(@"%ld",foodCount); ... @end 

代码分析:

CategoryCoverOrigin 的方式,平台库用 WMScheduler.h 文件存放全部的组件通讯接口的汇总,各个仓库用注释隔开,并在.m文件中编写了空实现。功能代码编写在服务提供方仓库的 WMScheduler+AKit.m,看这个文件的1七、18行业务逻辑是使用常规 OC 接口调用。在运行时此Category的方法会覆盖 WMScheduler.h 基类中的同名方法,从而达到目的。CategoryCoverOrigin 方式不须要其余功能类的支撑。

延伸思考:

若是业务库不少,方法不少,会不会出现 WMScheduler.h 爆炸? 目前咱们的工程跨组件调用的实际场景不是不少,因此汇总在一个文件了,若是满屏都是跨组件调用的工程,则须要思考业务架构与模块划分是否合理这一问题。固然,若是真出现 WMScheduler.h 爆炸的状况,彻底能够将各个业务的接口移至本身Category 的.h文件中,而后建立一个 WMSchedulerInterfaceList 文件统一 import 这些 Category。

两种方案的选择

刚才咱们对于 Category+NSInvocation 和 CategoryCoverOrigin 两种方式都作了详细的介绍,咱们再整理一下二者的优缺点对比:

  Category+NSInvocation CategoryCover
优势 只改两个仓库,流程上的时间成本更少
能够实现url调用方法
(scheme://target/method:?para=x)
无任何硬编码,常规OC接口调用
除了接口声明、分类覆盖、调用,没有其余多余代码
不存在下层调用上层的场景
缺点 功能复杂时硬编码写法成本较大
下层调上层,上层业务改变时会影响平台接口
不能使用url调用方法
新增接口时需改动三个仓库,稍有麻烦。
(当接口已存在时,两种方式都只需修改一处)

笔者更建议使用 CategoryCoverOrigin 的无硬编码的方案,固然具体也要看项目的实际场景,从而作出最优的选择。

更多建议

  • 关于组件对外提供的接口,咱们更倾向于借鉴 SPI 的思想,做为一个 Kit 哪些功能是须要对外公开的?提供哪些服务给其余方解耦调用?建议主动开放核心方法,尽可能减小“用到才补”的场景。例如全局购物车就须要“提供获取小红点数量的方法”,商家中心就须要提供“根据字符串 id 获得整个 Poi 的 Domain”的接口服务。
  • 须要考虑到抽象能力,提供更有泛用性的接口。好比“获取到了最低满减价格后拼接成一个文案返回字符串” 这个方法,就没有“获取到了最低满减价格” 这个方法具有泛用性。

Category 风险管控

先举两个发生过的案例

1. 2017年10月 一个关于NSDate重复覆盖的问题

当时美团平台有 NSDate+MTAddition 类,在外卖侧有 NSDate+WMAddition 类。前者 NSDate+MTAddition 以前就有方法 getCurrentTimestamp,返回的时间戳是秒。后者 NSDate+WMAddition 在一次需求中也增长了 getCurrentTimestamp 方法,可是为了和其余平台统一口径返回值使用了毫秒。在正常的加载顺序中外卖类比平台类要晚,所以在外卖的测试中没有发现问题。但集成到 imeituan 主项目以后,原先其余业务方调用这个返回“秒”的方法,就被外卖测的返回“毫秒”的同名方法给覆盖了,出现接口错误和UI错乱等问题。

2. 2018年3月 一个WMScheduler组件通讯遇到的问题

在外卖侧有订单组件和商家容器组件,这两个组件的联系是十分紧密的,有的功能放在两个仓库任意一个中都说的通。所以出现了了两个仓库写了同名方法的场景。在 WMScheduler+Restaurant 和 WMScheduler+Order 两个仓库都添加了方法 -(void)wms_enterGlobalCartPageFromPage:,在运行中这两处有一处被覆盖。在有一次 Bug 解决中,给其中一处增长了异常处理的代码,恰巧增长的这处先加载,就被后加载的同名方法覆盖了,这就致使了异常处理代码不生效的问题。

那么使用 CategoryCover 的方式是否是很不安全? NO!只要弄清其中的规律,风险点都是彻底能够管控的,接下来,咱们来分析 Category 的覆盖原理。

Category 方法覆盖原理

1) Category 的方法没有“彻底替换掉”原来类已经有的方法,也就是说若是 Category 和原来类都有methodA,那么 Category 附加完成以后,类的方法列表里会有两个 methodA。

2) Category 方法被放到了新方法列表的前面,而原来类的方法被放到了新方法列表的后面,这也就是咱们日常所说的 Category 的方法会“覆盖”掉原来类的同名方法,这是由于运行过程当中,咱们在查找方法的时候会顺着方法列表的顺序去查找,它只要一找到对应名字的方法,就会罢休^_^,却不知后面可能还有同样名字的方法。

Category 在运行期进行决议,而基类的类是在编译期进行决议,所以分类中,方法的加载顺序必定在基类以后。

美团曾经有一篇技术博客深刻分析了 Category,而且从编译器和源码的角度对分类覆盖操做进行详细解析:深刻理解Objective-C:Category

根据方法覆盖的原理,咱们能够分析出哪些操做比较安全,哪些存在风险,并针对性地进行管理。接下来,咱们就介绍美团 Category 管理相关的一些工做。

Category 方法管理

因为历史缘由,无论是什么样的管理规则,都没法直接“一刀切”。因此针对现状,咱们将整个管理环节先拆分为“数据”、“场景”、 “策略”三部分。

其中数据层负责发现异常数据,全部策略公用一个数据层。针对 Category 方法的数据获取,咱们有以下几种方式:

根据优缺点的分析,再考虑到美团已经完全实现了“组件化”的工程,因此对 Category 的管控最好放在集成阶段之后进行。咱们最终选择了使用 linkmap 进行数据获取,具体方法咱们将在下文进行介绍。

策略部分则针对不一样的场景异常进行控制,主要的开发工做位于咱们的组件化 CI 系统上,即以前介绍过的 Hyperloop 系统。

Hyperloop 自己即提供了包括白名单,发布集成流程管理等一系列策略功能,咱们只须要将工具进行关联开发便可。咱们开发的数据层做为一个独立组件,最终也是运行在 Hyperloop 上。

图5-2 方法管理环节

图5-2 方法管理环节

 

根据场景细分的策略以下表所示(须要注意的是,表中有的场景实际不存在,只是为了思考的严谨列出):

咱们在前文描述的 CategoryCoverOrigin 的组件通讯方案的管控体如今第2点。风险管控中提到的两个案例的管控主要体如今第4点。

Category 数据获取原理

上一章节,咱们提到了采用 linkmap 分析的方式进行 Category 数据获取。在这一章节内,咱们详细介绍下作法。

启用 linkmap

首先,linkmap 生成功能是默认关闭的,咱们须要在 build settings 内手动打开开关并配置存储路径。对于美团工程和美团外卖工程来讲,每次正式构建后产生的 linkmap,咱们还会经过内部的美团云存储工具进行持久化的存储,保证后续的可追溯。

图6 启用 linkmap 的设置

图6 启用 linkmap 的设置

 

linkmap 组成

若要解析 linkmap,首先须要了解 linkmap 的组成。

如名称所示,linkmap 文件生成于代码连接以后,主要由4个部分组成:基本信息、Object files 表、Sections 表和 Symbols 表。

前两行是基本信息,包括连接完成的二进制路径和架构。若是一个工程内有多个最终产物(如 Watch App 或 Extension),则通过配置后,每个产物的每一种架构都会生成一份 linkmap。

# Path: /var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/output-sandbox/DerivedData/Build/Intermediates.noindex/ArchiveIntermediates/imeituan/InstallationBuildProductsLocation/Applications/imeituan.app/imeituan
# Arch: arm64

第二部分的 Object files,列举了连接所用到的全部的目标文件,包括代码编译出来的,静态连接库内的和动态连接库(如系统库),而且给每个目标文件分配了一个 file id。

# Object files:
[  0] linker synthesized
[  1] dtrace
[  2] /var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/output-sandbox/DerivedData/Build/Intermediates.noindex/ArchiveIntermediates/imeituan/IntermediateBuildFilesPath/imeituan.build/DailyBuild-iphoneos/imeituan.build/Objects-normal/arm64/main.o …… [ 26] /private/var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/repo-sandbox/imeituan/Pods/AFNetworking/bin/libAFNetworking.a(AFHTTPRequestOperation.o) …… [25919] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/usr/lib/libobjc.tbd [25920] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/usr/lib/libSystem.tbd 

第三部分的 Sections,记录了全部的 Section,以及它们所属的 Segment 和大小等信息。

# Sections:
# Address	Size    	Segment	Section
0x100004450	0x07A8A8D0	__TEXT	__text
…… 0x109EA52C0 0x002580A0 __DATA __objc_data 0x10A0FD360 0x001D8570 __DATA __data 0x10A2D58D0 0x0000B960 __DATA __objc_k_kylin …… 0x10BFE4E5D 0x004CBE63 __RODATA __objc_methname 0x10C4B0CC0 0x000D560B __RODATA __objc_classname 

第四部分的 Symbols 是重头戏,列举了全部符号的信息,包括所属的 object file、大小等。符号除了咱们关注的 OC 的方法、类名、协议名等,也包含 block、literal string 等,能够供其余需求分析进行使用。

# Symbols:
# Address	Size    	File  Name 0x1000045B8 0x00000060 [ 2] ___llvm_gcov_writeout 0x100004618 0x00000028 [ 2] ___llvm_gcov_flush 0x100004640 0x00000014 [ 2] ___llvm_gcov_init 0x100004654 0x00000014 [ 2] ___llvm_gcov_init.4 0x100004668 0x00000014 [ 2] ___llvm_gcov_init.6 0x10000467C 0x0000015C [ 3] _main …… 0x10002F56C 0x00000028 [ 38] -[UIButton(_AFNetworking) af_imageRequestOperationForState:] 0x10002F594 0x0000002C [ 38] -[UIButton(_AFNetworking) af_setImageRequestOperation:forState:] 0x10002F5C0 0x00000028 [ 38] -[UIButton(_AFNetworking) af_backgroundImageRequestOperationForState:] 0x10002F5E8 0x0000002C [ 38] -[UIButton(_AFNetworking) af_setBackgroundImageRequestOperation:forState:] 0x10002F614 0x0000006C [ 38] +[UIButton(AFNetworking) sharedImageCache] 0x10002F680 0x00000010 [ 38] +[UIButton(AFNetworking) setSharedImageCache:] 0x10002F690 0x00000084 [ 38] -[UIButton(AFNetworking) imageResponseSerializer] …… 

linkmap 数据化

根据上文的分析,在理解了 linkmap 的格式后,经过简单的文本分析便可提取数据。因为美团内部 iOS 开发工具链统一采用 Ruby,因此 linkmap 分析也采用 Ruby 开发,整个解析器被封装成一个 Ruby Gem。

具体实施上,处于通用性考虑,咱们的 linkmap 解析工具分为解析、模型、解析器三层,每一层均可以单独进行扩展。

图7 linkmap解析工具

图7 linkmap解析工具

 

对于 Category 分析器来讲,link map parser 解析指定 linkmap,生成通用模型的实例。从实例中获取 symbol 类,将名字中有“()”的符号过滤出来,即为 Category 方法。

接下来只要按照方法名聚合,若是超过1个则确定有 Category 方法冲突的状况。按照上一节中分析的场景,分析其具体冲突类型,提供结论输出给 Hyperloop。

具体对外接口能够直接参考咱们的工具测试用例。最后该 Gem 会直接被 Hyperloop 使用。

 it 'should return a map with keys for method name and classify' do
    @parser = LinkmapParser::Parser.new
    @file_path = 'spec/fixtures/imeituan-LinkMap-normal-arm64.txt'
    @analyze_result_with_classification = @parser.parse @file_path

    expect(@analyze_result_with_classification.class).to eq(Hash)

    # Category 方法互相冲突 symbol = @analyze_result_with_classification["-[NSDate isEqualToDateDay:]"] expect(symbol.class).to eq(Hash) expect(symbol[:type]).to eq([LinkmapParser::CategoryConflictType::CONFLICT]) expect(symbol[:detail].class).to eq(Array) expect(symbol[:detail].count).to eq(3) # Category 方法覆盖原方法 symbol = @analyze_result_with_classification["-[UGCReviewManager setCommonConfig:]"] expect(symbol.class).to eq(Hash) expect(symbol[:type]).to eq([LinkmapParser::CategoryConflictType::REPLACE]) expect(symbol[:detail].class).to eq(Array) expect(symbol[:detail].count).to eq(2) end 

Category 方法管理总结

1. 风险管理

对于任何语法工具,都是有利有弊的。因此除了发掘它们在实际场景中的应用,也要时刻对它们可能带来的风险保持警戒,并选择合适的工具和时机来管理风险。

而 Xcode 自己提供了很多的工具和时机,能够供咱们分析构建过程和产物。如果在平常工做中遇到一些坑,不妨从构建期工具的角度去考虑管理。好比本文内提到的 linkmap,不只能够用于 Category 分析,还能够用于二进制大小分析、组件信息管理等。投入必定资源在相关工具开发上,每每能够得到事半功倍的效果。

2. 代码规范

回到 Category 的使用,除了工具上的管控,咱们也有相应的代码规范,从源头管理风险。如咱们在规范中要求全部的 Category 方法都使用前缀,下降无心冲突的可能。而且咱们也计划把“使用前缀”作成管控之一。

3. 后续规划

1.覆盖系统方法检查

因为目前在管控体系内暂时没有引入系统符号表,因此没法对覆盖系统方法的行为进行分析和拦截。咱们计划后续和 Crash 分析系统打通符号表体系,提前发现对系统库的不当覆盖。

2.工具复用

当前的管控系统仅针对美团外卖和美团 App,将来计划推广到其余 App。因为有 Hyperloop,事情在技术上并无太大的难度。

从工具自己的角度看,咱们有计划在合适的时机对数据层代码进行开源,但愿能对更多的开发有所帮助。

总结

在这篇文章中,咱们从具体的业务场景入手,总结了组件间调用的通用模型,并对经常使用的解耦方案进行了分析对比,最终选择了目前最适合咱们业务场景的方案。即经过 Category 覆盖的方式实现了依赖倒置,将构建时依赖延后到了运行时,达到咱们预期的解耦目标。同时针对该方案潜在的问题,经过 linkmap 工具管控的方式进行规避。

另外,咱们在模型设计时也提到,组件间解耦其实在 iOS 侧有多种方案选择。对于其余的方案实践,咱们也会陆续和你们分享。但愿咱们的工做能对你们的 iOS 开发组件间解耦工做有所启发。

 

 

美团开源Graver框架:用“雕刻”诠释iOS端UI界面的高效渲染

Graver 是一款高效的 UI 渲染框架,它以更低的资源消耗来构建十分流畅的 UI 界面。Graver 首创性的采用了基于绘制的视觉元素分解方式来构建界面,得益于此,该框架能让 UI 渲染过程变得更加简单、灵活。目前,该框架已经在美团 App 的外卖频道、独立外卖 App 核心业务场景的大多数业务中进行了应用,同时也获得美团外卖内部技术团队的承认和确定。

App 渲染性能优化是一个广泛存在的问题,为了惠及更多的前端开发同窗,美团外卖 iOS 开发团队将其进行开源,Github 项目地址与使用文档详见:https://github.com/Meituan-Dianping/Graver 。咱们但愿该框架可以应用到更广阔的业务场景。固然,咱们也知道该框架尚有待完善之处,也但愿能与更多技术同行一块儿交流、探讨、共建。

前言

咱们为何须要关注界面的渲染性能?App 使用体验主要包含产品功能、交互视觉、前端性能,而使用体验的好与坏,直接影响用户持续使用仍是转而使用其余 App,因此咱们很是关注 App 的渲染性能。并且在互联网产品流量竞争愈发激烈的大背景下,优质的使用体验能够为现有用户提供更好的服务,进而提升用户转化和留存,这也意味着创收、盈利。

图1 使用体验与转化、留存

图1 使用体验与转化、留存

 

背景

美团外卖 App 从2013年成立至今,已经走过了五个春秋,在技术层面前后经历了快速验证、模块化、精细化和平台化四个阶段,产品形态上也日趋成熟。在此期间,咱们构建并完善了监控、报警、容灾、备份等各项基础设施,Metrics 便是其中的性能监控系统。

曾经一段时间,咱们之外卖 App 首页商家卡片列表为例,经过 Metrics 性能监控系统发现其在 FPS、CPU、Memory 等方面的各项指标并不理想。因而,经过 Xcode 自带的 TimeProfile 等性能检测工具,而后结合代码分析等手段找到了现存性能瓶颈。与此同时,咱们梳理其近半年的迭代版本需求发现,UI 每每须要根据不一样场景甚至不一样用户展现不一样的内容。为了避免断迎合用户的需求,快速应对市场变化,这种特征还会持续存在。然而,它会带来如下问题:

  • 视图层级越发复杂、视图数量越发众多,从版本长期迭代来看是潜在的性能瓶颈点。
  • 如何快速、高效支撑 UI 变化,同时保证不会二次引入性能瓶颈。

图2 影响渲染性能、研发效率的瓶颈点

图2 影响渲染性能、研发效率的瓶颈点

 

Graver 介绍

为了解决现存的性能瓶颈以及后续潜在的性能瓶颈,咱们指望构建一套解决方案,该方案能在充分知足外卖业务特征的前提下,以标准化、一站式的方式解决 iOS 端 App 的渲染性能问题,而且对研发效率有必定提高, Graver(雕工)框架应运而生。

由于 Graver 首创性地采用了全新的视觉元素分解思路,因此该框架使用起来十分灵活、简单。咱们先来看一下 Graver 的主要特色:

性能表现优异

之外卖 App 首页商家列表为例,应用 Graver 以后5分位滚动帧率从满帧的84%提高至96%,50分位几乎满帧;CPU 占用率降低了近6个百分点,有效提高了空闲 CPU 的资源利用率,下降了峰值 CPU 的占用率。如图3所示:

图3 优化先后技术指标对比

图3 优化先后技术指标对比

 

“一站式”异步化

Graver 从文本计算、样式排版渲染、图片解码,再到绘制,实现了全程异步化,而且是线程安全的。使用 Graver 能够一站式得到所有性能优化点,能够让咱们:

  • 再也不担忧散点式的“碰见一处改一处”的麻烦。
  • 再也不担忧离屏渲染等各类可能致使性能瓶颈的问题,以及使人头痛的解决办法。
  • 再也不担忧优化会有遗漏、优化不到位。
  • 再也不担忧将来变化可能带来的任何性能瓶颈。

性能消耗的“边际成本”几乎为零

Graver 渲染整个过程除画板视图外彻底没有使用 UIKit 控件,最终产出的结果是一张位图(Bitmap),视图层级、数量大幅下降。之外卖 App 首页铂金展位视图为例,原有方案由58个控件、12层级拼接而成;而应用 Graver 后仅需1个视图、1级层级绘制而成。 伴随着需求迭代、视觉元素变化,性能消耗恒属常数级。如图4所示:

图4 外卖 App 铂金展位应用 Graver 先后对比

图4 外卖 App 铂金展位应用 Graver 先后对比

 

渲染速度快

Graver 并发进行多个画板视图的渲染、显示工做。得益于图文混排技术的应用,达到了内存占用低,渲染速度快的效果。因为排版数据是不变的,因此内部会进行缓存、复用,这又进一步促进了总体渲染效率。Graver 既作到了高效渲染,又保证了低时延页面加载。

图5 渲染效率说明

图5 渲染效率说明

 

以“少”胜“繁”

Graver 从新抽象封装 CoreText、CoreGraphic 等系统基础能力,经过少许系统标准图形绘制接口便可实现复杂界面展现。

基于位图(Bitmap)的轻量事件交互系统

如上述所说,界面展现从传统的视图树转变为一张位图,而位图不能响应、区份内部具体位置的点击事件。Graver 提供了基于位图的轻量事件交互系统,能够准确识别点击位置发生在位图的哪一块“绘制单元”内。该“绘制单元”能够理解为与咱们一向使用的某个具体 UI 控件相对应的视觉展现。使用 Graver 为某一视觉展现添加事件如同使用系统 UIButton 添加事件同样简单。

全新的视觉元素分解思路

Graver 一改界面编程思路,与传统的经过控件“拼接”、“添加”,视图排列组合方式构建界面不一样,它提供了灵活、便捷的接口让咱们以“视觉所见”的方式构建界面。这一特色在下文Graver使用中详细阐述,正是由于该特色实现了研发效率的提高。

Graver 使用

Graver 引入了全新的视觉元素分解的思路。借助该思路能够实现经过一种对象来表达任一视觉元素、甚至是任一视觉元素的组合,从而消除界面布局的复杂性。

咱们先来回顾下传统界面的构建方式,之外卖 App 商家卡片其中一种样式为例,如图6所示:

图6 外卖 App 商家卡片

图6 外卖 App 商家卡片

 

在实现商家卡片的界面样式时,一般会根据视觉上的识别、交互要求来创建界面展现与系统提供的 UI 控件间的映射关系。以标号②位置的样式为例,在考虑复用的状况下一般这部分会使用三个系统控件来完成,分别是左侧蓝底的“预订”使用 UILabel 控件、右侧的蓝色边框“2.26.21:30起送”使用 UILabel 控件、把左右两侧 UILabel 控件装起来的 UIView 控件;在肯定好采用的 UI 控件以后,须要针对展现样式分门别类的设置各个控件的渲染属性来实现图示 UI 效果,渲染属性一般一部分预设,一部分根据业务数据的不一样再进行二次设置;其次,设置各个控件的内容属性实现业务数据内容的展现,展现的内容通常是网络业务数据经逻辑处理、加工后的数据。若是涉及到点击事件,还须要添加手势或者更换成 UIButton 控件。接下来,须要根据视觉要求实现排版逻辑,以标号⑧、⑨为例,当标号⑧位置的数据没有的状况下,须要上提标号⑨位置的“美团专送”到图示标号⑧位置。诸如相似的排版逻辑随处可见。对于图示任一位置的展现内容都存在上述的循环思考、编写工做。随着界面元素的增长、变化,问题会变得更加复杂。

传统的界面构建方式实际上是在 UI控件的维度去分解视觉元素,具体是作如下四方面的编写工做:

  • 控件选择:根据展现内容、样式、交互要求肯定采用哪一种系统控件。
  • 布局信息:UI 控件的大小、位置,即 Frame。
  • 内容信息:UI 控件展现出来的业务数据,如标号①位置的“星巴克咖啡店”。
  • 渲染信息:UI 控件展现出来的效果,如字体、字号、透明度、边框、颜色等。

最后,将各个控件以排列组合方式合成为一棵视图树。

Graver 框架提供了以画板视图为基础,经过对更底层的 CoreText、CoreGraphic 框架封装,以更贴近“视觉所见”的角度定义了全新视觉元素分解、界面展现构建的过程。

一般“视觉所见”可划分为两部分:静态展现、动态展现。静态展现包含图片、文本;动态展现包含视频、动画等。在视觉展现所有为静态内容的时候,一个 Cell 便是一个画布,除此之外没有任何 UI 控件;不然,能够按需灵活的进行画布拆分来知足动画、视频等须要。

图7 画板和传统视图树

图7 画板和传统视图树

 

以图6商家卡片中标号②、⑧为例,新实现方式的伪代码是这样的:

WMMutableAttributedItem *item = [[WMMutableAttributedItem alloc] init];
[[[[item appendImage:[[UIImage wmg_imageWithColor:"blue"] wmg_drawText:"预订"]] 
         appendImage:[[UIImage wmg_imageWithColor:"clear" borderWidth:1 borderColor:"blue"] wmg_drawText:"2.26.21:30起送"] appendWhiteSpaceWithWidth:"width"]//整体宽度减去②和⑧的宽度总和剩余部分 apendText:"50分钟|2.5km"]; 

上述实现方式便是把标号②、⑧部分做为一个总体来实现,任何单一系统控件都没法作到这一点。

Graver 渲染原理

图8 Graver 工做时序

图8 Graver 工做时序

 

如图8所示,Graver 涉及多个队列间的交互,之外卖 App 商家列表为例,总体流程以下:

  • 主线程构建请求参数,建立请求任务并放入网络线程队列中,发起网络请求。
  • 网络线程向后端服务发起请求,得到对应的业务模型数据(如包含了店铺名称,商家头图,评分,配送时长,客单价,优惠活动等店铺属性的商家卡片列表)。
  • 网络线程建立包含业务模型数据(如商家卡片列表)的排版任务,提交到预排版线程处理,进入预排版流程。预排版队列取出排版任务,交由布局引擎计算 UI 布局,将业务模型解析成可被渲染引擎直接处理的,包含布局、层级、渲染信息的排版模型。解析结束后,通知主线程排版完成。
  • 主线程获取排版模型后,随即触发内容显示。根据相对屏幕位置及出现的前后顺序,建立包含将须要显示区域信息的绘制任务,放入异步绘制线程队列中,发起绘制流程。
  • 异步绘制线程队列取出绘制任务,进行图文绘制,最终输出一张包含了图文内容(如商家卡片)的图片。绘制任务结束后,通知主线程队绘制完成,主线程随后展现绘制区域。

总体按照队列间串行、队列内并行的方式执行。

业务应用

Graver 在外卖内部发布以后,咱们也将其推广到更多的业务线,并但愿 Graver 可以成为对业务开展有重要保障的一项基础服务。通过半年多的内部试用,Graver 的可靠性、渲染性能、业务适应能力也受到外卖内部的确定和承认。截止发稿时,Graver 已经基本覆盖了美团 App 的外卖频道、独立外卖 App 核心业务场景的大多数业务。下面列举 Graver 在外卖业务的部分应用案例:

09业务应用

09业务应用

 

经验总结

总结一下,对于界面渲染性能优化而言,要站在一个更高角度来思考问题的解决方案。横向上,从普适性角度解决性能瓶颈点,避免其余人遇到相似问题的重复工做;纵向上,从长远考虑问题作到防微杜渐,一次优化,长期受益。基于此,咱们提出一站式、标准化的渲染性能解决方案。诚然,这会遇到不少难点。面对界面样式构建的问题,系统 UIKit 框架着实为咱们提供了便利,然而有时候咱们须要跳出固有思惟,尝试创建一套全新界面构建、视觉元素分解的思路。

参考资料

 

美团外卖iOS App冷启动治理

1、背景

冷启动时长是App性能的重要指标,做为用户体验的第一道“门”,直接决定着用户对App的第一印象。美团外卖iOS客户端从2013年11月开始,历经几十个版本的迭代开发,产品形态不断完善,业务功能日趋复杂;同时外卖App也已经由原来的独立业务App演进成为一个平台App,陆续接入了闪购、跑腿等其余新业务。所以,更多更复杂的工做须要在App冷启动的时候被完成,这给App的冷启动性能带来了挑战。对此,咱们团队基于业务形态的变化和外卖App的特色,对冷启动进行了持续且有针对性的优化工做,目的就是为了呈现更加流畅的用户体验。

2、冷启动定义

通常而言,你们把iOS冷启动的过程定义为:从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止。这个过程主要分为两个阶段:

  • T1:main()函数以前,即操做系统加载App可执行文件到内存,而后执行一系列的加载&连接等工做,最后执行至App的main()函数。
  • T2:main()函数以后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

然而,当didFinishLaunchingWithOptions执行完成时,用户尚未看到App的主界面,也不能开始使用App。例如在外卖App中,App还须要作一些初始化工做,而后经历定位、首页请求、首页渲染等过程后,用户才能真正看到数据内容并开始使用,咱们认为这个时候冷启动才算完成。咱们把这个过程定义为T3。

综上,外卖App把冷启动过程定义为:从用户点击App图标开始到用户能看到App主界面内容为止这个过程,即T1+T2+T3。在App冷启动过程中,这三个阶段中的每一个阶段都存在不少能够被优化的点。

3、问题现状

性能存量问题

美团外卖iOS客户端通过几十个版本的迭代开发后,在冷启动过程当中已经积累了若干性能问题,解决这些性能瓶颈是冷启动优化工做的首要目标,这些问题主要包括:

注:启动项的定义,在App启动过程当中须要被完成的某项工做,咱们称之为一个启动项。例如某个SDK的初始化、某个功能的预加载等。

性能增量问题

通常状况下,在App早期阶段,冷启动不会有明显的性能问题。冷启动性能问题也不是在某个版本忽然出现的,而是随着版本迭代,App功能愈来愈复杂,启动任务愈来愈多,冷启动时间也一点点延长。最后当咱们注意到,并想要优化它的时候,这个问题已经变得很棘手了。外卖App的性能问题增量主要来自启动项的增长,随着版本迭代,启动项任务简单粗暴地堆积在启动流程中。若是每一个版本冷启动时间增长0.1s,那么几个版本下来,冷启动时长就会明显增长不少。

4、治理思路

冷启动性能问题的治理目标主要有三个:

  1. 解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
  2. 管控增量问题:冷启动流程规范化,经过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
  3. 完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。

5、规范启动流程

截止至2017年末,美团外卖用户数已达2.5亿,而美团外卖App也已完成了从支撑单一业务的App到支持多业务的平台型App的演进(美团外卖iOS多端复用的推进、支撑与思考),公司的一些新兴业务也陆续集成到外卖App当中。下面是外卖App的架构图,外卖的架构主要分为三层,底层是基础组件层,中层是外卖平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口,上层是基础组件层,包括外卖业务拆分的子业务组件(外卖App和美团App中的外卖频道能够复用子业务组件)和接入的其余非外卖业务。

App的平台化为业务方提供了高效、标准的统一平台,但与此同时,平台化和业务的快速迭代也给冷启动带来了问题:

  1. 现有的启动项堆积严重,拖慢启动速度。
  2. 新的启动项缺少添加范式,杂乱无章,修改风险大,难以阅读和维护。

面对这个问题,咱们首先梳理了目前启动流程中全部的启动项,而后针对App平台化设计了新的启动项管理方式:分阶段启动和启动项自注册。

分阶段启动

早期因为业务比较简单,全部启动项都是不加以区分,简单地堆积到didFinishLaunchingWithOptions方法中,但随着业务的增长,愈来愈多的启动项代码堆积在一块儿,性能较差,代码臃肿而混乱。

经过对SDK的梳理和分析,咱们发现启动项也须要根据所完成的任务被分类,有些启动项是须要刚启动就执行的操做,如Crash监控、统计上报等,不然会致使信息收集的缺失;有些启动项须要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则能够被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。咱们所作的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,而后依据每一个启动项所作的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

下面是咱们对美团外卖App启动阶段进行的从新定义,对全部启动项进行的梳理和从新分类,把它们对应到合理的启动阶段。这样作一方面能够推迟执行那些没必要过早执行的启动项,缩短启动时间;另外一方面,把启动项进行归类,方便后续的阅读和维护。而后把这些规则落地为启动项的维护文档,指导后续启动项的新增和维护。

经过上面的工做,咱们梳理出了十几个能够推迟执行的启动项,占全部启动项的30%左右,有效地优化了启动项所占的这部分冷启动时间。

启动项自注册

肯定了启动项分阶段启动的方案后,咱们面对的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时建立一个启动管理器,而后读取全部启动项,而后当时间节点到来时由启动器触发启动项执行。这种方式存在两个问题:

  1. 全部启动项都要预先写到一个文件中(在.m文件import,或用.plist文件组织),这种中心化的写法会致使臃肿的代码,难以阅读维护。
  2. 启动项代码没法复用:启动项没法收敛到子业务库内部,在外卖App和美团App中要重复实现,和外卖App平台化的方向不符。

而咱们但愿的方式是,启动项维护方式可插拔,启动项之间、业务模块之间不耦合,且一次实现可在两端复用。下图是咱们采用的启动项管理方式,咱们称之为启动项的自注册:一个启动项定义在子业务模块内部,被封装成一个方法,而且自声明启动阶段(例如一个启动项A,在独立App中能够声明为在willFinishLaunch阶段被执行,在美团App中则声明在resignActive阶段被执行)。这种方式下,启动项即实现了两端复用,不相关的启动项互相隔离,添加/删除启动项都更加方便。

那么如何给一个启动项声明启动阶段?又如何在正确的时机触发启动项的执行呢?在代码上,一个启动项最终都会对应到一个函数的执行,因此在运行时只要能获取到函数的指针,就能够触发启动项。美团平台开发的组件启动治理基建Kylin正是这样作的:Kylin的核心思想就是在编译时把数据(如函数指针)写入到可执行文件的__DATA段中,运行时再从__DATA段取出数据进行相应的操做(调用函数)。

为何要用借用__DATA段呢?缘由就是为了可以覆盖全部的启动阶段,例如main()以前的阶段。

Kylin实现原理简述:Clang 提供了不少的编译器函数,它们能够完成不一样的功能。其中一种就是 section() 函数,section()函数提供了二进制段的读写能力,它能够将一些编译期就能够肯定的常量写入数据段。 在具体的实现中,主要分为编译期和运行时两个部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key表明不一样的启动阶段), *pointer}对到数据段。到运行时,在合适的时间节点,在根据key读取出函数指针,完成函数的调用。

上述方式,能够封装成一个宏,来达到代码的简化,以调用宏 KLN_STRINGS_EXPORT(“Key”, “Value”)为例,最终会被展开为:

__attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"}; 

使用示例,编译器把启动项函数注册到启动阶段A:

KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m文件中,经过注册宏,把启动项A声明为在STAGE_KEY_A阶段执行
    // 启动项代码A
}
KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m文件中,把启动项B声明为在STAGE_KEY_A阶段执行
    // 启动项代码B
}

在启动流程中,在启动阶段STAGE_KEY_A触发全部注册到STAGE_KEY_A时间节点的启动项,经过对这种方式,几乎没有任何额外的辅助代码,咱们用一种很简洁的方式完成了启动项的自注册。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // 其余逻辑 [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A]; // 在此触发全部注册到STAGE_KEY_A时间节点的启动项 // 其余逻辑 return YES; } 

完成对现有的启动项的梳理和优化后,咱们也输出了后续启动项的添加&维护规范,规范后续启动项的分类原则,优先级和启动阶段。目的是管控性能问题增量,保证优化成果。

6、优化main()以前

在调用main()函数以前,基本全部的工做都是由操做系统完成的,开发者可以插手的地方很少,因此若是想要优化这段时间,就必须先了解一下,操做系统在main()以前作了什么。main()以前操做系统所作的工做就是把可执行文件(Mach-O格式)加载到内存空间,而后加载动态连接库dyld,再执行一系列动态连接操做和初始化操做的过程(加载、绑定、及初始化方法)。这方面的资料网上比较多,但重复性较高,此处附上一篇WWDC的Topic:Optimizing App Startup Time 。

加载过程—从exec()到main()

真正的加载过程从exec()函数开始,exec()是一个系统调用。操做系统首先为进程分配一段内存空间,而后执行以下操做:

  1. 把App对应的可执行文件加载到内存。
  2. 把Dyld加载到内存。
  3. Dyld进行动态连接。

下面咱们简要分析一下Dyld在各阶段所作的事情:

阶段 工做
加载动态库 Dyld从主执行文件的header获取到须要加载的所依赖动态库列表,而后它须要找到每一个 dylib,而应用所依赖的 dylib 文件可能会再依赖其余 dylib,因此所须要加载的是动态库列表一个递归依赖的集合
Rebase和Bind - Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,全部指针和数据对于代码都是对的,而如今地址空间布局是随机化,因此须要在原来的地址根据随机的偏移量作一下修正
- Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld须要去符号表里查找,找到symbol对应的实现
Objc setup - 注册Objc类 (class registration)
- 把category的定义插入方法列表 (category registration)
- 保证每个selector惟一 (selector uniquing)
Initializers - Objc的+load()函数
- C++的构造函数属性函数
- 非基本类型的C++静态全局变量的建立(一般是类或结构体)

最后 dyld 会调用 main() 函数,main() 会调用 UIApplicationMain(),before main()的过程也就此完成。

了解完main()以前的加载过程后,咱们能够分析出一些影响T1时间的因素:

  1. 动态库加载越多,启动越慢。
  2. ObjC类,方法越多,启动越慢。
  3. ObjC的+load越多,启动越慢。
  4. C的constructor函数越多,启动越慢。
  5. C++静态对象越多,启动越慢。

针对以上几点,咱们作了以下一些优化工做。

代码瘦身

随着业务的迭代,不断有新的代码加入,同时也会废弃掉无用的代码和资源文件,可是工程中常常有无用的代码和文件被遗弃在角落里,没有及时被清理掉。这些无用的部分一方面增大了App的包体积,另外一方便也拖慢了App的冷启动速度,因此及时清理掉这些无用的代码和资源十分有必要。

经过对Mach-O文件的了解,能够知道__TEXT:__objc_methname:中包含了代码中的全部方法,而__DATA__objc_selrefs中则包含了全部被使用的方法的引用,经过取两个集合的差集就能够获得全部未被使用的代码。核心方法以下,具体能够参考:objc_cover:

def referenced_selectors(path):
    re_sel = re.compile("__TEXT:__objc_methname:(.+)") //获取全部方法
    refs = set()
    lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() ## ios & mac //真正被使用的方法 for line in lines: results = re_sel.findall(line) if results: refs.add(results[0]) return refs } 

经过这种方法,咱们排查了十几个无用类和250+无用的方法。

+load优化

目前iOS App中或多或少的都会写一些+load方法,用于在App启动执行一些操做,+load方法在Initializers阶段被执行,但过多+load方法则会拖慢启动速度,对于大中型的App更是如此。经过对App中+load的方法分析,发现不少代码虽然须要在App启动时较早的时机进行初始化,但并不须要在+load这样很是靠前的位置,彻底是能够延迟到App冷启动后的某个时间节点,例如一些路由操做。其实+load也能够被当作一种启动项来处理,因此在替换+load方法的具体实现上,咱们仍然采用了上面的Kylin方式。

使用示例:

// 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING声明替换+load声明便可,不需其余改动
WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() { 
    // 原+load方法中的代码
}
// 在某个合适的时机触发注册到该阶段的全部方法,如冷启动结束后
[[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY] 
}

7、优化耗时操做

在main()以后主要工做是各类启动项的执行(上面已经叙述),主界面的构建,例如TabBarVC,HomeVC等等。资源的加载,如图片I/O、图片解码、archive文档等。这些操做中可能会隐含着一些耗时操做,靠单纯阅读很是难以发现,如何发现这些耗时点呢?找到合适的工具就会事半功倍。

Time Profiler

Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每个线程的堆栈信息,经过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并得到一个近似值。Time Profiler的使用方法网上有不少使用教程,这里咱们也不过多介绍,附上一篇使用文档:Instruments Tutorial with Swift: Getting Started

火焰图

除了Time Profiler,火焰图也是一个分析CPU耗时的利器,相比于Time Profiler,火焰图更加清晰。火焰图分析的产物是一张调用栈耗时图片,之因此称为火焰图,是由于整个图形看起来就像一团跳动的火焰,火焰尖部是调用栈的栈顶,底部是栈底,纵向表示调用栈的深度,横向表示消耗的时间。一个格子的宽度越大,越说明其多是瓶颈。分析火焰图主要就是看那些比较宽大的火苗,特别留意那些相似“平顶山”的火苗。下面是美团平台开发的性能分析工具-Caesium的分析效果图:

经过对火焰图的分析,咱们发现了冷启动过程当中存在着很多问题,并成功优化了0.3S+的时间。优化内容总结以下:

优化点 举例
发现隐晦的耗时操做 发如今冷启动过程当中archive了一张图片,很是耗时
推迟&减小I/O操做 减小动画图片组的数量,替换大图资源等。由于相比于内存操做,硬盘I/O是很是耗时的操做
推迟执行的一些任务 如一些资源的I/O,一些布局逻辑,对象的建立时机等

8、优化串行操做

在冷启动过程当中,有不少操做是串行执行的,若干个任务串行执行,时间必然比较长。若是能变串行为并行,那么冷启动时间就可以大大缩短。

闪屏页的使用

如今许多App在启动时并不直接进入首页,而是会向用户展现一个持续一小段时间的闪屏页,若是使用恰当,这个闪屏页就能帮咱们节省一些启动时间。由于当一个App比较复杂的时候,启动时首次构建App的UI就是一个比较耗时的过程,假定这个时间是0.2秒,若是咱们是先构建首页UI,而后再在Window上加上这个闪屏页,那么冷启动时,App就会实实在在地卡住0.2秒,可是若是咱们是先把闪屏页做为App的RootViewController,那么这个构建过程就会很快。由于闪屏页只有一个简单的ImageView,而这个ImageView则会向用户展现一小段时间,这时咱们就能够利用这一段时间来构建首页UI了,一箭双雕。

缓存定位&首页预请求

美团外卖App冷启动过程当中一个重要的串行流程就是:首页定位–>首页请求–>首页渲染过程,这三个操做占了整个首页加载时间的77%左右,因此想要缩短冷启动时间,就必定要从这三点出发进行优化。

以前串行操做流程以下:

优化后的设计,在发起定位的同时,使用客户端缓存定位,进行首页数据的预请求,使定位和请求并行进行。而后当用户真实定位成功后,判断真实定位是否命中缓存定位,若是命中,则刚才的预请求数据有效,这样能够节省大概40%的时间首页加载时间,效果很是明显;若是未命中,则弃用预请求数据,从新请求。

9、数据监控

Time Profiler和Caesium火焰图都只能在线下分析App在单台设备中的耗时操做,局限性比较大,没法在线上监控App在用户设备上的表现。外卖App使用公司内部自研的Metrics性能监控系统,长期监控App的性能指标,帮助咱们掌握App在线上各类环境下的真实表现,并为技术优化项目提供可靠的数据支持。Metrics监控的核心指标之一,就是冷启动时间。

冷启动开始&结束时间节点

  1. 结束时间点:结束时间比较好肯定,咱们能够将首页某些视图元素的展现做为首页加载完成的标志。
  2. 开始时间点:通常状况下,咱们都是在main()以后才开始接管App,但以main()函数做为冷启动起始点显然不合适,由于这样没法统计到T1时间段。那么,起始时间如何肯定呢?目前业界常见的有两种方法,一是以可执行文件中任意一个类的+load方法的执行时间做为起始点;二是分析dylib的依赖关系,找到叶子节点的dylib,而后以其中某个类的+load方法的执行时间做为起始点。根据Dyld对dylib的加载顺序,后者的时机更早。可是这两种方法获取的起始点都只在Initializers阶段,而Initializers以前的时长都没有被计入。Metrics则另辟蹊径,以App的进程建立时间(即exec函数执行时间)做为冷启动的起始时间。由于系统容许咱们经过sysctl函数得到进程的有关信息,其中就包括进程建立的时间戳。
#import <sys/sysctl.h> #import <mach/mach.h> + (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo { int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid}; size_t size = sizeof(*procInfo); return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0; } + (NSTimeInterval)processStartTime { struct kinfo_proc kProcInfo; if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) { return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0; } else { NSAssert(NO, @"没法取得进程的信息"); return 0; } } 

进程建立的时机很是早。通过实验,在一个新建的空白App中,进程建立时间比叶子节点dylib中的+load方法执行时间早12ms,比main函数的执行时间早13ms(实验设备:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外卖App线上的数据则更加明显,一样的机型(iPhone 7 Plus)和系统版本(iOS 12.0),进程建立时间比叶子节点dylib中的+load方法执行时间早688ms。而在所有机型和系统版本中,这一数据则是878ms。

冷启动过程时间节点

咱们也在App冷启动过程当中的全部关键节点打上一连串测速点,Metrics会记录下测速点的名称,及其距离进程建立时间的时长。咱们没有采用自动打点的方式,是由于外卖App的冷启动过程十分复杂,而自动打点没法作到如此细致,并不实用。另外,Metrics记录的是时间轴上以进程建立时间为原点的一组顺序的时间点,而不是一组时间段,是由于顺序的时间点能够计算任意两个时间点之间的距离,便可以将时间点处理成时间段。可是,一组时间段可能没法还原为顺序的时间点,由于时间段之间可能并非首尾相接的,特别是对于异步执行或者多线程的状况。

在测速完毕后,Metrics会统一将全部测速点上报到后台。下图是美团外卖App 6.10版本的部分过程节点监控数据截图:

Metrics还会由后台对数据作聚合计算,获得冷启动总时长和各个测速点时长的50分位数、90分位数和95分位数的统计数据,这样咱们就能从宏观上对冷启动时长分布状况有所了解。下图中横轴为时长,纵轴为上报的样本数。

10、总结

对于快速迭代的App,随着业务复杂度的增长,冷启动时长会不可避免的增长。冷启动流程也是一个比较复杂的过程,当遇到冷启动性能瓶颈时,咱们能够根据App自身的特色,配合工具的使用,从多方面、多角度进行优化。同时,优化冷启动存量问题只是冷启动治理的第一步,由于冷启动性能问题并非一日形成的,也不能简单的经过一次优化工做就能解决,咱们须要经过合理的设计、规范的约束,来有效地管控性能问题的增量,并经过持续的线上监控来及时发现并修正性能问题,这样才可以长期保证良好的App冷启动体验。

 

 

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

前言

美团外卖2013年11月开始起步,随后高速发展,不断刷新多项行业记录。截止至2018年5月19日,日订单量峰值已超过2000万,是全球规模最大的外卖平台。业务的快速发展对技术支撑提出了更高的要求。为线上用户提供高稳定的服务体验,保障全链路业务和系统高可用运行的同时,要提高多入口业务的研发速度,推动App系统架构的合理演化,进一步提高跨部门跨地域团队之间的协做效率。而另外一方面随着用户数与订单数的高速增加,美团外卖逐渐有了流量平台的特征,兄弟业务纷纷尝试接入美团外卖进行推广和发布,指望提供统一标准化服务平台。所以,基础能力标准化,推动多端复用,同时输出成熟稳定的技术服务平台,一直是咱们技术团队追求的核心目标。

多端复用的端

这里的“端”有两层意思:

  • 其一是相同业务的多入口

    美团外卖在iOS下的业务入口有三个,『美团外卖』App、『美团』App的外卖频道、『大众点评』App的外卖频道。

    值得一提的是:因为用户画像与产品策略差别,『大众点评』外卖频道与『美团』外卖频道和『美团外卖』虽经历技术栈融合,但业务形态区别较大,暂不考虑上层业务的复用,故这篇文章主要介绍美团系两大入口的复用。

    在2015年外卖C端合并以前,美团系的两大入口由两个不一样的团队研发,虽然用户感知的交互界面几乎相同,但功能实现层面的代码风格和技术栈都存在较大差别,同一需求须要在两端重复开发显然不合理。因此,咱们的目标是相同功能,只须要写一次代码,作一次估时,其余端只需作少许的适配工做。

  • 其二是指平台上各个业务线

    外卖不一样兄弟业务线都依赖外卖基础业务,包括但不限于:地图定位、登陆绑定、网络通道、异常处理、工具UI等。考虑到标准化的范畴,这些基础能力也是须要多端复用的。

    图1 美团外卖的多端复用的目标

    图1 美团外卖的多端复用的目标

     

关于组件化

提到多端复用,难免与组件化产生联系,能够说组件化是多端复用的必要条件之一。大多数公司口中的“组件化”仅仅作到代码分库,使用Cocoapods的Podfile来管理,再在主工程把各个子库的版本号聚合起来。可是能设计一套合理的分层架构,理清依赖关系,并有一整套工具链支撑组件发版与集成的相对较少。不然组件化只会致使包体积增大,开发效率变慢,依赖关系复杂等反作用。

总体思路

A. 多端复用概念图

图2 多端复用概念图

图2 多端复用概念图

 

多端复用的目标形态其实很好理解,就是将原有主工程中的代码抽出独立组件(Pods),而后各自工程使用Podfile依赖所需的独立组件,独立组件再经过podspec间接依赖其余独立组件。

B. 准备工做

确认多端所依赖的基层库是一致的,这里的基层库包括开源库与公司内的技术栈。

iOS中经常使用开源库(网络、图片、布局)每一个功能基本都有一个库业界垄断,这一点是iOS相对于Android的优点。公司内也存在一些对开源库二次开发或自行研发的基础库,即技术栈。不一样的大组之间技术栈可能存在必定差别。如须要复用的端之间存在差别,则须要重构使得技术栈统一。(这里建议重构,不建议适配,由于若是作的不够完全,后续很大可能须要填坑。)

就美团而言,美团平台与点评平台做为公司两大App,历史积淀厚重。自2015年末合并以来,为了共建和沉淀公共服务,减小重复造轮子,提高研发效率,对上层业务方提供统一标准的高稳定基础能力,两大平台的底层技术栈也在不断融合。而美团外卖做为较早实践独立App,同时也是依托于两大平台App的大业务方,在外卖C端合并后的1年内,咱们也作了大量底层技术栈统一的必要工做。

C. 方案选型

在演进式设计与计划式设计中的抉择。

演进式设计指随着系统的开发而作设计变动,而计划式设计是指在开发以前彻底指定系统架构的设计。演进的设计,一样须要遵循架构设计的基本准则,它与计划的设计惟一的区别是设计的目标。演进的设计提倡知足客户现有的需求;而计划的设计则须要考虑将来的功能扩展。演进的设计推崇尽快地实现,追求快速肯定解决方案,快速编码以及快速实现;而计划的设计则须要考虑计划的周密性,架构的完整性并保证开发过程的有条不紊。

美团外卖iOS客户端,在多端复用的立项初期面临着多个关键点:频道入口与独立应用的复用,外卖平台的搭建,兄弟业务的接入,点评外卖的协做,以及架构迁移不影响现有业务的开发等等,所以权衡后咱们使用“演进式架构为主,计划式架构为辅”的设计方案。不强求历史代码一下达到终极完美架构,而是按部就班一步一个脚印,知足现有需求的同时并保留必定的扩展性。

演进式架构推进复用

术语解释

  • Waimai:特指『美团外卖』App,泛指那些独立App形式的业务入口,通常为project。
  • Channel:特指『美团』App中的外卖频道,泛指那些以频道或者Tab形式集成在主App内的业务入口,通常为Pods。
  • Special:指将Waimai中的业务代码与原有工程分离出来,让业务代码成为一个Pods的形态。
  • 下沉:即下沉到下层,这里的“下层”指架构的基层,通常为平台层或通用层。“下沉”指将不一样上层库中的代码统一并移动到下层的基层库中。

在这里先贴出动态的架构演进过程,让你们有一个宏观的概念,后续再对不一样节点的经历作进一步描述。

图3 演进式架构动态图

图3 演进式架构动态图

 

原始复用架构

如图4所示,在过去一两年,由于技术栈等缘由咱们只能采用比较保守的代码复用方案。将独立业务或工具类代码沉淀为一个个“Kit”,也就是粒度较小的组件。此时分层的概念还比较模糊,而且以往的工程因历史包袱致使耦合严重、逻辑复杂,在将UGC业务剥离后发现其余的业务代码没法轻易的抽出。(此时的代码复用率只有2.4%。)

鉴于以前的准备工做已经完成,多端基础库已经一致,因而咱们再也不采起保守策略,丰富了一些组件化通讯、解耦与过渡的手段,在分层架构上开始发力。

图4 原始复用架构

图4 原始复用架构

 

业务复用探索

在技术栈已统一,基础层已对齐的背景下,咱们挑选外卖核心业务之一的Store(即商家容器)开始了在业务复用上的探索。如图5所示,大体能够理解为“二合一,一分三”的思路,咱们从代码风格和开发思路上对两边的Store业务进行对齐,在此过程当中顺势将业务类与技术(功能)类的代码分离,一些通用Domain也随之分离。随着一个个组件的拆分,咱们的总体复用度有明显提高,但开发效率却意外的受到了影响。多库开发在版本的发布与集成中增长了不少人工操做:依赖冲突、lock文件冲突等问题都阻碍了咱们的开发效率进一步提高,而这就是以前“关于组件化”中提到的反作用。

因而咱们将自动发版与自动集成提上了日程。自动集成是将“组件开发完毕到功能合入工程主体打出测试包”之间的一系列操做自动化完成。在这以前必须完成一些前期铺垫工做——壳工程分离。

图5 商家容器下沉时期

图5 商家容器下沉时期

 

壳工程分离

如图6所示,壳工程顾名思义就是将原来的project中的代码所有拆出去,获得一个空壳,仅仅保留一些工程配置选项和依赖库管理文件。

为何说壳工程是自动集成的必要条件之一?

由于自动集成涉及版本号自增,须要机器修改工程配置类文件。若是在建立二进制的过程当中有新业务PR合入,会形成commit树分叉大几率产生冲突致使集成失败。抽出壳工程以后,咱们的壳只关心配置选项修改(不多),与依赖版本号的变化。业务代码的正常PR流程转移到了各自的业务组件git中,以此来杜绝人工与机器的冲突。

图6 壳工程分离

图6 壳工程分离

 

壳工程分离的意义主要有以下几点:

  • 让职能更加明确,以前的综合层身兼数职过于繁重。
  • 为自动集成铺路,避免业务PR与机器冲突。
  • 提高效率,后续Pods往Pods移动代码比proj往Pods移动代码更快。
  • 『美团外卖』向『美团』开发环境靠齐,下降适配成本。

图7 壳工程分离阶段图

图7 壳工程分离阶段图

 

图7的第一张图到第二张图就是上文提到的壳工程分离,将“Waimai”全部的业务代码打包抽出,移动到过渡仓库Special,让原先的“Waimai”成为壳。

第二张图到第三张图是Pods库的内部消化。

前一阶段至关于简单粗暴的物理代码移动,后一阶段是对Pods内整块代码的梳理与分库。

内部消化对齐

在前文“多端复用概念图”的部分咱们提到过,所谓的复用是让多端的project以Pods的方式接入统一的代码。咱们兼容考虑保留一端代码完整性,下降回接成本,决定分Subpods使用阶段性合入达到平滑迁移。

图8 代码下沉方案

图8 代码下沉方案

 

图8描述了多端相同模块内的代码具体是如何统一的。此时由于已经完成了壳工程分离,因此业务代码都在“Special”这样的过渡仓库中。

“Special”和“Channel”两端的模块统一大体可分为三步:平移 → 下沉 → 回接。(前提是此模块的业务上已经肯定是彻底一致。)

平移阶段是保留其中一端“Special”代码的完整性,以自上而下的平移方式将代码文件拷贝到另外一端“Channel”中。此时前者不受任何影响,后者的代码由于新文件拷贝和原有代码存在重复。此时将旧文件重命名,并深度优先遍历新文件的依赖关系补齐文件,最终使得编译经过。而后将旧文件中的部分差别代码加到新文件中作好必定的差别化管理,最后删除旧文件。

下沉阶段是将“Channel”处理后的代码解耦并独立出来,移动到下层的Pods或下层的SubPods。此时这里的代码是既支持“Special”也支持“Channel”的。

回接阶段是让“Special”以Pods依赖的形式引用以前下沉的模块,引用后删除平移前的代码文件。(若是是在版本的间隙完成当然最好,不然须要考虑平移前的代码文件在这段时间的diff。)

实际操做中很难在有限时间内处理完一个完整的模块(例如订单模块)下沉到Pods再回接。因而选择将大模块分红一个个子模块,这些子模块平滑的下沉到SubPods,而后“Special”也只引用这个统一后的SubPods,待一个模块彻底下沉完毕再拆出独立的Pods。

再总结下大量代码下沉时如何保证风险可控:

  • 联合PM,先进行业务梳理,特殊差别要标注出来。
  • 使用OClint的提早扫描依赖,作到心中有数,精准估时。
  • 以“Special”的代码风格为基准,“Channel”在对齐时仅作加法不作减法。
  • “Channel”对齐工做不影响“Special”,而且回接时工做量很小。
  • 分迭代包,QA资源提早协调。

中间件层级压平

通过前面的“内部消化”,Channel和Special中的过渡代码逐渐被分发到合适的组件,如图9所示,Special只剩下AppOnly,Channel也只剩下ChannelOnly。因而Special消亡,Channel变成打包工程。

AppOnly和ChannelOnly 与其余业务组件层级压平。上层只留下两个打包工程。

图9 中间件层级压平

图9 中间件层级压平

 

平台层建设

如图10所示,下层是外卖基础库,WaimaiKit包含众多细分后的平台能力,Domain为通用模型,XunfeiKit为对智能语音二次开发,CTKit为对CoreText渲染框架的二次开发。

针对平台适配层而言,在差别化收敛与依赖关系梳理方面发挥重要角色,这两点在下问的“衍生问题解决中”会有详细解释。

外卖基础库加上平台适配层,总体构成了咱们的外卖平台层(这是逻辑结构不是物理结构),提供了60余项通用能力,支持无差别调用。

图10 外卖平台层的建设

图10 外卖平台层的建设

 

多端通用架构

此时咱们把基层组件与开源组件梳理并补充上,达到多端通用架构,到这里能够说真正达到了多端复用的目标。

图11 多端通用架构完成

图11 多端通用架构完成

 

由上层不一样的打包工程来控制实际须要的组件。除去两个打包工程和两个Only组件,下面的组件都已达到多端复用。对比下“Waimai”与“Channel”的业务架构图中两个黑色圆圈的部分。

图12 “Waimai”的业务架构

图12 “Waimai”的业务架构

 

图13 “Channel”的业务架构

图13 “Channel”的业务架构

 

衍生问题解决

差别问题

A.需求自己的差别

三种解决策略:

  • 对于文案、数值、等一两行代码的差别咱们使用 运行时宏(动态获取proj-identifier)或预编译宏(custome define)直接在方法中进行if else判断。
  • 对于方法实现的不一样 使用Glue(胶水层),protocol提供相同的方法声明,用来给外部调用,在不一样的载体中写不一样的方法实现。
  • 对于较大差别例如两边WebView容器不同,咱们建多个文件采用文件级预编译,可预编译常规.m文件或者Category。(例如WMWebViewManeger_wm.m&WMWebViewManeger_mt.m、UITableView+WMEstimated.m&UITableView+MTEstimated.m)

进一步优化策略:

用上述三种策略虽然完成差别化管理,但差别代码散落在不一样组件内难以收敛,不便于管理。有了平台适配层以后,咱们将差别化判断收敛到适配层内部,对上层提供无差别调用。组件开发者在开发中不用考虑宿主差别,直接调用用通用接口。差别的判断或者后续优化在接口内部处理外部不感知。

图14给出了一个平台适配层提供通用接口修改后的例子。

图14 平台适配层接口示例

图14 平台适配层接口示例

 

B.多端节奏差别

实际场景中除了需求的差别还有可能出现多端进版节奏的差别,这类差别问题咱们使用分支管理模型解决。

前提条件既然要多端复用了,那需求的大方向仍是会但愿多端统一。通常较多的场景是:多端中A端功能最少,B端功能基本算是是A端的超集。(没有绝对的超集,A端也会有较少的差别点。)在外卖的业务中,“Channel”就是这个功能较少的一端,“Waimai”基本是“Channel”的超集。

两端的差别大体分为了这5大类9小类:

  1. 需求两端相同(1.一、提测上线时间基本相同;1.二、“Waimai”比“Channel”早3天提测 ;1.三、“Waimai”比“Channel”晚3天提测)。
  2. 需求“Waimai”先进版,“Channel”下一版进 (2.一、频道下一版就上;2.二、频道下两版本后再上)。
  3. 需求“Waimai”先进版,“Channel”不须要。
  4. 需求“Channel”先进版,“Waimai”下一版进(4.一、须要改动通用部分;4.二、只改动“ChannelOnly”的部分)。
  5. 需求“Channel”先进版,“Waimai”不须要(只改动“ChannelOnly”的部分)。

图15 最复杂场景下的分支模型

图15 最复杂场景下的分支模型

 

也不用过多纠结,图15是最复杂的场景,实际场合中很难遇到,目前的咱们的业务只遇到1和2两个大类,最多2条线。

编译问题

以往的开发方式初次全量编译5分钟左右,以后就是差量编译很快。可是抽成组件后,随着部分子库版本的切换间接的增长了pod install的次数,此时高频率的3分钟、5分钟会让人难以接受。

因而在这个节点咱们采用了全二进制依赖的方式,目标是在平常开发中直接引用编译后的产物减小编译时间。

图16 使用二进制的依赖方式

图16 使用二进制的依赖方式

 

如图所示三个.a就是三个subPods,分了三种Configuration:

  1. debug/ 下是 deubg 设置编译的 x64 armv7 arm64。
  2. release/ 下是 release 设置编译的 armv7 arm64。
  3. dailybuild/ 下是 release + TEST=1编译的 armv7 arm64。
  4. 默认(在文件夹外的.a)是 debug x64 + release armv7 + release arm64。

这里有一个问题须要解决,即引用二进制带来的弊端,显而易见的就是将编译期的问题带到了运行期。某个宏修改了,可是编译完的二进制代码不感知这种改动,而且依赖版本不匹配的话,本来的方法缺失编译错误,就会带到运行期发生崩溃。解决此类问题的方法也很简单,就是在全部的打包工程中都配置了打包自动切换源码。二进制仅仅用来在开发中得到更高的效率,一旦打提测包或者发布包都会使用全源码从新编译一遍。关于切源码与切二进制是由环境变量控制拉取不一样的podspec源。

而且在开发中咱们支持源码与二进制的混合开发模式,咱们给某个binary_pod修饰的依赖库加上标签,或者使用.patch文件,控制特定的库拉源码。通常状况下,开发者将与本身当前需求相关联的库拉源码便于Debug,不关联的库拉二进制跳过编译。

依赖问题

如图17所示,外卖有多个业务组件,公司也有不少基础Kit,不一样业务组件或多或少会依赖几个Kit,因此极易造成网状依赖的局面。并且依赖的版本号可能不一致,易出现依赖冲突,一旦遇到依赖冲突须要对某一组件进行修改再从新发版来解决,很影响效率。解决方式是使用平台适配层来统一维护一套依赖库版本号,上层业务组件仅仅关心平台适配层的版本。

图17 平台适配层统一维护依赖

图17 平台适配层统一维护依赖

 

固然为了不引入平台适配层而增长过多无用依赖的问题,咱们将一些依赖较多且使用频度不高的Kit抽出subPods,支持可选的方式引入,例如IM组件。

再者就是pod install 时依赖分析慢的问题。对于壳工程而言,这是全部依赖库汇聚的地方,依赖关系写法若不科学极易在analyzing dependency中耗费大量时间。Cocoapods的依赖分析用的是Molinillo算法,连接中介绍了这个算法的实现方式,是一个具备前向检察的回溯算法。这个算法自己是没有问题的,依赖层级深只要依赖写的合理也能够达到秒开。可是若是对依赖树叶子节点的版本号控制不够严密,或中间出现了循环依赖的状况,会致使回溯算法重复执行了不少压栈和出栈操做耗费时间。美团针对此类问题的作法是维护一套“去依赖的podspec源”,这个源中的dependency节点被清空了(下图中间)。实际的所需依赖的全集在壳工程Podfile里平铺,统一维护。这么作的好处是将以前的树状依赖(下图左)压平成一层(下图右)。

图18 依赖数的压平

图18 依赖数的压平

 

效率问题

前面咱们提到了自动集成,这里展现下具体的使用方式。美团发布工程组自行研发了一套HyperLoop发版集成平台。当某个组件在建立二进制以前可自行选择集成的目标,若是多端复用了,那只须要在发版建立二进制的同时勾选多个集成的目标。发版后会自行进行一系列检查与测试,最终将代码合入主工程(修改对应壳工程的依赖版本号)。

图19 HyperLoop自动发版自动集成

图19 HyperLoop自动发版自动集成

 

图20 主工程commit message的变化

图20 主工程commit message的变化

 

以上是“Waimai”的commit对比图。第一张图是以往的开发方式,能看出工程配置的commit与业务的commit交错堆砌。第二张图是进行壳工程分离后的commit,能看出每条message都是改了某个依赖库的版本号。第三张图是使用自动集成后的commit,能看出每条message都是画风统一且机器串行提交的。

这里又衍生出另外一个问题,当咱们用壳工程引Pods的方式替代了project集中式开发以后,咱们的代码修改散落到了不一样的组件库内。想看下主工程6.5.0版本和6.4.0版本的diff时只能看到全部依赖库版本号的diff,想看commit和code diff时必须挨个去组件库查看,在三轮提测期间这样相似的操做天天都会重复屡次,很不效率。

因而咱们开发了atomic diff的工具,主要原理是调git stash的接口获得版本号diff,再经过版本号和对应的仓库地址深度遍历commit,再深度遍历commit对应的文件,最后汇总,获得总体的代码diff。

图21 atomic diff汇总后的commit message

图21 atomic diff汇总后的commit message

 

整套工具链对多端复用的支撑

上文中已经提到了一些自动化工具,这里整理下咱们工具链的全景图。

图22 整套工具链

图22 整套工具链

 

  1. 在准备阶段,咱们会用OClint工具对compile_command.json文件进行处理,对将要修改的组件提早扫描依赖。
  2. 在依赖库拉取时,咱们有binary_pod.rb脚本里经过对源的控制达到二进制与去依赖的效果,美团发布工程组维护了一套ios-re-sankuai.com的源用于存储remove dependency的podspec.json文件。
  3. 在依赖同步时,会经过sync_podfile定时同步主工程最新Podfile文件,来对依赖库全集的版本号进行维护。
  4. 在开发阶段,咱们使用Podfile.patch工具一键对二进制/源码、远端/本地代码进行切换。
  5. 在引用本地代码开发时,子库的版本号咱们不太关心,只关心主工程的版本号,咱们使用beforePod和AfterPod脚本进行依赖过滤以防止依赖冲突。
  6. 在代码提交时,咱们使用git squash对多条相同message的commit进行挤压。
  7. 在建立PR时,以往须要一些网页端手动操做,填写大量Reviewers,如今咱们使用MTPR工具一键完成,或者根据我的喜爱使用Chrome插件。
  8. 在功能合入master以前,会有一些jenkins的job进行检测。
  9. 在发版阶段,使用Hyperloop系统,一键发版操做简便。
  10. 在发版以后,可选择自动集成和联合集成的方式来打包,打包产物会自动上传到美团的“抢鲜”内测平台。
  11. 在问题跟踪时,若是须要查看主工程各个版本号间的commit message和code diff,咱们有atomic diff工具深度遍历各个仓库并汇总结果。

总结

  • 多端复用以后对PM-RD-QA都有较大的变化,咱们代码复用率由最初的2.4%达到了84.1%,让更多的PM投入到了新需求的吞吐中,但研发效率提高增大了QA的工做量。一个大的尝试须要RD不断与PM和QA保持沟通,选择三方都能接受的最优方案。
  • 分清主次关系,技术架构等最终是为了支撑业务,若是一个架构设计的美如画完美无缺,可是落实到本身的业务中确不能发挥理想效果,或引来抱怨一片,那这就是个失败的设计。而且在实际开发中技术类代码修改尽可能选择版本间隙合入,若是与业务开发的同窗产生冲突时,都要给业务同窗让路,不能影响本来的版本迭代速度。
  • 时刻对 “不合理” 和 “重复劳动”保持敏感。新增一个埋点常量要去改一下平台再发个版是否成本太大?一处订单状态的需求为何要修改首页的Kit?实际开发中遇到别扭的地方多增长一些思考而不是硬着头皮过去,而且手动重复两次以上的操做就要思考有没有自动化的替代方案。
  • 一旦决定要作,在一些关键节点决不能手软。例如某个节点为了避免Block别人,加班不可避免。在大量代码改动时也不用过于紧张,有提早预估,有Case自测,还有QA的三轮回归来保障,保持专一,放手去作就好。