新QQNT桌面版如何实现内存优化探索

#Desktop

背景

QQ 作为国民级应用,从互联网兴起就一直陪伴着大家,是很多用户刚接触互联网就开始使用的应用。而 QQ 桌面版最近一次技术架构升级还是在移动互联网兴起之前,在多年迭代过程中,QQ 桌面版也积累了不少技术债务,随着业务的发展和技术的进步,当前的架构已经无法很好支撑对 QQ 的发展了。在 2022 年初,我们下定决心对 QQ 进行全面的技术架构升级,对于这样一个国民级应用的重构,挑战无疑是巨大的。

新版桌面 QQ 自内测以来也受到许多热心网友和行业人士的关注,非常感谢大家在内测过程中提的各种有建设性的建议和反馈。其中,也有一小部分有开发背景的用户对我们采用 Electron 框架表达担心:高内存占用、超大安装包、启动缓慢等。究其原因还是担心新版本 QQ 资源占用大、体验变差,针对用户的担心,我们在内存上进行了专项优化,也取得了一些阶段性的进展,过程中也积累了不少经验,也借此机会分享给大家。

新版 QQ 在内存上的挑战主要表现在以下 4 个方面:

桌面端 QQ 整体架构

在这篇文章中,我们将和大家分享新版 QQ 在内存优化方面的探索和阶段性优化进展。虽然本文的讨论主要集中在 Windows 平台,但由于 Electron 的跨平台特性,大部分优化措施也同样适用于 macOS 和 Linux 平台。

内存现状与目标

在着手优化之前,我们结合旧版 QQ 以及其他优秀的桌面应用,给新版 QQ 设定了优化目标:

  1. 第一阶段目标,单个进程内存 < 300M。早先因为没有腾出手处理内存问题,代码中存在一些泄漏。长时间挂机后比较容易出现单个进程超过 300M 的情况。我们在去年 9 月份系统地处理过一波内存问题,基本可以保证单个进程的内存占用 < 300M。
  2. 第二阶段目标,单进程 <100M,整体 < 300M。整体是指启动 QQ 聊天面板后,6 个进程内存占用之和。内存达标之后才允许交付新版 QQ Windows 版本:

Windows 任务管理器的 QQ 内存占用详情

这些进程会随着 QQ 的启动一直存在。我们重点看下这 3 类进程,这也是内存优化的大头:

设定了目标后,我们先对 QQ 的内存占用情况进行了摸底。我们从用户的角度出发,使用 Windows 任务管理器来观察 QQ 的内存占用情况。我们先从最简单的 “Hello World” 开始,看看 Electron 应用的最低内存需求是多少,以及上限在哪里。结果显示,只需要 68M,并没有达到传说中的几百 M 那么大。

然而,随着使用的深入,比如在 QQ 聊天场景中进行一些操作之后,主进程、GPU 进程和渲染进程三个进程的内存占用就已经达到了 600M。这意味着我们距离目标还有超过 50% 的优化空间。

注:AIO 是聊天面板的简称。

这个初步的观察让我们看到了目前的挑战,同时也让我们看到了优化的可能性。我们有信心,通过精心设计和持续优化,逐步接近甚至超越我们设定的目标。

内存优化我们都做了什么

接下来,将重点介绍我们是如何掌控和优化 Electron 的内存的。我们的工作主要包括以下几个方面:

工具分析

在进行性能优化之前,我们需要选择合适的工具来帮助我们分析问题。QQ 的代码不仅包含 V8 的 JS 部分,还包括许多 Native 的 C++ 模块。仅依靠 Chromium 开发者工具进行性能分析是不够的,因此我们需要组合使用多种工具来共同解决问题。

这些工具如何使用,由于篇幅的关系我们在这里不做详细介绍。
部分内存分析工具截图

定向优化

最大化资源使用率

Devtools > Memory 分析 QQ 主窗口内存占用

首先是代码瘦身。对于第三方包或 SDK,它们往往包含了完备的 Web 兼容性及能力,而这些对于 Electron 客户端来说并不是必需的。因此,我们会对它们进行定制裁剪或独立实现,以减少代码的加载。

对于 QQ 的业务代码,分包策略不完全按照每个页面(窗口)以及模块复用次数来进行制订,更多的情况是按照场景模块来进行细粒度的定制。以打开一个窗口到进入使用场景为例:

QQ 主窗口业务模块的拆解

此外,其他静态资源(如 SVG、base64 图像)在加载时也会占用不少内存,所以我们采取了按需加载的策略:只在可见时加载,不可见时主动销毁和回收。

svg 及 base64 资源的 string 内存占用

