From 10bcddcc5cc658e44ecdf929335ae58cf8aec825 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 29 Oct 2018 16:32:26 -0700 Subject: [PATCH] Add option to track widget rebuilds and repaints from the Flutter inspector. (#23534) --- packages/flutter/lib/src/rendering/debug.dart | 24 +- .../flutter/lib/src/rendering/object.dart | 2 + packages/flutter/lib/src/widgets/binding.dart | 5 + packages/flutter/lib/src/widgets/debug.dart | 20 + .../flutter/lib/src/widgets/framework.dart | 3 + .../lib/src/widgets/widget_inspector.dart | 366 ++++++++++++++- .../foundation/service_extensions_test.dart | 10 +- .../test/widgets/widget_inspector_test.dart | 438 ++++++++++++++++++ 8 files changed, 853 insertions(+), 15 deletions(-) diff --git a/packages/flutter/lib/src/rendering/debug.dart b/packages/flutter/lib/src/rendering/debug.dart index 67a4860df76..4cd94ed5a0c 100644 --- a/packages/flutter/lib/src/rendering/debug.dart +++ b/packages/flutter/lib/src/rendering/debug.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; +import 'object.dart'; + export 'package:flutter/foundation.dart' show debugPrint; // Any changes to this file should be reflected in the debugAssertAllRenderVarsUnset() @@ -116,6 +118,25 @@ bool debugCheckIntrinsicSizes = false; /// areas are being excessively repainted. bool debugProfilePaintsEnabled = false; +/// Signature for [debugOnProfilePaint] implementations. +typedef ProfilePaintCallback = void Function(RenderObject renderObject); + +/// Callback invoked for every [RenderObject] painted each frame. +/// +/// This callback is only invoked in debug builds. +/// +/// See also: +/// +/// * [debugProfilePaintsEnabled], which does something similar but adds +/// [dart:developer.Timeline] events instead of invoking a callback. +/// * [debugOnRebuildDirtyWidget], which does something similar for widgets +/// being built. +/// * [WidgetInspectorService], which uses the [debugOnProfilePaint] +/// callback to generate aggregate profile statistics describing what paints +/// occurred when the `ext.flutter.inspector.trackRepaintWidgets` service +/// extension is enabled. +ProfilePaintCallback debugOnProfilePaint; + /// Setting to true will cause all clipping effects from the layer tree to be /// ignored. /// @@ -205,7 +226,8 @@ bool debugAssertAllRenderVarsUnset(String reason, { bool debugCheckIntrinsicSize debugPrintMarkNeedsPaintStacks || debugPrintLayouts || debugCheckIntrinsicSizes != debugCheckIntrinsicSizesOverride || - debugProfilePaintsEnabled) { + debugProfilePaintsEnabled || + debugOnProfilePaint != null) { throw FlutterError(reason); } return true; diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 3b533080352..b3e91f4c081 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -161,6 +161,8 @@ class PaintingContext extends ClipContext { assert(() { if (debugProfilePaintsEnabled) Timeline.startSync('${child.runtimeType}', arguments: timelineWhitelistArguments); + if (debugOnProfilePaint != null) + debugOnProfilePaint(child); return true; }()); diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index e73934f2e8b..a4a0a74a433 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -711,6 +711,11 @@ mixin WidgetsBinding on BindingBase, SchedulerBinding, GestureBinding, RendererB @override Future performReassemble() { + assert(() { + WidgetInspectorService.instance.performReassemble(); + return true; + }()); + deferFirstFrameReport(); if (renderViewElement != null) buildOwner.reassemble(renderViewElement); diff --git a/packages/flutter/lib/src/widgets/debug.dart b/packages/flutter/lib/src/widgets/debug.dart index 6ce2e7be335..fcbfe080e10 100644 --- a/packages/flutter/lib/src/widgets/debug.dart +++ b/packages/flutter/lib/src/widgets/debug.dart @@ -30,6 +30,26 @@ import 'table.dart'; /// See also the discussion at [WidgetsBinding.drawFrame]. bool debugPrintRebuildDirtyWidgets = false; +/// Signature for [debugOnRebuildDirtyWidget] implementations. +typedef RebuildDirtyWidgetCallback = void Function(Element e, bool builtOnce); + +/// Callback invoked for every dirty widget built each frame. +/// +/// This callback is only invoked in debug builds. +/// +/// See also: +/// +/// * [debugPrintRebuildDirtyWidgets], which does something similar but logs +/// to the console instead of invoking a callback. +/// * [debugOnProfilePaint], which does something similar for [RenderObject] +/// painting. +/// * [WidgetInspectorService], which uses the [debugOnRebuildDirtyWidget] +/// callback to generate aggregate profile statistics describing which widget +/// rebuilds occurred when the +/// `ext.flutter.inspector.trackRebuildDirtyWidgets` service extension is +/// enabled. +RebuildDirtyWidgetCallback debugOnRebuildDirtyWidget; + /// Log all calls to [BuildOwner.buildScope]. /// /// Combined with [debugPrintScheduleBuildForStacks], this allows you to track diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 03b8e9d89f1..500c8259dac 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -3514,6 +3514,9 @@ abstract class Element extends DiagnosticableTree implements BuildContext { if (!_active || !_dirty) return; assert(() { + if (debugOnRebuildDirtyWidget != null) { + debugOnRebuildDirtyWidget(this, _debugBuiltOnce); + } if (debugPrintRebuildDirtyWidgets) { if (!_debugBuiltOnce) { debugPrint('Building $this'); diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 673616c82ec..54d62ebd04b 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -32,6 +32,7 @@ import 'package:vector_math/vector_math_64.dart'; import 'app.dart'; import 'basic.dart'; import 'binding.dart'; +import 'debug.dart'; import 'framework.dart'; import 'gesture_detector.dart'; import 'icon_data.dart'; @@ -523,7 +524,7 @@ class _ScreenshotPaintingContext extends PaintingContext { /// /// The [debugPaint] argument specifies whether the image should include the /// output of [RenderObject.debugPaint] for [renderObject] with - /// [debugPaintSizeEnabled] set to `true`. Debug paint information is not + /// [debugPaintSizeEnabled] set to true. Debug paint information is not /// included for the children of [renderObject] so that it is clear precisely /// which object the debug paint information references. /// @@ -621,7 +622,7 @@ class _DiagnosticsPathNode { /// Index of the child that the path continues on. /// - /// Equal to `null` if the path does not continue. + /// Equal to null if the path does not continue. final int childIndex; } @@ -673,7 +674,7 @@ class _InspectorReferenceData { /// JSON mainly focused on if and how children are included in the JSON. class _SerializeConfig { _SerializeConfig({ - @required this.groupName, + this.groupName, this.summaryTree = false, this.subtreeDepth = 1, this.pathToInclude, @@ -693,6 +694,12 @@ class _SerializeConfig { includeProperties = base.includeProperties, expandPropertyValues = base.expandPropertyValues; + /// Optional object group name used to manage manage lifetimes of object + /// references in the returned JSON. + /// + /// A call to `ext.flutter.inspector.disposeGroup` is required before objects + /// in the tree are garbage collected unless [groupName] is null in + /// which case no object references are included in the JSON payload. final String groupName; /// Whether to only include children that would exist in the summary tree. @@ -712,6 +719,13 @@ class _SerializeConfig { /// Expand children of properties that have values that are themselves /// Diagnosticable objects. final bool expandPropertyValues; + + /// Whether to include object references to the [DiagnosticsNode] and + /// [DiagnosticsNode.value] objects in the JSON payload. + /// + /// If [interactive] is true, a call to `ext.flutter.inspector.disposeGroup` + /// is required before objects in the tree will ever be garbage collected. + bool get interactive => groupName != null; } // Production implementation of [WidgetInspectorService]. @@ -776,6 +790,9 @@ mixin WidgetInspectorService { List _pubRootDirectories; + bool _trackRebuildDirtyWidgets = false; + bool _trackRepaintWidgets = false; + _RegisterServiceExtensionCallback _registerServiceExtensionCallback; /// Registers a service extension method with the given name (full /// name "ext.flutter.inspector.name"). @@ -811,9 +828,11 @@ mixin WidgetInspectorService { } /// Registers a service extension method with the given name (full - /// name "ext.flutter.inspector.name"), which takes a single required argument + /// name "ext.flutter.inspector.name"), which takes a single optional argument /// "objectGroup" specifying what group is used to manage lifetimes of /// object references in the returned JSON (see [disposeGroup]). + /// If "objectGroup" is omitted, the returned JSON will not include any object + /// references to avoid leaking memory. void _registerObjectGroupServiceExtension({ @required String name, @required FutureOr callback(String objectGroup), @@ -821,7 +840,6 @@ mixin WidgetInspectorService { registerServiceExtension( name: name, callback: (Map parameters) async { - assert(parameters.containsKey('objectGroup')); return {'result': await callback(parameters['objectGroup'])}; }, ); @@ -930,6 +948,8 @@ mixin WidgetInspectorService { assert(!_debugServiceExtensionsRegistered); assert(() { _debugServiceExtensionsRegistered = true; return true; }()); + SchedulerBinding.instance.addPersistentFrameCallback(_onFrameStart); + _registerBoolServiceExtension( name: 'show', getter: () async => WidgetsApp.debugShowWidgetInspectorOverride, @@ -942,6 +962,60 @@ mixin WidgetInspectorService { }, ); + if (isWidgetCreationTracked()) { + // Service extensions that are only supported if widget creation locations + // are tracked. + _registerBoolServiceExtension( + name: 'trackRebuildDirtyWidgets', + getter: () async => _trackRebuildDirtyWidgets, + setter: (bool value) async { + if (value == _trackRebuildDirtyWidgets) { + return null; + } + _rebuildStats.resetCounts(); + _trackRebuildDirtyWidgets = value; + if (value) { + assert(debugOnRebuildDirtyWidget == null); + debugOnRebuildDirtyWidget = _onRebuildWidget; + // Trigger a rebuild so there are baseline stats for rebuilds + // performed by the app. + return forceRebuild(); + } else { + debugOnRebuildDirtyWidget = null; + return null; + } + }, + ); + + _registerBoolServiceExtension( + name: 'trackRepaintWidgets', + getter: () async => _trackRepaintWidgets, + setter: (bool value) async { + if (value == _trackRepaintWidgets) { + return; + } + _repaintStats.resetCounts(); + _trackRepaintWidgets = value; + if (value) { + assert(debugOnProfilePaint == null); + debugOnProfilePaint = _onPaint; + // Trigger an immediate paint so the user has some baseline painting + // stats to view. + void markTreeNeedsPaint(RenderObject renderObject) { + renderObject.markNeedsPaint(); + renderObject.visitChildren(markTreeNeedsPaint); + } + final RenderObject root = RendererBinding.instance.renderView; + if (root != null) { + markTreeNeedsPaint(root); + } + } else { + debugOnProfilePaint = null; + } + }, + ); + } + _registerSignalServiceExtension( name: 'disposeAllGroups', callback: disposeAllGroups, @@ -1001,7 +1075,6 @@ mixin WidgetInspectorService { name: 'getRootWidgetSummaryTree', callback: _getRootWidgetSummaryTree, ); - _registerServiceExtensionWithArg( name: 'getDetailsSubtree', callback: _getDetailsSubtree, @@ -1052,6 +1125,11 @@ mixin WidgetInspectorService { ); } + void _clearStats() { + _rebuildStats.resetCounts(); + _repaintStats.resetCounts(); + } + /// Clear all InspectorService object references. /// /// Use this method only for testing to ensure that object references from one @@ -1188,7 +1266,7 @@ mixin WidgetInspectorService { /// Set the [WidgetInspector] selection to the object matching the specified /// id if the object is valid object to set as the inspector selection. /// - /// Returns `true` if the selection was changed. + /// Returns true if the selection was changed. /// /// The `groupName` parameter is not required by is added to regularize the /// API surface of methods called from the Flutter IntelliJ Plugin. @@ -1200,7 +1278,7 @@ mixin WidgetInspectorService { /// Set the [WidgetInspector] selection to the specified `object` if it is /// a valid object to set as the inspector selection. /// - /// Returns `true` if the selection was changed. + /// Returns true if the selection was changed. /// /// The `groupName` parameter is not needed but is specified to regularize the /// API surface of methods called from the Flutter IntelliJ Plugin. @@ -1219,7 +1297,7 @@ mixin WidgetInspectorService { selection.current = object; } if (selectionChangedCallback != null) { - if (WidgetsBinding.instance.schedulerPhase == SchedulerPhase.idle) { + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) { selectionChangedCallback(); } else { // It isn't safe to trigger the selection change callback if we are in @@ -1310,9 +1388,18 @@ mixin WidgetInspectorService { return null; final Map json = node.toJsonMap(); - json['objectId'] = toId(node, config.groupName); final Object value = node.value; - json['valueId'] = toId(value, config.groupName); + if (config.interactive) { + json['objectId'] = toId(node, config.groupName); + json['valueId'] = toId(value, config.groupName); + } + + if (value is Element) { + if (value is StatefulElement) { + json['stateful'] = true; + } + json['widgetRuntimeType'] = value.widget?.runtimeType.toString(); + } if (config.summaryTree) { json['summaryTree'] = true; @@ -1321,6 +1408,7 @@ mixin WidgetInspectorService { final _Location creationLocation = _getCreationLocation(value); bool createdByLocalProject = false; if (creationLocation != null) { + json['locationId'] = _toLocationId(creationLocation); json['creationLocation'] = creationLocation.toJsonMap(); if (_isLocalCreationLocation(creationLocation)) { createdByLocalProject = true; @@ -1384,6 +1472,7 @@ mixin WidgetInspectorService { if (_pubRootDirectories == null || location == null || location.file == null) { return false; } + final String file = Uri.parse(location.file).path; for (String directory in _pubRootDirectories) { if (file.startsWith(directory)) { @@ -1573,6 +1662,7 @@ mixin WidgetInspectorService { /// information needed for the details subtree view. /// /// See also: + /// /// * [getChildrenDetailsSubtree], a method to get children of a node /// in the details subtree. String getDetailsSubtree(String id, String groupName) { @@ -1736,13 +1826,248 @@ mixin WidgetInspectorService { /// the `--track-widget-creation` flag is passed to `flutter_tool`. Dart 2.0 /// is required as injecting creation locations requires a /// [Dart Kernel Transformer](https://github.com/dart-lang/sdk/wiki/Kernel-Documentation). - @protected bool isWidgetCreationTracked() { _widgetCreationTracked ??= _WidgetForTypeTests() is _HasCreationLocation; return _widgetCreationTracked; } bool _widgetCreationTracked; + + Duration _frameStart; + + void _onFrameStart(Duration timeStamp) { + _frameStart = timeStamp; + SchedulerBinding.instance.addPostFrameCallback(_onFrameEnd); + } + + void _onFrameEnd(Duration timeStamp) { + if (_trackRebuildDirtyWidgets) { + _postStatsEvent('Flutter.RebuiltWidgets', _rebuildStats); + } + if (_trackRepaintWidgets) { + _postStatsEvent('Flutter.RepaintWidgets', _repaintStats); + } + } + + void _postStatsEvent(String eventName, _ElementLocationStatsTracker stats) { + postEvent(eventName, stats.exportToJson(_frameStart)); + } + + /// All events dispatched by a [WidgetInspectorService] use this method + /// instead of calling [developer.postEvent] directly so that tests for + /// [WidgetInspectorService] can track which events were dispatched by + /// overriding this method. + @protected + void postEvent(String eventKind, Map eventData) { + developer.postEvent(eventKind, eventData); + } + + final _ElementLocationStatsTracker _rebuildStats = _ElementLocationStatsTracker(); + final _ElementLocationStatsTracker _repaintStats = _ElementLocationStatsTracker(); + + void _onRebuildWidget(Element element, bool builtOnce) { + _rebuildStats.add(element); + } + + void _onPaint(RenderObject renderObject) { + try { + final Element element = renderObject.debugCreator?.element; + if (element is! RenderObjectElement) { + // This branch should not hit as long as all RenderObjects were created + // by Widgets. It is possible there might be some render objects + // created directly without using the Widget layer so we add this check + // to improve robustness. + return; + } + _repaintStats.add(element); + + // Give all ancestor elements credit for repainting as long as they do + // not have their own associated RenderObject. + element.visitAncestorElements((Element ancestor) { + if (ancestor is RenderObjectElement) { + // This ancestor has its own RenderObject so we can precisely track + // when it repaints. + return false; + } + _repaintStats.add(ancestor); + return true; + }); + } + catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + ), + ); + } + } + + /// This method is called by [WidgetBinding.performReassemble] to flush caches + /// of obsolete values after a hot reload. + /// + /// Do not call this method directly. Instead, use + /// [BindingBase.reassembleApplication]. + void performReassemble() { + _clearStats(); + } +} + +/// Accumulator for a count associated with a specific source location. +/// +/// The accumulator stores whether the source location is [local] and what its +/// [id] for efficiency encoding terse JSON payloads describing counts. +class _LocationCount { + _LocationCount({ + @required this.location, + @required this.id, + @required this.local, + }); + + /// Location id. + final int id; + + /// Whether the location is local to the current project. + final bool local; + + final _Location location; + + int get count => _count; + int _count = 0; + + /// Reset the count. + void reset() { + _count = 0; + } + + /// Increment the count. + void increment() { + _count++; + } +} + +/// A stat tracker that aggregates a performance metric for [Element] objects at +/// the granularity of creation locations in source code. +/// +/// This class is optimized to minimize the size of the JSON payloads describing +/// the aggregate statistics, for stable memory usage, and low CPU usage at the +/// expense of somewhat higher overall memory usage. Stable memory usage is more +/// important than peak memory usage to avoid the false impression that the +/// user's app is leaking memory each frame. +/// +/// The number of unique widget creation locations tends to be at most in the +/// low thousands for regular flutter apps so the peak memory usage for this +/// class is not an issue. +class _ElementLocationStatsTracker { + // All known creation location tracked. + // + // This could also be stored as a `Map` but this + // representation is more efficient as all location ids from 0 to n are + // typically present. + // + // All logic in this class assumes that if `_stats[i]` is not null + // `_stats[i].id` equals `i`. + final List<_LocationCount> _stats = <_LocationCount>[]; + + /// Locations with a non-zero count. + final List<_LocationCount> active = <_LocationCount>[]; + + /// Locations that were added since stats were last exported. + /// + /// Only locations local to the current project are included as a performance + /// optimization. + final List<_LocationCount> newLocations = <_LocationCount>[]; + + /// Increments the count associated with the creation location of [element] if + /// the creation location is local to the current project. + void add(Element element) { + final Object widget = element.widget; + if (widget is! _HasCreationLocation) { + return; + } + final _HasCreationLocation creationLocationSource = widget; + final _Location location = creationLocationSource._location; + final int id = _toLocationId(location); + + _LocationCount entry; + if (id >= _stats.length || _stats[id] == null) { + // After the first frame, almost all creation ids will already be in + // _stats so this slow path will rarely be hit. + while (id >= _stats.length) { + _stats.add(null); + } + entry = _LocationCount( + location: location, + id: id, + local: WidgetInspectorService.instance._isLocalCreationLocation(location), + ); + if (entry.local) { + newLocations.add(entry); + } + _stats[id] = entry; + } else { + entry = _stats[id]; + } + + // We could in the future add an option to track stats for all widgets but + // that would significantly increase the size of the events posted using + // [developer.postEvent] and current use cases for this feature focus on + // helping users find problems with their widgets not the platform + // widgets. + if (entry.local) { + if (entry.count == 0) { + active.add(entry); + } + entry.increment(); + } + } + + /// Clear all aggregated statistics. + void resetCounts() { + // We chose to only reset the active counts instead of clearing all data + // to reduce the number memory allocations performed after the first frame. + // Once an app has warmed up, location stats tracking should not + // trigger significant additional memory allocations. Avoiding memory + // allocations is important to minimize the impact this class has on cpu + // and memory performance of the running app. + for (_LocationCount entry in active) { + entry.reset(); + } + active.clear(); + } + + /// Exports the current counts and then resets the stats to prepare to track + /// the next frame of data. + Map exportToJson(Duration startTime) { + final List events = List.filled(active.length * 2, 0); + int j = 0; + for (_LocationCount stat in active) { + events[j++] = stat.id; + events[j++] = stat.count; + } + + final Map json = { + 'startTime': startTime.inMicroseconds, + 'events': events, + }; + + if (newLocations.isNotEmpty) { + // Add all newly used location ids to the JSON. + final Map> locationsJson = >{}; + for (_LocationCount entry in newLocations) { + final _Location location = entry.location; + final List jsonForFile = locationsJson.putIfAbsent( + location.file, + () => [], + ); + jsonForFile..add(entry.id)..add(location.line)..add(location.column); + } + json['newLocations'] = locationsJson; + } + resetCounts(); + newLocations.clear(); + return json; + } } class _WidgetForTypeTests extends Widget { @@ -2460,3 +2785,20 @@ _Location _getCreationLocation(Object object) { final Object candidate = object is Element ? object.widget : object; return candidate is _HasCreationLocation ? candidate._location : null; } + +// _Location objects are always const so we don't need to worry about the GC +// issues that are a concern for other object ids tracked by +// [WidgetInspectorService]. +final Map<_Location, int> _locationToId = <_Location, int>{}; +final List<_Location> _locations = <_Location>[]; + +int _toLocationId(_Location location) { + int id = _locationToId[location]; + if (id != null) { + return id; + } + id = _locations.length; + _locations.add(location); + _locationToId[location] = id; + return id; +} diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart index 775163e9dbf..3e08cdc23bb 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -528,12 +528,18 @@ void main() { }); test('Service extensions - posttest', () async { - // See widget_inspector_test.dart for tests of the 15 ext.flutter.inspector + // See widget_inspector_test.dart for tests of the ext.flutter.inspector // service extensions included in this count. + int widgetInspectorExtensionCount = 15; + if (WidgetInspectorService.instance.isWidgetCreationTracked()) { + // Some inspector extensions are only exposed if widget creation locations + // are tracked. + widgetInspectorExtensionCount += 2; + } // If you add a service extension... TEST IT! :-) // ...then increment this number. - expect(binding.extensions.length, 38); + expect(binding.extensions.length, 23 + widgetInspectorExtensionCount); expect(console, isEmpty); debugPrint = debugPrintThrottled; diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index 902c7635172..88c0f680e1c 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; +import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -12,6 +13,103 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +// Start of block of code where widget creation location line numbers and +// columns will impact whether tests pass. + +class ClockDemo extends StatefulWidget { + @override + _ClockDemoState createState() => _ClockDemoState(); +} + +class _ClockDemoState extends State { + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('World Clock'), + makeClock('Local', DateTime.now().timeZoneOffset.inHours), + makeClock('UTC', 0), + makeClock('New York, NY', -4), + makeClock('Chicago, IL', -5), + makeClock('Denver, CO', -6), + makeClock('Los Angeles, CA', -7), + ], + ), + ); + } + + Widget makeClock(String label, num utcOffset) { + return Stack( + children: [ + const Icon(Icons.watch), + Text(label), + ClockText(utcOffset: utcOffset), + ], + ); + } +} + +class ClockText extends StatefulWidget { + const ClockText({ + Key key, + this.utcOffset = 0, + }) : super(key: key); + + final num utcOffset; + + @override + _ClockTextState createState() => _ClockTextState(); +} + +class _ClockTextState extends State { + DateTime currentTime = DateTime.now(); + + void updateTime() { + setState(() { + currentTime = DateTime.now(); + }); + } + + void stopClock() { + setState(() { + currentTime = null; + }); + } + + @override + Widget build(BuildContext context) { + if (currentTime == null) { + return const Text('stopped'); + } + return Text( + currentTime + .toUtc() + .add(Duration(hours: widget.utcOffset)) + .toIso8601String(), + ); + } +} + +// End of block of code where widget creation location line numbers and +// columns will impact whether tests pass. + +class _CreationLocation { + const _CreationLocation({ + @required this.file, + @required this.line, + @required this.column, + @required this.id, + }); + + final String file; + final int line; + final int column; + final int id; +} + typedef InspectorServiceExtensionCallback = FutureOr> Function(Map parameters); class RenderRepaintBoundaryWithDebugPaint extends RenderRepaintBoundary { @@ -95,6 +193,9 @@ void main() { class TestWidgetInspectorService extends Object with WidgetInspectorService { final Map extensions = {}; + final Map>> eventsDispatched = + >>{}; + @override void registerServiceExtension({ @required String name, @@ -104,6 +205,15 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService { extensions[name] = callback; } + @override + void postEvent(String eventKind, Map eventData) { + getEventsDispatched(eventKind).add(eventData); + } + + List> getEventsDispatched(String eventKind) { + return eventsDispatched.putIfAbsent(eventKind, () => >[]); + } + Future testExtension(String name, Map arguments) async { expect(extensions.containsKey(name), isTrue); // Encode and decode to JSON to match behavior using a real service @@ -123,6 +233,11 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService { @override Future forceRebuild() async { rebuildCount++; + final WidgetsBinding binding = WidgetsBinding.instance; + + if (binding.renderViewElement != null) { + binding.buildOwner.reassemble(binding.renderViewElement); + } } @@ -1301,6 +1416,312 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService { expect(await service.testExtension('getSelectedWidget', {'objectGroup': 'my-group'}), contains('createdByLocalProject')); }, skip: !WidgetInspectorService.instance.isWidgetCreationTracked()); // Test requires --track-widget-creation flag. + testWidgets('ext.flutter.inspector.trackRebuildDirtyWidgets', (WidgetTester tester) async { + service.rebuildCount = 0; + + await tester.pumpWidget(ClockDemo()); + + final Element clockDemoElement = find.byType(ClockDemo).evaluate().first; + + service.setSelection(clockDemoElement, 'my-group'); + final Map jsonObject = await service.testExtension( + 'getSelectedWidget', + {'arg': null, 'objectGroup': 'my-group'}); + final Map creationLocation = + jsonObject['creationLocation']; + expect(creationLocation, isNotNull); + final String file = creationLocation['file']; + expect(file, endsWith('widget_inspector_test.dart')); + final List segments = Uri.parse(file).pathSegments; + // Strip a couple subdirectories away to generate a plausible pub root + // directory. + final String pubRootTest = + '/' + segments.take(segments.length - 2).join('/'); + await service.testExtension( + 'setPubRootDirectories', {'arg0': pubRootTest}); + + final List> rebuildEvents = + service.getEventsDispatched('Flutter.RebuiltWidgets'); + expect(rebuildEvents, isEmpty); + + expect(service.rebuildCount, equals(0)); + expect( + await service.testBoolExtension( + 'trackRebuildDirtyWidgets', {'enabled': 'true'}), + equals('true')); + expect(service.rebuildCount, equals(1)); + await tester.pump(); + + expect(rebuildEvents.length, equals(1)); + Map event = rebuildEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + List data = event['events']; + expect(data.length, equals(14)); + final int numDataEntries = data.length ~/ 2; + Map> newLocations = event['newLocations']; + expect(newLocations, isNotNull); + expect(newLocations.length, equals(1)); + expect(newLocations.keys.first, equals(file)); + final List locationsForFile = newLocations[file]; + expect(locationsForFile.length, equals(21)); + final int numLocationEntries = locationsForFile.length ~/ 3; + expect(numLocationEntries, equals(numDataEntries)); + + final Map knownLocations = + {}; + addToKnownLocationsMap( + knownLocations: knownLocations, + newLocations: newLocations, + ); + int totalCount = 0; + int maxCount = 0; + for (int i = 0; i < data.length; i += 2) { + final int id = data[i]; + final int count = data[i + 1]; + totalCount += count; + maxCount = max(maxCount, count); + expect(knownLocations.containsKey(id), isTrue); + } + expect(totalCount, equals(27)); + // The creation locations that were rebuilt the most were rebuilt 6 times + // as there are 6 instances of the ClockText widget. + expect(maxCount, equals(6)); + + final List clocks = find.byType(ClockText).evaluate().toList(); + expect(clocks.length, equals(6)); + // Update a single clock. + StatefulElement clockElement = clocks.first; + _ClockTextState state = clockElement.state; + state.updateTime(); // Triggers a rebuild. + await tester.pump(); + expect(rebuildEvents.length, equals(1)); + event = rebuildEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + data = event['events']; + // No new locations were rebuilt. + expect(event.containsKey('newLocations'), isFalse); + + // There were two rebuilds: one for the ClockText element itself and one + // for its child. + expect(data.length, equals(4)); + int id = data[0]; + int count = data[1]; + _CreationLocation location = knownLocations[id]; + expect(location.file, equals(file)); + // ClockText widget. + expect(location.line, equals(49)); + expect(location.column, equals(9)); + expect(count, equals(1)); + + id = data[2]; + count = data[3]; + location = knownLocations[id]; + expect(location.file, equals(file)); + // Text widget in _ClockTextState build method. + expect(location.line, equals(87)); + expect(location.column, equals(12)); + expect(count, equals(1)); + + // Update 3 of the clocks; + for (int i = 0; i < 3; i++) { + clockElement = clocks[i]; + state = clockElement.state; + state.updateTime(); // Triggers a rebuild. + } + + await tester.pump(); + expect(rebuildEvents.length, equals(1)); + event = rebuildEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + data = event['events']; + // No new locations were rebuilt. + expect(event.containsKey('newLocations'), isFalse); + + expect(data.length, equals(4)); + id = data[0]; + count = data[1]; + location = knownLocations[id]; + expect(location.file, equals(file)); + // ClockText widget. + expect(location.line, equals(49)); + expect(location.column, equals(9)); + expect(count, equals(3)); // 3 clock widget instances rebuilt. + + id = data[2]; + count = data[3]; + location = knownLocations[id]; + expect(location.file, equals(file)); + // Text widget in _ClockTextState build method. + expect(location.line, equals(87)); + expect(location.column, equals(12)); + expect(count, equals(3)); // 3 clock widget instances rebuilt. + + // Update one clock 3 times. + clockElement = clocks.first; + state = clockElement.state; + state.updateTime(); // Triggers a rebuild. + state.updateTime(); // Triggers a rebuild. + state.updateTime(); // Triggers a rebuild. + + await tester.pump(); + expect(rebuildEvents.length, equals(1)); + event = rebuildEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + data = event['events']; + // No new locations were rebuilt. + expect(event.containsKey('newLocations'), isFalse); + + expect(data.length, equals(4)); + id = data[0]; + count = data[1]; + // Even though a rebuild was triggered 3 times, only one rebuild actually + // occurred. + expect(count, equals(1)); + + // Trigger a widget creation location that wasn't previously triggered. + state.stopClock(); + await tester.pump(); + expect(rebuildEvents.length, equals(1)); + event = rebuildEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + data = event['events']; + newLocations = event['newLocations']; + + expect(data.length, equals(4)); + // The second pair in data is the previously unseen rebuild location. + id = data[2]; + count = data[3]; + expect(count, equals(1)); + // Verify the rebuild location is new. + expect(knownLocations.containsKey(id), isFalse); + addToKnownLocationsMap( + knownLocations: knownLocations, + newLocations: newLocations, + ); + // Verify the rebuild location was included in the newLocations data. + expect(knownLocations.containsKey(id), isTrue); + + // Turn off rebuild counts. + expect( + await service.testBoolExtension( + 'trackRebuildDirtyWidgets', {'enabled': 'false'}), + equals('false')); + + state.updateTime(); // Triggers a rebuild. + await tester.pump(); + // Verify that rebuild events are not fired once the extension is disabled. + expect(rebuildEvents, isEmpty); + }, + skip: !WidgetInspectorService.instance + .isWidgetCreationTracked()); // Test requires --track-widget-creation flag. + + testWidgets('ext.flutter.inspector.trackRepaintWidgets', (WidgetTester tester) async { + service.rebuildCount = 0; + + await tester.pumpWidget(ClockDemo()); + + final Element clockDemoElement = find.byType(ClockDemo).evaluate().first; + + service.setSelection(clockDemoElement, 'my-group'); + final Map jsonObject = await service.testExtension( + 'getSelectedWidget', + {'arg': null, 'objectGroup': 'my-group'}); + final Map creationLocation = + jsonObject['creationLocation']; + expect(creationLocation, isNotNull); + final String file = creationLocation['file']; + expect(file, endsWith('widget_inspector_test.dart')); + final List segments = Uri.parse(file).pathSegments; + // Strip a couple subdirectories away to generate a plausible pub root + // directory. + final String pubRootTest = + '/' + segments.take(segments.length - 2).join('/'); + await service.testExtension( + 'setPubRootDirectories', {'arg0': pubRootTest}); + + final List> repaintEvents = + service.getEventsDispatched('Flutter.RepaintWidgets'); + expect(repaintEvents, isEmpty); + + expect(service.rebuildCount, equals(0)); + expect( + await service.testBoolExtension( + 'trackRepaintWidgets', {'enabled': 'true'}), + equals('true')); + // Unlike trackRebuildDirtyWidgets, trackRepaintWidgets doesn't force a full + // rebuild. + expect(service.rebuildCount, equals(0)); + + await tester.pump(); + + expect(repaintEvents.length, equals(1)); + Map event = repaintEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + List data = event['events']; + expect(data.length, equals(18)); + final int numDataEntries = data.length ~/ 2; + final Map> newLocations = event['newLocations']; + expect(newLocations, isNotNull); + expect(newLocations.length, equals(1)); + expect(newLocations.keys.first, equals(file)); + final List locationsForFile = newLocations[file]; + expect(locationsForFile.length, equals(27)); + final int numLocationEntries = locationsForFile.length ~/ 3; + expect(numLocationEntries, equals(numDataEntries)); + + final Map knownLocations = + {}; + addToKnownLocationsMap( + knownLocations: knownLocations, + newLocations: newLocations, + ); + int totalCount = 0; + int maxCount = 0; + for (int i = 0; i < data.length; i += 2) { + final int id = data[i]; + final int count = data[i + 1]; + totalCount += count; + maxCount = max(maxCount, count); + expect(knownLocations.containsKey(id), isTrue); + } + expect(totalCount, equals(34)); + // The creation locations that were rebuilt the most were rebuilt 6 times + // as there are 6 instances of the ClockText widget. + expect(maxCount, equals(6)); + + final List clocks = find.byType(ClockText).evaluate().toList(); + expect(clocks.length, equals(6)); + // Update a single clock. + final StatefulElement clockElement = clocks.first; + final _ClockTextState state = clockElement.state; + state.updateTime(); // Triggers a rebuild. + await tester.pump(); + expect(repaintEvents.length, equals(1)); + event = repaintEvents.removeLast(); + expect(event['startTime'], isInstanceOf()); + data = event['events']; + // No new locations were rebuilt. + expect(event.containsKey('newLocations'), isFalse); + + // Triggering a a rebuild of one widget in this app causes the whole app + // to repaint. + expect(data.length, equals(18)); + + // TODO(jacobr): add an additional repaint test that uses multiple repaint + // boundaries to test more complex repaint conditions. + + // Turn off rebuild counts. + expect( + await service.testBoolExtension( + 'trackRepaintWidgets', {'enabled': 'false'}), + equals('false')); + + state.updateTime(); // Triggers a rebuild. + await tester.pump(); + // Verify that rapint events are not fired once the extension is disabled. + expect(repaintEvents, isEmpty); + }, skip: !WidgetInspectorService.instance.isWidgetCreationTracked()); // Test requires --track-widget-creation flag. + testWidgets('ext.flutter.inspector.show', (WidgetTester tester) async { service.rebuildCount = 0; expect(await service.testBoolExtension('show', {'enabled': 'true'}), equals('true')); @@ -1824,3 +2245,20 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService { }); } } + +void addToKnownLocationsMap({ + @required Map knownLocations, + @required Map> newLocations, +}) { + newLocations.forEach((String file, List entries) { + assert(entries.length % 3 == 0); + for (int i = 0; i < entries.length; i += 3) { + final int id = entries[i]; + final int line = entries[i + 1]; + final int column = entries[i + 2]; + assert(!knownLocations.containsKey(id)); + knownLocations[id] = + _CreationLocation(file: file, line: line, column: column, id: id); + } + }); +}