初略讲解如何调试Flutter应用

3,295 阅读15分钟

一、Dart Analyzer(分析器)

在使用flutter run命令来运行应用程序之前,请运行flutter analyze命令来检测你的代码,这个命令是Dart Analyzer(分析器)的一个包装,它将分析你的代码并帮助你发现可能出现的错误。因为Dart Analyzer分析器大量使用了代码中的类型注释来帮助追踪问题,所以笔者鼓励大家在任何地方任何时候都来使用它检测你的代码,从而避免var、无类型的参数和无类型的列表文字等问题,可以说它是追踪问题的最快的方式。

二、Dart Observatory(语句级的单步调式和分析器)

使用flutter run命令运行应用程序,运行的时候,在控制台可以看到一个Observatory URL(如http://127.0.0.1:8100),这个url可以通过浏览器打开,直接用语句级单步调试程序连接到你的应用程序。如果你使用的是IntelliJ,则可以使用其内置的调式器(运行的时候选择debug按钮)来调试你的应用程序。

Observatory同时支持分析、检查堆等,有关Observatory的更多信息请参考Observatory文档

如果使用Observatory进行分析,请使用flutter run --profile命令运行应用程序;否则,配置文件中出现的主要问题将是调试断言,以验证框架的各种不变量(请参阅下面的“三、调试模式断言”

1、debugger()

当使用Dart Observatory或另外一个Dart调试器(如:IntelliJ IDE中的调试器)时,可以使用debugger语句插入编程式断点,要使用该命令,必须添加import 'dart:developer';到相关文件顶部。

debugger()语句带有一个可选when参数,可以指定该参数仅在特定条件为真时中断,代码如下:

void someFunction(double offset) {
  debugger(when: offset > 30.0);
  // ...
}

2、printdebugPrintflutter logs

使用Dart的print()方法将日志打印到系统控制台上,我们可以使用flutter logs来查阅日志,这个命令基本上是对adb logcat命令做了一层封装。

如果日志一次输出太多,那么Android的做法是设置日志优先级或者有时会丢弃一些日志行,为了避免这种情况,可以使用Flutter的foundation库中的debugPrint()方法。这个方法对print()方法做了一层包装,它将输出限制在一个级别,避免被Android内核丢弃。

Flutter框架中的许多类都有对toString的实现,按照惯例,这些输出通常包括runtimeType对象的单行输出,通常在ClassName(more information about this instance…)表格中。树中使用的某些类也具有从该点返回整个子树的多行描述的toStringDeep方法。一些具有打印详细日志的toString()方法的类,会实现一个相应的只返回类型或者对对象只有一两个词语简短描述的toStringShort()方法。

三、调试模式断言

在开发过程中,强烈建议你使用Flutter的“调试(debug)”模式,有时也称为“检查(checked)”模式(注意:Dart2.0后“checked”模式被废除,可以使用“strong”模式)。如果你使用flutter run运行程序,“调试”模式是默认的,在这种模式下,Dart assert语句被启用,Flutter框架使用它来执行许多运行时检查、验证赋值是否合法。当一个赋值不合法时,它会向控制台报告,并提供一些上下文信息来帮助追踪问题的根源。

要关闭“调试(debug)”模式并使用“发布(release)”模式,请使用flutter run --release运行你的应用程序,不过这样也关闭了Observatory调式器,一个中间模式可以关闭除Observatory调试器之外的所有调试辅助工具,称为“profile”模式,用--profile替代--release即可。

四、调试应用程序层

Flutter框架的每一层都提供了将其当前状态或事件,转储(dump)到控制台(使用debugPrint)的功能。

Widget层

要转储Widgets(控件)库的状态,请调用debugDumpApp()方法。只要应用程序至少构建了一次(即,在调用runApp()之后的任何时间),就可以在应用程序未处于运行构建阶段(即,不在build()方法内调用)的任何时间调用此方法。小例子(这个小例子下面还会使用到)

import 'package:flutter/material.dart';

void main() {
  runApp(
    new MaterialApp(
      home: new AppHome(),
    ),
  );
}

class AppHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new Material(
      child: new Center(
        child: new FlatButton(
          onPressed: () {
            debugDumpApp();
          },
          child: new Text('Dump App'),
        ),
      ),
    );
  }
}

