深入理解Electron(一)Electron架构介绍

2,080 阅读12分钟

深入理解Electron(一)Electron架构介绍

Electron是什么

引用来自官网的解释:

Electron 是一个使用 JavaScript、 HTML 和 CSS 构建桌面应用程序的框架。通过将 Chromium 和 Node.js 嵌入到它的二进制文件中,Electron 允许你维护一个 JavaScript 代码库,并创建可以在 Windows、 macOS 和 Linux 上运行的跨平台应用程序ーー不需要本地开发经验。

如果我们追溯历史,可以发现Electron的前身是Atom Shell,最开始的设计目标就是为Atom编辑器而生,实际上Electron的作者之前也是node-webkit的核心贡献者,而node-webkit正是NW.js的前身。在作者离开英特尔公司(当初大力支持node-webkit)后,就加入了Github项目组,孵化了Atom Shell这个项目,再到后来(2014年)正式更名为Electron,发展至今。

Electron和众多类似的产品目标非常简单,他们将chromium(这里不同的框架选择不同,比如Tauri选择了原生的WebView,而WebView2选择了Edge内核)和Node.js(这里不同框架采取策略也不同,比如Webview2不提供默认的运行时,而Tauri选择了Rust等等)利用C++等原生语言集成起来,提供了一整套基于Web的运行环境,并提供了与底层OS交互的便捷API,目的就是为了让大家使用Web的技术栈去开发客户端原生应用,从而实现不同操作系统之间的跨平台开发。

说起桌面端GUI跨平台开发不得不提到Qt,这个使用C++来统一各个平台开发的框架才能被称为桌面跨端开发的鼻祖。在之前每个操作系统都是各自为战,经历过windows时代的MFCWPF的开发模式,在Mac上则是OCSwift作为底层语言,在经历了互联网时代变迁后,跨端框架越来越被大家所喜爱,广泛运用于互联网产品中。

若论起性能,肯定是越接近底层的语言开发出来的软件性能越高。如果要追溯历史,最底层的编程方式大概是最早的纸带打孔编码的时代,那时的人们是真正的编程大师,在用机器码编程,写二进制程序是非常复杂的,只通过0和1两个数字完成如此复杂的逻辑代码,基本上是需要巨大的精力和时间的,所以这个时代的程序只能做非常简单的事情。到后来汇编语言的产生才能让一些操作逐渐复杂一些,但依旧无法完成更复杂的架构设计。直到高级语言的出现,才让软件世界逐渐丰富多彩了起来,在计算机上产生了令人震惊的软件和操作系统,进入个人电脑时代,改变了世界。

在高级语言中,C语言应该是最接近底层的开发语言了,因此常用的操作系统内核基本都是用C语言和汇编写成,这是因为内核需要极高的性能以及对底层硬件驱动的精确控制。后来经过工程师在工程方面的不断探索,有了面向对象,编程范式,有更高层利于维护的语言出现。C++可以说是介于C和更高级语言之间的一个产物,它完全兼容C的语法,又添加了面向对象、STL模板等能力,让他在能拥有不俗的性能的同时,还拥有一定的大型工程化的能力,我们所了解到的大多数最复杂的软件,比如浏览器Chrome、Office、编译器、游戏等等,尤其是最复杂的浏览器软件,浏览器内核的复杂程度远超我们想象,也正是有chromium这样的开源贡献,造就了今天互联网时代的技术繁荣,基于V8LibUV构建的Node.js让前端迅速发展到一个不可思议的地步,本文所讲的Electron也同样依赖于这样的技术。

回到性能的讨论,毫无疑问在引入了chromiumNode.js作为运行时的Electron性能是不如基于C++Qt,同样Qt的性能也不及直接使用原生语言来开发的程序,多一层抽象的封装必然会折损一部分性能。那是不是说用性能越好的语言来开发就是最优的呢?答案显然不是,不然现在我们应该直接用机器码来开发。实际上,软件语言和框架的发展,离不开硬件的高速发展,硬件遵循着摩尔定律发展至今,硬件的计算能力、存储能力都有着飞跃式的进步,这才能够让软件脱离底层的束缚,越来越专注于工程的可维护性和易用性,释放掉一些不必要的生产力,让软件的世界发展到今天这种繁荣的地步。

