《互联网产品的客户端架构·端形态与体验基础》12.2 崩溃与卡顿归因

崩溃与卡顿的难点不是采集,而是把海量原始样本收敛成可行动的少数分组。本节给出崩溃消息归一化与签名聚合、卡顿阈值与样本字段设计、归因顺序(布局抖动、主线程阻塞、长任务),以及上报事件应携带的最小证据字段,并用可运行的 TypeScript 与真实测试输出验证每一步判断与取舍。

从一堆堆栈到几个分组

崩溃上报最容易被当成“有堆栈就行”。真实情况是,同一个空指针会在不同设备、不同版本、不同行号上产生成百上千条记录;如果按原始堆栈分组,报表第一天就失去可读性。卡顿更麻烦:它没有异常,只有一段超过帧预算的时间,必须靠样本里的证据判断“谁堵住了主线程”。

本节做两件事:把崩溃收敛成稳定签名,把卡顿样本收敛成可行动的归因。阈值是案例假设值,不是行业标准;真正要固定的是判断顺序和上报字段。

崩溃签名:先归一化,再取前三帧

消息里的随机部分要先抹掉

错误消息常带卡片 ID、行号、UUID 和内存地址。normalizeMessage 先把十六进制地址、UUID 和数字替换成占位符,再压缩空白。这样“card 42 missing”和“card 7 missing”会落到同一个模板,而不是两个分组。

只取最内层三帧

签名取堆栈最内层三帧。更深的帧经常因为构建工具、框架版本或包路径变化而改变,把整条堆栈纳入签名会让同一个问题在每次发版后裂开。代价是不同根因可能被合并,所以聚合结果必须保留 sampleReportId,让人能回到原始堆栈确认。

聚合时保留时间与设备分布

aggregateCrashes 按签名分组,记录次数、占比、首末出现时间、设备档位和版本集合。次数降序、同次数按签名升序,保证同一批数据每次生成的报表顺序一致,方便做前后对比。

卡顿样本与阈值

卡顿样本至少要有窗口时长、主线程阻塞时长、长任务数、强制布局数和路由。只有窗口时长,无法区分“一次长任务”和“十次布局抖动”;只有长任务数,又无法判断主线程到底被占了多少比例。

代码里的默认阈值是:单帧预算 16.7 毫秒(按 60Hz 假设)、严重卡顿 100 毫秒、阻塞占比 0.6、每秒强制布局 4 次。它们都是案例假设值,团队要按目标机型和体验标准调整,但调整后必须把 thresholdsVersion 带进上报事件。

归因顺序:先具体,后笼统

归因顺序固定为布局抖动 > 主线程阻塞 > 长任务。布局抖动证据最具体、最可行动;主线程阻塞看阻塞占比;长任务只看数量,最容易变成一个什么都能装的桶。先判具体原因,能避免所有样本都归到“长任务”而失去行动价值。

超过帧预算但三类证据都不足时,归因结果是 unknown,不是硬塞一个原因。unknown 的比例本身就是一个观测指标:它高,说明采样字段不够,而不是用户运气不好。

代码

下面的文件包含签名、聚合、归因、上报事件与汇总。所有时间由调用方传入,阈值显式作为参数,测试不需要真实设备。

// file: src/obs/crash-jank.ts
// tier: L1
// verify: node --test test/obs/crash-jank.test.ts

export type DeviceClass = "low" | "mid" | "high";
export type JankCause =
  | "within-budget"
  | "layout-thrash"
  | "main-thread-block"
  | "long-task"
  | "unknown";

