外观
画面流畅度(Hitch)指标:可行性研究与实施方案
Owner 2026-11-03:当前 FPS 与帧耗时不足以反映画面流畅度,需要研究一套能帮助用户真正发现“画面是否流畅”的指标,给出可行性研究报告与实施方案。研究入口为内部待办《专项研究待办:Hitch 与视觉流畅度评估》(2026-10-29,internal/design/hitch-visual-fluidity-research-backlog-2026-10-29.md)。
本文是研究结论与方案,不是已验证的指标规范;文中所有自算数值均为候选(candidate),未与官方工具配对校准前不得冠以官方同名或进入正式报告。状态 paused 表示等待 Owner 决定是否启动实施。
一、结论摘要
- 可行,且不需要新增采集通道。 产品已在用的 ActivityTrace 流里,iOS 26 的
com.apple.coreanimation / IOMFBServer / FrameUpdate每帧带有计算 Hitch 所需的全部关键时刻:帧开始时刻、实际呈现时刻、渲染开始/结束、GPU 执行区间、就绪时刻,以及每帧的目标刷新间隔(本次研究新发现,见第四节)。滑动区间(UIKit / ScrollView)与 App 提交事件(coreanimation / Transaction / Commit)也已经在同一条流中到达,目前只是被白名单丢弃。 - FPS 与帧耗时为什么不够:它们只看“两帧之间隔了多久”,无法区分“画面本来就不需要更新”和“画面该更新却晚了”。设备 A 150 秒样本中,帧间隔超过 1.5 个刷新间隔的帧有 60 个,绝大多数帧生命周期正常——是内容静止,不是卡顿;另有 24 帧是 ProMotion 主动降频(120→80 Hz)造成的间隔变化,也不是卡顿。
- 建议主指标:卡顿时间比(Hitch Time Ratio,ms/s)= 连续动画期间卡顿时长之和 ÷ 连续动画时长;配套卡顿次数、最长卡顿、时长分布、动画时长与覆盖率,以及 Commit / Render 卡顿分类。平均 FPS 保留为补充,不把 Hitch 折算成 FPS。
- 样本试算(candidate,E3):150 秒样本中连续动画 10.50 秒,检出 6 次卡顿共 91.8 ms,卡顿时间比 8.7 ms/s,最长 37.5 ms;其中 4 次与系统自身标记的
IOMFBServer.Stalls帧重合。 - 主要不确定性:官方“预期帧生命周期”在事件中没有直接给出,需要从稳态推导;动画起始帧、三缓冲吸收的晚帧两类边界的判定,必须与 Instruments「Animation Hitches」在同一会话上配对校准后才能定稿。官方模板只支持录制结束后分析(deferred),只能作为离线校准基准,不能作为产品实时来源。
二、官方口径核实(2026-11-03 读取 Apple 文档 JSON 源)
| 项 | 官方原文要点 | 对本方案的约束 |
|---|---|---|
| Hitch 定义 | 显示没有按预期节奏更新:下一帧未就绪,帧迟到。“dropping a frame is just one potential response… not every hitch causes a frame drop” | 不能用“掉帧数”或固定 16.67 ms 阈值替代 |
| 帧生命周期 | 预期帧生命周期 = begin time → presentation time;实际帧生命周期 = begin time → 实际切换上屏的 vsync;Hitch duration = 实际 − 预期 | 需要每帧的开始时刻、实际呈现时刻与预期生命周期 |
| 双/三缓冲 | 双缓冲预期生命周期 2 个 vsync,三缓冲 3 个;切换到三缓冲以从 Render 卡顿恢复,此后每个 vsync 都有新帧则“isn’t a hitch” | 预期生命周期随缓冲模式变化,不能写死 |
| Commit / Render | App 未赶上 commit deadline → Commit Hitch;App 准时但渲染服务未按时完成 → Render Hitch;commit deadline 通常是呈现前一个 vsync,begin time 再前一个 | 可用“渲染开始是否晚于 commit deadline”区分 |
| 可变刷新率 | 显示按最高刷新率运行,无内容时降低刷新率;“use the begin time, commit deadline, and presentation time of the relevant API instead of vsyncs” | 刷新间隔逐帧取,不用全局常数 |
| 何时报告 | 只有存在交互或其他更新触发时才能检测 Hitch,“When no such trigger exists… the frame can’t be late” | 静止期不计入分母,不能记 0 卡顿 |
| 分级(Xcode Organizer Hitches) | ≤10 ms/s good;≤25 warning;≤50 critical;>50 需立即处理 | 可作展示参考,须注明“参照 Organizer 分级”,不等于官方判定 |
| MetricKit | MXAnimationMetric.scrollHitchTimeRatio(iOS 14+,仅 UIScrollView 滑动);HitchTimeMetric(iOS 27+)“normalized against total animation duration and incorporates perceptual adjustments” | 需改 App 才能拿到,产品不可用;“感知修正”未公开,自算值不得声称等同 |
| Hang | 主线程 run loop 无响应;“Most of Apple’s developer tools start reporting issues when … exceeds 250 ms”;离散交互 100 ms 内很少可察觉 | Hang 与 Hitch 分开评价,第二阶段研究 |
| Instruments 模板 | 既有实验(2026-10-29):Animation Hitches 模板仅 deferred,不支持 immediate/windowed | 只能做录后校准基准 |
历史待办中“旧视频 5/10 ms/s”线索未在本轮文档中找到对应原文,不采用。
三、为什么现有 FPS / 帧耗时看不出流畅度
以设备 A(iOS 26,ProMotion)150 秒、Owner 期间滑动页面的抓包为例(1,046 帧,链式相接 1,045 对):
- 静止与卡顿不可分:帧间隔 > 1.5 个刷新间隔的帧 60 个,其中只有 4 个被系统标为 Stalls;其余多数帧生命周期正常(如 120 Hz 下 25 ms),说明帧“准时完成,只是没有新内容要画”。FPS 会把这段计为低帧率,帧耗时会把它记为长帧。
- 主动降频被误判:滑动减速时系统把刷新率从 120 Hz 降到 80/60 Hz,帧间隔从 8.34 ms 变为 12.5/16.67 ms。若以固定 120 Hz 衡量,会产生 24 个“4.2 ms 卡顿”假阳性。
- 分母失真:150 秒里真正处于连续动画的只有 10.5 秒;整场平均 FPS 把卡顿稀释在 139.5 秒的静止中。
四、数据来源可行性(source-to-contract)
4.1 FrameUpdate 字段语义(E3,设备 A 原始元组解码)
格式串:base_mct=… args1=…#llx args2=…#llx contributors=….*P FrameLifetimeInterval=…(begin_end_32b) FrameRender=…(begin_end_32b) FrameReady=…(event) FramePrevious=…(event) FrameGPUExecute=…(begin_end_32b)。时间单位为 Mach tick(设备 A 为 24 MHz,1 ms = 24,000 tick)。
| 字段 | 原始形态 | 解码 | 判断依据 |
|---|---|---|---|
base_mct | uint64 | 帧开始时刻(begin time) | 其后各偏移均相对于它 |
FrameLifetimeInterval | 4 字节 | 实际帧生命周期(结束偏移);base_mct + 它 = 实际呈现时刻 | 1,045 对与下一帧 FramePrevious 精确相接(既有 E4) |
FrameRender | 8 字节,高 32 位开始、低 32 位结束 | 渲染服务处理区间 | 渲染开始偏移恒约 1 个刷新间隔(120 Hz 7.9–8.7 ms、80 Hz 12.55 ms、60 Hz 16.2 ms),与官方“commit deadline = begin time 后一个 vsync”一致 |
FrameGPUExecute | 8 字节,同上 | GPU 执行区间 | 位于渲染区间内 |
FrameReady | 4 字节 | 帧就绪偏移 | 略晚于 GPU 结束 |
FramePrevious | uint64 | 上一帧实际呈现时刻 | 链式校验 |
args2 低 32 位 | — | 本帧目标刷新间隔(tick) | 只出现 200,044 / 300,066 / 400,088 三种值 = 8.335 / 12.50 / 16.67 ms,对应 120 / 80 / 60 Hz;与相邻呈现间隔一致 |
args1、args2 高 32 位 | — | 未知(args1 低位在 3 个值间轮换,疑为缓冲区标识) | 待证,不使用 |
contributors | data | 参与合成的客户端(未解码) | 待证,见风险 |
类别 IOMFBServer.Stalls | — | 系统自身标记的停顿帧(11/1,046) | 其生命周期均高于同刷新率基线;语义未公开 |
稳态帧生命周期(非 Stalls 帧众数):120 Hz 为 3.0 个间隔(25.0 ms,三缓冲),60 Hz 为 2.0 个间隔(33.4 ms,双缓冲),80 Hz 为 2.33 个间隔(29.2 ms)。说明缓冲模式随刷新率与负载变化,预期生命周期必须动态推导。
4.2 指标所需信息与来源
| 所需信息 | 来源 | 状态 |
|---|---|---|
| 实际呈现时刻 | FrameUpdate base_mct + FrameLifetimeInterval | 产品已解码(E4) |
| 每帧刷新间隔 | FrameUpdate args2 低 32 位 | 新发现(E3),待解析入库 |
| 开始时刻 / commit deadline / 渲染与 GPU 区间 | FrameUpdate base_mct、FrameRender、FrameGPUExecute、FrameReady | 字段已到达,待解析(E3) |
| 预期帧生命周期 | 无直接字段;按同刷新率稳态推导 | candidate,需校准 |
| 系统停顿标记 | IOMFBServer.Stalls 类别 | 已到达;作为辅助证据,不作为唯一判据 |
| 滑动区间 | UIKit / ScrollView / Scroll_Dragging、Scroll_Deceleration、Scroll_DraggingAndDeceleration(150 秒约 45 次) | 已到达、当前被丢弃;需按目标进程过滤 |
| App 提交 | coreanimation / Transaction / Commit(约 62 次/秒)、UIKit / UpdateCycle / UpdateSequence(约 107 次/秒) | 已到达、当前被丢弃 |
| 目标 App 归属 | contributors 或提交事件的进程号 | 待证;暂按 foreground-device |
| iOS 17 设备 | FrameLifetime(含 begin/end/prev/refresh_interval) | 字段可映射,需单独核验 |
| Hang | 主线程 run loop 无响应;候选为目标进程提交/更新序列的长间隔 | 第二阶段研究,无直接来源 |
成本:上述事件已随 onlySignposts=1、predicate="" 的现有订阅传输,不新增 USB 流量与设备侧负载。增量只在主机解析:FrameUpdate 已逐条解析,新增字段为同一次线性遍历;接收 ScrollView 与 Commit 事件约增加 170 条/秒的字段读取,按现有解析成本估计主机 CPU 增量 < 1%(实施时用基准验证)。
五、指标设计(candidate)
均为 foreground-device 范围,算法版本 perfowl-hitch.candidate.v1;未经第六节校准前只在开发者模式显示。
5.1 逐帧判定
对链式相接的帧 i(FramePrevious(i) 等于上一帧呈现时刻):
text
P(i) = base_mct(i) + FrameLifetimeInterval(i) 实际呈现
B(i) = base_mct(i) 开始时刻
V(i) = args2(i) & 0xffffffff 目标刷新间隔
Vx(i) = max(V(i−1), V(i), V(i+1)) 容忍刷新率切换
L*(i) = round(lifetime / V) 的近 16 个非 Stalls 同刷新率帧众数 × V(i) 稳态生命周期
连续(i) = B(i) ≤ P(i−1) 本帧在上一帧上屏前已开始,属于连续动画
E(i) = max(P(i−1) + Vx(i), B(i) + L*(i)) 预期呈现
H(i) = P(i) − E(i),当 连续(i) 且 H(i) ≥ 0.5 × V(i) 时为一次卡顿,否则 0- Commit / Render 分类:渲染开始偏移
FrameRender.begin − 0超过 1.5 × V(i) → Commit 卡顿(App 晚提交);否则 Render 卡顿(渲染服务/GPU 晚完成)。阈值待校准。 - 断链帧、首帧、会话/代际切换:不判定,不计入分母。
5.2 会话级指标
| 指标 | 公式 | 单位 | 回答的问题 |
|---|---|---|---|
| 连续动画时长 A | Σ 连续帧的呈现间隔 P(i) − P(i−1) | s | 分母,有多少时间在动 |
| 卡顿时间比(主指标) | ΣH ÷ A | ms/s | 动的时候卡了多少 |
| 卡顿次数 | #{H > 0};另报每分钟动画的次数 | 次 | 频繁短卡还是偶发 |
| 最长卡顿 | max H | ms | 最严重的一次 |
| 卡顿时长分布 | 按刷新间隔个数分档:1 个、2–3 个、≥4 个 | 次 | 卡顿轻重结构 |
| Commit / Render 占比 | 两类 ΣH 各占比例 | % | 优化方向是 App 主线程还是渲染 |
| 滑动段卡顿时间比 | 仅统计目标进程 ScrollView 区间内的帧 | ms/s | 与 MetricKit 滑动口径同类比较 |
| 覆盖率 | 有效判定帧时长 ÷ 会话中帧链覆盖时长 | % | 数据是否可信 |
5.3 质量语义
- A = 0(整场静止)时,卡顿类指标为 not-applicable,显示“无动画”,不写 0。
- 断链区间、来源缺失区间不计入 A,覆盖率如实下降;覆盖率 < 80% 时报告标注“覆盖不足”。
- 刷新率切换(Vx)只用于容忍,不改变 V(i) 的记录值。
- 展示分级参照 Xcode Organizer:≤10 良好、≤25 注意、≤50 严重、>50 需立即处理,明确标注“参照 Apple Organizer 分级,非官方判定”。
- 名称:界面使用“卡顿时间比(Perfowl)”,不使用“Apple Hitch Ratio”等官方同名。
5.4 实时与报告
- 实时:每秒输出“本秒卡顿时长(ms)”与“本秒是否在动”,工作台新增一条曲线,静止秒留空。
- 报告:画面表现区新增卡顿时间比卡片(附分级色、动画时长、覆盖率)、卡顿时间轴标记(点击定位对应秒的 CPU/GPU/截图)、Commit/Render 占比;Excel 逐秒页新增“卡顿时长(ms)、是否动画”,逐帧页新增“刷新间隔、帧生命周期、卡顿时长、卡顿类型”。
六、样本试算(candidate,E3)
数据:设备 A,2026-11-02 150 秒抓包(Owner 期间滑动),算法 5.1。
| 项 | 值 |
|---|---|
| 连续动画时长 | 10.50 s(1,011 帧;静止断开 34 处) |
| 卡顿次数 / 总时长 | 6 次 / 91.8 ms |
| 卡顿时间比 | 8.7 ms/s(参照分级:良好) |
| 最长卡顿 | 37.5 ms |
| 时长 | 4.2、8.3、8.3、12.5、20.9、37.5 ms |
| 与系统 Stalls 重合 | 4 / 6 |
| 未容忍刷新率切换时 | 31 次、19.5 ms/s(24 次为 ProMotion 降频假阳性) |
未被检出的 7 个 Stalls 帧:3 个是静止约 10 秒后动画的第一帧(生命周期偏长,但不满足“连续”);4 个生命周期偏长但呈现节奏未受影响(三缓冲吸收)。这两类是否计为 Hitch,必须以 Instruments 结果为准(第七节 Phase 2)。
七、实施方案
| 阶段 | 内容 | 产出与门禁 | 估计 |
|---|---|---|---|
| Phase 1 数据保留 | 解析器保留 V、B、渲染/GPU/就绪偏移、Stalls 标记,写入逐帧记录;白名单接收目标进程 ScrollView 与 Commit 事件;离线重算工具(读取会话逐帧记录输出 5.1/5.2)。iOS 17 FrameLifetime 映射核验。 | 单元:原始元组样例逐字段解码、begin_end_32b 拆分、刷新率切换容忍、静止不计;基准:解析 CPU、内存、逐帧记录体积前后对比(每帧增加约 40 字节) | 1–2 天 |
| Phase 2 校准 | Fixture 新增可控卡顿场景:DisplayLink 匀速动画中按计划时刻注入主线程阻塞 20/50/100 ms(Commit 卡顿)、重渲染负载(Render 卡顿)、静止→动画起步、ProMotion 降频减速。同一会话同时录 Instruments「Animation Hitches」(deferred)与 Perfowl,导出后按设备 Mach 时间逐个对齐。另选 2 个真实 App 滑动场景。 | 逐次卡顿召回率与精确率 ≥ 95%;单次时长误差 ≤ 1 个刷新间隔;卡顿时间比与 Instruments 差 ≤ max(10%, 2 ms/s);两类边界规则按结果定稿,公式版本升为 v1(非 candidate)。若并行录制互相干扰,改为 Fixture 确定性重放分别录制 | 2–3 天,需 Owner 配合约 30 分钟 |
| Phase 3 产品化 | 实时每秒卡顿曲线;报告卡片、时间轴标记、分级;Excel 列;注册 metricID(卡顿时间比、次数、最长、动画时长、Commit/Render 占比);知识页按十要素发布;首页与总览联动 | 真机 E4:Owner 在新包上录含滑动/静止/转场的会话,报告数值与离线重算一致;静止会话显示“无动画” | 2–3 天 |
| Phase 4 诊断与 Hang | 卡顿时刻关联主线程/CPU/GPU 证据;contributors 解码以实现目标 App 归属;Hang(≥250 ms 主线程无响应)候选来源研究 | 另立方案 | 待定 |
执行效率要求:解析为逐帧 O(1) 增量,众数窗口固定 16 帧;不新增进程、轮询或全量扫描;Phase 1 交付时附解析吞吐与内存前后对比。
八、风险与待证
- 预期生命周期推导:官方以 API 提供的 presentation time 为准,事件未直接给出预期值,稳态众数可能在缓冲模式切换瞬间误判——Phase 2 以 Instruments 对齐验证。
args2语义:刷新间隔解读基于 3 种取值与呈现间隔一致的统计证据(E3),未见官方说明;Phase 1 增加断言,出现其他取值时降级为“刷新间隔未知”、不判定。- 系统更新风险:FrameUpdate 为私有 signpost,iOS 版本变更可能改字段;解析失败按
unsupported-schema退化,不输出卡顿数。 - 前台归属:合成器帧包含系统 UI(状态栏、键盘),范围为 foreground-device;目标 App 精确归属依赖 Phase 4。
- 感知修正:Apple
HitchTimeMetric的感知修正未公开,Perfowl 值与 Organizer/MetricKit 数值不承诺相等。 - 并行录制干扰:Instruments 与 Perfowl 同时占用 DVT 可能互相影响,Phase 2 首先验证。
九、需要 Owner 决定
- 是否按上述阶段启动(建议先做 Phase 1 + Phase 2,校准通过后再做界面)。
- Phase 2 需要 Owner:保持设备 A 连接解锁、Xcode Instruments 可见设备,按脚本在 Fixture 与两个真实 App 上操作约 30 分钟。
- 界面名称:建议“卡顿时间比”(主)、“卡顿次数”“最长卡顿”“动画时长”。
Owner 决策 · 2026-11-03
启动 Phase 1(数据保留)与 Phase 2(配对校准);界面名称采用“卡顿时间比 / 卡顿次数 / 最长卡顿 / 动画时长”。Phase 3 在 Phase 2 校准通过后另行汇报再启动。
Phase 1 Plan · 2026-11-03:数据保留与离线重算
范围调整(Lead 决定):ActivityTrace 白名单是冻结的隐私分发协议,逐帧记录同时被实时、离线与 Swift 报告读取;接入 ScrollView / Commit 事件需改动这两处协议。它们只服务“滑动段卡顿时间比”与诊断,不影响主指标校准,因此移到 Phase 2 收尾(校准用的滑动区间直接取自 Instruments 轨迹)。Phase 1 只做逐帧时序保留、卡顿算法与离线重算。
- 解码保留(
display_performance.py):FrameUpdate 在既有字段之外解析args2低 32 位、FrameRender、FrameGPUExecute、FrameReady与类别(Stalls);逐帧输出新增frameTiming对象:lifetimeNs、targetIntervalNs、renderBeginNs/renderEndNs、gpuBeginNs/gpuEndNs、readyNs(均为相对帧开始的 ns 偏移)、stall。iOS 17 FrameLifetime 输出lifetimeNs与targetIntervalNs(取refresh_interval)。目标刷新间隔只接受 1–100 ms,否则为空。refresh_interval、帧耗时、FPS、P95、刷新率等既有字段与公式一律不变(不把args2喂给 12× 长间隔门)。 - 卡顿算法(新
hitch_metrics.py,perfowl-hitch.candidate.v1):按方案 5.1/5.2 逐帧增量计算;为取得 V(i+1) 延迟一帧定稿;稳态生命周期按目标刷新间隔分组、各保留最近 16 个非 Stalls 帧;输出逐帧结果、每秒桶(卡顿 ms、动画 ms)与会话汇总。O(1)/帧。 - 离线重算:采集组件新增
hitch-recompute --session DIR [--frames-csv PATH],读取会话frame-events-live.jsonl输出汇总 JSON(与 Phase 2 对齐脚本共用)。 - 测试:真机原始元组样例逐字段解码;合成序列覆盖稳态无卡顿、单帧迟到、静止断开不计、ProMotion 降频容忍、三缓冲切换、无动画 not-applicable、断链与 warmup;2026-11-02 设备 A 150 秒样本(仅时间戳)回放复现方案第六节数值。
- 基准:同一批原始 FrameUpdate 的解码吞吐前后对比、逐帧记录字节数前后对比、卡顿累加器吞吐。
Phase 1 Implementation Update · 2026-11-03
| 文件 | 修改 |
|---|---|
perfowl_collector_core/display_performance.py | FrameUpdate 额外解析 args2 低 32 位、FrameRender、FrameGPUExecute、FrameReady;新增 frame_timing() 与 signpost_identifier(),逐帧输出 frameTiming:lifetimeNs、targetIntervalNs(仅 1–100 ms)、renderBeginNs/renderEndNs、gpuBeginNs/gpuEndNs、readyNs、stall(仅为真时写出)、signpostId(64 位,含 swap_id,用于与 Instruments 对齐)。iOS 17 FrameLifetime 输出 lifetimeNs 与 targetIntervalNs。refresh_interval、帧耗时、FPS、P95、刷新率字段不变。 |
perfowl_collector_core/hitch_metrics.py(新) | perfowl-hitch.candidate.v1:HitchAccumulator 逐帧增量判定(一帧前瞻),会话汇总、每秒桶、逐帧结果;recompute_session() 流式读取 frame-events-live.jsonl。 |
perfowl_collector_core_cli.py | 新增 hitch-recompute --session DIR [--frames-csv PATH]。 |
tests/test_hitch_metrics.py(新)、tests/fixtures/frameupdate_150s_timing.json(新,仅时间戳) | 16 项:真机原始元组逐字段解码、Stalls 与异常刷新间隔、iOS 17 映射、稳态无卡顿、单帧 Commit 卡顿、Render 卡顿、静止断开、ProMotion 降频、缓冲模式切换只计一次、静止会话 not-applicable、断链/无时序不判定、Stalls 不进稳态、分级、150 秒回放、会话流式重算。 |
偏差
- ScrollView / Commit 事件接入移到 Phase 2 收尾(见 Phase 1 Plan)。
- 新增
signpostId:Instruments 表以 swap ID 标识帧,按 swap ID 对齐比按时钟对齐可靠;两半中哪一半是 swap_id 由配对数据判定(150 秒样本中两半都基本逐帧加 1,无法单独判定)。
验证(E2)
- 新增 16 项通过;150 秒设备 A 样本回放:连续动画 10.504 s、卡顿 6 次 91.776 ms、8.737 ms/s、最长 37.537 ms、Stalls 11 个中 4 个落在卡顿帧,与研究报告一致。
- Python 全量 3160 项,失败仅为既有 16 项环境基线(
native_direct_archive、native_raw_graph、sw1)。 - 基准(同一批 5 万条合成 FrameUpdate,x86_64):解码 34.9 → 44.9 µs/帧(单条微基准 31.3 → 38.9 µs,
frame_timing自身 2.6 µs);按 120 Hz 持续动画最坏情况约多占 0.09% 个核。逐帧记录 915 → 1,090 字节/行(+19%),持续 120 Hz 动画约多写 76 MB/小时,静止期几乎无帧。卡顿累加器为 O(1)/帧;150 秒样本 1,046 帧重算 < 10 ms。
遗留
- 逐帧记录体积增量:Phase 3 评估把诊断字段(渲染/GPU/就绪偏移)改为紧凑编码或仅开发者模式写出。
- iOS 17
FrameLifetime的begin_time是否同为帧开始时刻,未在 iOS 17 真机核验。
Phase 2 Prep · 2026-11-03:校准工具与前置条件
已完成(E1/E2):
- Fixture 卡顿校准场景(
fixtures/ios/PerformanceProbeFixture/.../FixtureHitchCalibrationView.swift):60 秒固定时间表——静止 5 s → 120 Hz 动画 15 s(第 2/5/8/11 s 注入主线程阻塞 20/50/100/200 ms)→ 静止 5 s → 重渲染动画 15 s(160 个带阴影与模糊的图层)→ 静止 5 s → 干净动画 15 s;进入页面时暂停主界面常驻显示回调以保证静止段真的不更新;每次注入写设备 Mach 时间到Documents/PerfowlHitchMarkers-<run>.jsonl。swiftc 类型检查通过。 - 对齐脚本
tools/validation/hitch/align_instruments.py:读取 Perfowl 会话与 Instrumentshitches、hitches-frame-lifetimes导出,按 swap ID 对齐,输出召回率、精确率、逐次时长差、帧生命周期差、同分母卡顿时间比与四项门禁;可选读取 Fixture 标记核对每次注入。合成数据测试 2 项通过;已能解析本机 Instruments 真实导出(Mac 3 秒轨迹:10 行去重为 2 次卡顿、62 个帧生命周期)。 - 配对录制脚本
tools/validation/hitch/record_instruments.sh:设备标识只取环境变量,录制后自动导出两张表并记录耗时与体积。 - 本机 3 秒 Mac 轨迹实测:录制与收尾共 105 秒、CPU 110 秒,再次确认官方模板只适合离线校准。
阻塞(需 Owner 操作):
- 本机 Xcode 26.6 未安装 iOS 26.5 平台组件(
xcodebuild报 “iOS 26.5 is not installed”),导致 xctrace 将设备 A 列为离线、也无法为真机构建 Fixture。需在 Xcode › Settings › Components 安装 iOS 26.5(数 GB)。 - 本机开发描述文件均不含设备 A;Fixture 真机安装需在 Xcode 中用开发团队自动签名登记设备(会修改开发者账户的设备列表),或由 Owner 在 Xcode 中直接运行安装。
- 两者完成后执行:Owner 打开 Perfowl 开始测试 → 运行
record_instruments.sh(75 秒)→ 手机上进入 Fixture「卡顿校准」点开始 → 结束后对齐;再在 2 个真实 App 上各滑动 60 秒重复一次。
Validation Update · 2026-11-03:内部测试包 250 安装
- 来源:main
412a5f49(源码门禁 passed);DMGPerfowl-250-macos-x86_64-internal.dmg,SHA-256714e0abb94e1ad0f20c160bbaed0ed7eec27b672fbee266044a4b7ab5733193a,145,455,163 字节。 codesign --verify --deep --strict通过;安装后目录树与 DMG 一致;冻结 Collector 109 个源模块与源码一致,CLI 与源码一致;包内hitch-recompute对 150 秒样本复现 6 次 / 91.776 ms / 8.737 ms/s(E2)。- 公证按 Owner 决定暂缓(Gatekeeper 退出码 3,符合预期);回滚包 249 已校验。
- 启动器唯一:
mdfind与lsregister均只剩/Applications/Perfowl.app(E3)。 - 真机记录新字段(E4)尚未进行,随 Phase 2 配对录制一并核验。
Phase 2 执行方案 · 2026-11-03(1.0.0 发布后)
前置条件进展:Owner 同意由 Claude 安装 iOS 26.5 平台组件。xcodebuild -downloadPlatform iOS 两次在注册阶段失败(“Expected a single .dmg file, but found multiple []”),原因是系统模拟器运行时缓存中有一个 2026-06-12 遗留的 48 KB 不完整资产,Xcode 将其当作已下载。删除需管理员权限,由 Owner 执行删除后重新下载。安装前 xctrace record 对设备 A 报“Timed out waiting for device to boot”(CoreDevice 显示已配对可用)。
调整:先不做 Fixture(需要签名决定),先用 2 个真实 App 滑动与 Instruments 对照;Fixture 在需要精确定位时再做。
执行顺序(Owner 约 15 分钟,Claude 不在手机上安装或删除任何东西):
- 3 秒试录确认 Instruments 能连上设备 A。
- 对照 1:Owner 在 Perfowl(1.0.0)选目标 App A 并开始测试;Claude 同时运行
record_instruments.sh(75 秒);Owner 在 App A 中滑动 60 秒(含快速滑动、停顿、再滑动);结束后停止测试。 - 对照 2:目标 App B 重复第 2 步。
- 样本采集:Perfowl 停止状态下,Claude 运行只读脚本
probe_scroll_commit.py30 秒,Owner 在任一 App 滑动;保存ScrollView区间与Transaction/Commit事件原始样本,用于确定解析字段(区间开始/结束配对方式、目标进程过滤字段)。 - Claude 用
align_instruments.py对齐两组数据,按第六节门槛(召回率与精确率 ≥ 95%、单次时长误差 ≤ 1 个刷新间隔、卡顿时间比差 ≤ max(10%, 2 ms/s))给出结论;若 Perfowl 与 Instruments 同时订阅互相干扰(任一方丢帧或断流),改为分别录制同一操作并只比较分布,结论降级为 partial。
滑动 / 提交事件接入:等第 4 步样本后写解析方案。改动点为 ActivityTrace 白名单(is_frame_lifetime_signpost)与逐帧记录协议,按 Phase 1 范围调整中的说明在 Phase 2 收尾实施。
Implementation Update · 2026-11-03:Instruments 前置条件就绪
- iOS 26.5(23F77)平台组件安装完成,
simctl runtime list显示 Ready(E3)。 - 安装后
xctrace record仍报“Timed out waiting for device to boot”。原因是设备 A 的 CoreDevice 通道处于 disconnected,xctrace 不会主动建立通道。执行一次只读的devicectl device info details后,通道变为 connected,开发者磁盘镜像服务可用,设备 A 出现在 xctrace 在线列表中(E3)。 - 用 Animation Hitches 模板对全部进程试录 3 秒成功,生成 71 MB 的 trace,包含
hitches、hitches-frame-lifetimes、hitches-renders、hitches-updates、hitches-gpu、hitches-framewait与display-*表(E3)。试录期间未在手机上安装或删除任何东西。 tools/validation/hitch/record_instruments.sh录制前先执行上述只读查询来建立通道。- 下一步:Owner 配合对照 1 / 2 与样本采集(见本节步骤 2–4)。
Validation Update · 2026-11-03:对照 1 失败(Instruments 与 Perfowl 同时录制)
- 第一次开录报“No devices found”:xctrace 的在线设备列表比 CoreDevice 通道晚一拍。脚本改为建立通道后最多等 30 秒,直到 xctrace 把设备列为在线再开录。第二次正常开录(E3)。
- 对照 1(目标 App A,Perfowl 测试同时进行,限时 75 秒):xctrace 到时后停在“Stopping recording…”超过 12 分钟。trace 只写出原始
trace-data.atrc,共 19.4 GB,缺少instrument_data/shared_data等,无法导出。系统临时目录另有同次的 19 GB ktrace。磁盘可用空间降到 11 GiB 后,Claude 中断了 xctrace(E3)。 - 对比:同一设备只开 Instruments、全部进程试录 3 秒,得到 71 MB,按比例 75 秒约 1.8 GB。本次约为 10 倍。未证假设:Perfowl 的 coreprofilesessiontap 与 Instruments 同时订阅设备 kdebug,双方相互影响。需要单独录 Instruments 来排除,在此之前不作定论。
- 清理:删除系统临时目录中已无进程引用的 3 个 instruments ktrace(3 s 试录、本次、上午探测的临时文件)。失败的 trace 暂时保留,可否删除由 Owner 决定。
- 依照第六节“相互干扰则改为分别录制”的预案,对照改为:Instruments 单独录 30 秒 → Perfowl 单独测同一操作,只比较分布,结论上限为 partial。Owner 在本次对照中完成的 App B 滑动没有 Instruments 录制,不计入。
- Owner 决定(2026-11-03):原始数据较大时用完即删。已删除失败的 19.4 GB trace 与已用完的 3 秒试录 trace,可用空间回升到 30 GiB。
record_instruments.sh改为导出表格后删除 trace(设KEEP_TRACE=1时保留),后续只保留导出表格、数字与本记录。
Validation Update · 2026-11-03:分别录制对照(App A / App B,各 30 秒)
方法:Instruments(Animation Hitches,全部进程)单独录 30 秒 → Perfowl 单独测同一页面、同样滑动 30 秒,只比较分布,结论上限 partial。工具改进:record_instruments.sh 开录前重复建立 CoreDevice 通道,直到 xctrace 列为在线(最多约 20 秒,否则退出);align_instruments.export_tables 对 xctrace export 偶发的 SIGSEGV(Xcode 26.6,Intel,崩溃在 autorelease pool 释放)最多重试 5 次。单独录 30 秒的 trace 为 551 MB / 312 MB,导出后删除。
| App A · Instruments | App A · Perfowl candidate v1 | App B · Instruments | App B · Perfowl candidate v1 | |
|---|---|---|---|---|
| 帧节奏 | 8.3 ms 为主(120 Hz) | 8.3 ms 为主 | 应用帧 16.7 / 33 ms 为主 | 目标间隔 8.3 / 12.5 / 16.7 ms 混合 |
| 时长 | 约 28 s 逐帧数据 | 动画 34.2 s | 约 30.5 s 逐帧数据 | 动画 31.7 s |
| 卡顿次数 | 8 | 9 | 0 | 45 |
| 单次时长 | 均 12.5 ms | 8.3 ms ×8、16.7 ms ×1 | — | 16.7 ms 为主,最大 33.4 ms |
| 卡顿时间比 | 约 3.6 ms/s | 2.4 ms/s | 0 | 22.4 ms/s(warning) |
结论(E4,真机;分别录制,不能逐次配对):
- App A(120 Hz 持续滑动):分布在门槛内(时间比相差约 1.2 ms/s ≤ 2 ms/s,单次时长相差约 4.2 ms,不到一个刷新间隔),partial 通过。Instruments 报的单次时长系统性地多约半帧,原因待查。
- App B(应用自行降低帧率 / 刷新率可变):Perfowl 报 45 次卡顿,Instruments 为 0,FAIL。待证假设:candidate v1 以 FrameUpdate 的
targetIntervalNs推算预期上屏时间,刷新率在 8.3 / 12.5 / 16.7 ms 之间切换时,把正常的降频帧判成了卡顿;Instruments 按应用请求的帧节奏判定。 - 在弄清 App B 误报并修正算法之前,卡顿指标保持 candidate,不进入产品界面。
- 下一步:在 App B 上做第 4 步原始事件采样(
probe_scroll_commit.py30 秒),并查看 Perfowl 逐帧的 target interval 与 lifetime,确定判定预期上屏时间的依据。
Validation Update · 2026-11-03:App B 原始事件采样与误报初查
- 采样:Perfowl 停止状态下,
probe_scroll_commit.py只读订阅 ActivityTraceTap 30 秒,Owner 在 App B 滑动。共有 2748 个 UIKitUpdateCycle/UpdateSequence、2146 个Transaction/Commit、880 个IOMFBServer/FrameUpdate,ScrollView区间 Dragging 46 / DraggingAndDeceleration 47 / Deceleration 37 / Animating 2。另有UpdateCycle.Stalls/UpdateSequence8 个、IOMFBServer.Stalls/FrameUpdate7 个(E3)。原始样本约 2 MB,保存在私有目录。 Commit带transaction_seed与begin_time,作用域是进程,可按目标 App 进程过滤;FrameUpdate来自系统合成进程,ID 由 update_seed 与 swap_id 组成。两者可以通过 seed 关联。- 前一假设被推翻:App B 的 45 次 Perfowl 卡顿中,36 次的前后帧目标间隔都是稳定的 16.67 ms,与刷新率切换无关(切换处只有 7 次)。
- 新的待证假设:App B 本身约每 33 ms 才提交一帧新内容(Instruments 逐帧表中 32.9 ms 最多),屏幕按 60 Hz 合成时会有“没有新内容”的空档。candidate v1 只看整机 FrameUpdate 的目标间隔,把这些空档算成了卡顿;Instruments 只在“已提交的帧比预期晚上屏”时才算卡顿。
- 修正方向(待写入方案后再实施):用目标 App 进程的
Transaction/Commit确定哪些帧真正由该 App 提交,预期上屏时间以提交为起点计算,没有新提交的合成帧不参与判定。这就是 Phase 2 收尾计划中的“ScrollView / Commit 接入”。效率评估:Commit 全机约 70 条/秒,按进程过滤后只保留目标 App。
Phase 2 修正方案 · 2026-11-03:candidate v2(连续判定留出起始余量)
Owner 2026-11-03:“继续,写方案并实现”。
证据(E3,离线重放两次真机会话的逐帧数据)
- App B 的 36 次 16.7 ms 卡顿,都是同一种帧:目标间隔 16.7 ms(60 Hz),开始时刻 B(i) 正好等于上一帧上屏 P(i-1)(差 0~0.02 ms),寿命 33.4 ms,上屏间隔 33.4 ms。它与“开始晚于上一帧上屏 16.7 ms、寿命 16.6 ms”的帧交替出现,整体是 App 按 30 帧/秒正常出帧(Instruments 逐帧表的 32.9 ms 间隔与此一致)。
- v1 的连续判定
B(i) <= P(i-1)包含等号,所以这类“上一帧上屏时才开始”的帧被算进动画,并按 60 Hz 的预期判成晚了一帧。 - 真正连续出帧时,下一帧在上一帧上屏前约 0.39~0.49 ms 就开始(两台会话的 B(i) − P(i-1) 分布:120 Hz 集中在 −0.48 / −0.49 ms,60 Hz 在 −0.39~−0.49 ms),与 0~−0.02 ms 的“上屏时才开始”之间有明显空档。
- 也试过只按寿命判定(去掉 P(i-1)+V 一项),误报反而更多(App A 65 次、App B 285 次),已否决。
公式变更(仅 5.1 的连续判定)
text
continuous(i) = B(i) <= P(i-1) − M, M = 0.2 ms (v1:M = 0)其余(预期上屏、卡顿时长、动画时间、会话汇总、质量语义)不变。M 取两类分布空档的中点;在 0.05~0.3 ms 之间结果基本稳定(App B 0.1~0.3 ms 都是 4 次,0.05 ms 时 5 次)。算法版本改为 perfowl-hitch.candidate.v2,仍是候选值,不进产品界面。
离线预期(E3)
| 会话 | v1 次数 / 时间比 | v2 次数 / 时间比 | Instruments(分别录制) |
|---|---|---|---|
| App A 单独 | 9 / 2.44 ms/s | 7 / 1.72 ms/s | 8 / 约 3.6 ms/s |
| App B 单独 | 45 / 22.36 ms/s | 4 / 2.09 ms/s | 0 / 0 |
| 同时录制失败那次的 App A / App B 会话 | 30 / 5.66;22 / 4.55 | 22 / 4.31;4 / 1.01 | 无 |
- App B 的时间比差为 2.09 ms/s,略高于 2 ms/s 的门槛。剩下的 4 次(8.3 ms ×2、12.5、33.4 ms)可能是真实卡顿,因为两边不是同一次滑动,无法逐次核对。所以结论只能写“误报的主要来源已消除”,不能写 PASS。
- 风险:M 是两台会话、一部设备(120 Hz ProMotion,iOS 26)得到的经验值;60 Hz 机型与其他系统版本需在能力确认阶段复核。
实施与验证
hitch_metrics.py:新增常量CONTINUITY_MARGIN_NS = 200_000,判定改为begin > before.present - CONTINUITY_MARGIN_NS则不连续;HITCH_ALGORITHM改为 v2;文档字符串同步。- 单测:新增“上屏时才开始的 30 帧节奏不是卡顿”;“晚一帧提交”等原有用例保持通过;150 秒回放的基准数字若变化,按新结果更新并在此记录。
- 效率:判定仍是每帧 O(1),无新增数据。对比改动前后离线重算 4 个会话的耗时。
- 真机:再找一个自带限帧的 App 复核(Owner 配合,可与 iOS 版本能力确认一起做)。
Implementation Update · 2026-11-03:candidate v2
| 文件 | 修改 |
|---|---|
MacApp/Sidecar/perfowl_collector_core/hitch_metrics.py | CONTINUITY_MARGIN_NS = 200_000;连续判定改为 B(i) <= P(i-1) − M;HITCH_ALGORITHM = "perfowl-hitch.candidate.v2";文档字符串写明原因与设备 A 的分布 |
MacApp/Sidecar/perfowl_collector_core/display_performance.py | 注释中的算法版本号 |
MacApp/Sidecar/tests/test_hitch_metrics.py | 新增 test_app_paced_below_display_rate_is_not_a_hitch(60 Hz 下 App 每两次刷新出一帧、在上一帧上屏时才开始):v2 为 0 次,把 M 改回 0 时为 8 次(已验证会失败);150 秒回放基准中连续帧 1011 → 1007、动画时间 10.50 → 10.37 s、时间比 8.7 → 8.85 ms/s,卡顿次数(6)、总时长(91.8 ms)、最大值(37.5 ms)不变 |
偏差:无。方案中“对比 4 个会话”的结果与离线预期一致:App A 7 次 / 1.715 ms/s,App B 4 次 / 2.092 ms/s;同时录制那次的两个会话 25 次 / 4.468 和 4 次 / 1.005。离线预期用的是简化脚本,与正式实现的帧链接条件略有差别,后两个数字(预期 22 / 4)以本段为准。
验证:
- 卡顿单测 16 项全部通过;Sidecar 全量 3162 项,失败与错误只有已知的 16 项环境基线(native_direct_archive 11、native_raw_graph 4、sw1 1),无新增(E2)。
- 效率:4 个会话离线重算,改动前后交替各测 2 轮,每轮取 5 次最快值。改动前 111~269 ms,改动后 111~271 ms,差异在噪声范围内;内存峰值约 30 MB,不变。判定仍是每帧 O(1)(E3)。
- 真机:用的是本次两个 App 的真机会话离线重算(E3)。新 Collector 尚未打包,仍需在一个自带限帧的 App 上复核。
状态:v2 已实现,仍是 candidate,不进产品界面。App B 时间比差 2.09 ms/s,略高于门槛(Instruments 为分别录制),不能写 PASS。
遗留:
- 打包:Collector 有改动,但卡顿指标只在开发者模式下离线重算,不影响 1.0.0 的界面与已有指标。双架构包需要 Apple 芯片机器重新冻结 arm64 工具,因此本批不单独打包,等下一批产品改动一起打包公证,交付状态记为 partial。
- Instruments 报的单次卡顿时长比 Perfowl 系统性地长约半帧,原因待查。
- M 需要在 60 Hz 机型和其他系统版本上复核,与 iOS 15+ 能力确认一起做。
Phase 2 方案 · 2026-11-04:Fixture 限帧校准(v2 真机复核)
Owner 2026-11-03:验收用的 Fixture 能做限帧;授权 Claude 构建并安装 Fixture 到设备 A(Xcode 自动签名,允许登记设备;只动这一个测试 App)。
- 校准时间表扩展为
hitch-calibration/2(100 秒):原有 60 秒不变(静止 → 120 Hz 动画含 20/50/100/200 ms 注入 → 静止 → 重渲染 → 静止 → 干净 120 Hz);之后追加“静止 5 s → 30 Hz 限帧动画 15 s(第 3 / 9 s 注入 50 / 100 ms)→ 静止 5 s → 60 Hz 限帧动画 15 s(无注入)”。限帧段用CADisplayLink.preferredFrameRateRange = (hz, hz, hz);每段开始的标记写入hz。 - 真值与判定:
- 120 Hz 与 30 Hz 段的每次注入都应识别为卡顿:时间对得上,时长误差不超过一个刷新间隔。
- 30 Hz、60 Hz 段除注入外不应有卡顿:v1 预期会误报,v2 应为 0 或接近 0。
- 静止段不计入动画时间。
- 标记用设备 Mach 时间,与 Perfowl 帧时间戳同源,可以直接对齐。标记文件在 Fixture 自己的 Documents 里,Claude 只读取这一个开发签名 Fixture 的容器。
- 执行:Fixture 真机构建 →
devicectl device install app只装 Fixture → Owner 开 Perfowl 测 Fixture,在 Fixture 里进入“卡顿校准”点开始,等 100 秒 → Claude 读取标记,用 v1 / v2 各重算一次后对比。整个过程不需要 Instruments。 - 效率:Fixture 只增加一个时间表分支;离线对比是一次流式读取,单会话 < 1 秒。
Implementation Update · 2026-11-04:Fixture 限帧校准
FixtureHitchCalibrationView.swift:每个时间段带hz(默认 120);新增 30 Hz(注入 50 / 100 ms)与 60 Hz 两个限帧段,总长 100 秒;每段开始时设置preferredFrameRateRange,120 Hz 段保持原来的 80~120 范围;标记写入hz,计划名改为hitch-calibration/2;入口文字改为“100 秒”。build.sh:证书主题匹配兼容 OpenSSL 3 的OU = X格式(此前只认 LibreSSL 的OU=X,在本机找不到开发身份)。- 验证:不签名真机编译
BUILD SUCCEEDED(E2)。 - 阻塞:真机签名构建报 “No Accounts”,描述文件不含设备 A,原因是本机 Xcode 没有登录开发者账号,自动登记设备做不了。Claude 改用已保存的 API 密钥登记,被本机权限机制拦下,未绕过。三条出路由 Owner 选:
- 在 Xcode › Settings › Accounts 登录开发团队账号后,Claude 重跑构建;
- Owner 在 Xcode 里直接运行到设备 A;
- Owner 在 Claude Code 权限设置中允许这一步。
Validation Update · 2026-11-04:Fixture 限帧校准真机结果(v1 / v2)
- 签名与安装:两条签名路径都没有走通。本机证书所属团队在 Xcode 里没有账号;Owner 登录的账号属于另一个团队,而该账号为本机签发过的开发证书私钥不在本机(Xcode 要求先吊销,Claude 没有吊销)。最后由 Claude 从 main
76b1afd9打出不签名 IPA(仓库外私有目录),Owner 自行重签后装到设备 A。 - 一次 100 秒校准:会话约 1 分 52 秒,逐帧记录 3857 帧,标记 24 行,
hitch-calibration/2(E4 原始数据,E3 离线重算)。 - 设备本次全程以 60 Hz 刷新:所有帧的目标间隔都是 16.67 ms,标为 120 Hz 的段也一样,原因未查(可能是低电量模式或“限制帧速率”设置)。因此“60 Hz 限帧段”与显示刷新率相同,没有测到“低于刷新率”的情况;30 Hz 段正好是 App B 的模式:446 帧中 441 帧在上一帧上屏时才开始(B − P(i-1) 在 ±0.2 ms 内),寿命 33.4 ms。
- 逐段结果(每次注入的卡顿 → 检出时长;“额外”指注入之外的卡顿次数):
| 段 | v1 | v2 |
|---|---|---|
| 120 Hz 标称(实际 60 Hz)注入 20 / 50 / 100 / 200 ms | 16.7 / 50.0 / 100.1 / 200.2,额外 0 | 同 v1 |
| 重渲染 15 s | 432 次(每次 16.7 ms) | 同 v1 |
| 重渲染后的静止段 | 2 次 | 同 v1 |
| 30 Hz 限帧,注入 50 / 100 ms | 50.1 / 100.1,额外 0 | 两次都漏检,额外 0 |
| 60 Hz 限帧、干净 120 Hz 段、其他静止段 | 0 | 0 |
- 解读:
- 20 ms 的注入检出 16.7 ms,是 60 Hz 下按一帧量化的结果,在一个刷新间隔之内。
- 重渲染段每帧寿命 66.7 ms、上屏间隔约 34.6 ms,433 帧全部带系统 Stalls 标记,432 次卡顿与之一致,判为真实卡顿;静止段那 2 次是重渲染的尾巴(同样带 Stalls 标记)。
- v2 在限帧 App 上丢了真卡顿:限帧 App 卡住的那一帧,同样是在上一帧上屏时才开始,v2 把它判成“不连续”,直接跳过,所以漏检。v1 在 30 Hz 段没有误报,是因为这里所有帧都是限帧帧,稳态寿命的众数是 2 个刷新间隔;App B 误报的原因是限帧帧与连续帧混在同一个刷新间隔下,众数被连续帧的“1 个间隔”占了。
- 结论:v2 在 Fixture 上失败(限帧段召回 0/2),不能保留。
Phase 2 修正方案 · 2026-11-04:candidate v3(限帧帧单独计稳态)
依据上面的 Fixture 结果与 App B 的逐帧数据。
公式变更(5.1)
text
gap(i) = B(i) − P(i-1)
start(i) = continuous 当 gap(i) <= −M
paced 当 −M < gap(i) <= M (App 在上一帧上屏时才开始)
idle 当 gap(i) > M (静止后重新开始,不判定)
L*(i) = 按 (V(i), start(i)) 分组,取最近 16 个非 Stalls 帧 round(lifetime / V) 的众数,乘 V(i)
judged(i) = start(i) ∈ {continuous, paced} (v1 的 B(i) <= P(i-1) 基本等价;v2 只判 continuous)
E(i), H(i) 同 v1;动画时间累计 judged 帧M 仍为 0.2 ms。限帧帧与连续帧各用自己的稳态寿命:App B 的限帧帧稳态为 2 个间隔,不再被判晚一帧;限帧 App 真正卡住的那一帧寿命变长,仍能检出。算法版本 perfowl-hitch.candidate.v3。
离线预期(E3,原型在私有目录)
| 数据 | v1 | v2 | v3 | 对照 |
|---|---|---|---|---|
| Fixture 注入 6 次 | 6/6 | 4/6 | 6/6 | 标记真值 |
| Fixture 限帧与干净段额外卡顿 | 0 | 0 | 0 | 0 |
| App A 单独 | 9 / 2.44 ms/s | 7 / 1.72 | 8 / 1.95 | Instruments 8 / 约 3.6(分别录制) |
| App B 单独 | 45 / 22.36 ms/s | 4 / 2.09 | 5 / 2.11 | Instruments 0(分别录制) |
App B 剩下的 5 次里,4 次出现在刷新率在 8.3 / 12.5 / 16.7 ms 之间切换的帧附近,1 次是带系统 Stalls 标记的 33.4 ms 帧;两边不是同一次滑动,无法逐次判定真伪,记为遗留,不在本批处理。
实施与验证
hitch_metrics.py:稳态历史改为按 (V, start) 分组;跳过条件改为只跳 idle;版本 v3;文档字符串同步。- 单测:保留“限帧不是卡顿”;新增“限帧 App 卡住一帧要检出”(v2 下会失败);新增“限帧帧与连续帧交替时不误报”(v1 下会失败)。150 秒回放的基准数字若变化,按新结果更新并在此记录。
- 效率:每帧仍是 O(1),稳态分组键多一维,组数最多翻 3 倍(每组 16 个整数)。对比改动前后 4 个会话离线重算耗时与内存。
- 真机:用本次 Fixture 会话与 App A / B 会话离线重算(E3)。下一轮 Fixture 校准需让设备真正跑 120 Hz(关闭低电量模式与“限制帧速率”),才能覆盖“60 Hz 限帧低于刷新率”的情况。
Implementation Update · 2026-11-04:candidate v3
| 文件 | 修改 |
|---|---|
MacApp/Sidecar/perfowl_collector_core/hitch_metrics.py | 按 gap(i) 把帧分为 continuous / paced / idle;稳态寿命历史按 (V, start) 分组;只跳过 idle;HITCH_ALGORITHM = "perfowl-hitch.candidate.v3";文档字符串改为 v3 公式与原因 |
MacApp/Sidecar/perfowl_collector_core/display_performance.py | 注释中的算法版本号 |
MacApp/Sidecar/tests/test_hitch_metrics.py | 新增 test_paced_frame_that_stalls_is_a_hitch(60 Hz 上 30 帧/秒限帧,注入 3 个间隔的卡顿,应检出 1 次 50 ms);“限帧不是卡顿”改为断言限帧帧计入动画时间;Stalls 稳态用例改用新的分组键;150 秒回放回到 v1 的数字(连续帧 1011、动画 10.50 s、8.74 ms/s;次数 6、总 91.8 ms、最大 37.5 ms 不变) |
偏差:方案写“新增限帧与连续帧交替时不误报”的用例,实际复用了 v2 已有的“限帧不是卡顿”用例,它本身就是 20 个连续帧后接限帧交替,不再重复新增。
验证:
- 卡顿单测 17 项全部通过。把实现换回 v2 时,“限帧卡住要检出”会失败;换回 v1(M = 0、不分组)时,“限帧不是卡顿”会失败(E2)。
- Sidecar 全量 3163 项,失败与错误只有已知的 16 项环境基线(native_direct_archive 11、native_raw_graph 4、sw1 1),无新增(E2)。
- 离线重算(正式实现,E3):
| 会话 | v2 | v3 |
|---|---|---|
| App A 单独 | 7 / 1.715 ms/s | 8 / 1.953 ms/s |
| App B 单独 | 4 / 2.092 ms/s | 5 / 2.105 ms/s |
| 同时录制那次的两个会话 | 25 / 4.468;4 / 1.005 | 25 / 4.478;3 / 0.783 |
| Fixture 校准(6 次注入) | 438 次,限帧段注入 0/2 | 440 次,注入 6/6,限帧与干净段额外 0 |
Fixture 的次数主要来自重渲染段(432 次真实卡顿),时间比没有对照意义。
- 效率:5 个会话离线重算,改动前后交替测 3 轮,每轮取 5 次最快值。改动前 105~429 ms,改动后 109~556 ms;556 ms 是单轮离群(另两轮同一会话 366 / 417 ms),其余同会话差异都在噪声内。内存峰值约 30 MB,不变;每帧仍是 O(1)(E3)。
状态:v3 已实现,仍是 candidate,不进产品界面。Fixture 限帧段的召回问题已修复;App B 与 Instruments 的差距(2.11 ms/s,门槛 2 ms/s)仍略超,不能写 PASS。
遗留:
- App B 剩下的 5 次与刷新率切换、系统 Stalls 帧有关,需要同一次滑动的 Instruments 对照才能定真伪;同时录制会撑爆 trace,方法待定。
- 下一轮 Fixture 校准让设备真正跑 120 Hz,覆盖“低于刷新率的 60 Hz 限帧”。
- 打包:同 v2,等下一批产品改动一起打包公证,交付状态 partial。
- Fixture 签名:本机开发证书与 Xcode 登录账号不属于同一团队,目前靠 Owner 手动重签 IPA。
Validation Update · 2026-11-04:Fixture 校准第 2 轮(Owner 关闭低电量与帧率限制后)
- 一次 100 秒校准,标记 24 行,逐帧记录约 3830 帧(E4 原始数据,E3 离线重算)。
- 仍然全程 60 Hz:每帧目标间隔都是 16.67 ms。根因在 Fixture 本身:
Info.plist没有CADisableMinimumFrameDurationOnPhone = YES。iPhone 上的 App 不声明这个键时,系统把显示回调限制在 60 Hz 以内,请求 120 Hz 也只得到 60 Hz(E1,Apple 文档行为,与两轮数据一致)。手机设置不是原因。 - v3 结果与第 1 轮一致:6 次注入全部检出(16.7 / 50.0 / 100.1 / 200.2 / 50.1 / 100.1 ms),30 Hz、60 Hz 限帧段与干净段额外 0 次;重渲染段 431 次(每帧都带系统 Stalls 标记),其后静止段 1 次。v1、v2 本轮结果相同:v2 本轮在 30 Hz 段没有漏检,因为两次注入帧恰好落在“连续”一侧(该段 3 帧 continuous),说明 v2 的漏检取决于卡住时刻,不稳定。
Phase 2 方案 · 2026-11-04:Fixture 开启 120 Hz
fixtures/ios/PerformanceProbeFixture/PerformanceProbeFixture/Info.plist增加CADisableMinimumFrameDurationOnPhone = YES。- 影响范围:只解除“请求超过 60 Hz 时被限到 60 Hz”。请求 ≤ 60 Hz 的场景(实验室帧场景 30 Hz、P95 场景 30/60 Hz、GPU 场景 30 Hz、MTKView 60/30/15 Hz)行为不变;首页“显示回调负载”的 120 Hz 选项从此真正跑 120 Hz(此前实际为 60 Hz),历史结果不回改。
- 验证:不签名构建并检查包内 Info.plist 带此键(E2);打出不签名 IPA 由 Owner 重签安装,再跑一次校准,确认 120 Hz 段目标间隔为 8.33 ms,60 Hz 限帧段落在“低于刷新率”的情况。
Implementation Update · 2026-11-04:Fixture 开启 120 Hz
Info.plist增加CADisableMinimumFrameDurationOnPhone = YES,其他不变。不签名 IPA 从本提交构建(仓库外私有目录),由 Owner 重签安装;包内 Info.plist 是否带此键在构建后核对,结果见下一条 Validation Update。
Validation Update · 2026-11-04:Fixture 120 Hz 构建核对
- 从 main
d9f3a28f不签名构建BUILD SUCCEEDED,包内Info.plist的CADisableMinimumFrameDurationOnPhone为true(E2)。不签名 IPA 已交 Owner 重签安装,真机 120 Hz 待第 3 轮校准确认。
Validation Update · 2026-11-04:Fixture 校准第 3 轮(120 Hz)
- 新包重签安装后一次 100 秒校准,标记 24 行(E4 原始数据,E3 离线重算)。
- 刷新率:120 Hz 段目标间隔 8.34 ms、稳态寿命 25.0 ms(3 个间隔),确认已解除 60 Hz 限制。30 Hz 与 60 Hz 限帧段系统把显示降到 60 Hz(目标间隔 16.67 ms),所以“60 Hz 限帧低于刷新率”在 Fixture 上无法复现——ProMotion 会跟随 App 的请求降刷新率。App B 那种情况需要屏幕上另有内容维持 120 Hz,Fixture 当前做不到,记为遗留。
- 逐段结果(v1 / v2 / v3 本轮相同):
| 段 | 结果 |
|---|---|
| 120 Hz 注入 20 / 50 / 100 / 200 ms | 检出 16.7 / 50.0 / 100.1 / 200.2 ms;后三次每次紧跟一次 8.3 ms |
| 重渲染 15 s(446 帧全带 Stalls) | 445 次 |
| 重渲染后的静止段 | 2 次(25.0、8.3 ms,均带 Stalls,重渲染尾巴) |
| 第 0 段开头 | 1 次 16.7 ms(带 Stalls,校准刚启动) |
| 30 Hz 限帧注入 50 / 100 ms | 检出 50.1 / 100.1 ms,额外 0 |
| 干净 120 Hz、60 Hz 限帧、其余静止段 | 0 |
- 注入后紧跟的 8.3 ms:卡住的那帧之后,下一帧上屏间隔 16.68 ms、寿命 33.4 ms,比稳态 25.0 ms 多一个刷新间隔,屏幕上确实少刷新了一次。它算一次独立卡顿,还是 Instruments 会并入前一次,目前没有对照,不改算法,作为待核对项。
- 结论:120 Hz 下注入卡顿召回 6/6,时长误差都在一个刷新间隔内;限帧与干净段 0 误报(E3,Fixture 单机单轮)。
下一步提案
Fixture 的校准是确定性的,可以不同时录制:先用 Perfowl 跑一轮(已有本轮),再单独用 Instruments Animation Hitches 录一轮同样的 100 秒校准,按“段 + 注入时刻”对齐两边的卡顿次数与时长。这样避开了同时录制撑爆 trace 的问题,可以核对“注入后紧跟的一帧”和重渲染段 Instruments 怎么算。Instruments 录 100 秒约 2 GB,导出后按 Owner 规则立即删除。
Validation Update · 2026-11-04:Fixture 校准 Instruments 单独录制对照(第 4 轮)
- 方法:Perfowl 不开会话,用
record_instruments.sh录 Animation Hitches 140 秒(全进程),Owner 在录制开始后点校准“开始”。Fixture 时间表是确定性的,与第 3 轮 Perfowl 会话按“段 + 注入时刻”对齐;两边时间用 200 ms 注入那次卡顿定偏移(5.712 s),其余注入点在偏移后误差 ≤ 8 ms。 - 成本:录制 140 s,xctrace 停止与落盘耗时,墙钟共 514 s;导出 83 s。trace 1.0 GB 已由脚本删除,另有 6.0 GB 的系统临时 ktrace 残留,确认 xctrace 已退出、无进程占用后删除;保留导出表 2.3 MB(私有目录)。
- 对照(Instruments 同一 swap 多行取最大;E4 真机,两边为不同轮次):
| 段 | Perfowl v3(第 3 轮) | Instruments(第 4 轮) |
|---|---|---|
| 120 Hz 注入 20 / 50 / 100 / 200 ms | 16.7 / 50.0 / 100.1 / 200.2,后三次各紧跟 8.3 | 16.7 / 50.0 / 100.1 / 200.2,后三次各紧跟 8.3 |
| 重渲染 15 s | 445 次,共 11 174 ms | 409 次,共 11 524 ms |
| 重渲染后的静止段 | 2 次(25.0、8.3) | 1 次(12.5) |
| 第 0 段开头 | 1 次 16.7(带 Stalls) | 0 |
| 30 Hz 限帧注入 50 / 100 ms | 50.1 / 100.1 | 不报(帧寿命表里有 83.4 / 133.5 ms 的帧,卡顿表无记录) |
| 干净 120 Hz、60 Hz 限帧、其他静止段 | 0 | 0 |
- 结论:
- 连续动画下的注入卡顿,两边逐次一致,时长相同到 0.1 ms;注入后紧跟的 8.3 ms,Instruments 同样单独计一次,Perfowl 的做法与之一致,待核对项关闭。
- 重渲染段总时长差 −3.0%,次数差 +8.8%(Perfowl 更多 20.9 ms 的短卡顿,Instruments 更多 25.0 ms),不同轮次,在门槛(时间比 10%)内。
- 限帧 App 的卡顿 Instruments 不计:画面实际停了 50 / 100 ms(Fixture 标记为真值),Instruments 的卡顿表没有记录,行为与 v2 跳过“在上一帧上屏时才开始的帧”一致。这是口径差异,不是 Perfowl 误报。是否沿用 Instruments 的口径(v2)还是按用户可见卡顿报告(v3),交 Owner 决定;Lead 建议保留 v3,在报告中把这类卡顿单独标注“Instruments 不计”。
Owner 决定 · 2026-11-04:限帧卡顿照报并标注
Owner 选择“报,并标注”:用户看得到的停顿都算卡顿(保留 v3);起点为 paced 的卡顿单独标注“Instruments 不计”,与 Instruments 对数时可以剔除。
实施(同一算法版本 v3,只加输出字段,不改判定):
- 逐帧结果与卡顿列表加
pacedStart(布尔)。 - 会话汇总加
pacedHitchCount、pacedHitchTotalMs,以及去掉这部分后的instrumentsComparableRatioMsPerS(分母仍是同一动画时间),无动画时都为None。 - 单测:限帧卡住用例断言
pacedHitchCount = 1;连续动画用例断言为 0。 - 效率:每帧多一次布尔判断,无新增数据。
Implementation Update · 2026-11-04:限帧卡顿标注
hitch_metrics.py:卡顿帧与卡顿列表加pacedStart;汇总加pacedHitchCount、pacedHitchTotalMs、instrumentsComparableRatioMsPerS(无动画时为None);算法版本仍为 v3(判定不变)。test_hitch_metrics.py:按方案补断言。偏差:无。- 验证:卡顿单测 17 项通过;Sidecar 全量 3163 项,只有已知的 16 项环境基线失败(E2)。
- 离线重算(E3):
| 会话 | 次数 / 时间比 | 其中限帧卡顿 | 去掉限帧后的时间比 |
|---|---|---|---|
| App A 单独 | 8 / 1.953 | 0 | 1.953 |
| App B 单独 | 5 / 2.105 | 2 次 / 16.8 ms | 1.576(Instruments 0,差距进入 2 ms/s 门槛内) |
| Fixture 第 1 轮(60 Hz) | 440 / 106.7 | 2 次 / 150.2 ms(即 30 Hz 段两次注入) | 104.8 |
| Fixture 第 3 轮(120 Hz) | 457 / 153.9 | 2 次 / 150.2 ms(同上) | 152.0 |
App B 的结论仍限于“分别录制、不是同一次滑动”,不能写 PASS。
- 效率:6 个会话离线重算,改动前后交替 4 轮,每轮取 5 次最快值;各会话取 4 轮最小值求和,改动前 1266 ms、改动后 1278 ms(+0.9%,噪声内;单轮波动达 ±15%,来自本机负载)。内存峰值约 30 MB,不变(E3)。
- 状态:v3 加标注已实现,仍是 candidate,不进产品界面。打包同前,待下一批产品改动一起打包公证(partial)。
知识沉淀 · 2026-11-04
卡顿校准中可复用的方法与坑点写入 性能采集实验:已验证经验与对照方法 第 9 节:iPhone 60 Hz 上限与 CADisableMinimumFrameDurationOnPhone、ProMotion 跟随请求降刷新率、用确定性 Fixture 分开录制对照、Instruments 不计限帧卡顿、trace 与临时 ktrace 的体积和清理。卡顿仍未注册 metricID,不建指标页(F 十要素中 metricID 缺项)。
Lead 验收 · 2026-11-04:Phase 2 校准结论
对照第七节 Phase 2 门禁(按方案允许的“Fixture 确定性重放、分别录制”方式):
| 门禁 | 结果 | 证据 |
|---|---|---|
| 注入卡顿召回 ≥ 95% | 120 Hz 4/4、60 Hz 4/4、30 Hz 限帧 2/2(v3) | Fixture 标记真值,E4 原始 + E3 重算 |
| 精确率 ≥ 95% | 干净动画与限帧段 0 次额外;额外卡顿只出现在带系统 Stalls 标记的帧(启动、重渲染尾巴) | 同上 |
| 单次时长误差 ≤ 1 个刷新间隔 | 注入 20 ms 量化为 1 帧,其余误差 ≤ 0.2 ms;与 Instruments 逐次相同到 0.1 ms | Fixture 对 Instruments 分别录制 |
| 时间比差 ≤ max(10%, 2 ms/s) | Fixture 重渲染段总时长差 3%;App B 去掉限帧卡顿后 1.58 ms/s(Instruments 0);App A 1.95 对约 3.6 | 分别录制,非同一次操作 |
| 边界规则定稿 | 注入后恢复帧单独计(与 Instruments 一致);限帧卡顿照报并标注“Instruments 不计”(Owner 决定);静止后首帧不判定 | 本记录 |
结论:Phase 2 有条件通过(Fixture 范围 E4/E3)。真实 App 的对照只能比分布,两次都在门槛内,但不是逐次对齐,保留为已知限制。适用范围:设备 A、iOS 26、120 Hz ProMotion。其他 iOS 版本与 60 Hz 机型并入分版本能力表核验。
算法定稿为 perfowl-hitch.v1(去掉 candidate,公式即 v3 + 限帧标注),在 Phase 3 与界面一起切换版本号。
Phase 3 方案 · 2026-11-04:产品化(待 Owner 确认启动)
- 采集端(Sidecar):在现有逐帧处理链中挂一个
HitchAccumulator(每帧 O(1),约 30 MB 进程内存不变),每秒向报告输出“本秒卡顿 ms、本秒动画 ms”,会话结束写入frame-report.json的hitch段(汇总、分布、限帧标注)。不新增采集通道、进程或轮询。 - 指标注册:
PerformanceModels.swift新增 4 项,范围foreground-device,版本perfowl-hitch.v1:卡顿时间比(ms/s)、卡顿次数、最长卡顿(ms)、动画时长(s)。无动画时为空,不是 0。 - 界面:
- 实时工作台:新增“卡顿(ms/s)”曲线,静止秒留空。
- 报告画面区:卡顿时间比卡片,带 Organizer 参照分级色(注明“非官方判定”)、动画时长、覆盖率、限帧卡顿数;时间轴上标出卡顿位置。
- Excel:逐秒页加“卡顿时长 ms、是否动画”两列。
- 知识与站点:按十要素新建 Canonical 指标页
site/knowledge/metrics/hitch-time-ratio.md;同步侧栏、知识总览、首页指标数与开发记录时间线(C1–C5)。 - 验证与交付:单测与回放基准;离线与在线数值一致性核对;从 main 打包、公证、安装
/Applications/Perfowl.app;Owner 在新包上录一段含滑动、静止和转场的会话(约 3 分钟),确认报告数值与离线重算一致,静止会话显示“无动画”(E4)。 - 效率门禁:在线逐帧增量计算,对比改动前后的解析吞吐、CPU、内存与报告生成耗时;任何一项明显退化都不交付。
- 打包限制:双架构包需要 Apple 芯片主机重新冻结 arm64 组件;若届时不可用,先交 Intel 单架构内部包,状态 partial。
预计 2–3 天开发,Owner 需要配合约 10 分钟。
Owner 决定 · 2026-11-04:启动 Phase 3
Owner 选择“启动”。按上节方案执行,第一步为采集端接入与 frame-report.json 的 hitch 段。
Phase 3 实施拆分 · 2026-11-04
按改动面拆成四步,每步单独提交:
| 步 | 内容 | 关键文件 |
|---|---|---|
| 3a | 在线帧累加器内挂 HitchAccumulator,frameSummary.hitch 输出汇总(时间比、次数、最长、动画时长、覆盖率、限帧标注、分布、算法版本);报告读取后在“帧统计”区展示 4 项,并在 Excel 统计说明中给出公式 | online_report.py(_FrameAccumulator)、hitch_metrics.py(版本改为 perfowl-hitch.v1)、SessionReport.swift、ReportDerivedStatistics.swift、ReportWorkbookPresentation.swift |
| 3b | 指标注册:4 个 metricID 进 Registry、采集计划与范围映射;十要素知识页;站点侧栏、总览、首页联动;知识门的 Registry 数量随之更新 | PerformanceModels.swift、WorkbenchModels.swift、capture_plan.py、collection_scope.py、site/data/metric-contracts.json、site/knowledge/metrics/hitch-time-ratio.md 等 |
| 3c | 实时:每秒“本秒卡顿 ms”曲线,静止秒留空 | 实时样本发布与工作台图表 |
| 3d | 打包公证、安装,Owner 录一段约 3 分钟会话做 E4 | 打包脚本 |
3a 的边界:
- 不改 PerfDog 29 项统计表(
report-stat.json的形状与数量不变),卡顿走frameSummary.hitch独立段。 - 静止会话时卡顿各值为
None,报告不显示卡顿项(不是 0)。 - 报告 4 项的窗口秒数用动画时长,说明文字写明“只统计在动的时间”。
hitch_metrics.py的版本号在 3a 改为perfowl-hitch.v1;判定逻辑不变,回放基准数字不变。- 效率:在线每帧多一次 O(1) 判定;对比改动前后的在线帧处理吞吐与内存,以及报告生成耗时。
Implementation Update · 2026-11-04:Phase 3a 采集端汇总与报告展示
| 文件 | 修改 |
|---|---|
hitch_metrics.py | 版本 perfowl-hitch.v1(判定不变);逐条卡顿列表改为运行中的合计(次数、总时长、最大值、分档、类型、限帧),状态有界;新增 to_state / restore_state(版本不符拒绝恢复);per_second=False 时不保留逐秒桶;稳态众数改为增量计数(_SteadyWindow,平局时回退到 Counter,结果与原来逐位一致);帧字段读取走 dict 快速路径 |
online_report.py | _FrameAccumulator 内挂 HitchAccumulator(per_second=False),同一批帧行逐帧喂入;summary() 增加 hitch 段;检查点写入并恢复卡顿状态,缺失时按既有规则要求离线重算 |
SessionReport.swift | 读取 frameSummary.hitch,仅在 applicable 时生成 5 个报告值,窗口秒数取动画时长 |
ReportDerivedStatistics.swift | 帧统计定义增加卡顿时间比、卡顿次数、最长卡顿、其中限帧卡顿、动画时长;hitchFields、isAnimationWindow |
ReportWorkbookPresentation.swift | Excel 单位 ms/s、s;卡顿项单独分组“画面表现(卡顿 · 动画 N 秒)”;5 项公式说明(含 Organizer 参照分级与“Instruments 不计”) |
SessionReportView.swift | 单位格式;卡顿卡片下方显示“动画时长 N 秒” |
SessionAggregateReport.swift | 多次汇总:卡顿时间比按动画时长加权,次数与动画时长合计,最长卡顿取最大,动画时长不评优劣 |
| 测试 | Python:存档恢复与不中断结果一致(含限帧卡顿与 commit 卡顿,状态 < 4 KB)、版本不符拒绝、增量众数与 Counter 5000 次随机一致;Swift:报告读取卡顿 5 项且窗口为动画时长、静止会话不生成卡顿项 |
偏差:方案写“报告展示 4 项”,实际加了“其中限帧卡顿”共 5 项,落实 Owner 的标注决定。
验证:
- Sidecar 全量 3166 项,只有已知的 16 项环境基线失败;卡顿单测 20 项通过(E2)。
- Swift 全量 463 项通过,17 项按条件跳过(E2)。
- 在线与离线一致(E3,3 个真机会话的逐帧记录喂入在线累加器):App A 8 次 / 1.954 对 1.953 ms/s(差异来自在线比离线少定稿最后一帧),App B 5 / 2.105,Fixture 第 3 轮 457 / 153.939,完全一致;存档约 1.3 KB,恢复后汇总相同。
- 效率(E3,同机交替多轮取最快值):
- 卡顿判定本身从每帧约 14 µs 降到约 6.4 µs;
- 挂入在线帧累加器后,帧统计每帧从约 41–47 µs 增至约 48–53 µs(+14~15%),120 帧/秒时约 0.8 ms CPU/秒;
- 离线重算 6 个会话快 25~35%(改动前 118~545 ms,改动后 94~297 ms),内存峰值约 30 MB 不变。
- 真实会话报告的界面渲染要等新 Collector 打包后才能看(3d,E4)。
状态:3a 已实现(E2/E3),未打包。下一步 3b:指标注册与十要素知识页。
Implementation Update · 2026-11-04:Phase 3b 指标注册与知识页
- Registry:从 66 项增加到 70 项。新增
ios.frame.hitch_time_ratio(ms/s)、ios.frame.hitch_count(count)、ios.frame.hitch_max(ms)、ios.frame.animation_time(s),范围foreground-device,版本perfowl-hitch.v1。“其中限帧卡顿”不单独注册,作为报告标注。 - 采集端:
- 平台来源合同把 4 项挂到既有帧来源家族(
core-profile-frame),自检数量改为常量EXPECTED_METRIC_CONTRACT_COUNT = 70; - 采集计划的后备列表、范围裁剪(未勾选时
hitch段置空)、在线覆盖账本同步更新。在线账本只在applicable时记为有值,静止会话记missing并写原因,不当成 0; - 离线帧报告
analyze_display_frames用同一实现生成hitch段,自校准增加 4 项。
- 平台来源合同把 4 项挂到既有帧来源家族(
- 工作台:画面表现组和旧 FrameTime 组都加上 4 项,默认全采从 66 个 ID 增加到 70 个。实时卡片不变,实时曲线在 3c 做。
- 测试:Python 与 Swift 中断言 66 项、14 个帧报告、默认计划哈希的用例改为新值。新的默认计划哈希两种语言一致:
0d5d2521…。 - 站点:
- 新建 Canonical 指标页 卡顿时间比(Hitch),十要素齐全;
- 合同索引新增 67~70 行并加 v5 历史;合同快照重新生成;知识门禁预期数改为 70;
- 侧栏、知识库总览、验收状态页(正式 55/70,4 项候选)与首页流畅度卡片及说明同步更新。
偏差:无。
验证:
- Sidecar 全量 3166 项,只有已知的 16 项环境基线失败(E2)。
- Swift 全量 463 项通过,17 项按条件跳过(E2)。
- 站点
npm run build0 错误;知识门禁 PASS,70 项 Registry 与快照、Canonical 归属、十要素、侧栏、总览一致(E2)。 - 效率:离线帧报告每帧多一次约 6 µs 的判定,在线部分沿用 3a 的测量,没有新的常驻开销。
状态:3b 已实现(E2),卡顿 4 项为候选,不计入正式验收。下一步 3c 实时卡顿曲线。
Lead 调整 · 2026-11-04:先做 3d,再做 3c
实时卡顿曲线(3c)要改实时投影器(逐帧解码路径)、1 Hz 产品样本结构、metrics.csv 列、Swift 样本模型与工作台图表,并且要重做实时调度公平性的基准,改动面最大。3a/3b 已经能在报告里给出卡顿结果,先打包请 Owner 做真机验收(3d),确认在线结果与离线重算一致、静止会话显示“无动画”。3c 之后单独立方案。
3d 执行:
- 从 main 构建内部包 255,单架构 x86_64(Collector 有改动,arm64 组件需 Apple 芯片主机重新冻结,本批不做,状态 partial);嵌套二进制带时间戳,App 与 DMG 都提交公证并 stapling。
- 核对:严格签名、
spctl、stapler validate、包内冻结核心与源码一致、CLI 一致、stream --prewarm冒烟;保留回滚包 254;同址覆盖/Applications/Perfowl.app;启动器唯一。 MacApp/dist/只留 255;1.0.0 的 DMG 移到私有归档 build-254 目录保存。- Owner 验收:在 255 上测一段约 3 分钟会话(含滑动、停顿和转场),停止后打开报告,看“画面统计”里的卡顿项;Claude 用离线重算核对(E4)。
Validation Update · 2026-11-04:内部包 255 打包、公证与安装(3d)
- 来源:main
9e5d3da8(含 3a、3b),源码门禁 passed,x86_64 单架构,构建 360 秒。 - 公证:App
6213308fAccepted 并 stapling;DMGaac99a90Accepted 并 stapling。 - 产物:
MacApp/dist/Perfowl-1.0.0-255-macos-x86_64-internal.dmg,146,712,823 字节,SHA-2562cb5c05a4b401eba4039335556092dc787d1194eb57d9801a69cd6074ebd63b4。dist/只有这一个包;1.0.0(254)的 DMG 已移入私有归档,哈希不变。 - 核对(E3,本机 Intel):
codesign --verify --deep --strict通过;spctl结果source=Notarized Developer ID;stapler validate通过;- DMG 内的 App 与构建树一致(12,913 个文件),安装后同样一致;
- 冻结核心 109 个模块与源码一致,CLI 与源码一致;
probeready,stream --prewarm首条为hello; - 卡顿冒烟:已安装核心对 Fixture 第 3 轮会话
hitch-recompute,得到perfowl-hitch.v1、457 次、153.939 ms/s、限帧 2 次,与源码一致; - 回滚包 254(224 MB)已解包核对;启动器唯一,
lsregister与mdfind都只剩/Applications/Perfowl.app。
- 过程问题:
- 第一次运行用了系统 Python 3.9,缺
hashlib.file_digest,开头就停了,未做任何改动; - 第二次在公证通过后、给已安装的 254 做回滚核对时停下:254 是双架构,用了单架构核对脚本。
/Applications未动。改为旧包用双架构核对脚本,从挂载 DMG 那一步继续,没有重复公证。
- 第一次运行用了系统 Python 3.9,缺
- 状态:partial。arm64 未构建,只能在 Intel Mac 上用。待 Owner 真机验收(E4)。
Validation Update · 2026-11-04:内部包 255 真机会话(3d,E4)
- 会话:设备 A、目标 App A,内部包 255,约 103 秒,含滑动、停顿与页面切换(Owner 操作)。原始数据在本机会话目录,不入库。
- 对照:在线报告
report-final.json的frameSummary.hitch与同一会话hitch-recompute(离线逐帧重算):
| 项 | 在线报告 | 离线重算 |
|---|---|---|
| 卡顿次数 | 22 | 22 |
| 卡顿总时长 | 385.419 ms | 385.419 ms |
| 最长卡顿 | 166.81 ms | 166.81 ms |
| 限帧卡顿 | 0 | 0 |
| 动画时长 | 67.112 s | 67.145 s |
| 卡顿时间比 | 5.743 ms/s | 5.740 ms/s |
| 已判定帧 | 7,141 | 7,142 |
| 时长分档(1 / 2–3 / ≥4 个刷新间隔) | 19 / 2 / 1 | 19 / 2 / 1 |
- 停顿期没有计入:离线逐秒结果里,静止的秒没有动画时间,也没有卡顿;整场 103 秒里动画时间 67 秒。
- 差别只有一帧:会话最后一帧(帧间隔 33.36 ms)。卡顿判定要看下一帧(容忍刷新率切换),所以最后一帧一直挂起等待;离线重算在文件读完时会补判,在线汇总没有补判。用“所有帧去掉最后一帧”离线重算,得到的 7,141 帧、67.112 s、5.743 ms/s 与在线完全一致,原因确认(E3)。帧统计其余部分(7,143 帧、7,142 个间隔)不受影响。
- 本轮没有录“整场静止”的会话,静止会话不显示卡顿项目前只有单测(E2)。
Lead 判定: 卡顿次数、总时长、最长卡顿在线与离线逐项相同,真机链路可用(E4,设备 A、iOS 26、120 Hz)。动画时长与卡顿时间比差一帧,不满足“完全一致”,按小修复处理(下节)。修复后的新包上补一次对照,再加一段整场静止的会话,之后 4 项再计入正式验收。在此之前保持候选,正式验收仍为 55/70。
修复方案 · 2026-11-04:在线汇总补判最后一帧
- 问题:
_FrameAccumulator.summary()直接取HitchAccumulator.summary(),挂起的最后一帧不在结果里。 - 方案:
HitchAccumulator新增settled_summary():没有挂起帧时等于summary();有挂起帧时用检查点状态复制一份(约 1.3 KB),在副本上补判后汇总,原累加器不变,之后的新帧照常判定。在线报告改用它。离线路径本来就补判,结果不变。 - 口径不变(仍是
perfowl-hitch.v1),只修在线实现与公式不一致的地方。 - 开销:每次发布报告多做一次状态复制,O(刷新间隔种类 × 16),微秒级;不增加检查点体积。
- 测试:在线累加器与离线
finish()后的结果逐项相同;补判后再追加帧,结果与一次性跑完一致。 - 交付:随 3c 的下一个内部包一起打包;Owner 届时在新包上测一段含滑动与停顿的会话,加一段约 30 秒整场静止的会话。
Implementation Update · 2026-11-04:在线汇总补判最后一帧
- 修改:
hitch_metrics.py新增HitchAccumulator.settled_summary();online_report.py的_FrameAccumulator.summary()改用它。与方案一致,无偏差。 - 测试:
test_hitch_metrics.py新增 1 项(补判前后与离线逐项相同,补判不消耗挂起帧,重复调用结果不变),21 项通过;Sidecar 全量 3,167 项,只有原有的 16 项环境失败(native_direct_archive 11、native_raw_graph 4、sw1 1),无新增(E2)。 - 真实数据回放(E3):把 255 会话的逐帧记录喂给在线累加器,
frameSummary.hitch与hitch-recompute全部字段相同(67.145 s、5.740 ms/s、7,142 帧)。 - 开销(x86_64):每次汇总 83 µs(原 5.8 µs)。报告每秒发布一次,约占 0.008% 个核;检查点体积不变。
- 状态:implemented(E2/E3),未打包。随 3c 的下一个内部包交付,届时补真机对照与整场静止会话(E4)。
Phase 3c 方案 · 2026-11-04:实时卡顿曲线
目标:测试时工作台显示一条“卡顿(ms/s)”曲线,画面静止的时段不画点,并且该曲线写入 metrics.csv 与 Excel 明细。会话报告的整场卡顿值仍以 frameSummary.hitch 为准,本批不改。
口径(公式入档)
- 实时累加器:
realtime.py在 FrameLifetime 分支把同一份raw_payload交给一个独立的HitchAccumulator(per_second=False)。判定规则和报告完全一样(perfowl-hitch.v1),不另写一套。 - 每次发布样本(约 1 Hz)取“上次发布以来新判定的帧”:
text
hitchMs = Σ H(i) (本窗口内判定帧的卡顿时长)
hitchAnimationMs = Σ 动画帧的 P(i) − P(i−1) (本窗口内的动画时长)
hitchTimeRatioMsPerS = hitchMs ÷ (hitchAnimationMs ÷ 1000) 当 hitchAnimationMs > 0- 用比值而不是“本秒卡顿 ms”:ActivityTrace 会把几秒的帧攒在一起送到主机(R8 实测约 6.7 秒),按到达窗口直接加总,会在补到的那一秒出现假尖峰;比值按动画时间归一,攒批不影响数值。任意区间的卡顿时间比 = Σ hitchMs ÷ Σ hitchAnimationMs,与整场汇总口径相同,可以合并。
- 质量与空值(不写 0):
- 窗口内有判定帧、动画时长为 0:
not-applicable-no-animation,空; - 窗口内没有新判定的帧:
missing-no-new-frame,空; - 非前台:
not-foreground;来源过期:stale-device-source;帧来源不是 FrameLifetime:不发布字段; - 有动画:
candidate。
- 窗口内有判定帧、动画时长为 0:
- 延迟:每帧要等下一帧才判定,曲线比画面晚至少一帧;再叠加传输攒批,值落在“到达”的那次样本上。
- 报告的区间统计:报告时间轴使用这条曲线;选中区间时,卡顿时间比按 Σ hitchMs ÷ Σ hitchAnimationMs 计算,不用逐点平均。整场值仍取
frameSummary.hitch。
改动范围
| 层 | 文件 | 改动 |
|---|---|---|
| 采集 | realtime.py | FrameLifetime 分支喂实时累加器;样本组装时取窗口差值,发布 hitchTimeRatioMsPerS、hitchMs、hitchAnimationMs 及质量、来源、算法、scope、unit;来源过期与非前台置空 |
| 采集 | hitch_metrics.py | 无规则改动;取窗口差值只读现有累计值 |
| 范围裁剪 | collection_scope.py | 三个字段归入 ios.frame.hitch_time_ratio,未选卡顿时置空 |
| Swift 模型 | PerformanceModels.swift | 样本与指标结构各加 3 个可选字段(旧会话缺字段时为空) |
| CSV / Excel | PerformanceSessionStore.swift、SessionExporter.swift | 在已有列之后追加列,不改动已有列的位置 |
| 工作台 | WorkbenchModels.swift、PerfowlWorkbenchView.swift、SessionReviewViews.swift | “画面表现”组新增“卡顿”曲线,单位 ms/s |
| 报告 | SessionReport.swift、ReportDerivedStatistics.swift | 时间轴绑定 ios.frame.hitch_time_ratio;区间值按动画时长加权 |
协议仍是 perfowl.collector/1;schemaVersion=2,只追加可选字段,旧客户端会忽略这些字段。Registry 仍是 70 项,不新增 metricID。
效率门禁
- 关键路径:实时线程每帧多一次
add()。此前单测约 6.4 µs/帧,120 fps 时约 0.8 ms CPU/s;样本组装时每次多做 O(1) 的差值。 - 前后对比(x86_64):
- 5 万帧合成 FrameUpdate 经
RealtimeMetricProjector的耗时; r8fix7/bounded-burst实时调度基准,看 p95、p99、max 与丢弃数;- 内存不随会话时长增长(累加器有界)。
- 5 万帧合成 FrameUpdate 经
- 门槛:每帧新增 ≤ 10 µs;bounded-burst 的 p99 退化 ≤ 10%,丢弃仍为 0。
测试
- Python:
- 合成流:稳态、注入卡顿、静止,覆盖逐窗口的值与质量;
- 攒批到达:3 秒的帧一次到达,比值不出现尖峰;
- 窗口之和等于整场汇总;
- 非前台与来源过期时置空;
- 范围裁剪。
- Swift:
- 样本解码,旧样本缺字段时为空;
- CSV 追加列;
- 工作台曲线定义;
- 报告区间值按动画时长加权,静止区间为空。
- 回放:255 真机会话逐帧喂实时投影器,各窗口 hitchMs 之和等于离线卡顿总时长 385.419 ms(除最后一帧外)。
交付:从 main 打内部包、公证、安装。Owner 测两段会话:一段有滑动和停顿(看实时曲线,静止时没有点),一段约 30 秒完全不动。同时复核上节最后一帧的修复(E4)。
预计 1 天开发与自测。
Implementation Update · 2026-11-04:实时卡顿曲线(3c)
实际修改
| 层 | 文件 | 实现 |
|---|---|---|
| 采集 | hitch_metrics.py | 新增 LiveHitchWindow:内含一个 HitchAccumulator,take() 返回上次以来的窗口值与质量;新增 HITCH_LIVE_FIELDS、HITCH_LIVE_ALGORITHM(perfowl-hitch.v1.window-since-last-sample)、HITCH_LIVE_UNITS。HitchAccumulator 新增 details=False,只累计、不生成逐帧明细;在线报告、离线帧报告、补判汇总和实时窗口都用它 |
| 采集 | realtime.py | FrameLifetime 分支在 DisplayPerformanceProjector 之后喂实时窗口;样本组装时发布三个字段,并带上质量、来源、算法、scope 和 unit;非前台置空;静止水位确认且无新帧时记 not-applicable-no-animation;来源过期时置空 |
| 范围裁剪 | collection_scope.py | 三个字段挂在 4 个卡顿 metricID 上,4 项都没选时不发布 |
| Swift | PerformanceModels.swift | CollectorMetrics、PerformanceSample 各加 3 个可选字段 |
| Swift | PerformanceSessionStore.swift、SessionExporter.swift | metrics.csv 末尾追加 hitchTimeRatioMsPerS,hitchMs,hitchAnimationMs,hitchQuality;会话导出 CSV 末尾追加 3 列(82→85) |
| Swift | WorkbenchModels.swift、PerfowlWorkbenchView.swift、DisplayMetricPresentation.swift | “画面表现”卡片加第 3 条曲线“卡顿”(ms/s);空值读数:静止时显示“— · 画面静止”,其余沿用帧类说明 |
| Swift | SessionReport.swift、ReportDerivedStatistics.swift | 时间轴绑定 ios.frame.hitch_time_ratio;报告保存逐窗口的卡顿与动画时长(hitchWindows);选中区间时用 rangeHitch() 按 Σ卡顿 ÷ Σ动画时长 计算;整场值仍取 frameSummary.hitch |
相对方案的偏差
- 没有改
SessionReviewViews.swift:它不按曲线类型分支,不需要改。 - 新增
details=False。原方案没有这一项,是为了降低每帧开销(见下)。 - 静止的判定复用了已有的 FPS 静止水位(
_static_idle_confirmed):确认静止时,“无新帧”记为“不适用”。
测试
- Python:
test_hitch_metrics.py新增 6 项:窗口之和等于整场、攒批到达不出尖峰、静止窗口为“不适用”、投影器发布字段与元数据、后台与未选时不发布、没有帧来源时不出字段;- 共 27 项通过;Sidecar 全量 3,173 项,只有原有的 16 项环境失败(E2)。
- Swift:
- 新增
testLiveHitchSeriesAndRangeRatioWeightedByAnimationTime,覆盖 CSV 追加列、曲线与“不适用”点、区间按动画时长加权(23.33 ms/s,不是逐点平均的 62.5)、静止区间为空、旧样本缺字段时为空; - 卡片契约测试由 2 条曲线改为 3 条;导出列数 82→85;
- 全量 464 项,0 失败,17 跳过(E2)。
- 新增
- 真实数据回放(E3):把 255 会话的 7,143 帧按主机到达时刻送入实时投影器,每秒取一次样本,共 98 个样本:
- 质量:candidate 93、无新帧 3、不适用 2;
- 各窗口卡顿之和 385.423 ms,动画时长之和 67.112 s,合计比值 5.743 ms/s,与在线报告相同;挂起的最后一帧不在任何窗口里,与 Phase 3d 的结论一致;
- 单个窗口最高 250.1 ms/s(长卡顿所在的那一秒)。
效率(x86_64,255 会话 7,143 帧 × 5 轮,交替运行)
| 项 | 修改前 | 修改后 |
|---|---|---|
实时投影器每帧 consume | 12.9–14.9 µs | 24.7–27.5 µs |
| 卡顿判定单独计时(同一进程) | — | 4.6–5.2 µs/帧 |
| 每次样本组装 | 335–348 µs | 347–407 µs |
r8fix7/bounded-burst 7,200 帧(两档)p99 | 1.16–1.68 ms | 1.09–1.94 ms,丢弃 0 |
- 结论:判定本身每帧约 5 µs,满足“≤ 10 µs”的门槛。但和投影器其他组件交替运行时,每帧总耗时多了 10–13 µs,略超门槛。
- 原因:主要是交替运行时的缓存干扰。同一进程里单独循环只要 5 µs,profile 中也没有其他热点。
- 按 120 fps 持续动画计,约 1.2–1.6 ms CPU/s,即 0.12–0.16% 个核。
- 调度公平性基准前后都在噪声范围内,丢弃为 0。
- 已做的优化:
- 不生成逐帧明细字典(
details=False); _Frame改用位置参数;- 刷新间隔取最大值时不再建临时列表;
- 不统计逐秒时跳过秒桶计算。
- 单独计时从 6.1 µs 降到 5.0 µs。
- 不生成逐帧明细字典(
- 没有做的方案:让实时曲线直接读在线报告的累加器,可以省掉重复判定。但在线报告在写入线程上,按来源分段,还可能被禁用。两边耦合会影响 R8 的线程隔离,所以不采用。
- Lead 判定:边际成本略超门槛,绝对值很小,调度无退化,接受;在交付汇报中如实说明。
- 状态:implemented(E2/E3)。下一步从 main 打内部包 256,Owner 真机验收(E4)。
Validation Update · 2026-11-04:内部包 256 打包、公证与安装
- 来源:main
2318cf3b(含 3c 与最后一帧补判),源码门禁 passed,x86_64 单架构,构建 433 秒。 - 公证:App
b3bd3521、DMG01c6ddcd均 Accepted 并 stapling。 - 产物:
MacApp/dist/Perfowl-1.0.0-256-macos-x86_64-internal.dmg,146,019,389 字节,SHA-256753527629105da73621bd5338402c0005b364e65ed6c87767bb361629292f2b6。dist/只留这一个包;255 的 DMG 已移入私有归档。 - 核对(E3,本机 Intel):
codesign --verify --deep --strict通过;spctl结果source=Notarized Developer ID;stapler validate通过;- 安装后的 App 与 DMG 内一致;冻结核心 109 个模块与源码一致,CLI 与源码一致;
probeready,stream --prewarm首条hello; - 已安装核心的
hitch-recompute:Fixture 第 3 轮 457 次、153.939 ms/s、限帧 2 次;255 真机会话 22 次、385.419 ms、67.145 s,都与源码一致; - 255 的回滚包已解包核对;
lsregister与mdfind都只剩/Applications/Perfowl.app。
- 过程问题:第一次运行在公证完成、覆盖安装之前停下,因为检查到 Perfowl 正在运行(当时没有会话在录制),
/Applications没有改动。Owner 退出应用后,从挂载 DMG 那一步继续,没有重复公证。 - 状态:partial。arm64 未构建;待 Owner 真机验收(E4):
- 一段含滑动与停顿的会话:看实时曲线、报告与离线重算;
- 一段约 30 秒完全不动的会话:报告不显示卡顿项。
Validation Update · 2026-11-04:内部包 256 真机会话(E4)
Owner 在 256 上录了 5 段会话(设备 A;目标 App A 4 段,目标 App B 1 段)。原始数据在本机会话目录,不入库。
| 会话 | 时长 | 结果 |
|---|---|---|
| 第 1 段(App A) | 19 s | 收尾 partial,报告未封存(见问题 2) |
| 第 2 段(App A) | 20 s | 同上 |
| 第 3 段(App B) | 19 s | 同上 |
| 第 4 段(App A) | 67 s | 在线报告与离线重算 8 项逐项相同(见下) |
| 第 5 段(App A) | 76 s | 帧来源“资源超限”,报告帧类全空;实时曲线仍有值(见问题 1) |
第 4 段对照(在线 frameSummary.hitch 与 hitch-recompute)
| 项 | 在线 | 离线 |
|---|---|---|
| 卡顿次数 | 13 | 13 |
| 卡顿总时长 | 197.255 ms | 197.255 ms |
| 最长卡顿 | 50.05 ms | 50.05 ms |
| 限帧卡顿 | 1 | 1 |
| 动画时长 | 33.377 s | 33.377 s |
| 卡顿时间比 | 5.910 ms/s | 5.910 ms/s |
| 已判定帧 | 3,678 | 3,678 |
- 最后一帧补判在真机上确认:动画时长与已判定帧数完全一致(255 时差一帧)。
- 实时曲线:62 个样本,candidate 56、画面静止(不适用)5、首个样本无字段 1。各窗口卡顿之和 197.257 ms,与报告一致(差值来自逐窗口取 3 位小数)。动画时长之和 33.118 s,比报告少 0.26 s,是最后一次发布样本之后到停止之间的帧,没有落在任何样本里,符合定义。单个窗口最高 148.2 ms/s。
- 4 个卡顿 metricID 的在线覆盖账本都是 candidate。
问题 1(本批引入,已修复):帧来源未选用时,实时曲线仍发布数值
- 第 5 段开头约 3.8 秒收到 27.6 MB ActivityTrace 数据,超过帧来源管理器每秒 4 MB 的上限,判定
resource-limited,整场不再选用逐帧来源。报告因此没有判定帧(no-timing),帧类指标都为空,这是对的。 - 实时窗口却在帧来源管理器之外直接消费原始帧,照样发布了 35 个有值样本,与报告不一致。
- 修复:发布前与帧耗时用同一个门控。帧来源管理器没有选用 ActivityTrace 时三个字段置空,质量取管理器的原因(如
resource-limited);选了其他来源时为frame-source-not-selected。新增测试 1 项,共 28 项通过(E2)。 - 同一 App 在第 4 段没有触发超限。为什么第 5 段开头数据量这么大,不属于卡顿口径,记为帧来源的遗留问题。
问题 2(另立方案):约 20 秒的短会话收尾 partial,报告未封存
- 3 段短会话停止时,
stream-pmd3.core-profile.raw与stream-device-conditions两个消费任务没有在 500 ms 内退出(CoreProfile 任务当时已在waiting-source停了约 14 秒)。按 R8-Fix-6 的判定规则,这算source-consumer-drain-timeout,收尾 partial,在线报告没有封存(live-report-seal-missing)。原始数据与写入都完整。 - 这台机器上 256 之前的 9 段会话收尾都正常,但都长于 1 分钟;256 上两段长会话也正常。本批没有改动收尾或 CoreProfile 代码,但 255 上没有做过 20 秒的短会话,所以不能断定是不是本批引入的(未证)。
- 不在卡顿批次里修。下一步单独立方案:复现条件、停止时 CoreProfile 等待源的取消路径、以及“数据完整但消费任务超时”是否仍应阻止报告封存。
整场静止会话:这 5 段都有动画,“整场静止时不显示卡顿项”仍只有单测(E2)。
Lead 判定
- 卡顿报告链路真机一致性通过(E4,设备 A、iOS 26、120 Hz):在线与离线逐项相同,最后一帧修复确认。
- 实时曲线有条件通过:正常情况下与报告一致;帧来源未选用时的门控已修复,待新包复核。
- 4 项暂不计入正式验收,仍为 55/70。还差两件事:
- 新包上测一段约 1 分钟、完全不动的会话(要长于 1 分钟,避开问题 2);
- 门控修复随新包复核。
Validation Update · 2026-11-04:内部包 257 打包、公证与安装
- 来源:main
ffd5f613(实时曲线跟随帧来源选择),源码门禁 passed,x86_64 单架构,构建 260 秒。 - 回归:Sidecar 全量 3,174 项。除原有的 16 项环境失败外,多了 1 项
test_build74_closeout_source_terminal.test_b_network_backlog_stop_drains_completed_with_no_timeout:它在 500 ms 内排空网络积压,当时机器负载约 7;单独连跑 5 次都通过,判为负载下的时序偶发,和本批无关(E2)。 - 公证:App
d59966e4、DMG3c3456a7均 Accepted 并 stapling。 - 产物:
MacApp/dist/Perfowl-1.0.0-257-macos-x86_64-internal.dmg,148,250,047 字节,SHA-256460128809e7fa13a35ee77b6950be69b2d554851e4b5479b6f7cab4736f64285。256 的 DMG 已移入私有归档。 - 核对(E3):
- 签名 strict、
spctl(Notarized Developer ID)与stapler validate都通过; - 安装后的 App 与 DMG 内一致;冻结核心 109 个模块与源码一致,CLI 与源码一致;
probeready,stream首条hello; hitch-recompute两个样本与源码一致;- 256 的回滚包已核对;启动器只剩
/Applications/Perfowl.app。
- 签名 strict、
- 过程问题:
- 第一次运行时,版本号批量替换把
sha256误改成了sha257,脚本一开头就停了,没有任何改动。 - 第二次在公证完成后、覆盖安装前停下:Owner 退出后 Perfowl 又被打开了(15:48)。留在
/Applications的 257 暂存副本核对版本号后删除,并注销了它的启动登记。Owner 再次退出后,从挂载 DMG 那一步继续,没有重复公证。
- 第一次运行时,版本号批量替换把
- 状态:partial(arm64 未构建)。待 Owner 测一段超过 1 分钟、完全不动的会话,再测一段普通会话(E4)。
Validation Update · 2026-11-04:内部包 257 真机验收(E4)
Owner 在 257 上录了 2 段会话(设备 A、目标 App A,iOS 26,120 Hz)。原始数据在本机会话目录,不入库。两段收尾都正常,报告已封存,帧来源全程选用 ActivityTrace。
| 项 | 第 1 段(静止,129 s) | 第 2 段(操作,88 s) |
|---|---|---|
| 卡顿次数(在线 / 离线) | 0 / 0 | 371 / 371 |
| 卡顿总时长 | 0 / 0 ms | 12,415.913 / 12,415.913 ms |
| 最长卡顿 | 0 / 0 ms | 200.165 / 200.165 ms |
| 限帧卡顿 | 0 / 0 | 1 / 1 |
| 动画时长 | 9.391 / 9.391 s | 59.364 / 59.364 s |
| 卡顿时间比 | 0 / 0 ms/s | 209.148 / 209.148 ms/s |
| 已判定帧 | 602 / 602 | 5,068 / 5,068 |
| 静止秒占比 | 76.7% | 0% |
- 两段在线报告与离线重算 8 项逐项相同。256 上的第 4 段同样逐项相同,三段累计零差异。
- 实时曲线:
- 第 2 段:85 个样本,有值 69、画面静止 15、首样本无字段 1;各窗口卡顿之和 12,415.912 ms,与报告一致;单窗口最高 809.2 ms/s。
- 第 1 段:125 个样本,有值 20、画面静止 28、来源过期 76(长时间没有新帧时,显示源时钟判为过期,读数是“暂无近期帧”),首样本无字段 1。所有有值窗口的卡顿都是 0;静止时段没有画出任何 0。
- 第 1 段不是“整场零动画”:目标 App A 约每 5 秒有一次约 0.18 秒的界面动画,中间还有一次约 5 秒的连续变化,合计动画 9.4 秒。这个 App 上做不出零动画的会话。“整场零动画时报告不显示卡顿项”这条路径,仍只由单测覆盖(E2),它与静止时段不画点用的是同一条
animation_ns == 0规则。 - 帧来源门控(
resource-limited时置空):这两段都没有触发超限,真机没有复现条件,仍为 E2。
Lead 判定:PASS
ios.frame.hitch_time_ratio、ios.frame.hitch_count、ios.frame.hitch_max、ios.frame.animation_time4 项通过正式验收,计入正式 59/70(原 55 项 + 卡顿 4 项;暂停 11 项不变)。- 适用范围:设备 A、iOS 26、120 Hz、FrameUpdate 帧来源;
perfowl-hitch.v1;scope 为foreground-device。 - 证据:
- Fixture 注入卡顿与 Instruments 逐次一致(E4/E3);
- 真机三段会话在线与离线逐项一致(E4);
- 实时曲线与报告一致(E4);
- 整场零动画和帧来源超限两条路径为 E2。
- 不覆盖:iOS 15–25 映射、60 Hz 机型、App 自己限帧低于屏幕刷新率、与 Instruments 同一操作逐次对齐,以及约 20 秒短会话的报告封存(另立方案)。
- 状态:validated。剩余门禁见 handoff:短会话收尾、帧来源超限原因、arm64 包。