| // Copyright (C) 2025 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| import {NUM, STR} from '../../trace_processor/query_result'; | |
| import type {Trace} from '../../public/trace'; | |
| import type {PerfettoPlugin} from '../../public/plugin'; | |
| import {TrackNode, type Workspace} from '../../public/workspace'; | |
| import FramesPlugin, { | |
| getProcessFrameTimelineUris, | |
| } from '../dev.perfetto.Frames'; | |
| const TRACKS_TO_COPY: string[] = [ | |
| 'L<', | |
| 'UI Events', | |
| 'IKeyguardService', | |
| 'Transition:', | |
| ]; | |
| const SYSTEM_UI_PROCESS: string = 'com.android.systemui'; | |
| // Plugin that creates an opinionated Workspace specific for SysUI | |
| export default class implements PerfettoPlugin { | |
| static readonly id = 'com.android.SysUIWorkspace'; | |
| static readonly dependencies = [FramesPlugin]; | |
| async onTraceLoad(ctx: Trace): Promise<void> { | |
| ctx.commands.registerCommand({ | |
| id: 'com.android.CreateSysUIWorkspace', | |
| name: 'Create System UI workspace', | |
| callback: () => | |
| ProcessWorkspaceFactory.create( | |
| ctx, | |
| SYSTEM_UI_PROCESS, | |
| 'System UI', | |
| TRACKS_TO_COPY, | |
| ), | |
| }); | |
| } | |
| } | |
| /** | |
| * Creates a workspace for a process with the following tracks: | |
| * - timelines | |
| * - main thread and render thread | |
| * - All other ui threads in a group | |
| * - List of tracks having name manually provided to this class constructor | |
| * - groups tracks having the "/(?<groupName>.*)##(?<trackName>.*)/" format | |
| * (e.g. "notifications##visible" will create a "visible" track inside the | |
| * "notification" group) | |
| * | |
| * This is useful to reduce the clutter when focusing on a single process, and | |
| * organizing tracks related to the same area in groups. | |
| */ | |
| class ProcessWorkspaceFactory { | |
| private readonly ws: Workspace; | |
| private readonly processTracks: TrackNode[]; | |
| constructor( | |
| private readonly trace: Trace, | |
| private readonly process: ProcessIdentifier, | |
| private readonly workspaceName: string, | |
| private readonly topLevelTracksToPin: string[] = [], | |
| ) { | |
| // We're going to iterate them often: let's filter the process ones. | |
| this.processTracks = this.findProcessTracks(); | |
| this.ws = this.trace.workspaces.createEmptyWorkspace(this.workspaceName); | |
| } | |
| /** | |
| * Creates a new workspace for a specific process in a trace. | |
| * | |
| * No workspace is created if it was there already. | |
| * This is expected to be called from the default workspace. | |
| * | |
| * @param trace The trace context. | |
| * @param packageName Name of the Android package to create the workspace for. | |
| * @param workspaceName Desired name for the new workspace. | |
| * @param tracksToCopy - An optional list of track names to be added to | |
| * the new workspace | |
| * @returns A `Promise` that resolves when the workspace has been created. | |
| */ | |
| public static async create( | |
| trace: Trace, | |
| packageName: string, | |
| workspaceName: string, | |
| tracksToCopy: string[] = [], | |
| ) { | |
| const exists = trace.workspaces.all.find( | |
| (ws) => ws.title === workspaceName, | |
| ); | |
| if (exists) return; | |
| const process = await getProcessInfo(trace, packageName); | |
| if (!process) return; | |
| const factory = new ProcessWorkspaceFactory( | |
| trace, | |
| process, | |
| workspaceName, | |
| tracksToCopy, | |
| ); | |
| await factory.createWorkspace(); | |
| } | |
| private async createWorkspace() { | |
| this.pinFrameTimelines(); | |
| this.pinMainThread(); | |
| this.pinFirstRenderThread(); | |
| await this.pinUiThreads(); | |
| this.topLevelTracksToPin.forEach((s) => | |
| this.pinTracksContainingInGroupIfNeeded(s), | |
| ); | |
| this.createGroups(); | |
| this.trace.workspaces.switchWorkspace(this.ws); | |
| } | |
| private findProcessTracks(): TrackNode[] { | |
| return this.trace.defaultWorkspace.flatTracks.filter((track) => { | |
| if (!track.uri) return false; | |
| const descriptor = this.trace.tracks.getTrack(track.uri); | |
| return descriptor?.tags?.upid === this.process.upid; | |
| }); | |
| } | |
| // Matched by URI rather than by name: the per-layer frame timelines share the | |
| // names of the process-level ones. | |
| private pinFrameTimelines() { | |
| for (const uri of getProcessFrameTimelineUris(this.process.upid)) { | |
| const track = this.processTracks.find((t) => t.uri === uri); | |
| if (track) this.ws.addChildLast(track.clone()); | |
| } | |
| } | |
| private pinTracksContainingInGroupIfNeeded( | |
| titleSubstring: string, | |
| minSizeToGroup: number = 2, | |
| ) { | |
| const tracks = this.getTracksContaining(titleSubstring); | |
| if (tracks.length == 0) return; | |
| if (tracks.length >= minSizeToGroup) { | |
| const newGroup = new TrackNode({name: titleSubstring, isSummary: true}); | |
| this.ws.addChildLast(newGroup); | |
| tracks.forEach((track) => newGroup.addChildLast(track.clone())); | |
| } else { | |
| tracks.forEach((track) => this.ws.addChildLast(track.clone())); | |
| } | |
| } | |
| private getTracksContaining(titleSubstring: string): TrackNode[] { | |
| return this.processTracks.filter((track) => | |
| track.name.includes(titleSubstring), | |
| ); | |
| } | |
| private pinMainThread() { | |
| const tracks = this.processTracks.filter((track) => { | |
| return this.getTrackUtid(track) == this.process.upid; | |
| }); | |
| tracks.forEach((track) => this.ws.addChildLast(track.clone())); | |
| } | |
| // In traces there might be many short-lived threads called "render thread" | |
| // used to allocate stuff. We don't care about them, but only of the first one | |
| // (that has lower thread id) | |
| private pinFirstRenderThread() { | |
| const tracks = this.getTracksContaining('RenderThread'); | |
| const utids = tracks | |
| .map((t) => this.getTrackUtid(t)) | |
| .filter((utid): utid is number => utid !== undefined); | |
| const minUtid = Math.min(...utids); | |
| const toPin = tracks.filter((track) => this.getTrackUtid(track) == minUtid); | |
| toPin.forEach((track) => this.ws.addChildLast(track.clone())); | |
| } | |
| private async pinUiThreads() { | |
| const result = await this.trace.engine.query(` | |
| INCLUDE PERFETTO MODULE slices.with_context; | |
| SELECT DISTINCT utid FROM thread_or_process_slice | |
| WHERE upid = ${this.process.upid} | |
| AND upid != utid -- main thread excluded | |
| AND name GLOB "Choreographer#doFrame*" | |
| `); | |
| if (result.numRows() === 0) { | |
| return; | |
| } | |
| const uiThreadUtidsSet = new Set<number>(); | |
| const it = result.iter({utid: NUM}); | |
| for (; it.valid(); it.next()) { | |
| uiThreadUtidsSet.add(it.utid); | |
| } | |
| const toPin = this.processTracks.filter((track) => { | |
| const utid = this.getTrackUtid(track); | |
| return utid != undefined && uiThreadUtidsSet.has(utid); | |
| }); | |
| toPin.sort((a, b) => { | |
| return a.name.localeCompare(b.name); | |
| }); | |
| const uiThreadTrack = new TrackNode({name: 'UI Threads', isSummary: true}); | |
| this.ws.addChildLast(uiThreadTrack); | |
| toPin.forEach((track) => uiThreadTrack.addChildLast(track.clone())); | |
| } | |
| private getTrackUtid(node: TrackNode): number | undefined { | |
| return this.trace.tracks.getTrack(node.uri!)?.tags?.utid; | |
| } | |
| private createGroups() { | |
| const groupRegex = /(?<groupName>.*)##(?<trackName>.*)/; | |
| const trackGroups = new Map<string, TrackNode>(); | |
| this.processTracks.forEach((track) => { | |
| const match = track.name.match(groupRegex); | |
| if (!match?.groups) return; | |
| const {groupName, trackName} = match.groups; | |
| const newTrack = track.clone(); | |
| newTrack.name = trackName!; | |
| if (!trackGroups.has(groupName!)) { | |
| const newGroup = new TrackNode({name: groupName, isSummary: true}); | |
| this.ws.addChildLast(newGroup); | |
| trackGroups.set(groupName!, newGroup); | |
| } | |
| trackGroups.get(groupName!)!.addChildLast(newTrack); | |
| }); | |
| } | |
| } | |
| type ProcessIdentifier = { | |
| upid: number; | |
| name: string; | |
| }; | |
| async function getProcessInfo( | |
| ctx: Trace, | |
| processName: string, | |
| ): Promise<ProcessIdentifier | undefined> { | |
| const result = await ctx.engine.query(` | |
| INCLUDE PERFETTO MODULE android.process_metadata; | |
| select | |
| _process_available_info_summary.upid, | |
| process.name | |
| from _process_available_info_summary | |
| join process using(upid) | |
| where process.name = '${processName}'; | |
| `); | |
| if (result.numRows() === 0) { | |
| return undefined; | |
| } | |
| return result.firstRow({ | |
| upid: NUM, | |
| name: STR, | |
| }); | |
| } |