Electron在新版QQ中的实践与优化
技术背景
老QQ存在的技术债
- 多端代码不统一
初版的QQ采用纯原生的方式进行开发,大小在200K左右,在后期的智能设备的普及下,开始像移动端、桌面端做转型,针对三大操作系统陆续组建了三个不同的开发团队,各自负责对应的技术代码;
- 多端功能不一致
旧版的额桌面端QQ,Windows 的功能最丰富,Mac OS 次之, Linux 功能非常简洁;这样会不利于用户对于QQ的统一认知,因此功能的统一迫在敏捷
初版的重构 → QQ NT项目
QQ NT项目是在 2022 年 3 月份正式启动, Mac OS QQ 在 6 月份开始发布内测, 9 月份正式上架了 App Store,迭代了几个版本之后,QQ 团队就同步开发 Linux。
在 2022 年,QQ 发布了新的 macOS 和 Linux 版本,包括 QQ 后台其实也做了很大的改变和重构,核心系统做了全新重写,云原生成熟度也得到了很大的提升。
在目前全新的框架设计下,无论是核心系统、功能迭代还是设计语言上,都可以尽可能地"原子化",来让 QQ 后续更好地迭代功能。
重构路上的挑战
业务功能上的挑战
历史原因,项目自身有很多的历史功能,如何取舍、如何重构、如何拓展等就具有相当的挑战,小小的改动有可能就会带来很激进的用户反馈
技术重构上的挑战
多端技术栈场景下的功能尽可能统一的挑战也相当大,尤其是Linux上
技术选型到Electron的原因
相比于QT、RN、Flutter、Webview2等,会有技术栈深入的问题考量,同时还需要考虑发展问题;有些框架技术,虽然它很热,但我们历史上踩过了很多很多非标准化的坑,一旦某个技术栈热度一过、维护力度不够,它就会成为全新的负债,做选型时必然也是避免再有类似经历。
选择Electron主要基于以下几点考量
- 首先最看重的是框架成熟度和技术栈的标准化
Electron是基于Web技术的,上手成本很低,且对于现有的代码有较高的复用率,不太需要太多的成本去做基建和周边工具的建设,之前的RN、Flutter的实践上就有类似的情况,同时由于其对于Web技术的支持,使得后续的版本迭代和重构效率有很大的提升;
- 其次是技术经验及人才储备
Qt 的确在性能上是一个很好的选择,但目前团队对 Qt 没有太多积累,基建基本没有,而且相关人才其实比较匮乏,招聘就更难了。 而当前 QQ 技术团队 Web 前端团队还是有比较多的积累,在 QQ 频道项目中,也完整验证了 Electron 的技术可行性。
- 最后就是 Electron 具备的桌面端跨平台的优势
QQ NT 架构并不是仅指 Electron,Electron 主要是作为 UI 跨平台的框架,只是占比很小的一部分,并且 QQ 桌面端不是全部用 Electron 实现,QQ NT 最核心的部分还是 QQ 底层通用抽象的模块,称之为 NT 内核,包括核心登录、消息系统、关系链、富媒体、长连接、数据库等等模块,完全用 C++ 实现,全平台通用。因此底层是完全跨平台的架构,而 Electron 只是上层桌面端 UI 跨平台较薄的一层。
在内存方面的优化实践
可以在于一系列的针对性的优化策略,包括缓存策略、按需加载、优雅降级等,同时配合各种分析工具、线上监控、自动化测试手段等来组织性能的退化
新版QQ在内存上的挑战
- 产品形态
在常规情况下,由一个复杂的大面板(100+复杂程度不等的模块)和一系列独立功能窗口构成。窗口和渲染进程一一对应,窗口进程数很大程度上影响了Electron的内存占用;
- 使用习惯
用户长时间挂机。相比用完即走的 Web 页面,QQ 用户在一次登录后,可能会挂机一个月以上。这段期间,如果没有控制好 QQ 内存使用,那么结果可能是内存越占越大、用户交互响应变慢、甚至发生闪退。
- 版本迭代
需要快速的补齐不同版本间的核心功能,同时一些较高优先级的新功能也需要同步产出,快速的新旧需求的迭代有可能也会引起新的问题导致性能劣化;
- 应用框架
新版 QQ 依赖一个 NT 核心数据模块(C++ addon),为 UI 提供本地化的数据服务。QQ 的加载体验能做到如此丝滑,这个模块起到了至关重要的作用。 同时,与 NT 的联动优化,也需要拉通客户端 C++ 开发同学共同完成。当然,会存在一些沟通成本,但不可否认,能把内存占用压下来,客户端同学也付出了非常多的努力。
新版QQ的内存优化目标
AIO是指聊天面板的简称
- 第一阶段:
单进程内存<300M
- 第二阶段:
单进程 < 100M,整体 < 300M
- 需要额外重点关注的点
- node:Electron 的主进程,负责窗口管理、跨进程通信等。包含 NT 核心数据模块,负责与服务端交互,为 UI 提供数据服务;
- renderer:Chromium 内核的渲染进程,负责渲染 UI、提供用户交互等。QQ 启动后,会有 2 个渲染进程:一个是 QQ 大面板,另一个是主进程的窗口池。窗口池是预创建的一个渲染进程。在新开窗口时,可以减少等待时间;
- gpu:Chromium 内核的 GPU 进程。它的主要作用是处理与图形相关的任务,例如渲染网页、播放视频、执行动画等。
具体的优化方案
分析工具侧(不仅仅包含V8的JS部分,还有很多NT 核心数据模块(C++ addon)
的C++模块)
需要使用不同维度的内存分析工具,从 V8 引擎到进程,再到整个应用程序,打通整个链路进行多角度的细节分析,以此来定位内存使用的瓶颈。
定向优化
在通过工具定位到问题之后,我们会采取一系列的针对性优化策略,包括缓存策略、按需加载、优雅降级等。
同时也需要进行项目整体的代码压缩瘦身、静态资源优化、分包和按需加载等优化措施配合实现;
- 第三方包或者SDK,一般会包含完整的能力,但是在某些场景下是不需要全量引入的,因此在这些资源没有提供按需引入和Tree-Shaking等技术前提下,就需要进行定制化裁剪或者单独实现了,从而减少项目依赖资源的代码体积;
- 项目自身方面需要配合
分包
策略来实现,而分包策略不会完全按照某个规定的方面进行定制,而是按照场景模块来进行细粒度的定制; - 其他静态资源可以使用按需加载的策略,可见时加载,不可见时主动回收和销毁
窗口池方案浅析
- 窗口池中预启动的窗口页面只加载必须执行的基础代码;
- 当打开具体窗口时加载对应的路由后页面入口代码;
- 当具体使用不同功能时动态加载(如点击搜索、打开表情面板、转发消息激活好友选择器的时候才会分别加载对应功能模块代码)
线上监控
在本地进行验证通过后,需要进行线上大范围的实际用户场景验证,从而根据实际的用户反馈来判断是否进行全量发布;
可以通过Electron提供的
app.getMetics
、process.getMemoryInfo
等api来采集内存指标,但是需要注意对应的api采集的数据的准确度
,有些不一定是你需要的数据
防止性能退化和自动化测试
除了比较常规的单元测试、代码检查、代码评审机制、框架内置一些开发规范等手段外,还建设一个防劣化平台,主要通过自动化的端对端 (e2e) 测试来持续监控项目集成后的性能变化。
- 定时对主干上集成构建的程序进自动化 e2e 测试;
- 除了对功能的冒烟测试外,针对重点关注的性能指标,构造了对应的帐号和环境,编辑特定的用例,用于采集性能指标;
- 通过将采集和采样的指标上报到防劣化的监控平台,来监控项目集成后的性能变化,如会话切换响应时间、内存占用、CPU 使用率等;
- 监控平台提供按版本和时间的指标曲线、对比,方便查看和分析性能变化情况。同时打通企业微信机器人,对性能指标情况进行实时推送告警。
Electron在vivo中的实践总结
技术背景
因业务发展,需要用到桌面端技术、技术特性涉及离线可用、调用桌面系统能力等要求;
实践细节
常用开发技术栈选型
- 构建工具选型
主要有electron-builder 和 electron-forge,最终采用了Electron-Forge进行实现,原因就是其简单又强大
- monorepo方案选型
主要原则就是
集大成法
,即取各家之长,如pnpm擅长依赖管理、turbo擅长构建任务编排
- 本地数据库选型
Electron应用数据库有非常多的选择如 lowdb 、 sqlite3 、 electron-store 、 pouchdb 、 dedb 、 rxdb 、 dexie 、 ImmortalDB 等。这些数据库都有一个特性,那就是
无服务器
;其部分细节对比如下:
- lowdb:生态、能力、性能三方面表现优秀, json 形式的存储结构, 支持 lodash 、 ramda 等 api 操作,利于备份和调用;
- sqlite3:生态、能力、性能三方面表现优秀, Nodejs 关系型数据库第一选择方案;
- nedb:生态、能力、性能三方面表现优秀,缺点是基本不维护了,但底子还在,尤其操作是 MongoDB 的子集,对于熟悉 MongoDB 的使用者来说是绝佳选择;
- electron-store:生态表现优秀,轻量级持久化方案,简单易用。
- pouchdb ,如果需要将本地数据同步到远端数据库,可以使用 pouchdb ,其和 couchdb 可以轻松完成同步
二进制文件(如ffmpeg)构建浅析
在开发桌面端程序时,难免会遇到需要引用第三方的二进制程序,如ffmpeg等,但需要注意以下两点:
- 二进制程序不能打包进asar中,可以在构建配置文件中进行配置
js
// forge.config.js
const os = require('os')
const platform = os.platform()
const config = {
packagerConfig: {
// 可以将 ffmpeg 目录打包到 asar 目录外面
extraResource: [`./src/main/ffmpeg/`]
}
}
- 开发和生产环境,获取二进制程序的路径方法是不一样的,可以通过如下方法进行实现
js
import { app } from 'electron'
import os from 'os'
import path from 'path'
const platform = os.platform()
const dir = app.getAppPath()
let basePath = ''
if(app.isPackaged) basePath = path.join(process.resourcesPath)
else basePath = path.join(dir, 'ffmpeg')
const isWin = platform === 'win32'
// ffmpeg 二进制程序路径
const ffmpegPath = path.join(basePath, `${platform}`, `ffmpeg${isWin ? '.exe' : '.org'}`)
- 按需构建,防止将不必要的终端构建结构也打到构建文件中
js
// forge.config.js
const os = require('os')
const platform = os.platform()
const config = {
packagerConfig: {
extraResource: [`./src/main/ffmpeg/${platform}`]
},
}
性能优化
- 构建体积的优化
asar中的文件就是构建后的项目代码,electron 构建机制中,会自动把 dependencies 的依赖全部打到 asar 中。
- 相关原则
-
将 web 端构建所需的依赖全部放到 devDependencies 中,只将在 electron 端需要的依赖放到 dependencies
-
将和生产无关的代码和文件从构建中剔除;
-
对跨平台使用的二进制文件,如 ffmpeg 进行按需构建(上文按需构建已介绍)
-
对 node_modules 进行清理精简
- 对于项目是通过
yarn
安装的依赖可以通过如下命令实现
jsyarn autoclean -I yarn autoclean -F
- 对于项目是通过
-
版本更新
-
全量更新
全量更新就是通过下载最新的包或者 zip 文件,进行软件更新,需要替换所有的文件。
架构流程图
- 开发服务端接口,获取最新的版本信息
- 渲染进程通过接口获取最新版本信息,封装更新逻辑,通过返回的版本信息判断是否通过IPC通信通知主进程实现更新
- 主进程检测渲染进程的消息,通过
Electron-updater
进行全量更新 - 将更新信息通过IPC推送给渲染进程
- 渲染进程向用户展示更新信息,若更新成功,则弹出弹窗告知用户重启应用,完成软件更新
-
增量更新
增量更新是通过拉取最新的渲染层打包文件,覆盖之前的渲染层代码,完成软件更新,此方案只需替换渲染层代码,无��替换所有文件。
- 开发服务端接口,获取最新的版本信息
- 渲染进程通过接口获取最新版本信息,封装更新逻辑,通过返回的版本信息判断是否通过IPC通信通知主进程实现更新
- 主进程检测渲染进程的消息,拉取线上最新包
jsimport { app, autoUpdater } from 'electron'; import isDev from 'electron-is-dev'; if (!isDev) { const serverUrl = 'https://your-update-server.com'; // 替换为实际的更新服务器地址或 GitHub Releases 地址 autoUpdater.setFeedURL(`${serverUrl}/update/${process.platform}/${app.getVersion()}`); // 启动自动检查更新 也可通过渲染进程接口请求方式获取 autoUpdater.checkForUpdatesAndNotify(); }
- 将更新信息通过IPC推送给渲染进程
jsautoUpdater.on('update-available', () => { console.log('发现新版本'); }); // 确认下载完成后触发更新逻辑 此处可以进行用户交互拦截逻辑 autoUpdater.on('update-downloaded', () => { autoUpdater.quitAndInstall(); });
- 渲染进程向用户展示更新信息,通过用户行为判断是否进行下载和安装对应的更新包
性能优化
-
启动时优化
- 使用v8-compile-cache缓存编译代码
- 优先加载核心功能,非核心功能动态加载
- 使用多进程、多线程技术
- 采用asar打包,加快启动速度
- 增加视觉过度
-
运行时优化
- 渲染进程进行Web性能优化
- 对主进程进行轻量瘦身
核心方案就是将运行时耗时、计算量大的功能交给新开的 node 进程去执行处理。
jsconst { fork } = require('child_process') let { app } = require('electron') function createProcess(socketName) { process = fork(`xxxx/server.js`, [ '--subprocess', app.getVersion(), socketName ]) } const initApp = async () => { // 其他初始化代码... let socket = await findSocket() createProcess(socket) } app.on('ready', initApp)