← All articles

MonitorHarmony: implementing on-device performance monitoring for phones

While running graphics tests or everyday apps, we want to see frame rate, CPU frequency, memory and power change right on the phone, and keep a record of the whole run. MonitorHarmony splits this into three paths: collecting data from the system and the target app, publishing state to the main UI and a floating window, and saving the complete session to produce history charts.

This article has no English version yet; the original Chinese text follows. Chart labels are in Chinese as well.

Contents
  1. 从本机调试连接建立采集会话
  2. 一条快照包含多种测量对象
  3. 100 毫秒采样与 200 毫秒界面更新
  4. TCP 数据需要先成为完整消息
  5. CPU 核组和频率兜底
  6. 浮窗只接收状态,不重新采集
  7. 完整记录与摘要窗口分开保存
  8. 绘图保留峰值,也保留断点
  9. 停止确认与下一次启动

运行图形测试或日常应用时,我们希望在手机上直接看到帧率、CPU 频率、内存和功率的变化,并把这段过程留存下来。MonitorHarmony 将这项工作分成三条链路:采集系统与目标应用的数据,向主界面和悬浮窗发布状态,保存完整会话并生成历史图表。

这三条链路共享同一份采样结果。主界面负责选择应用和控制会话,悬浮窗负责在目标应用上方显示关键数值,历史页负责解释一段时间内的变化。指标的来源、单位和有效时间,都在进入图表之前处理。

从本机调试连接建立采集会话

MonitorHarmony 在本机使用 HDC 连接设备的调试服务,再准备系统 SP_daemon 的采集通道。用户填写开发者选项中实际开放的 HDC 端口,本机地址固定使用回环地址。连接完成后,应用通过 Native HDC 模块执行必要的准备操作;后续实时数据通过 SP_daemon 的本地 TCP 通道接收。

HDC 在这里承担连接与准备工作,SP_daemon 承担目标应用及系统指标采集。读取电池电量、温度和电流等信息时,还会使用 HarmonyOS 的 batteryInfo 接口。不同来源在 PerfSampler 中整理为统一快照,再交给显示与记录模块。

MonitorHarmony · 采集、发布与记录

指标对象、来源、单位与有效时间在绘图之前处理

本机 HDC 准备调试连接 / 服务 /会话确认SP_daemon目标应用与系统指标TCP 分帧与解析精确键名 /逐字段有效期batteryInfo电池侧单位换算PerfSampler 统一快照100 ms 应用目标 · 各字段取最近有效值主界面与浮窗共享状态;显示层不重复采集完整会话记录保存原始快照与采样诊断摘要与历史曲线摘要首尾各裁 5 秒;原样本保留

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 同时记录这三类,阅读图表时按来源区分。

指标主要来源与转换数值表示什么
FPSSP_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 场景运行时的 MonitorHarmony 浮窗

图中保留了一次 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 样本的平均值。两者都基于采样窗口,与逐帧时长计算的低帧指标是两种口径。

汇总按快照计算,每条快照权重相同;底层源更新较慢时,重复值同样计入。它描述的是这份监控记录,与逐帧统计是两种口径。

历史页中的 CPU 分组频率与 GPU 曲线

图中这段记录有 71 个样本、约 7 秒:短会话同样完整保留并绘制曲线,但跨度不足十秒,摘要窗口标记为不足。核心编号分组按该设备的最大频率档位得出。

绘图保留峰值,也保留断点

历史曲线使用完整记录作为输入。绘制点数较多时,应用按时间分桶,保留每桶首点、最小值、最大值和末点,控制绘制规模的同时保留短时峰谷。这个压缩过程只服务于画图,统计仍在原始有效样本上计算。

缺失值、时间倒退及超过三个采样间隔的空档会形成分段。即使压缩前后的点发生变化,也保留各点所属的段,避免把中间没有数据的区域画成连续曲线。CPU 频率、GPU 频率与负载同时出现时,坐标轴分别标明 MHz 和百分比;不同单位不会共用一个没有说明的数值轴。

停止确认与下一次启动

停止操作先使当前代次失效,再停掉采样定时器并清空采样器状态,随后向 SP_daemon 请求停止。只有收到对应确认,才清除持久化的采集恢复标记。若确认丢失或连接中断,界面保留“停止未确认”,下一次连接先完成恢复,避免直接启动第二个相互重叠的会话。

后台数据传输任务也在停止时结束,已有样本再写入历史。浮窗隐藏、应用退到后台、停止采样和采集服务停止是不同事件:显示层的变化不代替底层停止确认。会话记录在停止时一次写入文件;应用在采集中被系统终止时,这次会话不会落盘。

MonitorHarmony 的这套实现,使一次监控可以从应用选择延续到画面上方的实时观察,再进入可回看的记录。使用这些记录做性能判断时,同时保留指标对象、单位、采样节奏与有效时间:频率说明运行状态,帧率描述对应画面的采样表现,电池功率反映电池侧的电气状态。它们在同一时间轴上互相补充,构成可解释的监控过程。

文中的独立数据处理函数见 代码说明;图像与采样配置的机器可读说明见 数据说明。

Comments

Discussion welcome, no login needed. Comments show up right away; abuse and ads get removed.

Comments

    Loading…