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);
+    }
+  });
+}