商品列表加了一层渐变、图片解码或复杂阴影后,功能测试仍然全绿,滚动却开始掉帧。人工拖几次很难判断这是偶发抖动还是代码回归。更稳妥的做法,是在云端 Mac 上固定模拟器、数据集和手势,用 XCTMetric 连续采样耗时、CPU 与内存,再让持续集成只拦截超过基线的变化。
先固定影响结果的变量
性能门禁的第一步不是写断言,而是减少环境噪声。固定 Xcode 与 macOS 版本、模拟器型号、系统语言、显示方向和测试数据。测试账户应直接进入目标页面,避免登录请求、图片下载和服务端响应时间混入滚动指标。
给应用增加仅在 UI 测试启用的启动参数,例如 -UITestSeed fixed。它应加载本地夹具,生成固定数量、固定顺序的列表项,并关闭随机动画。列表还需要稳定的无障碍标识,如 feed.list 和 feed.reset,测试代码不要依赖本地化文本查找元素。
首次运行通常包含应用安装、动态库装载、字体初始化和缓存预热。正式采样前先执行一次不计分测试,但不要通过删除缓存来制造不符合日常使用的“纯冷启动”环境。
性能基线描述的是一套完整环境,不是某个可以搬到所有 Mac、所有模拟器上的通用毫秒数。
写出可重复的滚动路径
单次 swipeUp() 太容易受起点和列表长度影响。更可靠的测试应先回到顶部,确认首个锚点可见,再执行固定次数的手势。每轮测量结束后重复复位,避免下一轮从列表底部开始。
import XCTest
final class FeedScrollPerformanceTests: XCTestCase {
func testFeedScrollPerformance() {
let app = XCUIApplication()
app.launchArguments += ["-UITestSeed", "fixed"]
app.launch()
let list = app.collectionViews["feed.list"]
XCTAssertTrue(list.waitForExistence(timeout: 10))
app.buttons["feed.reset"].tap()
XCTAssertTrue(app.cells["feed.item.0"].waitForExistence(timeout: 5))
let options = XCTMeasureOptions()
options.iterationCount = 5
measure(
metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()],
options: options
) {
app.buttons["feed.reset"].tap()
for _ in 0..<6 {
list.swipeUp(velocity: .fast)
}
}
}
}
复位按钮可以只在测试模式出现,但它必须执行确定性滚动,而不是重新请求数据。若页面采用分页加载,应把网络层替换为本地响应,或明确把分页耗时拆成另一项测试。
在固定目的地运行并保存结果
先列出可用模拟器,确认持续集成没有悄悄切换设备。随后为测试指定唯一的结果包路径;旧结果包必须提前删除,否则 xcodebuild 会拒绝覆盖。
set -euo pipefail
RESULT="$PWD/artifacts/FeedScroll.xcresult"
rm -rf "$RESULT"
mkdir -p "$PWD/artifacts"
xcodebuild test \
-workspace App.xcworkspace \
-scheme AppUITests \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-only-testing:AppUITests/FeedScrollPerformanceTests \
-resultBundlePath "$RESULT"
OS=latest 适合始终跟随当前工具链的流水线;需要长期比较时,应固定具体运行时,并在升级 Xcode 或系统后重建基线。OVPS 节点上的任务也应记录主机配置、提交号、Xcode 版本和模拟器运行时,避免把环境升级误判成代码退化。
使用当前 Xcode 自带的 xcresulttool 前先查看帮助,因为不同工具链的子命令可能调整。无论采用脚本还是测试报告解析器,原始 xcresult 都应作为失败附件保存。
用中位数和相对阈值判定
不要因为一次慢样本就阻断合并。每个提交执行五到七轮,先剔除预热轮,再计算中位数。基线可取主分支最近若干次稳定运行的中位数,而不是永久保存第一次测量值。
建议分别观察三类变化:总耗时反映用户等待,CPU 指标容易暴露重复布局或过度绘制,内存指标可发现图片缓存和视图复用问题。某次提交若耗时上升但 CPU 与内存稳定,应先检查模拟器负载;三项同时恶化,才更像应用代码回归。
阈值应使用比例并设置最小绝对差。例如,只有当中位耗时比基线上升超过 12%,且绝对增加超过一个团队定义的噪声地板时才失败。阈值数字需要根据同一环境的历史波动确定,不能照抄其他项目。
失败后保全证据并缩小范围
门禁失败时,不要立即覆盖基线。先重跑一次相同提交;若仍失败,保存 xcresult、完整日志、测试数据版本、系统与工具链版本,以及 CPU、内存和耗时摘要。再通过提交范围缩小变化,重点检查图片解码、阴影与模糊、自动布局约束、主线程同步 I/O、列表复用和日志输出。
还要区分“指标退化”与“测试路径变化”。如果无障碍标识失效、复位动作没有回到顶部,测试可能仍能执行,却测量了不同页面区域。为关键起点和终点增加可见性断言,比单纯增加重试次数更有效。
基线更新应作为独立变更提交,说明环境升级或可接受的交互调整,并附更新前后的样本。这样,性能门禁既不会被偶发噪声频繁触发,也不会因为随手刷新基线而失去作用。
常见问题
滚动性能门禁应该使用固定毫秒阈值吗?
不建议直接套用跨机器的固定值。应在同一机型、系统、模拟器和测试数据下建立基线,再按最近稳定样本的中位数设置相对阈值。
为什么首次测试结果通常不能进入基线?
首次运行可能包含应用安装、动态库装载、字体初始化和缓存预热成本。先执行一次不计分预热,再采集多次正式样本更可靠。
性能测试失败后最少要保存哪些材料?
至少保存 xcresult、测试日志、提交号、Xcode 与系统版本、模拟器型号、测试数据版本,以及失败时的 CPU 和内存指标。
把下一次构建放到独享物理节点
从三档 Apple Silicon 配置和六个在售节点中完成选择,实际可用状态以控制台实时返回为准。