UME – 丰富的Flutter调试工具(flutter uri)
背景
目前西瓜视频作者侧 Flutter 业务场景已经覆盖了 80% (包括视频播放场景),用户侧核心场景包括我的 Tab 也已经是 Flutter,在开发过程中,暴露了一些问题,debug 调试难、离开了 IDE 后犹如抓瞎、PM 设计 QA 验收过程中拿不到有用的信息,在市面上找了一圈,也没有类似 iOS Flex 这样强大的调试工具,例如视图大小、层级的展示,实例对象属性的实时修改,网络请求抓取,log 日志打印,文件查看等,因此西瓜视频 Flutter 基础团队决定开发 UME 以解决上述问题。
介绍
UME (读音:油米~) 是一个 Flutter 调试工具包,内部集成了丰富的调试小工具,设计 UI、网络、监控、性能、logger 等,无论是研发、PM、还是 QA 均能使用。
目前已实现的功能
- 首页功能支持拖拽排序展示当前使用功能
- Widget 信息展示widget 名称widget 大小widget 文件路径widget 代码所在行
- Widget 层级widget 构建链支持 widget 搜索widget 信息展示renderObject 详细信息展示
- 网络调试支持所有基于 http 包的等网络请求抓取数据支持结构化展示,长按可以复制到剪贴板收藏请求,单独展示;清空非收藏列表请求过滤与搜索(支持部分匹配、正则匹配)请求导出 curl持久化与导出 HARmock 响应内容完整 har 文件映射修改单个字段结构化信息长按复制
- 内存泄露支持自动检测由 route 打开的页面Widget、State、Route 对象的内存泄漏检测
- 内存查看Dart vm 信息展示当前 Dart 内存使用情况具体类信息所占用的空间跟数量类属性和方法展示
- CPU 模块CPU 详细信息展示CPU 是使用率(iOS)App 内存和磁盘使用情况
- 性能看板GPU 和 UI FPS 信息展示
- 颜色吸管颜色值获取十六进制
- 对齐标尺widget 屏幕坐标展示可自动吸附最近 widget
- 二维码二维码生成
- Logger展示 debugPrint 函数输出日志支持搜索
- Device Info手机硬件信息展示
- HTTP Server目前 8080 端口用于上传文件,用户可上传 HAR 文件,用于 Mock 请求8888 端口作为数据端口,目前用途是校验服务 host、接收用户上传的文件未来计划开放更多数据 api,允许通过该服务读取 UME 中的数据
- Channel Monitorchannel 调用方法名称请求参数返回起始时间返回结果支持搜索
接下来会详细介绍一些核心功能的使用效果以及核心实现
模块详解
Widget 信息
可以查看当前选中 widget 的大小、名称,文件路径以及代码所在行数,有了这工具,即使你不负责这个功能模块的开发,你也能迅速找到当前代码。
那如何能获取到选中当前 widget 的信息呢,大小通过RenderObject 就能拿到,那 widget 的代码位置呢? 通过WidgetInspectorService 中的 getSelectedSummaryWidget 便可以获取到一个 json 字符串,我们来看下它的结构:
{ "description":"Text", "type":"_ElementDiagnosticableTreeNode", "style":"dense", "hasChildren":true, "allowWrap":false, "locationId":0, "creationLocation":{ "file":"file:///Users/.../example/lib/home/widgets/category_card.dart", "line":69, "column":15, "parameterLocations":[ { "file":null, "line":70, "column":24, "name":"data" }, ... ] }, "createdByLocalProject":true, "children":[ { "description":"RichText", "type":"_ElementDiagnosticableTreeNode", "style":"dense", "allowWrap":false, "locationId":1, "creationLocation":{ "file":"file://../packages/flutter/lib/src/widgets/text.dart", "line":425, "column":21, "parameterLocations":[ { "file":null, "line":426, "column":7, "name":"textAlign" }, ... ] }, "children":[], "widgetRuntimeType":"RichText", "stateful":false } ], "widgetRuntimeType":"Text", "stateful":false}
由于数据太多了,省略了一部分, 然后根据对应的 key 即可找到需要的部分。
Widget 层级
可以查看当前选中 widget 的树层级,以及它 renderObject 的详细 build 链。
这个获取到选中 widget 的一个 build 链还是比较简单的,通过 InspectorSelection 获取到当前 currentElement ,然后 使用 debugGetDiagnosticChain 方法就可以获取到整个 build 链了。
RenderObject 的信息也很好得到,通过currentElement 拿到 当前的RenderObject,然后使用 toString方法就可以拿到了。
ShowCode
可以查看到当前页面的页面代码。
主要实现涉及到以下几个关键点:
- 获取到当前页面 widget 所属的文件名
- 根据 dart 脚本的文件名来找到并读取脚本
获取文件名主要利用WidgetInspectorService实现。
而读取脚本主要使用VMService实现。
获取当前页面 widget 文件名
- 我们通过遍历获得当前页面的renderObject列表,按照大小筛选出我们想要的目标 widget。
- Widget 信息中讲解到过,我们可以通过WidgetInspectorService 中getSelectedSummaryWidget 方法获取到 json 字符串。
- 提取"creationLocation"的值即是当前 widget 的在开发过程中的文件地址。
- 我们截取出来地址字符串的最后一部分就是当前页面代码所在的文件名了。
找到并读取脚本
- VMService中的getScripts方法可以获取当前线程下的所有库文件的 ID 和文件名。
- 我们通过比对文件名可以获得目标库文件 id。
- 通过VMService的getObject方法可以获取到当前 id 对应的对象,我们传入刚刚获取的库文件 id 即可获得这个库对象,读取对象的source属性,里面就是我们的源码了。
内存泄露
LeakDetector 用于检测 flutter 内存泄漏,总体的实现思想和 Android 平台的LeakCannary工具类似。利用Expando来弱引用持有待检测对象,并且使用 VMService 拿到泄漏对象的引用链,最终将泄漏信息本地存储并且展示出来。
Dart VM Service Dart 提供的一套 web 服务,数据传输协议是 JSON-RPC 2.0。通过它提供的接口我们能获取到 Dart 虚拟机内部的一些重要信息。下面介绍下整个过程:
- 获取 VMService 服务
- 获取 ObservatoryUri通过Service.“getInfo“()获取ServiceProtocolInfo,从中取出serverUri通过vm_service中的 util 工具方法convertToWebSocketUrl()将上面的 http 格式的 uri 格式转为 ws://格式获取 VmService 服务对象, vm_service_io文件中有个vmServiceConnectUri()方法,传入一个observatoryUri就可以获取一个 VmService 对象
- 获取 isolateId
- 通过 VmService 的 getVM 方法拿到 VM 对象,VM 对象中存储着所有的 IsolateRef通过Service.getIsolateID(Isolate.current)拿到,只有 debug 下有效,release 下会返回 null
- 获取 libraryId
- 通过第 2 步拿到 isolateId 之后,然后调用 VmService 的getIsolate拿到对应的 Isolate 对象。
- 遍历 Isolate 的 libraries 字段,这是一个 LibraryRef 的 List,然后拿当前 Library 的 uri 去 List 中匹配 LibraryRef 的 uri,就可以获取 LibraryRef 的 id。
- 拿着 isolateId 和 LibraryRef 的 Id,调用 VmService 的 getObject 方法就可以获取 Library,取其 id 字段就是我们要找的 libraryId(其实 LibraryRef 的 id 应该就是了,实际可以测试)。
- 获取 objectId
由于getInstance(isolateId, classId, limit)方法存在性能和 limit 限制的问题,我们转而利用invoke(isolateId, targetId, selector, argumentIds, disableBreakpoints)方法,借助 Library 顶层函数就可以获取 libraryId 也就是 invoke 方法中的 targetId,最后我们只需要将目标对象暂存一下再通过 invoke 方法取出来就可以拿到该对象的 InstanceRef 了,进而拿到其 id 字段就是我们要找的 objectId 了。
- 泄漏判断
- 通过 getObject(isolateId, objectId)方法拿到 Expando 的对象的 Obj 实例,它的真实类型其实是一个 Instance。
- 遍历 Instance 的 fields 字段找到_data(_data 的类型是 ObjRef,可以拿到它对应的 Instance 实例)字段(怎么找_data?可以通过 BoundField 的 FieldRef 字段,然后匹配 FieldRef 的 name 为‘_data’),在expando_path.dart中我们可以看到 Expando 的具体实现,_data 字段是一个 List。
- 遍历_data 字段,如果都为 null,表明我们观察的 key 对象都释放了;如果元素不为 null,则将该该元素转为 Instance 对象(其实就是一个 WeakProperty),取其 propertyKey 字段就是我们实际的没被回收的对象了。
- 获取引用路径
- VmService 有一个getRetainingPath方法可以直接拿到一个对象的引用链,但是只会拿一条。
- 需要注意在前面使用 Expando 检测完内存泄漏之后,就释放 Expando 对原始对象的引用。
- Instance 的 id 会过期,VmService 对它的缓存最大是 8192,所以不要保存 id 而要保存对象。
- 触发 GC
- VmService 有一个getAllocationProfile(isolateId, gc=true)方法,通过它来触发 dart vm 进行 gc,这个也是 Dev Tools 工具上触发 gc 按钮最终调用的方法。据测试触发的都是 FULL GC。
- 触发时机
- Route 检测借助 framework 提供的NavigatorObserver机制,可以很轻松的监听到页面的进出栈,在 didPop、didRemove、didReplace 方法中触发对 route 的泄漏检测。
- Widget/State 检测一般的页内 Widget/State 不检测,而只检测真正页面对应的 Widget 和 State,framework 并没有提供一个全局监听页面销毁的机制。这里我们借助hook_annotation(这个后面会解释)来 hook 两个点:RouteRootState 的 initState 方法,记录要检测的页面对象;State 的 dispose 方法,如果是我们已记录的页面,则触发检测流程。
内存查看
Memory 可用于查看当前 Dart VM 对象所占用情况。
需要拿到 vm 内存的话就必须得依赖 Dart VM,上文说到,通过 vm_service 就可通过它提供的接口拿到。
通过 Future<MemoryUsage> getMemoryUsage 就能获取到当前 isolate 所占用的信息,来看下 MemoryUsage 的结构, 每个属性都有详细的解释,这里就不再赘述了。
/// The amount of non-Dart memory that is retained by Dart objects. For/// example, memory associated with Dart objects through APIs such as/// Dart_NewWeakPersistentHandle and Dart_NewExternalTypedData. This usage is/// only as accurate as the values supplied to these APIs from the VM embedder/// or native extensions. This external memory applies GC pressure, but is/// separate from heapUsage and heapCapacity.int externalUsage;/// The total capacity of the heap in bytes. This is the amount of memory used/// by the Dart heap from the perspective of the operating system.int heapCapacity;/// The current heap memory usage in bytes. Heap usage is always less than or/// equal to the heap capacity.int heapUsage;
那如何获取到每个类对象的内存信息呢?
通过 getAllocationProfile 获取分配对象的信息,通过members属性来获取到每个 class 所占用的堆信息。
对齐标尺
对齐标尺用来测量当前 widget 所在屏幕的一个坐标位置,开启吸附开关后可以自动吸附最近 widget。
标尺显示当前坐标还是非常简单的,通过手势移动的坐标,来改变Positioned的位置即可,并通过屏幕的大小来计算出当前的距离,下面会着重讲一下自动吸附的实现。
要吸附最近的 widget,就必须找到当前位置的所在的 widget,然后并画出当前 widget 的一个大小范围,最后设置标尺的位置即可,那么如何找到当前坐标的 widget 呢?
通过 globalKey 我们可以获取到当前页面的一个RenderObject,然后通过它的debugDescribeChildren 获取到它的所有子节点,然后通过describeApproximatePaintClip获取到当前对象坐标系中的Rect,之后在根据一些坐标转换,判断是不是在当前坐标范围,最后根据RenderObject 的大小做一个排序,这样我们就能知道最小的那个一定是当前坐标位置中最近的 widget 了,得到最近的 widget 之后,我们只需要将标尺的中心位置设置成离 widget 最近的四个角即可。
颜色吸管
可以查看到当前页面任何像素的颜色,方便调试 UI。
这个功能首先分为两步,1、背景放大 2、获取当前像素的颜色值
如何放大图片
在 Flutter 中,要想给图片加一些效果,我们可以用到 BackdropFilter, 其实就是加上一层滤镜效果,发现参数其实并不多,通过 ImageFilter就能添加具体的滤镜,想要做一个放大的效果,我们可以使用 ImageFilter.matrix ,它能够放大背景图片, filterQuality 参数可以用来设置放大效果的质量,那如何放大对应的位置以及放大的倍数呢?
通过Matrix4便可以设置,通过我们手势移动的位置,加上 scale 就能计算出它的矩阵参数,并赋值给ImageFilter.matrix就能得到放大效果。
如何获取图片像素及颜色值
在 Flutter 中想要截图的话就必须借助RepaintBoundary了,配合globalKey我们就能获取当屏幕的当前截图了。
RenderRepaintBoundary boundary = rootKey.currentContext.findRenderObject();Image image = await boundary.toImage();ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);Uint8List pngBytes = byteData.buffer.asUint8List();snapshot = img.decodeImage(pngBytes);
获取到截图后,我们就需要通过移动的位置来获取到图片的当前像素值了,可以通过Image的 getPixelSafe 来获取到 用 Uint32 编码过的像素颜色值了(#AABBGGRR),最后我们只需要把abgr转换成 argb 就好了。
int abgrToArgb(int argbColor) { int r = (argbColor >> 16) & 0xFF; int b = argbColor & 0xFF; return (argbColor & 0xFF00FF00) | (b << 16) | r;}
网络调试
在调试 Flutter 网络的时候,要 mock 数据或者查看请求非常麻烦,需要连代理,使用抓包工具才可以进行这些操作,想要简单的在手机上就能完成这些操作,所以网络调试模块目前支持的功能:
- 支持所有网络请求抓取
- 数据支持结构化展示,长按可以复制到剪贴板
- 收藏请求,单独展示;清空非收藏列表
- 请求过滤与搜索(支持部分匹配、正则匹配)
- 请求导出 curl
- 持久化与导出 HAR
- mock 响应内容完整 har 文件映射修改单个字段
- 结构化信息长按复制
看到这,你可能会问这是怎么拦截到所有的网络请求的呢?
这里通过 Dart 在编译时的插桩从而达到对特定 API 的 hook 效果(其实就是替换掉某个方法的实现从而添加自己的实现),由于篇幅问题,这里暂时不展开讲 Hook 的具体流程~ 之后也会有另外的文章来详细说这个。
Flutter 中的所有网络请求走的都是 package:http/src/base_client.dart 中 BaseClient 类中的_sendUnstreamed, 因此,我们只需要 hook _sendUnstreamed 方法便可以拦截到所有的网络请求。
Logger
会展示使用 debugprint 函数打印的日志,特别是播放器的一些日志,在没有 IDE 的情况下,查看日志还是很方便的。
拦截 print 有两种方式:
- Dart 中有一个runZoned方法,可以给执行对象指定一个 Zone,Zone 表示一个代码执行的环境范围,Zone 类似一个代码执行沙箱,不同沙箱的之间是隔离的,沙箱可以捕获、拦截或修改一些代码行为,如 Zone 中可以捕获日志输出、Timer 创建、微任务调度的行为,同时 Zone 也可以捕获所有未处理的异常。runZoned(…)方法定义:
R runZoned<R>(R body(), { Map zoneValues, ZoneSpecification zoneSpecification, Function onError}) zoneValues: Zone 的私有数据,可以通过实例zone[key]获取
zoneSpecification:Zone 的一些配置,可以自定义一些代码行为,比如拦截日志输出行为等。
这样所有调用 print 方法输出日志的行为都会被拦截。
runZoned(() => runApp(MyApp()), zoneSpecification: new ZoneSpecification( print: (Zone self, ZoneDelegate parent, Zone zone, String line) { print(line);}));
- 通 hook 的方式
由于在 hook 的 print 方法里可能会调用 print 来打印日志造成死循环,这里我们只 hook debugPrint 方法,对 package:flutter/src/foundation/print.dart 中 debugPrintThrottled 进行 hook 即可。
Channel Monitor
可以查看到所有的 channel 调用,包括方法名,时间,参数,返回结果。
hook package:flutter/src/services/platform_channel.dart 中 MethodChannel 类的invokeMethod方法即可。
目前存在的问题
目前只是完成了初步的版本,很多功能还需要继续完善以及更多的新功能;接下来会从一些细节上继续深入;现在网络调试、channel 监控、Logger 这些功能依赖于 Hook 方案,后续 hook 方案也会考虑开源。
总结
以上介绍了一些 UME 的核心功能以及实现,还有很多丰富的功能由于篇幅问题在这里就不继续展开了,之后还会有更多有趣的东西出现,未来会考虑开源一些核心功能。
加入我们
我们是负责西瓜视频客户端 Flutter 基础技术研发团队。我们在 Flutter 工程,研发工具等方向深耕,支撑业务快速迭代的同时,提高 Flutter 开发调式打包效率。
如果你对技术充满热情,欢迎加入西瓜视频 Flutter 基础技术团队或者西瓜基础业务团队。目前我们在上海、北京、杭州、均有招聘需求,内推可以联系邮箱:tech@bytedance.com ;邮件标题:姓名 – 工作年限 – 西瓜 – iOS/Android。
更多分享
字节跳动破局联邦学习:开源Fedlearner框架,广告投放增效209%
欢迎关注「 字节跳动技术团队 」
简历投递联系邮箱「 tech@bytedance.com 」