《互联网产品的客户端架构·端形态与体验基础》3.1 首屏、可交互与流畅度

从「回声」客户端的采样问题出发,拆解 FP、FCP、LCP、INP、TBT 五个指标各自回答什么、何时采样,用最近秩分位与 p75/p95 双线预算替代平均值判定,并给出可运行的 TypeScript 分位计算器、注入时钟的采样器与真实测试输出。

先说结论:平均分不能当预算

「回声」客户端上线后,团队在周报里写“首屏平均 1.8 秒,达标”。两周后客服反馈集中在“有时候点开要等很久”,而周报仍然是 1.8 秒。问题不在于数据造假,而在于平均值把少数极慢的会话平均掉了:一百次打开里有三次卡了十几秒,平均分只上升几百毫秒,用户记住的却是那三次。

体验质量属性要先解决三个问题:指标回答的是什么、在什么条件下采样、用什么统计量做判定。顺序不能反。先有口径,再谈采样,最后才是预算。否则团队会陷入“同一个词各自理解”的争论:有人说 LCP 是首屏,有人说 LCP 是最大图片,有人说 INP 是点击延迟,最后只能挑一个看起来能达标的数字。

本节只讨论启动与交互相关的五个指标:FP、FCP、LCP、INP、TBT。它们不是五个可以互相替代的分数,而是从不同角度描述同一段时间。文中除明确标注为本机运行输出的内容外,所有耗时、比例、样本量都是案例假设值,用来演示判断过程。

五个指标各自回答什么

FP 与 FCP:从白屏到有内容

FP(First Paint)记录浏览器第一次绘制像素的时刻。它可能只是背景色,用户仍然看不到任何内容,所以 FP 单独用来做预算意义不大,更适合排查“页面是不是一直白着”。

FCP(First Contentful Paint)记录第一次绘制文本、图片或 canvas 的时刻。它回答“用户什么时候第一次看到内容”,比 FP 更接近感知。FCP 不关心内容是否完整,也不关心主内容是否可见,一个加载中的骨架屏就可以让它很早发生。

这两个指标都依赖“绘制”的定义,跨浏览器比较时要确认口径一致。它们适合做启动阶段的下限检查,不适合单独代表整个首屏体验。

LCP:主内容何时可见

LCP(Largest Contentful Paint)记录视口内最大内容元素的渲染时刻,候选包括图片、视频封面、背景图和块级文本。它回答“用户什么时候看到页面的主体”。

LCP 的口径有几个容易忽略的细节:它只统计视口内的元素;用户滚动或交互后不再更新;页面进入后台时上报最终值。因此 LCP 是“首屏主内容”的指标,不是“整页加载完成”的指标。把 LCP 当成全部资源加载完成,会得出错误的优化方向。

INP:交互有没有被卡住

INP(Interaction to Next Paint)观察用户交互到下一帧绘制的延迟,取一次会话中较慢的交互作为代表值。它回答“点了之后,界面多久有反应”。

INP 替代了 FID。FID 只测第一次输入的延迟,容易在“首屏很空、没什么可点”的页面上拿到好看的数字;INP 覆盖整次访问的点击、按键与触摸,更接近用户对“卡不卡”的感受。INP 的采样依赖真实交互,没有交互的会话不会产生有效值。

TBT:主线程被占用了多久

TBT(Total Blocking Time)是实验室指标:在 FCP 与可交互之间,把所有超过 50 毫秒的长任务超出 50 毫秒的部分累加。它回答“主线程被 JavaScript 占用了多久”。

TBT 不能直接替代 INP。TBT 是实验室里可重复测量的代理量,INP 是真实用户交互的结果。两者的关系是“主线程越堵,交互越可能慢”,但不是线性映射。正确用法是:实验室用 TBT 做回归门禁,线上用 INP 做真实体验判定。

采样:在什么条件下记,记多少次

同一个指标,采样条件不同,数值可以差一个数量级。至少要把三类条件写进采样说明:

  • 设备档位:低端安卓、中端安卓、旗舰机、桌面,不能混在一个分位里。
  • 网络条件:4G、弱网、Wi-Fi、离线恢复,冷启动与热启动也要分开。
  • 页面状态:首次访问、缓存命中、登录态、是否有历史数据。

实验室数据(Lighthouse、WebPageTest)适合做可重复的回归对比,真实用户监控(RUM)适合做体验判定。两者不能直接相减,也不能用实验室数据证明线上达标。

样本量决定分位的可信度。p50 在几十个样本上就相对稳定,p95 需要更多样本;样本只有二十个时,p95 基本等于“第二慢的那个”,容易被一次抖动主导。一个实用规则是:p75 至少几百个样本,p95 至少上千个样本;样本不足时只报 p75,并明确写“p95 样本不足”。

时序也需要注入。采样器不应该直接调用全局时钟,否则测试只能等待真实时间,或者写出一碰就碎的断言。把 clock: () => number 作为参数传进去,生产环境用 performance.now,测试环境用手动推进的假时钟,采样逻辑本身就能被稳定覆盖。

为什么平均值会骗人