应用程序运行起来之后,点击“Dump App”按钮,此时控制台上会输出以下日志(精确的细节会根据框架的版本、设备的大小等等而变化):

I/flutter ( 6559): WidgetsFlutterBinding - CHECKED MODE
I/flutter ( 6559): RenderObjectToWidgetAdapter<RenderBox>([GlobalObjectKey RenderView(497039273)]; renderObject: RenderView)
I/flutter ( 6559): └MaterialApp(state: _MaterialAppState(1009803148))
I/flutter ( 6559):  └ScrollConfiguration()
I/flutter ( 6559):   └AnimatedTheme(duration: 200ms; state: _AnimatedThemeState(543295893; ticker inactive; ThemeDataTween(ThemeData(Brightness.light Color(0xff2196f3) etc...) → null)))
I/flutter ( 6559):    └Theme(ThemeData(Brightness.light Color(0xff2196f3) etc...))
I/flutter ( 6559):     └WidgetsApp([GlobalObjectKey _MaterialAppState(1009803148)]; state: _WidgetsAppState(552902158))
I/flutter ( 6559):      └CheckedModeBanner()
I/flutter ( 6559):       └Banner()
I/flutter ( 6559):        └CustomPaint(renderObject: RenderCustomPaint)
I/flutter ( 6559):         └DefaultTextStyle(inherit: true; color: Color(0xd0ff0000); family: "monospace"; size: 48.0; weight: 900; decoration: double Color(0xffffff00) TextDecoration.underline)
I/flutter ( 6559):          └MediaQuery(MediaQueryData(size: Size(411.4, 683.4), devicePixelRatio: 2.625, textScaleFactor: 1.0, padding: EdgeInsets(0.0, 24.0, 0.0, 0.0)))
I/flutter ( 6559):           └LocaleQuery(null)
I/flutter ( 6559):            └Title(color: Color(0xff2196f3))
... #省略剩余内容

这是一个“扁平化”的树,显示通过各种build函数投影的所有widget(这是在widget树的根上调用toStringDeep时获得的树)。从上面的输入日志你将看到很多在应用程序源代码中没有出现过的widget,因为它们是被框架中widget的build函数插入的。如:InkFeature是Material widget的一个实现细节。

当“Dump App”按钮从被按下到被释放时debugDumpApp()方法将被调用,FlatButton对象同时调用setState(),并将自己标记为“dirty”,这就是为什么当你查看转储时,会看到特定的对象标记为“dirty”。你还可以查看哪些手势监听器(GestureDetector)已注册了,在这种情况下,一个单一的GestureDetector被列出,它只监听“tap”手势(“tap”是TapGestureDetectortoStringShort()方法的输出)。

如果编写自己的widget,则可以通过重写debugFillProperties()方法来添加信息到转储,并将DiagnosticsProperty对象作为方法的参数进行传递,同时调用父类方法,这个方法是toString方法用来填充widget描述信息的。代码如下:

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
  super.debugFillProperties(properties);
  properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.dense;
}

渲染层

如果你尝试调试布局问题,那么Widgets(控件)层的树可能不够详细,在这种情况下,你可以通过调用debugDumpRenderTree()转储渲染树。和debugDumpApp()用法一样,除了layout(布局)或paint(绘画)阶段之外,你可以随时调用它,约定俗成,frame(帧)回调或事件处理时调用它。

要调用debugDumpRenderTree(),需要添加import'package:flutter/rendering.dart';到你的源文件当中。

在上面的小例子中调用此方法,输出日志如下:

