1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {duration, Time, time} from '../../base/time'; 17import {exists} from '../../base/utils'; 18import { 19 ColumnDescriptor, 20 Table, 21 TableData, 22 widgetColumn, 23} from '../../widgets/table'; 24import {DurationWidget} from '../../components/widgets/duration'; 25import {Timestamp} from '../../components/widgets/timestamp'; 26import { 27 LONG, 28 LONG_NULL, 29 NUM, 30 NUM_NULL, 31 STR, 32} from '../../trace_processor/query_result'; 33import {DetailsShell} from '../../widgets/details_shell'; 34import {GridLayout, GridLayoutColumn} from '../../widgets/grid_layout'; 35import {Section} from '../../widgets/section'; 36import {SqlRef} from '../../widgets/sql_ref'; 37import {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph'; 38import {dictToTreeNodes, Tree} from '../../widgets/tree'; 39import { 40 buildScrollOffsetsGraph, 41 getInputScrollDeltas, 42 getJankIntervals, 43 getPredictorJankDeltas, 44 getPresentedScrollDeltas, 45} from './scroll_delta_graph'; 46import {JANKS_TRACK_URI, renderSliceRef} from './selection_utils'; 47import {TrackEventDetailsPanel} from '../../public/details_panel'; 48import {Trace} from '../../public/trace'; 49 50interface Data { 51 // Scroll ID. 52 id: number; 53 // Timestamp of the beginning of this slice in nanoseconds. 54 ts: time; 55 // DurationWidget of this slice in nanoseconds. 56 dur: duration; 57} 58 59interface Metrics { 60 inputEventCount?: number; 61 frameCount?: number; 62 presentedFrameCount?: number; 63 jankyFrameCount?: number; 64 jankyFramePercent?: number; 65 missedVsyncs?: number; 66 startOffset?: number; 67 endOffset?: number; 68 totalPixelsScrolled?: number; 69} 70 71interface JankSliceDetails { 72 cause: string; 73 id: number; 74 ts: time; 75 dur?: duration; 76 delayVsync?: number; 77} 78 79export class ScrollDetailsPanel implements TrackEventDetailsPanel { 80 private data?: Data; 81 private metrics: Metrics = {}; 82 private orderedJankSlices: JankSliceDetails[] = []; 83 84 // TODO(altimin): Don't store Mithril vnodes between render cycles. 85 private scrollDeltas: m.Child; 86 87 constructor( 88 private readonly trace: Trace, 89 private readonly id: number, 90 ) {} 91 92 async load() { 93 const queryResult = await this.trace.engine.query(` 94 WITH scrolls AS ( 95 SELECT 96 id, 97 IFNULL(gesture_scroll_begin_ts, ts) AS start_ts, 98 CASE 99 WHEN gesture_scroll_end_ts IS NOT NULL THEN gesture_scroll_end_ts 100 WHEN gesture_scroll_begin_ts IS NOT NULL 101 THEN gesture_scroll_begin_ts + dur 102 ELSE ts + dur 103 END AS end_ts 104 FROM chrome_scrolls WHERE id = ${this.id}) 105 SELECT 106 id, 107 start_ts AS ts, 108 end_ts - start_ts AS dur 109 FROM scrolls`); 110 111 const iter = queryResult.firstRow({ 112 id: NUM, 113 ts: LONG, 114 dur: LONG, 115 }); 116 this.data = { 117 id: iter.id, 118 ts: Time.fromRaw(iter.ts), 119 dur: iter.dur, 120 }; 121 122 await this.loadMetrics(); 123 } 124 125 private async loadMetrics() { 126 await this.loadInputEventCount(); 127 await this.loadFrameStats(); 128 await this.loadDelayData(); 129 await this.loadScrollOffsets(); 130 } 131 132 private async loadInputEventCount() { 133 if (exists(this.data)) { 134 const queryResult = await this.trace.engine.query(` 135 SELECT 136 COUNT(*) AS inputEventCount 137 FROM slice s 138 WHERE s.name = "EventLatency" 139 AND EXTRACT_ARG(arg_set_id, 'event_latency.event_type') = 'TOUCH_MOVED' 140 AND s.ts >= ${this.data.ts} 141 AND s.ts + s.dur <= ${this.data.ts + this.data.dur} 142 `); 143 144 const iter = queryResult.firstRow({ 145 inputEventCount: NUM, 146 }); 147 148 this.metrics.inputEventCount = iter.inputEventCount; 149 } 150 } 151 152 private async loadFrameStats() { 153 if (exists(this.data)) { 154 const queryResult = await this.trace.engine.query(` 155 SELECT 156 IFNULL(frame_count, 0) AS frameCount, 157 IFNULL(missed_vsyncs, 0) AS missedVsyncs, 158 IFNULL(presented_frame_count, 0) AS presentedFrameCount, 159 IFNULL(janky_frame_count, 0) AS jankyFrameCount, 160 ROUND(IFNULL(janky_frame_percent, 0), 2) AS jankyFramePercent 161 FROM chrome_scroll_stats 162 WHERE scroll_id = ${this.data.id} 163 `); 164 const iter = queryResult.iter({ 165 frameCount: NUM, 166 missedVsyncs: NUM, 167 presentedFrameCount: NUM, 168 jankyFrameCount: NUM, 169 jankyFramePercent: NUM, 170 }); 171 172 for (; iter.valid(); iter.next()) { 173 this.metrics.frameCount = iter.frameCount; 174 this.metrics.missedVsyncs = iter.missedVsyncs; 175 this.metrics.presentedFrameCount = iter.presentedFrameCount; 176 this.metrics.jankyFrameCount = iter.jankyFrameCount; 177 this.metrics.jankyFramePercent = iter.jankyFramePercent; 178 return; 179 } 180 } 181 } 182 183 private async loadDelayData() { 184 if (exists(this.data)) { 185 const queryResult = await this.trace.engine.query(` 186 SELECT 187 id, 188 ts, 189 dur, 190 IFNULL(sub_cause_of_jank, IFNULL(cause_of_jank, 'Unknown')) AS cause, 191 event_latency_id AS eventLatencyId, 192 delayed_frame_count AS delayVsync 193 FROM chrome_janky_frame_presentation_intervals s 194 WHERE s.ts >= ${this.data.ts} 195 AND s.ts + s.dur <= ${this.data.ts + this.data.dur} 196 ORDER by dur DESC; 197 `); 198 199 const it = queryResult.iter({ 200 id: NUM, 201 ts: LONG, 202 dur: LONG_NULL, 203 cause: STR, 204 eventLatencyId: NUM_NULL, 205 delayVsync: NUM_NULL, 206 }); 207 208 for (; it.valid(); it.next()) { 209 this.orderedJankSlices.push({ 210 id: it.id, 211 ts: Time.fromRaw(it.ts), 212 dur: it.dur ?? undefined, 213 cause: it.cause, 214 delayVsync: it.delayVsync ?? undefined, 215 }); 216 } 217 } 218 } 219 220 private async loadScrollOffsets() { 221 if (exists(this.data)) { 222 const inputDeltas = await getInputScrollDeltas( 223 this.trace.engine, 224 this.data.id, 225 ); 226 const presentedDeltas = await getPresentedScrollDeltas( 227 this.trace.engine, 228 this.data.id, 229 ); 230 const predictorDeltas = await getPredictorJankDeltas( 231 this.trace.engine, 232 this.data.id, 233 ); 234 const jankIntervals = await getJankIntervals( 235 this.trace.engine, 236 this.data.ts, 237 this.data.dur, 238 ); 239 this.scrollDeltas = buildScrollOffsetsGraph( 240 inputDeltas, 241 presentedDeltas, 242 predictorDeltas, 243 jankIntervals, 244 ); 245 246 if (presentedDeltas.length > 0) { 247 this.metrics.startOffset = presentedDeltas[0].scrollOffset; 248 this.metrics.endOffset = 249 presentedDeltas[presentedDeltas.length - 1].scrollOffset; 250 251 let pixelsScrolled = 0; 252 for (let i = 0; i < presentedDeltas.length; i++) { 253 pixelsScrolled += Math.abs(presentedDeltas[i].scrollDelta); 254 } 255 256 if (pixelsScrolled != 0) { 257 this.metrics.totalPixelsScrolled = pixelsScrolled; 258 } 259 } 260 } 261 } 262 263 private renderMetricsDictionary(): m.Child[] { 264 const metrics: {[key: string]: m.Child} = {}; 265 metrics['Total Finger Input Event Count'] = this.metrics.inputEventCount; 266 metrics['Total Vsyncs within Scrolling period'] = this.metrics.frameCount; 267 metrics['Total Chrome Presented Frames'] = this.metrics.presentedFrameCount; 268 metrics['Total Janky Frames'] = this.metrics.jankyFrameCount; 269 metrics['Number of Vsyncs Janky Frames were Delayed by'] = 270 this.metrics.missedVsyncs; 271 272 if (this.metrics.jankyFramePercent !== undefined) { 273 metrics[ 274 'Janky Frame Percentage (Total Janky Frames / Total Chrome Presented Frames)' 275 ] = `${this.metrics.jankyFramePercent}%`; 276 } 277 278 if (this.metrics.startOffset != undefined) { 279 metrics['Starting Offset'] = this.metrics.startOffset; 280 } 281 282 if (this.metrics.endOffset != undefined) { 283 metrics['Ending Offset'] = this.metrics.endOffset; 284 } 285 286 if ( 287 this.metrics.startOffset != undefined && 288 this.metrics.endOffset != undefined 289 ) { 290 metrics['Net Pixels Scrolled'] = Math.abs( 291 this.metrics.endOffset - this.metrics.startOffset, 292 ); 293 } 294 295 if (this.metrics.totalPixelsScrolled != undefined) { 296 metrics['Total Pixels Scrolled (all directions)'] = 297 this.metrics.totalPixelsScrolled; 298 } 299 300 return dictToTreeNodes(metrics); 301 } 302 303 private getDelayTable(): m.Child { 304 if (this.orderedJankSlices.length > 0) { 305 const columns: ColumnDescriptor<JankSliceDetails>[] = [ 306 widgetColumn<JankSliceDetails>('Cause', (jankSlice) => 307 renderSliceRef({ 308 trace: this.trace, 309 id: jankSlice.id, 310 trackUri: JANKS_TRACK_URI, 311 title: jankSlice.cause, 312 }), 313 ), 314 widgetColumn<JankSliceDetails>('Duration', (jankSlice) => 315 jankSlice.dur !== undefined 316 ? m(DurationWidget, {dur: jankSlice.dur}) 317 : 'NULL', 318 ), 319 widgetColumn<JankSliceDetails>( 320 'Delayed Vsyncs', 321 (jankSlice) => jankSlice.delayVsync, 322 ), 323 ]; 324 325 const tableData = new TableData(this.orderedJankSlices); 326 327 return m(Table, { 328 data: tableData, 329 columns: columns, 330 }); 331 } else { 332 return 'None'; 333 } 334 } 335 336 private getDescriptionText(): m.Child { 337 return m( 338 MultiParagraphText, 339 m(TextParagraph, { 340 text: `The interval during which the user has started a scroll ending 341 after their finger leaves the screen and any resulting fling 342 animations have finished.`, 343 }), 344 m(TextParagraph, { 345 text: `Note: This can contain periods of time where the finger is down 346 and not moving and no active scrolling is occurring.`, 347 }), 348 m(TextParagraph, { 349 text: `Note: Sometimes if a user touches the screen quickly after 350 letting go or Chrome was hung and got into a bad state. A new 351 scroll will start which will result in a slightly overlapping 352 scroll. This can occur due to the last scroll still outputting 353 frames (to get caught up) and the "new" scroll having started 354 producing frames after the user has started scrolling again.`, 355 }), 356 ); 357 } 358 359 private getGraphText(): m.Child { 360 return m( 361 MultiParagraphText, 362 m(TextParagraph, { 363 text: `The scroll offset is the discrepancy in physical screen pixels 364 between two consecutive frames.`, 365 }), 366 m(TextParagraph, { 367 text: `The overall curve of the graph indicates the direction (up or 368 down) by which the user scrolled over time.`, 369 }), 370 m(TextParagraph, { 371 text: `Grey blocks in the graph represent intervals of jank 372 corresponding with the Chrome Scroll Janks track.`, 373 }), 374 m(TextParagraph, { 375 text: `Yellow dots represent frames that were presented (sae as the red 376 dots), but that we suspect are visible to users as unsmooth 377 velocity/stutter (predictor jank).`, 378 }), 379 ); 380 } 381 382 render() { 383 if (this.data == undefined) { 384 return m('h2', 'Loading'); 385 } 386 387 const details = dictToTreeNodes({ 388 'Scroll ID': this.data.id, 389 'Start time': m(Timestamp, {ts: this.data.ts}), 390 'Duration': m(DurationWidget, {dur: this.data.dur}), 391 'SQL ID': m(SqlRef, {table: 'chrome_scrolls', id: this.id}), 392 }); 393 394 return m( 395 DetailsShell, 396 { 397 title: 'Scroll', 398 }, 399 m( 400 GridLayout, 401 m( 402 GridLayoutColumn, 403 m(Section, {title: 'Details'}, m(Tree, details)), 404 m( 405 Section, 406 {title: 'Slice Metrics'}, 407 m(Tree, this.renderMetricsDictionary()), 408 ), 409 m( 410 Section, 411 {title: 'Frame Presentation Delays'}, 412 this.getDelayTable(), 413 ), 414 ), 415 m( 416 GridLayoutColumn, 417 m(Section, {title: 'Description'}, this.getDescriptionText()), 418 m( 419 Section, 420 {title: 'Scroll Offsets Plot'}, 421 m(".div[style='padding-bottom:5px']", this.getGraphText()), 422 this.scrollDeltas, 423 ), 424 ), 425 ), 426 ); 427 } 428} 429