假设「回声」发现页有一百次 LCP 采样:九十次 100 毫秒,九次 1000 毫秒,一次 1000000 毫秒(案例假设值,用来放大长尾)。平均值约 10180 毫秒,看起来灾难性;但 p75 是 100 毫秒,p95 是 1000 毫秒。反过来,如果只看平均值,团队会去优化那一次异常,而忽略了 p75 附近的真实分布。

平均值的问题有两个方向:

  • 被长尾拉高:少数极慢样本让平均分变差,但大多数用户的体验没有变化。
  • 被大量快样本稀释:绝大多数请求很快时,少数慢请求在平均值里几乎看不见。

分位回答的是“有多少比例的用户经历得比这个值更差”。p75 意味着四分之一的使用者体验不优于该值,p95 意味着最慢的百分之五。对体验预算,团队通常同时看 p75 与 p95:p75 代表主体体验,p95 代表长尾风险。

最近秩(nearest-rank)是延迟指标常用的分位算法:排序后取第 ceil(p * n) 个样本。它不会像线性插值那样在样本之间造出一个不存在的值,代价是分位会随样本量跳动。对预算判定,这个代价可以接受;对趋势图,应该固定采样窗口与样本量再比较。

用分位与预算做判定

判定规则要简单到能在评审会上复述:

  • p95 超过失败线:本次版本不达标。
  • p75 超过预警线但 p95 未超:标黄,进入下次迭代的优化清单。
  • 两者都未超:通过。
  • 平均值高于失败线但 p95 未超:只记录说明,不判失败,因为平均值不是用户分布。

下面的实现把这三条规则固定下来。它同时包含最近秩分位、汇总、预算判定和一个注入时钟的采样器。代码不依赖 DOM、网络、定时器或第三方库,测试可以直接运行。

// file: src/quality/percentiles.ts
// tier: L1
// verify: node --test test/quality/percentiles.test.ts

export type MetricName = "FP" | "FCP" | "LCP" | "INP" | "TBT";

export interface MetricSample {
  metric: MetricName;
  value: number;
}

export interface MetricSummary {
  metric: MetricName;
  count: number;
  min: number;
  max: number;
  mean: number;
  p50: number;
  p75: number;
  p95: number;
}

export interface BudgetRule {
  p75: number;
  p95: number;
}

export interface BudgetVerdict {
  metric: MetricName;
  status: "pass" | "warn" | "fail";
  reasons: string[];
}

export interface TimedSample {
  metric: MetricName;
  value: number;
  atMs: number;
}

export interface Sampler {
  record(value: number): TimedSample;
  values(): number[];
  summary(): MetricSummary;
  reset(): void;
}

/**
 * 最近秩(nearest-rank)分位数:返回排序后第 ceil(p * n) 个样本。
 * 对延迟指标,最近秩不会像线性插值那样凭空造出一个更小的中间值。
 */
export function percentile(samples: readonly number[], p: number): number {
  if (samples.length === 0) {
    throw new Error("percentile: samples 不能为空");
  }
  if (!(p > 0 && p <= 1)) {
    throw new Error(`percentile: p 必须在 (0, 1] 内,收到 ${p}`);
  }
  const sorted = [...samples].sort((a, b) => a - b);
  const rank = Math.max(1, Math.ceil(p * sorted.length));
  return sorted[rank - 1];
}

/** 把一组同指标样本压缩成均值与常用分位;均值只作参考,不参与预算判定。 */
export function summarizeMetric(
  metric: MetricName,
  values: readonly number[],
): MetricSummary {
  if (values.length === 0) {
    throw new Error(`summarizeMetric: ${metric} 没有样本`);
  }
  const total = values.reduce((sum, value) => sum + value, 0);
  return {
    metric,
    count: values.length,
    min: Math.min(...values),
    max: Math.max(...values),
    mean: total / values.length,
    p50: percentile(values, 0.5),
    p75: percentile(values, 0.75),
    p95: percentile(values, 0.95),
  };
}

/**
 * p75 是预警线,p95 是失败线。
 * 平均值被极少数超长任务拉高时,只记录一条说明,不改变判定。
 */
export function judgeBudget(
  summary: MetricSummary,
  rule: BudgetRule,
): BudgetVerdict {
  const reasons: string[] = [];
  const p75Over = summary.p75 > rule.p75;
  const p95Over = summary.p95 > rule.p95;

  if (p95Over) {
    reasons.push(
      `${summary.metric} p95 ${summary.p95.toFixed(1)}ms 超过失败线 ${rule.p95}ms`,
    );
  }
  if (p75Over) {
    reasons.push(
      `${summary.metric} p75 ${summary.p75.toFixed(1)}ms 超过预警线 ${rule.p75}ms`,
    );
  }
  if (summary.mean > rule.p95 && !p95Over) {
    reasons.push(
      `${summary.metric} 平均值 ${summary.mean.toFixed(1)}ms 高于失败线,但 p95 未超标,按分位口径不判失败`,
    );
  }

  const status = p95Over ? "fail" : p75Over ? "warn" : "pass";
  return { metric: summary.metric, status, reasons };
}