毫无疑问,Electron是处于最上层的抽象层级当中,通过简单高效的Web技术来实现复杂的桌面软件开发。从语言发展的视角来看,C和C++可以对内存进行精细地控制访问,而底层的汇编则更可以操纵寄存器,存储越靠近CPU运行速度就会越快。但是它带来的代价是昂贵的开发成本和安全问题,还记得在windows时代有着各种蠕虫木马病毒,很大一部分都是程序本身的缓冲区溢出、访问越界等等问题,这类底层的语言对开发人员的要求较高,需要能够掌握操作系统的内部机制,在大型工程的情况下,bug是难免的,而底层语言造成的bug往往影响面更大。因此后来Java等语言去除了指针的设计,弱化了对内存的控制程度,增强了工程化的能力,以更好地应对复杂的软件环境。再到后来pythonjavascript等脚本语言的流行,更简化了开发人员编写代码的成本,涌现多种编程范式和繁荣的生态。而新兴的现代化编程语言如golangrust等则是性能与易用之间的权衡,从这个发展趋势来看,大家一直在探寻着一个平衡点。

从另一个角度来看,软件的研发成本和产品、商业也息息相关,行业巨头Google、微软、IBM等大型软件公司,都曾面临过无法按时交付的问题。人月神话中也深刻地揭示了软件开发的工期不是靠纯堆人力就能将问题解决的,一旦有这类风险产生,无论是对企业还是用户都会造成比较大的伤害。而越是底层的语言,所需要软件开发人员的专业素质越高,维护成本也就越高,因此在互联网高速发展的时代,系统开发相关的人才不断减少,更多涌现出大量的应用开发人员,进入Web高速发展的阶段。这是由于互联网业务的高速发展、软硬件技术的不断迭代更新,形成的一种趋势。因此ElectronReact NativeFlutteruniAppTaro这类跨端解决方案越来越受大家欢迎也是必然的结果。

Electron架构

ElectronJS 进程模型图解

ElectronJS 进程模型图解

Electron的架构设计很大程度上是受到Chromium的启发。ChromiumGoogle开源的浏览器内核代码,ChromeEdgeOpera等等浏览器都是基于Chromium来实现的。Chromium堪称是全世界最复杂的软件应用,整体代码库已经庞大到了近40G,我们简单举个例子说明下其复杂程度:

  • net模块,实现了主机解析,cookies,网络改变探测,SSL,资源缓存,ftp,HTTP, OCSP实现,代理 (SOCKS和HTTP) 配置,解析,脚本获取(包括各种不同系统下实现),QUIC,socket池,SPDY,WebSockets等等,每套协议都是十分复杂的。
  • v8模块,包括字节码解析器,JIT 编译器,多代GC,inspector (调试支持),内存和 CPU 的 profiler(性能统计),WebAssembly 支持,两种 post-mortem diagnostics 的支持,启动快照,代码缓存、代码热点分析等等。
  • Skia模块,用点画出各种图。然而里面包括十几种矢量的绘制,文字绘制、GPU加速、矢量的指令录制以及回放(还要能支持线程安全)、各种图像格式的编解码、GPU渲染优化等等。
  • Blink内核,这个更复杂,HTMLCSS规范就得近万页了,要将它们完全实现是一个巨大的工作量,再加上实现Layout的成本,涉及到非常庞大的计算,还要考虑极致的性能,保证浏览器的渲染能够流畅快速。
  • 还有音视频相关、沙箱、插件、UI等等,就不赘述了,总之称之为巨型软件应用也不为过,

正是由于这一庞大复杂架构的开源,经过数年的不断打磨完善,让Web技术能够快速发展,百花齐放。

我们知道,在浏览器内部有很多的处理模块,这些模块的代码可以采用进程或者线程的方式来进行组织调度:

browser architecture

browser architecture

在浏览器实现方面是没有标准规范的,这取决于不同浏览器内部的实现细节。在最早期,浏览器渲染都是在同一个进程里面完成的,后来Chrome采用了多进程架构,演变成了这种模式:

browser architecture

browser architecture

其中:

  • 浏览器主进程,实现浏览器的主要UI、负责和文件、网络等等操作系统底层接口对接通信。
  • 渲染进程,每一个tab独立开一个渲染进程,核心进行web代码解析和渲染工作。
  • 插件进程,负责浏览器插件的控制。
  • GPU进程,独立的进程负责处理GPU图像的渲染绘制。