为了提升执行效率和代码保护的目的,我们将 JS 代码转成了字节码。尽管跳过了源码编译,直接将字节码交给 V8 执行,但在程序报错还原堆栈等运行时步骤中,V8 仍然会引用源码字符串。为了去掉这份源码,我们使用和源码等长的空格来占位,但通过 devtool 检查发现这些空格字符串仍会占用不少内存空间。最终,我们采取修改和移除 V8 对源码字符串引用的方式,彻底解决了源码字符串的内存占用问题。

QQ 作为一款 IM 工具,会涉及到大量的图片收发。然而,图片的渲染会占用相当大的内存。举个例子,一张分辨率为 4000 x 2750 的图片,结合设备屏幕像素和聊天区设计尺寸,只需渲染宽度为 567 像素的分辨率图像即可清晰展示。如果以宽度为 4000 像素的分辨率渲染,理论上两者位图所占用的内存大小差距可达 50 倍,并且还会因为渲染带来性能损失。

图片尺寸对内存影响举例

在聊天消息列表中的大部分图片仅仅起到预览作用,缩略图渲染就满足了需要。而仅仅在用户真正打开图片查看器放大查看时,才会需要用原图渲染。

实测在聊天中多张不同大尺寸分辨率图片在展示时,渲染进程和 GPU 进程的内存占用有着明显差别。在收发图片时,我们会根据屏幕设备信息和计算展示区域所需实际渲染分辨率,当原图分辨率超出计算所需值,则先调用压缩服务进行图片压缩,生成渲染所需分辨率的缩略图,并在聊天区域进行渲染上屏。在这个策略的优化下,一般聊天图片场景测试下来,使用缩略图比原图约有 30M ~ 50M 的内存优化。

QQ 优化图片上屏策略

可视区域按需渲染

在 DOM 元素使用数量我们也有严格的控制,总体采用”所见即占用“的 DOM 渲染策略。在 QQ 大面板中只有视口所见的内容才会渲染对应 DOM 元素。其他所有组件在不渲染展示时,均会移除组件及其 DOM 元素来避免其内存开销。

大虚拟列表控制 DOM 数量

尤其对于各个大列表模块,比如联系人列表和群成员列表,DOM 元素都非常多。最开始的内测版本中,使用有大量好友和群聊的 QQ 号,窗口平均 DOM 数达到 13000。我们将 QQ 所有的普通分页列表替换为虚拟滚动列表,并且对列表滚动 buffer 进行极限压缩甚至是 0 buffer 。由于不再一味采取空间换时间,没有 buffer 的情况下必然面对列表滑动性能挑战,因此也需不断优化各类 item 组件渲染性能。

此外,我们还通过精简组件 DOM 层级,移除非核心组件 keep-alive (重新优化渲染性能) 等方式,大账号使用下整体的 DOM 数量从 13000 减少到控制在平均 4000 以内,这部分优化减少约 20M 内存。

渲染图层方面,在渲染时满足某些特殊条件的渲染层,会被浏览器自动提升为合成层,达到提升渲染性能的目的。但是每个合成层都占用额外的内存,应当去掉过量且不必要的合成层来控制图层带来的内存占用。当然结合渲染性能考量,对于高频且列表等核心模块,是可以单独提升合成层。

QQ 对于渲染合成层的优化处理

在桌面端 QQ 中通过超级调色盘可以为进行色彩换肤,在这个场景中全局各模块有不少单独提升的合成层来实现毛玻璃、渐变和纹理效果。另外还有许多不经意间被提升的隐式合成层。通过对不必要的合成层进行移除与合并,整体也优化了约 9.3M 内存。

QQ 支持丰富的消息类型,从简单的文本、图文消息,到复杂的 lottie 表情、下图所示的业务可定制的结构化消息等。我们知道 JavaScript 是单线程的,这些消息同时上屏的时候可能会出现过长的上屏任务而导致 UI 卡顿,给到用户的感受就是切换消息列表卡顿,消息上屏慢等糟糕的体验。

新版 QQ 针对这类复杂消息上屏,使用了 JavaScript 事件机制结合 WebWorker 来实现消息异步上屏,并使用 OffscreenCanvas +  Worker 池绘制来提升渲染性能。

QQ 结构化消息的处理方案

为了在 Canvas 中实现 CSS 的 Flex 布局效果,我们采用了跨平台的布局解决方案,将 Yoga 编译成 WebAssembly 运行在 WebWorker 中。Yoga 官方编译采用的是 asm.js 的方案, 这种方案不支持动态分配内存,可以看到它默认分配了一个较高的内存,达到了 128M。

Yoga 渲染引擎的原始内存占用

