用静态链接Qt库的方式使用Qt(上)

文中涉及的静态链接Qt的方法,都是仅限于在Windows下的情况。

首先说点废话套话。用Qt框架写GUI程序,要做出来能在其它电脑上运行的软件包是一件麻烦的事情。编译生成出来的程序通常情况下要带一大堆Qt的DLL文件,还需要用特定的目录结构组织起来。Qt虽然提供了windeployqt.exe可以一条命令就能把Qt应用程序依赖的Qt库复制到程序所在的目录,能保证程序正常运行,但是对一些简单的项目来说,程序目录下带了一堆DLL文件和qm文件一点也不简洁,编译出来的程序只有单个可执行文件,只复制一个EXE就是要比EXE附带一大堆DLL要简单。要让使用Qt的程序不依赖Qt的DLL就能运行,就得想办法把Qt库静态链接到程序中与程序合为一体。

软件许可协议问题

虽然代码的许可协议在天朝几乎等同于废纸,但是还是强调一下静态链接带来的法律风险。

Qt提供了两种许可方式:商业许可证或者LGPL许可证。在LGPL协议的情景下引用Qt的代码,动态链接Qt库时,自己的项目是闭源的没有任何的问题,只是修改了Qt代码时要把相应的修改也开源出来;静态链接Qt库时,避免违反协议要做的事情就麻烦的多,按照GNU FAQ(https://www.gnu.org/licenses/gpl-faq.en.html#LGPLStaticVsDynamic)的说法,闭源软件可以静态链接LGPL协议的库,但是必须提供途径使得用户可以方便地把LGPL库的修改链接到闭源的代码上,重组成新的程序;实际操作上就是要把调用LGPL库的代码都公开,并且公开代码生成出来的所有.obj文件或者.o文件,还要提供链接生成新程序的链接命令;这套操作实践起来是如此的困难以至于从省事的角度上不如直接将源码公开。必须有静态链接的需求,开发的还是闭源软件,又不差钱的情况下,最省事的方式就是向The Qt Company买Qt许可证为Qt的发展做贡献了,Qt的商业许可证完全没有LGPL这些条条框框的限制。

因此要静态链接Qt库到自己的项目时,请务必考虑这样做会带来的法律风险。

编译静态库形式的Qt库

Qt提供的安装包默认是没有静态库形式的Qt库的,编译成静态库的Qt库需要自己来编译。事实上动态库形式的Qt库确实也能满足绝大部分的需求,但咱们现在必须要静态库形式的Qt库,就是看Qt5Core.dll这堆DLL不顺眼。所以接着往下写。

我这里编译的Qt库的版本是5.9.8,在5.12版成为LTS版本之前,是最新的LTS版本。

获取Qt的源代码

虽然通过Qt的安装程序就自动安装Qt的源代码到电脑上,但是要考虑到Qt是一个巨大的项目,编译生成出来的中间文件大小也是非常可观不可小视的,我这里没有编译QtWebEngine,编译成功后源代码的目录大小就超过了10GB,之后install出来的Qt静态库所在目录的大小也有将近5GB,要编译QtWebEngine就涉及到了Chromium内核,占用的空间只会更大。而Qt安装程序会默认把Qt安装到操作系统所在的分区,由于磁盘空间不够编译失败是一件很尴尬的事情,所以建议把源代码复制到磁盘空间够的地方来编译,或者单独下载源代码然后解压到空间够的地方。

这里先下载Qt的源代码压缩包,然后解压:
https://download.qt.io/official_releases/qt/5.9/5.9.8/single/qt-everywhere-opensource-src-5.9.8.zip

编译的环境要求

Qt的这篇文档指出了Windows环境编译Qt库所需的工具:https://doc.qt.io/qt-5.9/windows-requirements.html

编译过程依赖的工具有:
– ActivePerl: 不太推荐5.28和以后的版本,5.28版废弃了一些Perl模块会导致编译不成功,编译OpenSSL 1.0.2的时候用了5.28版,在configure时会出错。
– Python: 用的是Python3,编译QtWebEngine时编译脚本不支持Python3,不编译QtWebEngine我这里用Python3是没有问题的,不过2020年Python2就停止维护了,Chromium的depot_tools工具集里面依赖的Python2什么时候能换掉啊!
– GPerf、Bison、Flex:Qt依赖的ANGLE库编译过程中使用了gnuwin32项目里的这些程序。为了编译的方便,Qt的源码包里默认提供了这些程序,在gnuwin32/bin目录下。
– OpenSSL: Qt使用OpenSSL提供对TLS的支持,Qt5.9只支持OpenSSL 1.0.2版,但是Qt5.12支持OpenSSL 1.1和更高的版本。

编译前请务必保证所有所需的工具加入到了PATH环境变量中。

Qt5.9在Windows下支持用MinGW/Clang/MSVC(2013/2015/2017)编译器编译,这里我用了vc2017的x86工具集编译Qt库。

Configure和编译

Qt的文档给出了在Windows下从源码构建的具体步骤:https://doc.qt.io/qt-5.9/windows-building.html

  • 先打开VC命令提示符,因为我要用vc2017工具集生成x86版的Qt静态库,所以我在开始菜单里选Visual Studio 2017/x86 Native Tools Command Prompt for VS 2017这个快捷方式打开命令提示符,这个快捷方式会运行vcvarsall.bat帮你把msvc对应的工具集配置好。

  • 把编译依赖的工具所在的路径加入到PATH环境变量,假设源码被解压到了C:\qt5-src

    SET PATH=C:\qt5-src\qtbase\bin;C:\qt5-src\gnuwin32\bin;%PATH%
    
  • 切换到源码目录,用下面的命令来配置编译所需的工具集:
    configure -static -skip webengine -opensource -debug-and-release -no-pch -nomake tests -nomake examples -force-debug-info -platform win32-msvc -opengl desktop -prefix F:/Qt-5.9-static-msvc2017-x86
    
    • 解释下configure命令的参数:
    • -static: 把Qt库编译成静态库。
    • -skip webengine: 不要编译QtWebEngine。文中也没有涉及这个模块的编译。
    • -opensource: 向configure脚本声明我们要按LGPL协议的要求使用Qt库。
    • -debug-and-release: 编译生成debug版和release版两份Qt库(windows only)。
    • -no-pch: 编译过程中不使用预编译头。
    • -nomake tests: 不要编译Qt的测试用例。
    • -nomake examples: 不要编译Qt带的示例程序。
    • -static: 把Qt库编译成静态库。
    • -force-debug-info: 为Release版的Qt库生成调试符号(编译成静态库大概不需要,去掉应该也可以)。
    • -platform win32-msvc: 用MSVC编译器编译Qt库。
    • -opengl desktop: 使用操作系统提供的OpenGL API。
    • -prefix F:/Qt-5.9-static-msvc2017-x86: 编译成功然后运行install命令时把Qt库安装到F:/Qt-5.9-static-msvc2017-x86这个目录。
    • 要添加TLS的支持,需要在configure脚本里指定预先编译好的OpenSSL所在的目录。如何编译OpenSSL1.0.2版可以参照这篇博客:http://p-nand-q.com/programming/windows/building_openssl_with_visual_studio_2013.html
    • 添加参数的方法是configure参数里加上这些:
      -ssl -openssl-runtime -I "<path-to-openssl-1.0.2>/include"

      • 参数的含义是:生成出的二进制文件要支持TLS;使用动态库形式的OpenSSL库,程序运行时会动态加载OpenSSL的DLL;把OpenSSL头文件所在的目录加入到configure的环境变量中。
    • configure脚本可以加的参数不止这些,输入configure --help 能看到所有参数对应的帮助。
  • 输入configure命令后脚本会提示你是否同意LGPL协议,输入y同意(再次请注意法律风险!):
    Microsoft (R) Program Maintenance Utility Version 14.16.27034.0
    Copyright (C) Microsoft Corporation.  All rights reserved.
    
    
    This is the Qt Open Source Edition.
    
    You are licensed to use this software under the terms of
    the GNU Lesser General Public License (LGPL) version 3
    or the GNU General Public License (GPL) version 2.
    
    Type 'L' to view the GNU Lesser General Public License version 3 (LGPLv3).
    Type 'G' to view the GNU General Public License version 2 (GPLv2).
    Type 'y' to accept this license offer.
    Type 'n' to decline this license offer.
    
    Do you accept the terms of either license? 
    
  • configure完成时脚本会输出一份编译Qt设定选项的摘要,可以核对下是否符合自己的想法:
Configure summary:

Build type: win32-msvc (i386, CPU features: sse sse2)
Configuration: sse2 sse3 ssse3 sse4_1 sse4_2 avx avx2 compile_examples f16c force_debug_info largefile debug_and_release release debug build_all c++11 concurrent dbus no-pkg-config release_tools static stl
Build options:
  Mode ................................... debug and release (with debug info); default link: debug; optimized tools
  Optimize release build for size ........ no
  Building shared libraries .............. no
..........................
Qt Multimedia:
  ALSA ................................... no
  GStreamer 1.0 .......................... no
  GStreamer 0.10 ......................... no
  Video for Linux ........................ no
  OpenAL ................................. no
  PulseAudio ............................. no
  Resource Policy (libresourceqt5) ....... no
  Windows Audio Services ................. yes
  DirectShow ............................. yes
  Windows Media Foundation ............... yes
  Media player backend ................... DirectShow

Note: Using static linking will disable the use of dynamically
loaded plugins. Make sure to import all needed static plugins,
or compile needed modules into the library.

Note: No wayland-egl support detected. Cross-toolkit compatibility disabled.

Qt is now configured for building. Just run 'nmake'.
Once everything is built, you must run 'nmake install'.
Qt will be installed into 'F:\Qt-5.9-static-msvc2017-x86'.

Prior to reconfiguration, make sure you remove any leftovers from
the previous build.
  • 之后就是编译流程,输入命令:
nmake

或者可以用Qt文档介绍的jom工具让CPU核心得到充分利用,输入命令:

jom -j <core count>

Qt是个庞大的项目,编译时间当然也会非常漫长。电脑的CPU是八核十六线程的锐龙,编译大概花了半个多小时……

  • 编译成功后,输入命令nmake install把生成出来的Qt静态库安装到configure脚本指定的目录。
    用jom工具时,命令改成jom install

编译这就完成了。在安装目录的lib子目录下就能看到生成出来的Qt静态库,Debug版的和Release版的都有。

Qt代码量虽然庞大,但是整个编译过程并没有脱离configure -> make -> make install这个套路。难点还是指定好正确的configure参数和安装好所需的依赖,只要按照文档配置好是没有什么阻碍的。

其实真正难受的地方是在使用Qt静态库上面,Qt文档只说了如何在qmake项目里使用静态库的Qt,Qt提供的CMake脚本并没有考虑到静态库的情况,这个之后再介绍踩的坑。

参考资料

感谢一些博客和Qt社区帖子对这篇文章和我想办法编译静态Qt库提供的帮助:

下篇 用静态链接Qt库的方式使用Qt(下)

给Electron做C++开发的那些坑

Electron这东西太火了,客户端程序全面Electron化DSSQ不可避,几乎所有新上线的客户端程序你打开安装目录都能看到熟悉的app.asarelectron.asar。在前东家干活做客户端,项目经理也执意要上Electron框架,说有那么多成功案例了,还有你看看咱公司别的项目组用Electron做的项目多漂亮,这东西一定很好用;而且你看公司这么多前端高手,你写界面多费劲啊,界面这边前端全包了就行了,你只要把后面的cpp代码维护好,给前端提供合适的API就行,你看怎么样。我的心情那是十万头羊驼呼啸而过。因为眼前的这个项目不仅仅是要用Electron,最终生成出来的程序还要在一套代码的基础上在Windows/Linux/Mac平台上全都能正常编译运行,甚至还要在某些机构因为安全可靠原因定制的Linux上跑。用Electron这就算了,这涉及到多个语言的交互,一套代码还要在三个平台上跑,Linux上甚至缺依赖,这坑不是一般的大啊!可是经理执意认为他的想法是正确的,毕竟那么多成功的案例(?),必须得走这个道路。虽然心中一万个不愿意,然而为了讨生活,只好硬着头皮按照项目经理的思路把项目推进下去。

最终基本达成需求的客户端是做出来了,也成功地实现了跨平台,在”安全可靠”的linux发行版上也能正常运行。客户端是在Electron框架下Web和C++代码混合的架构,Web代码负责界面展现,c++代码包揽了与服务端通信、存储本地数据的工作。做出来的成果虽然能用,但是无论是界面还是后台处理的代码,里面都有很多不尽如人意的地方,给客户展示的程序没有做到差强人意,直到我离职的时候,还有很多缺陷亟待解决。

同样,在开发和测试的过程中,也毫无悬念地踩了许多的坑,更发生了许多不愉快的事情。这里把当时遇到的坑和教训写下来做备忘,分享出来。

文章的内容都是基于自己的体验所写,随着文中涉及项目版本的迭代,事实情况可能有变化,所以仅供参考。

编译你的C++插件

写c和C++程序,编译是一道绕不过去的坎,所以首先来讲编译。

用过Electron的人应该都知道,Electron大体上是Node.js和Chromium的缝合怪。我的理解是让Node.js和Chromium共享同一个V8脚本引擎,然后再导出一些和客户端相关的API,例如操作窗口、任务栏等等,把这些都完成了,Electron这东西就做出来了。Chromium和Node.js大部分的代码都是用c++实现的,所以理所当然地也可以用C++为它们开发插件。

然后Electron给出的解决方案是什么呢?在Eletcron文档里搜native这个字眼,找到了这个:https://electronjs.org/docs/tutorial/using-native-node-modules,所以Electron给的方案是,你可以写Node.js的插件为你的客户端提供后台支持,写好的Node插件构建的时候链接到Electron带的Node.js实例上,你的JS代码就能调用你用C++写的插件了。那么再看看怎样用C++给Node.js写插件吧,你就找到了这个文档:https://nodejs.org/dist/latest-v10.x/docs/api/addons.html,它告诉你,给Node.js写C++插件需要用到node-gyp这个工具,这个工具调用的是gyp这个google写的构建工具来构建你的伟大的C++插件。之后你照着Node.js文档给的示例,成功写出了你的第一个Node插件;然后又照着Electron的文档把你的Node.js插件链接到了Electron上,之后又写了个HTML给Electron用,里面成功调用了你写的插件。

很有成就感,很轻松,是不是?先别激动,Node.js给的示例太简单,里面只寥寥几句说了gyp这个工具应该怎么用,实际的需求一定会有坑,官方文档是不可能面面俱到全部解答的。

就这样你开始写项目了。假如你负责的用了Electron的东西要在Windows上跑,然后你需要调用DirectShow来操作摄像头捕获自定义分辨率的图像。虽然Chromium自身就带了摄像头的支持,但是功能很有限,不能用来完成要求你去实现的需求,所以你不得不去用DirectShow(当然DirectShow过时了微软推荐用Media Foundation)。用DirectShow你必须在最终的程序里链接这两个库:quartz.libStrmiids.lib。怎么链接这两个静态库?Node.js没告诉你。既然如此那就去找GYP这个工具的文档吧。google第一条,https://gyp.gsrc.io,好!点开看了,你绝望地发现,google对你的疑问做出的答案是四个英文字母:TODO。玛德这文档里一坨坨的TODO和没写有什么区别吗?

必须要用node-gyp,还有最后一条路可走,那就是看GYP的源码找源码里的设定,但是GYP是用Python2写的,假如你不会Python那这就是个很大的困难,更何况Python2要被抛弃了。你的时间很有限的话,也不可能投入大部分精力去hack GYP这个没文档的东西。

绝望的你把目光投向了另一个著名的跨平台构建工具:CMake。CMake是个好东西,文档比GYP不知道强多少倍了,好多C++开源项目也在用CMake作为构建工具,这样也方便把第三方库集成进我们的项目,开心!但是还有一个问题,怎么让我们的Node插件正确地链接到Electron上?在npm库里找cmake的字眼,找到了个cmake-js模块,这个模块在介绍里把GYP毫不留情地损了一顿,然后开始推广CMake的种种好处,最后说明了下自己为帮助CMake项目链接到Node.js/Electron上做了什么样的努力,在configure你写的CMakeLists.txt时,cmake-js在CMake环境里自动帮你配置好了Node.js的头文件和链接库的全局变量,只要在CMake脚本里加上这两个环境变量就可以。看起来还不错。

于是你尝试用cmake-js构建你的插件,代码规模小的时候似乎还不错。代码量开始增长,需要重新组织项目结构的时候,事情突然变得微妙了起来。cmake-js把CMake的环境变量CMAKE_RUNTIME_OUTPUT_DIRECTORYCMAKE_LIBRARY_OUTPUT_DIRECTORY都给改写成了与node-gyp类似的形式,cmake-js的用意大概是想让插件直接生成到指定的目录下。但是假如你的插件项目是依赖cmake的install命令来安装,cmake-js的这个举动就会给你带来麻烦。

cmake-js还有个毛病,给node.exe构建c++插件的问题算是解决了,但是给electron构建插件,它用了一种丑陋的方式。要对特定的Electron版本构建插件,需要在package.json里定义一个”cmake-js”字段,里面写上node runtime的类型是electron,然后指定个版本号,这样cmake-js才能正确地构建出指定版本electron可用的二进制文件,如果你的整个项目需要更新Electron版本,你就需要同时改两个地方,有点麻烦同时又有遗漏的可能。

最后我参与的项目用了这样的构建路线:代码是用CMake组织起来的,因为一些库是动态链接到插件主库,需要自己来设计最终生成文件的目录结构,用到了CMake的install命令。这样node-gyp和cmake-js都不好用了。处理链接Node.js的问题时,我在CMake脚本里声明了外部变量,configure的时候必须正确指定Node.js头文件和链接库所在的路径。需要获取Node.js或者Electron的头文件和库时,node-gyp提供了node-gyp install命令能把指定版本的Node.js头文件和lib文件下载到在特定的目录,windows环境下保存的目录在%LOCALAPPDATA%\node-gyp\Cache\<版本号>,下载成功后就把头文件和静态库保存的目录传给CMake脚本。为Electron构建插件也是同理,只不过命令换成了node-gyp install --target=4.2.9 --arch=x64 --dist-url=https://electronjs.org/headers。为了方便,还写了一个JS脚本按顺序调用node-gyp和CMake把整个的构建过程封装了起来。

这样构建确实麻烦,但是假如你不决定把你的C++插件上传到npm库供所有人使用,这种构建方式对最终的用户没有任何影响,因为用户能接触到的只有二进制文件。

我的感觉是,给Node.js/Electron写C++插件,项目大到一定程度时,真正靠谱的只有自己手动处理Node.js实例的链接问题。node-gyp给开发者提供的支持太少,而cmake-js又包办了一些不该包办的东西,专门给Electron开发插件时也不那么好用。只有自己手动处理依赖才是最灵活的方式。手动解决依赖在你需要为Electron打补丁时,处理起来也最方便。

在Electron环境下调试C++插件的代码

用Electron写客户端,假如涉及到C/C++代码,大多数情况是写C++库的负责实现功能然后导出API,前端工程师负责实现界面,然后调用c++库导出的JS API。毕竟这就是Electron所谓的长处么,Web技术写界面厉害,效果非常绚丽,那就用Web写;C++性能高,和操作系统交互方便,有这些需求的代码用C++写。两者都能发挥自己的长处,听起来似乎非常美好。

然而又懂C++又懂JS框架的人凤毛麟角。所以通常情况下界面和后端肯定是要不同的人分别推进的。那么在c++这边,给JS提供的API实现好了,怎么调试代码来保证功能是正确实现的呢?这情况有点像写Web系统,只不过做Web系统给前端工程师们的东西不是一个HTTP URL,给Electron做C++插件给是给前端工程师用JavaScript能直接调用的接口。HTTP API还好说,有个Postman神器(居然也是用Electron框架做的!),用来测试HTTP接口非常好用。可是直接给JS API就没那么简单了。

按照Electron的思路,API实现完了,想要在Electron环境下测试API,大概流程是这样的:Electron是个GUI框架,意味着要测试接口,你得先用Electron画出个GUI出来,点界面里面的按钮来测试;具体实现是首先要写个index.js,里面让Electron创建一个浏览器窗口,之后让新建出来的浏览器窗口加载一个网页,网页里把按钮画出来,在网页附带的js脚本里再调用实现好的C++函数;之后要导出新功能的时候,继续在网页里加按钮,然后按钮调用新加的方法;每次新加功能要测试的时候都要在测试脚本(网页)里修改两个以上的地方,真的很麻烦。C++代码出错了要现场调试附加到Electron的时候,要找到已经加载C++插件的进程也很麻烦:插件是在主进程中加载时比较好办,要调试的进程就是为了测试而启动的那个进程;插件是在html中直接引用的时候,加载C++插件的Electron进程是渲染进程,这时候就麻烦了,你不得不去挨个试Electron主进程fork出来的子进程,看哪个加载了自己写的C++模块,或者Windows下去用Process Explorer去看。之后才能开展调试的工作。

只用c#/c++做客户端的时候,我心中理想的调试方式是在Visual Studio里面加好断点,按F5,等代码编译完,之后就可以开展调试的工作,很舒服。那么调试给Electron写的C++代码,真的就不能做到这样轻松吗?虽然是给Electron写插件,但是我们调用的都是Node.js的API,也一直是参照Node.js文档给的指导写的C++代码,所以完全可以为Node.js编译写好的C++代码嘛!给Node.js写C++插件,要测试的时候只要写好一个js测试脚本就够了。调试也简单,在vs里面把启动进程设成node.exe,然后在启动参数里启动之前写好的测试脚本,这样设置好调试选项,按F5就能直接调试C++插件。有了方便和可靠的调试方法,C++代码的大部分问题都能解决掉。

开发时调试代码的问题,用这种变通的方式算是圆满解决了。不过还是得研究如何在Electron环境下调试,因为与前端工程师们的代码整合的时候还是要面对Electron环境,前端在Electron环境下发现了C++代码的BUG,在Node.js环境下没发现,于是还是得去想出一种方法去调试解决它。要在前端工程师开发界面的环境下调试C++代码,由于前端配置开发环境方式多种多样,这种环境下的调试没有唯一的解决方案,不过主要的思路还是去想办法附加上正确的Electron进程,在vs的调试选项里启动electron.exe然后调试是一件不现实的事情。例如前东家的项目里用到了electron-vue,electron-vue项目启动开发模式时是用Webpack启动了一个本地Web服务器,之后启动Electron的时候是从本地Web服务器加载界面元素的,这就意味着单纯启动Electron.exe是不可能正确启动前端工程师的开发环境的。

假如能成功地附加到正确的进程,除了初始化相关的代码,其余的代码应该都能调试到。要调试插件初始化相关的c++代码,如果是在主进程加载c++插件,Electron提供了--inspect-brk启动参数,可以让程序在入口点JS之前中断运行等待调试器附加。这时候把vs调试器附加到electron.exe上,在C++的相关初始化代码处加断点,之后再让Node.js的调试器连接到inspect-brk参数启动的调试端口,程序就会中断在刚才加的C++断点上;如果C++插件是在渲染进程上加载的,暂时没有什么好办法。

JS代码怎样引用C++插件

Electron程序启动时会启动多个子进程,主进程负责管理窗口消息和处理与系统有关的界面相关的功能,渲染进程负责解析网页元素,然后将解析结果传回给主进程显示。所以要加载C++模块也存在两种可能:在主进程和渲染进程里加载。这两种加载方式各有利弊,实际用的时候都有一些坑。

渲染进程中引用C++插件非常容易,只要在网页加载的JS代码里直接用require('plugin')加载模块就行。这种方式引用C++代码的优点是效率高,网页端的JS代码能直接读取到C++代码返回的对象。还有得益于现代浏览器的多进程架构,C++代码出问题了,整个Electron进程不至于全部崩溃,只有当前网页对应的渲染进程会崩溃退出,这时主进程会收到渲染进程崩溃的通知,然后主进程可以立刻恢复崩溃的页面的显示。有利必有弊,因为每个渲染进程在同一时间内只会负责处理一个页面的显示工作,所以调用主进程的接口新建一个BrowserWindow打开新窗口时,会有一个新的渲染进程来负责新页面的显示,也就意味着在新窗口加载的页面中require C++模块相当于又加载了一次。你是没办法访问之前已经加载的那个插件的实例的。假如有要求C++模块同时只能存在一个实例,这里加载了多个实例也会导致不可预知的后果出现。另外的一个坑是JS代码这样引用插件,C++模块中不能有让JS陷入挂起状态的代码,例如C++模块导出的方法会弹出模态对话框,模态对话框会阻塞JS脚本的运行直至对话框关闭为止,渲染进程中调用这个方法会就会让页面陷入未响应的状态。C++代码中有这样的方法时,这个模块就必须在主进程中引用。

主进程中引用C++模块,相比在渲染进程中引用要麻烦点。首先当然是要在主进程的入口点js中require要require的模块,之后要把require的结果注册成全局变量。网页中的JS脚本要调用已经在主进程加载的模块时,用类似这样的语句:

  const module = require('electron').remote.getGlobal('module');

之后调用module导出的方法时,调用的就都是Electron主进程中加载的C++模块,而不会在渲染进程中重新加载一遍模块。

在主进程中引用C++模块,能保证模块的实例只会初始化一次,所有的页面访问到的C++插件都是同一个实例。上文中提到的不能在渲染进程中引用的C++模块,在主进程中运行也是没有问题的。要调试C++代码的时候,找到正确的Electron实例也很方便。这样做也有显而易见的缺点:require('electron').remote引用主进程的代码逻辑是依靠进程间通讯进行的,开销比直接访问处于渲染进程中的JS对象大很多,频繁访问主进程中的对象会让程序的性能变得非常差,大部分的时间会耽误在等待主进程的IPC消息上;C++代码在主线程卡死时,Electron的所有窗口都会变成未响应状态;C++代码里面出了问题让宿主Electron进程崩溃时,这种引用方式会让整个应用程序崩溃,没有恢复的机会。所以要在主进程中引用Node.js模块时,出于性能考虑必须想办法减少渲染进程访问主进程对象的频率,对C++代码质量的要求也更严格,毕竟交给客户的产品不能频繁未响应或者崩溃退出。

开Electron项目的坑,具体要使用哪种引用方式,需要根据具体的情境来决定。我参与的项目最初出于保证唯一实例和调试方便的考虑,把C++模块放在了主进程加载。但是看Discord是把所有的C++模块都放到了渲染进程上去加载,有点不太明白Discord是怎么做的。后来出于进程间通讯性能太差和崩溃容易处理的考虑,一直在尝试把自己的C++代码也放到渲染进程上加载,可惜这部分工作直到离职还没做完。

Electron 4.0 之前和之后

4.0前和4.0后的区别

Electron版本4.0之前和之后的版本差异非常大,这里的差异指的是C++这边看到的差异。

Electron 4.0之前,构建代码使用的工具是和Node.js一致的GYP,就是那个用Python2写的没有文档的GYP。项目附带的Node.js是集成在版本库中的,位置在vendor/node下,要编译这时期的Electron代码,还需要用到GitHub团队为Electron量身定制的另外一个库:libchromiumcontent。这个库按照Chromium的Content API把Chromium浏览器封装成了库,然后Electron本身调用这个库实现对Chromium的集成。没有修改配置的情况下,编译脚本会从github上拉取这个库的编译好的二进制文件。Debug模式下libchromiumcontent是编译成动态库,Release下是编译成静态库与Electron主程序集成到一起。郁闷的是这个二进制文件不包含调试符号,导致调试Debug版的Electron的源码时,会发现找不到所有与Chromium相关的C++代码对应的符号,也就没法跟踪Chromium相关的代码。要调试Chromium的部分,就得想办法先编译出来libchromiumcontent,之后再让Electron的编译脚本用自己编译出来的libchromiumcontent。整个编译过程非常的tricky,碰到错误了还需要读懂编译用的Python2脚本然后想办法改(一般是网络错误,国内的网络环境大家都懂)。

再说下编译出来的程序。这时的Electron用Release配置编译出来的二进制文件大概是这样的情况:Electron主程序是动态链接C运行库的,解压后的Electron目录可以看到一堆api-ms-win-crt-xxxx.dll;Node.js库被编译成动态库文件链接到Electron主程序上;Chromium内核使用的V8脚本引擎在Node.js编译生成的动态库内,与Node.js共享同一个。整个项目在Windows下编译代码用的是MSVC,在linux和macOS下用的是clang。这时候开发C++插件实际上调用的都是Node.js的动态库导出的方法,在windows下是node.dll,linux下是libnode.so,mac下是libnode.dylib。提供的SDK这方面,用node-gyp下载过来的Electron头文件与Node.js下载过来的头文件的目录结构也不一样。构建C++代码要手工处理Node.js依赖的时候要分别处理Node.js和Electron两种情况。

Electron版本4.0之后,编译的工具链和最终生成的文件发生了翻天覆地的变化。最重大的变化是构建工具从GYP换成了Chromium项目正在使用的GN,理由和我当时的体验差不多:GYP没有文档、工具本身用Python2实现效率低、迁移到GN工具能更方便地跟进Chromium项目的步伐整合新版本的Chromium内核。其次是为Electron量身定制的libchromiumcontent库被废弃了,现在的编译步骤是用depot_tools里面的工具gclient先把chromium源码checkout过来,之前做了点配置让gclient顺带把Electron也checkout下来。之后build的时候其实build的是Chromium项目,Electron项目只是其中的一个子项目,但是之前checkout的过程中在Chromium源码里打上了Electron的私货。因为整个build的过程会直接拉取Chromium源码而不是像以前那样下载编译好的文件,所以从源码构建Electron对梯子速度和稳定性的要求变得更高了。

这时候构建出来的Electron程序与以前比较也是大不一样。Node.js编译成了静态库集成进了Electron主程序,Node.js的API改为由主程序导出的符号来提供。同样,主程序C运行库也改成了静态链接。从官网下载的release解压后再也看不到Universal CRT运行库和Node.js的动态库了。编译Electron使用的编译器在Windows/Mac/Linux三个平台上被统一成了Clang。至于用node-gyp下载过来的SDK,Electron提供的Node.js头文件改成了与Node.js一致的目录结构,Windows下另外提供的node.lib实际上是编译生成的electron.lib。这里有坑,后面会讲到。

所以如此大的变化对C++插件有什么影响呢。当时我发现的这个带来的坑都是在Windows上出现的。

新版本Electron正确加载C++模块必须做的trick

第一个坑是Electron的文档明确指出的,相应的文档在这里:https://electronjs.org/docs/tutorial/using-native-node-modules#a-note-about-win_delay_load_hook。新版本把Node.js静态链接到主程序上,Node.js的API都由electron.lib来导出,为了与之前的工具链兼容,下载过来的lib文件名仍然是node.lib。但是由此带来了一个弊病,生成出来的为Electron编译的C++插件在PE导入表里依赖的不是期望的electron.exe而是node.exe。这个问题会让C++扩展无法正确被Electron加载。Electron给的解决方案是,如果C++插件是用node-gyp编译的,binding.gyp文件里必须把win_delay_load_hook的值设为true;如果要自己处理Node.js依赖问题(原来Electron官方也知道让C++代码链接到Electron上是一件非常tricky的事情啊!),需要保证生成C++插件的链接器做了以下命令做的事情:

 link.exe /OUT:"foo.node" "...\node.lib" delayimp.lib /DELAYLOAD:node.exe /DLL
     "my_addon.obj" "win_delay_load_hook.obj"

简单解释下,就是要打开DLL的延迟加载功能,然后需要把node-gyp附带的一个C++源文件win_delay_load_hook.cc加入到编译过程中。
用了CMake处理编译时,要完成这个事情,需要在CMakeLists.txt里面加上类似这样的内容:

# 源文件要加上win_delay_load_hook.cc
add_library(plugin SHARED
  win_delay_load_hook.cc
)

# 添加HOST_BINARY宏定义,这个宏有什么用途后面会说
target_compile_definitions(plugin PRIVATE
  HOST_BINARY="node.exe"
)

# 链接node.lib和delayimp.lib
target_link_libraries(plugin
  node.lib
  delayimp.lib
)

# 设置链接器参数
set_target_properties(plugin PROPERTIES
  LINK_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} /DELAYLOAD:node.exe"
)