export interface StackFrame { module: string; symbol: string; }
export interface CrashReport {
  reportId: string;
  occurredAtMs: number;
  deviceClass: DeviceClass;
  appVersion: string;
  message: string;
  frames: StackFrame[];
}
export interface CrashAggregate {
  signature: string;
  message: string;
  count: number;
  share: number;
  devices: DeviceClass[];
  versions: string[];
  firstSeenMs: number;
  lastSeenMs: number;
  sampleReportId: string;
}
export interface JankSample {
  sampleId: string;
  occurredAtMs: number;
  deviceClass: DeviceClass;
  appVersion: string;
  route: string;
  durationMs: number;
  /** 主线程真正被占用的毫秒数,必须不大于 durationMs */
  blockingMs: number;
  longTaskCount: number;
  forcedLayoutCount: number;
}
export interface JankThresholds {
  frameBudgetMs: number;
  severeMs: number;
  blockRatio: number;
  layoutsPerSecond: number;
}
export interface JankVerdict { sampleId: string; cause: JankCause; reasons: string[]; }
export interface JankEvent {
  event: "client.jank";
  schemaVersion: 1;
  thresholdsVersion: string;
  sampleId: string;
  occurredAtMs: number;
  deviceClass: DeviceClass;
  appVersion: string;
  route: string;
  cause: JankCause;
  durationMs: number;
  blockingMs: number;
  longTaskCount: number;
  forcedLayoutCount: number;
}
export interface JankSummary {
  total: number;
  janky: number;
  causes: Record<JankCause, number>;
  p95DurationMs: number;
}

export const DEFAULT_JANK_THRESHOLDS: JankThresholds = {
  frameBudgetMs: 16.7,
  severeMs: 100,
  blockRatio: 0.6,
  layoutsPerSecond: 4,
};

/** 把随机 ID、地址和数字替换成占位符,避免同一种崩溃裂成上千个签名。 */
export function normalizeMessage(message: string): string {
  return message
    .replace(/0x[0-9a-f]+/gi, "#addr")
    .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "#uuid")
    .replace(/\b\d+(\.\d+)?\b/g, "#n")
    .replace(/\s+/g, " ")
    .trim();
}

function frameKey(frame: StackFrame): string {
  return `${frame.module}::${frame.symbol}`;
}

function fnv1a(input: string): string {
  let hash = 0x811c9dc5;
  for (let index = 0; index < input.length; index += 1) {
    hash ^= input.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, "0");
}

/** 签名只取最内层三帧:更深的帧经常随构建变化,会把同一问题拆散。 */
export function crashSignature(report: CrashReport): string {
  const framePart = report.frames.slice(0, 3).map(frameKey).join(">");
  return fnv1a(`${normalizeMessage(report.message)}|${framePart}`);
}

/** 按签名聚合,次数降序;同次数按签名升序,保证报表顺序稳定。 */
export function aggregateCrashes(reports: readonly CrashReport[]): CrashAggregate[] {
  const groups = new Map<string, CrashAggregate>();

  for (const report of reports) {
    if (report.occurredAtMs < 0 || !Number.isFinite(report.occurredAtMs)) {
      throw new Error(`aggregateCrashes: ${report.reportId} 的 occurredAtMs 非法`);
    }
    const signature = crashSignature(report);
    const existing = groups.get(signature);
    if (existing === undefined) {
      groups.set(signature, {
        signature,
        message: normalizeMessage(report.message),
        count: 1,
        share: 0,
        devices: [report.deviceClass],
        versions: [report.appVersion],
        firstSeenMs: report.occurredAtMs,
        lastSeenMs: report.occurredAtMs,
        sampleReportId: report.reportId,
      });
      continue;
    }
    existing.count += 1;
    if (!existing.devices.includes(report.deviceClass)) {
      existing.devices.push(report.deviceClass);
    }
    if (!existing.versions.includes(report.appVersion)) {
      existing.versions.push(report.appVersion);
    }
    existing.firstSeenMs = Math.min(existing.firstSeenMs, report.occurredAtMs);
    existing.lastSeenMs = Math.max(existing.lastSeenMs, report.occurredAtMs);
  }

  const total = reports.length;
  const aggregates = [...groups.values()];
  for (const group of aggregates) {
    group.devices.sort();
    group.versions.sort();
    group.share = total > 0 ? group.count / total : 0;
  }
  aggregates.sort((left, right) =>
    right.count === left.count
      ? left.signature.localeCompare(right.signature)
      : right.count - left.count,
  );
  return aggregates;
}

