Skip to content

Commit dad7979

Browse files
committed
ui: Add Heapdump Explorer plugin
Add the com.android.HeapDumpExplorer plugin, inspired by Android's ahat tool, with navigation, shared components, queries, and all heap analysis views: overview dashboard, classes, objects, instances, dominators, bitmap gallery, and strings. Uses DataGrid with SQLDataSource for efficient sorting and filtering of large datasets. Change-Id: I28d4f8ef7b3b949e2e1a5fefe3aeefddab2b3c5f
1 parent 1b9d439 commit dad7979

File tree

17 files changed

+5795
-0
lines changed

17 files changed

+5795
-0
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// Copyright (C) 2026 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+
15+
import m from 'mithril';
16+
import type {SqlValue} from '../../trace_processor/query_result';
17+
import type {CellRenderResult} from '../../components/widgets/datagrid/datagrid_schema';
18+
import type {InstanceRow, PrimOrRef} from './types';
19+
import {fmtSize} from './format';
20+
import type {BreadcrumbEntry, NavState} from './nav_state';
21+
export type {BreadcrumbEntry};
22+
23+
export type NavFn = (
24+
view: NavState['view'],
25+
params?: Record<string, unknown>,
26+
) => void;
27+
28+
export type ObjLinkRef = {
29+
id: number;
30+
display: string;
31+
str?: string | null;
32+
};
33+
34+
// ─── InstanceLink ─────────────────────────────────────────────────────────────
35+
36+
interface InstanceLinkAttrs {
37+
row: InstanceRow | ObjLinkRef | null;
38+
navigate: NavFn;
39+
}
40+
export function InstanceLink(): m.Component<InstanceLinkAttrs> {
41+
return {
42+
view(vnode) {
43+
const {row, navigate} = vnode.attrs;
44+
if (!row || row.id === 0) {
45+
return m('span', {class: 'ah-badge-referent'}, 'ROOT');
46+
}
47+
const full = 'className' in row ? (row as InstanceRow) : null;
48+
return m(
49+
'span',
50+
full &&
51+
full.reachabilityName !== 'unreachable' &&
52+
full.reachabilityName !== 'strong'
53+
? m('span', {class: 'ah-badge-reachability'}, full.reachabilityName)
54+
: null,
55+
full?.isRoot ? m('span', {class: 'ah-badge-root'}, 'root') : null,
56+
m(
57+
'button',
58+
{
59+
class: 'ah-link',
60+
onclick: () => navigate('object', {id: row.id, label: row.display}),
61+
},
62+
row.display,
63+
),
64+
row.str != null
65+
? m(
66+
'span',
67+
{
68+
class: 'ah-badge-string',
69+
title: row.str.length > 80 ? row.str : undefined,
70+
},
71+
'"' +
72+
(row.str.length > 80
73+
? row.str.slice(0, 80) + '\u2026'
74+
: row.str) +
75+
'"',
76+
)
77+
: null,
78+
full?.referent
79+
? m(
80+
'span',
81+
{class: 'ah-badge-referent'},
82+
' for ',
83+
m(InstanceLink, {
84+
row: full.referent,
85+
navigate,
86+
}),
87+
)
88+
: null,
89+
);
90+
},
91+
};
92+
}
93+
94+
// ─── Section ──────────────────────────────────────────────────────────────────
95+
96+
interface SectionAttrs {
97+
title: string;
98+
defaultOpen?: boolean;
99+
}
100+
export function Section(): m.Component<SectionAttrs> {
101+
let open = true;
102+
return {
103+
oninit(vnode) {
104+
open = vnode.attrs.defaultOpen !== false;
105+
},
106+
view(vnode) {
107+
return m(
108+
'div',
109+
{class: 'ah-section'},
110+
m(
111+
'button',
112+
{
113+
'class': 'ah-section__toggle',
114+
'onclick': () => {
115+
open = !open;
116+
},
117+
'aria-expanded': open,
118+
},
119+
m('span', {class: 'ah-section__title'}, vnode.attrs.title),
120+
m(
121+
'svg',
122+
{
123+
class: `ah-section__chevron${open ? ' ah-section__chevron--open' : ''}`,
124+
viewBox: '0 0 20 20',
125+
fill: 'currentColor',
126+
},
127+
m('path', {
128+
'fill-rule': 'evenodd',
129+
'd': 'M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z',
130+
'clip-rule': 'evenodd',
131+
}),
132+
),
133+
),
134+
open ? m('div', {class: 'ah-section__body'}, vnode.children) : null,
135+
);
136+
},
137+
};
138+
}
139+
140+
// ─── DataGrid cell renderers (shared across views) ──────────────────────────
141+
142+
/** Renders a size value as a right-aligned formatted byte string. */
143+
export function sizeRenderer(value: SqlValue): CellRenderResult {
144+
return {
145+
content: m('span', {class: 'ah-mono'}, fmtSize(Number(value ?? 0))),
146+
align: 'right',
147+
};
148+
}
149+
150+
/** Renders a numeric count value as a right-aligned locale string. */
151+
export function countRenderer(value: SqlValue): CellRenderResult {
152+
return {
153+
content: m('span', {class: 'ah-mono'}, Number(value ?? 0).toLocaleString()),
154+
align: 'right',
155+
};
156+
}
157+
158+
/** Returns the short (unqualified) class name, preserving array brackets. */
159+
export function shortClassName(full: string): string {
160+
const bracket = full.indexOf('[');
161+
const base = bracket >= 0 ? full.slice(0, bracket) : full;
162+
const dot = base.lastIndexOf('.');
163+
const short = dot >= 0 ? base.slice(dot + 1) : base;
164+
return bracket >= 0 ? short + full.slice(bracket) : short;
165+
}
166+
167+
/** Shared SQL preamble for views that use the dominator tree module. */
168+
export const DOMINATOR_TREE_PREAMBLE =
169+
'INCLUDE PERFETTO MODULE android.memory.heap_graph.dominator_tree';
170+
171+
// ─── PrimOrRefCell ────────────────────────────────────────────────────────────
172+
173+
interface PrimOrRefCellAttrs {
174+
v: PrimOrRef;
175+
navigate: NavFn;
176+
}
177+
export function PrimOrRefCell(): m.Component<PrimOrRefCellAttrs> {
178+
return {
179+
view(vnode) {
180+
const {v, navigate} = vnode.attrs;
181+
if (v.kind === 'ref') {
182+
return m(InstanceLink, {
183+
row: {id: v.id, display: v.display, str: v.str},
184+
navigate,
185+
});
186+
}
187+
return m('span', {class: 'ah-mono'}, v.v);
188+
},
189+
};
190+
}
191+
192+
// ─── BitmapImage ──────────────────────────────────────────────────────────────
193+
194+
interface BitmapImageAttrs {
195+
width: number;
196+
height: number;
197+
format: string;
198+
data: Uint8Array;
199+
}
200+
201+
export function BitmapImage(): m.Component<BitmapImageAttrs> {
202+
let blobUrl: string | null = null;
203+
204+
return {
205+
oncreate(vnode) {
206+
const {width, height, format, data} = vnode.attrs;
207+
if (format === 'rgba') {
208+
const canvas = vnode.dom as HTMLCanvasElement;
209+
canvas.width = width;
210+
canvas.height = height;
211+
const ctx = canvas.getContext('2d');
212+
if (!ctx) return;
213+
const clamped = new Uint8ClampedArray(data.length);
214+
clamped.set(data);
215+
ctx.putImageData(new ImageData(clamped, width, height), 0, 0);
216+
return;
217+
}
218+
const mimeMap: Record<string, string> = {
219+
png: 'image/png',
220+
jpeg: 'image/jpeg',
221+
webp: 'image/webp',
222+
};
223+
const copy = new Uint8Array(data.length);
224+
copy.set(data);
225+
const blob = new Blob([copy], {
226+
type: mimeMap[format] ?? 'image/png',
227+
});
228+
blobUrl = URL.createObjectURL(blob);
229+
m.redraw();
230+
},
231+
onremove() {
232+
if (blobUrl) {
233+
URL.revokeObjectURL(blobUrl);
234+
blobUrl = null;
235+
}
236+
},
237+
view(vnode) {
238+
const {format} = vnode.attrs;
239+
const imgStyle = {
240+
maxWidth: '100%',
241+
maxHeight: '100%',
242+
objectFit: 'contain' as const,
243+
imageRendering: 'pixelated' as const,
244+
};
245+
if (format === 'rgba') {
246+
return m('canvas', {style: imgStyle});
247+
}
248+
// Always render the img element so oncreate fires and creates the blob URL.
249+
// Before the blob URL is ready, src is empty (blank image).
250+
return m('img', {src: blobUrl ?? '', style: imgStyle});
251+
},
252+
};
253+
}
254+
255+
// ─── Breadcrumbs ──────────────────────────────────────────────────────────────
256+
257+
interface BreadcrumbsAttrs {
258+
trail: BreadcrumbEntry[];
259+
activeIndex: number;
260+
onNavigate: (index: number) => void;
261+
}
262+
export function Breadcrumbs(): m.Component<BreadcrumbsAttrs> {
263+
return {
264+
view(vnode) {
265+
const {trail, activeIndex, onNavigate} = vnode.attrs;
266+
if (trail.length <= 1) return null;
267+
return m(
268+
'nav',
269+
{'class': 'ah-breadcrumbs', 'aria-label': 'Breadcrumb'},
270+
m(
271+
'button',
272+
{
273+
'class': 'ah-breadcrumbs__back',
274+
'onclick': () => {
275+
if (activeIndex > 0) onNavigate(activeIndex - 1);
276+
},
277+
'title': 'Back',
278+
'aria-label': 'Back',
279+
},
280+
m(
281+
'svg',
282+
{
283+
class: 'ah-breadcrumbs__back-icon',
284+
viewBox: '0 0 20 20',
285+
fill: 'currentColor',
286+
},
287+
m('path', {
288+
'fill-rule': 'evenodd',
289+
'd': 'M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z',
290+
'clip-rule': 'evenodd',
291+
}),
292+
),
293+
),
294+
trail.map((crumb, i) => {
295+
const isActive = i === activeIndex;
296+
return m(
297+
'span',
298+
{key: i, class: 'ah-breadcrumbs__item'},
299+
i > 0 ? m('span', {class: 'ah-breadcrumbs__sep'}, '/') : null,
300+
isActive
301+
? m('span', {class: 'ah-breadcrumbs__active'}, crumb.label)
302+
: m(
303+
'button',
304+
{
305+
class: `ah-breadcrumbs__link ${i > activeIndex ? 'ah-breadcrumbs__link--future' : 'ah-breadcrumbs__link--past'}`,
306+
onclick: () => onNavigate(i),
307+
},
308+
crumb.label,
309+
),
310+
);
311+
}),
312+
);
313+
},
314+
};
315+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (C) 2026 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+
15+
export function downloadBlob(name: string, data: Uint8Array): void {
16+
const blob = new Blob([data], {type: 'application/octet-stream'});
17+
const url = URL.createObjectURL(blob);
18+
const a = document.createElement('a');
19+
a.href = url;
20+
a.download = name;
21+
a.click();
22+
URL.revokeObjectURL(url);
23+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (C) 2026 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+
15+
export function fmtSize(n: number): string {
16+
if (n === 0) return '0';
17+
if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GiB`;
18+
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MiB`;
19+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KiB`;
20+
return n.toLocaleString();
21+
}
22+
23+
export function fmtHex(id: number): string {
24+
return '0x' + id.toString(16).padStart(8, '0');
25+
}
26+
27+
export function deltaBgClass(deltaKb: number): string {
28+
if (deltaKb === 0) return '';
29+
const abs = Math.abs(deltaKb);
30+
if (deltaKb > 0) {
31+
if (abs >= 50_000) return 'ah-delta-bg-pos-heavy';
32+
if (abs >= 10_000) return 'ah-delta-bg-pos-medium';
33+
if (abs >= 1_000) return 'ah-delta-bg-pos-light';
34+
return '';
35+
}
36+
if (abs >= 50_000) return 'ah-delta-bg-neg-heavy';
37+
if (abs >= 10_000) return 'ah-delta-bg-neg-medium';
38+
if (abs >= 1_000) return 'ah-delta-bg-neg-light';
39+
return '';
40+
}
41+
42+
export function fmtDelta(deltaKb: number): string {
43+
if (deltaKb === 0) return '';
44+
const sign = deltaKb > 0 ? '+' : '\u2212';
45+
return `${sign}${fmtSize(Math.abs(deltaKb) * 1024)}`;
46+
}
47+
48+
/** Format a byte-level delta (inspired by Android ahat's %+,d format). */
49+
export function fmtSizeDelta(bytes: number): string {
50+
if (bytes === 0) return '';
51+
const sign = bytes > 0 ? '+' : '\u2212';
52+
return `${sign}${fmtSize(Math.abs(bytes))}`;
53+
}
54+
55+
export function deltaBgClassBytes(bytes: number): string {
56+
return deltaBgClass(bytes / 1024);
57+
}

0 commit comments

Comments
 (0)