平白无故多的宏定义是什么意思呢?来看下win_delay_load_hook.cc写了什么:

/*
 * When this file is linked to a DLL, it sets up a delay-load hook that
 * intervenes when the DLL is trying to load the host executable
 * dynamically. Instead of trying to locate the .exe file it'll just
 * return a handle to the process image.
 *
 * This allows compiled addons to work when the host executable is renamed.
 */

#ifdef _MSC_VER

#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif

#include <windows.h>

#include <delayimp.h>
#include <string.h>

static FARPROC WINAPI load_exe_hook(unsigned int event, DelayLoadInfo* info) {
  HMODULE m;
  if (event != dliNotePreLoadLibrary)
    return NULL;

  if (_stricmp(info->szDll, HOST_BINARY) != 0)
    return NULL;

  m = GetModuleHandle(NULL);
  return (FARPROC) m;
}

decltype(__pfnDliNotifyHook2) __pfnDliNotifyHook2 = load_exe_hook;

#endif

代码的意思应该很清楚,加了延迟加载功能后,在DllMain函数执行时操作系统会通知win_delay_load_hook.cc里面定义的回调函数。回调函数检测到要延迟加载的DLL名字是node.exe时,会直接返回当前进程的句柄(GetModuleHandle(NULL);)。从而让Electron能成功地链接给node.exe的C++插件载入到内存。这里面的HOST_BINARY宏正常情况下是未定义的,所以前面的CMake脚本要加上这个宏定义。

