UME - 丰富的Flutter调试工具

avatar
@字节跳动

背景

目前西瓜视频作者侧 Flutter 业务场景已经覆盖了 40多个页面 (包括视频播放场景),用户侧核心场景包括我的 Tab 也已经是 Flutter,在开发过程中,暴露了一些问题,debug 调试难、离开了 IDE 后犹如抓瞎、PM 设计 QA 验收过程中拿不到有用的信息,在市面上找了一圈,也没有类似 iOS Flex 这样强大的调试工具,例如视图大小、层级的展示,实例对象属性的实时修改,网络请求抓取,log 日志打印,文件查看等,因此西瓜视频 Flutter 基础团队决定开发 UME 以解决上述问题。

介绍

UME (读音:油米~) 是一个 Flutter 调试工具包,内部集成了丰富的调试小工具,设计 UI、网络、监控、性能、logger 等,无论是研发、PM、还是 QA 均能使用。

目前已实现的功能

接下来会详细介绍一些核心功能的使用效果以及核心实现:

模块详解

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

可以查看到当前页面的页面代码。

主要实现涉及到以下几个关键点:

  1. 获取到当前页面 widget 所属的文件名
  2. 根据 dart 脚本的文件名来找到并读取脚本

获取文件名主要利用WidgetInspectorService实现。而读取脚本主要使用VMService实现。

获取当前页面 widget 文件名
  • 我们通过遍历获得当前页面的renderObject列表,按照大小筛选出我们想要的目标 widget。
  • Widget 信息中讲解到过,我们可以通过WidgetInspectorServicegetSelectedSummaryWidget方法获取到 json 字符串。
  • 提取"creationLocation"的值即是当前 widget 的在开发过程中的文件地址。
  • 我们截取出来地址字符串的最后一部分就是当前页面代码所在的文件名了。
找到并读取脚本
  • VMService中的getScripts方法可以获取当前线程下的所有库文件的 ID 和文件名。
  • 我们通过比对文件名可以获得目标库文件 id。
  • 通过VMServicegetObject方法可以获取到当前 id 对应的对象,我们传入刚刚获取的库文件 id 即可获得这个库对象,读取对象的source属性,里面就是我们的源码了。

内存泄露

LeakDetector 用于检测 flutter 内存泄漏,总体的实现思想和 Android 平台的LeakCannary工具类似。利用Expando来弱引用持有待检测对象,并且使用 VMService 拿到泄漏对象的引用链,最终将泄漏信息本地存储并且展示出来。

Dart VM Service Dart 提供的一套 web 服务,数据传输协议是 JSON-RPC 2.0。通过它提供的接口我们能获取到 Dart 虚拟机内部的一些重要信息。下面介绍下整个过程:

  1. 获取 VMService 服务
  • 获取 ObservatoryUri
    • 通过Service.``getInfo``()获取ServiceProtocolInfo,从中取出serverUri
    • 通过vm_service中的 util 工具方法convertToWebSocketUrl()将上面的 http 格式的 uri 格式转为 ws://格式
    • 获取 VmService 服务对象,vm_service_io文件中有个vmServiceConnectUri()方法,传入一个observatoryUri就可以获取一个 VmService 对象
  1. 获取 isolateId
  • 通过 VmService 的 getVM 方法拿到 VM 对象,VM 对象中存储着所有的 IsolateRef
    • 通过Service.getIsolateID(Isolate.current)拿到,只有 debug 下有效,release 下会返回 null
  1. 获取 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 应该就是了,实际可以测试)。
  1. 获取 objectId

由于getInstance(isolateId, classId, limit)方法存在性能和 limit 限制的问题,我们转而利用invoke(isolateId, targetId, selector, argumentIds, disableBreakpoints)方法,借助 Library 顶层函数就可以获取 libraryId 也就是 invoke 方法中的 targetId,最后我们只需要将目标对象暂存一下再通过 invoke 方法取出来就可以拿到该对象的 InstanceRef 了,进而拿到其 id 字段就是我们要找的 objectId 了。

  1. 泄漏判断
  • 通过 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 字段就是我们实际的没被回收的对象了。
  1. 获取引用路径
  • VmService 有一个getRetainingPath方法可以直接拿到一个对象的引用链,但是只会拿一条。
  • 需要注意在前面使用 Expando 检测完内存泄漏之后,就释放 Expando 对原始对象的引用。
  • Instance 的 id 会过期,VmService 对它的缓存最大是 8192,所以不要保存 id 而要保存对象。
  1. 触发 GC
  • VmService 有一个getAllocationProfile(isolateId, gc=true)方法,通过它来触发 dart vm 进行 gc,这个也是 Dev Tools 工具上触发 gc 按钮最终调用的方法。据测试触发的都是 FULL GC。
  1. 触发时机
  • 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);
复制代码

获取到截图后,我们就需要通过移动的位置来获取到图片的当前像素值了,可以通过ImagegetPixelSafe来获取到 用 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.dartBaseClient类中的_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 来打印日志造成死循环,这里我们只 hookdebugPrint方法,对package:flutter/src/foundation/print.dartdebugPrintThrottled进行 hook 即可。

Channel Monitor

可以查看到所有的 channel 调用,包括方法名,时间,参数,返回结果。 ⁣

hookpackage:flutter/src/services/platform_channel.dartMethodChannel类的invokeMethod方法即可。

目前存在的问题

目前只是完成了初步的版本,很多功能还需要继续完善以及更多的新功能;接下来会从一些细节上继续深入;现在网络调试、channel 监控、Logger 这些功能依赖于 Hook 方案,后续 hook 方案也会考虑开源。

总结

以上介绍了一些 UME 的核心功能以及实现,还有很多丰富的功能由于篇幅问题在这里就不继续展开了,之后还会有更多有趣的东西出现,未来会考虑开源一些核心功能。

加入我们

我们是负责西瓜视频客户端 Flutter 基础技术研发团队。我们在 Flutter 工程,研发工具等方向深耕,支撑业务快速迭代的同时,提高 Flutter 开发调式打包效率。如果你对技术充满热情,欢迎加入西瓜视频 Flutter 基础技术团队或者西瓜基础业务团队。目前我们在上海、北京、杭州、均有招聘需求,内推可以联系邮箱:tech@bytedance.com;邮件标题:姓名 - 工作年限 - 西瓜 - iOS/Android

更多分享

一例 Go 编译器代码优化 bug 定位和修复解析

字节跳动破局联邦学习:开源Fedlearner框架,广告投放增效209%

抖音品质建设 - iOS启动优化《原理篇》

iOS 性能优化实践:头条抖音如何实现 OOM 崩溃率下降50%+


欢迎关注「字节跳动技术团队

简历投递联系邮箱「tech@bytedance.com