Electron 外掛開發實踐

網易雲信發表於2022-05-17

前言

早期跨平臺桌面應用開發大多采用 Qt 和 C++,受語言學習成本開發效率影響,越來越多的人將目光轉向了 Electron。Electron 是以 Nodejs 和 Chromium 為核心的跨平臺開發框架。

Electron 基於 Web 技術開發桌面應用,Web 技術在軟體開發領域應用非常廣泛,生態較為成熟,學習成本較低、開發效率高。但是 Web 在處理多執行緒、併發場景時顯得捉襟見肘,Electron 底層有 Nodejs 支援,Nodejs 的外掛模組具有呼叫 C++ 的能力,C++ 非常適合處理高併發、音視訊等複雜業務,彌補了 Web 的效能問題。本文就 js 和 C++ 混合程式設計在 Electron 桌面程式中的應用進行介紹。

Nodejs 中使用 C++,有以下幾種方式:

  • 將 C++ 程式作為獨立子程式使用。
  • 通過 node-ffi 方式呼叫。
  • Nodejs 擴充套件,將 C++ 程式碼編譯為 Nodejs 模組,本文主要針對這種方式進行介紹。

C++ 擴充套件

C++ 擴充套件簡介

Nodejs 本身採用 C++ 編寫,所以我們可以使用 C++ 編寫的自己的 Nodejs 模組,可以像 Nodejs 原生模組一樣使用。C++ 擴充套件格式為 .node,其本質為動態連結庫,相當於 Windows 下 .dll。C++ 擴充套件作為動態連結庫,通過 dlopen 在 Nodejs 中載入。

C++ 擴充套件架構圖:

C++ 擴充套件實現的幾種方式

實現 C++  擴充套件有3種方式:原生模式、nan、Node-API。

* 原生模式

直接使用 Nodejs API 及 Chrome V8 API 進行開發,這種方式早已被遺棄。

特點:Nodejs API 和 Chrome V8 API 介面一旦變化,依賴這些 API 的 C++ 擴充套件便無法使用,特定版本的 C++ 擴充套件只能在對應版本 Nodejs 環境中使用。

* nan(Native Abstractions for Nodejs)

nan 是 Nodejs 抽象介面集,nan 根據當前 Nodejs 版本,使用巨集判斷執行對應版本的 API。

特點:C++ 擴充套件在不同版本 Nodejs 中執行,需重新編譯,Nodejs 升級到較高版本後出現介面不相容問題。

  • Node-API

Node-API 使用 Nodejs 二進位制介面,相比 nan 方式這些二進位制介面更為穩定。

特點:不同版本 Nodejs 只要 abi 版本號一致,C++ 擴充套件可以直接使用無需重新編譯,消除了 Nodejs 版本差異。

構建工具

  • node-gyp

node-gyp 對 gyp(Chromium 編寫的構建工具)進行了封裝,binding.gyp 為其配置檔案。

node-gyp 工作分為兩個過程:

a. 結合 binding.gyp 生成對應平臺下的工程配置,比如:Windwos 下生成 .sln 專案檔案。

b. 專案檔案編譯,生成 C++ 擴充套件。

binding.gyp 配置檔案,以 Windows 為例:

{
    "targets": [
    {        
        "target_name": "addon_name", 
        "type": "static_library"
        'defines': [
          'DEFINE_FOO',
          'DEFINE_A_VALUE=value',
        ],
        'include_dirs': [
          './src/include',
          '<!(node -e "require(\'nan\')")' // include NAN in your project
        ],
        'sources': [
          'file1.cc',
          'file2.cc',
        ],
        'conditions': [
          [
              'OS=="win"', 
              {
                'copies': [{
                  'destination': '<(PRODUCT_DIR)',
                  'files': [
                    './dll/*'
                  ]
                }],
                'defines': [
                  'WINDOWS_SPECIFIC_DEFINE',
                 ],
                'library_dirs': [
                  './lib/'
                ],
                'link_settings': {
                  'libraries': [
                    '-lyou_sdk.lib'
                  ]
                },
                'msvs_settings': {
                  'VCCLCompilerTool': {
                    'AdditionalOptions': [
                      '/utf-8'
                    ]
                  }
               },
             }
           ]
        ],
      },
    ]
}

欄位說明:

target_name:目標的名稱,此名稱將用作生成的 Visual Studio 解決方案中的專案名稱。

type:可選項:static_library 靜態庫、executable 可執行檔案、shared_library 共享庫。

defines:將在編譯命令列中傳入的 C 前處理器定義(使用 -D 或 /D 選項)。

include_dirs:C++ 標頭檔案所在的目錄。

sources:C++ 原始檔。

conditions:適配不同環境配置條件塊。

copies:拷貝 dll 動態庫到生成目錄。

library_dirs: 配置 lib 庫目錄到 vs 專案中。

libraries:專案依賴的庫。

msvs_settings:Visual Studio 中屬性設定。

node-gyp 編譯指令:


node-gyp clean //清空上一次構建目錄
node-gyp configure //配置專案
node-gyp build //專案編譯,生成C++擴充套件
node-gyp rebuild //重新生成C++擴充套件,相當於clean configure build的結合

* cmake-js

cmake-js 與 node-gyp 工作原理類似。cmake-js 是基於 CMake 的構建系統,而 node-gyp 是基於 Goole 的 gyp 工具,這裡不在進行詳細介紹。

回撥事件處理 

