雾非雾の迷惘头像
冷启动时间变短了,瀑布图如何对齐同一条启动链封面图

冷启动时间变短了,瀑布图如何对齐同一条启动链

启动耗时诊断器显示冷启动从 1.04 秒降到 812 ms,数字看起来很好。但两次采集不一定从同一时刻开始:一次以进程创建为零点,另一次以应用框架初始化为零点,瀑布图上的条形更短,却无法判断优化到底发生在哪个阶段。

零点差多少,结论就能假多少。以框架初始化为零点的采集,天然比以进程创建为零点少一段,两次条形摆在一起,短的那次未必真的快。所以对齐不是图表美化,是结论成立的前提。

我先给一次启动分配稳定的 traceId,再把所有阶段转换成相对同一个根事件的时间。图表渲染只消费对齐后的片段。

启动阶段需要父子关系

export interface StartupSpan {
  id: string
  traceId: string
  parentId?: string
  name: string
  startedAt: number
  endedAt: number
  thread: 'main' | 'worker' | 'io'
}

export interface AlignedSpan extends StartupSpan {
  offsetMs: number
  durationMs: number
}

traceId 区分不同启动批次,parentId 用于确认资源初始化是否属于同一条启动链。没有这些字段,多个进程或上一次残留回调很容易混进当前瀑布图。

traceId 在进程内自增,冷启动记一次、热启动另记一次,两次之间不共用。残留回调靠它挡在门外:晚到的 span 只要 traceId 对不上,alignTrace 直接过滤掉,图表上看不见,也不会把 offset 算歪。

let traceSeq = 0

export function nextTraceId(kind: 'cold' | 'hot'): string {
  traceSeq += 1
  return `${kind}-${traceSeq}`
}

一个进程里冷启动通常只发生一次,热启动可能连着来七八回。自增序列让每次采集都有唯一编号,导出报告时按编号就能把同一批片段找齐,不用靠时间戳猜。

有了父子关系,下一步就是把这些片段对齐到同一个零点。

export function alignTrace(spans: StartupSpan[], traceId: string): AlignedSpan[] {
  const current = spans.filter((span: StartupSpan) => span.traceId === traceId)
  if (current.length === 0) return []
  const root = current.find((span: StartupSpan) => span.parentId === undefined) ??
    current.slice().sort((a: StartupSpan, b: StartupSpan) => a.startedAt - b.startedAt)[0]
  return current.map((span: StartupSpan) => ({
    ...span,
    offsetMs: Math.max(0, span.startedAt - root.startedAt),
    durationMs: Math.max(0, span.endedAt - span.startedAt)
  })).sort((a: AlignedSpan, b: AlignedSpan) =>
    a.offsetMs - b.offsetMs || a.durationMs - b.durationMs)
}

如果采集器没有显式根事件,就用最早开始的片段兜底,但页面应标记为“近似对齐”。真实诊断优先要求采集层上报进程创建或启动请求的根 span。

兜底方案在本地演示里够用:所有片段同源,最早开始的那个离真正的进程创建通常只差几毫秒。可它一旦出现在对比报告里,118 ms 的“优化”就可能整个是零点差造成的,所以近似对齐的图表只在调研时看,不进结论。

在这里插入图片描述

对比前先按名称与父链配对

“资源初始化”可能有两个同名任务,单纯按名字比对会把 IO 线程和主线程混在一起。配对键由父链、名称和线程组成。

function lineage(span: StartupSpan, all: StartupSpan[]): string {
  const result: string[] = [span.name]
  let parentId = span.parentId
  while (parentId) {
    const parent = all.find((item: StartupSpan) => item.id === parentId)
    if (!parent) break
    result.unshift(parent.name)
    parentId = parent.parentId
  }
  return `${span.thread}:${result.join('/')}`
}

export interface SpanDiff {
  key: string
  beforeMs: number
  afterMs: number
  deltaMs: number
}

export function compare(before: StartupSpan[], after: StartupSpan[]): SpanDiff[] {
  const afterMap = new Map(after.map((span: StartupSpan) => [lineage(span, after), span]))
  return before.map((span: StartupSpan) => {
    const key = lineage(span, before)
    const next = afterMap.get(key)
    const beforeMs = Math.max(0, span.endedAt - span.startedAt)
    const afterMs = next ? Math.max(0, next.endedAt - next.startedAt) : 0
    return { key, beforeMs, afterMs, deltaMs: afterMs - beforeMs }
  })
}

这样诊断器能明确说明“资源初始化减少 118 ms”,而不是只给出一个总时长差。

对比结果按阶段键逐行给出,长这样:

阶段键优化前优化后变化
main:启动/资源初始化236 ms118 ms-118 ms
io:启动/资源初始化96 ms94 ms-2 ms
main:启动/首帧布局154 ms143 ms-11 ms
worker:启动/预加载数据88 ms未采集不作对比

同一份报告里,主线程的资源初始化砍掉一半,IO 线程只少了 2 ms,着力点一眼能看出来。最后一行是没采到的部分,画成横杠,不画成 0。

首帧和可交互,两个终点分开报

页面显示首帧时间 812 ms、可交互时间 1.16 秒。首帧意味着内容已绘制,可交互则还要等输入处理、关键数据和动画门槛满足。它们应该分别由不同事件结束。

这两个数字差着 348 ms,差的正是输入通道打通的那一段。只报首帧,用户点第一下没反应的尴尬就被藏起来了;只报可交互,渲染侧的优化又看不见。两个终点并排摆,谁的问题谁领走。

在这里插入图片描述

export interface StartupMilestone {
  traceId: string
  kind: 'firstFrame' | 'interactive'
  reachedAt: number
}

export function milestoneOffset(rootAt: number, milestone?: StartupMilestone): number | undefined {
  return milestone ? Math.max(0, milestone.reachedAt - rootAt) : undefined
}

报告页不能把首帧缩短直接写成“用户已经能操作”。两个终点并排展示,优化资源初始化后若输入仍被阻塞,问题仍然会留在可交互时间上。

少采了一个阶段,图上怎么说话

某次启动没有采到某个 span,不能把它画成 0 ms。对比结果增加 missing 状态,报告区用“未采集”替代“已优化”。

export interface SafeSpanDiff extends SpanDiff {
  missingBefore: boolean
  missingAfter: boolean
}

export function toSafeDiff(
  diff: SpanDiff,
  beforeHas: boolean,
  afterHas: boolean
): SafeSpanDiff {
  return {
    ...diff,
    missingBefore: !beforeHas,
    missingAfter: !afterHas
  }
}

export function describeDiff(diff: SafeSpanDiff): string {
  if (diff.missingBefore && diff.missingAfter) return '两次都未采集'
  if (diff.missingBefore) return '优化前未采集,不作对比'
  if (diff.missingAfter) return '优化后未采集,疑似丢失'
  const sign = diff.deltaMs > 0 ? '+' : ''
  return `${diff.key} ${sign}${diff.deltaMs} ms`
}

“疑似丢失”这四个字是刻意留的:优化后反而采不到,通常是采集点被优化掉了,也可能是链路真的断了,两种都得人工看一眼才能下结论。

missingBefore 和 missingAfter 分开记也是有原因的:只记一个 missing,就没法区分“以前没采”和“现在没采”,而这两种情况后续动作完全不同——前者放弃对比,后者去查采集链路。

异常场景清单

对齐这条链上翻过的车,按出现频率排了四条,处理方式都收在上面的代码里:

  • 采集器没给根事件。 兜底用最早开始的片段,页面标记“近似对齐”。这种模式下的报告只信趋势,不信绝对值,118 ms 的“优化”可能整个是零点差。
  • 时间回退。 设备改时间或时钟同步让 endedAt 小于 startedAt,offsetMs 和 durationMs 都有 Math.max(0, …) 兜底,负值画不出反向条形。
  • 同名 span 跨线程。 主线程和 IO 线程各有一个“资源初始化”,lineage 键带线程前缀,配对时不混。去掉前缀,两次对比会把 IO 的 96 ms 记到主线程账上。
  • 里程碑缺省。 某次采集没有 firstFrame 事件,milestoneOffset 返回 undefined,报告区显示“—”。0 是量出来的,没有才是没采到,两个含义不能用一个数字表达。

当前数据用于本地诊断演示。后续接入真实性能追踪时,只要保证同一启动链拥有相同 traceId,瀑布图、阶段对比和首帧指标就能围绕统一零点解释,不会因为采集起点不同制造一场虚假的优化。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Dovis5884/article/details/169139310

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--