Add option to track widget rebuilds and repaints from the Flutter inspector. (#23534)
diff --git a/packages/flutter/lib/src/rendering/debug.dart b/packages/flutter/lib/src/rendering/debug.dart
index 67a4860..4cd94ed 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 @@
/// 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 @@
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 3b53308..b3e91f4 100644
--- a/packages/flutter/lib/src/rendering/object.dart
+++ b/packages/flutter/lib/src/rendering/object.dart
@@ -161,6 +161,8 @@
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 e73934f..a4a0a74 100644
--- a/packages/flutter/lib/src/widgets/binding.dart
+++ b/packages/flutter/lib/src/widgets/binding.dart
@@ -711,6 +711,11 @@
@override
Future<void> 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 6ce2e7b..fcbfe08 100644
--- a/packages/flutter/lib/src/widgets/debug.dart
+++ b/packages/flutter/lib/src/widgets/debug.dart
@@ -30,6 +30,26 @@
/// 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 03b8e9d..500c825 100644
--- a/packages/flutter/lib/src/widgets/framework.dart
+++ b/packages/flutter/lib/src/widgets/framework.dart
@@ -3514,6 +3514,9 @@
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 673616c..54d62eb 100644
--- a/packages/flutter/lib/src/widgets/widget_inspector.dart
+++ b/packages/flutter/lib/src/widgets/widget_inspector.dart
@@ -32,6 +32,7 @@
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 @@
///
/// 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 @@
/// 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 @@
/// 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 @@
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 @@
/// 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 @@
List<String> _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 @@
}
/// 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<Object> callback(String objectGroup),
@@ -821,7 +840,6 @@
registerServiceExtension(
name: name,
callback: (Map<String, String> parameters) async {
- assert(parameters.containsKey('objectGroup'));
return <String, Object>{'result': await callback(parameters['objectGroup'])};
},
);
@@ -930,6 +948,8 @@
assert(!_debugServiceExtensionsRegistered);
assert(() { _debugServiceExtensionsRegistered = true; return true; }());
+ SchedulerBinding.instance.addPersistentFrameCallback(_onFrameStart);
+
_registerBoolServiceExtension(
name: 'show',
getter: () async => WidgetsApp.debugShowWidgetInspectorOverride,
@@ -942,6 +962,60 @@
},
);
+ 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 @@
name: 'getRootWidgetSummaryTree',
callback: _getRootWidgetSummaryTree,
);
-
_registerServiceExtensionWithArg(
name: 'getDetailsSubtree',
callback: _getDetailsSubtree,
@@ -1052,6 +1125,11 @@
);
}
+ 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 @@
/// 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 @@
/// 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 @@
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 @@
return null;
final Map<String, Object> 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 @@
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 @@
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 @@
/// 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 @@
/// 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<Object, Object> 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<int, _LocationCount>` 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<String, dynamic> exportToJson(Duration startTime) {
+ final List<int> events = List<int>.filled(active.length * 2, 0);
+ int j = 0;
+ for (_LocationCount stat in active) {
+ events[j++] = stat.id;
+ events[j++] = stat.count;
+ }
+
+ final Map<String, dynamic> json = <String, dynamic>{
+ 'startTime': startTime.inMicroseconds,
+ 'events': events,
+ };
+
+ if (newLocations.isNotEmpty) {
+ // Add all newly used location ids to the JSON.
+ final Map<String, List<int>> locationsJson = <String, List<int>>{};
+ for (_LocationCount entry in newLocations) {
+ final _Location location = entry.location;
+ final List<int> jsonForFile = locationsJson.putIfAbsent(
+ location.file,
+ () => <int>[],
+ );
+ 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 @@
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 775163e..3e08cdc 100644
--- a/packages/flutter/test/foundation/service_extensions_test.dart
+++ b/packages/flutter/test/foundation/service_extensions_test.dart
@@ -528,12 +528,18 @@
});
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 902c763..88c0f68 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/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<ClockDemo> {
+ @override
+ Widget build(BuildContext context) {
+ return Directionality(
+ textDirection: TextDirection.ltr,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ 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: <Widget>[
+ 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<ClockText> {
+ 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<Map<String, Object>> Function(Map<String, String> parameters);
class RenderRepaintBoundaryWithDebugPaint extends RenderRepaintBoundary {
@@ -95,6 +193,9 @@
class TestWidgetInspectorService extends Object with WidgetInspectorService {
final Map<String, InspectorServiceExtensionCallback> extensions = <String, InspectorServiceExtensionCallback>{};
+ final Map<String, List<Map<Object, Object>>> eventsDispatched =
+ <String, List<Map<Object, Object>>>{};
+
@override
void registerServiceExtension({
@required String name,
@@ -104,6 +205,15 @@
extensions[name] = callback;
}
+ @override
+ void postEvent(String eventKind, Map<Object, Object> eventData) {
+ getEventsDispatched(eventKind).add(eventData);
+ }
+
+ List<Map<Object, Object>> getEventsDispatched(String eventKind) {
+ return eventsDispatched.putIfAbsent(eventKind, () => <Map<Object, Object>>[]);
+ }
+
Future<Object> testExtension(String name, Map<String, String> arguments) async {
expect(extensions.containsKey(name), isTrue);
// Encode and decode to JSON to match behavior using a real service
@@ -123,6 +233,11 @@
@override
Future<void> forceRebuild() async {
rebuildCount++;
+ final WidgetsBinding binding = WidgetsBinding.instance;
+
+ if (binding.renderViewElement != null) {
+ binding.buildOwner.reassemble(binding.renderViewElement);
+ }
}
@@ -1301,6 +1416,312 @@
expect(await service.testExtension('getSelectedWidget', <String, String>{'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<String, Object> jsonObject = await service.testExtension(
+ 'getSelectedWidget',
+ <String, String>{'arg': null, 'objectGroup': 'my-group'});
+ final Map<String, Object> creationLocation =
+ jsonObject['creationLocation'];
+ expect(creationLocation, isNotNull);
+ final String file = creationLocation['file'];
+ expect(file, endsWith('widget_inspector_test.dart'));
+ final List<String> 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', <String, String>{'arg0': pubRootTest});
+
+ final List<Map<Object, Object>> rebuildEvents =
+ service.getEventsDispatched('Flutter.RebuiltWidgets');
+ expect(rebuildEvents, isEmpty);
+
+ expect(service.rebuildCount, equals(0));
+ expect(
+ await service.testBoolExtension(
+ 'trackRebuildDirtyWidgets', <String, String>{'enabled': 'true'}),
+ equals('true'));
+ expect(service.rebuildCount, equals(1));
+ await tester.pump();
+
+ expect(rebuildEvents.length, equals(1));
+ Map<Object, Object> event = rebuildEvents.removeLast();
+ expect(event['startTime'], isInstanceOf<int>());
+ List<int> data = event['events'];
+ expect(data.length, equals(14));
+ final int numDataEntries = data.length ~/ 2;
+ Map<String, List<int>> newLocations = event['newLocations'];
+ expect(newLocations, isNotNull);
+ expect(newLocations.length, equals(1));
+ expect(newLocations.keys.first, equals(file));
+ final List<int> locationsForFile = newLocations[file];
+ expect(locationsForFile.length, equals(21));
+ final int numLocationEntries = locationsForFile.length ~/ 3;
+ expect(numLocationEntries, equals(numDataEntries));
+
+ final Map<int, _CreationLocation> knownLocations =
+ <int, _CreationLocation>{};
+ 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<Element> 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<int>());
+ 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<int>());
+ 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<int>());
+ 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<int>());
+ 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', <String, String>{'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<String, Object> jsonObject = await service.testExtension(
+ 'getSelectedWidget',
+ <String, String>{'arg': null, 'objectGroup': 'my-group'});
+ final Map<String, Object> creationLocation =
+ jsonObject['creationLocation'];
+ expect(creationLocation, isNotNull);
+ final String file = creationLocation['file'];
+ expect(file, endsWith('widget_inspector_test.dart'));
+ final List<String> 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', <String, String>{'arg0': pubRootTest});
+
+ final List<Map<Object, Object>> repaintEvents =
+ service.getEventsDispatched('Flutter.RepaintWidgets');
+ expect(repaintEvents, isEmpty);
+
+ expect(service.rebuildCount, equals(0));
+ expect(
+ await service.testBoolExtension(
+ 'trackRepaintWidgets', <String, String>{'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<Object, Object> event = repaintEvents.removeLast();
+ expect(event['startTime'], isInstanceOf<int>());
+ List<int> data = event['events'];
+ expect(data.length, equals(18));
+ final int numDataEntries = data.length ~/ 2;
+ final Map<String, List<int>> newLocations = event['newLocations'];
+ expect(newLocations, isNotNull);
+ expect(newLocations.length, equals(1));
+ expect(newLocations.keys.first, equals(file));
+ final List<int> locationsForFile = newLocations[file];
+ expect(locationsForFile.length, equals(27));
+ final int numLocationEntries = locationsForFile.length ~/ 3;
+ expect(numLocationEntries, equals(numDataEntries));
+
+ final Map<int, _CreationLocation> knownLocations =
+ <int, _CreationLocation>{};
+ 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<Element> 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<int>());
+ 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', <String, String>{'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', <String, String>{'enabled': 'true'}), equals('true'));
@@ -1824,3 +2245,20 @@
});
}
}
+
+void addToKnownLocationsMap({
+ @required Map<int, _CreationLocation> knownLocations,
+ @required Map<String, List<int>> newLocations,
+}) {
+ newLocations.forEach((String file, List<int> 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);
+ }
+ });
+}