目前 Flutter 在贝壳的使用量越来越高,业务中 Flutter 页面达到 600+,甚至在某些业务线 Flutter 页面占比达到 70%。这种状况下我们迫切需要一个功能完善、体验流畅的 Flutter 调试工具。调研市面上 Flutter 调试工具之后,结合我们公司的业务特点,开发了自己的 Flutter 调试工具——FDB。
本文将简要介绍 FDB 有哪些功能,并重点介绍核心功能是如何实现的。
FDB(Flutter Debug)不仅仅是只面向开发过程的工具,也解决性能优化、设计走查、QA 测试等环节的痛点问题。
在 Flutter 的研发过程中,您肯定遇见过以下问题:
因此,我们开发了以下功能,为开发者在开发过程中或优化性能时,提供精确的数据支撑和问题定位。
可以看出 FDB 的功能包含:
这些功能都需要我们在合适的节点中获取,这些节点穿插在 Dart 代码的运行流程中,这些功能的实现需要我们对 Dart 代码的运行流程有大概的了解,这样我们知道在哪个节点能获取哪些信息。
总的来说,Dart 代码的运行可分为:前端编译阶段、虚拟机运行阶段。接下来,我们从编译、运行两个方面来说。
Dart VM 有多种方式运行 Dart 代码: • JIT 模式下运行源码或者 Kernal binary • 通过 snapshot 的方式 鉴于本文描述的使用场景,我们以 Debug 模式为例来介绍 Dart VM 是如何运行我们的代码。
在 Flutter Debug 模式下我们的 Dart 源码被 gen_kernel 处理成 Kernal binary,也就是 dill 文件。
我们可以在 Debug 产物中找到该 dill 文件,这便是 Kernal binary。Kernal 是一种从 Dart 衍生而来的高级语言,用于分析和进一步的转化。Kernal binary 就是该语言的描述,它包含了序列化的 Kernel ASTs 以及内存标识。
Kernel AST 是 CFE(前端编译器 common front-end)生成,有语义分析的作用,可交付于 Dart VM、dev_compiler 以及 dart2js 等工具直接用于语义的分析。
内存标识可以序列化成可被虚拟机运行的机器码,类似于 Class 和 JVM 的关系。
上述的前端编译流程如下图所示:
snapshot_delegate
通过以上简单的介绍,我们了解了 Debug 模式下代码是如何编译成产物的。接下来我们重点来看:编译的产物是如何被 VM 运行的。
虚拟机要执行任务需要初始化以下几个功能模块:
• 运行时系统: 负责运行期间代码的装载(懒加载)和释放。比如:对象的实例化、类成员信息的读取,方法的调用,内存 GC,并保存了签名信息(快照)。由此可知,我们获取内存信息、方法调用链等运行时数据或主动触发 GC 都是在此处。
• service 协议: 这是 VM 为方便我们拿到运行时数据而开启的一个服务,连接此服务之后,我们就可以通过 Dart VM 的相关协议,拿到运行时系统的数据。
• 其他:包含核心库原生方法、编译流水线、解释器、ARM 模拟器。鉴于本文不涉及 VM 的其他部分,暂不讲解。
下图为 VM 的整体结构:
总结一下:运行时数据代表了 Dart 虚拟机的具体运行状态,比如内存快照、对象分配、方法调用等等。并且我们可以通过 service 协议来获取运行时数据,像 Dev-Tools、Android Studio 中的调试功能都是通过这个协议来实现的。
那么,如果我们想获取当前虚拟机的内存信息、内存中的 Class、Class 的实例、实例的具体信息、方法调用链等属性,就需要借助此 Service 去找运行时数据要。具体的如何通过 Service 获取 VM 运行时中的数据,后面的 2.4 补充知识点中会详细介绍。
OK,到此 VM 已经具备了执行我们 Dart 代码的能力。因为 Dart VM 所需要的运行数据包含在 Flutter 的“三棵树”中,接下来,我们来看 Flutter 的“三棵树”。
我们开发的代码,从 runApp 开始到页面展示出来,期间 Flutter 会用我们的代码生成“三棵树”:
Widget 树:是开发者使用 Flutter 对一个页面的描述;
Element 树:每一个 Widget 都会有一个 Element 与之对应,因此 Flutter 会根据 Widget 树生成 Element,并且 Element 树是 Widget 树转化为 RenderObject 树的中间产物,起到“上下文”的作用;
RenderObject 树:真正用于绘制的树,包含了尺寸等具体布局信息;
由此可见“三棵树”关系如下:
• Widget 可通过 createElement()方法创建 Element。
• Element 通过调用 Widget 的 createRenderObject()方法创建 RenderObject
• Element 直接持有 Widget 和 RenderObject。
• RenderObject 通过 DebugCreator 包装器的方式间接持有 Element。
那么,如果我们想获取页面上某个元素的属性,找到 Element 就是关键,因为 Element 作为上下文,可以拿到具体的 Widget 及 RendObject,从而拿到其属性及布局信息。
OK,到此,我们知道 VM 将我们代码通过三棵树的原理转化为可渲染的对象,从而渲染出来,而且我们可以通过 Element 获取到 Widget,需要注意的是“三棵树”给我们的信息是组件的静态信息,但是如果我们想获取某个 Widget 的轮廓等动态信息以及对应我们的源码文件名和行列等信息那就要介绍另两个概念:WidgetInspector 和 WidgetInspectorService
Flutter 的入口 main 函数中,会使用 runApp()创建一个 WidgetsApp,WidgetsApp 便会在 Debug 模式下为我们开启一个 WidgetInspector。那么 WidgetInspector 是什么呢,WidgetInspector 和 WidgetInspectorService 之间有什么关系呢?
WidgetInspector:可以检查屏幕上一个 widget 的结构,包括轮廓、属性及对应源码的位置。
WidgetInspectorService:WidgetInspector 实例不能被用户直接获取使用,WidgetInspectorService 为用户提供了全局的单例,用来操作 WidgetInspector 从而获取我们想要的信息。
那么,如果我们想获取界面上某个具体 Widget 的轮廓、属性及对应源码的位置信息,就需要通过 WidgetInspectorService.instance 单例去获取。
我们知道 VM 运行时是 C++开发的,在 Flutter 中直接获取 VM 的运行时数据需要借助一个中间件,幸运的是官方为我们提供了 Service,借助 Service 的能力,我们便可以实现拿到 VM 运行时的数据。既然是 Service,那么就需要一个数据传输的协议。
协议双方遵循 C-S 架构,虚拟机作为远端服务来响应工具的请求,请求和响应的格式是 Json。服务端暴露了很多接口,比如获取版本信息,获取内存快照、获取方法调用和对象信息等等。我们以 getVersion 为例看一下调用。
调试工具以下面的格式发出请求:
{ "jsonrpc": "2.0", "method": "getVersion", "params": {}, "id": "1"}
复制代码
服务端以下面的格式返回响应数据:
{ "jsonrpc": "2.0", "result": { "type": "Version", "major": 3, "minor": 5 } "id": "1"}
复制代码
另外,虚拟机通过 ObjRef、Obj、id 这三个字段,来描述一个具体的对象。
• ObjRef:对象的基本信息,比如 name、id,但是不包括完整信息,我们可以把它理解成是一个对象指针。
•id:即 ObjRef 中的 id,它是对象的唯一标识,VM 通过 id 来识别一个对象。举个例子,如果你想获取一个对象的详细信息,就需要给 VM 该对象的 id。
• Obj:对象的详细信息,也就是我们通过 id 在 VM 处获取到的完整对象。它包含该对象所有的信息,比如字段、父类等等。
图示三个字段的关系:
这样一个完整的数据就从虚拟机获得了,那虚拟机具体支持哪些接口呢?大家可以看这一篇官方文档。
一般常用接口如下:
getAllocationProfile:获取当前运行内存中 Class、实例等的内存使用数据,另外该方法可获取每次 GC 的时间戳,还可以主动触发 GC。
getClassList:获取 VM 中所有类。
getInstances:获取某个类所有实例的引用。
getObject:获取指定实例的类型、内存大小、变量等。
getScripts:获取所有文件和文件对应 id 的 list。
OK,具备了以上知识,我们进入下一部分:FDB 的具体实现。
FDB 一共分为三类:UI 相关、性能优化相关、功能代码相关。下面着重介绍:性能优化相关、Widget 拾取、页面代码工具的核心原理与实现。
我们知道性能优化的成果,需要依赖数据指标佐证,Flutter 虽然提供了 Dev Tools 和 Observatory,来帮助我们开发者采集性能数据,但是操作复杂,上手难度高。比如:
• 内存检测
• 帧率检测
都需要先在 Android Studio 连接上应用的前提下,在网页上具体操作相应的工具,网页失败次数较高,等待时间较长。
因此根据原生优化的经验,我们提供了内存信息、内存泄漏检测、帧率检测三个工具,功能如下所示:
左图是内存泄漏演示,演示了整个检测的过程以及检测结果。中图是内存信息演示。右图是帧率检测演示。
说到内存信息,回忆上面的知识,我们想到的是内存数据一定在 VM 运行时中,对应我们 1.2.1 章节的内容。
内存信息主要包含:类信息获取和对象信息的获取。
获取到虚拟机的原始数据后,通过我们的加工,使展示给用户的内存信息数据更简洁,更有用:以分组的方式只展示内存中的类信息,并且不仅可查看类信息,还具体到了对象信息、属性信息。
同 Java 类似,Dart 语言也具有垃圾回收机制,有垃圾回收就避免不了会内存泄漏。那么如何检测内存泄漏呢?关于内存泄漏检测的核心原理,这篇文章有很好的描述
总结来说,就是使用弱引用引用待观测对象,并在合适的时机,发起虚拟机 GC 任务,然后检查弱引用的对象是否为 null。如果不为 null,说明发生了内存泄漏。
由于我们是从页面的维度检查内存泄漏,那么待观测对象就是 Flutter 页面——Widget、State、Element。那时机就是页面的打开和关闭了,页面打开的时候进行观测,页面关闭的时候进行检查。检测流程如下:
我们自定义了 Route 观察者,把检测的任务封装到自定义的 NavigatorObserver 中,比如 didPush、didPop 方法。
关于 Route 观察者,一般的做法是 业务方给自己的 MaterialApp 的 navigatorObservers 属性赋值。但是,我们 FDB 的核心原则是:更少的侵入业务代码,所以我们使用自动监听的方式。
上图可知,左侧的方式需要业务方自己设置,而右侧的方式业务方代码不需要任何变动。那么自动方式是如何实现的呢?
使用业务代码的子节点,向上查找到业务的 Navigator 节点,并为此 Navigator 新增一个路由观察者。这样就解决了兄弟节点无法查找 Navigator 以及业务代码手动绑定的问题。核心代码如下:
从上面的泄漏演示流程,我们可以看到,最终的结果完整的表达出了泄漏链和具体泄漏的代码行数,开发者就可以根据具体代码行数,进行修复。
帧率检测的数据来自 SchedulerBinding。Flutter 中包含多个 Binding,每个 Binding 都是是 Flutter 的“胶水粘合剂”,而 SchedulerBinding 就是粘合了绘制相关的任务,比如调度帧 scheduleTask、回调帧_handleBeginFrame、帧时间回调 addTimingsCallback 等等。
其中 addTimingsCallback 方法就是关于帧时间的回调,只要有帧被绘制了(setState、动画等刷新),该回调会被执行,给我们的回调数据是数组的 FrameTiming。
FrameTiming 包含了这一帧的总时长、光栅时长、build 时长等等,根据这些信息就可以算出帧率、耗时帧等等。
大家研发过程中可能会遇到:无法快速定位页面上的 Widget 在源码中的位置;查看某个 Widget 的边界范 围,必须依赖 IDE;UI、UE 走查时无法动态查看文本过长或过短的边界情况。 通过 UI 拾取工具以上问题都可以很好的解决
以下是 UI 拾取工具的功能演示:
从上面的演示,我们可以看到 UI 拾取工具的基本功能:自由拖动拾取器来标记 Widget 范围;获取 Widget 的代码文件和行数、文本组件编辑文本等等。细看功能其实能够发现,我们获取的就是某个组件的轮廓、属性及对应源码的位置信息,我们想到的是什么?没错就是 WidgetInspector,对应 1.2.2 和 1.2.3 章节的内容。
根据上面的内容,该功能的思路如下:
既然 Element 是 Widget 和 Render 的桥梁,那么我们先获取坐标对应的 Element,然后利用 WidgetInspector 去获取 Widget 的轮廓、源码等信息即可。
从上图看,Element 是 Widget 和 Render 的桥梁。因此,只要我们找到 Element,理顺了三棵树的关系,功能实现就有了突破口。实现过程如下:
1. 找到坐标选中的元素。
层序遍历 Element 树,比对 拾取坐标和 Element 持有的 Render 的范围,最深层级的元素就是选中元素。查找流程如下:
经过上图的四步之后,edgeHits 数组的第一个元素(最深层级)就是目标 Element。
2. 获取所需控件信息。
WidgetInspectorService 的 getSelectedSummaryWidget 可以通过 told 方法返回的 id 获取对应的 Widget 信息(如下图所示),包括:组件类型、代码路径和行数等等。这两个方法配合使用就可以拿到所需信息。
上面的 json 结构中,description 字段是 Widget 类型,creationLocation 字段是创建 Widget 的代码位置。有了具体的代码行数,即使不是自己的代码,也可以快速定位,快速进入开发。
3. 编辑文本。
文本编辑功能是和市面同类产品的创新点,不仅能看还能编辑。设计的同学非常需要编辑文本功能,因为设计同学不会本地 mock 数据,数据是什么,走查就只能看到什么,想要查看文本过多或者过少的情况,每次都需要依赖后端同学模拟,所以经常出现走查不彻底、走查成本高的问题。
从实现的角度来看,该功能较为复杂,原因有:
鉴于以上原因,实现方法较为巧妙:临时生成新文本组件,主动触发 Element 更新和绘制,只更新选中的 Element。
处理的流程图
左图是处理的流程图,右图是具体的核心代码。
测试过程中,我们经常对 QA 说,“你的包是不是没有我提交的代码啊,你的包不是最新包吧,...”。查看 Flutter 代码的功能,对解决上面的问题有一定的帮助。工具的效果演示如下:
WidgetInspectorService 的 selection 属性可以获取到一个 Element,并使用 Element 绑定的 Widget 就可以获取文件路径,但是我们的 Widget 拾取功能、Android Studio 等其他调试工具可能会变动到这个值。导致 WidgetInspectorService 的 Element 可能不能直接找到当前页面。FDB 根据 Flutter 页面叠加的原理来找到当前的页面,查找方式如下:
Flutter 的根节点是 Overlay 组件,该组件是一个可以管理 Widget 的栈。如果将一个 Widget 插入这个栈上,就可以让此 Widget 浮在其他的 Widget 之上。而且这个 Overlay 组件是被 Navigator 所创建。
我们开发的页面,就这样被一个个的叠加到了 Navigator 的 Overlay 上。所以,只要能拿到 Overlay,页面代码问题就有了突破口。因为 Overlay 保存了一个个的 Route,最顶层的 Route 就是当前页面的 Route。根据 Route 封装的 WidgetBuilder 就拿到了当前页面 Widget 是谁。
再结合上面介绍的 getSelectedSummaryWidget 方法,就得到了当前页面的文件名。
Dart 虚拟机的 getScripts 方法可以获取所有库文件的 Id 和文件名,对比文件名获得目标文件的 Id。
在 Dart 虚拟机中眼中,文件也是 Object,也可以通过 Id 进行 getObject 操作。这样最终就拿到了页面源码。流程如下:
上面介绍了 FDB 的功能,以及核心工具的实现,相信大家对 FDB、Flutter 工具建设有了一定的认识。FDB 的每一个功能都依赖虚拟机数据,掌握 Flutter 运行中的每一个节点,是我们能获取所需数据的支撑。
目前第一版本的 Flutter 调试工具已经完成,在贝壳 B 端已有 3 个 APP 接入,接入之初两周时间使用次数已经突破 2000。
FDB 项目已开源,后面会根据各业务方及社区内开发者的反馈进行下一步的迭代和调优,以提高大家开发需求和排查问题的效率。同时我们鼓励 Flutter 社区开发者们参与 FDB 的共建或者多提些建议、反馈。
我们的实现是站在前人的肩膀山续接探索的结果,这里特别感谢一系列开源的作者,是你们为 Flutter 更好的落地保驾护航。
https://pub.flutter-io.cn/packages/flutter_ume
https://pub.dev/packages/dokit
https://pub.dev/packages/leak_detector
https://flutter.cn/community/tutorials/memory-leak-monitoring-on-flutter
领取专属 10元无门槛券
私享最新 技术干货