/**
 * 采样器把“何时记录”交给注入的时钟,测试里因此不需要真实定时器。
 * 生产代码可以把 clock 换成 performance.now,测试里换成手动推进的假时钟。
 */
export function createSampler(
  metric: MetricName,
  clock: () => number,
): Sampler {
  const samples: TimedSample[] = [];

  return {
    record(value: number): TimedSample {
      if (!Number.isFinite(value) || value < 0) {
        throw new Error(`sampler: ${metric} 样本必须是非负有限数,收到 ${value}`);
      }
      const sample: TimedSample = { metric, value, atMs: clock() };
      samples.push(sample);
      return sample;
    },
    values(): number[] {
      return samples.map((sample) => sample.value);
    },
    summary(): MetricSummary {
      return summarizeMetric(metric, samples.map((sample) => sample.value));
    },
    reset(): void {
      samples.length = 0;
    },
  };
}

实现里有两个刻意的选择。

第一,分位用最近秩,不用线性插值。线性插值会给出更平滑的曲线,但在小样本上可能返回一个没有任何用户实际经历过的值。预算判定需要的是“第几个用户有多慢”,最近秩更诚实。

第二,mean 保留在汇总里,但不参与 status。保留是为了排查“长尾是否在恶化”,不参与是为了避免平均值重新变成考核指标。代码里甚至专门写了一条说明:平均值高于失败线但 p95 未超时,只记录原因,不判失败。

测试与真实输出

实验工程里有 7 个测试,覆盖最近秩、非法输入、汇总、预警与失败、平均值误导、注入时钟和采样器校验。在工程根目录执行:

node --test test/quality/percentiles.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 最近秩分位返回排序后第 ceil(p*n) 个样本 (0.474625ms)
✔ 空样本与非法分位会直接抛错 (0.236ms)
✔ 汇总同时给出均值与分位 (0.094209ms)
✔ p75 超线判预警,p95 超线判失败 (0.154458ms)
✔ 均值被极端样本拉高时,只要分位达标仍判通过 (0.65775ms)
✔ 采样器使用注入时钟,并可重置 (0.626541ms)
✔ 采样器拒绝负数与非有限值 (0.116666ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 155.804708

“均值被极端样本拉高”这个用例构造了 90 个 100 毫秒、9 个 1000 毫秒和 1 个 1000000 毫秒的样本。p75 与 p95 都在预算内,平均值却超过失败线,判定仍然是 pass,并附带一条说明。这不是说平均值没用,而是说它不该拥有否决权。

预算线怎么定

预算线不能从竞品抄,也不能从“行业标准”直接搬。一个可操作的推导顺序是:

  1. 选定目标设备与网络分位,例如“中端安卓、4G、p75 用户”。
  2. 测量当前版本在该条件下的分布,得到 p50、p75、p95。
  3. 把 p75 的目标定在“主体用户可接受”的位置,把 p95 定在“长尾不崩”的位置。
  4. 每次版本只收紧一档,并保留一个版本的回退空间。

下面是一组案例假设值,仅用于演示表格结构:

指标p75 预警线p95 失败线口径
FCP1800ms3000ms首次内容绘制
LCP2500ms4000ms视口内最大内容绘制
INP200ms500ms交互到下一帧
TBT200ms600ms实验室主线程阻塞

FP 不进预算表,因为它可能只画了背景色;它适合做“是否白屏”的诊断项。TBT 只进实验室门禁,不拿它替代 INP。四个指标分别对应启动、主内容、交互与主线程,缺一个都会让优化方向失衡。

小结

体验指标的第一层不是“优化”,而是“口径”。FP、FCP、LCP、INP、TBT 各自回答不同问题,混用会得出互相矛盾的结论。采样条件必须写清楚,实验室数据与真实用户数据不能直接相减。

判定要用分位,不用平均值。p75 代表主体体验,p95 代表长尾风险;最近秩分位在小样本上更诚实。把时钟注入采样器,测试就不需要真实等待,口径也能被回归测试锁住。

下一节把视角从时间转到体积:首包、路由包、图片、字体和运行内存,这些预算如何定、如何在 CI 里卡住,以及为什么“能跑”不等于“装得下”。

阅读导航:上一节:2.3 选型的判据矩阵 · 下一节:3.2 包体与内存预算 。

常见错误与边界

  • 把 FP 当首屏指标。FP 可能只画了背景色,用户仍然看不到内容。
  • 用 LCP 代表整页加载完成。LCP 只统计视口内主内容,滚动后不再更新。
  • 用 TBT 替代 INP。TBT 是实验室代理量,INP 才来自真实交互。
  • 把不同设备、不同网络、冷启动与热启动混进同一个分位。
  • 样本不足时硬报 p95。二十个样本的 p95 基本等于第二慢的样本。
  • 用平均值做版本考核。平均值会被长尾拉高,也会被大量快样本稀释。
  • 分位算法频繁切换。趋势图必须固定窗口、样本量与算法再比较。
  • 只看 p75 不看 p95。主体体验达标不代表长尾没有崩溃。
  • 预算线一次收紧太多。没有回退空间的预算会逼团队做假数据。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查