@@ -17,6 +17,15 @@ import '../../shared/primitives/byte_utils.dart';
1717import '../../shared/utils/utils.dart' ;
1818import '_memory_desktop.dart' if (dart.library.js_interop) '_memory_web.dart' ;
1919
20+ /// The result of a request to [MemoryObserver.reduceMemory] .
21+ ///
22+ /// `fromBytes` - total memory usage before the reduction request.
23+ /// `toBytes` - total memory usage after the reduction request is completed.
24+ /// `success` - whether the reduction in memory brought DevTools memory usage
25+ /// below the threshold [MemoryObserver._memoryPressureLimitGb] .
26+ typedef ReduceMemoryResult =
27+ ({bool success, int ? fromBytes, int ? toBytes, String ? error});
28+
2029/// Observes the memory usage of the DevTools app (web only) and shows a memory
2130/// pressure warning to users when DevTools is nearing the memory limit.
2231///
@@ -40,6 +49,12 @@ class MemoryObserver extends DisposableController {
4049
4150 DebounceTimer ? _timer;
4251
52+ /// Tracks the most recent memory usage measurement.
53+ ///
54+ /// This value is updated each time [MemoryObserver._memoryExceedsThreshold]
55+ /// is called.
56+ static int ? _lastMemoryUsageInBytes;
57+
4358 @override
4459 void init () {
4560 super .init ();
@@ -56,14 +71,9 @@ class MemoryObserver extends DisposableController {
5671 Future <void > _pollForMemoryUsage ({
5772 DebounceCancelledCallback ? cancelledCallback,
5873 }) async {
59- final memoryUsageInBytes =
60- _debugMeasureUsageInBytes != null
61- ? await _debugMeasureUsageInBytes ()
62- : await measureMemoryUsageInBytes ();
63- if (memoryUsageInBytes == null ) return ;
64-
65- final memoryInGb = convertBytes (memoryUsageInBytes, to: ByteUnit .gb);
66- if (memoryInGb > _memoryPressureLimitGb) {
74+ if (await _memoryExceedsThreshold (
75+ debugMeasureUsageInBytes: _debugMeasureUsageInBytes,
76+ )) {
6777 final gaScreen = DevToolsRouterDelegate .currentPage ?? gac.devToolsMain;
6878 ga.impression (gaScreen, gac.memoryPressure);
6979 bannerMessages.addMessage (
@@ -72,14 +82,76 @@ class MemoryObserver extends DisposableController {
7282 );
7383 }
7484 }
85+
86+ static Future <bool > _memoryExceedsThreshold ({
87+ @visibleForTesting Future <int ?> Function ()? debugMeasureUsageInBytes,
88+ }) async {
89+ final memoryUsageInBytes =
90+ debugMeasureUsageInBytes != null
91+ ? await debugMeasureUsageInBytes ()
92+ : await measureMemoryUsageInBytes ();
93+ _lastMemoryUsageInBytes = memoryUsageInBytes;
94+ if (memoryUsageInBytes == null ) return false ;
95+
96+ final memoryInGb = convertBytes (memoryUsageInBytes, to: ByteUnit .gb);
97+ return memoryInGb > _memoryPressureLimitGb;
98+ }
99+
100+ /// Attempts to reduce the memory footprint of DevTools by releasing memory
101+ /// from unused DevTools screens and, if necessary, releasing partial memory
102+ /// from the current screen in use.
103+ ///
104+ /// Returns a [ReduceMemoryResult] containing metadata and a success result.
105+ static Future <ReduceMemoryResult > reduceMemory ({
106+ @visibleForTesting Future <int ?> Function ()? debugMeasureUsageInBytes,
107+ }) async {
108+ final fromBytes = _lastMemoryUsageInBytes;
109+ await screenControllers.forEachInitializedAsync ((screenController) async {
110+ if (DevToolsRouterDelegate .currentPage != screenController.screenId) {
111+ // If we need to release more memory, we can consider disposing the
112+ // screen controllers too. This would revert the controller back to it's
113+ // lazy initialized state, waiting to be re-initialized upon first use.
114+ await screenController.releaseMemory ();
115+ }
116+ });
117+
118+ // TODO(kenz): clear other potential sources of memory bloat such as the
119+ // console history or caches like the resolved URI manager.
120+
121+ if (await _memoryExceedsThreshold (
122+ debugMeasureUsageInBytes: debugMeasureUsageInBytes,
123+ )) {
124+ await screenControllers.forEachInitializedAsync ((screenController) async {
125+ if (DevToolsRouterDelegate .currentPage == screenController.screenId) {
126+ // If memory usage still exceeds the threshold, perform a partial
127+ // release of memory on the current screen. This is more disruptive
128+ // to the user, so only do this if releasing memory from every other
129+ // screen first did not work.
130+ await screenController.releaseMemory (partial: true );
131+ }
132+ });
133+ }
134+
135+ final success =
136+ ! (await _memoryExceedsThreshold (
137+ debugMeasureUsageInBytes: debugMeasureUsageInBytes,
138+ ));
139+ final toBytes = _lastMemoryUsageInBytes;
140+ return (
141+ success: success,
142+ fromBytes: fromBytes! ,
143+ toBytes: toBytes! ,
144+ error: null ,
145+ );
146+ }
75147}
76148
77149class _MemoryPressureBannerMessage extends banner_messages.BannerWarning {
78150 _MemoryPressureBannerMessage ()
79151 : super (
80152 screenId: banner_messages.universalScreenId,
81153 key: _messageKey,
82- buildTextSpans: (_ ) {
154+ buildTextSpans: (context ) {
83155 final limitAsBytes = convertBytes (
84156 MemoryObserver ._memoryPressureLimitGb,
85157 from: ByteUnit .gb,
@@ -92,24 +164,242 @@ class _MemoryPressureBannerMessage extends banner_messages.BannerWarning {
92164 '${printBytes (limitAsBytes , unit : ByteUnit .gb , includeUnit : true )}. '
93165 'Consider releasing memory by clearing data you are no '
94166 'longer analyzing, or by clicking "Reduce memory" below, '
95- 'which will make a best-effort attempt to clear stale data. '
96- 'If you do not take action, DevTools may eventually crash '
97- 'due to an out of memory error (OOM).' ,
167+ 'which will make a ' ,
168+ children: [
169+ TextSpan (
170+ text: 'best-effort attempt' ,
171+ style: Theme .of (context).boldTextStyle,
172+ ),
173+ const TextSpan (
174+ text:
175+ ' to clear stale data. If you do not take action, '
176+ 'DevTools may eventually crash due to an out of memory '
177+ 'error (OOM).\n\n '
178+ 'WARNING: clicking "Reduce memory" will clear data from '
179+ 'other DevTools screens and may partially clear data '
180+ 'from the screen you are currently using. Consider '
181+ 'saving data from other DevTools screens, where '
182+ 'supported, if you do not want to lose data.' ,
183+ ),
184+ ],
98185 ),
99186 ];
100187 },
101188 buildActions:
102189 (_) => [
103- DevToolsButton (
104- label: 'Reduce memory' ,
105- onPressed: () {
106- ga.select (gac.devToolsMain, gac.memoryPressureReduce);
107- // TODO(https://github.com/flutter/devtools/issues/7002): add
108- // support to screen controllers to reduce memory.
109- },
110- ),
190+ // Wrapping with an `Expanded` is okay because this list is set as
191+ // the `children` parameter of a `Row` widget in `BannerMessage`.
192+ const Expanded (child: _ReduceMemoryButton ()),
111193 ],
112194 );
113195
114196 static const _messageKey = Key ('MemoryPressureBannerMessage' );
115197}
198+
199+ class _ReduceMemoryButton extends StatefulWidget {
200+ const _ReduceMemoryButton ();
201+
202+ @override
203+ State <_ReduceMemoryButton > createState () => _ReduceMemoryButtonState ();
204+ }
205+
206+ class _ReduceMemoryButtonState extends State <_ReduceMemoryButton > {
207+ bool inProgress = false ;
208+
209+ final result = ValueNotifier <ReduceMemoryResult ?>(null );
210+
211+ @override
212+ void dispose () {
213+ result.dispose ();
214+ super .dispose ();
215+ }
216+
217+ @override
218+ Widget build (BuildContext context) {
219+ final colorScheme = Theme .of (context).colorScheme;
220+ return Row (
221+ mainAxisSize: MainAxisSize .min,
222+ children: [
223+ DevToolsButton (
224+ label: 'Reduce memory' ,
225+ onPressed: _onPressed,
226+ color: colorScheme.onTertiaryContainer,
227+ ),
228+ Flexible (
229+ child: Padding (
230+ padding: const EdgeInsets .symmetric (horizontal: denseSpacing),
231+ child:
232+ inProgress
233+ ? SizedBox (
234+ height: actionsIconSize,
235+ width: actionsIconSize,
236+ child: const CircularProgressIndicator (),
237+ )
238+ : ValueListenableBuilder (
239+ valueListenable: result,
240+ builder: (context, result, _) {
241+ return _SuccessOrFailureMessage (result: result);
242+ },
243+ ),
244+ ),
245+ ),
246+ ],
247+ );
248+ }
249+
250+ Future <void > _onPressed () async {
251+ ga.select (gac.devToolsMain, gac.memoryPressureReduce);
252+ setState (() {
253+ inProgress = true ;
254+ result.value = null ;
255+ });
256+ ReduceMemoryResult ? reduceMemoryResult;
257+ try {
258+ reduceMemoryResult = await MemoryObserver .reduceMemory ();
259+ } catch (e) {
260+ reduceMemoryResult = (
261+ success: false ,
262+ fromBytes: null ,
263+ toBytes: null ,
264+ error: e.toString (),
265+ );
266+ } finally {
267+ setState (() {
268+ inProgress = false ;
269+ result.value = reduceMemoryResult;
270+ });
271+ }
272+ }
273+ }
274+
275+ class _SuccessOrFailureMessage extends StatefulWidget {
276+ const _SuccessOrFailureMessage ({required this .result});
277+
278+ final ReduceMemoryResult ? result;
279+
280+ @override
281+ State <_SuccessOrFailureMessage > createState () =>
282+ _SuccessOrFailureMessageState ();
283+ }
284+
285+ class _SuccessOrFailureMessageState extends State <_SuccessOrFailureMessage > {
286+ static const _startingDismissCountDown = 5 ;
287+
288+ int dismissCountDown = _startingDismissCountDown;
289+
290+ Timer ? _timer;
291+
292+ @override
293+ void initState () {
294+ super .initState ();
295+ _init ();
296+ }
297+
298+ @override
299+ void didUpdateWidget (covariant _SuccessOrFailureMessage oldWidget) {
300+ super .didUpdateWidget (oldWidget);
301+ if (oldWidget.result != widget.result) {
302+ _init ();
303+ }
304+ }
305+
306+ void _init () {
307+ final result = widget.result;
308+ if (result != null && result.success) {
309+ dismissCountDown = _startingDismissCountDown;
310+ _timer? .cancel ();
311+ _timer = Timer .periodic (const Duration (seconds: 1 ), (_) {
312+ if (dismissCountDown <= 1 ) {
313+ _timer? .cancel ();
314+ _timer = null ;
315+ bannerMessages.removeMessageByKey (
316+ _MemoryPressureBannerMessage ._messageKey,
317+ banner_messages.universalScreenId,
318+ );
319+ }
320+ setState (() {
321+ dismissCountDown-- ;
322+ });
323+ });
324+ }
325+ }
326+
327+ @override
328+ void dispose () {
329+ _timer? .cancel ();
330+ _timer = null ;
331+ super .dispose ();
332+ }
333+
334+ @override
335+ Widget build (BuildContext context) {
336+ final theme = Theme .of (context);
337+ final color = theme.colorScheme.onTertiaryContainer;
338+
339+ final result = widget.result;
340+ if (result == null ) return const SizedBox .shrink ();
341+
342+ String message;
343+ if (result.error != null ) {
344+ message =
345+ 'Attempt to reduce memory was unsuccessful. Error: ${result .error }' ;
346+ } else {
347+ assert (result.fromBytes != null && result.toBytes != null );
348+ final fromBytesAsString = printBytes (
349+ result.fromBytes! ,
350+ fractionDigits: 2 ,
351+ unit: ByteUnit .gb,
352+ includeUnit: true ,
353+ );
354+ final toBytesAsString = printBytes (
355+ result.toBytes! ,
356+ fractionDigits: 2 ,
357+ unit: ByteUnit .gb,
358+ includeUnit: true ,
359+ );
360+
361+ if (result.success) {
362+ message =
363+ 'Successfully reduced memory from $fromBytesAsString to '
364+ '$toBytesAsString . This warning will automatically dismiss in '
365+ '$dismissCountDown seconds.' ;
366+ } else {
367+ final limitAsBytes = convertBytes (
368+ MemoryObserver ._memoryPressureLimitGb,
369+ from: ByteUnit .gb,
370+ to: ByteUnit .byte,
371+ );
372+ final limitBytesAsString = printBytes (
373+ limitAsBytes,
374+ unit: ByteUnit .gb,
375+ includeUnit: true ,
376+ );
377+ message =
378+ 'Attempt to reduce memory was unsuccessful. Memory was reduced from '
379+ '$fromBytesAsString to $toBytesAsString , but the total memory still '
380+ 'exceeds the $limitBytesAsString threshold.' ;
381+ }
382+ }
383+
384+ return RichText (
385+ text: TextSpan (
386+ children: [
387+ WidgetSpan (
388+ child: Padding (
389+ padding: const EdgeInsets .only (right: denseSpacing),
390+ child: Icon (
391+ result.success ? Icons .check : Icons .close,
392+ size: actionsIconSize,
393+ color: color,
394+ ),
395+ ),
396+ ),
397+ TextSpan (
398+ text: message,
399+ style: theme.regularTextStyleWithColor (color),
400+ ),
401+ ],
402+ ),
403+ );
404+ }
405+ }
0 commit comments