std::fstream让C++模块崩溃

第二个坑是Electron静态链接Node.js和C运行库带来的副作用。静态链接Node.js使得部分不该导出的C++符号被错误地导出来,其中包括但可能不限于C++标准库相关的东西,例如std::stringstreamstd::fstream。假如C++插件动态链接了C/C++运行库,在使用std::fstream这类C++标准库中的类时就会有很神奇的事情发生。初始化fstream的实例时,编译出来的机器指令错误地调用了electron.exe导出的std::fstream构造函数的实现,对文件流一番操作,准备关闭文件流时,代码又调用了动态链接的C++运行库导出的std::fstream析构函数的实现。因为两个C++运行库实例(electron自带的和C++插件动态链接的)使用了不同的操作系统堆,所以调用析构函数会让程序立刻Crash掉。但是很神奇的是用node-gyp编译的C++插件就没有这个问题,打开node-gyp生成的vcxproj文件,原来node-gyp编译C++插件默认用的选项是静态链接C/C++运行库,这时用的fstream就都是C++插件自带的fstream实现,也就不会因为使用的堆不同而Crash。

为什么Electron 4.0之前就没有这个问题?那时候Electron主程序还是动态链接C/C++运行库的,新加载的C++插件如果是动态链接C/C++运行库就会直接使用在内存里的C/C++运行库DLL实例,也不会存在操作系统堆不一致导致Crash的问题。

