深入浅出 Electron macOS原生模块集成

1,037 阅读5分钟

再识 Electron

Electron从开发角度上来说,能够认为是一个javascript的执行环境,这个环境包含了:

  • 浏览器环境:Chromium 内核,用于处理各种浏览器渲染、链接等事务。
  • Node.Js环境:使用NodeJs作为javascript的运行环境,意味着包含了v8语言解析器。
  • 系统API:各种系统原生的API,用于支持Electron APP能够有更接近原生app的体验。

image.png

以上三个模块的结合对于构建Electron是密不可分,使Electron可以运行部分系统api、node api、浏览器api。相反地,macOS 上有各种系统api可以调用,也可以使用node去运行javascript,但却无法使用浏览器的各种api;而如果在浏览器中使用node执行javascript,也没有办法调用到各种系统api。

NodeJS C++ 扩展

image.png

既然 Electron node环境使用的是Node.js,所以它支持原生Node.js模块的接入,但由于Electron具有与给定Node.js不同的应用二进制接口 (ABI)(由于使用Chromium的 BoringSL 而不是 OpenSSL 等 差异),原生模块需要为Electron重新编译。利用node-gyp,配合工程配置能够生成Electron能使用的C++扩展。

原生模块编写与整合

image.png

举个例子,这是原生实现一个界面的代码,其中有几个关键部分:

  1. 末尾的NAPI_MODULE(NODE_GYP_MODULE_NAME, Initialize)定义了当前原生模块的名称,同时告诉外部注册入口是Initialize 方法
  2. 创建了OpenNativeWindo方法,并提供原生方法的实现
  3. Initialize方法中,注册了要使用到的方法,并提供了方法名和对应的方法实现,以及方法的作用域

同时需要注意,在macOS上编写原生模块需要使用Objective-C++,而且提供给外部使用的方法,需要再用C++包一层,如OpenNativeWindow方法,其实是返回napi_valueC++方法,在内部才使用了NSRunLoop等OC方法,因为napi并没有提供直接让Objective-C++使用的注册方法,所以需要额外转化一层。

image.png

在完成了原生模块编写后,需要利用node-gyp进行打包,其中需要对binding.gyp文件进行构建的配置:

  • target_name: 模块的名称
  • cflags: c编译配置,如果有C++配置需要可以使用cflags_cc
  • sources: 需要编译的C++文件
  • conditions: 环境配置,这里我们可以配置macOS上Xcode编译选项,也可以配置Windows上Visual Studio的配置选项,具体配置可以查看官方文档

image.png

image.png

编译完成后就会生成addon.node文件,在Node中使用require引用就可以使用其中整合的代码。

好简单,但为什么?

为什么编译出来Electron就能加载?又是怎么用到我的原生代码的呢?想搞清楚这个问题,我们一步步了解一下原生模块

原生模块的本质

image.png

通过file指令,可以看出.node文件实际上是一个Mach-O文件

image.png

再通过otool命令,可以看到.node文件包含了两个dylib动态库,所以.node实际上是一个dylib动态对(win上为dll,linux上为efi),这也说明了为什么在mac上安装node的时候需要xcode-select,windows上安装需要vs。

原生模块的加载

既然它是一个动态库,那么它加载的秘密就要在Electron中寻找了,Electron又是基于Node,所以最终我们找到Node的加载模块,模块被required的时候,它会通过process.DLOpen()引入:

image.png

  1. 其核心是对env->TryLoadAddon的调用,它接受回调以执行实际的加载过程
  2. 回调函数中马上创建了dlib_load_mutex并进行加锁
  3. 随后使用dlib->Open()加载动态库,获取动态库句柄
  4. node_module开始创建,并通过local_modpending中获取等待加载的node_module地址

image.png

那么nodule_module是怎么创建的呢?可以看到我们之前使用了NAPI_MODULE(NODE_GYP_MODULE_NAME, Initialize)方法定义了模块,而这个宏拆开其实是定义了模块名称,并将方法Initilize作为_register_addon,注册方法。

_register_addon是被constructor修饰的函数,所以在模块实例化的时候,它就会被调用来注册我们名为addon的原生模块。它将我们的模块地址传递给napi_module_reigster,随后将其数据包装在node::node-module中,再传递给src/node_binding.cc中的node_module_register。一旦我们通过DLIB::Open打开原生模块,注册方法就会被调用,并在继续之前,将我们的模块地址保存在上文提到的thread_local_modpending中,并将刚刚加载的动态库句柄转移到mp上。

image.png

mp也就是napi_module结构体,在拿到地址后,就会继续做剩余的工作,通过mp->nm_register_func调用我们的注册函数,注册函数返回我们模块的exports,也就是公开的属性和方法,并注册到上下文,最终我们就能够使用我们的插件。

性能对比

排序

image.png

图像处理

  • JS:使用Jimp图像处理库
  • C++:使用OpenCV图像处理
  • 统一将图片缩放到 1024*1024 大小

image.png

image.png

可以看到在执行少量排序时,JS执行效率要比原生模块高,是因为原生模块需要进行加载等操作,加载耗时比排序耗时更长,但在大量排序时,依旧是原生模块表现更好。

在图片处理上,JS效率则远低于原生模块。

使用LLDB进行调试

  • 利用node-gyp构建一个debug环境的模块:

    node-gyp build --debug

  • 确定当前node.js路径:

    which node

  • ·利用LLDB链接到对应Node:

    lldb -- your/path/to/node test.js

  • 在对应模块文件中调试,添加断点:

    breakpoint set -f addon.mm -l 25