指标与求证方法
# 跨平台性能模型与指标体系
📊 学习成本预估 | 难度:⭐⭐⭐⭐(4/5)| 阅读:约 25 分钟 | 实操:1 小时 🔗 前置阅读:卷零·01 | ➡️ 后续延伸:卷一·01, 卷一·03
本文定义专栏的统一指标语言。所有性能议题(启动、卡顿、内存、网络…)必须能在本文定义的指标体系中找到对应位置,否则视为"伪议题"。
# 目录介绍
# 01.为什么需要统一的指标体系
# 1.1 指标混乱的代价
设想以下真实对话:
- A:我们启动优化了 30%!
- B:你说的是冷启动还是温启动?
- A:……都算吧,平均值。
- B:哪一档机型?是 Time to First Frame 还是 Time to Interactive?
- A:……
指标定义不清的结果:
- 团队之间无法对齐 —— 同一个数字代表不同含义。
- 优化效果不可验证 —— 改动前后的"对比"不可比。
- 线上线下不一致 —— 实验室数据漂亮,线上灾难。
- 老板看的报表是错的 —— 决策基于错觉。
统一指标体系的目的:让"启动 1.2s"这种描述对任何团队、任何平台都有精确、可复现、可度量的含义。
# 1.2 一个好指标的五个特征
借鉴 Google SRE 与 Brendan Gregg 的实践:
| 特征 | 含义 | 反例 |
|---|---|---|
| 可度量 | 可由仪器自动采集 | "用户感觉很卡" |
| 可定义 | 有数学表达,无歧义 | "性能优秀" |
| 可对比 | 不同时刻 / 不同版本之间可比 | 单次抽样 |
| 可归因 | 异常时能定位到具体层 | "整体性能下降" |
| 可决策 | 有阈值能驱动行动 | "FPS 大概 50" |
# 02.三种指标视角
性能指标的世界有三个互补的视角,缺一不可。
# 2.1 USE 模型 — 资源视角
由 Brendan Gregg 提出,针对任意资源的健康度三元组:
| 维度 | 含义 | 典型指标 |
|---|---|---|
| Utilization 利用率 | 资源在被使用的时间占比 | CPU% / 内存使用率 / GPU 占用率 |
| Saturation 饱和度 | 排队 / 等待程度(超额需求) | Run Queue 长度 / Page Fault 频率 / IO Wait |
| Errors 错误数 | 资源相关错误事件 | OOM / IO Error / 网络重传 |
关键认知:
- 利用率高 ≠ 有问题(CPU 100% 可能是健康的,只要不饱和、不出错)。
- 饱和度才是性能瓶颈的真实信号。CPU 利用率 70% + Run Queue 持续 > 1,比 CPU 100% + Run Queue=0 更危险。
适用场景:CPU、内存、磁盘、网络、GPU、文件描述符、线程池…… 任何"有限资源"。
# 2.2 RED 模型 — 请求视角
由 Tom Wilkie 提出,针对任意请求型组件:
| 维度 | 含义 | 典型指标 |
|---|---|---|
| Rate 速率 | 单位时间请求数 | QPS / 启动次数 / 帧产出速率 |
| Errors 错误率 | 失败请求占比 | 网络错误率 / 渲染丢帧率 / 崩溃率 |
| Duration 时长 | 单次请求耗时分布 | 接口耗时 P95 / 帧时长 P99 |
适用场景:网络请求、页面加载、单帧渲染、单次启动、单次手势响应。
# 2.3 APDEX / Web Vitals — 用户感知视角
USE 看资源、RED 看请求,但用户不关心这些,用户关心感受。因此需要第三类指标:
# APDEX (Application Performance Index)
APDEX = (Satisfied + Tolerating/2) / Total
阈值 T 由产品定义:
≤ T → Satisfied
T < x ≤ 4T → Tolerating
> 4T → Frustrated
# Google Web Vitals(Web 端事实标准,可借鉴到客户端)
| 指标 | 含义 | 优秀阈值 | 物理依据 |
|---|---|---|---|
| LCP Largest Contentful Paint | 主要内容渲染 | < 2.5s | 用户感知"加载完成" |
| INP Interaction to Next Paint | 交互到下次绘制 | < 200ms | 操作反馈连续性 |
| CLS Cumulative Layout Shift | 布局抖动累积 | < 0.1 | 视觉稳定性 |
# 客户端等价指标(本专栏定义)
| 客户端指标 | Web Vitals 对应 | 含义 |
|---|---|---|
| TTFM Time to First Meaningful Frame | LCP | 启动到首屏可见有意义内容 |
| TTI Time to Interactive | INP/TTI | 启动到可响应用户操作 |
| Tap Latency 点击响应延迟 | INP | 用户触摸到下一帧绘制 |
| Frame Drop Ratio 丢帧率 | — | 单位时间内未按时绘制的帧占比 |
| Jank Score 卡顿评分 | — | 综合卡顿严重度(次数 × 时长) |
# 2.4 三视角组合使用
用户感知(APDEX/Vitals)
▲
│ "用户体验如何?"
│
请求时长/错误(RED)
▲
│ "哪类操作出问题?"
│
资源饱和/错误(USE)
▲
│ "为什么会出问题?"
│
系统层
自顶向下分析路径:
- 用户感知层(APDEX)告警 → 用户体验劣化。
- 下钻到 RED → 哪些请求慢 / 错?
- 下钻到 USE → 是哪个资源饱和 / 出错导致的?
反例:只看 USE 不看 APDEX —— "CPU 不高、内存不满,但用户骂街"。这种情况几乎一定是流水线阻塞(off-CPU stall),需要专门的方法(详见《05.归因方法论》)。
# 03.分布与百分位
# 3.1 为什么均值是性能领域的谎言
考虑两组延迟数据,单位 ms:
组 A: [50, 50, 50, 50, 50, 50, 50, 50, 50, 50] 均值 50, P99 50
组 B: [10, 10, 10, 10, 10, 10, 10, 10, 10, 410] 均值 50, P99 410
均值相同,但 B 的用户体验是灾难:每 10 次操作有 1 次卡顿 410ms。如果只看均值,你以为两个版本一样好。
为什么性能数据天然长尾?
- GC 偶发触发
- IO 偶发抖动
- 缓存偶发未命中
- 锁偶发竞争激烈
- 网络偶发抖动
→ 性能延迟分布几乎都是右偏的、长尾的,均值被尾部样本严重拉偏。
# 3.2 百分位的物理含义
| 百分位 | 物理含义 | 关注场景 |
|---|---|---|
| P50(中位数) | 一半用户的体验 | 普遍体验 |
| P90 | 10% 用户感受到的"差体验" | 优化的常规目标 |
| P95 | 5% 用户感受到的"明显差体验" | 商业级 SLO 常用 |
| P99 | 1% 用户的"灾难体验" | 大流量产品必看 |
| P99.9 / P99.99 | 万分之一概率的极端尾部 | 金融 / 关键系统 |
关键认知:
- DAU 1 亿的产品,P99 = 5s 意味着每天 100 万次"灾难体验"。
- 优化的目标不是降均值,而是压缩尾部。
# 3.3 直方图与 HDR 直方图
百分位需要从直方图计算。两种常见实现:
| 类型 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 等距直方图 | 简单 | 高百分位精度差 | 范围已知的指标 |
| HDR Histogram | 对数桶,全量程高精度 | 实现复杂 | 性能延迟(推荐) |
实践要点:
- 不要用"算 P99 = 取后 1% 平均" —— 这是错的。
- 不要在客户端聚合 P99 后上报 —— P99 不可线性合并。
- 上报直方图桶 + 计数,在服务端聚合后计算分位。
# 3.4 抖动与稳定性指标
均值低不代表稳定。以下指标度量"波动":
| 指标 | 含义 | 用途 |
|---|---|---|
| 标准差 σ | 波动幅度 | 整体稳定性 |
| 变异系数 CV = σ/μ | 相对波动 | 跨指标对比 |
| P99/P50 比率 | 长尾扩张倍数 | 长尾健康度 |
| Jitter | 相邻帧 / 相邻请求时长差 | 帧率稳定性 |
帧率领域有句话:"60FPS 的均值不如 50FPS 的稳定"。用户对抖动的感知比对速度更敏感。
# 04.跨平台核心指标矩阵
下面是本专栏的"指标字典",后续章节直接引用。
# 4.1 CPU 类指标
| 指标 | 视角 | 定义 | Android 采集 | iOS 采集 | Web 采集 | 嵌入式采集 |
|---|---|---|---|---|---|---|
| 进程 CPU 使用率 | USE-U | 进程占用 CPU 时间 / 总时间 | /proc/[pid]/stat | host_processor_info | Performance API(受限) | /proc/[pid]/stat |
| 主线程 on-CPU% | USE-U | 主线程在 CPU 上执行时间占比 | /proc/[pid]/task/[tid]/stat | thread_info | Long Tasks API | task_struct |
| Run Queue 长度 | USE-S | 等待 CPU 的线程数 | /proc/loadavg | host_load_info | 不可见 | /proc/loadavg |
| 上下文切换率 | USE-S | 每秒上下文切换次数 | /proc/[pid]/status | task_events_info | 不可见 | /proc/stat |
| IPC(每周期指令) | 效率 | 单位 CPU 周期完成的指令数 | simpleperf | Instruments CPU Counters | 不可见 | perf |
# 4.2 内存类指标
| 指标 | 视角 | 定义 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|---|---|
| RSS 物理内存 | USE-U | 实际占用物理内存 | /proc/[pid]/status | task_vm_info | 不可见(隔离) | /proc/[pid]/status |
| PSS 比例集 | USE-U | 共享内存按比例分摊后 | /proc/[pid]/smaps | 无对应 | 不可见 | /proc/[pid]/smaps |
| Java/Heap 使用 | USE-U | VM 堆已用 | Runtime.totalMemory() | 无 | performance.memory | 无 |
| GC 频率 | USE-S | 单位时间 GC 次数 | ART trace | 无(ARC) | DevTools | — |
| GC 停顿时长 | USE-S | 单次 GC 暂停时长 | ART trace | 无 | PerformanceObserver(longtask) | — |
| OOM 次数 | USE-E | 进程因内存被杀次数 | lowmemorykiller | Jetsam | crash | OOM Killer |
| 内存抖动 | USE-S | 短时间内大量分配 / 释放 | Allocation Tracker | Allocations Instrument | DevTools | — |
# 4.3 渲染类指标
| 指标 | 视角 | 定义 |
|---|---|---|
| 平均帧率 FPS | RED-R | 单位时间渲染帧数 |
| 帧时长 P99 | RED-D | 99% 的帧能在多长时间内绘制完成 |
| 丢帧率 | RED-E | 未在 Vsync 内完成的帧占比 |
| 大卡顿率 | APDEX | 单帧 > 700ms 的次数 / 总帧数 |
| Jitter | 抖动 | 相邻帧时长方差 |
| GPU 占用 | USE-U | GPU 利用率 |
| Overdraw 倍数 | 效率 | 像素被重复绘制的次数 |
平台采集:
- Android:
Choreographer.FrameCallback+gfxinfo+ Perfetto - iOS:
CADisplayLink+MetricKit+os_signpost - Web:
requestAnimationFrame+PerformanceObserver(frame) - 嵌入式:显示控制器 IRQ + 帧缓冲交换计数
# 4.4 网络类指标
| 指标 | 视角 | 定义 |
|---|---|---|
| 请求成功率 | RED-E | 2xx + 3xx 占比 |
| 接口耗时 P95 | RED-D | 95% 请求完成时长 |
| DNS / Connect / TLS / TTFB / Total 分段耗时 | RED-D | 请求各阶段时长 |
| 重传率 | USE-S | TCP 重传比例 |
| 弱网占比 | 环境 | RTT > 阈值 / 丢包率 > 阈值的会话占比 |
# 4.5 启动类指标
启动是客户端最复杂的复合指标:
| 指标 | 起点 | 终点 | 物理含义 |
|---|---|---|---|
| Cold Launch Time | 进程创建 | 首帧 | 完整冷启动 |
| TTFF Time to First Frame | 进程创建 | 第一帧上屏 | 用户看到"动了" |
| TTFM Time to First Meaningful Frame | 进程创建 | 首屏内容可见 | 用户看到"有用" |
| TTI Time to Interactive | 进程创建 | 主线程空闲、可响应 | 用户能"操作" |
三个时刻递进:TTFF < TTFM < TTI。多数团队只关注 TTFM,但 TTI 才决定"用户能不能用"。
# 4.6 稳定性类指标
| 指标 | 定义 |
|---|---|
| Crash Rate | 崩溃用户数 / DAU |
| ANR Rate | ANR 用户数 / DAU |
| Foreground OOM Rate | 前台 OOM 数 / DAU |
| 异常退出率 | 非正常进程结束占比 |
# 05.指标的边界与陷阱
# 5.1 高 Goodhart's Law 风险指标
"When a measure becomes a target, it ceases to be a good measure." — Charles Goodhart
某些指标一旦被作为 KPI,团队会"针对指标"而非"针对体验"优化:
| 高风险指标 | 作弊方式 |
|---|---|
| 平均 FPS | 把卡顿挪出采集窗口 |
| 启动时长 | 把耗时初始化推迟到首帧之后(伪首帧) |
| 崩溃率 | 把 Crash 拦截后吞掉 |
| 内存均值 | 在采集时机主动 GC |
对策:
- 用多指标组合(FPS 必带 P99 + 大卡顿率)。
- 用用户感知指标兜底(如 NPS、留存)。
- 关键指标做采集口径文档化 + 评审。
# 5.2 平台口径差异
同名指标在不同平台口径不同,必须显式声明:
| 指标 | Android 口径 | iOS 口径 | 是否可直接对比 |
|---|---|---|---|
| 内存 | PSS(含共享分摊) | Footprint(不含 dirty 共享) | ❌ |
| FPS | Choreographer 回调统计 | CADisplayLink 回调统计 | ✅(语义相近) |
| 启动 | Application.onCreate→首帧 | main()→viewDidAppear | ❌ 起止点不同 |
| 崩溃 | 包含 Native + Java + ANR | 只含信号崩溃 | ❌ |
# 5.3 采样误差与置信区间
任何指标都是样本估计,应附带置信区间:
P95 = 1.2s ±0.05s (95% CI, n=10000)
经验法则:
- 估计 P50:样本量 ≥ 100
- 估计 P95:样本量 ≥ 1000
- 估计 P99:样本量 ≥ 10000
- 估计 P99.9:样本量 ≥ 100000
样本不足时,禁止给出高百分位结论。
# 06.从指标到 SLO
# 6.1 SLI / SLO / SLA 的关系
| 概念 | 含义 | 例子 |
|---|---|---|
| SLI Service Level Indicator | 实际度量值 | "本周冷启动 P95 = 2.1s" |
| SLO Service Level Objective | 内部目标 | "冷启动 P95 ≤ 2.0s 占比 ≥ 99%" |
| SLA Service Level Agreement | 对外承诺 | 客户端通常无 SLA |
# 6.2 错误预算
错误预算 = 1 - SLO
如 SLO 为 P95 ≤ 2.0s 占比 99%,
则允许 1% 的"违约时段"作为创新 / 上线的预算。
错误预算的两个用途:
- 预算未耗尽:可激进上线、可做有风险的优化实验。
- 预算耗尽:冻结新功能、专注治理。
# 6.3 端侧 SLO 的特殊性
服务端 SLO 主要看可用性,端侧 SLO 必须分维度切片:
| 切片维度 | 必要性 |
|---|---|
| 机型档位(高 / 中 / 低端) | 高端机 SLO 严格,低端机宽松 |
| 系统版本 | 老系统给宽松目标 |
| 网络类型(WiFi / 4G / 弱网) | 弱网下放宽 |
| 国家 / 地区 | 海外网络环境差异大 |
| 应用版本 | 防止新版本劣化老用户 |
端侧 SLO 的常见错误:用一个总均值定 SLO。结果是高端机被低端机数据掩盖、问题永远找不到。
# 07 行业 SLO 基准对照
本章汇集业界公开过的性能基准数据,作为团队制定 SLO 时的参考锚点。 数据来源:Google I/O / WWDC / 字节火山引擎 / 阿里云栖 / Meta 工程博客等公开材料。 使用注意:业界基准 ≠ 你的目标,你应该比所在行业平均高一档,比头部低一档(除非你就是头部)。
# 7.1 启动时间基准
| 类型 | 行业平均 | 优秀基准 | 头部目标 | 来源 |
|---|---|---|---|---|
| Android 冷启动(中端机) | 2.5-3.5s | ≤ 2.0s | ≤ 1.2s | Google Play Console 公开 |
| iOS 冷启动 | 1.5-2.5s | ≤ 1.5s | ≤ 0.8s | WWDC 'Optimize App Launch' |
| Web 首屏(FCP) | 2.5s | ≤ 1.8s | ≤ 1.0s | Google Web Vitals |
| 微信小程序冷启动 | 3-5s | ≤ 2.0s | ≤ 1.5s | 微信公开课 |
# 7.2 渲染与卡顿基准
| 指标 | 行业平均 | 优秀基准 | 头部目标 |
|---|---|---|---|
| FPS P95(60Hz) | ≥ 50 | ≥ 55 | ≥ 58 |
| FPS P99(60Hz) | ≥ 40 | ≥ 50 | ≥ 55 |
| 卡顿率(帧 > 100ms) | ≤ 2% | ≤ 0.5% | ≤ 0.1% |
| ANR 率 | ≤ 0.4% | ≤ 0.1% | ≤ 0.05% |
| 输入响应 P95 | ≤ 200ms | ≤ 100ms | ≤ 50ms |
# 7.3 内存与崩溃基准
| 指标 | 行业平均 | 优秀基准 | 头部目标 |
|---|---|---|---|
| 崩溃率 | ≤ 0.5% | ≤ 0.1% | ≤ 0.02% |
| OOM 占崩溃比 | 30-50% | < 20% | < 10% |
| Java 堆峰值(中端机) | 200-300MB | ≤ 200MB | ≤ 150MB |
| Native 堆 | < 200MB | < 150MB | < 100MB |
# 7.4 网络性能基准
| 指标 | 行业平均 | 优秀基准 | 头部目标 |
|---|---|---|---|
| API 成功率 | ≥ 98% | ≥ 99.5% | ≥ 99.9% |
| API 耗时 P95 | ≤ 2s | ≤ 1s | ≤ 500ms |
| DNS 解析 P95 | ≤ 200ms | ≤ 100ms | ≤ 50ms |
| 弱网(地铁)成功率 | ≥ 80% | ≥ 95% | ≥ 99% |
# 7.5 头部 App 公开过的真实数字
| App | 场景 | 数字 | 来源 |
|---|---|---|---|
| 抖音 | 启动到首条视频可播 | < 1s | 字节火山引擎案例 |
| 微信 | 冷启动 P50 | ~ 1.5s | 微信公开课 |
| 手淘 | 冷启动到首屏 | ~ 1.8s | 阿里云栖大会 |
| iOS 冷启动 | < 1.2s | Meta 工程博客 | |
| Notion | 离线优先架构下,启动 P95 | ~ 800ms | Notion Engineering 博客 |
# 7.6 反例:失败的优化案例
| 案例 | 教训 |
|---|---|
| Meta Lite 启动从 2.5s 优化到 0.8s | 包体积增加 30%,发达地区收益 < 落后地区 |
| 某电商 App 全量异步化启动 | 启动反升 250ms(异步切换开销),后续回滚 |
| Twitter "Predict and Preload" 实验 | 命中率仅 23%,整体功耗反而升高 |
核心启示:基准是参考,不是目标——你的业务、用户群、设备分布决定了你"应该"达到什么水平。
# 一句话总结
指标体系的价值,不是产生数字,而是产生"可决策的语言"。
当团队所有人在说"P95 帧时长"时,指的是同一件事,性能工程才能开始运转。
# 附录:03.性能求证方法论
# 性能求证实验方法论
📊 学习成本预估 | 难度:⭐⭐⭐⭐⭐(5/5)| 阅读:约 30 分钟 | 实操:2 小时 🔗 前置阅读:卷零·01-02 | ➡️ 后续延伸:所有专项卷
本文是专栏"科学家求证"风格的核心。任何"优化建议"在写入文档前,必须经过本文定义的实验流程产出可证伪的数据。否则视为经验主义,不予采纳。
# 目录介绍
# 01.为什么需要"实验"
# 1.1 经验性结论的不可靠性
性能领域充斥着"民间智慧",例如:
- ❓ "ArrayList 比 LinkedList 快"
- ❓ "局部变量比成员变量访问快"
- ❓ "RecyclerView 比 ListView 快"
- ❓ "WebP 一定比 JPG 省流量"
- ❓ "线程池一定比 new Thread 快"
这些结论部分情况下成立,但作为"通用建议"是危险的:
- ArrayList vs LinkedList:取决于访问模式(随机访问 vs 频繁插入)。
- RecyclerView vs ListView:在数据量小时,RecyclerView 的 ViewHolder 创建开销反而更高。
- WebP vs JPG:解码 CPU 开销显著高于 JPG,弱机型下"省了流量、卡了体验"。
- 线程池 vs new Thread:高频短任务确实如此,但单次长任务两者无差异。
结论的有效性永远依赖前提条件。求证实验的目的,就是把这些前提条件显式量化。
# 1.2 性能优化是一门可证伪的工程学
引入卡尔·波普尔(Karl Popper)的科学哲学:
一个命题是科学的,当且仅当它可被证伪。
反例(不可证伪):
- "异步加载提升性能" —— 提升多少?什么场景下成立?无法证伪。
正例(可证伪):
- "在 Android 8.0+、4GB 内存机型上,将首页 12 张图片改为 IO 线程预解码,首屏 P95 帧时长从 32ms 降至 18ms,置信区间 ±1.5ms" —— 任何人可重复实验,发现不符即证伪。
本专栏所有"建议"都必须以可证伪的形式陈述。
# 02.实验的科学框架
# 2.1 假设的可证伪性
实验从假设出发。一个合格的性能假设包含 5 个要素:
H₁: [变更 X] 在 [场景 S] 下,会使 [指标 M] 改善 [幅度 Δ],置信水平 [C]。
示例对比:
| 不合格假设 | 问题 |
|---|---|
| "用 ProtoBuf 替换 JSON 会更快" | 缺少场景、指标、幅度 |
| "ProtoBuf 比 JSON 快 50%" | 缺少场景、指标、置信水平 |
| ✅ "在 1000 字段大对象的反序列化上,ProtoBuf 较 Gson 的 P95 耗时降低 40%(±5%,95% CI),样本 n=10000" | 完整 |
配套零假设 H₀:
H₀: 变更 X 对指标 M 没有显著影响(差异由随机波动产生)
实验目标是用数据拒绝 H₀,而非"证明 H₁"。
# 2.2 控制变量原则
只允许一个变量变化,其他变量必须严格一致。
典型违规:
- "我升级了 SDK 版本,顺便重构了缓存策略,性能提升了 20%" —— 无法判断是哪个变更带来的提升,甚至可能两个变更互相抵消。
控制变量清单(性能实验必查):
- [ ] 设备型号、SoC、内存容量
- [ ] 系统版本(OS Build 号)
- [ ] 应用版本、Build Type(Debug / Release)
- [ ] 编译选项(-O0 / -O2 / R8 等级)
- [ ] 充电状态、温度(移动设备会触发降频)
- [ ] 网络类型 / 模拟弱网参数
- [ ] 后台进程、内存压力
- [ ] 数据集(图片、列表数据)
- [ ] 启动状态(冷 / 温 / 热)
# 2.3 对照组与实验组
最少需要两组:
| 组别 | 含义 |
|---|---|
| 对照组(Control) | 不做变更(baseline) |
| 实验组(Treatment) | 应用待测变更 |
进阶:
- 多实验组:变更 A vs 变更 B vs 不变更,找出最优。
- 交叉实验:同一台设备先跑 A 后跑 B,再换 B → A,消除设备序列偏差。
反例:"改完代码跑了一下,比之前快了 200ms" —— 没有对照组,无法排除环境差异。
# 2.4 重复性与可复现性
重复性(Repeatability):同一实验者、同一环境,多次运行结果一致。 可复现性(Reproducibility):他人按文档复刻实验,结果一致。
最低要求:
- 单次实验内部,至少重复 30 次取分布。
- 整体实验,至少在 2 台同型号设备上独立验证。
- 关键结论,鼓励他人复刻(开源样例工程)。
# 03.实验设计七步法
# 3.1 第一步:明确命题
写下一句话命题,用 [01.总论] 的"四步归因"反推:
现象:用户反馈滚动列表卡
↓
命题候选:
- H₁ₐ: 图片解码在主线程,导致单帧 > 16ms
- H₁ᵦ: ViewHolder 创建过于频繁
- H₁ᵧ: 业务回调里有同步 IO
↓
选择最可能的一个作为本实验命题
一次实验只验证一个命题。多命题混跑等于没跑。
# 3.2 第二步:选择度量指标
参考 [02.指标体系] 选择主指标 + 副指标 + 护栏指标:
| 类型 | 作用 | 示例(首屏卡顿实验) |
|---|---|---|
| 主指标 | 直接验证假设 | 首屏 5s 内丢帧率 |
| 副指标 | 辅助理解机制 | 主线程 on-CPU%、IO Wait |
| 护栏指标 | 防止顾此失彼 | 首屏内存峰值、CPU 总耗时 |
护栏指标的作用:避免"优化了主指标,但其他指标劣化"。
# 3.3 第三步:设计实验环境
环境必须稳定且接近真实:
| 维度 | 推荐设置 |
|---|---|
| 设备 | 至少高 / 中 / 低三档机型 |
| 系统 | 主流版本 + 最低支持版本 |
| 编译 | Release 包(与线上一致) |
| 监控 | 关闭其他采集 SDK,避免互相干扰 |
| 状态 | 充电至 50% 以上、温度 < 35°C、清空后台 |
线下复现真实环境的技巧:
- 模拟弱网:Charles / 系统 Network Link Conditioner
- 模拟内存压力:
ActivityManager.setProcessImportance/memory_pressure_simulator - 模拟低端机:CPU 限频、关闭核心、
ThrottlingOptions
# 3.4 第四步:确定样本量
样本量决定结论的统计力(Statistical Power)。
经验法则(参见 [02.指标体系] 5.3):
| 目标 | 最小样本量 |
|---|---|
| 估计均值 | n ≥ 30 |
| 估计 P95 | n ≥ 1000 |
| 估计 P99 | n ≥ 10000 |
| 显著性检验(中等效应量) | n ≥ 100 / 组 |
正式公式(双样本 t 检验):
n = 2 × (Z_{α/2} + Z_β)² × σ² / Δ²
其中:
Z_{α/2} —— 显著性水平(α=0.05 时为 1.96)
Z_β —— 统计力(β=0.2 即 power=0.8 时为 0.84)
σ —— 指标标准差(先做 pilot 估计)
Δ —— 期望检测的最小差异
不知道 σ?做一次 30 样本的预实验估计一下,再算正式样本量。
# 3.5 第五步:执行采样
采样规范:
- 预热:先跑 N 次丢弃数据(消除冷启动 / JIT 编译影响)。
- 随机化:交替执行对照组与实验组,避免时间漂移(设备温度变化等)影响一组。
- 隔离:每次实验后清理状态(清缓存、重启应用),保证起点一致。
- 记录原始数据:保留每次原始耗时,不要直接保存均值。
典型采样脚本结构:
def run_experiment(group, n_warmup=10, n_sample=100):
samples = []
for _ in range(n_warmup):
run_once(group) # 丢弃
for _ in range(n_sample):
reset_state()
t = run_once(group)
samples.append(t)
return samples
# 交替执行避免时间漂移
results_a, results_b = [], []
for _ in range(rounds):
if random.random() < 0.5:
results_a += run_experiment("A")
results_b += run_experiment("B")
else:
results_b += run_experiment("B")
results_a += run_experiment("A")
# 3.6 第六步:统计分析
必做的四件事:
# A. 描述性统计
组 A: n=1000, mean=18.2ms, P50=17ms, P95=24ms, P99=38ms, σ=4.1
组 B: n=1000, mean=14.5ms, P50=14ms, P95=19ms, P99=29ms, σ=3.5
# B. 可视化分布
- 直方图 / 密度曲线:看分布形状(是否双峰、是否长尾)。
- 箱线图:看四分位与离群点。
- CDF 曲线:跨组对比时最直观。
# C. 显著性检验
| 数据特性 | 推荐检验 |
|---|---|
| 样本量大、近似正态 | t 检验 |
| 长尾分布(性能数据常见) | Mann-Whitney U 检验(非参数) |
| 多组对比 | Kruskal-Wallis |
判定:p < 0.05 即拒绝 H₀,认为差异显著。
# D. 效应量
显著 ≠ 重要。还要看效应大小:
Cohen's d = (μ_A - μ_B) / σ_pooled
d ≥ 0.2 小效应
d ≥ 0.5 中等效应
d ≥ 0.8 大效应
"显著且效应量大"才值得上线。"显著但效应量极小"是统计噪声。
# 3.7 第七步:边界探查
任何结论都有适用边界。明确以下问题:
- 在哪些条件下结论不再成立?(机型 / 数据规模 / 网络条件)
- 是否存在反例场景?
- 是否会与其他优化冲突?(如预解码可能与内存压力冲突)
记录到结论的"适用边界"章节。没有边界声明的结论就是错的。
# 04.基准测试(Benchmark)的陷阱
线下做微基准(micro-benchmark)时,以下陷阱极易出错:
# 4.1 编译器优化导致的"代码消失"
// 想测试 expensiveCalc 耗时
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
expensiveCalc(i);
}
long cost = System.nanoTime() - start;
如果 expensiveCalc 的返回值未被使用,编译器(JIT / R8 / LLVM)可能完全消除调用,测出几乎 0ms。
对策:
- 使用专业框架:JMH(Java)、XCTest measure(iOS)、Benchmark.js(Web)
- 强制使用返回值(
Blackhole.consume(result)) - 关闭可能影响测量的优化等级
# 4.2 缓存预热与冷启动
第一次调用慢(JIT 未编译、Cache Miss、Page 未加载),后续快。
对策:
- 区分冷态和热态测试,分别上报。
- 预热样本(warmup)丢弃。
# 4.3 测量本身扰动被测对象
Heisenberg 效应:
System.nanoTime()调用本身有开销。- 频繁打日志拖慢被测路径。
- Profiler 插桩使代码慢 5-10 倍(但比例可能改变结论)。
对策:
- 测量调用尽量稀疏,外层包大循环平摊开销。
- 使用低开销采样(如 perfetto sampling profiler)。
- 测量"绝对值"用低开销工具,测量"相对差异"才用 profiler。
# 4.4 设备状态干扰
- 移动设备温度上升触发降频(CPU 时钟降低 30%+)。
- 后台进程抢占 CPU / IO。
- 电池低于 20% 触发省电模式。
对策:
- 实验前检查温度、电量、后台进程。
- 单次实验时长 < 10 分钟,避免温度漂移。
- 同设备多组实验间隔休息(降温)。
# 05.线上 A/B 实验
线下基准能验证"机制层"的因果,但最终需要线上 A/B 验证用户层效果。
# 5.1 与线下基准的差异
| 维度 | 线下基准 | 线上 A/B |
|---|---|---|
| 控制度 | 高(可控制变量) | 低(环境多样) |
| 真实性 | 低(数据集理想) | 高(真实用户) |
| 样本量 | 千级 | 万级到亿级 |
| 反馈速度 | 分钟级 | 天级 |
| 关注 | 机制是否成立 | 业务指标是否真改善 |
最佳实践:先线下验证机制成立,再线上验证业务有效。
# 5.2 流量切分与样本平衡
- 随机切分:基于稳定 hash(用户 ID)保证用户在各组的稳定性。
- 同质性检查(A/A 实验):上线前先做 A/A 实验,确认两组在没有变更时也无显著差异。若有差异,说明分流或采集存在偏差。
- 切片对齐:实验组与对照组在机型、地域、版本等维度上的分布要一致。
# 5.3 显著性检验
线上由于样本量极大,几乎任何差异都会"显著",因此重点不是 p 值,而是:
- 置信区间:差值的 95% CI 是否完全在期望方向上。
- 效应量:差异是否值得(业务上有意义)。
- 持续时长:实验跑够 1 个完整业务周期(至少 7 天,覆盖工作日 / 周末)。
# 5.4 副作用监控
性能优化常见的"按下葫芦浮起瓢":
| 优化目标 | 可能的副作用 |
|---|---|
| 启动时长 ↓ | 内存峰值 ↑、首屏崩溃 ↑(资源加载竞争) |
| 内存占用 ↓ | CPU ↑(更频繁 GC、解码)、卡顿 ↑ |
| 包体积 ↓ | 启动 / 首屏耗时 ↑(动态加载) |
| 网络流量 ↓ | CPU ↑(解压)、首屏耗时 ↑ |
护栏指标必须配齐:上线前明确"哪些指标劣化超过 X% 即回滚"。
# 06.求证案例完整示范
下面用一个真实风格的案例演示完整流程。
# 6.1 命题:图片预解码是否真的能减少首屏卡顿
背景:列表页首屏存在 12 张图片,发现首屏 P95 帧时长 32ms,疑似图片解码占主线程。
初步假设:
H₁: 在主线程外预解码图片,首屏 P95 帧时长降低 ≥ 30%
H₀: 预解码对首屏 P95 帧时长无显著影响
# 6.2 实验流程演示
# Step 1 - 度量基线
| 指标类型 | 指标 | 当前值 |
|---|---|---|
| 主指标 | 首屏 5s 内 P95 帧时长 | 32ms |
| 副指标 | 主线程 on-CPU% | 78% |
| 副指标 | 解码耗时占帧时长比 | 41% |
| 护栏指标 | 首屏内存峰值 | 86MB |
| 护栏指标 | 总解码 CPU 耗时 | 920ms |
# Step 2 - 设计实验
| 项 | 配置 |
|---|---|
| 设备 | A 机:旗舰;B 机:中端;C 机:低端,各 2 台 |
| 系统 | Android 12 / 14;iOS 16 / 17 |
| 编译 | Release,相同混淆等级 |
| 数据集 | 固定 12 张图片(300x400,JPG),首次进入清缓存 |
| 控制变量 | 充电、35°C 以下、关闭其他 App |
| 样本量 | 每机型 / 每组 100 次,共 600 样本 |
# Step 3 - 采样
对照组(主线程解码):旗舰 100 / 中端 100 / 低端 100
实验组(IO 线程预解码):旗舰 100 / 中端 100 / 低端 100
交替执行,每组之间间隔 10 秒等待降温。
# Step 4 - 数据呈现
总体(n=600):
对照组 P95 帧时长:32.4ms ±1.1ms
实验组 P95 帧时长:18.7ms ±0.9ms
改善幅度:-42.3%
Mann-Whitney U 检验 p < 0.001(显著)
Cohen's d = 1.34(大效应)
按机型切片:
旗舰:32 → 14 ms (-56%)
中端:33 → 19 ms (-42%)
低端:32 → 22 ms (-31%) ← 改善最小,因 IO 线程也慢
# Step 5 - 护栏检查
| 指标 | 对照组 | 实验组 | 变化 | 是否可接受 |
|---|---|---|---|---|
| 内存峰值 | 86MB | 112MB | +30% | ⚠️ 低端机风险 |
| 总解码 CPU 耗时 | 920ms | 950ms | +3% | ✅ |
| 首屏崩溃率 | 0.02% | 0.04% | +100% 相对 | ⚠️ 需排查 |
# Step 6 - 边界探查
✅ 在 ≥ 4GB 内存设备上,结论稳定成立
⚠️ 在 2GB 内存机型上,预解码导致内存压力,反而触发更多 GC,反向劣化
❌ 在 1GB 机型上,可能触发 OOM
# Step 7 - 结论
对外结论:
"在内存 ≥ 4GB 的 Android / iOS 设备上,将首屏 12 张图片改为
IO 线程预解码,可使首屏 P95 帧时长降低 40%-56%(95% CI),
代价是内存峰值 +30%。
不适用于 ≤ 2GB 机型,需通过 Build.MODEL 黑名单兜底。"
# 6.3 数据呈现规范
实验结论必须包含以下结构(任何环节缺失都视为不合格):
- ✅ 假设 H₁ / H₀
- ✅ 实验环境(机型 / 系统 / 编译 / 数据集)
- ✅ 样本量与重复次数
- ✅ 主指标 + 副指标 + 护栏指标 的前后数据(带 P50/P95/P99 + 置信区间)
- ✅ 显著性检验(p 值)+ 效应量(Cohen's d)
- ✅ 适用边界(什么条件下不成立)
- ✅ 上线策略(灰度比例 / 回滚指标)
# 07.求证报告模板
以下模板可直接复制到具体优化文档中:
## 求证报告:[变更名称]
### 一、命题
- H₁:[5 要素假设]
- H₀:[零假设]
### 二、实验设计
| 项 | 值 |
|---|---|
| 设备 | |
| 系统版本 | |
| 编译配置 | |
| 数据集 | |
| 控制变量 | |
| 样本量 | |
| 重复次数 | |
### 三、指标
- 主指标:
- 副指标:
- 护栏指标:
### 四、原始数据
(直方图 / CDF 图 / 数据表)
### 五、统计分析
- 描述性统计(mean / P50 / P95 / P99 / σ)
- 显著性检验:(方法 + p 值)
- 效应量:(Cohen's d)
### 六、护栏检查
| 护栏指标 | 阈值 | 实测 | 是否通过 |
|---|---|---|---|
### 七、边界
- 适用:
- 不适用:
### 八、结论与上线建议
- 结论:
- 上线策略:
- 回滚指标:
# 一句话总结
没有实验的优化是巫术,没有边界的结论是谎言。
性能工程师的核心能力,不是"想到办法",而是**"证明办法"**。