diff --git a/packages/devtools_app/lib/src/screens/performance/performance_model.dart b/packages/devtools_app/lib/src/screens/performance/performance_model.dart index 89c310747ff..4bb4feb6f12 100644 --- a/packages/devtools_app/lib/src/screens/performance/performance_model.dart +++ b/packages/devtools_app/lib/src/screens/performance/performance_model.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -41,7 +43,21 @@ class OfflinePerformanceData { ); } + /// Legacy key for the Perfetto trace, stored as a JSON array of numbers. + /// + /// Encoding every byte as a JSON number is extremely memory intensive for + /// large traces (see https://github.com/flutter/devtools/issues/10010), so + /// this key is only read for backwards compatibility with files exported by + /// older versions of DevTools. New exports use [traceBinaryBase64Key]. static const traceBinaryKey = 'traceBinary'; + + /// Key for the Perfetto trace, stored as a base64 encoded string once the + /// data is encoded as JSON. + /// + /// This is intentionally a different key than [traceBinaryKey] so that older + /// versions of DevTools, which expect a JSON array under [traceBinaryKey], + /// treat a new file as having no trace instead of throwing a type error. + static const traceBinaryBase64Key = 'traceBinaryBase64'; static const rebuildCountModelKey = 'rebuildCountModel'; static const displayRefreshRateKey = 'displayRefreshRate'; static const flutterFramesKey = 'flutterFrames'; @@ -67,8 +83,16 @@ class OfflinePerformanceData { bool get isEmpty => perfettoTraceBinary == null; + /// The JSON serializable form of this data. + /// + /// The trace is stored as a [ByteData] view of [perfettoTraceBinary] rather + /// than being encoded here, so that this method does not copy the trace. + /// `toEncodable` converts the [ByteData] to a base64 string when the data is + /// encoded as JSON for export. Map toJson() => { - traceBinaryKey: perfettoTraceBinary, + traceBinaryBase64Key: perfettoTraceBinary == null + ? null + : ByteData.sublistView(perfettoTraceBinary!), flutterFramesKey: frames.map((frame) => frame.json).toList(), selectedFrameIdKey: selectedFrame?.id, displayRefreshRateKey: displayRefreshRate, @@ -79,9 +103,22 @@ class OfflinePerformanceData { extension type _PerformanceDataJson(Map json) { Uint8List? get traceBinary { - final value = (json[OfflinePerformanceData.traceBinaryKey] as List?) - ?.cast(); - return value == null ? null : Uint8List.fromList(value); + final base64Trace = json[OfflinePerformanceData.traceBinaryBase64Key]; + switch (base64Trace) { + case final String encoded: + return base64Decode(encoded); + case final ByteData data: + return Uint8List.sublistView(data); + } + + // Files exported by older versions of DevTools store the trace as a list of + // numbers. + final legacyTrace = json[OfflinePerformanceData.traceBinaryKey]; + return switch (legacyTrace) { + final Uint8List bytes => bytes, + final List numbers => Uint8List.fromList(numbers.cast()), + _ => null, + }; } int? get selectedFrameId => diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart index d78708747ce..f000ebc1a8b 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; import 'dart:js_interop'; import 'dart:typed_data'; @@ -13,6 +14,8 @@ ExportControllerWeb createExportController() { return ExportControllerWeb(); } +const _revokeObjectUrlDelay = Duration(seconds: 1); + class ExportControllerWeb extends ExportController { ExportControllerWeb() : super.impl(); @@ -29,11 +32,16 @@ class ExportControllerWeb extends ExportController { throw 'Unsupported content type: $T'; } - element.setAttribute('href', URL.createObjectURL(blob)); + final objectUrl = URL.createObjectURL(blob); + element.setAttribute('href', objectUrl); element.setAttribute('download', fileName); element.style.display = 'none'; (document.body as HTMLBodyElement).append(element); element.click(); element.remove(); + + // Release the memory held by the blob. Revoking synchronously can cancel + // the download in some browsers, so wait for the download to start. + Timer(_revokeObjectUrlDelay, () => URL.revokeObjectURL(objectUrl)); } } diff --git a/packages/devtools_app/lib/src/shared/offline/offline_data.dart b/packages/devtools_app/lib/src/shared/offline/offline_data.dart index a7c6f48ff91..772288801c0 100644 --- a/packages/devtools_app/lib/src/shared/offline/offline_data.dart +++ b/packages/devtools_app/lib/src/shared/offline/offline_data.dart @@ -189,9 +189,20 @@ mixin OfflineScreenControllerMixin /// Exports the current screen data to a .json file and downloads the file to /// the user's Downloads directory. void exportData() { - final encodedData = _exportController.encode( - prepareOfflineScreenData().toJson(), - ); + final String encodedData; + try { + encodedData = _exportController.encode( + prepareOfflineScreenData().toJson(), + ); + } on RangeError catch (e) { + // The data is too large to be encoded as a single JSON string (the + // maximum string length in the browser is about 2^29 characters). + notificationService.pushError( + 'Unable to export this screen\'s data because it is too large: ' + '${e.message}', + ); + return; + } _exportController.downloadFile(encodedData); } diff --git a/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart b/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart index c6dd14f2c63..e9e4de14503 100644 --- a/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart +++ b/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart @@ -162,12 +162,17 @@ class Uint8ListRingBuffer { /// Merges all the data in this ring buffer into a single [Uint8List] and /// returns it. Uint8List get merged { - final allBytes = BytesBuilder(); - debugTimeSync( - () => data.forEach(allBytes.add), - debugName: 'Uint8ListRingBuffer.mergeAllData', - ); - return allBytes.takeBytes(); + // Allocate exactly once at the final size. A [BytesBuilder] over-allocates + // up to the next power of two, which is costly for very large traces. + final merged = Uint8List(size); + debugTimeSync(() { + var offset = 0; + for (final chunk in data) { + merged.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + }, debugName: 'Uint8ListRingBuffer.mergeAllData'); + return merged; } void clear() { diff --git a/packages/devtools_app/lib/src/shared/primitives/encoding.dart b/packages/devtools_app/lib/src/shared/primitives/encoding.dart index a787dd9a1e1..ff22ad15e33 100644 --- a/packages/devtools_app/lib/src/shared/primitives/encoding.dart +++ b/packages/devtools_app/lib/src/shared/primitives/encoding.dart @@ -52,8 +52,9 @@ class ByteDataEncodeDecode extends EncodeDecode { @override Object toEncodable(ByteData value) { - final list = value.buffer.asUint8List(); - return base64Encode(list); + // Use a view of just this data. The underlying buffer may be larger than + // the data represented by [value]. + return base64Encode(Uint8List.sublistView(value)); } @override diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 993227cb3ef..ea6ba4b7843 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -37,6 +37,12 @@ To learn more about DevTools, check out the * Added a message in the Performance panel when widget rebuild tracking is unavailable because the app is running in profile mode. [#9755](https://github.com/flutter/devtools/pull/9755) +* Reduced the memory used when saving Performance data for offline viewing, + and made the saved files smaller. Files saved with this version cannot be + opened in older versions of DevTools. Files saved with older versions can + still be opened. + [#10034](https://github.com/flutter/devtools/pull/10034), + [#10010](https://github.com/flutter/devtools/issues/10010) ## CPU profiler updates diff --git a/packages/devtools_app/test/screens/performance/performance_model_test.dart b/packages/devtools_app/test/screens/performance/performance_model_test.dart index 7dade0fd72f..d2fc5ca309d 100644 --- a/packages/devtools_app/test/screens/performance/performance_model_test.dart +++ b/packages/devtools_app/test/screens/performance/performance_model_test.dart @@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/primitives/encoding.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../test_infra/test_data/performance/sample_performance_data.dart'; @@ -41,7 +45,7 @@ void main() { expect( offlineData.toJson(), equals({ - OfflinePerformanceData.traceBinaryKey: null, + OfflinePerformanceData.traceBinaryBase64Key: null, OfflinePerformanceData.flutterFramesKey: [], OfflinePerformanceData.selectedFrameIdKey: null, OfflinePerformanceData.displayRefreshRateKey: 60, @@ -51,7 +55,91 @@ void main() { ); offlineData = OfflinePerformanceData.fromJson(rawPerformanceData); - expect(offlineData.toJson(), rawPerformanceData); + final json = offlineData.toJson(); + final expectedTrace = Uint8List.fromList( + (rawPerformanceData[OfflinePerformanceData.traceBinaryKey] as List) + .cast(), + ); + expect(json.containsKey(OfflinePerformanceData.traceBinaryKey), isFalse); + expect( + Uint8List.sublistView( + json[OfflinePerformanceData.traceBinaryBase64Key] as ByteData, + ), + equals(expectedTrace), + ); + expect( + {...json}..remove(OfflinePerformanceData.traceBinaryBase64Key), + {...rawPerformanceData}..remove(OfflinePerformanceData.traceBinaryKey), + ); + }); + + group('trace binary encoding', () { + final trace = Uint8List.fromList( + List.generate(1 << 20, (i) => (i * 31) % 256), + ); + + test('round trips through JSON as a base64 string', () { + final encoded = jsonEncode( + OfflinePerformanceData(perfettoTraceBinary: trace).toJson(), + toEncodable: toEncodable, + ); + final json = jsonDecode(encoded) as Map; + expect( + json[OfflinePerformanceData.traceBinaryBase64Key], + isA(), + ); + expect( + json.containsKey(OfflinePerformanceData.traceBinaryKey), + isFalse, + ); + // Base64 is ~1.33x the trace size, versus ~3.7x for a JSON number array. + expect(encoded.length, lessThan(trace.length * 1.5)); + + final parsed = OfflinePerformanceData.fromJson(json); + expect(parsed.perfettoTraceBinary, equals(trace)); + }); + + test('only encodes the bytes in a view of a larger buffer', () { + final largerBuffer = Uint8List(trace.length * 2) + ..setRange(0, trace.length, trace); + final view = Uint8List.sublistView(largerBuffer, 0, trace.length); + final encoded = jsonEncode( + OfflinePerformanceData(perfettoTraceBinary: view).toJson(), + toEncodable: toEncodable, + ); + final parsed = OfflinePerformanceData.fromJson( + jsonDecode(encoded) as Map, + ); + expect(parsed.perfettoTraceBinary, equals(trace)); + }); + + test('loads an in-memory ByteData without encoding', () { + final parsed = OfflinePerformanceData.fromJson( + OfflinePerformanceData(perfettoTraceBinary: trace).toJson(), + ); + expect(parsed.perfettoTraceBinary, equals(trace)); + }); + + test('loads the legacy list of numbers format', () { + final parsed = OfflinePerformanceData.fromJson({ + OfflinePerformanceData.traceBinaryKey: [1, 2, 3], + }); + expect( + parsed.perfettoTraceBinary, + equals(Uint8List.fromList([1, 2, 3])), + ); + }); + + test('loads a legacy Uint8List', () { + final parsed = OfflinePerformanceData.fromJson({ + OfflinePerformanceData.traceBinaryKey: trace, + }); + expect(parsed.perfettoTraceBinary, same(trace)); + }); + + test('is empty when no trace is present', () { + expect(OfflinePerformanceData.fromJson({}).isEmpty, isTrue); + }); }); test('round trips a non-zero selectedTab', () { diff --git a/packages/devtools_app/test/shared/primitives/encoding_test.dart b/packages/devtools_app/test/shared/primitives/encoding_test.dart index cae53d1f12f..edfabe623ac 100644 --- a/packages/devtools_app/test/shared/primitives/encoding_test.dart +++ b/packages/devtools_app/test/shared/primitives/encoding_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:convert'; +import 'dart:typed_data'; import 'package:devtools_app/src/shared/primitives/encoding.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,6 +23,16 @@ void main() { expect(decoded.objects.length, graph.objects.length); }); + test('$ByteDataEncodeDecode only encodes the bytes in the view', () { + final buffer = Uint8List.fromList([9, 1, 2, 3, 9]); + final view = ByteData.sublistView(buffer, 1, 4); + final encodeDecode = ByteDataEncodeDecode.instance; + + final decoded = encodeDecode.decode(encodeDecode.toEncodable(view)); + + expect(Uint8List.sublistView(decoded), [1, 2, 3]); + }); + test('$DateTimeEncodeDecode', () { final date = DateTime.now(); final encodeDecode = DateTimeEncodeDecode.instance;