I/flutter ( 6559): RenderView
I/flutter ( 6559):  │ debug mode enabled - android
I/flutter ( 6559):  │ window size: Size(1080.0, 1794.0) (in physical pixels)
I/flutter ( 6559):  │ device pixel ratio: 2.625 (physical pixels per logical pixel)
I/flutter ( 6559):  │ configuration: Size(411.4, 683.4) at 2.625x (in logical pixels)
I/flutter ( 6559):  │
I/flutter ( 6559):  └─child: RenderCustomPaint
I/flutter ( 6559):    │ creator: CustomPaint ← Banner ← CheckedModeBanner ←
I/flutter ( 6559):    │   WidgetsApp-[GlobalObjectKey _MaterialAppState(1009803148)] ←
I/flutter ( 6559):    │   Theme ← AnimatedTheme ← ScrollConfiguration ← MaterialApp ←
I/flutter ( 6559):    │   [root]
I/flutter ( 6559):    │ parentData: <none>
I/flutter ( 6559):    │ constraints: BoxConstraints(w=411.4, h=683.4)
I/flutter ( 6559):    │ size: Size(411.4, 683.4)
... # 省略剩余内容

以上是在根RenderObject对象上调用toStringDeep方法的输出内容。

当调试布局问题时,关键要看的是size(大小)和constraints(约束)字段,约束沿着树向下传递,大小则向上传递。

例如:在上面的转储中,你可以看到窗口大小,Size(411.4, 683.4),它用于强制RenderPositionedBox下的所有渲染框成为屏幕的大小,并具有BoxConstraints(w=411.4, h=683.4)的约束。从RenderPositionedBox的转储中看到是由Center组件(由creator字段描述的)创建的,设置其子控件的约束为:BoxConstraints(0.0<=w<=411.4,0.0<=h<=683.4)。子控件RenderPadding进一步插入这些约束以确保有足够的空间填充,padding值为EdgeInsets(16.0, 0.0, 16.0, 0.0),因此RenderConstrainedBox具有一个BoxConstraints(0.0<=w<=395.4, 0.0<=h<=667.4)约束。该creator字段告诉我们,此对象可能是其FlatButton定义的一部分,它在内容上设置的最小宽度为88px,具体高度为36.0px(这是Material Design设计规范中FlatButton类的尺寸标准)。

最内部的RenderPositionedBox再次释放约束,这次是将按钮中的文本居中, RenderParagraph根据其内容选择其大小,如果现在按照size继续往下查看,你会看到文本的尺寸是如何影响其按钮的框的宽度的,因为它们会根据子控件的框的尺寸自行调整大小。

另一种需要注意的是每个box(盒子容器)描述的“relayoutSubtreeRoot”部分,因为它在告诉你有多少“祖先”在某种程度上依赖于这个元素的大小。比如RenderParagraph有一个relayoutSubtreeRoot=up8,那么这就意味着当它RenderParagraph被标记为“dirty”时,它的八个“祖先”也必须被标记为“dirty”,因为它们可能受到新尺寸的影响。

如果编写自己的渲染对象,则可以通过覆写debugFillProperties()方法将信息添加到转储,并将DiagnosticsProperty对象作为方法的参数进行传递,同时调用父类方法。

图层

如果你尝试调试合成问题,则可以使用debugDumpLayerTree

继续使用上面的小例子,输出日志如下:

