- A+
相比用户停留时间短、用完即走的 Web 页面,桌面 QQ 用户在一次登录后,可能会挂机一周以上,这段期间,如果没有严格控制好 QQ 内存占用,那么结果可能是用户交互响应变慢、甚至 Crash。 在系统监控工具里,高内存占用也会被直观地反映出来,带来不好的口碑。MAC QQ 灰度期间,也听到了一些用户关于内存占用偏高的声音。 即然不能置若罔闻,那么必须得痛下决心系统地来一波内存占用分析与优化。 在这个过程中,团队前前后后挖出来了不少优化项,最终,可以让桌面 QQ 在内存占用上达到一个相对较低且稳定的状态。 本文内容是探索桌面 QQ 内存优化上的一个阶段性小结,肯定还有更多内存优化 trick,欢迎各位多多提意见。
背景
新版桌面 QQ 自内测以来受到许多热心网友和行业人士,以及鹅厂小伙伴的关注,非常感谢大家在内测过程中提的各种有建设性的建议和反馈。其中,也有一小部分有开发背景的用户对我们采用 Electron 框架表达担心:高内存占用、超大安装包、启动缓慢等。究其原因还是担心新版本 QQ 资源占用大、体验变差,针对用户的担心,我们在内存上进行了专项优化,也取得了一些阶段性的进展,在此做一个小结。
新版 QQ 在内存上的挑战主要表现在以下 4 个方面:
-
产品形态:由 1 个复杂的大面板(100+ 复杂程度不等的模块)和一系列独立功能窗口构成。窗口与渲染进程一一对应,窗口进程数很大程度影响 Electron 的内存占用。对于那个复杂的大面板, 一旦没有精细控制就很容易导致内存持续走高。
-
使用习惯:用户长时间挂机。相比用完即走的 Web 页面,QQ 用户在一次登录后,可能会挂机一周以上。这段期间,如果没有控制好 QQ 内存使用,那么结果可能是内存越占越大、用户交互响应变慢、甚至发生闪退。 -
版本迭代:已经 24 岁的 QQ 拥有众多的功能和特性,过去一年我们一直做这件事:从核心特性开始快速补齐 Windows 版本的功能,同时也有一些高优先级的新功能要上。持续且快速的版本迭代,很可能产生新问题,使性能劣化。 -
应用架构:新版 QQ 依赖一个 NT 核心数据模块(C++ addon),为 UI 提供本地化的数据服务。QQ 的加载体验能做到如此丝滑,这个模块起到了至关重要的作用。同时,与 NT 的联动优化,也需要拉通客户端 C++ 开发同学共同完成,当然,会存在一些沟通成本,但不可否认,能把内存占用压下来,客户端同学也付出了非常多的努力。
在这篇文章中,我们将和大家分享新版 QQ 在内存优化方面的探索和阶段性优化进展。虽然本文的讨论主要集中在 Windows 平台,但由于 Electron 的跨平台特性,大部分优化措施也同样适用于 macOS 和 Linux 平台。
内存现状与目标
在着手优化之前,我们结合旧版 QQ 以及其他优秀的桌面应用,给新版 QQ 设定了优化目标:
-
第一阶段目标,单个进程内存 < 300M。早先因为没有腾出手处理内存问题,代码中存在一些泄漏。长时间挂机后比较容易出现单个进程超过 300M 的情况。我们在去年 9 月份系统地处理过一波内存问题,基本可以保证单个进程的内存占用 < 300M。 -
第二阶段目标,**单进程 <100M,整体 < 300M**。**整体是指启动 QQ 聊天面板后,6 个进程内存占用之和****。**内存达标之后才允许交付新版 QQ Windows 版本:
这些进程会随着 QQ 的启动一直存在。我们重点看下这 3 类进程,这也是内存优化的大头:
-
node:Electron 的主进程,负责窗口管理、跨进程通信等。包含 NT 核心数据模块,负责与服务端交互,为 UI 提供数据服务。 -
renderer:Chromium 内核的渲染进程,负责渲染 UI、提供用户交互等。QQ 启动后,会有 2 个渲染进程:一个是 QQ 大面板,另一个是主进程的窗口池。窗口池是预创建的一个渲染进程。在新开窗口时,可以减少等待时间。 -
gpu:Chromium 内核的 GPU 进程。它的主要作用是处理与图形相关的任务,例如渲染网页、播放视频、执行动画等。
设定了目标后,我们先对 QQ 的内存占用情况进行了摸底。我们从用户的角度出发,使用 Windows 任务管理器来观察 QQ 的内存占用情况。我们先从最简单的 “Hello World” 开始,看看 Electron 应用的最低内存需求是多少,以及上限在哪里。结果显示,只需要 68M,并没有达到传说中的几百 M 那么大。
然而,随着使用的深入,比如在 QQ 聊天场景中进行一些操作之后,主进程、GPU 进程和渲染进程三个进程的内存占用就已经达到了 600M。这意味着我们距离目标还有超过 50% 的优化空间。
这个初步的观察让我们看到了目前的挑战,同时也让我们看到了优化的可能性。我们有信心,通过精心设计和持续优化,逐步接近甚至超越我们设定的目标。
内存优化我们都做了什么
接下来,将重点介绍我们是如何掌控和优化 Electron 的内存的。我们的工作主要包括以下几个方面:
-
工具分析:首先,我们需要使用不同维度的内存分析工具,从 V8 引擎到进程,再到整个应用程序,打通整个链路进行多角度的细节分析,以此来定位内存使用的瓶颈。 -
定向优化:在通过工具定位到问题之后,我们会采取一系列的针对性优化策略,包括缓存策略、按需加载、优雅降级等。具体的优化工作我们将在后面进行详细介绍。 -
线上监控:在本地或小范围内验证通过之后,我们需要广大用户的验证来确认我们的优化措施是否适用于所有场景。然而,如何获取用户在 Windows 任务管理器中看到的内存使用量是一个挑战,我们已经做了大量的研究和验证。 -
防止性能退化和自动化测试:为了保护我们辛苦得来的优化成果,并避免频繁的版本迭代影响 QQ 的内存目标,我们会借助开发框架、工具建设、代码审查等手段来预防性能退化。
分析工具
在进行性能优化之前,我们需要选择合适的工具来帮助我们分析问题。QQ 的代码不仅包含 V8 的 JS 部分,还包括许多 Native 的 C++ 模块。仅依靠 Chromium 开发者工具进行性能分析是不够的,因此我们需要组合使用多种工具来共同解决问题。
这些工具如何使用,由于篇幅的关系我们在这里不做详细介绍。
定向优化
1) 最大化资源使用率
-
代码及静态资源
桌面版 QQ 的功能逻辑非常复杂,代码量庞大。虽然代码不需要通过网络请求加载,本地加载速度通常较快,但加载如此庞大的代码会占用大量内存。因此,仍然需要进行代码瘦身、静态资源优化、分包和按需加载等优化措施。
首先是代码瘦身。对于第三方包或 SDK,它们往往包含了完备的 Web 兼容性及能力,而这些对于 Electron 客户端来说并不是必需的。因此,我们会对它们进行定制裁剪或独立实现,以减少代码的加载。
对于 QQ 的业务代码,分包策略不完全按照每个页面(窗口)以及模块复用次数来进行制订,更多的情况是按照场景模块来进行细粒度的定制。以打开一个窗口到进入使用场景为例:1)窗口池中预启动的窗口页面只加载必须执行的基础代码;2)当打开具体窗口时加载对应的路由后页面入口代码;3)当具体使用不同功能时动态加载,如点击搜索、打开表情面板、转发消息激活好友选择器的时候才会分别加载对应功能模块代码。
此外,其他静态资源(如 SVG、base64 图像)在加载时也会占用不少内存,所以我们采取了按需加载的策略:只在可见时加载,不可见时主动销毁和回收。
为了提升执行效率和代码保护的目的,我们将 JS 代码转成了字节码。尽管跳过了源码编译,直接将字节码交给 V8 执行,但在程序报错还原堆栈等运行时步骤中,V8 仍然会引用源码字符串。为了去掉这份源码,我们使用和源码等长的空格来占位,但通过 devtool 检查发现这些空格字符串仍会占用不少内存空间。最终,我们采取修改和移除 V8 对源码字符串引用的方式,彻底解决了源码字符串的内存占用问题。
-
图片资源
QQ 作为一款 IM 工具,会涉及到大量的图片收发。然而,图片的渲染会占用相当大的内存。举个例子,一张分辨率为 4000 x 2750 的图片,结合设备屏幕像素和聊天区设计尺寸,只需渲染宽度为 567 像素的分辨率图像即可清晰展示。如果以宽度为 4000 像素的分辨率渲染,理论上两者位图所占用的内存大小差距可达 50 倍,并且还会因为渲染带来性能损失。
在聊天消息列表中的大部分图片仅仅起到预览作用,缩略图渲染就满足了需要。而仅仅在用户真正打开图片查看器放大查看时,才会需要用原图渲染。
实测在聊天中多张不同大尺寸分辨率图片在展示时,渲染进程和 GPU 进程的内存占用有着明显差别。在收发图片时,我们会根据屏幕设备信息和计算展示区域所需实际渲染分辨率,当原图分辨率超出计算所需值,则先调用压缩服务进行图片压缩,生成渲染所需分辨率的缩略图,并在聊天区域进行渲染上屏。在这个策略的优化下,一般聊天图片场景测试下来,使用缩略图比原图约有 30M ~ 50M 的内存优化。
2) 可视区域按需渲染
-
DOM 元素数量
在 DOM 元素使用数量我们也有严格的控制,总体采用”所见即占用“的 DOM 渲染策略。在 QQ 大面板中只有视口所见的内容才会渲染对应 DOM 元素。其他所有组件在不渲染展示时,均会移除组件及其 DOM 元素来避免其内存开销。
尤其对于各个大列表模块,比如联系人列表和群成员列表,DOM 元素都非常多。最开始的内测版本中,使用有大量好友和群聊的 QQ 号,窗口平均 DOM 数达到 13000。我们将 QQ 所有的普通分页列表替换为虚拟滚动列表,并且对列表滚动 buffer 进行极限压缩甚至是 0 buffer 。由于不再一味采取空间换时间,没有 buffer 的情况下必然面对列表滑动性能挑战,因此也需不断优化各类 item 组件渲染性能。
此外,我们还通过精简组件 DOM 层级,移除非核心组件 keep-alive (重新优化渲染性能)等方式,大账号使用下整体的 DOM 数量从 13000 减少到控制在平均 4000 以内,这部分优化减少约 20M 内存。
-
渲染图层
渲染图层方面,在渲染时满足某些特殊条件的渲染层,会被浏览器自动提升为合成层,达到提升渲染性能的目的。但是每个合成层都占用额外的内存,应当去掉过量且不必要的合成层来控制图层带来的内存占用。当然结合渲染性能考量,对于高频且列表等核心模块,是可以单独提升合成层。
在桌面端 QQ 中通过超级调色盘可以为进行色彩换肤,在这个场景中全局各模块有不少单独提升的合成层来实现毛玻璃、渐变和纹理效果。另外还有许多不经意间被提升的隐式合成层。通过对不必要的合成层进行移除与合并,整体也优化了约 9.3M 内存。
-
结构化消息
QQ 支持丰富的消息类型,从简单的文本、图文消息,到复杂的 lottie 表情、下图所示的业务可定制的结构化消息等。我们知道 JavaScript 是单线程的,这些消息同时上屏的时候可能会出现过长的上屏任务而导致 UI 卡顿,给到用户的感受就是切换消息列表卡顿,消息上屏慢等糟糕的体验。
新版 QQ 针对这类复杂消息上屏,使用了 JavaScript 事件机制结合 WebWorker 来实现消息异步上屏,并使用 OffscreenCanvas + Worker 池绘制来提升渲染性能。
为了在 Canvas 中实现 CSS 的 Flex 布局效果,我们采用了跨平台的布局解决方案,将 Yoga 编译成 WebAssembly 运行在 WebWorker 中。Yoga 官方编译采用的是 asm.js 的方案, 这种方案不支持动态分配内存,可以看到它默认分配了一个较高的内存,达到了 128M。
为了优化 WebAssembly 的内存占用,我们调整了编译方式,将 Yoga 编译成独立的 wasm 文件,这种方式相比 asm.js 支持动态内存分配。同时结合聊天窗口的消息卸载策略,经过不断的测试调优,在既要保证初始内存较少又要尽可能避免内存爆发式增长带来的性能损耗的前提下,我们把 WebAssembly 的初始内存分配优化到 2M,再加上对象共享、享元模式等策略,WebWorker 的内存占用有了非常可观的优化。
复杂的聊天消息虽然是必不可少的功能,但是实际的消息量还是远少于普通的图文消息,因此在保证用户体验的前提下,在合适的条件下适时销毁 WebWorker 是一个合理的策略,而随着 WebWorker 被销毁这个线程所占用的内存也能被完全释放。
3) 性能与体验平衡
-
Lottie 及动画方案选型
超级表情采用 Lottie 动画技术方案,有高清高帧率高质量特点,但同时也为我们带来了渲染的高成本。为了保证 Lottie 的高帧率和减少 CPU 占用,我们缓存了 Lottie 渲染器生成的动画帧,内存消耗成为了首要问题。
对其进行定量分析,超级表情 Lottie 资源继承自手机 QQ,尺寸是 512 × 512,动画帧以 int8 数组存储,所以一帧动画为 512 × 512 × 4 = 1MB(乘 4 是因为每个像素由 RGBA 表示),一个普通大小的超级表情,例如庆祝表情,有 160 帧动画,依据缓存 9/10 帧动画的策略,庆祝表情会占用 144Mb 内存,虽然是可回收的,但也无疑是巨大的内存消耗。关注到 Lottie 渲染的内存消耗后,我们主要从以下 2 步入手:
-
缓存的动画帧尺寸:桌面端 lottie 渲染大小为 120 × 120,考虑到需要保持 Lottie 动画的高质量,缓存的动画帧尺寸调整为实际尺寸大小的两倍,即 240 × 240,降低内存消耗 72%。经设计确认,清淅度上也没有明显的差异; -
缓存策略:缓存 9/10 的动画帧减少到缓存 3/4,降低内存消耗 35%,而且调整之后帧率还能得到保障;
通过以上 2 步,一共降低内存消耗 81.8%,庆祝表情从 144 MB 降低到 35 MB。
最后,旧策略对于渲染过且暂时不用的 Lottie 表情,会 buffer 它的第一帧,总共 31 个 Lottie 表情:2.3KB * 31 = 7MB(最多),经评估之后,我们暂时也拿掉了该策略。
另外,桌面 QQ 左侧导航栏目,为了与移动端统一体验,使用 Lottie 动画来实现,从 memory 面板来看, 4 个 icon 导航条会占用约 6MB 的内存。改用 CSS 实现,不仅效果与 Lottie 的几乎一致,而且这 6MB 的内存占用就完全省掉了。
-
聊天列表与消息
聊天列表 AIO,作为 QQ IM 模块中最主要的承载消息数据展示模块,其滚动体验必然离不开用户体验与内存的权衡。
聊天列表在静态与滚动过程中,维持消息组件的数量多少决很大程度决定整个 QQ 的内存占用。消息数据从服务端拉取后会存储在本地 DB,根据策略会将当前会话的消息数据缓存在内存中。
随着滚动加载,消息缓存占用的内存也越多,所以也有一定动态阈值的策略,丢弃滚动方向相反的旧消息,从而将内存控制在可接受范围。如果用户重新操作又需要加载时,这请求底层向本地磁盘 DB 重新拉取。
消息组件实例是内存占用的大户,每条消息组件内部包含头像/昵称/状态/内容等多个实例,如果不对消息实例进行回收销毁,每百条消息约能带来 20M+ 的内存增量,因此消息实例的回收策略尤为关键。
最早版本中对消息上屏没有丢弃策略,内存增量没有很好控制。于是采用分页列表,屏内保持固定几页消息(约 30 ~ 50 条消息,视屏幕尺寸决定),超过范围的消息进行丢弃,列表高度由屏内消息直接撑起,用户通过触顶或触底进行上下一页消息的加载。
但这页带来些点问题:一方面随着触顶触底,滚动条频繁跳动的体验并不好;另一方面列表高度由不定高的组件渲染消息来维持,不得不始终保留 30 ~ 50 条消息以撑起滚动高度,不可见消息的那部分便造成内存的浪费。
使用虚拟列表维持计算高度后,列表不再依赖保持真实消息内容的渲染,理论上我们可以将可视区域以外的消息实例全部销毁,仅保留用户可见的消息,最大程度地压缩消息实例数量,指保留很少的 buffer 消息实例。在实际滚动中由于消息实例在滚动过程被不断创建和销毁,占用主线程,影响 UI 绘制和用户输入。因此我们还做了:1.对创建销毁做一定聚合,批量处理消息上屏。2. 精简优化单条组件的渲染性能。3.不同滚动方向调整上下不同 buffer 大小 等等措施。4、会话切换和窗口聚失焦最小化等操作时对不再使用的消息资源内存进行主动回收。
滚动性能和内存占用之间需要取得平衡,既要最大程度压缩上屏消息数量以节省内存,又要保证滚动性能体验。然而经过优化后,本地测试加载 200 条混合种类的消息场景下,从空状态进入聊天会话中,消息列表内存增量从最多 44.2M 降至 6.1M,且滚动静止后内存不会任意增长。
4) Electron 使用姿势
Electron 给主进程提供了不少对系统能力调用的 API,如托盘、系统通知、macOS 中 dock 栏设置等。但是如果对这些 Electron 能力的使用方式不对,就可能导致不必要的大量内存占用甚至是泄漏。
比如 QQ 中,我们通过短间隔定时调用 Tray setImage API 来实现 QQ 托盘的闪烁,如果不注意传入 string Path 则会每次创建 Image 对象导致内存占用,正确的方式应该创建 NativeImage 并缓存,调用 Tray setImage 传入指定 NativeImage,避免反复创建 Image 导致的内存问题。
类似的问题还有在 macOS 中调用 API dock.setIcon 也会持续占用约 20M 的 CGImage 位图内存,正确的方案应该是不通过 Electron API 指定,而是通过打包 plist(属性文件) 指定 dock 栏图标。
在使用 Electron 的过程中,还存在类似会导致内存问题的使用方式,我们需要结合客户端内存工具进行深度挖掘和分析,才能发现和处理这些问题。
5) 消灭内存泄漏
我们知道 V8 有自己的垃圾回收机制,虽然它在 GC(垃圾回收)方面有着其各种策略,并做了各种优化从而尽可能的确保垃圾得以回收,但我们仍应当避免任何可能导致无法回收的代码操作。常见的例子包括:
-
未移除的监听器和定时器:在监听事件处理函数其中引用的不被释放导致的泄漏。 -
游离 DOM 未释放:移出 document 后游离 DOM 仍存在引用导致无法释放。较多发生于框架的组件销毁时,相关监听未取消导致组件没有释放的情况。 -
监控/打点导致的泄漏:在使用 Performance.mark 打点监控时,产生 PerformanceMark 对象,在用完之后没有手动清除,也会导致内存泄漏。 -
error 导致的泄漏:控制台持有被打印对象始终不释放,导致应用的泄漏。 -
其他不当的闭包及隐式的全局变量。
以上是桌面 QQ 在早期遇到的常见问题。后续,我们通过代码检测手段来防范这类问题的出现。与一般的前端项目不同,由于桌面 QQ 的长周期使用特性,任何缓慢而微小的内存泄漏都可能被放大,这也是我们极力把控并阻止任何可能导致内存泄漏的代码引入的原因。
优化结果与线上监控
经过一系列组合优化之后,在我们自己的设备上来看,QQ 的内存使用基本是达标了,长时间挂机稳定在 300M 以下,在广大 QQ 用户侧能否保持这个水平?只有通过线上内存及性能的采集监控,才有数据指标来观测,从而才能对优化有效性进行验证和决定如何调整优化方向。好在 Electron 提供了 app.getMetics 、 process.getMemoryInfo 等 API 来采集内存指标。但需要注意的是这些 API 所采集返回的内存值的真实含义,如 getMetric 所采集的到 workingsetSize 和 privateBytes 均不是任务管理器用户所看到的内存。
这里我们通过 patch 定制改造 Electron getMetics API,来增加不同平台任务管理器的内存类型的指标,并且采集包含了主进程、渲染进程、GPU 进程和工具进程等所有内存指标。
为了避免频繁采集上报内存指标所带来的的性能消耗,我们设定了一定时间的采集间隔,同时针对使用场景的采集做了抽样。并将渲染进程 pid 映射寻找窗口名,只在若干次采集后再做聚合计算,通过 SDK 上报到 prometheus + grafana 的指标观测平台。
经过若干次内存性能优化的迭代,目前从线上数据指标来看,新版 Windows QQ 运行的内存在主场景下基本控制在 300M, 这个值已经基本达到我们设定的目标。
从登录后使用过程中的内存指标如下:整体应用的内存平均占用约为 228M;其中中位数占用约为 211M,90% 分位用户内存占用约为 350M。
当然,这个目标只是阶段性的,我们还会持续针对更多使用场景进行内存优化。
防劣化与自动化测试
为了持续关注和保证新版 QQ 项目的性能达标且不劣化,除了比较常规的单元测试、代码检查、代码评审机制、框架内置一些开发规范等手段外,我们还在建设一个防劣化平台,主要通过自动化的端对端(e2e)测试来持续监控项目集成后的性能变化。
-
定时对主干上集成构建的程序进自动化 e2e 测试; -
除了对功能的冒烟测试外,针对重点关注的性能指标,构造了对应的帐号和环境,编辑特定的用例,用于采集性能指标; -
通过将采集和采样的指标上报到防劣化的监控平台,来监控项目集成后的性能变化,如会话切换响应时间、内存占用、CPU 使用率等; -
监控平台提供按版本和时间的指标曲线、对比,方便查看和分析性能变化情况。同时打通企业微信机器人,对性能指标情况进行实时推送告警; -
根据告警信息对应的版本信息和代码记录,排查情况,闭环问题。
这一套机制之前在 内测中的 QQ 频道桌面端的项目中尝试应用,运行发现了一些比较典型的代码异常、crash、oom 问题,证明确实有效。新版 QQ 业务和设计都更复杂,建设好防劣化机制无论是对发现问题的效率,还是对整体的性能和质量都是意义重大的,也是我们团队当前重点建设、未来持续迭代的重要任务。
总结
可能大家比较关心,为什么一定要选择 Electron?其实我们是经过深思熟虑的:
首先,全新 QQ 意味着我们应该专注在功能快速迭代上,否则,以 QQ 的体量战线会拉得非常长。我们希望最后选择的跨平台方案应该是足够成熟、低开发和使用成本,不需要为了使用框架本身,还需要投入额外巨大的人力成本。这个其实在 React Native、Flutter、Tauri 等跨平台框架的使用过程中,我们都遇到过类似的问题,除了功能开发,为了把框架生态、周边、工具链建设好,还需要投入巨大的额外成本,Qt 也有类似的问题。而使用 Electron,对于 Web 前端开发同学,基本上是 0 成本,现有的 Web 前端的大部分基建都可以直接复用,而且使用 Web 开发 UI 的效率,在主流技术栈里算是很高的了。并且这几年主流的桌面端应用基本都选择了 Electron,如 VScode、Discord、Slack、Skype、Whatsapp、Figma 等等,新的桌面应用基本上也是首选 Electron,另外,Electron 版本的迭代速度和社区氛围都很在线。
其次,从结果或者解决问题的角度来看,经过一系列优化之后基本可以将 QQ 核心聊天场景的内存控制在 300M 以内,150M 的安装包大小,与旧版纯 Native QQ 差别较小。不单单内存占用,其他核心体验,比如切 AIO 的流畅度上要优于旧版 QQ。即便是在今天,QQ 也坚定一年半之前选择了 Electron。
最后,让我们再次聚焦在内存优化的工作上,下图是我们在桌面 QQ 中针对 Electron 内存优化工作的一个概览。内存优化没有银弹,有的只是一步一个脚印深入做下去,芝麻西瓜都要捡,从量变到质变。未来我们完全有信心,凭着已有的经验和对其技术的理解,守住现在这些成果的同时,进一步优化 QQ 生态下的各个子业务、子模块的内存占用问题。因此,也希望通过我们实践经验分享, 让大家从更多辩证的视角来重新看待 Electron 或类 CEF 的技术方案。
作者:fred