要解决这个问题倒是可以把C++插件使用的C/C++运行库也改成静态链接的,在CMake里面这样做就可以:

target_compile_options(plugin PRIVATE $<$<CONFIG:Debug>:/MTd>)
target_compile_options(plugin PRIVATE $<$<CONFIG:MinSizeRel>:/MT>)
target_compile_options(plugin PRIVATE $<$<CONFIG:RelWithDebInfo>:/MT>)
target_compile_options(plugin PRIVATE $<$<CONFIG:Release>:/MT>)

但是静态链接C/C++运行库还会引入其它的问题,特别是在C++插件依赖第三方库的情况下,直接修改成静态链接是有很大概率碰上链接错误的,没条件修改第三方库的链接方式那就只好凉拌了。幸运的是在Electron 4.2.x和以后的版本里这个问题被修复了https://github.com/electron/electron/pull/18281

Electron静态链接C/C++运行库还有一个小坑,之前Electron自带了C/C++运行库的DLL,于是最终生成安装程序部署安装的时候没必要安装VC++运行库。现在改成静态链接Electron就没必要自带C/C++运行库DLL了。所以假如插件是动态链接C/C++运行库,那么就必须在安装程序中去设法安装VC++2015或者2017的运行库,否则如果目标系统没有装运行库,C++插件照样会加载失败。

