blob: d22f7e70238d2087c23c6cc07eaf6ed089a406b6 [file] [log] [blame]
// Copyright 2018 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:convert';
import 'dart:html' as html;
import 'dart:math' as math;
import 'package:js/js.dart';
import 'package:meta/meta.dart';
import 'package:split/split.dart' as split;
import '../charts/flame_chart_canvas.dart';
import '../framework/framework.dart';
import '../globals.dart';
import '../service_extensions.dart';
import '../ui/analytics.dart' as ga;
import '../ui/analytics_platform.dart' as ga_platform;
import '../ui/custom.dart';
import '../ui/elements.dart';
import '../ui/icons.dart';
import '../ui/material_icons.dart';
import '../ui/primer.dart';
import '../ui/service_extension_elements.dart';
import '../ui/ui_utils.dart';
import '../ui/vm_flag_elements.dart';
import 'event_details.dart';
import 'frames_bar_chart.dart';
import 'timeline_controller.dart';
import 'timeline_flame_chart.dart';
import 'timeline_model.dart';
import 'timeline_protocol.dart';
// TODO(devoncarew): show the Skia picture (gpu drawing commands) for a frame
// TODO(devoncarew): show the list of widgets re-drawn during a frame
// TODO(devoncarew): display whether running in debug or profile
// TODO(devoncarew): Have a timeline view thumbnail overview.
// TODO(devoncarew): Switch to showing all timeline events, but highlighting the
// area associated with the selected frame.
const enableMultiModeTimeline = false;
class TimelineScreen extends Screen {
TimelineScreen({bool disabled, String disabledTooltip})
: super(
name: 'Timeline',
id: timelineScreenId,
iconClass: 'octicon-pulse',
disabled: disabled,
disabledTooltip: disabledTooltip,
);
TimelineController timelineController = TimelineController();
FramesBarChart framesBarChart;
CoreElement flameChartContainer;
TimelineFlameChartCanvas flameChartCanvas;
EventDetails eventDetails;
PButton pauseButton;
PButton resumeButton;
PButton _startRecordingButton;
PButton _stopRecordingButton;
PButton clearButton;
PButton exportButton;
PButton exitOfflineModeButton;
ServiceExtensionButton performanceOverlayButton;
ProfileGranularitySelector _profileGranularitySelector;
CoreElement _frameBasedTimelineSettingContainer;
CoreElement _frameBasedTimelineCheckbox;
CoreElement _recordingInstructions;
CoreElement _recordingStatus;
CoreElement _recordingStatusMessage;
CoreElement upperButtonSection;
CoreElement debugButtonSection;
split.Splitter splitter;
bool splitterConfigured = false;
@override
CoreElement createContent(Framework framework) {
ga_platform.setupDimensions();
final CoreElement screenDiv = div(c: 'custom-scrollbar')..layoutVertical();
pauseButton = PButton.icon('Pause recording', FlutterIcons.pause_white_2x)
..small()
..primary()
..hidden(timelineController.timelineMode == TimelineMode.full)
..click(_pauseFrameRecording);
resumeButton =
PButton.icon('Resume recording', FlutterIcons.resume_black_disabled_2x)
..small()
..clazz('margin-left')
..disabled = timelineController.manuallyPaused
..hidden(timelineController.timelineMode == TimelineMode.full)
..click(_resumeFrameRecording);
_startRecordingButton = PButton.icon('Record', recordPrimary)
..small()
..primary()
..hidden(timelineController.timelineMode == TimelineMode.frameBased)
..click(_startRecording);
_stopRecordingButton = PButton.icon('Stop', stop)
..small()
..clazz('margin-left')
..disabled = !timelineController.recording
..hidden(timelineController.timelineMode == TimelineMode.frameBased)
..click(_stopRecording);
_recordingInstructions = createRecordingInstructions(
recordingGoal: 'to start recording timeline trace.');
_recordingStatus = div(c: 'center-in-parent')
..layoutVertical()
..flex()
..add([
_recordingStatusMessage = div(c: 'recording-status-message'),
Spinner.centered(classes: ['recording-spinner']),
]);
exportButton = PButton.icon('Export', exportIcon)
..small()
..clazz('margin-left')
..setAttribute('title', 'Export timeline')
..click(_exportTimeline);
clearButton = PButton.icon('Clear', clearIcon)
..small()
..clazz('margin-left')
..setAttribute('title', 'Clear timeline')
..click(clearTimeline);
exitOfflineModeButton = PButton.icon(
'Exit offline mode',
exitIcon,
)
..small()
..setAttribute('title', 'Exit offline mode to connect to a VM Service.')
..setAttribute('hidden', 'true')
..click(_exitOfflineMode);
performanceOverlayButton = ServiceExtensionButton(performanceOverlay);
_profileGranularitySelector = ProfileGranularitySelector(framework);
_frameBasedTimelineCheckbox = CoreElement('input', classes: 'checkbox')
..setAttribute('type', 'checkbox');
final html.InputElement checkbox = _frameBasedTimelineCheckbox.element;
checkbox
..checked = timelineController.timelineMode == TimelineMode.frameBased
..onChange.listen((_) => _setTimelineMode(
timelineMode:
checkbox.checked ? TimelineMode.frameBased : TimelineMode.full));
_frameBasedTimelineSettingContainer = div(c: 'checkbox-container')
..layoutHorizontal()
..add([
_frameBasedTimelineCheckbox,
div(text: 'Show frames', c: 'checkbox-text')
]);
// TODO(kenzie): once [enableMultiModeTimeline] is enabled by default,
// adjust collapsible-xxx CSS classes to account for timeline mode checkbox.
upperButtonSection = div(c: 'section')
..layoutHorizontal()
..add(<CoreElement>[
div(c: 'btn-group collapsible-885')
..add([
pauseButton,
resumeButton,
_startRecordingButton,
_stopRecordingButton,
]),
div(c: 'btn-group collapsible-685')..add(clearButton),
exitOfflineModeButton,
div()..flex(),
debugButtonSection = div(c: 'btn-group'),
if (enableMultiModeTimeline) _frameBasedTimelineSettingContainer,
_profileGranularitySelector.selector..clazz('margin-left'),
div(c: 'btn-group collapsible-685 margin-left')
..add(performanceOverlayButton.button),
div(c: 'btn-group collapsible-685')..add(exportButton),
]);
_maybeAddDebugButtons();
screenDiv.add(<CoreElement>[
upperButtonSection,
framesBarChart = FramesBarChart(timelineController),
div(c: 'section')
..layoutVertical()
..flex()
..add(<CoreElement>[
flameChartContainer =
div(c: 'timeline-flame-chart-container section-border')
..flex()
..layoutVertical()
..hidden(true),
eventDetails = EventDetails(timelineController)..hidden(true),
]),
]);
maybeAddDebugMessage(framework, timelineScreenId);
return screenDiv;
}
@override
void onContentAttached() {
timelineController.onSelectedFrame.listen((_) {
flameChartContainer
..clear()
..hidden(false);
final TimelineFrame frame = timelineController.timelineData.selectedFrame;
flameChartCanvas = TimelineFlameChartCanvas(
data: frame,
width: flameChartContainer.element.clientWidth.toDouble(),
height: math.max(
// Subtract [rowHeightWithPadding] to account for timeline at the top of
// the flame chart.
flameChartContainer.element.clientHeight.toDouble(),
// Add 1 to account for a row of padding at the bottom of the chart.
(frame.uiEventFlow.depth + frame.gpuEventFlow.depth + 1) *
rowHeightWithPadding +
TimelineFlameChartCanvas.sectionSpacing,
),
);
flameChartCanvas.onNodeSelected.listen((node) {
eventDetails.titleBackgroundColor = node.backgroundColor;
eventDetails.titleTextColor = node.textColor;
timelineController.selectTimelineEvent(node.data);
});
flameChartContainer.add(flameChartCanvas.element);
_configureSplitter();
});
timelineController.onLoadOfflineData.listen((_) {
framesBarChart.hidden(false);
flameChartContainer..hidden(true);
_destroySplitter();
});
timelineController.onNonFatalError.listen((message) {
ga.error(message, false);
});
// The size of [flameChartContainer] will change as the splitter moved.
// Observe resizing so that we can rebuild the flame chart canvas as
// necessary.
// TODO(kenzie): clean this code up when
// https://github.com/dart-lang/html/issues/104 is fixed.
final observer =
html.ResizeObserver(allowInterop((List<dynamic> entries, _) {
// TODO(kenzie): observe resizing for recordedTimeline as well. Recorded
// timeline will not have a selected frame.
if (flameChartCanvas == null ||
timelineController.timelineMode == TimelineMode.full) {
return;
}
flameChartCanvas.forceRebuildForSize(
flameChartCanvas.widthWithInsets,
math.max(
// Subtract [rowHeightWithPadding] to account for the size of
// [stackFrameDetails] section at the bottom of the chart.
flameChartContainer.element.scrollHeight.toDouble(),
// Add 1 to account for a row of padding at the bottom of the chart.
(timelineController.timelineData.selectedFrame.uiEventFlow.depth +
timelineController
.timelineData.selectedFrame.gpuEventFlow.depth +
1) *
rowHeightWithPadding +
TimelineFlameChartCanvas.sectionSpacing,
),
);
}));
observer.observe(flameChartContainer.element);
}
void _configureSplitter() {
// Configure the flame chart / event details splitter if we haven't
// already.
if (!splitterConfigured) {
// TODO(jacobr): we need to tweak this layout so there is more room to
// display this UI. On typical devices, the space available is very
// limited making the UI harder to use than it would be otherwise.
splitter = split.flexSplit(
[flameChartContainer.element, eventDetails.element],
horizontal: false,
gutterSize: defaultSplitterWidth,
sizes: [75, 25],
minSize: [50, 50],
);
splitterConfigured = true;
}
}
void _destroySplitter() {
if (splitterConfigured) {
splitter.destroy();
splitterConfigured = false;
}
}
@override
void entering() {
_updateListeningState();
_updateButtonStates();
_profileGranularitySelector.setGranularity();
}
@override
void exiting() {
_updateListeningState();
_updateButtonStates();
}
void _exitOfflineMode() {
// This needs to be called first because [framework.exitOfflineMode()] will
// remove all elements from the dom if we are not connected to an app.
// Performing operations from [_clearTimeline()] on elements that have been
// removed will throw exceptions, so we need to maintain this order.
clearTimeline();
eventDetails.reset(hide: true);
timelineController.exitOfflineMode();
// This needs to be called before we update the button states because it
// changes the value of [offlineMode], which the button states depend on.
framework.exitOfflineMode();
// Revert to the previously selected mode on offline exit.
_setTimelineMode(timelineMode: timelineController.timelineMode);
_updateButtonStates();
}
Future<void> _pauseFrameRecording() async {
assert(timelineController.timelineMode == TimelineMode.frameBased);
timelineController.pause(manual: true);
ga.select(ga.timeline, ga.pause);
_updateButtonStates();
await _updateListeningState();
}
Future<void> _resumeFrameRecording() async {
assert(timelineController.timelineMode == TimelineMode.frameBased);
timelineController.resume();
ga.select(ga.timeline, ga.resume);
_updateButtonStates();
await _updateListeningState();
}
void _startRecording() {
assert(timelineController.timelineMode == TimelineMode.full);
timelineController.startRecording();
_recordingInstructions.hidden(true);
_recordingStatusMessage.text = 'Recording timeline trace';
_recordingStatus.hidden(false);
_updateButtonStates();
}
void _stopRecording() {
assert(timelineController.timelineMode == TimelineMode.full);
_recordingStatusMessage.text = 'Processing timeline trace';
timelineController.stopRecording();
_recordingStatus.hidden(true);
_updateButtonStates();
}
void _setTimelineMode({@required TimelineMode timelineMode}) {
// TODO(kenzie): the two modes should be aware of one another and we should
// share data. For simplicity, we will start by having each mode be aware of
// only its own data and clearing on mode switch.
timelineController.timelineData.clear();
timelineController.timelineMode = timelineMode;
_updateButtonStates();
// Update visibility and then reset - the order matters here.
framesBarChart
..hidden(timelineMode == TimelineMode.full)
..frameUIgraph.reset();
flameChartCanvas = null;
flameChartContainer
..clear()
..hidden(timelineMode == TimelineMode.frameBased);
if (timelineMode == TimelineMode.full) {
flameChartContainer.add([
_recordingInstructions..hidden(false),
_recordingStatus..hidden(true),
]);
}
eventDetails.reset(hide: timelineMode == TimelineMode.frameBased);
if (timelineMode == TimelineMode.frameBased) {
_destroySplitter();
} else {
_configureSplitter();
}
}
void _updateButtonStates() {
pauseButton
..disabled = timelineController.manuallyPaused
..hidden(
offlineMode || timelineController.timelineMode == TimelineMode.full);
resumeButton
..disabled = !timelineController.manuallyPaused
..hidden(
offlineMode || timelineController.timelineMode == TimelineMode.full);
_startRecordingButton
..disabled = timelineController.recording
..hidden(offlineMode ||
timelineController.timelineMode == TimelineMode.frameBased);
_stopRecordingButton
..disabled = !timelineController.recording
..hidden(offlineMode ||
timelineController.timelineMode == TimelineMode.frameBased);
_frameBasedTimelineCheckbox.disabled = timelineController.recording;
// TODO(kenzie): support loading offline data in both modes.
_frameBasedTimelineSettingContainer.hidden(offlineMode);
clearButton
..disabled = timelineController.recording
..hidden(offlineMode);
exportButton
..disabled = timelineController.recording
..hidden(offlineMode);
performanceOverlayButton.button.hidden(offlineMode);
_profileGranularitySelector.selector.hidden(offlineMode);
exitOfflineModeButton.hidden(!offlineMode);
}
Future<void> _updateListeningState() async {
final bool shouldBeRunning =
!timelineController.manuallyPaused && !offlineMode && isCurrentScreen;
final bool isRunning = !timelineController.paused;
await timelineController.timelineService.updateListeningState(
shouldBeRunning: shouldBeRunning,
isRunning: isRunning,
);
}
void clearTimeline() {
timelineController.timelineData?.clear();
flameChartContainer
.hidden(timelineController.timelineMode == TimelineMode.frameBased);
flameChartCanvas = null;
eventDetails.reset(
hide: timelineController.timelineMode == TimelineMode.frameBased);
switch (timelineController.timelineMode) {
case TimelineMode.frameBased:
debugHandledTraceEvents.clear();
debugFrameTracking.clear();
framesBarChart.frameUIgraph.reset();
_destroySplitter();
break;
case TimelineMode.full:
_recordingInstructions.hidden(false);
}
}
void _exportTimeline() {
// TODO(kenzie): add analytics for this. It would be helpful to know how
// complex the problems are that users are trying to solve.
final String encodedTimelineData =
jsonEncode(timelineController.timelineData.json);
final now = DateTime.now();
final timestamp =
'${now.year}_${now.month}_${now.day}-${now.microsecondsSinceEpoch}';
downloadFile(encodedTimelineData, 'timeline_$timestamp.json');
}
/// Adds a button to the timeline that will dump debug information to text
/// files and download them. This will only appear if the [debugTimeline] flag
/// is true.
void _maybeAddDebugButtons() {
if (debugTimeline) {
debugButtonSection.add(PButton('Debug dump timeline')
..small()
..click(() {
// Trace event json in the order we handled the events.
final handledTraceEventsJson = {
'traceEvents': debugHandledTraceEvents
};
downloadFile(
jsonEncode(handledTraceEventsJson),
'handled_trace_output.json',
);
// Significant events in the frame tracking process.
downloadFile(
debugFrameTracking.toString(),
'frame_tracking_output.txt',
);
final timelineProtocol = timelineController.timelineProtocol;
// Current status of our frame tracking elements (i.e. pendingEvents,
// pendingFrames).
final buf = StringBuffer();
buf.writeln('Pending events: '
'${timelineProtocol.pendingEvents.length}');
for (TimelineEvent event in timelineProtocol.pendingEvents) {
event.format(buf, ' ');
buf.writeln();
}
buf.writeln('\nPending frames: '
'${timelineProtocol.pendingFrames.length}');
for (TimelineFrame frame in timelineProtocol.pendingFrames.values) {
buf.writeln('${frame.toString()}');
}
if (timelineProtocol.currentEventNodes[TimelineEventType.ui.index] !=
null) {
buf.writeln('\nCurrent UI event node:');
timelineProtocol.currentEventNodes[TimelineEventType.ui.index]
.format(buf, ' ');
}
if (timelineProtocol.currentEventNodes[TimelineEventType.gpu.index] !=
null) {
buf.writeln('\n Current GPU event node:');
timelineProtocol.currentEventNodes[TimelineEventType.gpu.index]
.format(buf, ' ');
}
if (timelineProtocol.heaps[TimelineEventType.ui.index].isNotEmpty) {
buf.writeln('\nUI heap');
for (TraceEventWrapper wrapper in timelineProtocol
.heaps[TimelineEventType.ui.index]
.toList()) {
buf.writeln(wrapper.event.json.toString());
}
}
if (timelineProtocol.heaps[TimelineEventType.gpu.index].isNotEmpty) {
buf.writeln('\nGPU heap');
for (TraceEventWrapper wrapper in timelineProtocol
.heaps[TimelineEventType.gpu.index]
.toList()) {
buf.writeln(wrapper.event.json.toString());
}
}
downloadFile(buf.toString(), 'pending_frame_tracking_status.txt');
}));
}
}
}