The measurements behind the preceding commits needed a repeatable way to drive a real Android TV box and read both sides of the frame. `dumpsys gfxinfo` alone is not enough (it sees the HWUI composite, not Flutter's UI and raster threads), and DevTools is not scriptable. - `vmclient.mjs` -- Dart VM Service client over WebSocket, plus percentile and timeline helpers and a CPU self/total-time reducer over `getCpuSamples`. - `scenarios.mjs` -- repeatable D-pad workloads. Keys are sent as one batched `input keyevent` invocation per burst, because a separate invocation per key costs more on the device than the interaction being measured. - `bench.mjs` -- runs a scenario and correlates Flutter's own frame phases (`Animator::BeginFrame`, LAYOUT, SEMANTICS, BUILD, PAINT, `Rasterizer::DoDraw`) with HWUI framestats, reporting medians over repeats. - `profile.mjs` -- CPU self/total time by function plus an allocation profile. - `launch.sh` -- cold-launches the profile build, sets up its own port forward and prints a host-reachable VM Service URI. Passes `--ez enable-dart-profiling`, which only `flutter run` supplies by default, and wakes the display first because `am start -W` silently reports no `TotalTime` when the screen is off. Not referenced by `lib/` and not packaged; `tools/` is outside the APK.
148 lines
4.7 KiB
JavaScript
148 lines
4.7 KiB
JavaScript
// Dart VM Service client + measurement helpers for on-device Flutter profiling.
|
|
// Measurement scaffolding only: not shipped, not referenced by lib/.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
const ADB = `${process.env.HOME}/Library/Android/sdk/platform-tools/adb`;
|
|
export const DEVICE = process.env.PLEZY_DEVICE ?? "192.168.1.7:5555";
|
|
|
|
export function adb(...args) {
|
|
const r = spawnSync(ADB, ["-s", DEVICE, ...args], { encoding: "utf8" });
|
|
if (r.status !== 0) throw new Error(`adb ${args.join(" ")}: ${r.stderr}`);
|
|
return r.stdout;
|
|
}
|
|
|
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/** Send a keyevent burst with a fixed inter-key delay. */
|
|
export async function keys(sequence, delayMs = 320) {
|
|
for (const k of sequence) {
|
|
adb("shell", "input", "keyevent", String(k));
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
|
|
export class VM {
|
|
constructor(ws) {
|
|
this.ws = ws;
|
|
this.id = 0;
|
|
this.pending = new Map();
|
|
ws.addEventListener("message", (e) => {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.id != null && this.pending.has(msg.id)) {
|
|
const { resolve, reject } = this.pending.get(msg.id);
|
|
this.pending.delete(msg.id);
|
|
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
|
}
|
|
});
|
|
}
|
|
|
|
static async connect(uri) {
|
|
const url = uri.replace(/^http/, "ws").replace(/\/$/, "") + "/ws";
|
|
const ws = new WebSocket(url);
|
|
await new Promise((res, rej) => {
|
|
ws.addEventListener("open", res);
|
|
ws.addEventListener("error", rej);
|
|
});
|
|
return new VM(ws);
|
|
}
|
|
|
|
call(method, params = {}) {
|
|
const id = ++this.id;
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(id, { resolve, reject });
|
|
this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
|
setTimeout(() => {
|
|
if (this.pending.delete(id)) reject(new Error(`${method} timed out`));
|
|
}, 120000);
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.ws.close();
|
|
}
|
|
|
|
/** The main UI isolate (the one running the Flutter framework). */
|
|
async uiIsolate() {
|
|
const vm = await this.call("getVM");
|
|
const named = vm.isolates.find((i) => i.name === "main" || /main/i.test(i.name));
|
|
return (named ?? vm.isolates[0]).id;
|
|
}
|
|
}
|
|
|
|
/** p50/p90/p95/p99/max over a numeric array. */
|
|
export function pct(values) {
|
|
if (!values.length) return null;
|
|
const s = [...values].sort((a, b) => a - b);
|
|
const at = (q) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
|
return {
|
|
n: s.length,
|
|
mean: +(s.reduce((a, b) => a + b, 0) / s.length).toFixed(2),
|
|
p50: +at(0.5).toFixed(2),
|
|
p90: +at(0.9).toFixed(2),
|
|
p95: +at(0.95).toFixed(2),
|
|
p99: +at(0.99).toFixed(2),
|
|
max: +s[s.length - 1].toFixed(2),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Duration (ms) of every begin/end pair for the named timeline events,
|
|
* keyed by event name. Handles the engine's 'B'/'E' and 'X' phases.
|
|
*/
|
|
export function durationsByName(traceEvents, names) {
|
|
const want = new Set(names);
|
|
const stacks = new Map(); // `${pid}:${tid}:${name}` -> [ts...]
|
|
const out = new Map(names.map((n) => [n, []]));
|
|
for (const e of traceEvents) {
|
|
if (!want.has(e.name)) continue;
|
|
if (e.ph === "X" && typeof e.dur === "number") {
|
|
out.get(e.name).push(e.dur / 1000);
|
|
continue;
|
|
}
|
|
const key = `${e.pid}:${e.tid}:${e.name}`;
|
|
if (e.ph === "B") {
|
|
if (!stacks.has(key)) stacks.set(key, []);
|
|
stacks.get(key).push(e.ts);
|
|
} else if (e.ph === "E") {
|
|
const st = stacks.get(key);
|
|
if (st?.length) out.get(e.name).push((e.ts - st.pop()) / 1000);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Self-time histogram (in samples) by function, from getCpuSamples output. */
|
|
export function selfTime(cpu) {
|
|
const fns = cpu.functions ?? [];
|
|
const label = (i) => {
|
|
const f = fns[i]?.function;
|
|
if (!f) return `<unknown ${i}>`;
|
|
const owner = f.owner?.name ?? f.owner?.class?.name ?? "";
|
|
const uri = f.location?.script?.uri ?? f.owner?.location?.script?.uri ?? "";
|
|
return `${owner ? owner + "." : ""}${f.name} [${uri.replace(/^package:/, "")}]`;
|
|
};
|
|
const self = new Map();
|
|
const total = new Map();
|
|
for (const s of cpu.samples ?? []) {
|
|
const st = s.stack ?? [];
|
|
if (!st.length) continue;
|
|
const leaf = label(st[0]);
|
|
self.set(leaf, (self.get(leaf) ?? 0) + 1);
|
|
const seen = new Set();
|
|
for (const idx of st) {
|
|
const l = label(idx);
|
|
if (seen.has(l)) continue;
|
|
seen.add(l);
|
|
total.set(l, (total.get(l) ?? 0) + 1);
|
|
}
|
|
}
|
|
const rank = (m) =>
|
|
[...m.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ({
|
|
fn: k,
|
|
samples: v,
|
|
pctOfSamples: +((100 * v) / (cpu.samples?.length || 1)).toFixed(2),
|
|
}));
|
|
return { sampleCount: cpu.samples?.length ?? 0, self: rank(self), total: rank(total) };
|
|
}
|