写这个文章的时候,在Electron 6.0的release notes里看到官方团队已经决定停止支持3.x版本了。所以建议是能用4.x以后的版本就用,二次开发也最好基于这之后的版本,最好是用4.2.x以后的版本,开发的坑确实少很多。

与前端工程师们配合工作

在Electron上开发,假如互相之间没有技术栈的交集,写cpp的和写JS的前端工程师互相沟通起来简直就是互相对牛弹琴。写cpp看不透层出不穷的前端框架,写前端的认为cpp高深莫测看不懂Node.js的C++ API。两个群体之间唯一的共同话题几乎只有cpp给Node.js提供的JavaScript形式的API。所以互相之间约定好API的声明是非常重要的事情。然而两者在这上面要担负的责任却不是对等的。

写C++的要负责API的实现,把API测试正确,还要处理好编译让前端工程师能顺利调用C++代码。前端们只需要拿到C++模块的二进制文件,负责调用导出的API,然后看预期结果对不对就行,前端工程师发现API的调用结果与预期不符的时候,还是会把锅甩到C++工程师头上。前端已经开始使用C++模块导出的API时,C++这边API的声明发生了与之前不兼容的变化,已有的前端代码调用就会出错。这时还得麻烦前端工程师来修改对应的调用,可是有时候时间紧急还是必须得写C++的自己去改,这就很尴尬,自己挖的坑还得自己填。种种不如意的情况都要求写C++模块的人去学习前端工程师们的使用的框架和代码,对能力的要求相对要高一些。