function assertJankSample(sample: JankSample): void {
  if (!Number.isFinite(sample.durationMs) || sample.durationMs < 0) {
    throw new Error(`attributeJank: ${sample.sampleId} 的 durationMs 非法`);
  }
  if (!Number.isFinite(sample.blockingMs) || sample.blockingMs < 0) {
    throw new Error(`attributeJank: ${sample.sampleId} 的 blockingMs 非法`);
  }
  if (sample.blockingMs > sample.durationMs) {
    throw new Error(`attributeJank: ${sample.sampleId} 的 blockingMs 大于 durationMs`);
  }
  if (sample.longTaskCount < 0 || sample.forcedLayoutCount < 0) {
    throw new Error(`attributeJank: ${sample.sampleId} 的计数不能为负`);
  }
}

/**
 * 归因顺序是布局抖动 > 主线程阻塞 > 长任务。
 * 布局抖动最具体、最可行动;主线程阻塞看“占比”,长任务看“数量”。
 * 先判具体原因,避免所有样本都落进“长任务”这个无行动价值的桶。
 */
export function attributeJank(
  sample: JankSample,
  thresholds: JankThresholds = DEFAULT_JANK_THRESHOLDS,
): JankVerdict {
  assertJankSample(sample);
  if (sample.durationMs <= thresholds.frameBudgetMs) {
    return {
      sampleId: sample.sampleId,
      cause: "within-budget",
      reasons: [`时长 ${sample.durationMs}ms 未超过单帧预算 ${thresholds.frameBudgetMs}ms`],
    };
  }

  const reasons: string[] = [];
  const seconds = sample.durationMs / 1_000;
  const layoutsPerSecond = seconds > 0 ? sample.forcedLayoutCount / seconds : 0;
  if (sample.forcedLayoutCount >= 2 && layoutsPerSecond >= thresholds.layoutsPerSecond) {
    reasons.push(`每秒强制布局 ${layoutsPerSecond.toFixed(1)} 次,超过阈值 ${thresholds.layoutsPerSecond}`);
    return { sampleId: sample.sampleId, cause: "layout-thrash", reasons };
  }

  const blockRatio = sample.durationMs > 0 ? sample.blockingMs / sample.durationMs : 0;
  if (blockRatio >= thresholds.blockRatio && sample.blockingMs >= thresholds.frameBudgetMs * 2) {
    reasons.push(
      `主线程阻塞占比 ${(blockRatio * 100).toFixed(0)}%,超过阈值 ${(thresholds.blockRatio * 100).toFixed(0)}%`,
    );
    return { sampleId: sample.sampleId, cause: "main-thread-block", reasons };
  }

  if (sample.longTaskCount >= 1) {
    reasons.push(`窗口内有 ${sample.longTaskCount} 个长任务,但阻塞占比只有 ${(blockRatio * 100).toFixed(0)}%`);
    return { sampleId: sample.sampleId, cause: "long-task", reasons };
  }

  reasons.push("超过帧预算但缺少阻塞、长任务或布局证据,需要补充采样");
  return { sampleId: sample.sampleId, cause: "unknown", reasons };
}

/** 上报事件只带聚合结论与原始证据计数,不带用户内容。 */
export function buildJankEvent(
  sample: JankSample,
  thresholds: JankThresholds = DEFAULT_JANK_THRESHOLDS,
): JankEvent {
  const verdict = attributeJank(sample, thresholds);
  return {
    event: "client.jank",
    schemaVersion: 1,
    thresholdsVersion: `frame-${thresholds.frameBudgetMs}-block-${thresholds.blockRatio}`,
    sampleId: sample.sampleId,
    occurredAtMs: sample.occurredAtMs,
    deviceClass: sample.deviceClass,
    appVersion: sample.appVersion,
    route: sample.route,
    cause: verdict.cause,
    durationMs: sample.durationMs,
    blockingMs: sample.blockingMs,
    longTaskCount: sample.longTaskCount,
    forcedLayoutCount: sample.forcedLayoutCount,
  };
}

