Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand All @@ -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<String, Object?> toJson() => {
traceBinaryKey: perfettoTraceBinary,
traceBinaryBase64Key: perfettoTraceBinary == null
? null
: ByteData.sublistView(perfettoTraceBinary!),
flutterFramesKey: frames.map((frame) => frame.json).toList(),
selectedFrameIdKey: selectedFrame?.id,
displayRefreshRateKey: displayRefreshRate,
Expand All @@ -79,9 +103,22 @@ class OfflinePerformanceData {

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 Uint8List bytes => bytes,
final List<Object?> numbers => Uint8List.fromList(numbers.cast<int>()),
_ => null,
};
}

int? get selectedFrameId =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,6 +14,8 @@ ExportControllerWeb createExportController() {
return ExportControllerWeb();
}

const _revokeObjectUrlDelay = Duration(seconds: 1);

class ExportControllerWeb extends ExportController {
ExportControllerWeb() : super.impl();

Expand All @@ -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));
}
}
17 changes: 14 additions & 3 deletions packages/devtools_app/lib/src/shared/offline/offline_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,20 @@ mixin OfflineScreenControllerMixin<T>
/// 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);
}

Expand Down
17 changes: 11 additions & 6 deletions packages/devtools_app/lib/src/shared/primitives/byte_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ class ByteDataEncodeDecode extends EncodeDecode<ByteData> {

@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
Expand Down
6 changes: 6 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,7 +45,7 @@ void main() {
expect(
offlineData.toJson(),
equals({
OfflinePerformanceData.traceBinaryKey: null,
OfflinePerformanceData.traceBinaryBase64Key: null,
OfflinePerformanceData.flutterFramesKey: <Object?>[],
OfflinePerformanceData.selectedFrameIdKey: null,
OfflinePerformanceData.displayRefreshRateKey: 60,
Expand All @@ -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<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('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', () {
Expand Down
11 changes: 11 additions & 0 deletions packages/devtools_app/test/shared/primitives/encoding_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
Loading