为了优化 WebAssembly 的内存占用,我们调整了编译方式,将 Yoga 编译成独立的 wasm 文件,这种方式相比 asm.js 支持动态内存分配。同时结合聊天窗口的消息卸载策略,经过不断的测试调优,在既要保证初始内存较少又要尽可能避免内存爆发式增长带来的性能损耗的前提下,我们把 WebAssembly 的初始内存分配优化到 2M,再加上对象共享、享元模式等策略,WebWorker 的内存占用有了非常可观的优化。

QQ 结构化消息渲染引擎优化前后的内存占用对比

复杂的聊天消息虽然是必不可少的功能,但是实际的消息量还是远少于普通的图文消息,因此在保证用户体验的前提下,在合适的条件下适时销毁 WebWorker 是一个合理的策略,而随着 WebWorker 被销毁这个线程所占用的内存也能被完全释放。

性能与体验平衡

超级表情采用 Lottie 动画技术方案,有高清高帧率高质量特点,但同时也为我们带来了渲染的高成本。为了保证 Lottie 的高帧率和减少 CPU 占用,我们缓存了 Lottie 渲染器生成的动画帧,内存消耗成为了首要问题。

QQ Lottie 动画示例

对其进行定量分析,超级表情 Lottie 资源继承自手机 QQ,尺寸是 512 × 512,动画帧以 int8 数组存储,所以一帧动画为 512 × 512 × 4 / 1024 bit= 1024 Kb = 1Mb,一个普通大小的超级表情,例如庆祝表情,有 160 帧动画,依据缓存 9/10 帧动画的策略,庆祝表情会占用 144Mb 内存,虽然是可回收的,但也无疑是巨大的内存消耗。关注到 Lottie 渲染的内存消耗后,我们主要从以下 2 步入手:

通过以上 2 步,一共降低内存消耗 81.8%,庆祝表情从 144 Mb 降低到 35 Mb。

最后,旧策略对于渲染过且暂时不用的 Lottie 表情,会 buffer 它的第一帧,总共 31 个 Lottie 表情:2.3k * 31 = 7M(最多),经评估之后,我们暂时也拿掉了该策略。

QQ Lottie 动画缓存首帧对内存的影响

另外,桌面 QQ 左侧导航栏目,为了与移动端统一体验,使用 Lottie 动画来实现,从 memory 面板来看, 4 个 icon 导航条会占用约 6M 的内存。改用 CSS 实现,不仅效果与 Lottie 的几乎一致,而且这 6M 的内存占用就完全省掉了。

QQ 导航条动画对内存的影响

APNG 是一个基于 PNG 的位图动画格式,后缀名也是.png,在一些类似背景图的场景下会使用;在早期的超级调色盘中,为了实现最佳炫彩效果,选用了由 300 张 15KB 静态图合成的配色渐变的 apng 图片,其大小达到了 4.2M;不过带来的问题是渲染的延迟感;经过和设计讨论,在不影响效果体验的基础上,进行了大量的压缩,压缩到了 157KB;压缩率超过 96%。

聊天列表 AIO,作为 QQ IM 模块中最主要的承载消息数据展示模块,其滚动体验必然离不开用户体验与内存的权衡。

聊天列表在静态与滚动过程中,维持消息组件的数量多少决很大程度决定整个 QQ 的内存占用。消息数据从服务端拉取后会存储在本地 DB,根据策略会将当前会话的消息数据缓存在内存中。

随着滚动加载,消息缓存占用的内存也越多,所以也有一定动态阈值的策略,丢弃滚动方向相反的旧消息,从而将内存控制在可接受范围。如果用户重新操作又需要加载时,这请求底层向本地磁盘 DB 重新拉取。

QQ 聊天消息列表的加载策略

消息组件实例是内存占用的大户,每条消息组件内部包含头像 / 昵称 / 状态 / 内容等多个实例,如果不对消息实例进行回收销毁,每百条消息约能带来 20M+ 的内存增量,因此消息实例的回收策略尤为关键。

最早版本中对消息上屏没有丢弃策略,内存增量没有很好控制。于是采用分页列表,屏内保持固定几页消息(约 30 ~ 50 条消息,视屏幕尺寸决定),超过范围的消息进行丢弃,列表高度由屏内消息直接撑起,用户通过触顶或触底进行上下一页消息的加载。

但这页带来些点问题:一方面随着触顶触底,滚动条频繁跳动的体验并不好;另一方面列表高度由不定高的组件渲染消息来维持,不得不始终保留 30 ~ 50 条消息以撑起滚动高度,不可见消息的那部分便造成内存的浪费。

