我们最近推出了 对 Web 应用检查器进行了一些重大改进 — 此功能作为您的 Sketch 订阅的一部分提供,无需额外费用。最大的变化之一是显着的性能提升。
我们最大的重点始终是为您提供最佳体验。我们的用户每天都在突破 Sketch 的可能性,这不可避免地意味着更大、更复杂的文件。因此,我们努力改进检查器中的加载时间,以帮助每个人更有效地在浏览器中检查设计,并为新功能提供更多空间。现在,我们想深入探讨实现这一目标的技术故事。
专注于预处理
为了尽可能提高检查器的性能,我们预处理 Sketch 文件(通过 AWS lambda 函数),这为我们提供了所需的数据。 Sketch 文档可能很大,但因为您一次只检查一个画板,我们可以只加载您需要的数据,并保持交互性能。此预处理是我们想要加速的步骤。
当您想要提高性能时,首先要做的是衡量它。我们 Web 检查器的预处理器是用 Go 编写的,幸运的是 Go 开箱即用地配备了出色的分析工具。如果您的应用程序中已经有基准测试,Go 可以非常容易地分析 CPU 使用率
go test -bench BenchmarkParse -cpuprofile out.pprof
然后可以使用 go tool pprof out.pprof
分析生成的文件 (out.pprof
)。
解析 JSON
这里可能会开个玩笑,有多少性能瓶颈问题最终都归结为某人在某个地方解析 JSON。因为我们的文件格式基本上是一个包含 JSON 文件的文件夹(以及一个开放规范——但更多内容将在未来的帖子中介绍),它也是我们今天讨论的核心。
我们发现大部分加载时间都花在了各种 JSON 解析函数内部(使用 jsoniter),而不是我们之后使用这些数据进行的处理。这并不令人惊讶;我们使用的测试文档包含 750MB 的未压缩 JSON 数据,将这些数据从纯文本解析回某种结构需要时间。
正如我们在上面提到的,因为 Web 应用程序允许您一次检查一个画板,所以我们不需要解析整个 Sketch 文档。考虑到这一点,我们最初的方法是将 JSON 解析成通用的 map[string]interface{}
映射。这样,我们可以在进一步处理之前简单地忽略我们未使用的画板的数据 — 这会将我们需要处理的 JSON 量减少到原始 JSON 加载的一小部分。然后我们使用 mapstructure 将剩下的数据转换为前端可以直接使用的有意义的 struct
。
检查我们的假设
当我们第一次进行此优化时,直觉上感觉这是一个正确的决定。但是用硬数据检查你的假设总是好的,对吗?为了确定解析为 map 或 struct
s 的速度有多快,我们编写了测试来衡量两者。

事实证明,直接将 JSON 解码为 struct
s(2.23 秒)比先将其转换为 map(6.51 秒)快近 3 倍。很明显,我们的首要任务是删除我们使用的 mapstructure 库。这样,我们可以将所有内容直接解析为 struct
s,然后删除之后我们不需要的页面和画板。一旦我们重写了我们 lambda 的很大一部分,我们就准备好进行初步比较了

结果 — 从 5.27 秒 变为 3.35 秒 — 是一个非常好的速度提升!但我们正在努力,我们不能就此止步。也许我们可以利用 Go 著名的并发功能来进一步加速?
不幸的是……事实并非如此。在大多数情况下,Web 检查器 lambda 只处理 Sketch 文档中的单个 JSON 文件 — 毕竟,我们一次只查看一个页面或画板。不想承认失败,我们将注意力转向了主要的 document.json
。此文件包含我们需要引用的共享文本和图层样式 — 也许那里有一个快速的胜利?几行代码之后,很明显,节省的… 略低于 8 毫秒。好吧,并非每个想法都能产生完美的结果!
解析点
由于并发功能无法帮助我们,我们再次回到 profiler 并注意到许多与 regex 相关的函数出现。我们知道我们在一个地方使用 regex 来解析点 — 我们将它们表示为 "{x, y}"
字符串,该字符串使用 regex 解析并转换为浮点数
re := regexp.MustCompile(`{([\\w\\.\\-]+),\\s?([\\w\\.\\-]+)}`)
parts := re.FindAllStringSubmatch(pointString, -1)
x, _ := strconv.ParseFloat(parts[0][1], 64)
y, _ := strconv.ParseFloat(parts[0][2], 64
return Point{X: x, Y: y}
查看我们的大型测试文档,我们注意到它包含超过一百万个点 — 许多图层,带有描述矢量路径的坐标和点。这感觉像是另一个潜在的瓶颈。 Regex 非常方便,但并非总是最快的,因此我们删除了 regex 并用一些手工制作的字符串解析代替了它
var point Point
pointString = strings.TrimLeft(pointString, "{")
pointString = strings.TrimRight(pointString, "}")
parts := strings.Split(pointString, ",")
x, _ := strconv.ParseFloat(parts[0], 64)
y, _ := strconv.ParseFloat(parts[1], 64)
point.X = x
point.Y = y
return point
它稍微快一些 — 但我们可以做得更好。这时灵感袭来:我们意识到这些点中的许多点都是相同的。为什么是这样?嗯,Sketch 文件格式描述了单位坐标中的所有矢量点(其中坐标系从 {0,0} 到 {1,1})。所以我们检查了一下,的确,在我们的测试文档中,几乎 70% 的点都是“{0, 0}”、“{0, 1}”、“{1, 0}”和“{1, 1}”。这真是个好消息 — 这意味着我们可以作弊!
var point Point
switch pointString {
case "{0, 0}":
point.X = 0
point.Y = 0
case "{1, 0}":
point.X = 1
point.Y = 0
// [...]
default:
// parse string
}
但是它产生影响了吗?好吧,事实证明,“作弊”对性能非常好。我们将执行时间减少了 560 毫秒;从 3.34 秒到 2.78 秒。与我们开始时相比,处理速度现在几乎快了两倍。

部署更改
现在感觉是停止并测试性能改进在实际硬件上的外观的好时机,而不是在我们的开发机器上(在这种情况下为 M1 MacBook Air)。我们在服务器端使用大量 Mac 来处理 Sketch 文档,但有更大的部分在 AWS 上运行 Linux。

最后,我们在我们的 AWS 测试服务器上运行了一个测试,并将结果与旧流程进行了比较。改进很明显,支持了我们之前的所有测试(我们在 M1 MacBook Air 上进行的)。
我们对结果感到非常满意 — 执行速度提高了 2.3 倍,内存使用率提高了 3.7 倍,甚至超过了我们的预期。经过一轮虚拟击掌,我们将其推广到我们所有的用户。您可以很清楚地看到我们切换到新代码的那一刻

很少有文档像我们在所有这些测试中使用过的文档那样大而复杂,但在进行这样的改进时,使用极端情况通常很有用。查看我们的指标显示,p99 延迟(除 1% 异常值之外的所有文档处理的平均时间)从 6.25 秒 降至 1.97 秒 — 实现了 3.2 倍的稳固改进。
现在,我们处理绝大多数 Sketch 文档只需不到两秒钟的时间。考虑到其中几百毫秒用于从我们的存储服务器上拉取 Sketch 文件,我们对这个结果非常满意。我们希望您已经注意到日常工作中的改进,并且这些改进使您在 Sketch 中的工作更加顺畅。