Add pin rows to Profile Memory table (#9832)
* Add pin rows to Profile Memory table
* Update packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* Update packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* fix issues
* Tighten pin column and show icon on hover
* 32->10
* fix pin hover test
* update img
* Remove unused pin helpers.
* Update profile tab golden.
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart
index b1c412b..36f7ec0 100644
--- a/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart
+++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart
@@ -34,13 +34,21 @@
factory AdaptedProfile.fromAllocationProfile(
AllocationProfile profile,
ClassFilter filter,
- String? rootPackage,
- ) {
+ String? rootPackage, {
+ Set<String> pinnedClassFullNames = const {},
+ }) {
final adaptedProfile = AdaptedProfile._(
total: ProfileRecord.total(profile),
items: (profile.members ?? [])
.where((e) => (e.instancesCurrent ?? 0) > 0)
- .map((e) => ProfileRecord.fromClassHeapStats(e))
+ .map(
+ (e) => ProfileRecord.fromClassHeapStats(
+ e,
+ userPinned: pinnedClassFullNames.contains(
+ HeapClassName.fromClassRef(e.classRef).fullName,
+ ),
+ ),
+ )
.toList(),
newSpaceGCStats: profile.newSpaceGCStats,
oldSpaceGCStats: profile.oldSpaceGCStats,
@@ -70,6 +78,31 @@
);
}
+ /// Returns a copy of [profile] with [pinnedClassFullNames] applied to items.
+ factory AdaptedProfile.withPinnedClasses(
+ AdaptedProfile profile,
+ Set<String> pinnedClassFullNames,
+ String? rootPackage,
+ ) {
+ final itemsWithPins = profile._items
+ .map(
+ (record) => record.copyWith(
+ userPinned: pinnedClassFullNames.contains(
+ record.heapClass.fullName,
+ ),
+ ),
+ )
+ .toList();
+ final updated = AdaptedProfile._(
+ total: profile._total,
+ items: itemsWithPins,
+ newSpaceGCStats: profile.newSpaceGCStats,
+ oldSpaceGCStats: profile.oldSpaceGCStats,
+ totalGCStats: profile.totalGCStats,
+ );
+ return AdaptedProfile.withNewFilter(updated, profile.filter, rootPackage);
+ }
+
factory AdaptedProfile.fromJson(Map<String, dynamic> json) {
return AdaptedProfile._(
total: ProfileRecord.fromJson(json[_ProfileJson.total]),
@@ -117,6 +150,7 @@
class _RecordJson {
static const isTotal = 'it';
static const heapClass = 'c';
+ static const userPinned = 'p';
static const totalInstances = 'ti';
static const totalSize = 'ts';
static const totalDartHeapSize = 'tds';
@@ -135,6 +169,7 @@
ProfileRecord._({
required this.isTotal,
required this.heapClass,
+ this.userPinned = false,
required this.totalInstances,
required this.totalSize,
required this.totalDartHeapSize,
@@ -151,7 +186,10 @@
_verifyIntegrity();
}
- factory ProfileRecord.fromClassHeapStats(ClassHeapStats stats) {
+ factory ProfileRecord.fromClassHeapStats(
+ ClassHeapStats stats, {
+ bool userPinned = false,
+ }) {
assert(
stats.bytesCurrent! == stats.newSpace.size + stats.oldSpace.size,
'${stats.bytesCurrent}, ${stats.newSpace.size}, ${stats.oldSpace.size}',
@@ -159,6 +197,7 @@
return ProfileRecord._(
isTotal: false,
heapClass: HeapClassName.fromClassRef(stats.classRef),
+ userPinned: userPinned,
totalInstances: stats.instancesCurrent ?? 0,
totalSize:
stats.bytesCurrent! +
@@ -180,6 +219,7 @@
ProfileRecord.total(AllocationProfile profile)
: isTotal = true,
+ userPinned = false,
heapClass = HeapClassName.fromPath(className: 'All Classes', library: ''),
totalInstances = null,
totalSize =
@@ -202,6 +242,7 @@
return ProfileRecord._(
isTotal: json[_RecordJson.isTotal] as bool,
heapClass: HeapClassName.fromJson(json[_RecordJson.heapClass]),
+ userPinned: json[_RecordJson.userPinned] as bool? ?? false,
totalInstances: json[_RecordJson.totalInstances] as int?,
totalSize: json[_RecordJson.totalSize] as int,
totalDartHeapSize: json[_RecordJson.totalDartHeapSize] as int,
@@ -217,11 +258,32 @@
);
}
+ ProfileRecord copyWith({bool? userPinned}) {
+ return ProfileRecord._(
+ isTotal: isTotal,
+ heapClass: heapClass,
+ userPinned: userPinned ?? this.userPinned,
+ totalInstances: totalInstances,
+ totalSize: totalSize,
+ totalDartHeapSize: totalDartHeapSize,
+ totalExternalSize: totalExternalSize,
+ newSpaceInstances: newSpaceInstances,
+ newSpaceSize: newSpaceSize,
+ newSpaceDartHeapSize: newSpaceDartHeapSize,
+ newSpaceExternalSize: newSpaceExternalSize,
+ oldSpaceInstances: oldSpaceInstances,
+ oldSpaceSize: oldSpaceSize,
+ oldSpaceDartHeapSize: oldSpaceDartHeapSize,
+ oldSpaceExternalSize: oldSpaceExternalSize,
+ );
+ }
+
@override
Map<String, dynamic> toJson() {
return {
_RecordJson.isTotal: isTotal,
_RecordJson.heapClass: heapClass,
+ _RecordJson.userPinned: userPinned,
_RecordJson.totalInstances: totalInstances,
_RecordJson.totalSize: totalSize,
_RecordJson.totalDartHeapSize: totalDartHeapSize,
@@ -239,6 +301,8 @@
final bool isTotal;
+ final bool userPinned;
+
final HeapClassName heapClass;
final int? totalInstances;
@@ -257,7 +321,7 @@
final int? oldSpaceExternalSize;
@override
- bool get pinToTop => isTotal;
+ bool get pinToTop => isTotal || userPinned;
void _verifyIntegrity() {
assert(() {
diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
index d469c23..0b5007e 100644
--- a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
+++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart
@@ -11,20 +11,28 @@
import '../../../../shared/config_specific/import_export/import_export.dart';
import '../../../../shared/globals.dart';
+import '../../../../shared/memory/class_name.dart';
import '../../shared/heap/class_filter.dart';
import 'model.dart';
@visibleForTesting
-enum Json { profile, rootPackage }
+enum Json { profile, rootPackage, pinnedClasses }
class ProfilePaneController extends DisposableController
with AutoDisposeControllerMixin, Serializable {
- ProfilePaneController({required this.rootPackage, AdaptedProfile? profile}) {
+ ProfilePaneController({
+ required this.rootPackage,
+ AdaptedProfile? profile,
+ Set<String>? pinnedClassFullNames,
+ }) {
+ if (pinnedClassFullNames != null) {
+ _pinnedClassFullNames.addAll(pinnedClassFullNames);
+ }
// [profile] should only be non-null when loading offline data.
if (profile != null) {
- _currentAllocationProfile.value = AdaptedProfile.withNewFilter(
- profile,
- classFilter.value,
+ _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses(
+ AdaptedProfile.withNewFilter(profile, classFilter.value, rootPackage),
+ _pinnedClassFullNames,
rootPackage,
);
}
@@ -34,6 +42,9 @@
return ProfilePaneController(
profile: deserialize(json[Json.profile.name], AdaptedProfile.fromJson),
rootPackage: json[Json.rootPackage.name],
+ pinnedClassFullNames: (json[Json.pinnedClasses.name] as List?)
+ ?.cast<String>()
+ .toSet(),
);
}
@@ -42,11 +53,35 @@
return {
Json.profile.name: _currentAllocationProfile.value,
Json.rootPackage.name: rootPackage,
+ Json.pinnedClasses.name: _pinnedClassFullNames.toList(),
};
}
bool _initialized = false;
+ /// Classes pinned to the top of the Profile Memory table.
+ final _pinnedClassFullNames = <String>{};
+
+ void togglePin(HeapClassName heapClass) {
+ final key = heapClass.fullName;
+ if (_pinnedClassFullNames.contains(key)) {
+ _pinnedClassFullNames.remove(key);
+ } else {
+ _pinnedClassFullNames.add(key);
+ }
+ _reapplyPinnedState();
+ }
+
+ void _reapplyPinnedState() {
+ final currentProfile = _currentAllocationProfile.value;
+ if (currentProfile == null) return;
+ _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses(
+ currentProfile,
+ _pinnedClassFullNames,
+ rootPackage,
+ );
+ }
+
/// Initializes the controller if it is not initialized yet.
@override
void init() {
@@ -84,6 +119,7 @@
profile,
classFilter.value,
rootPackage,
+ pinnedClassFullNames: _pinnedClassFullNames,
);
_initializeSelection();
}
@@ -105,9 +141,13 @@
_classFilter.value = filter;
final currentProfile = _currentAllocationProfile.value;
if (currentProfile == null) return;
- _currentAllocationProfile.value = AdaptedProfile.withNewFilter(
- currentProfile,
- classFilter.value,
+ _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses(
+ AdaptedProfile.withNewFilter(
+ currentProfile,
+ classFilter.value,
+ rootPackage,
+ ),
+ _pinnedClassFullNames,
rootPackage,
);
}
diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
index 7325b42..ed4637b 100644
--- a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
+++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart
@@ -32,12 +32,97 @@
/// instances, memory).
const _defaultNumberFieldWidth = 80.0;
+@visibleForTesting
+const allocationProfilePinButtonKey = Key('allocation-profile-pin-button');
+
+class _PinColumn extends ColumnData<ProfileRecord>
+ implements ColumnRenderer<ProfileRecord> {
+ _PinColumn({required this.controller})
+ : super(
+ '',
+ titleTooltip: 'Pin class to the top of the table',
+ fixedWidthPx: 10.0,
+ alignment: ColumnAlignment.center,
+ );
+
+ final ProfilePaneController controller;
+
+ @override
+ bool get supportsSorting => false;
+
+ @override
+ Widget build(
+ BuildContext context,
+ ProfileRecord item, {
+ bool isRowSelected = false,
+ bool isRowHovered = false,
+ }) {
+ if (item.isTotal) return const SizedBox.shrink();
+
+ final pinned = item.userPinned;
+ return _HoverPinButton(
+ pinned: pinned,
+ onPressed: () {
+ ga.select(
+ gac.memory,
+ '${gac.MemoryEvents.profilePinClass.name}-$pinned',
+ );
+ controller.togglePin(item.heapClass);
+ },
+ );
+ }
+
+ @override
+ bool? getValue(ProfileRecord _) => null;
+
+ @override
+ int compare(ProfileRecord a, ProfileRecord b) =>
+ a.userPinned.boolCompare(b.userPinned);
+}
+
+class _HoverPinButton extends StatefulWidget {
+ const _HoverPinButton({required this.pinned, required this.onPressed});
+
+ final bool pinned;
+ final VoidCallback onPressed;
+
+ @override
+ State<_HoverPinButton> createState() => _HoverPinButtonState();
+}
+
+class _HoverPinButtonState extends State<_HoverPinButton> {
+ bool _hovering = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final showIcon = widget.pinned || _hovering;
+
+ return MouseRegion(
+ key: allocationProfilePinButtonKey,
+ onEnter: (_) => setState(() => _hovering = true),
+ onExit: (_) => setState(() => _hovering = false),
+ child: showIcon
+ ? IconButton(
+ visualDensity: VisualDensity.compact,
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
+ iconSize: 18,
+ icon: Icon(
+ widget.pinned ? Icons.push_pin : Icons.push_pin_outlined,
+ ),
+ tooltip: widget.pinned ? 'Unpin class' : 'Pin class to top',
+ onPressed: widget.onPressed,
+ )
+ : const SizedBox(width: 32, height: 32),
+ );
+ }
+}
+
class _FieldClassNameColumn extends ColumnData<ProfileRecord>
implements
ColumnRenderer<ProfileRecord>,
ColumnHeaderRenderer<ProfileRecord> {
- const _FieldClassNameColumn(this.classFilterData)
- : super('Class', fixedWidthPx: 200);
+ const _FieldClassNameColumn(this.classFilterData) : super.wide('Class');
@override
String? getValue(ProfileRecord dataObject) => dataObject.heapClass.className;
@@ -479,40 +564,39 @@
}
}
-class _AllocationProfileTable extends StatelessWidget {
- _AllocationProfileTable({required this.controller});
+class _AllocationProfileTable extends StatefulWidget {
+ const _AllocationProfileTable({required this.controller});
+ final ProfilePaneController controller;
+
+ @override
+ State<_AllocationProfileTable> createState() =>
+ _AllocationProfileTableState();
+}
+
+class _AllocationProfileTableState extends State<_AllocationProfileTable> {
/// List of columns displayed in advanced developer mode state.
static final _vmModeColumnGroups = [
ColumnGroup.fromText(title: '', range: const Range(0, 1)),
+ ColumnGroup.fromText(title: '', range: const Range(1, 2)),
ColumnGroup.fromText(
title: HeapGeneration.total.toString(),
- range: const Range(1, 5),
+ range: const Range(2, 6),
),
ColumnGroup.fromText(
title: HeapGeneration.newSpace.toString(),
- range: const Range(5, 9),
+ range: const Range(6, 10),
),
ColumnGroup.fromText(
title: HeapGeneration.oldSpace.toString(),
- range: const Range(9, 13),
+ range: const Range(10, 14),
),
];
static const _fieldSizeColumn = _FieldSizeColumn(heap: HeapGeneration.total);
- late final _columns = <ColumnData<ProfileRecord>>[
- _FieldClassNameColumn(
- ClassFilterData(
- filter: controller.classFilter,
- onChanged: controller.setFilter,
- rootPackage: controller.rootPackage,
- ),
- ),
- const _FieldInstanceCountColumn(heap: HeapGeneration.total),
- _fieldSizeColumn,
- _FieldDartHeapSizeColumn(heap: HeapGeneration.total),
- ];
+ late _PinColumn _pinColumn;
+ late List<ColumnData<ProfileRecord>> _columns;
late final _advancedDeveloperModeColumns = [
const _FieldExternalSizeColumn(heap: HeapGeneration.total),
@@ -526,12 +610,41 @@
const _FieldExternalSizeColumn(heap: HeapGeneration.oldSpace),
];
- final ProfilePaneController controller;
+ @override
+ void initState() {
+ super.initState();
+ _initColumns();
+ }
+
+ @override
+ void didUpdateWidget(covariant _AllocationProfileTable oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.controller != widget.controller) {
+ _initColumns();
+ }
+ }
+
+ void _initColumns() {
+ _pinColumn = _PinColumn(controller: widget.controller);
+ _columns = <ColumnData<ProfileRecord>>[
+ _pinColumn,
+ _FieldClassNameColumn(
+ ClassFilterData(
+ filter: widget.controller.classFilter,
+ onChanged: widget.controller.setFilter,
+ rootPackage: widget.controller.rootPackage,
+ ),
+ ),
+ const _FieldInstanceCountColumn(heap: HeapGeneration.total),
+ _fieldSizeColumn,
+ _FieldDartHeapSizeColumn(heap: HeapGeneration.total),
+ ];
+ }
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AdaptedProfile?>(
- valueListenable: controller.currentAllocationProfile,
+ valueListenable: widget.controller.currentAllocationProfile,
builder: (context, profile, _) {
// TODO(bkonyi): make this an overlay so the table doesn't
// disappear when we're retrieving new data, especially since the
@@ -547,18 +660,18 @@
data: profile.records,
dataKey: 'allocation-profile',
columnGroups: advancedDeveloperModeEnabled
- ? _AllocationProfileTable._vmModeColumnGroups
+ ? _AllocationProfileTableState._vmModeColumnGroups
: null,
columns: [
..._columns,
if (advancedDeveloperModeEnabled)
..._advancedDeveloperModeColumns,
],
- defaultSortColumn: _AllocationProfileTable._fieldSizeColumn,
+ defaultSortColumn: _AllocationProfileTableState._fieldSizeColumn,
defaultSortDirection: SortDirection.descending,
pinBehavior: FlatTablePinBehavior.pinOriginalToTop,
includeColumnGroupHeaders: false,
- selectionNotifier: controller.selection,
+ selectionNotifier: widget.controller.selection,
);
},
);
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart
index 6549ec6..bce8a49 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart
@@ -53,6 +53,7 @@
profileHelp,
profileRefreshManual,
profileRefreshOnGc,
+ profilePinClass,
// 'Tracing' tab events
tracingClear,
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index dedd867..ee62970 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -35,7 +35,7 @@
## Memory updates
-TODO: Remove this section if there are not any updates.
+* Added the ability to pin classes to the top of the Profile Memory table. [#8898](https://github.com/flutter/devtools/issues/8898)
## Debugger updates
diff --git a/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart b/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart
index c3824f0..72c0f19 100644
--- a/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart
+++ b/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart
@@ -5,6 +5,7 @@
import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart';
import 'package:devtools_app/src/screens/memory/panes/profile/model.dart';
import 'package:devtools_app/src/screens/memory/panes/profile/profile_pane_controller.dart';
+import 'package:devtools_app/src/screens/memory/panes/profile/profile_view.dart';
import 'package:devtools_app/src/shared/globals.dart';
import 'package:devtools_app/src/shared/memory/gc_stats.dart';
import 'package:devtools_app/src/shared/primitives/byte_utils.dart';
@@ -12,6 +13,7 @@
import 'package:devtools_app/src/shared/table/table.dart';
import 'package:devtools_test/devtools_test.dart';
import 'package:devtools_test/helpers.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -319,5 +321,57 @@
lastValue = internalSize;
}
});
+
+ testWidgetsWithWindowSize('pins class to top of table', windowSize, (
+ WidgetTester tester,
+ ) async {
+ await scene.pump(tester);
+
+ final allocationProfileController = scene.controller.profile!;
+ await navigateToAllocationProfile(tester, allocationProfileController);
+
+ final table = find.byType(FlatTable<ProfileRecord>);
+ expect(table, findsOneWidget);
+
+ final state = tester.state<FlatTableState<ProfileRecord>>(table.first);
+
+ // "All Classes" is always pinned via [ProfileRecord.isTotal].
+ expect(state.tableController.pinnedData, isNotEmpty);
+ expect(
+ state.tableController.pinnedData.every((record) => !record.userPinned),
+ isTrue,
+ );
+
+ // Pin icons are hidden until the cell is hovered.
+ final pinTargets = find.byKey(allocationProfilePinButtonKey);
+ expect(pinTargets, findsWidgets);
+
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+ addTearDown(gesture.removePointer);
+ await tester.pump();
+
+ await gesture.moveTo(tester.getCenter(pinTargets.first));
+ await tester.pumpAndSettle();
+
+ final pinButtons = find.byIcon(Icons.push_pin_outlined);
+ expect(pinButtons, findsWidgets);
+
+ await tester.tap(pinButtons.first);
+ await tester.pumpAndSettle();
+
+ expect(find.byIcon(Icons.push_pin), findsWidgets);
+ expect(
+ state.tableController.pinnedData.any((record) => record.userPinned),
+ isTrue,
+ );
+
+ // Pinned class stays at the top after sorting by class name.
+ await tester.tap(find.text('Class'));
+ await tester.pumpAndSettle();
+
+ final pinnedData = state.tableController.pinnedData;
+ expect(pinnedData.any((record) => record.userPinned), isTrue);
+ });
});
}
diff --git a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
index 1a01c25..112c1d0 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
Binary files differ