I/flutter : TransformLayer
I/flutter :  │ creator: [root]
I/flutter :  │ offset: Offset(0.0, 0.0)
I/flutter :  │ transform:
I/flutter :  │   [0] 3.5,0.0,0.0,0.0
I/flutter :  │   [1] 0.0,3.5,0.0,0.0
I/flutter :  │   [2] 0.0,0.0,1.0,0.0
I/flutter :  │   [3] 0.0,0.0,0.0,1.0
I/flutter :  │
I/flutter :  ├─child 1: OffsetLayer
I/flutter :  │ │ creator: RepaintBoundary ← _FocusScope ← Semantics ← Focus-[GlobalObjectKey MaterialPageRoute(560156430)] ← _ModalScope-[GlobalKey 328026813] ← _OverlayEntry-[GlobalKey 388965355] ← Stack ← Overlay-[GlobalKey 625702218] ← Navigator-[GlobalObjectKey _MaterialAppState(859106034)] ← Title ← ⋯
I/flutter :  │ │ offset: Offset(0.0, 0.0)
I/flutter :  │ │
I/flutter :  │ └─child 1: PictureLayer
I/flutter :  │
I/flutter :  └─child 2: PictureLayer

以上是在根Layer对象上调用toStringDeep方法的输出内容。

根的变换是应用设备像素比的变换,在本例中,每个逻辑像素的比率为3.5个设备像素。

RepaintBoundary 控件在渲染层中创建了一个新的图层RenderRepaintBoundary,这个通常用来减少需要重绘的需求量。

语义

你还可以调用debugDumpSemanticsTree()方法获取语义树(该树存在于系统可访问的API中)的转储。要使用此功能,必须先设置允许访问,如启用系统可访问性工具或SemanticsDebugger

继续使用上面的小例子,输出日志如下:

I/flutter : SemanticsNode(0; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4))
I/flutter :  ├SemanticsNode(1; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4))
I/flutter :  │ └SemanticsNode(2; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4); canBeTapped)
I/flutter :  └SemanticsNode(3; Rect.fromLTRB(0.0, 0.0, 411.4, 683.4))
I/flutter :    └SemanticsNode(4; Rect.fromLTRB(0.0, 0.0, 82.0, 36.0); canBeTapped; "Dump App")

调度

要找出相对于帧的开始/结束事件发生的位置,可以切换debugPrintBeginFrameBannerdebugPrintEndFrameBanner的boolean(布尔值)来将帧的开始和结束打印到控制台上。例如:

I/flutter : ▄▄▄▄▄▄▄▄ Frame 12         30s 437.086ms ▄▄▄▄▄▄▄▄
I/flutter : Debug print: Am I performing this work more than once per frame?
I/flutter : Debug print: Am I performing this work more than once per frame?
I/flutter : ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀

debugPrintScheduleFrameStacks还可以用来打印导致当前帧被调度的调用堆栈。

五、可视化调试

你也可以通过将debugPaintSizeEnabled设置为true,以可视化方式调试布局问题。这是来自rendering(渲染)库中的一个布尔值变量,它可以在任何时候启用,并在为true时影响所有的绘制。最简单的办法是在void main()主函数顶部入口去设置它:

//add import to rendering library
import 'package:flutter/rendering.dart';

void main() {
  debugPaintSizeEnabled=true;
  runApp(MyApp());
}

当打开可视化调试时,所有的盒子都会得到一个明亮的深青色边框,padding(来自Widget如Padding)显示为浅蓝色,子控件的内边距会有一个明亮渐变的深蓝色边框,对齐方式(来自Widget如Center和Align)显示为黄色箭头,没有任何子节点的Container显示为灰色。

debugPaintBaselinesEnabled的功能类似于对象的基准线,字母基线显示亮绿色,表意基线以橙色显示。

debugPaintPointersEnabled标记位会打开一种特殊模式,在此模式下,被点击的任何对象都会以深青色突出显示。这可以帮助你确定某个对象是否以某种不正确的方式进行hit(命中)测试(Flutter检测点击的位置是否有能响应用户操作的widget),例如,某个对象实际上超出了其父对象的范围之外,那么就不会第一时间被考虑通过hit(命中)测试。

如果你尝试调试混合图层,例如,以确定是否以及在何处添加RepaintBoundary控件,则可以使用debugPaintLayerBordersEnabled标记位,该标记用橙色或轮廓线绘制出每个图层的边界,或者使用debugRepaintRainbowEnabled标记位,当图层重新绘制时,它将让图层显示旋转的色彩。

