前端篇

用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攻击?

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

 

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

前言

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

目前, mpvue 已经在美团点评多个实际业务项目中得到了验证,因此我们决定将其开源,希望更多技术同行一起开发,应用到更广泛的场景里去。github 项目地址请参见 mpvue 。使用文档请参见 http://mpvue.com/

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

小程序开发特点

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

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

mpvue是什么

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

为什么做mpvue

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

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

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

多端复用:常见的业务场景有两类,通过已有 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 开发小程序的目的。较好地实现了技术栈统一、组件化开发、多端代码复用、降低学习成本和提高开发效率的目标。

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中修改:

 APKAAR
修改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 one rror(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()), one rror: (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、H5、微信小程序)统一大前端实践的基础之上,达成高性能大前端实践,同时配合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个视图元素、间距10、顶对齐;可能会有如下的锚点布局逻辑代码:

    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 架构但没有对应的框架支撑,而导致的风格不统一、可维护性差、开发效率低等多种问题。而 MVVM 中最重要的一个功能就是绑定,EasyReact 就是为了让绑定和响应式的代码变得 Easy 起来。

它的目标就是让开发者能够简单的理解响应式编程,并且简单的将响应式编程的优势利用起来。

EasyReact 依赖库介绍

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

EasyTuple

EasyTuple

EasyTuple

 

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

EasySequence

EasySequence

EasySequence

 

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

EasyFoundation

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 从诞生之初,就不可避免要和已有的其他响应式编程框架做对比。下表对几大响应式框架进行了一个大概的对比:

项目EasyReactReactiveCocoaReactiveX
核心概念 图论和面向对象编程 函数式编程 函数式编程和泛型编程
传播可变性
基本变换
组合变换
高阶变换
遍历节点 / 信号
多语言支持 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 次,得到数据平均值如下:

namelistenermapfilterflattenMapcombinezipmergesyncWith
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% 以上。

namelistener
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结构化后入库,然后通过机器学习快速分析上报的日志,指出日志中可能存在的问题,并给出解决方案建议;
  • 数据开放平台。业务方可以通过数据开放平台获取数据,再结合自身业务的特性研发出适合自己业务的工具、产品。

平台支持

PlatformiOSAndroidWebMini Programs
Support

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

测试覆盖率

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

开源计划

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

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

最后,我们希望提供更加完整的一体化个案分析生态系统,也欢迎大家给我们提出建议,共建社区。

ModuleOpen SourceProcessingPlanning
iOS    
Android    
Web    
Mini Programs    
Back End    
Front End    

原文地址:https://www.cnblogs.com/xiaowenshu/p/10382177.html

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


