给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那样臃肿。

5 thoughts on “给Electron做C++开发的那些坑”

  1. 单实例这个需求最近也遇到了。一开始通过electron-store 在本地记录了这个实例有没有开过,但是有些问题。后来找到一方法就是利用window title; BrowserWindow.getAllWindows() 可以获取所有window的 title getTitle(),然后判断下,如果已存在,就不重新打开了。完了定期再全部close 一下,目前还能满足需求。如果有更好的办法,请交流。谢谢

    1. 摊手表示自己这边同样没什么好的解决方案,当时做项目学习参考的都是Discord的实现。Discord是严格地在同一个网页中实现了所有的界面功能,也就没有检测单实例的必要,但是估计他们的前端写代码的时候会很难受。

  2. 想请教一下关于多线程的问题,我们项目是用多窗口做的,专门用一个渲染进程来链接dll,然后用进程间通信处理任务,这里就有个阻塞的问题了,就是有一个耗时工作时,调用其它窗口处理任务时就要等待了,而我又不想把之前的那部分拆分出一个新dll中
    我们现在用的是ffi调用直接编译好的dll,ffi好像不支持多线程
    所以请问下您这种方法支持多线程吗?

    用electron真心累人,之前还没接触过nodejs、vue之类的

  3. 作为一个C++客户端6年+js/vue前端5年+node.js3年的过来人,表示Electron还是很香的,在界面效果这块比用C++写不知道好/快多少倍。当然了,像你说的,需要同时对js和C++都很熟悉的,才能够把控整个项目,否则很容易产生两眼一抹黑的感觉

Leave a Reply

Your email address will not be published. Required fields are marked *