这样设计架构的好处主要有两个:

  1. 保证每个tab独立进程,这样在某一个页面crash的时候,仅仅影响当前的Tab,而不至于让整个浏览器崩溃。这提升了软件的健壮性和用户体验。
  2. 有利于实现沙箱隔离的安全机制,基于进程可以很方便地控制不同页面之间的安全访问策略,确保每个renderer进程在自己单独的沙箱环境内安全地运行。

不过这类架构也带来一个额外的成本,那就是更多的内存消耗,因为每一个进程需要开辟独立的内存空间,不同进程之间的内存很难做到共享。因此这个策略也是个权衡的策略考虑,Chrome基于用户当前的CPU和内存的情况,对进程的数量进行了动态的限制,以确保达到一个最佳的效果。

Electron本身参考了这个架构的实现,将各个GUI窗口通过renderer进程实现,交由chromium来加载渲染,主进程集成Node.js,负责与系统API交互,处理核心事务。

我们可以从Electron源码结构上很清晰地看到这点:

Electron
├── build/ - 构建相关
├── buildflags/ - feature flag
├── chromium_src/ - chromium的一份拷贝(源码仅包含build文件)
├── default_app/ - 默认启动时的app程序
├── docs/ -文档
├── lib/ - 使用JS/TS编写的模块
|   ├── browser/ - 主进程初始化相关
|   |   ├── api/ - 主进程暴露的API,通过_linkedBinding调用C++模块
|   ├── common/ - 主进程和渲染进程共用代码
|   |   └── api/ - 主进程和渲染进程共同暴露的API
|   ├── renderer/ - 渲染进程初始化相关
|   |   ├── api/ - 渲染进程API
|   |   └── web-view/ - webview相关逻辑
├── patches/ - 关于依赖的一些patch,主要是chromium、node、v8的
├── shell/ - C++编写的模块
|   ├── app/ - 入口
|   ├── browser/ - 主进程相关
|   |   ├── ui/ - 系统UI组件的一些实现
|   |   ├── api/ - 主进程API实现
|   |   ├── net/ - 网络相关实现
|   |   ├── mac/ - Mac系统下的一些实现(OC实现)
|   ├── renderer/ - 渲染进程相关
|   |   └── api/ - 渲染进程API实现
|   └── common/ - 主进程渲染进程通用实现
|       └── api/ - 主进程渲染进程通用API实现
└── BUILD.gn - 构建入口

Electron在源码上与Node.js实现类似,lib目录使用js实现,通过_linkedBinding来调用C++的模块,在一开始会被编译进内存中,通过C++程序装在启动。

C++启动入口在shell/app/electron_library_main.mm:

int ElectronMain(int argc, char* argv[]) {
  electron::ElectronMainDelegate delegate;
  content::ContentMainParams params(&delegate);
  params.argc = argc;
  params.argv = const_cast<const char**>(argv);
  electron::ElectronCommandLine::Init(argc, argv);
  return content::ContentMain(std::move(params));
}

这里依赖的content实际上是chromiumcontent模块,chromium将浏览器主进程部分和渲染进程进行了抽象,主进程部分的逻辑在blink模块实现,而渲染进程相关的实现则封装在content模块。

所以这里调用content就可以使用chromiumrenderer进程来进行启动渲染了。

关于C++部分的核心实现会在后续文章中进一步探讨。

小结

Electron本质上就是集成Node.jsChromium提供了基于Web技术栈开发客户端软件的能力,通过Web技术栈的快速迭代和Chrome本身的向前兼容迭代能力,可以做到跨平台的快速迭代开发。

在软件发展的进程中,我们始终在平衡软硬件之间的性能关系,性能与成本的平衡,软件维护和生态的持续发展,基于互联网的发展,Web技术的发展,促成了Electron这样的跨端技术被广泛应用。

Electron在实现架构上参考了Chromium的核心设计思想,通过主进程进行核心的调度启动,不同的GUI窗口独立渲染进程,并提供沙箱安全机制,做到进程间的隔离,进程与进程之间实现了IPC通信机制,对主进程提供Node.js运行时,封装上层API,通过C++提供具体系统组件、系统方法能力的实现。