function p95Of(values: readonly number[]): number {
  const sorted = [...values].sort((left, right) => left - right);
  return sorted[Math.max(1, Math.ceil(0.95 * sorted.length)) - 1] ?? 0;
}

export function summarizeJank(
  samples: readonly JankSample[],
  thresholds: JankThresholds = DEFAULT_JANK_THRESHOLDS,
): JankSummary {
  const causes: Record<JankCause, number> = {
    "within-budget": 0,
    "layout-thrash": 0,
    "main-thread-block": 0,
    "long-task": 0,
    unknown: 0,
  };
  for (const sample of samples) {
    causes[attributeJank(sample, thresholds).cause] += 1;
  }
  return {
    total: samples.length,
    janky: samples.length - causes["within-budget"],
    causes,
    p95DurationMs: samples.length > 0 ? p95Of(samples.map((sample) => sample.durationMs)) : 0,
  };
}

测试与真实输出

在实验工程根目录执行:

node --test test/obs/crash-jank.test.ts

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

✔ 消息归一化把数字、地址与 UUID 替换为占位符 (0.994208ms)
✔ 签名跨不同行号与随机 ID 稳定,不同帧则不同 (0.231083ms)
✔ 崩溃按签名聚合,次数降序并保留首末时间 (0.621875ms)
✔ 单帧预算内的样本不进入卡顿归因 (0.176416ms)
✔ 高频强制布局优先归因为布局抖动 (0.086791ms)
✔ 阻塞占比高归因为主线程阻塞 (0.066125ms)
✔ 阻塞占比不高但存在长任务时归因为长任务 (0.062917ms)
✔ 超过帧预算但缺少证据时标记 unknown (0.059292ms)
✔ 上报事件包含归因结论、证据计数与口径版本 (0.186916ms)
✔ 汇总按原因计数并给出 p95 时长 (0.217542ms)
✔ 非法样本直接抛错,不污染归因结果 (0.267917ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 183.5425

一次被“长任务”掩盖的布局抖动

假设某条卡顿样本:窗口 120 毫秒,阻塞 20 毫秒,长任务 1 个,强制布局 10 次。只看长任务数,很容易写一句“主线程有一个长任务”就结束。但 10 次强制布局发生在 0.12 秒里,折算每秒 83 次,远超阈值,真正的问题是读写样式交替造成的布局抖动。

如果归因顺序反过来,先判长任务,这条样本就会进入一个没有行动价值的桶,修复方向会变成“优化那个长任务”,而不是“合并样式读写”。顺序不是审美问题,它直接决定下一步查哪段代码。

小结

崩溃归因的关键是稳定签名:先归一化消息,再取最内层三帧,聚合时保留样本与时间分布。卡顿归因的关键是证据字段与判断顺序:布局抖动、主线程阻塞、长任务,最后才是 unknown。

阈值可以调,顺序不要随手改。上报事件只带聚合结论和证据计数,不带用户内容,也不把原始堆栈塞进每一条事件。下一节转向另一条链路:埋点规范与数据质量。

阅读导航:上一节:12.1 性能指标口径 · 下一节:12.3 埋点规范与数据质量 。

常见错误与边界

  • 按完整堆栈分组。发版后行号变化会让同一问题裂成多个签名。
  • 不归一化消息。卡片 ID、UUID 和地址会把分组数量放大几个数量级。
  • 只保留聚合结果,不保留样本 ID。合并错了根因时无法回查。
  • 用固定毫秒数判断卡顿而不看帧预算。不同刷新率设备的预算不同。
  • 把长任务当成唯一原因。布局抖动和阻塞占比会被掩盖。
  • 证据不足时硬归因。unknown 是合法结果,比例高说明采样不够。
  • 上报事件携带用户内容。堆栈和路由已经足够定位,不要顺手带上参数。
  • 改了阈值却不改 thresholdsVersion。新旧数据会被拼成一条曲线。
  • 阻塞时长大于窗口时长仍照常入库。这类样本说明计时器有问题,应直接拒绝。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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