使用虚拟列表维持计算高度后,列表不再依赖保持真实消息内容的渲染,理论上我们可以将可视区域以外的消息实例全部销毁,仅保留用户可见的消息,最大程度地压缩消息实例数量,指保留很少的 buffer 消息实例。在实际滚动中由于消息实例在滚动过程被不断创建和销毁,占用主线程,影响 UI 绘制和用户输入。因此我们还做了:1. 对创建销毁做一定聚合,批量处理消息上屏。2. 精简优化单条组件的渲染性能。3. 不同滚动方向调整上下不同 buffer 大小 等等措施。4、会话切换和窗口聚失焦最小化等操作时对不再使用的消息资源内存进行主动回收。

QQ 聊天消息列表的上屏策略

滚动性能和内存占用之间需要取得平衡,既要最大程度压缩上屏消息数量以节省内存,又要保证滚动性能体验。然而经过优化后,本地测试加载 200 条混合种类的消息场景下,从空状态进入聊天会话中,消息列表内存增量从最多 44.2M 降至 6.1M,且滚动静止后内存不会任意增长。

Electron 使用姿势

Electron 给主进程提供了不少对系统能力调用的 API,如托盘、系统通知、macOS 中 dock 栏设置等。但是如果对这些 Electron 能力的使用方式不对,就可能导致不必要的大量内存占用甚至是泄漏。

比如 QQ 中,我们通过短间隔定时调用 Tray setImage API 来实现 QQ 托盘的闪烁,如果不注意传入 string Path 则会每次创建 Image 对象导致内存占用,正确的方式应该创建 NativeImage 并缓存,调用 Tray setImage 传入指定 NativeImage,避免反复创建 Image 导致的内存问题。

Windows 托盘图标内存泄漏定位

类似的问题还有在 macOS 中调用 API dock.setIcon 也会持续占用约 20M 的 CGImage 位图内存,正确的方案应该是不通过 Electron API 指定,而是通过打包 plist(属性文件) 指定 dock 栏图标。

macOS dock 图标内存泄漏定位

在使用 Electron 的过程中,还存在类似会导致内存问题的使用方式,我们需要结合客户端内存工具进行深度挖掘和分析,才能发现和处理这些问题。

消灭内存泄漏

我们知道 V8 有自己的垃圾回收机制,虽然它在 GC(垃圾回收)方面有着其各种策略,并做了各种优化从而尽可能的确保垃圾得以回收,但我们仍应当避免任何可能导致无法回收的代码操作。常见的例子包括:

以上是桌面 QQ 在早期遇到的常见问题。后续,我们通过代码检测手段来防范这类问题的出现。与一般的前端项目不同,由于桌面 QQ 的长周期使用特性,任何缓慢而微小的内存泄漏都可能被放大,这也是我们极力把控并阻止任何可能导致内存泄漏的代码引入的原因。

优化结果与线上监控

经过一系列组合优化之后,在我们自己的设备上来看,QQ 的内存使用基本是达标了,长时间挂机稳定在 300M 以下,在广大 QQ 用户侧能否保持这个水平?只有通过线上内存及性能的采集监控,才有数据指标来观测,从而才能对优化有效性进行验证和决定如何调整优化方向。好在 Electron 提供了 app.getMetics 、 process.getMemoryInfo 等 API 来采集内存指标。但需要注意的是这些 API 所采集返回的内存值的真实含义,如 getMetric 所采集的到 workingsetSize 和 privateBytes 均不是任务管理器用户所看到的内存。

这里我们通过 patch 定制改造 Electron getMetics API,来增加不同平台任务管理器的内存类型的指标,并且采集包含了主进程、渲染进程、GPU 进程和工具进程等所有内存指标。

为了避免频繁采集上报内存指标所带来的的性能消耗,我们设定了一定时间的采集间隔,同时针对使用场景的采集做了抽样。并将渲染进程 pid 映射寻找窗口名,只在若干次采集后再做聚合计算,通过 SDK 上报到 prometheus + grafana 的指标观测平台。

QQ 内存监控整体方案

经过若干次内存性能优化的迭代, 目前从线上数据指标来看, 新版 Windows QQ 运行的内存在主场景下基本控制在 300M, 这个值已经基本达到我们设定的目标。

从登录后使用过程中的内存指标如下:整体应用的内存平均占用约为 228M;其中中位数占用约为 211M,90% 分位用户内存占用约为 350M。

QQ 线上内存监控视图

当然,这个目标只是阶段性的,我们还会持续针对更多使用场景进行内存优化。

防劣化与自动化测试

为了持续关注和保证新版 QQ 项目的性能达标且不劣化,除了比较常规的单元测试、代码检查、代码评审机制、框架内置一些开发规范等手段外,我们还在建设一个防劣化平台,主要通过自动化的端对端 (e2e) 测试来持续监控项目集成后的性能变化。

[防劣化机制示意图]

这一套机制之前在 内测中的 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 的技术方案。