所有这些标志位只在调试模式下工作,一般来说,在Flutter框架中,任何以“debug...”开头的变量或方法,都只能在调试模式下有效。

六、调试动画

调试动画最简单的方法是放慢它们的速度。为此,请将timeDilation变量(在scheduler库中)设置为大于1.0的数字,例如50.0。最好在应用程序启动时只设置一次,如果在运行中更改它,尤其是在动画运行时将其值变小,则框架可能会观察到时间倒退,这可能会导致断言并且通常会干扰你的工作。

七、调试性能问题

要了解导致你的应用程序重新布局或重新绘制的原因,可以分别设置debugPrintMarkNeedsLayoutStacksdebugPrintMarkNeedsPaintStacks标志。每当渲染框被要求重新布局或重新绘制时,这些都会随时将堆栈跟踪日志打印到控制台上。如果这种方法对你有用,你可以使用services库中的debugPrintStack()方法按需打印堆栈痕迹。

统计应用启动时间

要收集有关Flutter应用程序启动所需时间的详细信息,可以在运行flutter run命令时使用trace-startupprofile选项。

$ flutter run --trace-startup --profile

跟踪日志被保存在你的Flutter工程目录下的build目录下的start_up_info.json文件中。日志输出列出了从应用程序启动到这些跟踪事件(以微秒捕获)所用的时间:

  • 进入Flutter引擎代码的时间
  • 绘制应用第一帧的时间
  • 初始化Flutter框架的时间
  • 完成Flutter框架初始化的时间

例如:

{
  "engineEnterTimestampMicros": 96025565262,
  "timeToFirstFrameMicros": 2171978,
  "timeToFrameworkInitMicros": 514585,
  "timeAfterFrameworkInitMicros": 1657393
}

跟踪Dart代码性能

要执行自定义性能跟踪并测量Dart任意代码块的wall/CPU时间(类似于在Android上使用systrace)。可以使用dart:developerTimeline工具来包含你想测试的代码块,例如:

Timeline.startSync('interesting function');
// iWonderHowLongThisTakes();
Timeline.finishSync();

然后打开你的应用程序的Observatory timeline页面,在“Recorded Streams”中选择‘Dart’复选框,并执行你想测试的功能。刷新该页面,将会在Chrome浏览器的跟踪工具中按时间顺序显示应用程序的时间轴记录。

务必使用flutter run --profile命令运行应用程序,以确保运行时性能特征与你的最终产品的性能差异最小。

八、性能覆盖图

要获得应用程序性能图形视图,请将MaterialApp构造函数的showPerformanceOverlay参数设置为trueWidgetsApp构造函数也有类似的参数(如果你没有使用MaterialApp或者WidgetsApp,你可以通过将你的应用程序封装在一个堆栈中,调用new PerformanceOverlay.allEnabled()方法创建一个控件放在堆栈上来获得相同的效果)。

这将显示两个图表,第一个是GPU线程花费的时间,第二个是CPU线程花费的时间。图中的白线以16ms增量沿纵轴显示,如果图表经过其中的一条白线,那么你的运行频率(速度)低于60Hz,横轴代表帧。该图表只会在应用程序绘制时更新,所以如果它处于空闲状态,该图表将停止移动。

这个操作一定是在发布模式下完成的,因为在调试模式下,会故意牺牲性能来换取有助于开发调试的功能,如assert声明,这些都是非常耗时的,因此结果将会产生误导。

九、Material网格

当我们开发实现Material Design的应用程序时,应用程序上会覆盖一个帮助验证对齐的Material Design基线网格。为此,在调试模式下,将MaterialApp构造函数debugShowMaterialGrid参数设为true,将会覆盖这样一个网格。也可以直接使用GridPaper控件在非Material应用程序上覆盖这样的网格。