Reduce memory used when saving Performance data for offline viewing (#10034)

* Reduce memory used when saving Performance data for offline viewing

The Perfetto trace was exported as a JSON array of numbers, which makes
jsonEncode do roughly two string writes per trace byte and can exhaust the
browser's memory for large traces. Export it as a base64 string under a new
key instead, and keep reading the legacy list format so older files still
load.

Also:
- Only encode the bytes in a ByteData view when converting to base64.
- Allocate Uint8ListRingBuffer.merged once at its exact size.
- Show an error instead of crashing when the data is too large to export.
- Revoke the download Blob URL after the download starts.

Fixes #10010

* Link the PR in the release note

* Address review feedback on performance export

---------

Co-authored-by: Sam Rawlins <srawlins@google.com>
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 89c3107..100eab0 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 @@
     );
   }
 
+  /// 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 @@
 
   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<String, Object?> 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,21 @@
 
 extension type _PerformanceDataJson(Map<String, Object?> json) {
   Uint8List? get traceBinary {
-    final value = (json[OfflinePerformanceData.traceBinaryKey] as List?)
-        ?.cast<int>();
-    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 List<Object?> numbers => Uint8List.fromList(numbers.cast<int>()),
+      _ => 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 d787087..f000ebc 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 @@
   return ExportControllerWeb();
 }
 
+const _revokeObjectUrlDelay = Duration(seconds: 1);
+
 class ExportControllerWeb extends ExportController {
   ExportControllerWeb() : super.impl();
 
@@ -29,11 +32,16 @@
       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 a7c6f48..c0be579 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,22 @@
   /// 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(),
+      );
+    } catch (e) {
+      // Encoding fails when the data is too large for a single JSON string (the
+      // maximum string length in the browser is about 2^29 characters). The
+      // exact error type depends on the compiler (dart2js vs. dart2wasm), so
+      // catch everything rather than a specific type.
+      notificationService.pushError(
+        'Unable to export this screen\'s data. It may be too large to export: '
+        '$e',
+      );
+      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 c6dd14f..e9e4de1 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 @@
   /// 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 a787dd9..ff22ad1 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 @@
 
   @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 7d47c6b..7e9c505 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -24,7 +24,12 @@
 
 ## Performance updates
 
-TODO: Remove this section if there are not any updates.
+* 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 7dade0f..93da445 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 @@
       expect(
         offlineData.toJson(),
         equals({
-          OfflinePerformanceData.traceBinaryKey: null,
+          OfflinePerformanceData.traceBinaryBase64Key: null,
           OfflinePerformanceData.flutterFramesKey: <Object?>[],
           OfflinePerformanceData.selectedFrameIdKey: null,
           OfflinePerformanceData.displayRefreshRateKey: 60,
@@ -51,7 +55,84 @@
       );
 
       offlineData = OfflinePerformanceData.fromJson(rawPerformanceData);
-      expect(offlineData.toJson(), rawPerformanceData);
+      final json = offlineData.toJson();
+      final expectedTrace = Uint8List.fromList(
+        (rawPerformanceData[OfflinePerformanceData.traceBinaryKey] as List)
+            .cast<int>(),
+      );
+      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<String, Object?>;
+        expect(
+          json[OfflinePerformanceData.traceBinaryBase64Key],
+          isA<String>(),
+        );
+        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<String, Object?>,
+        );
+        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('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 cae53d1..edfabe6 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 @@
     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;