Replace heatmap with treemap in memory page
diff --git a/packages/devtools_app/lib/src/charts/treemap.dart b/packages/devtools_app/lib/src/charts/treemap.dart new file mode 100644 index 0000000..a093093 --- /dev/null +++ b/packages/devtools_app/lib/src/charts/treemap.dart
@@ -0,0 +1,546 @@ +import 'package:flutter/material.dart'; + +import '../common_widgets.dart'; +import '../trees.dart'; +import '../ui/colors.dart'; +import '../utils.dart'; + +enum PivotType { pivotByMiddle, pivotBySize } + +class Treemap extends StatelessWidget { + const Treemap.fromRoot({ + @required this.rootNode, + this.nodes, + @required this.levelsVisible, + @required this.isOutermostLevel, + @required this.height, + @required this.onRootChangedCallback, + }); + + const Treemap.fromNodes({ + this.rootNode, + @required this.nodes, + @required this.levelsVisible, + @required this.isOutermostLevel, + @required this.height, + @required this.onRootChangedCallback, + }); + + final TreemapNode rootNode; + + final List<TreemapNode> nodes; + + /// The depth of children visible from this Treemap widget. + /// + /// A decremented level should be passed in when constructing [Treemap.fromRoot], + /// but not when constructing [Treemap.fromNodes]. This is because + /// when constructing from a root, [Treemap] either builds a nested [Treemap] to + /// show its node's children, or it shows its node. When constructing from a list + /// of nodes, however, [Treemap] is built to become part of a bigger treemap, + /// which means the level should not change. + /// + /// For example, levelsVisible = 2: + /// ``` + /// _______________ + /// | Root | + /// --------------- + /// | 1 | + /// | --------- | + /// | | 2 | | + /// | | | | + /// | | | | + /// | --------- | + /// --------------- + /// ``` + final int levelsVisible; + + /// Whether current levelsVisible matches the outermost level. + final bool isOutermostLevel; + + final double height; + + final void Function(TreemapNode node) onRootChangedCallback; + + static const PivotType pivotType = PivotType.pivotBySize; + + static const treeMapHeaderHeight = 20.0; + + static const minHeightToDisplayTitleText = 20.0; + + static const minHeightToDisplayCellText = 40.0; + + /// Computes the total size of a given list of treemap nodes. + /// [endIndex] defaults to nodes.length - 1. + int computeByteSizeForNodes({ + @required List<TreemapNode> nodes, + int startIndex = 0, + int endIndex, + }) { + endIndex ??= nodes.length - 1; + int sum = 0; + for (int i = startIndex; i <= endIndex; i++) { + sum += nodes[i].byteSize; + } + return sum; + } + + int computePivot(List<TreemapNode> children) { + switch (pivotType) { + case PivotType.pivotByMiddle: + return (children.length / 2).floor(); + case PivotType.pivotBySize: + int pivotIndex = -1; + double maxSize = double.negativeInfinity; + for (int i = 0; i < children.length; i++) { + if (children[i].byteSize > maxSize) { + maxSize = children[i].byteSize.toDouble(); + pivotIndex = i; + } + } + return pivotIndex; + default: + return -1; + } + } + + /// Implements the ordered treemap algorithm studied in [this research paper](https://www.cs.umd.edu/~ben/papers/Shneiderman2001Ordered.pdf). + /// + /// **Algorithm** + /// + /// Divides a given list of treemap nodes into four parts: + /// L1, P, L2, L3. + /// + /// P (pivot) is the treemap node chosen to be the pivot based on the pivot type. + /// L1 includes all treemap nodes before the pivot treemap node. + /// L2 and L3 combined include all treemap nodes after the pivot treemap node. + /// A combination of elements are put into L2 and L3 so that + /// the aspect ratio of the pivot cell (P) is as close to 1 as it can be. + /// + /// **Layout** + /// ``` + /// ---------------------- + /// | | P | | + /// | | | | + /// | L1 |------| L3 | + /// | | L2 | | + /// | | | | + /// ---------------------- + /// ``` + List<Positioned> buildTreemaps({ + @required List<TreemapNode> children, + @required double width, + @required double height, + }) { + final isHorizontalRectangle = width > height; + + final totalByteSize = computeByteSizeForNodes(nodes: children); + if (children.isEmpty) { + return []; + } + + // Sort the list of treemap nodes, descending in size. + children.sort((a, b) => b.byteSize.compareTo(a.byteSize)); + if (children.length <= 2) { + final positionedChildren = <Positioned>[]; + double offset = 0; + + for (final child in children) { + final ratio = child.byteSize / totalByteSize; + + positionedChildren.add( + Positioned( + left: isHorizontalRectangle ? offset : 0.0, + top: isHorizontalRectangle ? 0.0 : offset, + width: isHorizontalRectangle ? ratio * width : width, + height: isHorizontalRectangle ? height : ratio * height, + child: Treemap.fromRoot( + rootNode: child, + levelsVisible: levelsVisible - 1, + isOutermostLevel: false, + onRootChangedCallback: onRootChangedCallback, + height: height, + ), + ), + ); + offset += isHorizontalRectangle ? ratio * width : ratio * height; + } + + return positionedChildren; + } + + final pivotIndex = computePivot(children); + + final pivotNode = children[pivotIndex]; + final pivotByteSize = pivotNode.byteSize; + + final list1 = children.sublist(0, pivotIndex); + final list1ByteSize = computeByteSizeForNodes(nodes: list1); + + var list2 = <TreemapNode>[]; + int list2ByteSize = 0; + var list3 = <TreemapNode>[]; + int list3ByteSize = 0; + + // The maximum amount of data we can put in [list3]. + final l3MaxLength = children.length - pivotIndex - 1; + int bestIndex = 0; + double pivotBestWidth = 0; + double pivotBestHeight = 0; + + // We need to be able to put at least 3 elements in [list3] for this algorithm. + if (l3MaxLength >= 3) { + double pivotBestAspectRatio = double.infinity; + // Iterate through different combinations of [list2] and [list3] to find + // the combination where the aspect ratio of pivot is the lowest. + for (int i = pivotIndex + 1; i < children.length; i++) { + final list2Size = computeByteSizeForNodes( + nodes: children, + startIndex: pivotIndex + 1, + endIndex: i, + ); + + // Calculate the aspect ratio for the pivot treemap node. + final pivotAndList2Ratio = (pivotByteSize + list2Size) / totalByteSize; + final pivotRatio = pivotByteSize / (pivotByteSize + list2Size); + + final pivotWidth = isHorizontalRectangle + ? pivotAndList2Ratio * width + : pivotRatio * width; + + final pivotHeight = isHorizontalRectangle + ? pivotRatio * height + : pivotAndList2Ratio * height; + + final pivotAspectRatio = pivotWidth / pivotHeight; + + // Best aspect ratio that is the closest to 1. + if ((1 - pivotAspectRatio).abs() < (1 - pivotBestAspectRatio).abs()) { + pivotBestAspectRatio = pivotAspectRatio; + bestIndex = i; + // Kept track of width and height to construct the pivot cell. + pivotBestWidth = pivotWidth; + pivotBestHeight = pivotHeight; + } + } + // Split the rest of the data into [list2] and [list3]. + list2 = children.sublist(pivotIndex + 1, bestIndex + 1); + list2ByteSize = computeByteSizeForNodes(nodes: list2); + + list3 = children.sublist(bestIndex + 1); + list3ByteSize = computeByteSizeForNodes(nodes: list3); + } else { + // Put all data in [list2] and none in [list3]. + list2 = children.sublist(pivotIndex + 1); + list2ByteSize = computeByteSizeForNodes(nodes: list2); + + final pivotAndList2Ratio = + (pivotByteSize + list2ByteSize) / totalByteSize; + final pivotRatio = pivotByteSize / (pivotByteSize + list2ByteSize); + pivotBestWidth = isHorizontalRectangle + ? pivotAndList2Ratio * width + : pivotRatio * width; + pivotBestHeight = isHorizontalRectangle + ? pivotRatio * height + : pivotAndList2Ratio * height; + } + + final positionedTreemaps = <Positioned>[]; + + // Contruct list 1 sub-treemap. + final list1SizeRatio = list1ByteSize / totalByteSize; + final list1Width = isHorizontalRectangle ? width * list1SizeRatio : width; + final list1Height = + isHorizontalRectangle ? height : height * list1SizeRatio; + if (list1.isNotEmpty) { + positionedTreemaps.add( + Positioned( + left: 0.0, + right: 0.0, + width: list1Width, + height: list1Height, + child: Treemap.fromNodes( + nodes: list1, + levelsVisible: levelsVisible, + isOutermostLevel: isOutermostLevel, + onRootChangedCallback: onRootChangedCallback, + height: height, + ), + ), + ); + } + + // Construct list 2 sub-treemap. + final list2Width = + isHorizontalRectangle ? pivotBestWidth : width - pivotBestWidth; + final list2Height = + isHorizontalRectangle ? height - pivotBestHeight : pivotBestHeight; + final list2XCoord = isHorizontalRectangle ? list1Width : 0.0; + final list2YCoord = isHorizontalRectangle ? pivotBestHeight : list1Height; + if (list2.isNotEmpty) { + positionedTreemaps.add( + Positioned( + left: list2XCoord, + top: list2YCoord, + width: list2Width, + height: list2Height, + child: Treemap.fromNodes( + nodes: list2, + levelsVisible: levelsVisible, + isOutermostLevel: isOutermostLevel, + onRootChangedCallback: onRootChangedCallback, + height: height, + ), + ), + ); + } + + // Construct pivot cell. + final pivotXCoord = isHorizontalRectangle ? list1Width : list2Width; + final pivotYCoord = isHorizontalRectangle ? 0.0 : list1Height; + + positionedTreemaps.add( + Positioned( + left: pivotXCoord, + top: pivotYCoord, + width: pivotBestWidth, + height: pivotBestHeight, + child: Treemap.fromRoot( + rootNode: pivotNode, + levelsVisible: levelsVisible - 1, + isOutermostLevel: false, + onRootChangedCallback: onRootChangedCallback, + height: height, + ), + ), + ); + + // Construct list 3 sub-treemap. + final list3Ratio = list3ByteSize / totalByteSize; + final list3Width = isHorizontalRectangle ? list3Ratio * width : width; + final list3Height = isHorizontalRectangle ? height : list3Ratio * height; + final list3XCoord = + isHorizontalRectangle ? list1Width + pivotBestWidth : 0.0; + final list3YCoord = + isHorizontalRectangle ? 0.0 : list1Height + pivotBestHeight; + + if (list3.isNotEmpty) { + positionedTreemaps.add( + Positioned( + left: list3XCoord, + top: list3YCoord, + width: list3Width, + height: list3Height, + child: Treemap.fromNodes( + nodes: list3, + levelsVisible: levelsVisible, + isOutermostLevel: isOutermostLevel, + onRootChangedCallback: onRootChangedCallback, + height: height, + ), + ), + ); + } + + return positionedTreemaps; + } + + Text buildNameAndSizeText({ + @required Color fontColor, + @required bool oneLine, + }) { + return Text( + rootNode.displayText(oneLine: oneLine), + style: TextStyle(color: fontColor), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ); + } + + @override + Widget build(BuildContext context) { + if (rootNode == null && nodes.isNotEmpty) { + return buildSubTreemaps(); + } else { + return buildTreemap(context); + } + } + + /// **Treemap widget layout** + /// ``` + /// ---------------------------- + /// | Title Text | + /// |--------------------------| + /// | | + /// | Cell | + /// | | + /// | | + /// ---------------------------- + /// ``` + Widget buildTreemap(BuildContext context) { + if (levelsVisible > 0 && rootNode.children.isNotEmpty) { + return Padding( + padding: const EdgeInsets.all(1.0), + child: Column( + children: [ + if (height > minHeightToDisplayTitleText) buildTitleText(context), + Expanded( + child: Treemap.fromNodes( + nodes: rootNode.children, + levelsVisible: levelsVisible, + isOutermostLevel: isOutermostLevel, + onRootChangedCallback: onRootChangedCallback, + height: height, + ), + ), + ], + ), + ); + } else { + return Column( + children: [ + Expanded( + child: buildSelectable( + child: Container( + decoration: BoxDecoration( + color: mainUiColor, + border: Border.all(color: Colors.black87), + ), + child: Center( + child: height > minHeightToDisplayCellText + ? buildNameAndSizeText( + fontColor: Colors.black, + oneLine: false, + ) + : const SizedBox(), + ), + ), + ), + ), + ], + ); + } + } + + Widget buildTitleText(BuildContext context) { + if (isOutermostLevel) { + final pathFromRoot = rootNode.pathFromRoot(); + // Build breadcrumbs navigator. + return Container( + height: treeMapHeaderHeight, + child: ListView.separated( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) { + return const Text(' > '); + }, + itemCount: pathFromRoot.length, + itemBuilder: (BuildContext context, int index) { + return buildSelectable( + child: Text( + index < pathFromRoot.length - 1 + ? pathFromRoot[index].name + : pathFromRoot[index].displayText(), + ), + newRoot: pathFromRoot[index], + ); + }, + ), + ); + } else { + return buildSelectable( + child: Container( + height: treeMapHeaderHeight, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.black87), + ), + child: buildNameAndSizeText( + fontColor: Theme.of(context).textTheme.bodyText2.color, + oneLine: true, + ), + ), + ); + } + } + + /// Builds a selectable container with [child] as its child. + /// + /// Selecting this widget will trigger a re-root of the tree + /// to the associated [TreemapNode]. + /// + /// The default value for newRoot is [rootNode]. + Tooltip buildSelectable({@required Widget child, TreemapNode newRoot}) { + newRoot ??= rootNode; + return Tooltip( + message: rootNode.displayText(), + waitDuration: tooltipWait, + preferBelow: false, + child: InkWell( + onTap: () { + if (rootNode.children.isNotEmpty) onRootChangedCallback(newRoot); + }, + child: child, + ), + ); + } + + Widget buildSubTreemaps() { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: buildTreemaps( + children: nodes, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), + ); + }, + ); + } +} + +class TreemapNode extends TreeNode<TreemapNode> { + TreemapNode({ + @required this.name, + this.byteSize = 0, + this.childrenMap = const <String, TreemapNode>{}, + }) : assert(name != null), + assert(byteSize != null), + assert(childrenMap != null); + + final String name; + final Map<String, TreemapNode> childrenMap; + int byteSize; + + String displayText({bool oneLine = true}) { + final separator = oneLine ? ' ' : '\n'; + return '$name$separator[${prettyPrintBytes(byteSize, includeUnit: true)}]'; + } + + /// Returns a list of [TreemapNode] in the path from root node to [this]. + List<TreemapNode> pathFromRoot() { + TreemapNode node = this; + final path = <TreemapNode>[]; + while (node != null) { + path.add(node); + node = node.parent; + } + return path.reversed.toList(); + } + + void printTree() { + printTreeHelper(this, ''); + } + + void printTreeHelper(TreemapNode root, String tabs) { + print(tabs + '$root'); + for (final child in root.children) { + printTreeHelper(child, tabs + '\t'); + } + } + + @override + String toString() { + return '{name: $name, size: $byteSize}'; + } +}
diff --git a/packages/devtools_app/lib/src/logging/logging_controller.dart b/packages/devtools_app/lib/src/logging/logging_controller.dart index 8960c04..eb01c98 100644 --- a/packages/devtools_app/lib/src/logging/logging_controller.dart +++ b/packages/devtools_app/lib/src/logging/logging_controller.dart
@@ -386,7 +386,7 @@ final String summary = '${isolateRef['name']} • ' '${e.json['reason']} collection in $time ms • ' - '${printMb(usedBytes)} MB used of ${printMb(capacityBytes)} MB'; + '${printMB(usedBytes, includeUnit: true)} used of ${printMB(capacityBytes, includeUnit: true)}'; final Map<String, dynamic> event = <String, dynamic>{ 'reason': e.json['reason'],
diff --git a/packages/devtools_app/lib/src/memory/memory_controller.dart b/packages/devtools_app/lib/src/memory/memory_controller.dart index 5687d7f..77081ff 100644 --- a/packages/devtools_app/lib/src/memory/memory_controller.dart +++ b/packages/devtools_app/lib/src/memory/memory_controller.dart
@@ -52,12 +52,12 @@ static const logFilenamePrefix = 'memory_log_'; - final _showHeatMap = ValueNotifier<bool>(false); + final _showTreemap = ValueNotifier<bool>(false); - ValueListenable<bool> get showHeatMap => _showHeatMap; + ValueListenable<bool> get showTreemap => _showTreemap; - void toggleShowHeatMap(bool value) { - _showHeatMap.value = value; + void toggleShowTreeMap(bool value) { + _showTreemap.value = value; } final snapshots = <Snapshot>[];
diff --git a/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart b/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart index 70cf4f7..28f40a3 100644 --- a/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart +++ b/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart
@@ -23,8 +23,8 @@ import 'memory_controller.dart'; import 'memory_filter.dart'; import 'memory_graph_model.dart'; -import 'memory_heatmap.dart'; import 'memory_snapshot_models.dart'; +import 'memory_treemap.dart'; import 'memory_utils.dart'; class HeapTree extends StatefulWidget { @@ -227,12 +227,8 @@ : _isSnapshotComplete ? 'Done' : '...'), ]); } else if (controller.snapshotByLibraryData != null) { - if (controller.showHeatMap.value) { - snapshotDisplay = HeatMapSizeAnalyzer( - child: SizedBox.expand( - child: FlameChart(controller), - ), - ); + if (controller.showTreemap.value) { + snapshotDisplay = MemoryTreemap(controller); } else { snapshotDisplay = MemorySnapshotTable(); } @@ -268,7 +264,7 @@ mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded(child: snapshotDisplay), - if (hasDetails) const SizedBox(width: defaultSpacing), + hasDetails ? const SizedBox(width: defaultSpacing) : const SizedBox(), // TODO(terry): Need better focus handling between 2 tables & up/down // arrows in the right-side field instance view table. controller.isLeafSelected @@ -343,13 +339,13 @@ const SizedBox(width: defaultSpacing), Row( children: [ - const Text('Heat Map'), + const Text('Treemap'), Switch( - value: controller.showHeatMap.value, + value: controller.showTreemap.value, onChanged: (value) { setState(() { closeAutoCompleteOverlay(); - controller.toggleShowHeatMap(value); + controller.toggleShowTreeMap(value); controller.search = ''; controller.selectedLeaf = null; }); @@ -358,13 +354,13 @@ ], ), const SizedBox(width: defaultSpacing), - controller.showHeatMap.value + controller.showTreemap.value ? const SizedBox() : _groupByDropdown(textTheme), const SizedBox(width: defaultSpacing), // TODO(terry): Mechanism to handle expand/collapse on both // tables objects/fields. Maybe notion in table? - controller.showHeatMap.value + controller.showTreemap.value ? const SizedBox() : OutlineButton( key: collapseAllButtonKey, @@ -386,7 +382,7 @@ : null, child: const Text('Collapse All'), ), - controller.showHeatMap.value + controller.showTreemap.value ? const SizedBox() : OutlineButton( key: expandAllButtonKey, @@ -463,7 +459,7 @@ ), ); - if (controller.showHeatMap.value && controller.snapshots.isNotEmpty) { + if (controller.showTreemap.value && controller.snapshots.isNotEmpty) { searchFieldFocusNode.requestFocus(); }
diff --git a/packages/devtools_app/lib/src/memory/memory_heatmap.dart b/packages/devtools_app/lib/src/memory/memory_heatmap.dart deleted file mode 100644 index 5d18241..0000000 --- a/packages/devtools_app/lib/src/memory/memory_heatmap.dart +++ /dev/null
@@ -1,684 +0,0 @@ -// Copyright 2020 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:math' as math; -import 'dart:math'; -import 'dart:ui'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' hide TextStyle; -import 'package:flutter/rendering.dart' hide TextStyle; -import 'package:flutter/widgets.dart' hide TextStyle; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; - -import '../auto_dispose_mixin.dart'; -import '../ui/colors.dart'; - -import 'memory_controller.dart'; -import 'memory_graph_model.dart'; -import 'memory_utils.dart'; - -/// UX navigation in a heat map e.g., -/// ------------------------------------------------------------ -/// | ||| || |||| | | new String | | || Foundation (38M) | -/// ------------------------------------------------------------ -/// || | | | | List | String | | || src (42M) | -/// ------------------------------------------------------------ -/// | src (36.4M) | dart:core (58M) |package:flutter (42M)| -/// ------------------------------------------------------------ -/// | Root (136M) | -/// ------------------------------------------------------------ -/// Mouse action (click or mouse scroll wheel) over a rectangle for example -/// package:flutter will to expand its contained children to more rectangles -/// upwards. The sizes of the contained children (if an aggregated parent). -/// Mouse action over, upward, children will further expand its children in -/// a upward fashion too. To collapse a rectangle move the mouse below a child -/// rectangle (its parent) and click or mouse scroll. - -class HeatMapSizeAnalyzer extends SingleChildRenderObjectWidget { - const HeatMapSizeAnalyzer({ - Key key, - Widget child, - }) : super(key: key, child: child); - - @override - RenderFlameChart createRenderObject(BuildContext context) { - return RenderFlameChart(); - } -} - -class RenderFlameChart extends RenderProxyBox { - @override - void paint(PaintingContext context, Offset offset) { - if (child != null) { - context.paintChild(child, offset); - } - } -} - -class FlameChart extends StatefulWidget { - const FlameChart( - this.controller, - ); - - final MemoryController controller; - - @override - FlameChartState createState() => FlameChartState(controller); -} - -class FlameChartState extends State<FlameChart> with AutoDisposeMixin { - FlameChartState(this.controller); - - InstructionsSize sizes; - - Map<String, Function> callbacks = {}; - - MemoryController controller; - - Widget snapshotDisplay; - - _FlameChart _flameChart; - - @override - void initState() { - super.initState(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - // TODO(terry): Unable to short-circuit need to investigate why? - controller = Provider.of<MemoryController>(context); - - cancel(); - - addAutoDisposeListener(controller.selectedSnapshotNotifier, () { - setState(() { - controller.computeAllLibraries(true, true); - - sizes = InstructionsSize.fromSnapshop(controller); - }); - }); - - addAutoDisposeListener(controller.filterNotifier, () { - setState(() { - controller.computeAllLibraries(true, true); - }); - }); - - addAutoDisposeListener(controller.selectTheSearchNotifier, () { - setState(() { - if (_trySelectItem()) { - closeAutoCompleteOverlay(); - } - }); - }); - - addAutoDisposeListener(controller.searchNotifier, () { - setState(() { - if (_trySelectItem()) { - closeAutoCompleteOverlay(); - } - }); - }); - - addAutoDisposeListener(controller.searchAutoCompleteNotifier, () { - setState(autoCompleteOverlaySetState(controller, context)); - }); - } - - /// Returns true if node found and node selected. - bool _trySelectItem() { - if (controller.search.isNotEmpty) { - if (controller.selectTheSearch) { - // Select the node. - final node = findNode(controller.search); - selectNode(node); - - closeAutoCompleteOverlay(); - - controller.selectTheSearch = false; - controller.search = ''; - return true; - } else { - var matches = matchNames(controller.search)..sort(); - // No exact match, return top matches. - matches = matches.sublist(0, min(topMatchesLimit, matches.length)); - controller.searchAutoComplete.value = matches; - } - } else if (controller.selectTheSearch) { - // Escape hit, on empty search. - selectNode(null); - controller.selectTheSearch = false; - } - - return false; - } - - @override - void dispose() { - super.dispose(); - } - - @override - Widget build(BuildContext context) { - sizes = InstructionsSize.fromSnapshop(controller); - - if (sizes != null) { - _flameChart = _FlameChart( - sizes, - // TODO(terry): Can the color range match flame chart? Review with UX. - memoryHeatMapLightColor, - memoryHeatMapDarkColor, - callbacks, - ); - return _flameChart; - } else { - return const SizedBox(); - } - } - - List<String> matchNames(String searchValue) { - final MatchNamesFunction callback = _flameChart.callbacks[matchNamesKey]; - return callback(searchValue); - } - - Node findNode(String searchValue) { - final FindNodeFunction callback = _flameChart.callbacks[findNodeKey]; - return callback(searchValue); - } - - void selectNode(Node nodeValue) { - final SelectNodeFunction callback = _flameChart.callbacks[selectNodeKey]; - callback(nodeValue); - } -} - -/// Definitions of exposed callback methods stored in callback Map the key -/// is the function name (String) and the value a callback function signature. - -/// matchNames callback name. -const matchNamesKey = 'matchNames'; - -/// matchNames callback signature. -typedef MatchNamesFunction = List<String> Function(String); - -/// findNode callback name. -const findNodeKey = 'findNode'; - -/// findNode callback signature. -typedef FindNodeFunction = Node Function(String); - -/// selectNode callback name. -const selectNodeKey = 'selectNode'; - -/// selectNode callback signature. -typedef SelectNodeFunction = void Function(Node); - -class _FlameChart extends LeafRenderObjectWidget { - const _FlameChart( - this.sizes, this.lightColor, this.darkColor, this.callbacks); - - final InstructionsSize sizes; - - final Color lightColor; - - final Color darkColor; - - final Map<String, Function> callbacks; - - @override - FlameChartRenderObject createRenderObject(BuildContext context) { - return FlameChartRenderObject() - // callbacks must be before sizes as hookup is done in sizes setter. - ..callbacks = callbacks - ..sizes = sizes - ..lightColor = lightColor - ..darkColor = darkColor; - } - - @override - void updateRenderObject( - BuildContext context, - FlameChartRenderObject renderObject, - ) { - renderObject - ..sizes = sizes - ..lightColor = lightColor - ..darkColor = darkColor; - } -} - -class FlameChartRenderObject extends RenderBox { - FlameChartRenderObject(); - - InstructionsSize _sizes; - - Offset paintOffset; - - Map<String, Function> callbacks; - - set sizes(InstructionsSize value) { - callbacks[matchNamesKey] = _exposeMatchNames; - callbacks[findNodeKey] = _exposeFindNode; - callbacks[selectNodeKey] = _exposeSelectNode; - - if (value == _sizes) { - return; - } - _sizes = value; - _selectedNode ??= value.root; - markNeedsPaint(); - } - - Color _lightColor; - - set lightColor(Color value) { - if (value == _lightColor) { - return; - } - _lightColor = value; - markNeedsPaint(); - } - - Color _darkColor; - - set darkColor(Color value) { - if (value == _lightColor) { - return; - } - _darkColor = value; - markNeedsPaint(); - } - - List<String> _exposeMatchNames(String searchName) { - return matchNodeNames(_sizes.root.children, searchName); - } - - /// Look for the node with a particular name (depth first traversal). - List<String> matchNodeNames(Map<String, Node> children, String searchName) { - final matches = <String>[]; - final matchName = searchName.toLowerCase(); - for (var child in children.entries) { - final node = child.value; - - final lcNodeName = node.name.toLowerCase(); - if (!lcNodeName.endsWith('.dart') && lcNodeName.startsWith(matchName)) { - matches.add(node.name); - } - if (node.children.isNotEmpty) { - final childMatches = matchNodeNames(node.children, searchName); - if (childMatches.isNotEmpty) { - matches.addAll(childMatches); - } - } - } - - return matches; - } - - Node _exposeFindNode(String searchName) { - return findNode(_sizes.root.children, searchName); - } - - /// Look for the node with a particular name (depth first traversal). - Node findNode(Map<String, Node> children, String searchName) { - final matchName = searchName.toLowerCase(); - for (var child in children.entries) { - final node = child.value; - if (node.children.isEmpty) { - return node.name.toLowerCase() == matchName ? node : null; - } else { - if (node.name.toLowerCase() == matchName) return node; - final foundNode = findNode(node.children, matchName); - if (foundNode != null) return foundNode; - } - } - - return null; - } - - void _exposeSelectNode(Node value) { - selectedNode = value; - } - - Node _selectedNode; - - set selectedNode(Node value) { - value ??= _sizes.root; - if (value == _selectedNode) { - return; - } - _selectedNode = value; - - markNeedsPaint(); - } - - @override - bool get sizedByParent => true; - - @override - bool hitTest(BoxHitTestResult result, {Offset position}) { - final computedPosition = - Offset(position.dx + paintOffset.dx, position.dy + paintOffset.dy); - - final node = _selectedNode.findRect(computedPosition); - if (node != null) { - selectedNode = node; - } - - return super.hitTest(result, position: position); - } - - @override - void paint(PaintingContext context, Offset offset) { - paintOffset = offset; - - final rootWidth = size.width; - final top = _paintAncestors(context, _selectedNode.ancestors); - - _paintNode( - context, _selectedNode, 0 + paintOffset.dx, rootWidth, top - offset.dy); - - _paintChildren( - context: context, - currentLeft: 1 + offset.dx, - parentSize: _selectedNode.byteSize, - children: _selectedNode.children.values, - topFactor: top + Node.nodeHeight + 1 - offset.dy, - maxWidth: rootWidth - 1, - ); - } - - final _logs = <int, double>{}; - final _inverseColors = <Color, Color>{}; - - void _paintNode( - PaintingContext context, - Node node, - double left, - double width, - double top, - ) { - node.rect = Rect.fromLTWH(left, size.height - top, width, Node.nodeHeight); - final double t = - _logs.putIfAbsent(node.byteSize, () => math.log(node.byteSize)) / - _logs.putIfAbsent( - _sizes.root.byteSize, () => math.log(_sizes.root.byteSize)); - final Color backgroundColor = Color.lerp( - _lightColor, - _darkColor, - t, - ); - - context.canvas.drawRRect( - RRect.fromRectAndRadius(node.rect, const Radius.circular(2.0)), - Paint()..color = backgroundColor, - ); - // Don't bother figuring out the text length if the box is too small. - if (width < 100) { - return; - } - - final formattedByteSize = NumberFormat.compact().format(node.byteSize); - - final builder = ParagraphBuilder( - ParagraphStyle( - textAlign: TextAlign.center, - maxLines: 2, - ellipsis: '...', - ), - ) - ..pushStyle( - TextStyle( - color: _inverseColors.putIfAbsent(backgroundColor, () { - return HSLColor.fromColor(backgroundColor).lightness > .7 - ? const Color(0xFF000000) - : const Color(0xFFFFFFFF); - }), - fontFamily: 'Courier New', - ), - ) - ..addText('${node.name}\n($formattedByteSize)'); - final Paragraph paragraph = builder.build() - ..layout(ParagraphConstraints(width: width - 5)); - context.canvas.drawParagraph( - paragraph, - Offset(10 + left, size.height - top + 10), - ); - } - - double _paintAncestors(PaintingContext context, List<Node> nodes) { - double top = Node.nodeHeight; - for (var node in nodes.reversed) { - _paintNode( - context, node, 0 + paintOffset.dx, size.width, top - paintOffset.dy); - top += Node.nodeHeight; - } - return top; - } - - void _paintChildren({ - PaintingContext context, - double currentLeft, - int parentSize, - Iterable<Node> children, - double topFactor, - double maxWidth, - }) { - double left = currentLeft; - - for (var child in children) { - final double width = child.byteSize / parentSize * maxWidth; - _paintNode(context, child, left, width, topFactor); - - if (!child.isLeaf) { - final double factor = math.max( - 0.0001, - math.min( - math.log(maxWidth * .01), - math.log(width * .1), - ), - ); - // Very minor chunk of memory, just skip display for now. Smaller than 1 pixel in width. - if (factor < width) { - _paintChildren( - context: context, - currentLeft: left + factor, - parentSize: child.byteSize, - children: child.children.values, - topFactor: topFactor + Node.nodeHeight + 1, - maxWidth: width - (2 * factor), - ); - } - } - left += width; - } - } -} - -class InstructionsSize { - const InstructionsSize(this.root); - - factory InstructionsSize.fromSnapshop(MemoryController controller) { - final Map<String, Node> rootChildren = <String, Node>{}; - final Node root = Node( - 'root', - children: rootChildren, - ); - Node currentParent = root; - - // TODO(terry): Should heat map be all memory or just the filtered group? - // Using rawGroup not graph.groupByLibrary. - controller.heapGraph.rawGroupByLibrary.forEach( - (libraryGroup, value) { - final classes = value; - for (final theClass in classes) { - final shallowSize = theClass.instancesTotalShallowSizes; - var className = theClass.name; - if (shallowSize == 0 || - libraryGroup == null || - className == null || - className == '::') { - continue; - } - - // Ensure the empty library name is our group name e.g., '' -> 'src'. - String libraryName = theClass.libraryUri.toString(); - if (libraryName.isEmpty) { - libraryName = libraryGroup; - } - - // Map class names to familar user names. - final predefined = - predefinedClasses[LibraryClass(libraryName, className)]; - if (predefined != null) { - className = predefined.prettyName; - } - - final symbol = Symbol( - name: 'new $className', - size: shallowSize, - libraryUri: libraryName, - className: className, - ); - - Map<String, Node> currentChildren = rootChildren; - final Node parentReset = currentParent; - for (String pathPart in symbol.parts) { - currentChildren.putIfAbsent( - pathPart, - () => Node(pathPart, - parent: currentParent, children: <String, Node>{}), - ); - currentChildren[pathPart].byteSize += symbol.size; - currentParent = currentChildren[pathPart]; - currentChildren = currentChildren[pathPart].children; - } - currentParent = parentReset; - } - }, - ); - - root.byteSize = root.children.values - .fold(0, (int current, Node node) => current + node.byteSize); - - final snapshotGraph = controller.snapshots.last.snapshotGraph; - - // Add the external heap to the heat map. - root.children.putIfAbsent('External Heap', () { - final node = Node( - 'External Heap', - parent: root, - children: <String, Node>{}, - ); - node.byteSize = snapshotGraph.externalSize; - return node; - }); - - // Add the filtered libraries/classes to the heat map. - root.children.putIfAbsent('All Filtered Libraries', () { - final node = Node( - 'All Filtered Libraries', - parent: root, - children: <String, Node>{}, - ); - node.byteSize = snapshotGraph.shallowSize - root.byteSize; - return node; - }); - - root.byteSize = snapshotGraph.shallowSize + snapshotGraph.externalSize; - - return InstructionsSize(root); - } - - final Node root; -} - -class Node { - Node( - this.name, { - this.byteSize = 0, - this.parent, - this.children = const <String, Node>{}, - }) : assert(name != null), - assert(byteSize != null), - assert(children != null); - - static const double nodeHeight = 50.0; - - final String name; - int byteSize; - Rect rect = Rect.zero; - final Node parent; - final Map<String, Node> children; - - Iterable<Node> get ancestors { - final nodes = <Node>[]; - Node current = this; - while (current.parent != null) { - nodes.add(current.parent); - current = current.parent; - } - return nodes; - } - - bool get isLeaf => children.isEmpty; - - Node findRect(Offset offset) { - if (rect.contains(offset)) { - return this; - } - for (var ancestor in ancestors) { - if (ancestor.rect.contains(offset)) { - return ancestor; - } - } - for (var child in children.values) { - final value = child.findRect(offset); - if (value != null) { - return value; - } - } - return null; - } - - @override - String toString() => 'Node($name, $byteSize, $rect)'; -} - -class Symbol { - const Symbol({ - @required this.name, - @required this.size, - this.libraryUri, - this.className, - }) : assert(name != null), - assert(size != null); - - static Symbol fromMap(Map<String, dynamic> json) { - return Symbol( - name: json['n'] as String, - size: json['s'] as int, - className: json['c'] as String, - libraryUri: json['l'] as String, - ); - } - - final String name; - final int size; - final String libraryUri; - final String className; - - List<String> get parts { - return <String>[ - if (libraryUri != null) ...libraryUri.split('/') else '@stubs', - if (className != null && className.isNotEmpty) className, - name, - ]; - } -}
diff --git a/packages/devtools_app/lib/src/memory/memory_treemap.dart b/packages/devtools_app/lib/src/memory/memory_treemap.dart new file mode 100644 index 0000000..a810167 --- /dev/null +++ b/packages/devtools_app/lib/src/memory/memory_treemap.dart
@@ -0,0 +1,266 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart' hide TextStyle; +import 'package:flutter/widgets.dart' hide TextStyle; +import 'package:provider/provider.dart'; + +import '../auto_dispose_mixin.dart'; +import '../charts/treemap.dart'; + +import 'memory_controller.dart'; +import 'memory_graph_model.dart'; +import 'memory_utils.dart'; + +class MemoryTreemap extends StatefulWidget { + const MemoryTreemap(this.controller); + + final MemoryController controller; + + @override + MemoryTreemapState createState() => MemoryTreemapState(controller); +} + +class MemoryTreemapState extends State<MemoryTreemap> with AutoDisposeMixin { + MemoryTreemapState(this.controller); + + InstructionsSize sizes; + + Map<String, Function> callbacks = {}; + + MemoryController controller; + + Widget snapshotDisplay; + + TreemapNode root; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // TODO(terry): Unable to short-circuit need to investigate why? + controller = Provider.of<MemoryController>(context); + + sizes = InstructionsSize.fromSnapshot(controller); + + root = sizes.root; + + cancel(); + + addAutoDisposeListener(controller.selectedSnapshotNotifier, () { + setState(() { + controller.computeAllLibraries(true, true); + + sizes = InstructionsSize.fromSnapshot(controller); + }); + }); + + addAutoDisposeListener(controller.filterNotifier, () { + setState(() { + controller.computeAllLibraries(true, true); + }); + }); + // TODO(peterdjlee): Need to check if applicable to treemap. + // addAutoDisposeListener(controller.selectTheSearchNotifier, () { + // setState(() { + // if (_trySelectItem()) { + // closeAutoCompleteOverlay(); + // } + // }); + // }); + + // addAutoDisposeListener(controller.searchNotifier, () { + // setState(() { + // if (_trySelectItem()) { + // closeAutoCompleteOverlay(); + // } + // }); + // }); + + addAutoDisposeListener(controller.searchAutoCompleteNotifier, () { + setState(autoCompleteOverlaySetState(controller, context)); + }); + } + + void _onRootChanged(TreemapNode newRoot) { + setState(() { + root = newRoot; + }); + } + + @override + Widget build(BuildContext context) { + if (sizes != null) { + return LayoutBuilder( + builder: (context, constraints) { + return Treemap.fromRoot( + rootNode: root, + levelsVisible: 2, + isOutermostLevel: true, + height: constraints.maxHeight, + onRootChangedCallback: _onRootChanged, + ); + }, + ); + } else { + return const SizedBox(); + } + } +} + +/// Definitions of exposed callback methods stored in callback Map the key +/// is the function name (String) and the value a callback function signature. + +/// matchNames callback name. +const matchNamesKey = 'matchNames'; + +/// matchNames callback signature. +typedef MatchNamesFunction = List<String> Function(String); + +/// findNode callback name. +const findNodeKey = 'findNode'; + +/// findNode callback signature. +typedef FindNodeFunction = TreemapNode Function(String); + +/// selectNode callback name. +const selectNodeKey = 'selectNode'; + +/// selectNode callback signature. +typedef SelectNodeFunction = void Function(TreemapNode); + +class InstructionsSize { + const InstructionsSize(this.root); + + factory InstructionsSize.fromSnapshot(MemoryController controller) { + final rootChildren = <String, TreemapNode>{}; + final root = TreemapNode( + name: 'root', + childrenMap: rootChildren, + ); + TreemapNode currentParent = root; + + // TODO(terry): Should treemap be all memory or just the filtered group? + // Using rawGroup not graph.groupByLibrary. + + controller.heapGraph.rawGroupByLibrary.forEach( + (libraryGroup, value) { + final classes = value; + for (final theClass in classes) { + final shallowSize = theClass.instancesTotalShallowSizes; + var className = theClass.name; + if (shallowSize == 0 || + libraryGroup == null || + className == null || + className == '::') { + continue; + } + + // Ensure the empty library name is our group name e.g., '' -> 'src'. + String libraryName = theClass.libraryUri.toString(); + if (libraryName.isEmpty) { + libraryName = libraryGroup; + } + + // Map class names to familar user names. + final predefined = + predefinedClasses[LibraryClass(libraryName, className)]; + if (predefined != null) { + className = predefined.prettyName; + } + + final symbol = Symbol( + name: 'new $className', + size: shallowSize, + libraryUri: libraryName, + className: className, + ); + + Map<String, TreemapNode> currentChildren = rootChildren; + final parentReset = currentParent; + for (String pathPart in symbol.parts) { + currentChildren.putIfAbsent( + pathPart, + () { + final node = TreemapNode( + name: pathPart, + childrenMap: <String, TreemapNode>{}, + ); + currentParent.addChild(node); + return node; + }, + ); + currentChildren[pathPart].byteSize += symbol.size; + currentParent = currentChildren[pathPart]; + currentChildren = currentChildren[pathPart].childrenMap; + } + currentParent = parentReset; + } + }, + ); + + // Get sum of children's sizes. + root.byteSize = root.childrenMap.values + .fold(0, (int current, TreemapNode node) => current + node.byteSize); + + final snapshotGraph = controller.snapshots.last.snapshotGraph; + // Add the external heap to the treemap. + root.childrenMap.putIfAbsent('External Heap', () { + final node = TreemapNode( + name: 'External Heap', + childrenMap: <String, TreemapNode>{}, + )..byteSize = snapshotGraph.externalSize; + root.addChild(node); + return node; + }); + + // Add the filtered libraries/classes to the treemap. + root.childrenMap.putIfAbsent('All Filtered Libraries', () { + final node = TreemapNode( + name: 'All Filtered Libraries', + childrenMap: <String, TreemapNode>{}, + )..byteSize = snapshotGraph.shallowSize - root.byteSize; + root.addChild(node); + return node; + }); + + root.byteSize = snapshotGraph.shallowSize + snapshotGraph.externalSize; + + return InstructionsSize(root); + } + + final TreemapNode root; +} + +class Symbol { + const Symbol({ + @required this.name, + @required this.size, + this.libraryUri, + this.className, + }) : assert(name != null), + assert(size != null); + + static Symbol fromMap(Map<String, dynamic> json) { + return Symbol( + name: json['n'] as String, + size: json['s'] as int, + className: json['c'] as String, + libraryUri: json['l'] as String, + ); + } + + final String name; + final int size; + final String libraryUri; + final String className; + + List<String> get parts { + return <String>[ + if (libraryUri != null) ...libraryUri.split('/') else '@stubs', + if (className != null && className.isNotEmpty) className, + name, + ]; + } +}
diff --git a/packages/devtools_app/lib/src/ui/colors.dart b/packages/devtools_app/lib/src/ui/colors.dart index 3096989..3a7e00b 100644 --- a/packages/devtools_app/lib/src/ui/colors.dart +++ b/packages/devtools_app/lib/src/ui/colors.dart
@@ -13,6 +13,7 @@ Color memoryHeatMapLightColor = const Color(0xFFBBDEFB); // Material BLUE 100 Color memoryHeatMapDarkColor = const Color(0xFF0D47A1); // Material BLUE 900 +// TODO(peterdjlee): Rename mainUiColor to something that more broadly matches where the color is used. const mainUiColor = Color(0xFF88B1DE); const mainRasterColor = Color(0xFF2C5DAA); const mainUnknownColor = Color(0xFFCAB8E9);
diff --git a/packages/devtools_app/lib/src/utils.dart b/packages/devtools_app/lib/src/utils.dart index 51d86e8..ed39357 100644 --- a/packages/devtools_app/lib/src/utils.dart +++ b/packages/devtools_app/lib/src/utils.dart
@@ -64,17 +64,34 @@ String percent2(double d) => '${(d * 100).toStringAsFixed(2)}%'; +String prettyPrintBytes(int size, {bool includeUnit = false}) { + final sizeInKB = size / 1024.0; + if (sizeInKB < 1024.0) { + return '${printKB(size, includeUnit: includeUnit)}'; + } else { + return '${printMB(size, fractionDigits: 2, includeUnit: includeUnit)}'; + } +} + final NumberFormat _kbPattern = NumberFormat.decimalPattern() ..maximumFractionDigits = 0; -String printKb(num bytes) { +String printKB(num bytes, {bool includeUnit = false}) { // We add ((1024/2)-1) to the value before formatting so that a non-zero byte // value doesn't round down to 0. - return _kbPattern.format((bytes + 511) / 1024); + var output = _kbPattern.format((bytes + 511) / 1024); + if (includeUnit) { + output += ' KB'; + } + return output; } -String printMb(num bytes, [int fractionDigits = 1]) { - return (bytes / (1024 * 1024.0)).toStringAsFixed(fractionDigits); +String printMB(num bytes, {int fractionDigits = 1, bool includeUnit = false}) { + var output = (bytes / (1024 * 1024.0)).toStringAsFixed(fractionDigits); + if (includeUnit) { + output += ' MB'; + } + return output; } String msText(
diff --git a/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift index 8236f57..4618f38 100644 --- a/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import path_provider_macos import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) }
diff --git a/packages/devtools_app/test/timeline_flame_chart_test.dart b/packages/devtools_app/test/timeline_flame_chart_test.dart index 4052631..5a43356 100644 --- a/packages/devtools_app/test/timeline_flame_chart_test.dart +++ b/packages/devtools_app/test/timeline_flame_chart_test.dart
@@ -69,10 +69,7 @@ await pumpTimelineBody(tester, TimelineController()); await tester.pumpAndSettle(); expect(find.byType(TimelineFlameChart), findsNothing); - expect( - find.byKey(TimelineScreen.emptyTimelineKey), - findsOneWidget, - ); + expect(find.byKey(TimelineScreen.emptyTimelineKey), findsOneWidget); }); testWidgetsWithWindowSize( @@ -85,6 +82,7 @@ await tester.pumpAndSettle(); expect(find.byType(TimelineFlameChart), findsOneWidget); +<<<<<<< HEAD await expectLater( find.byType(TimelineFlameChart), matchesGoldenFile( @@ -93,5 +91,12 @@ // Await delay for golden comparison. await tester.pumpAndSettle(const Duration(seconds: 2)); }, skip: kIsWeb || !Platform.isMacOS); +======= + expect( + find.byType(TimelineFlameChart), + matchesGoldenFile( + 'goldens/timeline_flame_chart_with_selected_frame.png')); + }); +>>>>>>> 4191e172... Fix merge conflict }); }
diff --git a/packages/devtools_app/test/utils_test.dart b/packages/devtools_app/test/utils_test.dart index daaf198..91dd59a 100644 --- a/packages/devtools_app/test/utils_test.dart +++ b/packages/devtools_app/test/utils_test.dart
@@ -11,27 +11,40 @@ void main() { group('utils', () { + test('prettyPrintBytes', () { + const int kb = 1024; + const int mb = 1024 * kb; + + expect(prettyPrintBytes(kb), '1'); + expect(prettyPrintBytes(kb, includeUnit: true), '1 KB'); + expect(prettyPrintBytes(kb * 1000, includeUnit: true), '1,000 KB'); + + expect(prettyPrintBytes(mb), '1.00'); + expect(prettyPrintBytes(mb, includeUnit: true), '1.00 MB'); + expect(prettyPrintBytes(mb - kb, includeUnit: true), '1,023 KB'); + }); + test('printKb', () { const int kb = 1024; - expect(printKb(0), '0'); - expect(printKb(1), '1'); - expect(printKb(kb - 1), '1'); - expect(printKb(kb), '1'); - expect(printKb(kb + 1), '2'); - expect(printKb(2000), '2'); + expect(printKB(0), '0'); + expect(printKB(1), '1'); + expect(printKB(kb - 1), '1'); + expect(printKB(kb), '1'); + expect(printKB(kb + 1), '2'); + expect(printKB(2000), '2'); }); test('printMb', () { - const int MB = 1024 * 1024; + const int mb = 1024 * 1024; - expect(printMb(10 * MB, 0), '10'); - expect(printMb(10 * MB), '10.0'); - expect(printMb(10 * MB, 2), '10.00'); + expect(printMB(10 * mb, fractionDigits: 0), '10'); + expect(printMB(10 * mb), '10.0'); + expect(printMB(10 * mb, fractionDigits: 2), '10.00'); - expect(printMb(1000 * MB, 0), '1000'); - expect(printMb(1000 * MB), '1000.0'); - expect(printMb(1000 * MB, 2), '1000.00'); + expect(printMB(1000 * mb, fractionDigits: 0), '1000'); + expect(printMB(1000 * mb), '1000.0'); + expect(printMB(1000 * mb, fractionDigits: 2), '1000.00'); }); test('msAsText', () {