外观
iOS 15+ 分版本指标能力确认与自动判定:方案
Owner 2026-11-03:产品需支持 iOS 15 及以上;Owner 准备各版本设备,由 Claude 逐版本确认每项指标能做到什么程度;最终按连接设备的系统版本自动判断支持哪些指标。
一、现状(E1,读代码与既有记录)
- 平台合同已有:
ios-platform-profiles.candidate.v1.json与 Swift 镜像IOSPlatformSupport按大版本(15/16/17/18/26/27)规定连接路线与启动解码器;不在表内的大版本拒绝连接。没有“版本 × 指标”的支持表。 - 指标开关已有:
WorkbenchMetricCatalog.availability()是添加指标列表的唯一门,上下文里已有iosMajorVersion,但目前没有用它;条件类指标靠采集准备后的运行时回报决定。 - 源家族 10 个:sysmontap(CPU / 内存 / 线程 / 调度 / 磁盘,14 项)、整机网络、进程网络、图形(GPU 利用率)、显示(FPS / 帧耗时)、GPU Counter、逐帧管线(Jank 等 14 项)、能耗、设备状态(电池 / 温度)、启动。
- 既有真机证据:连接 + 六个实时源在 iOS 16、17.0、18.2 达到 E4(2026-09-05);iOS 26.5 连接、应用列表、截图 E4,帧耗时 / FPS(FrameUpdate)E4(2026-11-02);iOS 15、17.4+、27 无真机;卡顿仅 iOS 26 离线 E2。各指标在各版本的逐项结论没有汇总。
二、要交付的东西
- 分版本能力表(唯一事实源,随包发布):行 = 系统版本段,列 = 工作台指标组(截图、启动、FPS、帧耗时、显示性能、卡顿、CPU、CPU 核、内存、GPU、线程、上下文切换、唤醒、磁盘、温度、网络、电池、能耗、GPU Counter)。每格一个状态:
supported:该版本真机 E4 通过;candidate:能出数,但口径未校准或只有部分证据;unsupported:该版本确认拿不到(附原因,如“系统不再提供该事件”);untested:没有真机证据。 另有硬件条件列(如卡顿分类需要渲染时间点,ProMotion 影响刷新率),按设备能力再收窄。
- 版本段按实际差异划分,不预设:先按大版本,真机发现小版本差异(如 17.0–17.3 与 17.4+ 连接路线不同)再拆。
- 自动判定:连接设备后按“系统版本 → 能力表 → 硬件条件 → 运行时回报”四层决定每个指标的可用性;添加指标列表与报告都显示同一结论,不可用给出一句原因。能力表只能收紧,不能把运行时失败的指标显示成可用。
三、逐版本确认流程
每台设备一轮,Owner 约 15 分钟、无需改手机上的 App:
- Owner 连接设备、信任电脑、打开开发者模式(iOS 16+);告诉 Claude 设备别名与系统版本(公开文档只写“设备 B / iOS 15.x”)。
- Claude 读取设备信息与可用系统服务清单(只读)。
- Owner 选一个常用 App,Claude 开全部指标,Owner 按提示操作 5 分钟:静止 1 分钟 → 滑动列表 2 分钟 → 切到后台再回来 → 静止。期间 Claude 不在手机上安装或删除任何东西。
- 再做一次冷启动测试(启动指标)。
- Claude 离线分析:每个源是否有数据、字段是否齐、数值是否合理(与同场 sysmontap、截图时间线交叉核对),帧事件是哪一种(FrameLifetime / FrameUpdate / 其他),卡顿所需时间点是否齐全。
- 结论写入能力表与本 devlog(证据等级逐格标注),失败样本保留在私有目录。
建议设备(每个版本段一台即可):iOS 15.x、16.x、17.0–17.3、17.4–17.x、18.x、26.x(已有设备 A);有余力再加一台 60 Hz 非 ProMotion 机型。
四、实施顺序
| 阶段 | 内容 | 依赖 |
|---|---|---|
| A | 能力表格式与加载(Python 与 Swift 共用一份 JSON),先用既有证据填写,其余 untested;availability() 接入版本层 | 无,可先做 |
| B | 版本探测分析脚本:读取一次会话,逐源输出“有无数据 / 字段完整度 / 帧事件类型 / 卡顿时间点” | 无 |
| C | 逐台设备确认(第三节) | Owner 设备 |
| D | 按结果补解码(如某版本帧事件格式不同),能力表定稿,界面与报告显示“该系统不支持”原因 | C |
效率要求:版本判定在连接时一次查表(内存常数时间),不增加设备请求;探测分析为离线流式读取,单会话 < 5 秒。
五、待 Owner 决定
untested的指标怎么显示:建议允许选择、标“未验证”,因为大多数指标在相邻版本通常可用;只有unsupported才禁止选择。备选:未验证一律禁用。- 可提供哪些版本的设备、大约何时可用。
Owner 决策 · 2026-11-03
untested指标允许选择,界面不标“未验证”;只有unsupported禁止选择并给出原因。能力表内部仍保留untested状态与证据等级,供报告审计与后续确认。- Owner 有全版本设备,稍后准备。
- 本方案排在“正式包第一版”发版之后执行。
Implementation Update · 2026-11-04:Fixture 支持 iOS 15(确认测试用)
- Owner 指定逐版本确认统一使用 PerformanceProbeFixture(含 100 秒卡顿校准),并要求它能装在 iOS 15 上。
- 修改:
- 最低系统版本从 16.0 改为 15.0;
- 网络请求计时由
ContinuousClock(iOS 16+)改为DispatchTime.uptimeNanoseconds,同样是单调时钟,毫秒值不变; - 主界面导航:iOS 16+ 仍用
NavigationStack,iOS 15 用NavigationView(stack 样式),由FixtureNavigation按系统版本选择。
- 卡顿校准页面没有改动,设备 A 上的校准结果不受影响。
- 验证:iphoneos Release 不签名构建通过,产物
MinimumOSVersion=15.0(E2)。IPA 交 Owner 重签安装。 - 首台确认设备:设备 B,iOS 15.3.1,60 Hz 屏幕;调试组件已由 Xcode 挂载(只读核对,E3)。
Validation Update · 2026-11-04:设备 B(iOS 15.3.1,60 Hz)首轮
- 会话:内部包 259,目标为 Fixture(iOS 15 版),3 分 19 秒;冷启动 → 卡顿校准 100 秒 → 滑动 → 切后台再回来。报告已封存,收尾正常。原始数据在本机会话目录,不入库。
- 分析工具(阶段 B):新增
tools/validation/capability/probe_session.py,只读、流式读取一次会话,逐字段输出有值样本数与质量分布、原始事件类型、帧来源、帧/卡顿/启动汇总与覆盖账本。本会话 0.47 秒(门槛 < 5 秒)。
| 指标组 | 结论(设备 B / iOS 15.3.1) | 依据 |
|---|---|---|
| CPU(应用/整机/逐核)、内存(5 项)、线程、上下文切换、中断唤醒、磁盘读写 | 有值 | 196 个样本中 194–196 个有值;缺的 2 个是首个差分窗口 |
| 网络(整机/进程 收发) | 有值 | 193–195/196 |
| GPU 利用率(Device/Renderer/Tiler) | 有值 | 182/196;其余 14 个样本 App 不在前台 |
| FPS(每秒,图形统计) | 有值 | 182/196,同上 |
| 电池温度 | 有值 | 196/196 |
| 电池电流、电压、功率 | 不适用(本次) | 全程外接电源(external-power-connected),不是采集故障 |
| 能耗(总/CPU/GPU/网络/定位) | 有值 | 188–193/196 |
| 能耗(显示、开销) | 无值 | missing / source-sentinel,与其他版本相同,属既有暂停项 |
| 启动 9 个阶段 | 有值 | 冷启动合计 368.0 ms,阶段覆盖完整(CoreProfile) |
| 启动 observed_running / foreground_ready | 无值 | 196 个样本都是 missing,待查 |
| 帧耗时、P95、Jitter、刷新率、卡顿 4 项 | 无值 | 见下 |
| GPU Counter | 无值 | RuntimeError,待查 |
| 热状态 | 无值 | missing,与其他版本相同(既有开放项) |
逐帧来源(主要问题)
- ActivityTrace 全程没有返回任何消息:
totalMessageCount=0,frameLifetimeMatchedCount=0,只收到 1 个 28 KB、0 行的数据块。 - 帧来源管理器判定主来源证据不足,回退到 CoreProfile;CoreProfile 只给出 30 帧(启动阶段),之后没有再出帧。
- 结果:报告帧统计只有这 30 帧,帧耗时实时值整场为空,卡顿
no-timing。卡顿校准的注入记录因此无法对照。
待查
- iOS 15 上 ActivityTrace 为什么没有数据:是订阅参数(如只取 signpost 的选项)在 iOS 15 不被接受,还是服务本身需要不同的开启方式。需要在设备 B 上做只读的短时探测。
- 回退到 CoreProfile 后为什么只出启动阶段的帧。
- 启动 observed_running / foreground_ready、GPU Counter 两项无值的原因。
能力表暂定(iOS 15)
candidate(有值,口径沿用其他版本):上表“有值”各项。untested:其余各项。- 在查清 ActivityTrace 之前,帧类与卡顿不标
unsupported。
修复方案 · 2026-11-04:iOS 15 ActivityTrace 逐帧来源
原因(设备 B 只读探测,E3)
用 pymobiledevice3 11.3.1 的 ActivityTraceTap 直连设备 B,参数与 Perfowl 相同,录了 12 秒:
- 解析中断:
_handle_end_row按 4 字节struct.unpack("<I")解析process、thread。iOS 15 有一部分行的这两个字段是 6 字节,于是抛出unpack requires a buffer of 4 bytes,整条流在第 1–2 条消息后就结束了。Perfowl 的BoundedActivityTraceTap执行的是同一段上游代码,所以整场totalMessageCount=0。- 改用能处理长字段的解析后,12 秒收到 2,823 条消息,其中 FrameLifetime 689 条(约 57/s,与 60 Hz 屏幕一致)。
- 格式不同:iOS 15 的 FrameLifetime 格式串是
begin_time / frame_seed / refresh_interval / buffer_count / end_time / prev_frame / skip_request,比 iOS 17 少了display_id。解码器要求 8 个字段全齐,因此全部 685 条都判为unsupported-schema。refresh_interval为 399,987 个时钟周期,按 125/3 换算是 16.67 ms,与 60 Hz 一致。
修改
| 文件 | 改动 |
|---|---|
activity_trace_rows.py | BoundedActivityTraceTap 的私有 globals 中加入一个 struct 替身:unpack("<I", b) 遇到长于 4 字节的值时按小端整数整体读取,其他格式原样交给标准 struct。上游代码与摘要锁定都不动,只换这一份私有 globals 的依赖,与现有的 dataclasses 替身同一做法 |
display_performance.py | _extract_fields_from_formatted_signpost:格式串里完全没有 display_id 时记为 0(主屏),与 iOS 26 FrameUpdate 的约定一致;其余 7 个字段仍必须全部存在,否则照旧 unsupported-schema |
- 口径不变:帧耗时、FPS、P95、卡顿都沿用现有公式。
- iOS 15 的卡顿要用到开始时刻、结束时刻(得出帧寿命)、上一帧时刻与刷新间隔;这几个字段都在,
frame_timing的 FrameLifetime 路径已有单测。 - 开销:替身只在每行的两次
unpack上多做一次长度判断,O(1)。 - 测试:
- 用设备 B 抓到的一条真实 FrameLifetime 消息(只含时间戳与计数,不含标识)做解码单测;
- 6 字节
thread行解析单测; - 摘要锁定测试照旧;
- 再用 Perfowl 的有界解析加解码器对设备 B 做一次 12 秒只读复测;
- 之后打包,Owner 重测。
Implementation Update · 2026-11-04:iOS 15 逐帧来源修复
activity_trace_rows.py:新增_WideIdStruct,放进BoundedActivityTraceTap行构建器的私有 globals。上游代码与摘要锁定都没有改动。display_performance.py:格式串里没有display_id、surface_id时,两项都记为 0。- 与方案相比多处理了
surface_id:iOS 15 同样没有这个字段,解码器原本把它列为必需。0 与 iOS 26 FrameUpdate 的取值一致。
- 与方案相比多处理了
- 测试:新增
tests/test_ios15_frame_lifetime.py5 项:- 用设备 B 的 3 条连续真实消息(只含时间戳与计数)验证解码:帧耗时 16.664 ms,60 Hz,帧寿命为 2 个刷新间隔(双缓冲),卡顿可以判定;
- 缺少其他字段时仍拒绝;
- 6 字节字段整体读取、4 字节不变;
- 有界解析确实使用了替身。
- Sidecar 全量 3,179 项,失败清单与基线相同(16 项环境失败)(E2)。
- 设备 B 实时复测,Perfowl 有界解析加解码器,15 秒,只读(E3):
- 收到 3,615 条消息、863 帧,有效 859 帧,时序字段全部齐全;
- 卡顿判定为
applicable,动画 14.3 秒,0 次卡顿; - 修复前同样 12 秒只有 0–2 条消息。
- iOS 15 没有渲染开始时刻,卡顿能判出时长,但类型记为
unknown(不分提交阶段或渲染阶段)。 - 状态:implemented(E2/E3)。打内部包 260 后请 Owner 在设备 B 上重测(E4)。
Validation Update · 2026-11-04:内部包 260(含 iOS 15 修复)
- 第一次构建被源码门禁拒绝:
e2c49175、f6d76d73两个提交没有推到 GitHub(Permission denied (publickey))。原因是推送结果只按 error/reject 字样过滤,这条报错漏看了。- Claude 的命令环境仍然无法通过 GitHub 认证,同样的认证在 Owner 的终端里能通过。由 Owner 手动推送后,两个远程都到了
f6d76d73,再重新构建。 - 以后每次推送都逐个核对两个远程的提交号。
- Claude 的命令环境仍然无法通过 GitHub 认证,同样的认证在 Owner 的终端里能通过。由 Owner 手动推送后,两个远程都到了
- 来源:main
f6d76d73,x86_64,构建 252 秒。App80bb9d97、DMG4fa38df7均 Accepted 并 stapling,Gatekeeper 结果为 Notarized Developer ID。 - 产物:
MacApp/dist/Perfowl-1.0.0-260-macos-x86_64-internal.dmg,146,892,712 字节,SHA-256647b22d37d08fd262a210510c3976ab3d1dca8bcbebbb7526d20329dd0e7612d。 - 核对:冻结核心 109 个模块与源码一致,CLI 一致;
probeready,stream首条hello;卡顿重算两个样本与源码一致;259 回滚包已核对;启动器唯一(E3)。 - 待办:Owner 在设备 B 上重测(E4)。
Validation Update · 2026-11-04:设备 B 第二轮(内部包 260)
- 会话:内部包 260,目标为 Fixture(iOS 15 版),3 分 9 秒;冷启动 → 卡顿校准 100 秒 → 滑动 → 切后台再回来。报告已封存,交付质量 candidate(第一轮是 partial)。
- 注入标记通过 house_arrest 只读取出 App 容器(
devicectl不支持 iOS 15),存放在私有目录。
修复生效(E4)
- ActivityTrace 选为逐帧来源,0 次回退,共 7,869 帧(第一轮为 0);数据量 0.59 MB,远低于超限门槛。
- 帧耗时 159/185 个样本有值,其余 15 个是
missing-no-new-frame、11 个不在前台;P95、Jitter 各 158/185;FPS 改由逐帧数据计算,159/185。 - 实时卡顿曲线 137/185 个样本有值,其余为“画面静止”、无新帧或不在前台。
- 启动 9 个阶段完整,合计 153.4 ms。第一轮 368.0 ms 是安装后首次启动。
- 仍然无值:刷新率、GPU Counter、热状态、启动辅助两项(observed_running / foreground_ready),以及外接电源时的电池电流、电压、功率。
卡顿校准:v1 在 iOS 15 上漏检(E4)
| 校准段 | 设备 A(iOS 26,120 Hz) | 设备 B(iOS 15,60 Hz) |
|---|---|---|
| 注入 20/50/100/200 ms 与 50/100 ms,共 6 次 | 6/6 检出,时长与注入一致 | 0/6 |
| 重渲染 15 s | 每帧寿命 66.7 ms,431 次卡顿 | 15 秒内没有任何帧(GPU 94–98%),0 次 |
| 整场 | — | 8 次,每次一个刷新间隔(16.5–16.7 ms),全部为限帧起点 |
- 机制:
- iOS 15 的 FrameLifetime 是整屏合成帧。App 主线程卡住时,屏幕不会停帧,而是按静止时的 20 Hz 节奏每 50 ms 合成一帧(200 ms 注入期间是 4 帧,间隔都是 50 ms)。这几帧的开始时刻落在上一帧上屏之后,按 v1 规则算“静止后重新开始(idle)”,不参与判定。
- 重渲染时 GPU 跑满,15 秒没有 FrameLifetime;之后的第一帧同样记为 idle。
- iOS 26 的 FrameUpdate 在卡顿时表现为“帧寿命变长”,v1 正是按这种形态设计的。iOS 15 没有这种形态。
- 时钟:注入标记用的是
mach_absolute_time;iOS 15 的 FrameLifetime 时间戳与它相差约 8,233 秒(推测是设备休眠时间累计,即连续时钟与绝对时钟之差,未证)。本轮按每秒帧数的规律对齐:校准开始约在第一帧后 7 秒,60 fps、20 fps、0 fps 各段与时间表一一对应。
Lead 判定(待 Owner 确认)
iOS 15:帧耗时、FPS、P95、Jitter 记为
candidate(有数,口径与其他版本相同)。卡顿 4 项在 iOS 15 上不能按 v1 发布:会把明显的卡顿显示成接近 0,误导使用者。两个选项:
- 暂时在 iOS 15 上记为
unsupported,原因写“该系统的帧数据无法判定卡顿”,添加指标时禁用,报告中不显示; - 为 iOS 15 另设规则,例如把“动画中途合成节奏降到静止节奏”判为卡顿,再用本轮校准数据验证。
建议先做 1,再评估 2。
- 暂时在 iOS 15 上记为
研究方案 · 2026-11-04:iOS 15 卡顿规则(Owner 选“直接研究”)
数据特征(设备 B 第二轮,E4 原始数据 + E3 离线分析)
- 注入期间合成帧与静止时完全相同:间隔 50 ms、帧种子每帧 +2、帧寿命 16.6 或 33 ms 交替。单看一帧无法区分。
- 卡顿只能从上下文认出来:连续动画(60 fps、起点在上一帧上屏之前)中插入一段静止节奏,随后动画恢复。
- 30 Hz 限帧时,屏幕本身的合成节奏就不规则,连续、限帧、静止三种起点交替出现。
路线 A:只用整屏合成节奏(离线试算)
- 规则:连续动画帧之后出现静止起点的插段,插段 ≤ 1 s 后恢复连续动画,记一次卡顿,H = 恢复帧上屏时刻 − 插段前最后一帧上屏时刻 − V。另外,超过 150 ms 完全没有帧也记一次。
- 试算结果:
| 校准项 | 结果 |
|---|---|
| 注入 50 / 100 / 200 ms(第一组) | 50.0 / 100.0 / 200.0 ms,与注入值一致 |
| 注入 20 ms | 未检出 |
| 注入 50 / 100 ms(第二组) | 66.7 ms ×2、83.3 ms,偏差 1 帧 |
| 重渲染 15 s 无帧 | 检出 1 次,15,049 ms |
| 干净 60 Hz 动画段 | 1–2 次,33 ms |
| 30 Hz 限帧段 | 149 次误报,共 10.0 s |
| 校准前主界面 | 58 次 33 ms |
- 结论:注入能抓到,但限帧场景大量误报,不能直接用。
路线 B:用目标 App 自己的 Core Animation 提交事件(待验证)
- 设备 B 的探测里,
com.apple.coreanimation / Transaction / Commit12 秒有 1,405 条。它是各进程提交画面时自己发出的,带进程号。App 主线程卡住时提交必然停止;屏幕合成节奏不影响它。 - 规则草案:目标进程在连续动画期间相邻两次提交间隔 > 1.5 V,且前后都是稳态节奏,记为一次卡顿。限帧 App 的提交节奏本身就是稳态,不会被误判。
- 需要先确认三件事:
- 卡顿注入期间,目标进程的 Commit 是否按预期停止;
- 限帧段的提交节奏是否稳定;
- 带宽与开销。Commit 事件数约为帧数的 2 倍,现有白名单只放行 FrameLifetime,要扩到“目标进程的 Commit”;隐私上只保留时间戳与进程号,不保留内容。
下一步
- 用只读探测在设备 B 上录一次完整校准(约 110 秒),同时记录 FrameLifetime 与目标进程的 Commit,验证路线 B。需要 Owner 在手机上打开校准页。
- 路线 B 可行的话,再定 iOS 15 规则(
perfowl-hitch.ios15.candidate.v1),并在同一份数据上检查漏检与误报。 - 验收门槛沿用 Phase 2:注入召回率与精确率 ≥ 95%,单次时长误差 ≤ 1 个刷新间隔,干净段与限帧段误报为 0。
- 在通过之前,iOS 15 卡顿仍保持现状(v1,会偏低),在报告中标注。
Validation Update · 2026-11-04:路线 B 在设备 B 上验证(第三轮校准)
- 采集:
tools/validation/capability/capture_frames_commits.py,只读录制 180 秒,同时记录 FrameLifetime 与 Core AnimationTransaction/Commit的时间戳,以及“是否来自目标进程”(只保留这个布尔值,不写进程名)。- 结果:6,420 帧,Commit 8,580 次,Fixture 自己的 Commit 合并为 6,754 次画面更新(同一更新内 4 ms 以内的多次提交合为一次)。
- Commit 与 FrameLifetime 用同一时钟,可以直接比较。注入标记用另一个时钟,以重渲染段的 15 秒无帧为锚对齐,误差约 0.06 秒。
- 规则(
tools/validation/capability/ios15_commit_rule.py,研究版):- Δ = 相邻两次画面更新的间隔;
- 稳态 = 最近 16 个间隔的
round(Δ/V)众数。每个间隔都参与统计,所以节奏切换后稳态会跟上; - H = Δ − 稳态 × V。H ≥ 0.5 V,且下一个间隔恢复变短,记一次卡顿。下一个间隔一样长,说明是节奏本身变了,不计;
- Δ > 2 s 视为 App 停止动画,不判定;
- 另外,App 仍在提交但超过 150 ms 没有合成帧,记一次显示断流。
| 校准项 | 注入 | 检出 |
|---|---|---|
| 120 Hz 动画段 | 20 / 50 / 100 / 200 ms | 22.1 / 51.7 / 102.3 / 203.8 ms |
| 30 Hz 限帧段 | 50 / 100 ms | 49.4 / 98.9 ms |
| 干净动画段、60 Hz 限帧段、多数静止段 | 无 | 0 |
| 静止段 6 | 无 | 1 次 19.3 ms(误报) |
| 重渲染 15 s | — | 显示断流 15,099 ms,外加 7 次提交间隔变长(共 415 ms) |
| 校准开始前(进入校准页) | — | 3 次(134–333 ms),页面切换,未判定真伪 |
- 召回 6/6,单次时长误差都不超过 1 个刷新间隔(最大 2.1 ms)。
- 误报:已知无卡顿的段里有 1 次,所以按校准段计精确率 6/7。还没达到 Phase 2 的 95% 门槛。
- 演进过程:
- 第一版只有“非卡顿间隔”参与稳态,从 60 Hz 切到 30 Hz 后稳态停在旧值,30 Hz 段误报 428 次;
- 改成所有间隔都参与稳态后,降到 9 次;
- 加上“下一个间隔恢复变短”的条件后,降到只剩 2 次注入本身。
结论与下一步
- 路线 B 可行,可作为
perfowl-hitch.ios15.candidate.v1。还差:- 静止段那次误报的原因;
- 真实 App 滑动场景的误报率;
- 进入产品需要扩展白名单(放行目标进程的 Commit,只保留时间戳),并评估开销(Commit 约为帧数的 1.3 倍);
- 第二台 iOS 15 设备或第二轮复测。
- 在通过之前,iOS 15 卡顿仍按 v1 输出(会偏低),没有改动产品代码。
更新 · 2026-11-04:消除节奏切换误报
- 上一版在“静止段 6”的那次 19.3 ms,实际落在 60 → 30 Hz 切换的瞬间:对齐误差约 0.06 s,它属于 30 Hz 段开头。切换时的间隔依次是 30.7、36.0、11.2、18.9 ms,长间隔后面恰好跟着一个短间隔,被误判。
- 新增条件:长间隔的前一个间隔必须处在稳态节奏上(|Δ前 − 稳态×V| ≤ 0.5 V)。后一个间隔仍只要求比当前间隔短,因为卡顿结束后的头几帧本身就不规则(220 ms 之后是 24.8、19.8、13.7 ms)。
- 试过同时要求后一个间隔也处在稳态,结果 100、200 ms 和第二组注入全部漏掉,所以没有采用。
- 结果(同一份数据):
| 项 | 结果 |
|---|---|
| 注入 6 次 | 6/6 检出:22.1 / 51.7 / 102.3 / 203.8 / 49.4 / 98.9 ms |
| 干净动画、限帧、静止各段 | 0 次误报 |
| 重渲染 | 显示断流 15,099 ms,外加 5 次提交间隔变长 |
| 校准开始前 | 1 次 325.5 ms(打开校准页时的页面切换,未判定真伪) |
- 规则是在这一份数据上调出来的,有过拟合风险。下一步:
- 留出验证:再录一次校准,规则不改,直接检验;
- 真实 App 滑动约 2 分钟,看误报。
- 录制脚本改为记录每次 Commit 的进程号,事后取滑动期间提交最多的进程作为前台 App。进程号只保存在本机。
Validation Update · 2026-11-04:路线 B 留出验证(第四轮校准)
- 采集 300 秒:9,509 帧,Commit 11,608 次。规则参数与上一节完全相同,没有再调整。
| 校准项 | 注入 | 检出 |
|---|---|---|
| 120 Hz 动画段 | 20 / 50 / 100 / 200 ms | 22.1 / 51.1 / 101.7 / 201.2 ms |
| 30 Hz 限帧段 | 50 / 100 ms | 51.8 / 102.2 ms |
| 干净动画、限帧、静止各段 | 无 | 0 |
| 重渲染 15 s | — | 显示断流 15,149 ms,外加 1 次提交间隔变长 40.7 ms |
- 召回 6/6,单次时长误差最大 2.2 ms(< 1 个刷新间隔);已知无卡顿的段误报 0。两轮校准合计 12/12,误报 0(E4 原始数据,E3 离线判定)。
- 校准之后约 90 秒的列表滑动:前台进程仍是 Fixture(Owner 在 Fixture 的主列表里滑动)。
- 2,453 次画面更新,可判定时长 40.3 s;
- 检出 2 次,160.1 ms 与 18.4 ms,合计 4.43 ms/s;
- 同期合成帧最大间隔 100 ms。
- 没有注入,无法判定真伪,只作为“正常滑动的量级”参考。
判定:路线 B 达到 Phase 2 的门槛(召回、精确率 ≥ 95%,时长误差 ≤ 1 个刷新间隔),只在设备 B 一台设备上验证过。可以进入产品化方案:perfowl-hitch.ios15.candidate.v1。
产品化方案 · 2026-11-04:iOS 15 卡顿(commit 规则)
| 层 | 改动 |
|---|---|
| 采集白名单 | ActivityTrace 过滤器在 iOS 15 额外放行 com.apple.coreanimation / Transaction / Commit,且只放行目标进程(按已核验的 PID)。只保留时间戳,不保留消息内容。iOS 16+ 不放行,行为不变 |
| 原始事件 | 新增 display.ca-commit.raw(时间戳、目标代际、来源纪元),写入 Raw 与封存 |
| 判定 | hitch_metrics.py 新增 CommitHitchAccumulator:画面更新合并(4 ms)、16 个间隔的众数作稳态、前一个间隔须在稳态上、后一个间隔须变短、2 s 以上视为静止;另外 App 仍在提交而合成帧中断超过 150 ms 记为显示断流。summary 字段与 v1 相同,算法标记 perfowl-hitch.ios15.candidate.v1 |
| 选择 | 按平台合同选判定器:iOS 15 用 commit 规则,其余版本用 v1。在线、离线、实时曲线三处共用同一实现 |
| 能力表 | iOS 15 卡顿 4 项记为 candidate,证据为设备 B 两轮校准 |
- 开销:Commit 约为帧数的 1.2–1.3 倍,单条只做时间比较,O(1);按 60 fps 估算多约 80 条/秒。实施后做基准对比。
- 风险:只有一台 iOS 15 设备、一个 App 的证据;多窗口、键盘等系统界面的提交不在目标进程里,不会计入。
Implementation Update · 2026-11-04:iOS 15 卡顿(commit 规则)产品化
实际修改
| 文件 | 实现 |
|---|---|
display_performance.py | 白名单过滤层 _ActivityTraceFilteredStream 新增可选的 commit_pid:开启后,目标 PID 的 com.apple.coreanimation / Transaction / Commit 只记下设备时间戳,挂到下一条 FrameLifetime 上,其余内容不保留;每帧最多 4,096 个,超过时标记溢出。适配器新增类属性 collect_app_commits(默认关闭),解码后把时间戳按同一时基换算成纳秒,写入 appCommitTimesNs,溢出时写 appCommitOverflow |
realtime.py | 会话开始时 collect_app_commits = (iOS 大版本 == 15)。一个采集进程只跑一场会话,所以用类属性是安全的;iOS 16+ 行为不变。实时窗口的算法标记改为跟随判定器 |
hitch_metrics.py | 新增 _CommitJudge 与 HITCH_ALGORITHM_COMMIT = perfowl-hitch.ios15.candidate.v1。HitchAccumulator 遇到带 appCommitTimesNs 的帧行就进入 commit 模式:汇总字段与 v1 相同,algorithm 写新标记;检查点保存 commit 状态,算法不一致时拒绝恢复;遇到溢出则清空稳态,不跨缺口判定。重复的计数逻辑抽成 _count_hitch |
- 规则与研究版
tools/validation/capability/ios15_commit_rule.py相同:4 ms 合并为一次更新,16 个间隔取众数作稳态,前一个间隔须在稳态上、后一个间隔须变短,间隔超过 2 s 视为静止,150 ms 以上无合成帧视为显示断流。 - 在线报告、离线帧报告、
hitch-recompute与实时曲线都通过HitchAccumulator自动生效,不需要另改。 - 偏差:方案里写的是新增原始事件
display.ca-commit.raw,实际改为把时间戳挂在 FrameLifetime 上。这样 Raw 记录、帧明细、在线报告和实时投影器都不用另开链路;隐私上同样只有时间戳。
测试(E2)
- 新增
tests/test_ios15_commit_hitch.py12 项:- 规则:卡住一次、节奏切换、同一更新内多次提交、静止、显示断流、溢出、检查点往返、实时标记;
- 设备 B 第四轮录制(只含时间戳,
tests/fixtures/ios15_commit_timing_r4.json.gz,71 KB):复现研究版结果,11 次、15,898.348 ms; - 采集层:只收目标 PID 的 Commit、挂到下一帧、缓冲有上限、默认关闭。
- 卡顿相关 45 项通过;Sidecar 全量 3,191 项,失败清单与基线相同(16 项环境失败)。
- 产品实现对两次录制的结果与研究版逐位一致:第三轮 14 次、16,396.392 ms;第四轮 11 次、15,898.348 ms。
开销(x86_64)
过滤层每条消息 3.18 → 3.53 µs,按设备 B 每秒数百条消息计,约 0.1 ms/s;只在 iOS 15 开启。
commit 判定每帧 3.5 µs,低于 v1 的约 5 µs。
帧事件每帧多 1–2 个整数。
状态:implemented(E2/E3)。打内部包 261 后由 Owner 在设备 B 上用 Perfowl 正式测一次(E4)。