这篇文章主要讲解了“FlutterComponent动画的显和隐怎么实现”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究...
这篇文章主要讲解了“flutter微信聊天输入框功能如何实现”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“f...
本篇内容介绍了“Flutter之Navigator的高级用法有哪些”的有关知识,在实际案例的操作过程中,不少人都会遇到这样的困境,接下来就让小编带领大家学习一下如何处...
这篇文章主要介绍“Flutter怎么使用Android原生播放器”,在日常操作中,相信很多人在Flutter怎么使用Android原生播放器问题上存在疑惑,小编查阅了各式资料,整...
Flutter开发的android端如何修改APP名称,logo,版本号,具体的操作步骤:修改APP名称找到文件:android\\app\\src\\main\\AndroidManifest.xml
Flutter路由管理初识路由概念一.路由管理1.1.Route1.2.MaterialPageRoute1.3.Navigator1.4.路由传值1.5 命名路由1.6.命名路由参数传递1.7.适配二、路由钩子三、onUnknownRoute四、结尾初识路由概念路由的概念由来已久,包括网络路由、后端路由,到现在广为流行的前端路由。无论路由的概念如何应用,它的核心是一个路由映射表。比如:名字 detail 映射到 DetailPage 页面等。有了这个映射表之后,我们就可以方便的根据名字来完成路由的转发
前提:针对Android开发者(windows系统下),已安装Git,AndroidStudio(建议4.0+版本)一.下载Flutter SDK地址:https://flutter.dev/docs/development/tools/sdk/releases,在 Stable channel (Windows)里面下最新版本即可。Flutter的渠道版本会不停变动,请以Flutter官网为准。在中国,要想正常获取安装包列表或下载安装包,可能需要翻墙,也可以去Flutter github项目下去下载安
一、变量变量是一个引用,根据Dart中“万物皆对象”原则,即变量存储的都是对象的引用,或者说它们都是指向对象。1.1.声明变量://1.不指定类型var name = 'aaa';//2.明确指定类型String name = 'aaa';因为有类型推导,所以两种实现效果一样,官方推荐在函数内的本地变量尽量使用var声明。在变量类型并不明确的情况下,可以使用dynamic关键字//3.使用dynamic关键字dynamic name = 'aaa';1.2.默认值未初始化的变量
前言Flutter2.0发布不久,对web的支持刚刚进入stable阶段。初学几天,构建web应用时候碰到一些问题,比如中文显示成乱码,然后加载图片出现图片跨域问题:Failed to load network image...Trying to load an image from another domain?1.开启web端构建:使用下面这个命令才可以开启Web端构建的支持flutter config --enable-web提示我们:重新启动编辑器,以便它们读取新设置。2.重
一.Flutter打Android release包的步骤:1.为项目创建一个.jks签名文件(很简单,跳过)2.创建一个文件key.properties,直接复制下面key.properties位置如图:在里面输入一下内容:storePassword=iflytekkeyPassword=iflytekkeyAlias=teachingmachinestoreFile=E:/teacher/app/keys/TeachingMachine.jks输入你自己的passwork以及
1 问题Android原生向js发消息,并且可以携带数据2 实现原理Android原生可以使用RCTEventEmitter来注册事件,然后这里需要指定事件的名字,然后在js那端进行监听同样事件的名字监听,就可以收到消息得到数据Android注册关键代码reactContext.getJSModule(DeviceEventManagerModule.RCT...
1 Flexbox布局1) flexDirection 可以决定布局的主轴,子元素是应该沿着水平轴(row)方向排列,还是沿着竖直轴(column)方向排列2) justifyContent 决定其子元素沿着次轴(与主轴垂直的轴,比如若主轴方向为row,则次轴方向为column)的排列方式 有flex-start、center、flex-end、space-around...
1 实现的功能在网上看React Native文档,我特码就想实现一个页面到另外一个页面的跳转,然后另外一个页面怎么获取参数,特么没找到一个说清楚的,要么太复杂,要么说了不理解,下面是我自己写的一个App.js文件,实现一个Home页面跳到另外Details页面,并且携带了参数怎么在Details页面获取,就是这么简单粗暴.2 测试DemoApp.js文件如下...
1 问题在一个文件构建一个对象,然后在另外一个文件里面new这个对象,通过构造方法传递参数,然后再获取这个参数2 测试代码Student.js文件如下'use strict';import React from 'react'import {NativeModules, NativeEventEmitter, DeviceEventEmitter,Ale...
1 简单部分代码export default class App extends Component&amp;lt;Props&amp;gt; { render() { return ( &amp;lt;View {styles.container}&amp;gt; &amp;lt;View {styles.welcome}&amp;gt; &amp;l...
1 怎么实现发送和接收事件理论上封装了Android原生广播的代码,需要注册和反注册,这里用DeviceEventEmitter实现//增加监听DeviceEventEmitter.addListener//取消监听//this.emitter.remove();这里可也可以通过安卓原生向页面js发送消息,可以参考我的这篇博客React Native之Android原生通过Dev...
1、Component介绍一般Component需要被其它类进行继承,Component和Android一样,也有生命周期英文图片如下2 具体说明1)、挂载阶段constructor()//构造函数,声明之前先调用super(props)componentWillMount()//因为它发生在render()方法前,因此在该方法内同步设置状态...
1 触摸事件普通点击我们可以使用onPress方法,我们可以使用Touchable 系列控件设计我们的按钮TouchableHighlight 背景会在用户手指按下时变暗TouchableNativeFeedback用户手指按下时形成类似墨水涟漪的视觉效果TouchableOpacity指按下时降低按钮的透明度,而不会改变背景的颜色TouchableWithoutFeedbac...
1 问题部分代码如下class HomeScreen extends React.Component { render() { return ( &amp;lt;View {{ flex: 1, alignItems: 'center', justifyContent: 'center' }}&amp;gt; &amp;lt;Text&amp;gt;Home Scre...
1 Props(属性)和State(状态)和简单样式简单使用App.js代码如下/** * Sample React Native App * https://github.com/facebook/react-native * * @format * @flow */import React, {Component} from 'react';import {Pla...