MonitorHarmony 手机端性能监控的实现
运行图形测试或日常应用时,我们希望在手机上直接看到帧率、CPU 频率、内存和功率的变化,并把这段过程留存下来。MonitorHarmony 将这项工作分成三条链路:采集系统与目标应用的数据,向主界面和悬浮窗发布状态,保存完整会话并生成历史图表。
目录
运行图形测试或日常应用时,我们希望在手机上直接看到帧率、CPU 频率、内存和功率的变化,并把这段过程留存下来。MonitorHarmony 将这项工作分成三条链路:采集系统与目标应用的数据,向主界面和悬浮窗发布状态,保存完整会话并生成历史图表。
这三条链路共享同一份采样结果。主界面负责选择应用和控制会话,悬浮窗负责在目标应用上方显示关键数值,历史页负责解释一段时间内的变化。指标的来源、单位和有效时间,都在进入图表之前处理。
从本机调试连接建立采集会话
MonitorHarmony 在本机使用 HDC 连接设备的调试服务,再准备系统 SP_daemon 的采集通道。用户填写开发者选项中实际开放的 HDC 端口,本机地址固定使用回环地址。连接完成后,应用通过 Native HDC 模块执行必要的准备操作;后续实时数据通过 SP_daemon 的本地 TCP 通道接收。
HDC 在这里承担连接与准备工作,SP_daemon 承担目标应用及系统指标采集。读取电池电量、温度和电流等信息时,还会使用 HarmonyOS 的 batteryInfo 接口。不同来源在 PerfSampler 中整理为统一快照,再交给显示与记录模块。
MonitorHarmony · 采集、发布与记录
指标对象、来源、单位与有效时间在绘图之前处理
100 ms 为应用快照目标,200 ms 为主界面最小发布间隔;浮窗读取共享状态,不另起采集会话。
文字版
| 步骤 | 说明 | 去向 |
|---|---|---|
| 本机 HDC 准备 | 调试连接 / 服务 / 会话确认 | SP_daemon |
| SP_daemon | 目标应用与系统指标 | TCP 分帧与解析 |
| TCP 分帧与解析 | 精确键名 / 逐字段有效期 | PerfSampler 统一快照 |
| batteryInfo | 电池侧单位换算 | PerfSampler 统一快照 |
| PerfSampler 统一快照 | 100 ms 应用目标 · 各字段取最近有效值 | 主界面与浮窗、完整会话记录 |
| 主界面与浮窗 | 共享状态;显示层不重复采集 | |
| 完整会话记录 | 保存原始快照与采样诊断 | 摘要与历史曲线 |
| 摘要与历史曲线 | 摘要首尾各裁 5 秒;原样本保留 |
采集以包名为目标。启动前先处理上一次会话的停止状态,再发送初始化和启动请求,并分别等待对应确认。控制端保留随机令牌,用于与自身的采集会话关联。令牌不作为日志中的普通文本输出,遇到其他受保护会话时也不会直接当成自己的任务接管。
下面摘出实际初始化参数的构造方式。safeBundle 和 safeInterval 已经过校验;controlCommand 负责加入当前会话令牌,sendCommandAndWait 负责命令发送、确认匹配、超时与会话代次检查。这是协议接入位置的节选,依赖连接和停止恢复逻辑。
const initArguments =
`-SESSIONID 1 -INTERVAL ${safeInterval} -PKG ${safeBundle}` +
` -f -c -g -r -d -t -p -gc`;
await this.sendCommandAndWait(
this.controlCommand('init', initArguments),
'init::True', 8000, generation);
await this.sendCommandAndWait(
this.controlCommand('start'),
'start::True', 4000, generation);
采集参数覆盖帧率、CPU、GPU、内存、DDR、温度、电池及计数器请求。SP_daemon 实际输出哪些字段取决于设备;界面为没有取得的指标显示“—”,不显示为 0。
使用前需要在设备上打开调试入口并完成授权,悬浮窗需要在系统设置中允许。使用时先连接 HDC、刷新或搜索目标应用,再开始采集;需要直接进入目标场景时,使用启动并记录入口。
一条快照包含多种测量对象
快照里的字段来自三类对象:目标应用、整机状态和电池侧信息。MonitorHarmony 同时记录这三类,阅读图表时按来源区分。
| 指标 | 主要来源与转换 | 数值表示什么 |
|---|---|---|
| FPS | SP_daemon 的目标应用帧率字段 | 目标应用画面的采样结果;targetFps 单独保存 |
| 应用 CPU 占用 | 进程 CPU 字段;低频兜底读取进程与全机时钟计数差值 | 目标进程执行负载,与整机平均占用不同 |
| CPU 逐核频率 | 采集器逐核字段;缺失时由本地估测路径补齐 | 对应核心的频率读数或估值,内部保存 kHz |
| GPU 频率、负载及绘制调用 | 采集器字段与 GPU Counter 内容 | 设备不输出时显示“—” |
| DDR 频率 | 采集器频率字段 | 以 MHz 展示的频率,不是内存带宽 |
| 进程内存 | PSS 优先,缺失时取 RSS | 目标进程内存;KiB 按 1024 换算 |
| 系统可用内存 | 采集器字段或低频读取 MemAvailable | 全机可用内存,与目标进程内存分开 |
| 电池温度、电量 | 采集器字段与 batteryInfo 补充 | 电池温度与电量,不是 CPU 结温 |
| 功率 | 电压、电流的乘积 | 电池侧电功率幅值,不是单独 CPU/GPU 功耗 |
CPU 兜底算法使用相邻两次的进程 utime + stime 差值,以及系统 CPU 时间总量差值。系统总量已经汇总多个核心,因此计算时乘以核心数,使单个核心持续忙碌对应约 100%,多核并行时可超过 100%。第一次读取、PID 改变或计数异常时,先重新建立基线。
电池功率的单位转换尤其容易出错。SDK 中 voltage 的单位是微伏,nowCurrent 的单位是毫安,所以两者相乘后要除以十亿,才得到瓦。电池温度的单位为 0.1°C,需要除以十。下面是从项目换算逻辑提炼的独立示例,供展示层复用:
export function batteryPowerWatts(voltageMicroV, currentMilliA) {
if (!Number.isFinite(voltageMicroV) || voltageMicroV <= 0 ||
!Number.isFinite(currentMilliA)) return null;
const watts = Math.abs(voltageMicroV * currentMilliA) / 1e9;
return Number.isFinite(watts) && watts <= 100 ? watts : null;
}
功率取绝对值显示幅值。接入充电器时电流方向反转,这时的读数是充电侧功率而不是整机耗电,所以应用间的能耗对比在不充电、同亮度、同网络条件下进行,并使用相同的测量时间窗口。
100 毫秒采样与 200 毫秒界面更新
应用侧以 100 ms 作为采样目标,同时向采集服务请求相同间隔;主界面的状态发布间隔为 200 ms。这样可以保存较密的快照,又控制频繁组件更新带来的开销。浮窗内部还有一次 100 ms 的状态同步检查,它读取共享状态,不会额外启动一轮底层采集。
三个周期各有含义。采样循环每 100 ms 保存一条快照,快照里的每个字段取该数据源最近一次有效值;数据源更新慢于 100 ms 时,相邻快照会重复同一个值。所以“每秒十条”是记录密度,不是每个硬件指标的独立测量次数。
循环调度使用系统运行时钟计算下一次截止时间,避免用“本轮执行完成后再睡 100 ms”的方式不断累积漂移。若本轮已经落后一个或多个周期,就记录延迟和跳过次数,把截止时间向前推进,而不是堆积一串过期任务。快照的时间戳则使用墙钟,便于会话记录与界面时间对应。
应用为每一轮会话分配代次标识。切换目标或停止采集会改变代次;异步任务返回后先核对自己是否仍属于当前会话,确认之后才发布结果。这样,上一轮较慢的内存读取就不会覆盖新目标的数据。
默认的 100 ms 活跃采集中,周期性 shell 兜底被关闭,避免反复调用 hidumper 等工具增加目标设备负担。低频兜底路径有独立节流:普通 shell 至少间隔一秒,进程内存请求至少间隔 1.5 秒,与 UI 的刷新频率无关。
TCP 数据需要先成为完整消息
TCP 提供字节流,一次回调可以只带半个数字,也可以同时带控制确认和多条采样数据。直接对每个网络回调做字段搜索,会把尚未收完的 60 解析成 6,或把 targetFps 当成 fps。
客户端先持续解码 UTF-8,缓存未结束的片段,再查找完整的结构边界。括号计数会识别字符串与转义字符,防止字符串中的花括号影响分帧。完整负载进入解析器后,字段按精确键名保存,帧间抖动等包含分隔符的字符串也不会被随意截断。
新鲜度按字段管理。一次数据包只更新 GPU 字段,不会延长旧 FPS 的有效时间。实时指标使用 3.5 秒有效窗口;时间回退、没有时间戳或超过窗口,都视为不可用。这里的更新时间是客户端收到字段的时间。
以下独立函数展示这项规则。工程内部以负数表示缺失,网页或通用 JSON 消费端使用 null,缺失不替换为零:
export function freshValue(value, updatedAt, now, maxAgeMs = 3500) {
if (![value, updatedAt, now, maxAgeMs].every(Number.isFinite)) return null;
if (value < 0 || updatedAt <= 0 || maxAgeMs < 0) return null;
const age = now - updatedAt;
return age >= 0 && age <= maxAgeMs ? value : null;
}
CPU 核组和频率兜底
CPU 频率同时支持逐核和分组展示。应用保存每个核心的编号、当前频率及最大频率,在最大频率资料完整时按相同最大频率分组,再显示组内平均值。分组依据是频率档位;最大频率资料不完整时,核心归入一个通用组,不套用固定的“几个小核、几个大核”布局。
当采集器缺少逐核频率时,本地 Native 路径执行依赖 ADD 指令链,根据操作量与耗时估算频率。任务以低优先级进入 TaskPool,每批处理两个核心,轮流补齐缓存;该批次请求之间至少间隔 250 ms,缓存有效期为 3.5 秒。
估测的依据是依赖链上每条 ADD 占一个周期。线程调度与系统中断会让估值偏低,估测本身也要在目标核心上执行一段计算,并请求线程 QoS 和亲和性。所以它是采集器缺失逐核频率时的补充路径:各核心的估值分批得到,不是同一时刻的全核快照。
浮窗只接收状态,不重新采集
浮窗通过 TYPE_FLOAT 窗口承载独立 ArkUI 页面。应用维护一个窗口实例,在进入目标应用或主页面转入后台时显示,在回到主页面时按设置收起。窗口内容加载后持续更新状态;核心数量、分组模式或屏幕方向改变时才调整几何尺寸。
主页面把规范化快照写入共享 MetricStore。浮窗使用同一个 LocalStorage 实例,并通过 @LocalStorageLink 与观察回调读取更新。除对象状态外,还提供一个带版本字段的文本快照,作为跨窗口状态传递的稳定入口。这样,底层采样只执行一份,主页面和浮窗看到的是同一轮发布的数据。
浮窗设置透明背景、不可聚焦与触摸穿透,不影响目标应用的操作。位置和大小根据屏幕密度、方向与可显示空间计算,不覆盖状态栏和边缘区域。

