Unverified Commit 10bcddcc authored by Jacob Richman's avatar Jacob Richman Committed by GitHub

Add option to track widget rebuilds and repaints from the Flutter inspector. (#23534)

parent b722a744
......@@ -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;
......
......@@ -161,6 +161,8 @@ class PaintingContext extends ClipContext {
assert(() {
if (debugProfilePaintsEnabled)
Timeline.startSync('${child.runtimeType}', arguments: timelineWhitelistArguments);
if (debugOnProfilePaint != null)
debugOnProfilePaint(child);
return true;
}());
......
......@@ -711,6 +711,11 @@ mixin WidgetsBinding on BindingBase, SchedulerBinding, GestureBinding, RendererB
@override
Future<void> performReassemble() {
assert(() {
WidgetInspectorService.instance.performReassemble();
return true;
}());
deferFirstFrameReport();
if (renderViewElement != null)
buildOwner.reassemble(renderViewElement);
......
......@@ -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
......
......@@ -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');
......
......@@ -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<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 @@ 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<Object> callback(String objectGroup),
......@@ -821,7 +840,6 @@ mixin WidgetInspectorService {
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 @@ 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<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 @@ 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<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 @@ _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;
}
......@@ -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;
......
......@@ -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<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 @@ void main() {
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 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService {
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 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService {
@override
Future<void> 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', <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 @@ class TestWidgetInspectorService extends Object with WidgetInspectorService {
});
}
}
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);
}
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment