无论您开发的是哪种类型的应用,优化其性能并确保其加载速度快且互动顺畅,对于用户体验和应用成功至关重要。为此,一种方法是使用性能分析工具检查应用的活动,以了解应用在某个时间窗口内运行时的后台情况。开发者工具中的“性能”面板是一款出色的分析工具,可用于分析和优化 Web 应用的性能。如果您的应用在 Chrome 中运行,它会详细直观地显示浏览器在执行应用时所做的工作。了解此活动有助于您识别可采取行动来提升性能的模式、瓶颈和性能热点。
以下示例将引导您使用性能面板。
设置和重新创建分析场景
最近,我们设定了一个目标,即提高效果面板的性能。特别是,我们希望它能更快地加载大量效果数据。例如,在分析长时间运行或复杂的进程或捕获精细度较高的数据时,就会出现这种情况。为此,首先需要了解应用的运行方式和运行原因,而这可以通过使用分析工具来实现。
您可能知道,DevTools 本身就是一个 Web 应用。因此,可以使用性能面板对其进行性能分析。如需分析此面板本身,您可以打开开发者工具,然后打开附加到该工具的另一个开发者工具实例。在 Google,此设置称为 DevTools-on-DevTools。
设置就绪后,必须重新创建并记录要分析的场景。为避免混淆,我们将原始开发者工具窗口称为“第一个开发者工具实例”,将检查第一个实例的窗口称为“第二个开发者工具实例”。

在第二个开发者工具实例中,性能面板(以下简称“性能面板”)会观察第一个开发者工具实例,以重现加载配置文件的场景。
在第二个开发者工具实例中,系统会开始实时录制,而在第一个实例中,系统会从磁盘上的文件加载配置文件。系统会加载一个大文件,以便准确分析处理大型输入的性能。当两个实例都完成加载后,性能分析数据(通常称为“轨迹”)会显示在第二个开发者工具实例的“性能”面板中,并加载配置文件。
初始状态:发现改进机会
加载完成后,我们在第二个性能面板实例中观察到了以下内容(如以下屏幕截图所示)。重点关注主线程的 activity,该 activity 显示在标记为 Main 的轨道下方。从火焰图可以看出,有五大类活动。这些任务是指加载耗时最长的任务。这些任务的总时间约为 10 秒。在以下屏幕截图中,性能面板用于重点关注每个活动组,以查看可以找到哪些内容。

第一个 activity 组:不必要的工作
很明显,第一组 activity 是仍在运行但实际上并不需要的旧版代码。基本上,标有 processThreadEvents
的绿色方块下的所有内容都是白费力气。这个效果很快。移除该函数调用后,节省了大约 1.5 秒的时间。棒极了!
第二个活动组
在第二组活动中,解决方案不像第一组那样简单。buildProfileCalls
大约耗时 0.5 秒,并且该任务无法避免。

出于好奇,我们在性能面板中启用了内存选项以进一步调查,发现 buildProfileCalls
activity 也使用了大量内存。在此处,您可以看到蓝线图在运行 buildProfileCalls
的时间附近突然跳动,这表明可能存在内存泄漏。

为了跟进这一怀疑,我们使用了“内存”面板(开发者工具中的另一个面板,不同于“性能”面板中的“内存”抽屉式窗格)进行调查。在“内存”面板中,选择了“分配抽样”性能剖析类型,该类型记录了堆快照,以便性能面板加载 CPU 配置文件。

以下屏幕截图显示了收集的堆快照。

从这个堆快照中,我们观察到 Set
类正在消耗大量内存。通过检查调用点,我们发现我们不必要地将 Set
类型的属性分配给了大量创建的对象。这种开销不断累积,消耗了大量内存,以至于应用经常在处理大型输入时崩溃。
集合非常适合用于存储唯一项,并提供利用其内容唯一性的操作,例如对数据集进行去重处理和提供更高效的查找。不过,由于存储的数据保证与来源数据不同,因此这些功能并不必要。因此,一开始就不需要设置。为了改进内存分配,属性类型已从 Set
更改为纯数组。应用此更改后,系统又拍摄了一张堆快照,并观察到内存分配减少了。虽然此项更改并未显著提升速度,但次要好处是应用崩溃的频率降低了。

第三个活动组:权衡数据结构方面的利弊
第三部分比较特殊:您可以在火焰图中看到,它由窄而高的列组成,表示函数调用较深,在本例中表示递归较深。此部分总共持续了大约 1.4 秒。通过查看此部分的底部,我们发现这些列的宽度由一个函数的时长决定:appendEventAtLevel
,这表明它可能是一个瓶颈
在 appendEventAtLevel
函数的实现中,有一点非常突出。对于输入中的每个数据条目(在代码中称为“事件”),系统都会向一个跟踪时间轴条目垂直位置的地图添加一个项。这会带来问题,因为存储的商品数量非常大。对于基于键的查找,映射速度很快,但这种优势并非免费。随着映射越来越大,向其中添加数据可能会因重新哈希而变得非常耗时。当连续向地图添加大量项时,这种开销会变得明显。
/**
* Adds an event to the flame chart data at a defined vertical level.
*/
function appendEventAtLevel (event, level) {
// ...
const index = data.length;
data.push(event);
this.indexForEventMap.set(event, index);
// ...
}
我们尝试了另一种方法,该方法不需要我们为火焰图中的每个条目都在地图中添加一个项目。改进效果非常显著,这证实了瓶颈确实与将所有数据添加到地图时产生的开销有关。活动组所用时间从大约 1.4 秒缩短到大约 200 毫秒。
之前:

之后:

第四个活动组:延迟非关键工作和缓存数据以防止重复工作
放大此窗口后,可以看到有两块几乎完全相同的函数调用。通过查看所调用函数的名称,您可以推断出这些块包含用于构建树的代码(例如,名称中包含 refreshTree
或 buildChildren
)。实际上,相关代码是在面板底部抽屉中创建树状视图的代码。有趣的是,这些树状视图不会在加载后立即显示。用户需要选择树状视图(抽屉中的“自下而上”“调用树”和“事件日志”标签页),才能显示树。此外,从屏幕截图中可以看出,树构建过程执行了两次。

我们发现此图片存在两个问题:
- 一个非关键任务阻碍了加载时间的性能。用户并不总是需要其输出。因此,该任务对个人资料加载而言并非至关重要。
- 这些任务的结果未缓存。因此,尽管数据没有变化,但系统还是计算了两次树。
我们首先将树计算推迟到用户手动打开树视图时进行。只有这样,创建这些树的成本才值得付出。两次运行的总时间约为 3.4 秒,因此延迟加载对加载时间产生了显著影响。我们仍在研究是否可以缓存这些类型的任务。
第五个活动组:尽可能避免复杂的调用层次结构
仔细查看此群组后,我们发现某个特定的调用链被反复调用。同一模式在火焰图的不同位置出现了 6 次,此窗口的总时长约为 2.4 秒!

被多次调用的相关代码是处理要在“迷你地图”(面板顶部的时间轴活动概览)上呈现的数据的部分。我们不清楚为什么这种情况会多次发生,但肯定不应该发生 6 次!事实上,如果没有加载其他配置文件,代码的输出应保持最新状态。从理论上讲,该代码应该只运行一次。
经过调查,我们发现相关代码之所以被调用,是因为加载流水线中的多个部分直接或间接调用了计算迷你地图的函数。这是因为程序的调用图的复杂性随时间推移而不断演变,并且在不知不觉中添加了更多对该代码的依赖项。此问题没有快速的解决方法。解决此问题的方法取决于相关代码库的架构。在本例中,我们必须稍微降低调用层次结构的复杂性,并添加一项检查,以防止在输入数据保持不变的情况下执行代码。实现此功能后,时间轴的概览如下所示:

请注意,微型地图渲染执行两次,而不是一次。这是因为每个配置文件都会绘制两个迷你地图:一个用于面板顶部的概览,另一个用于从历史记录中选择当前可见配置文件的下拉菜单(此菜单中的每个项都包含所选配置文件的概览)。不过,这两者包含的内容完全相同,因此一个应该可以重复用于另一个。
由于这两个迷你地图都是在画布上绘制的图片,因此只需使用 drawImage
canvas 实用程序,然后运行一次代码即可节省一些额外的时间。经过这一努力,该群组的持续时间从 2.4 秒缩短到了 140 毫秒。
总结
应用所有这些修复(以及其他一些小修复)后,个人资料加载时间轴的变化如下所示:
之前:

之后:

改进后的加载时间为 2 秒,这意味着只需付出相对较少的努力,即可实现约 80%的改进,因为所做的大部分工作都是快速修复。当然,正确识别最初要做的事情至关重要,而性能面板正是为此而生的工具。
另请务必注意,这些数字仅适用于作为研究对象的特定个人资料。我们之所以对该个人资料感兴趣,是因为它特别大。不过,由于每个配置文件的处理流水线都相同,因此所取得的显著改进适用于性能面板中加载的每个配置文件。
要点总结
从这些结果中,我们可以得出一些关于应用性能优化的经验:
1. 利用分析工具识别运行时性能模式
分析工具非常有助于了解应用在运行期间发生的情况,尤其是在确定可提高性能的机会方面。Chrome 开发者工具中的“性能”面板是 Web 应用的绝佳选择,因为它是浏览器中的原生 Web 分析工具,并且一直在积极维护,以确保与最新的 Web 平台功能保持同步。此外,它的速度现在也显著提升了!😉
使用可作为代表性工作负载的样本,看看您能发现什么!
2. 避免复杂的调用层次结构
尽可能避免使调用图过于复杂。如果调用层次结构很复杂,很容易出现性能回归,并且很难了解代码的运行方式,从而难以实现改进。
3. 确定不必要的工作
过时的代码库通常包含不再需要的代码。在我们的案例中,旧版和不必要的代码占用了总加载时间的很大一部分。移除该功能是最容易实现的目标。
4. 恰当使用数据结构
使用数据结构来优化性能,但在决定使用哪些数据结构时,也要了解每种数据结构带来的成本和权衡取舍。这不仅是数据结构本身的空间复杂度,也是适用操作的时间复杂度。
5. 缓存结果,避免复杂或重复性操作的重复工作
如果操作的执行成本很高,那么存储其结果以备下次需要时使用是有意义的。如果操作执行多次,即使每次执行的成本不高,这样做也是有意义的。
6. 推迟非关键工作
如果不需要立即获得任务的输出,并且任务执行会延长关键路径,请考虑在实际需要任务输出时延迟调用任务,从而推迟任务执行。
7. 在大型输入上使用高效算法
对于大型输入,最佳时间复杂度算法至关重要。在此示例中,我们未研究此类别,但其重要性怎么强调都不为过。
8. 奖励:为流水线设置基准
为确保不断演变的代码始终保持快速运行,最好监控其行为并将其与标准进行比较。这样,您就可以主动识别出回归,并提高整体可靠性,为长期成功奠定基础。