图中保留了一次 GFXBench 场景运行时的原始界面,展示浮窗与目标画面共存的效果。浮窗里的帧率是 SP_daemon 对目标应用的采样值,与引擎自己的调试文字统计窗口不同;进程内存显示“—”,表示这次采样没有取得该字段。
逐核频率分批更新,浮窗合并最近一次收到的各核心数据后整体显示,列表不会因为某一批缺项而跳动。完整的逐核时间序列在会话记录里。
完整记录与摘要窗口分开保存
一次会话保留包名、应用名、起止时间、采样间隔、全部快照及采样诊断。停止后,完整数组写入应用自己的记录目录;历史页载入记录并按时间排序。记录文件使用毫秒级时间和经过清理的包名组织文件名,便于多个会话独立保留。
统计时,应用在首个和末个样本时间的基础上,各跳过五秒。这样可以把刚切入场景和结束操作对摘要的影响减小,但原始数组不删除任何点。如果总跨度不足十秒,或者裁剪后不足两个样本,摘要窗口标记为不足;历史曲线仍然显示完整过程。
下面的独立示例返回统计窗口,并保留原始输入不变。它用于解释记录处理方法;实际应用还会把窗口边界、裁剪时长和有效样本数一同保存。
export function summaryWindow(samples, trimMs = 5000) {
if (!Number.isFinite(trimMs) || trimMs < 0) throw new RangeError('trimMs');
const ordered = samples.filter(s => Number.isFinite(s.timestamp))
.slice().sort((a, b) => a.timestamp - b.timestamp);
if (ordered.length < 2) return { samples: [], insufficient: true };
const startAt = ordered[0].timestamp + trimMs;
const endAt = ordered[ordered.length - 1].timestamp - trimMs;
const selected = endAt > startAt
? ordered.filter(s => s.timestamp >= startAt && s.timestamp <= endAt) : [];
return { startAt, endAt, samples: selected, insufficient: selected.length < 2 };
}
历史摘要中的平均帧率、最高/最低帧率、标准差都只使用窗口内的有效 FPS 样本。标准差采用总体公式,即偏差平方的平均值再开方,单位仍是 FPS。“45 FPS 达成率”是达到阈值的有效采样点比例;“低帧样本 5%”是排序后最低一部分 FPS 样本的平均值。两者都基于采样窗口,与逐帧时长计算的低帧指标是两种口径。
汇总按快照计算,每条快照权重相同;底层源更新较慢时,重复值同样计入。它描述的是这份监控记录,与逐帧统计是两种口径。