反过来前端工程师在这方面也不轻松,虽然说前端工程师需要的只是C++模块的二进制文件和API的声明,但是要看懂C++模块的编译方法以及配置好工作环境也是个不容易的事情。我参与项目的时候,几乎每新来一个前端加入开发,我就得亲自去帮忙在他的电脑上面把VC2017、Python2/3、CMake和环境变量都安装配置一遍,虽然项目的readme里面已经把环境搭建的方法写得自认为很详细了,但是在各种电脑上面还是有很多未知的坑,尤其是VC++2017的安装,几乎没有装对的,到头来我还得亲自跑腿去帮忙。为了从支持的无底洞里面解脱出来,强烈建议写C++的为C++模块配置一个持续集成环境,这样前端工程师们就不需要为编译操心了,只要从网站上下载最新的二进制文件就行,大家都轻松。

结论

Electron这个东西确实很优秀很强大,毕竟把Chromium和Node.js两个大坑整合到了一起,还抽象出了一组助力客户端开发的基本API,这本身就是一件了不起的事情,至少以我而言我是没有能力做这件事。抛开弊病不谈,Discord、Skype、Postman、VSCode……甚至Visual Studio安装程序都改用Electron来做界面了。那么多成功的案例摆在那里,表明这东西确实是受到大家认可的。大量的前端工程师们透过Electron这个框架进入了客户端开发的领域,为一切都可以用JavaScript实现的愿景立下了汗马功劳。所以这东西真的有那么好吗?虽然在JavaScript那边看起来很光鲜,但是客户端的逻辑很多情况下要比Web页面复杂的多,Electron更是塞了个高度复杂的Chromium内核,只用JavaScript时就已经需要弄懂Electron的进程间通讯是怎么回事。在C++代码这边,更是一个混乱邪恶的世界,把C++代码正确的链接到Electron上已经是一件tricky的工作,Electron环境和单纯的Node.js环境之间更有巨大不同,还有无数的坑等待去发掘。所以很明显Electron虽然看起来易用,但是实际上对开发者的要求非常高。个人做一些小东西可能还不错,公司真的要拿它做复杂的项目,必须得有一位懂Web前端也懂Node.js的大佬坐镇,涉及到其它语言的开发,更是需要去有人懂Chromium和Node.js的源代码或者有这个能力去攻克它们。否则这东西并不会表现的像它承诺的那样好用,碰到一些问题真的是完全没法找原因,项目也会一而再再而三的延期。

最后用两个自问自答来表达对Electron的看法。

有人要高薪找懂Electron的人来开发你愿意去吗?我的回答是愿意。谁会和钱过不去?既然踩那么多坑了,自然有经验去掌控新项目,项目的进展也可能会更快因为可能能少走很多弯路,这是件双赢的事情。

个人独立写小东西会首选Electron框架吗?我的回答是坚决不用。先不说两种语言交互本来就比单一语言开发要复杂这件事,单是眼花缭乱的前端框架就已经能让我崩溃了。抛开前端框架不谈,Electron这东西弄个Hello World都复杂的要命,Qt GUI的Hello World不超过20行C++代码就能解决,《Windows程序设计第五版》里面的那个Windows Hello虽然甩的概念多但是也都可以在同一个C++源文件内解决,哪个都比Electron简单。Electron对内存和硬盘空间的需求也实在是太饥渴。写客户端对界面没要求的话,我还不如用Qt或者WPF这样的框架,起码我对这些更熟悉,它们也不像Electron那样臃肿。