/* * 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. */ package io.flutter.perf; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.HashMultimap; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Multimap; import com.google.common.collect.SetMultimap; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.intellij.concurrency.JobScheduler; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.Application; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.fileEditor.TextEditor; import com.intellij.openapi.util.Disposer; import com.intellij.openapi.util.TextRange; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.util.ui.EdtInvocationManager; import gnu.trove.TIntObjectHashMap; import io.flutter.utils.AsyncUtils; import javax.swing.Timer; import java.awt.event.ActionEvent; import java.util.*; import java.util.concurrent.TimeUnit; /** * This class provides the glue code between code fetching performance * statistics json from a running flutter application and the ui rendering the * performance statistics directly within the text editors. * <p> * This class is written to be amenable to unittesting unlike * FlutterWidgetPerfManager so try to put all complex logic in this class * so that issues can be caught by unittests. * <p> * See EditorPerfDecorations which performs all of the concrete ui rendering * and VmServiceWidgetPerfProvider which performs fetching of json from a * production application. */ public class FlutterWidgetPerf implements Disposable, WidgetPerfListener { public static final long IDLE_DELAY_MILISECONDS = 400; static class StatsForReportKind { final TIntObjectHashMap<SlidingWindowStats> data = new TIntObjectHashMap<>(); private int lastStartTime = -1; private int lastNonEmptyReportTime = -1; } // Retry requests if we do not receive a response within this interval. private static final long REQUEST_TIMEOUT_INTERVAL = 2000; // Intentionally use a low FPS as the animations in EditorPerfDecorations // are quite CPU intensive due to animating content in TextEditor windows. private static final int UI_FPS = 8; private boolean isDirty = true; private boolean requestInProgress = false; private long lastRequestTime; private final Set<PerfModel> perfListeners = new HashSet<>(); /** * Note: any access of editorDecorations contents must happen on the UI thread. */ private final Map<TextEditor, EditorPerfModel> editorDecorations = new HashMap<>(); private final TIntObjectHashMap<Location> knownLocationIds = new TIntObjectHashMap<>(); private final SetMultimap<String, Location> locationsPerFile = HashMultimap.create(); private final Map<PerfReportKind, StatsForReportKind> stats = new HashMap<>(); final Set<TextEditor> currentEditors = new HashSet<>(); private boolean profilingEnabled; final Timer uiAnimationTimer; private final WidgetPerfProvider perfProvider; private boolean isDisposed = false; private final FilePerfModelFactory perfModelFactory; private final FileLocationMapperFactory fileLocationMapperFactory; private volatile long lastLocalPerfEventTime; private final WidgetPerfLinter perfLinter; FlutterWidgetPerf(boolean profilingEnabled, WidgetPerfProvider perfProvider, FilePerfModelFactory perfModelFactory, FileLocationMapperFactory fileLocationMapperFactory) { this.profilingEnabled = profilingEnabled; this.perfProvider = perfProvider; this.perfModelFactory = perfModelFactory; this.fileLocationMapperFactory = fileLocationMapperFactory; this.perfLinter = new WidgetPerfLinter(this, perfProvider); perfProvider.setTarget(this); uiAnimationTimer = new Timer(1000 / UI_FPS, event -> { AsyncUtils.invokeLater(() -> onFrame(event)); }); } // The logic for when requests are in progress is fragile. This helper // method exists to we have a single place to instrument to track when // request status changes to help debug issues,. private void setRequestInProgress(boolean value) { requestInProgress = value; } private void onFrame(ActionEvent event) { for (EditorPerfModel decorations : editorDecorations.values()) { decorations.onFrame(); } for (PerfModel model : perfListeners) { model.onFrame(); } } private boolean isConnected() { return perfProvider.isConnected(); } public long getLastLocalPerfEventTime() { return lastLocalPerfEventTime; } /** * Schedule a repaint of the widget perf information. * <p> * When.now schedules a repaint immediately. * <p> * When.soon will schedule a repaint shortly; that can get delayed by another request, with a maximum delay. */ @Override public void requestRepaint(When when) { if (!profilingEnabled) { isDirty = false; return; } isDirty = true; if (!isConnected() || (this.currentEditors.isEmpty() && this.perfListeners.isEmpty())) { return; } final long currentTime = System.currentTimeMillis(); if (requestInProgress && (currentTime - lastRequestTime) < REQUEST_TIMEOUT_INTERVAL) { return; } setRequestInProgress(true); lastRequestTime = currentTime; final TextEditor[] editors = this.currentEditors.toArray(new TextEditor[0]); AsyncUtils.invokeLater(() -> performRequest(editors)); } @Override public void onWidgetPerfEvent(PerfReportKind kind, JsonObject json) { // Read access to the Document objects on background thread is needed so // a ReadAction is required. Document objects are used to determine the // widget names at specific locations in documents. final Runnable action = () -> { synchronized (this) { final long startTimeMicros = json.get("startTime").getAsLong(); final int startTimeMilis = (int)(startTimeMicros / 1000); lastLocalPerfEventTime = System.currentTimeMillis(); final StatsForReportKind statsForReportKind = getStatsForKind(kind); if (statsForReportKind.lastStartTime > startTimeMilis) { // We went backwards in time. There must have been a hot restart so // clear all old stats. statsForReportKind.data.forEachValue((SlidingWindowStats entry) -> { entry.clear(); return true; }); } statsForReportKind.lastStartTime = startTimeMilis; if (json.has("newLocations")) { final JsonObject newLocations = json.getAsJsonObject("newLocations"); for (Map.Entry<String, JsonElement> entry : newLocations.entrySet()) { final String path = entry.getKey(); final FileLocationMapper locationMapper = fileLocationMapperFactory.create(path); final JsonArray entries = entry.getValue().getAsJsonArray(); assert (entries.size() % 3 == 0); for (int i = 0; i < entries.size(); i += 3) { final int id = entries.get(i).getAsInt(); final int line = entries.get(i + 1).getAsInt(); final int column = entries.get(i + 2).getAsInt(); final TextRange textRange = locationMapper.getIdentifierRange(line, column); String name = locationMapper.getText(textRange); if (name == null) { name = ""; } final Location location = new Location(locationMapper.getPath(), line, column, id, textRange, name); final Location existingLocation = knownLocationIds.get(id); if (existingLocation == null) { addNewLocation(id, location); } else { if (!location.equals(existingLocation)) { // Cleanup all references to the old location as it is stale. // This occurs if there is a hot restart or reload that we weren't aware of. locationsPerFile.remove(existingLocation.path, existingLocation); for (StatsForReportKind statsForKind : stats.values()) { statsForKind.data.remove(id); } addNewLocation(id, location); } } } } } final StatsForReportKind statsForKind = getStatsForKind(kind); final PerfSourceReport report = new PerfSourceReport(json.getAsJsonArray("events"), kind, startTimeMicros); if (report.getEntries().size() > 0) { statsForReportKind.lastNonEmptyReportTime = startTimeMilis; } for (PerfSourceReport.Entry entry : report.getEntries()) { final int locationId = entry.locationId; SlidingWindowStats statsForLocation = statsForKind.data.get(locationId); if (statsForLocation == null) { statsForLocation = new SlidingWindowStats(); statsForKind.data.put(locationId, statsForLocation); } statsForLocation.add(entry.total, startTimeMilis); } } }; final Application application = ApplicationManager.getApplication(); if (application != null) { application.runReadAction(action); } else { // Unittest case. action.run(); } } @Override public void onNavigation() { synchronized (this) { for (StatsForReportKind statsForKind : stats.values()) { statsForKind.data.forEachValue((SlidingWindowStats entry) -> { entry.onNavigation(); return true; }); } } } @Override public void addPerfListener(PerfModel listener) { perfListeners.add(listener); } @Override public void removePerfListener(PerfModel listener) { perfListeners.remove(listener); } private StatsForReportKind getStatsForKind(PerfReportKind kind) { StatsForReportKind report = stats.get(kind); if (report == null) { report = new StatsForReportKind(); stats.put(kind, report); } return report; } private void addNewLocation(int id, Location location) { knownLocationIds.put(id, location); locationsPerFile.put(location.path, location); } void setProfilingEnabled(boolean enabled) { profilingEnabled = enabled; } private void performRequest(TextEditor[] fileEditors) { assert EdtInvocationManager.getInstance().isEventDispatchThread(); if (!profilingEnabled) { setRequestInProgress(false); return; } final Multimap<String, TextEditor> editorForPath = LinkedListMultimap.create(); final List<String> uris = new ArrayList<>(); for (TextEditor editor : fileEditors) { final VirtualFile file = editor.getFile(); if (file == null) { continue; } final String uri = file.getPath(); editorForPath.put(uri, editor); uris.add(uri); } if (uris.isEmpty() && perfListeners.isEmpty()) { setRequestInProgress(false); return; } isDirty = false; showReports(editorForPath); } private void showReports(Multimap<String, TextEditor> editorForPath) { // True if any of the EditorPerfDecorations want to animate. boolean animate = false; synchronized (this) { for (String path : editorForPath.keySet()) { for (TextEditor fileEditor : editorForPath.get(path)) { if (!fileEditor.isValid()) return; final EditorPerfModel editorDecoration = editorDecorations.get(fileEditor); if (editorDecoration != null) { if (!perfProvider.shouldDisplayPerfStats(fileEditor)) { editorDecoration.clear(); continue; } final FilePerfInfo fileStats = buildSummaryStats(fileEditor); editorDecoration.setPerfInfo(fileStats); if (editorDecoration.isAnimationActive()) { animate = true; } } } } } if (!animate) { for (PerfModel listener : perfListeners) { if (listener.isAnimationActive()) { animate = true; break; } } } if (animate != uiAnimationTimer.isRunning()) { if (animate) { uiAnimationTimer.start(); } else { uiAnimationTimer.stop(); } } performRequestFinish(); } private FilePerfInfo buildSummaryStats(TextEditor fileEditor) { final String path = fileEditor.getFile().getPath(); final FilePerfInfo fileStats = new FilePerfInfo(); for (PerfReportKind kind : PerfReportKind.values()) { final StatsForReportKind forKind = stats.get(kind); if (forKind == null) { continue; } final TIntObjectHashMap<SlidingWindowStats> data = forKind.data; for (Location location : locationsPerFile.get(path)) { final SlidingWindowStats entry = data.get(location.id); if (entry == null) { continue; } final TextRange range = location.textRange; if (range == null) { continue; } fileStats.add( range, new SummaryStats( kind, new SlidingWindowStatsSummary(entry, forKind.lastStartTime, location), location.name ) ); } } return fileStats; } private void performRequestFinish() { setRequestInProgress(false); JobScheduler.getScheduler().schedule(this::maybeNotifyIdle, IDLE_DELAY_MILISECONDS, TimeUnit.MILLISECONDS); if (isDirty) { requestRepaint(When.soon); } } private void maybeNotifyIdle() { if (isDisposed) { return; } if (System.currentTimeMillis() >= lastRequestTime + IDLE_DELAY_MILISECONDS) { AsyncUtils.invokeLater(() -> { for (EditorPerfModel decoration : editorDecorations.values()) { decoration.markAppIdle(); } for (PerfModel listener : perfListeners) { listener.markAppIdle(); } uiAnimationTimer.stop(); }); } } public void showFor(Set<TextEditor> editors) { AsyncUtils.invokeAndWait(() -> { currentEditors.clear(); currentEditors.addAll(editors); // Harvest old editors. harvestInvalidEditors(editors); for (TextEditor fileEditor : currentEditors) { // Create a new EditorPerfModel if necessary. if (!editorDecorations.containsKey(fileEditor)) { editorDecorations.put(fileEditor, perfModelFactory.create(fileEditor)); } } requestRepaint(When.now); }); } private void harvestInvalidEditors(Set<TextEditor> newEditors) { final Iterator<TextEditor> editors = editorDecorations.keySet().iterator(); while (editors.hasNext()) { final TextEditor editor = editors.next(); if (!editor.isValid() || (newEditors != null && !newEditors.contains(editor))) { final EditorPerfModel editorPerfDecorations = editorDecorations.get(editor); editors.remove(); Disposer.dispose(editorPerfDecorations); } } } public void setAlwaysShowLineMarkersOverride(boolean show) { for (EditorPerfModel model : editorDecorations.values()) { model.setAlwaysShowLineMarkersOverride(show); } } @Override public void dispose() { if (isDisposed) { return; } this.isDisposed = true; if (uiAnimationTimer.isRunning()) { uiAnimationTimer.stop(); } Disposer.dispose(perfProvider); AsyncUtils.invokeLater(() -> { clearModels(); for (EditorPerfModel decorations : editorDecorations.values()) { Disposer.dispose(decorations); } editorDecorations.clear(); perfListeners.clear(); }); } /** * Note: this must be called on the UI thread. */ @VisibleForTesting() public void clearModels() { for (EditorPerfModel decorations : editorDecorations.values()) { decorations.clear(); } for (PerfModel listener : perfListeners) { listener.clear(); } } protected void clear() { AsyncUtils.invokeLater(this::clearModels); } protected void onRestart() { AsyncUtils.invokeLater(() -> { // The app has restarted. Location ids may not be valid. knownLocationIds.clear(); stats.clear(); clearModels(); }); } public WidgetPerfLinter getPerfLinter() { return perfLinter; } public ArrayList<FilePerfInfo> buildAllSummaryStats(Set<TextEditor> textEditors) { final ArrayList<FilePerfInfo> stats = new ArrayList<>(); synchronized (this) { for (TextEditor textEditor : textEditors) { stats.add(buildSummaryStats(textEditor)); } } return stats; } public ArrayList<SlidingWindowStatsSummary> getStatsForMetric(ArrayList<PerfMetric> metrics, PerfReportKind kind) { final ArrayList<SlidingWindowStatsSummary> entries = new ArrayList<>(); synchronized (this) { final StatsForReportKind forKind = stats.get(kind); if (forKind != null) { final int time = forKind.lastNonEmptyReportTime; forKind.data.forEachEntry((int locationId, SlidingWindowStats stats) -> { for (PerfMetric metric : metrics) { if (stats.getValue(metric, time) > 0) { final Location location = knownLocationIds.get(locationId); // TODO(jacobr): consider changing this check for // location != null to an assert once the edge case leading to // occassional null locations has been fixed. I expect the edge // case occurs because we are sometimes including a few stats // from before a hot restart due to an incorrect ordering for // when the events occur. In any case, the extra != null check // is harmless and ensures the UI display is robust at the cost // of perhaps ommiting a little likely stale data. // See https://github.com/flutter/flutter-intellij/issues/2892 if (location != null) { entries.add(new SlidingWindowStatsSummary( stats, time, location )); } return true; } } return true; }); } } return entries; } }