图中这段记录有 71 个样本、约 7 秒:短会话同样完整保留并绘制曲线,但跨度不足十秒,摘要窗口标记为不足。核心编号分组按该设备的最大频率档位得出。
绘图保留峰值,也保留断点
历史曲线使用完整记录作为输入。绘制点数较多时,应用按时间分桶,保留每桶首点、最小值、最大值和末点,控制绘制规模的同时保留短时峰谷。这个压缩过程只服务于画图,统计仍在原始有效样本上计算。
缺失值、时间倒退及超过三个采样间隔的空档会形成分段。即使压缩前后的点发生变化,也保留各点所属的段,避免把中间没有数据的区域画成连续曲线。CPU 频率、GPU 频率与负载同时出现时,坐标轴分别标明 MHz 和百分比;不同单位不会共用一个没有说明的数值轴。
停止确认与下一次启动
停止操作先使当前代次失效,再停掉采样定时器并清空采样器状态,随后向 SP_daemon 请求停止。只有收到对应确认,才清除持久化的采集恢复标记。若确认丢失或连接中断,界面保留“停止未确认”,下一次连接先完成恢复,避免直接启动第二个相互重叠的会话。
后台数据传输任务也在停止时结束,已有样本再写入历史。浮窗隐藏、应用退到后台、停止采样和采集服务停止是不同事件:显示层的变化不代替底层停止确认。会话记录在停止时一次写入文件;应用在采集中被系统终止时,这次会话不会落盘。
MonitorHarmony 的这套实现,使一次监控可以从应用选择延续到画面上方的实时观察,再进入可回看的记录。使用这些记录做性能判断时,同时保留指标对象、单位、采样节奏与有效时间:频率说明运行状态,帧率描述对应画面的采样表现,电池功率反映电池侧的电气状态。它们在同一时间轴上互相补充,构成可解释的监控过程。
评论
欢迎讨论,不用登录。评论会直接显示;骂人和广告会被删掉。
管理
评论
加载中……