
秒开率从 18% 到 64%,我们对小程序模拟器做了什么? 原创
小程序是一种运行在快手生态内,无需下载安装、即用即走的轻量级应用。其中,模拟器是快手开发者所使用的工具中最核心的模块之一,但因性能问题收到开发者反馈。为此,24 年 Q2 快手启动了模拟器性能优化专项,从线上数据看:模拟器秒开率从 18%提升至 64%,FCP P90 从 4.4s 提升至 1.9s。本文详细介绍优化措施和成效。
一、问题背景
小程序是快手开放平台对外提供的开放能力之一, 是一种运行在快手生态内,无需下载安装、即用即走的轻量级应用。开发者以快手小程序为载体,以优质的内容、服务供给或内容生产连接用户。小程序接入流程可简单划分为四步:注册小程序、开发调试、审核上线、线上运营。其中开发调试工作主要在快手开发者工具上进行,而模拟器是快手开发者工具最核心的模块之一。
由于小程序不能直接使用浏览器环境运行,我们在开发者工具中提供了模拟器模块,模拟小程序在快手客户端的表现。
模拟器是快手开发者工具中开发者使用频率最高的模块,最常见的使用场景是:修改代码=>触发编译=>模拟器刷新=>查看效果,模拟器的加载速度直接影响开发者的开发效率。
从线上数据看,模拟器性能确实比较差:FCP P90 只有 4.4s,秒开率只有 18%,开发者也多次反馈期望优化模拟器的性能。
注:本文提到的 FCP 指编译完成后模拟器收到刷新事件,到小程序首次内容渲染所花的时间,秒开率指在 1 秒内完成 FCP 的比例。
二、分析解决
对于性能优化,常见思路是:理清各阶段耗时->找出耗时原因->制定相应优化方案。第一步,我们先要统计各阶段耗时。
2.1 如何统计模拟器启动各阶段耗时?
对于常规前端项目,很容易想到使用 performance 录制火焰图来分析耗时,但小程序模拟器比较特殊,无法使用 performance 录制。
2.1.1 为什么模拟器不能使用 performance 录制
快手小程序采用了双线程架构,分为逻辑层与渲染层。在模拟器中,渲染层使用 Electron Webview(独立进程)承载,一个页面对应一个 iframe;逻辑层使用 Electron BrowserView(独立进程)承载。
因为执行信息分布在两个进程中,但调试器 performance 录制的只能对接一个进程,这导致了不能直接使用 performance 录制功能,所以只能先在代码中手动打点来记录启动时各阶段耗时。
2.2 手动打点分析,确定主要优化方向
通过在代码中手动打点,我们观察到容器准备阶段的耗时比较长,再加上不能用 performance 录制,无法细致的统计加载执行阶段中的耗时,所以一开始我们优先考虑优化容器准备阶段耗时。
2.2.1 优化容器准备阶段耗时
双进程改为单进程:
在当前的模拟器方案中,每次模拟器刷新,都需要销毁并重建逻辑层容器,重新加载框架文件以及基础库(因为需要重新加载基础库、执行用户代码,重新走一遍小程序的生命周期),这些操作耗费了比较多的时间。我们的优化思路是尽可能减少需要重新加载执行的资源(进程资源、文件资源),有三个方案:
三个方案运行逻辑简述如下:
BrowserView + IFrame:
webworker:
IFrame:
综合评估:计划采用 IFrame 方案。
- 从内存占用、可移植性来看,webworker、IFrame 方案比较好;
- IFrame 刷新速度略快于 webworker,webworker 运行时性能更好:
- webworker 方案会新开一个线程,运行时性能优与 IFrame,但由于是在 PC 端,单线程带来性能影响比起在移动端更小,也能接受。
- 模拟器主要场景是刷新看效果,而不是操作使用模拟器中的小程序,所以刷新速度比运行时性能优先级更高
- 从时间成本看,IFrame 方案更小,且改动点在能够被 webworker 复用,后期即便考虑 webworker 方案也能成本也更小一些。
将逻辑层通过一个 IFrame 来承载,并且将其至于模拟器容器进程下,与页面 IFrame 同级,这样就可以拿掉一个进程,并且得益于同源特性,IFrame 还可以复用父进程的线程资源,刷新速度会更快。
模块缓存复用:
由于逻辑层、渲染层调整后变成了同级的 IFrame,可以共享 parent window,在此基础上,我们可以将逻辑 / 渲染层框架文件一些比较独立的、业务无关的功能模块提取至父容器里面,通过 parent window 调用,以降低框架文件包体积,提升加载速度。
2.3 performance 录制统计更精细耗时
模拟器改为单进程后带来另一个好处是能够使用调试器的 performance 录制功能了,因为渲染层跟逻辑层改成了 IFrame 来承载,处于同一个进程中,执行信息可以由调试器直接采集到。
2.3.1 编译产物优化为按需加载
通过观察火焰图,发现模拟器在启动时就将小程序代码中所有页面的编译产物加载了,这一段逻辑耗费了比较多的时间,但其实每次模拟器刷新并不需要把所有的页面编译产物都加载进来,只需加载当前页面所需编译产物即可。
我们将编译产物调整为了按需加载:当基础库需要执行对应编译产物时再去加载。加载方式也由之前的 script 标签异步加载,替换成了 readFileSync + eval(基础库限制只能使用同步方式加载),readFileSync 读取文件内容,eval 完成加载。
但调整为按需加载之后,发现了一个新的问题:如果断点所指向代码的执行时机比较靠前(如 onLoad 阶段),则有可能导致断点失效。
为什么断点失效了?
经过 debug,发现断点失效跟编译产物的加载方式有关。原先使用 script 标签加载,调试器可以将代码与 script 标签的 src 地址指向文件直接关联上,而优化后使用了 eval,无法直接将代码与文件关联上,从而导致断点失效。
全量加载(优化前):
断点设置流程:第一次收到路由事件后先使用 script 标签全量加载所有页面编译产物,调试器根据 script src 属性指向的文件路径找到并通知内核设置断点。
加载流程:
按需加载(优化后):
断点设置流程:eval 加载执行代码时,调试器开始解析 sourcemap,解析完成后通知内核设置断点。
加载流程:
但我们还发现一个现象是:如果断点指向代码执行时机比较靠后,则可以断点成功。这是因为编译产物文件中有 sourcemap,sourcemap 解析完成后调试器也能将 eval 的代码跟对应文件关联上,找到对应文件相关断点并通知内核设置断点,所以此时能断点成功。
通过 #sourceURL 解决
问题主要原因在于,调试器无法在一开始将 eval 的代码与源文件关联上,虽然 sourcemap 解析完也能关联上,但 sourcemap 解析是耗时的,也就导致了调试器通知模拟器内核设置断点时,断点指向代码已经执行过了,导致断点不生效,所以不能完全依赖 sourcemap。
有没有办法不依赖 sourcemap 的情况下能让 eval 与源文件直接关联上?我们在 Chrome 官方文档中找到了 #sourceURL 这个配置:
通过这个配置可以让调试器将 eval 的代码跟文件直接关联上,无需等待解析 sourcemap 完成,即可直接根据 sourceURL 配置查找并通知内核设置断点。
所以我们给源码加上了 #sourceURL 注释再进行 eval。
优化后的断点设置流程:
2.4 performance 录制为什么会影响模拟器性能?
在使用 performance 录制功能时,发现开启录制状态下模拟器的启动速度要快一些。
经过实测,performance 录制确实让模拟器启动速度变快了,且差距明显。
考虑到调试器与模拟器主要是通过 CDP 消息进行交互,我推测是开启录制后某条 CDP 消息导致了模拟器性能大幅提升。
2.4.1 CDP 是什么?
CDP 全称是 Chrome DevTools Protocol,是供 Chrome Devtools 使用的一个协议,简单说下 Chrome Devtools 的原理:
- 加载一个 web 页面时,浏览器会为该页面起一个 Websocket Server,在打开这个页面的 Devtools 时与该 Server 建立 Websocket 连接,以这种方式实现通信。
- 在某些关键事件发生时(如网络请求,用户调用 console api),浏览器内核会向 devtools 发送 CDP 消息;devtools 也可以向浏览器内核发送消息,来命令页面执行某些操作(如在 console 面板中输入代码并执行)。二者之间的通信遵循 Chrome Devtool Porotol。
一条 CDP 消息示例
2.4.2 开启 performance 录制后,调试器对模拟器做了什么?
我们可以通过 Protocol Monitor 面板看到开启录制后调试器往模拟器发送了哪些 CDP 消息(图中红框部分,大多是 xx.disable:禁用某些功能)
简单的方法是将这些消息直接挨个拦截测试一下就能知道哪些消息提升了性能,不过由于这里用的是 Electron Webview 自带的原生调试器,没办法直接拦截原生调试器发送至模拟器内核的 CDP 消息,还是需要从开发者工具自己实现的调试器入手。
但开发者工具中的调试器未实现 performance 功能,所以也不能直接测试。我尝试将开发者工具中调试器与原生调试器都关闭后,模拟器性能达到了开启 performance 录制后的效果,这能得出一个结论是「模拟器性能下降是由开发者工具调试器造成的」。
2.4.3 优化调试器相关逻辑
经过调试,我们发现可以对调试器的部分 CDP 消息做相关优化:对一些在启动阶段用不上的调试器功能,先暂时关闭(缓存相关 CDP 消息),等实际用到对应功能或模拟器加载完成时再打开(发送所有缓存消息),来达到提升模拟器加载速度的效果。
最终方案如下图所示:
出于稳定性考虑,我们也为这个优化加了开关
三、总结
本文介绍了我们在对模拟器进行性能优化过程中,做了哪些事情。首先通过手动打点分析耗时,确定了主要优化方向,将模拟器的双进程架构改成了单进程架构。在单进程架构下,通过增加缓存复用层,进一步提升了加载速度。同时单进程架构也使得我们可以使用 performance 录制工具进行更精细的耗时分析,针对性的对编译产物做了按需加载优化,并通过「#sourceURL 注释」解决了断点失效的问题。此外,我们也对调试器相关逻辑进行了优化,并取得不错的效果。
优化前后对比
经过本次优化后,模拟器秒开率从 18%提升至 64%,FCP P90 从 4.4s 提升至 1.9s,在开发者满意度调研中也获得了好评。模拟器的性能与开发者体验、开发效率息息相关,而提高开发者的开发体验与开发效率,是我们团队的首要任务。未来,我们将继续努力,不断优化和完善模拟器的各项功能,为开发者提供更好的支持。