Nodejs 執行在單執行緒中,但它能夠支援高併發,就是依賴事件迴圈實現。簡單來說 Nodejs 主執行緒維護一個事件佇列,收到一個耗時任務將任務放入佇列,繼續向下執行其他任務。主執行緒空閒時,遍歷事件佇列,非 I/O 任務親自處理,通過回撥函式返回給上層呼叫。I/O 任務放入執行緒池執行,並指定回撥函式,然後繼續執行其他任務。

C++ 擴充套件呼叫 js 回撥函式時,會在 Nodejs 掛在一個 libuv 執行緒池,用於處理回撥函式,當 Nodejs 主執行緒空閒時,去遍歷執行緒池,處理任務。libuv 具體細節參考 nertc-electron-sdk:
https://github.com/netease-im...

混合程式設計實踐

示例1

結合 node-addon-api 進行演示,node-addon-api 對 Node-API 介面進行了封裝開發簡單。該例項完成 js 呼叫 C++ 函式實現兩個數字相加。

  • 專案結構

  • package.json 配置檔案
//package.json
{
  "name": "addon-sdk",
  "version": "0.1.0",
  "description": "test nodejs addon sample",
  "main": "./api/index.js",
  "private": true,
  "gypfile": true,
  "dependencies": {
    "bindings": "~1.2.1",
    "node-addon-api": "^3.0.0"
  },
  "devDependencies": {
    "node-gyp": "^8.2.0"
  },
  "scripts": {
    "test": "node ./api/index.js"
  },
  "license": "ISC",
  "author": "liyongqiang"
}
  • binding.gyp 配置檔案
//binding.gyp
{
  "targets": [
    {
      "target_name": "addon",
      "sources": [ 
        "./src/addon.cc", 
        "./src/engine.h" , 
        "./src/engine.cpp" 
      ],
      "include_dirs": [
        "<!@(node -p \"require('node-addon-api').include\")"
      ],
      'defines': [ 
        'NAPI_DISABLE_CPP_EXCEPTIONS' 
      ]
    }
  ]
}
  • C++ 擴充套件
//addon.cc
#include <napi.h>
#include "engine.h"

Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
  return nertc::Engine::Init(env, exports);
}

NODE_API_MODULE(addon, InitAll)

//engine.h
#pragma once
#include <napi.h>
namespace nertc {
class Engine : public Napi::ObjectWrap<Engine> {
 public:
  static Napi::Object Init(Napi::Env env, Napi::Object exports);
  Engine(const Napi::CallbackInfo& info);
 private:
  Napi::Value add(const Napi::CallbackInfo& info);
};
}

//engine.cpp
#include "engine.h"
namespace nertc {  
Napi::Object Engine::Init(Napi::Env env, Napi::Object exports) 
{
    Napi::Function func =
        DefineClass(env, "Engine",
                   {InstanceMethod("add", &Engine::add)});
    Napi::FunctionReference* constructor = new Napi::FunctionReference();
    *constructor = Napi::Persistent(func);
    env.SetInstanceData(constructor);
    exports.Set("Engine", func);
    return exports;
}

Engine::Engine(const Napi::CallbackInfo& info): Napi::ObjectWrap<Engine>(info) {}

Napi::Value Engine::add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();//獲取環境變數
  int ret = 0;
  int length = info.Length();//獲取引數個數
  if (length != 2 || !info[0].IsNumber() || !info[1].IsNumber()) 
  {
      Napi::TypeError::New(env, "Number expected").ThrowAsJavaScriptException();
      ret = -1;
      return Napi::Number::New(env, ret);
  }
  int num1 = info[0].As<Napi::Number>().Int32Value();//獲取第一個引數
  int num2 = info[1].As<Napi::Number>().Int32Value();////獲取第二個引數
  int sum = num1 + num2;
  return Napi::Number::New(env, sum);//返回結果到js層
}
}
  • js 呼叫 C++ 擴充套件
var addon = require('bindings')('addon');//呼叫C++擴充套件
var engine = new addon.Engine();
console.log( `num1 + num2  = ${engine.add(1,2)}`);//輸出3

在 package.json 目錄下,執行 npm install、npm run test,可以看到 js 呼叫 C++ 介面成功,輸出兩個數字相加結果。

示例2

網易雲信音視訊通話 nertc-electron-sdk,採 Node-API 方式進行開發,將 C++ 原生 sdk 封裝成 Nodejs 模組(nertc-electron-sdk.node),結合 Electron 可以快速實現音視訊通話。
github demo 體驗地址:https://github.com/netease-im...

常見問題

  • Electron 應用中 js 呼叫 C++ 擴充套件時,提示 Error: The specified module could not be found。

答:該錯誤表示能找到 C++ 擴充套件模組(.node)但是載入失敗,因為 .node 會依賴其他 .dll 和 C++ 執行庫,缺少這些庫時就會報上面的錯誤,使用 depends 檢視缺少哪種庫,配置即可。

  • 執行使用 C++ 擴充套件的 Electron 應用,提示 The specifield module could not be found。

答:該錯誤表示找不到 C++ 擴充套件模組。在專案 package.json 檔案中配置 extraFiles 欄位,將擴充套件拷貝到 Electron 可載入目錄即可。

  • Electron 載入 C++ 擴充套件時提示:Module parse failed: Unexpected character '�'。

答:webpack 只能識別 js 和 json 檔案無法識別 C++ 擴充套件模式,在 Electron 打包時需要在 vue.config.js 中配置 C++ 擴充套件的 loader。

更多常見問題彙總:
https://doc.yunxin.163.com/do...

相關文章