首页
/ Electron 原生模块开发(Linux / C++):用 GTK3 打造可双向通信的原生界面

Electron 原生模块开发(Linux / C++):用 GTK3 打造可双向通信的原生界面

2026-09-07 13:56:11作者:邓越浪Henry

本教程是在 Native Code and Electron 通用指南 基础上的 Linux 平台实战篇,核心目标是用 C++ 与 GTK3 编写一个能嵌入 Electron 应用的原生 Node.js Addon。文章将带你从零搭建一个完整的 Todo 示例:通过 node-gyp 完成 Linux 条件化构建、让 GTK3 GUI 运行在独立线程上、并借助 N-API 线程安全函数(ThreadSafe Function)实现"JavaScript → C++ → GTK3"与"GTK3 → C++ → JavaScript"的双向通信。读完你既能掌握 GTK3 原生界面与 Electron 共存的工程化方法,也能理解其中最容易出错的线程模型与回调安全边界。

[!NOTE] 阅读前置 本教程面向已具备基础 GTK 开发经验的读者,需要了解 widget、signal、主事件循环等核心概念;为保证篇幅,教程不会逐行解释所用 GTK 元素。若尚不熟悉,建议先阅读 GTK3 官方文档与入门教程,GNOME 开发者文档也提供了更全面的 GTK 开发指南。

为什么是 GTK3 而不是 GTK4

本教程选择 GTK3 作为 GUI 方案,除了它本身提供的丰富控件能力(按钮、输入框、列表等)之外,最关键的原因与 Electron 的运行时基石直接相关:

  • Chromium(以及由它衍生的 Electron)内部使用的正是 GTK3
  • 如果同时在一个进程中加载 GTK3 与 GTK4,会引发运行时符号冲突;
  • 一旦未来 Chromium 升级到 GTK4,你的原生代码通常可以平滑迁移过去。

这一点在当前仓库源码中可以得到佐证:Electron 的 Linux 侧大量直接依赖 GTK3,例如 shell/browser/browser_linux.cc 直接 #include <gtk/gtk.h>,并在图标主题解析逻辑中通过 gtk::GtkCheckVersion(4) 分别处理 GTK3/GTK4 两套 API(见 shell/browser/browser_linux.cc),shell/browser/printing/printing_utils.ccshell/browser/ui/gtk/menu_gtk.cc 等文件也都以 GTK3 头文件为基础。也就是说,本教程所做的一切,正是站在 Electron 自身在 Linux 上所使用的原生技术栈之上。

环境要求(Requirements)

通用教程 中提到的 Node.js、npm 与本地编译工具链外,本教程还需要:

  • 安装了 GTK3 开发文件的 Linux 发行版
  • pkg-config 工具(用于自动探测 GTK3 的编译参数)
  • G++ 编译器与构建工具

各主流发行版安装命令:

# Ubuntu / Debian
sudo apt-get install build-essential pkg-config libgtk-3-dev
# Fedora / RHEL / CentOS
sudo dnf install gcc-c++ pkgconfig gtk3-devel

1. 创建 Addon 包结构与依赖

可以复用 Native Code and Electron 教程中创建的包(前序步骤不再重复)。先规划 addon 的目录结构:

cpp-linux/
├── binding.gyp          # 供 node-gyp 构建原生 addon 的配置文件
├── include/
│   └── cpp_code.h       # 声明原生 C++ 代码的接口头文件
├── js/
│   └── index.js         # 加载并对外暴露原生 addon 的 JavaScript 封装
├── package.json         # Node.js 包配置与依赖
└── src/
    ├── cpp_addon.cc     # 桥接 Node.js/Electron 与原生代码的 C++ 文件
    └── cpp_code.cc      # 基于 GTK3 的原生 C++ 功能实现

package.json 内容如下:

{
  "name": "cpp-linux",
  "version": "1.0.0",
  "description": "A demo module that exposes C++ code to Electron",
  "main": "js/index.js",
  "scripts": {
    "clean": "rm -rf build",
    "build-electron": "electron-rebuild",
    "build": "node-gyp configure && node-gyp build"
  },
  "license": "MIT",
  "dependencies": {
    "node-addon-api": "^8.3.0",
    "bindings": "^1.5.0"
  }
}

三个脚本的分工:

  • build:执行 node-gyp configure && node-gyp build,把 C++ 源码编译成 .node 二进制;
  • build-electron:调用 electron-rebuild。需要说明的是,Electron 的 ABI 与 Node.js 不同(例如使用 Chromium 的 BoringSSL 而非 OpenSSL),因此安装到 Electron 应用时必须按目标 Electron 版本重新编译,相关原理与 @electron/rebuild 用法详见 Native Node Modules
  • clean:删除 build 目录以获得全新构建。

依赖方面,node-addon-api 是底层 N-API 的 C++ 面向对象封装,bindings 则自动定位编译产物 .node 文件,二者已在通用教程中详细说明。

2. 配置 Linux 专属构建(binding.gyp)

对于一个依赖 GTK3 的 Linux 专属 addon,需要让 binding.gyp 只在 Linux 上编译、在其他平台尽量不做任何事,并借助 pkg-config 自动获取用户系统上 GTK3 的库与头文件路径,同时配置异常处理与线程支持等编译选项:

{
  "targets": [
    {
      "target_name": "cpp_addon",
      "conditions": [
        ['OS=="linux"', {
          "sources": [
            "src/cpp_addon.cc",
            "src/cpp_code.cc"
          ],
          "include_dirs": [
            "<!@(node -p \"require('node-addon-api').include\")",
            "include",
            "<!@(pkg-config --cflags-only-I gtk+-3.0 | sed s/-I//g)"
          ],
          "libraries": [
            "<!@(pkg-config --libs gtk+-3.0)",
            "-luuid"
          ],
          "cflags": [
            "-fexceptions",
            "<!@(pkg-config --cflags gtk+-3.0)",
            "-pthread"
          ],
          "cflags_cc": [
            "-fexceptions",
            "<!@(pkg-config --cflags gtk+-3.0)",
            "-pthread"
          ],
          "ldflags": [
            "-pthread"
          ],
          "cflags!": ["-fno-exceptions"],
          "cflags_cc!": ["-fno-exceptions"],
          "defines": ["NODE_ADDON_API_CPP_EXCEPTIONS"],
          "dependencies": [
            "<!(node -p \"require('node-addon-api').gyp\")"
          ]
        }]
      ]
    }
  ]
}

关键点解析:

配置项 作用
conditions: [['OS=="linux"', {...}]] GYP 的条件编译机制,整个 target 的源文件与编译选项只在 Linux 生效,实现平台隔离
<!@(...) 命令展开操作符:执行括号内的命令并把输出当作该位置的值。所有内嵌 pkg-config 的写法都是在调用 pkg-config 并取用其输出
pkg-config --cflags-only-I gtk+-3.0 | sed s/-I//g 只提取 GTK3 的 include 目录,再让 sed 去掉 -I 前缀,转换为 GYP 兼容的头文件搜索路径格式
pkg-config --libs gtk+-3.0 输出链接 GTK3 所需的 -l 库参数,加入 libraries
-fexceptions / cflags!cflags_cc! 中的 -fno-exceptions 强制打开 C++ 异常支持并移除 node-gyp 默认的 -fno-exceptions,配合 defines 中的 NODE_ADDON_API_CPP_EXCEPTIONS,让 node-addon-api 的异常机制可以正常工作
-pthread 开启 POSIX 线程支持,GTK 运行线程与本教程的线程模型依赖它
-luuid 链接 libuuid,用于 uuid/uuid.h 提供的 UUID 生成接口
dependencies<!(node -p ...) 引入 node-addon-api 自带的 gyp 配置片段

3. 定义 C++ 接口(include/cpp_code.h)

头文件声明了将暴露给 JS 侧的全部原生能力:

#pragma once
#include <string>
#include <functional>

namespace cpp_code {

std::string hello_world(const std::string& input);
void hello_gui();

// Callback function types
using TodoCallback = std::function<void(const std::string&)>;

// Callback setters
void setTodoAddedCallback(TodoCallback callback);
void setTodoUpdatedCallback(TodoCallback callback);
void setTodoDeletedCallback(TodoCallback callback);

} // namespace cpp_code
  • #pragma once 是防止同一编译单元内重复包含的头文件守卫;
  • hello_world:一个用于验证桥接链路的基础函数;
  • hello_gui:创建 GTK3 GUI 的入口;
  • TodoCallback 类型别名 + 三个 setter:供上层桥接代码注册"新增/更新/删除 Todo"三类事件回调,是原生侧通知 JavaScript 的通道。

4. 实现 GTK3 GUI(src/cpp_code.cc)

GTK 代码往往相当冗长,因此分小节逐步拼装。最终文件需要同时涵盖数据结构、全局状态、线程与主循环管理、事件处理以及回调管理。

4.1 基础配置与数据结构

#include <gtk/gtk.h>
#include <string>
#include <functional>
#include <chrono>
#include <vector>
#include <uuid/uuid.h>
#include <ctime>
#include <thread>
#include <memory>

using TodoCallback = std::function<void(const std::string &)>;

namespace cpp_code
{
  // Basic functions
  std::string hello_world(const std::string &input)
  {
    return "Hello from C++! You said: " + input;
  }

  // Data structures
  struct TodoItem
  {
    uuid_t id;
    std::string text;
    int64_t date;

    std::string toJson() const
    {
      char uuid_str[37];
      uuid_unparse(id, uuid_str);
      return "{"
             "\"id\":\"" +
             std::string(uuid_str) + "\","
                                     "\"text\":\"" +
             text + "\","
                    "\"date\":" +
             std::to_string(date) +
             "}";
    }

    static std::string formatDate(int64_t timestamp)
    {
      char date_str[64];
      time_t unix_time = timestamp / 1000;
      strftime(date_str, sizeof(date_str), "%Y-%m-%d", localtime(&unix_time));
      return date_str;
    }
  };
  • 引入 GTK3、标准库与 UUID 生成所需头文件;
  • TodoCallback 类型用于定义回传 JavaScript 的事件负载类型;
  • TodoItem 使用 UUID(uuid_t 唯一标识每条待办,记录文本与毫秒级时间戳,并提供两个关键成员:
    • toJson():把对象序列化为 JSON 字符串。它是 C++ 对象发送到 JavaScript 的前提——教程刻意保持 JSON 手写序列化的直白风格,生产代码可选用各类 C++ JSON 库;
    • formatDate():把时间戳格式化为 %Y-%m-%d 便于显示。

注意:目前尚无任何用户界面,下一步才开始搭 UI。

4.2 全局状态与前向声明

  // Forward declarations
  static void update_todo_row_label(GtkListBoxRow *row, const TodoItem &todo);
  static GtkWidget *create_todo_dialog(GtkWindow *parent, const TodoItem *existing_todo);

  // Global state
  namespace
  {
    TodoCallback g_todoAddedCallback;
    TodoCallback g_todoUpdatedCallback;
    TodoCallback g_todoDeletedCallback;
    GMainContext *g_gtk_main_context = nullptr;
    GMainLoop *g_main_loop = nullptr;
    std::thread *g_gtk_thread = nullptr;
    std::vector<TodoItem> g_todos;
  }

匿名命名空间中的全局变量负责跨函数共享应用状态:

  • 三个 TodoCallback:Todo 增/改/删操作的对外回调;
  • g_gtk_main_context / g_main_loop:GTK 主上下文与主循环指针,是线程协作的核心;
  • g_gtk_thread:GTK 运行线程指针;
  • g_todos:存放待办数据的容器。

线程模型在这里定型:GTK 并非线程安全库,所有 UI 操作必须发生在其主上下文中。由于 addon 的函数会被 Node.js/Electron 的 JS 线程调用,若直接在 JS 线程跑 GTK 事件循环,会阻塞 JavaScript 事件循环——所以必须把 GTK 放到独立线程,同时保持与 Electron 应用的双向通信。这正是教程中最需要被理解的设计动机。

4.3 工具函数(helper)

  // Helper functions
  static void notify_callback(const TodoCallback &callback, const std::string &json)
  {
    if (callback && g_gtk_main_context)
    {
      g_main_context_invoke(g_gtk_main_context, [](gpointer data) -> gboolean
                            {
            auto* cb_data = static_cast<std::pair<TodoCallback, std::string>*>(data);
            cb_data->first(cb_data->second);
            delete cb_data;
            return G_SOURCE_REMOVE; }, new std::pair<TodoCallback, std::string>(callback, json));
    }
  }

  static void update_todo_row_label(GtkListBoxRow *row, const TodoItem &todo)
  {
    auto *label = gtk_label_new((todo.text + " - " + TodoItem::formatDate(todo.date)).c_str());
    auto *old_label = GTK_WIDGET(gtk_container_get_children(GTK_CONTAINER(row))->data);
    gtk_container_remove(GTK_CONTAINER(row), old_label);
    gtk_container_add(GTK_CONTAINER(row), label);
    gtk_widget_show_all(GTK_WIDGET(row));
  }

  static GtkWidget *create_todo_dialog(GtkWindow *parent, const TodoItem *existing_todo = nullptr)
  {
    auto *dialog = gtk_dialog_new_with_buttons(
        existing_todo ? "Edit Todo" : "Add Todo",
        parent,
        GTK_DIALOG_MODAL,
        "_Cancel", GTK_RESPONSE_CANCEL,
        "_Save", GTK_RESPONSE_ACCEPT,
        nullptr);

    auto *content_area = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
    gtk_container_set_border_width(GTK_CONTAINER(content_area), 10);

    auto *entry = gtk_entry_new();
    if (existing_todo)
    {
      gtk_entry_set_text(GTK_ENTRY(entry), existing_todo->text.c_str());
    }
    gtk_container_add(GTK_CONTAINER(content_area), entry);

    auto *calendar = gtk_calendar_new();
    if (existing_todo)
    {
      time_t unix_time = existing_todo->date / 1000;
      struct tm *timeinfo = localtime(&unix_time);
      gtk_calendar_select_month(GTK_CALENDAR(calendar), timeinfo->tm_mon, timeinfo->tm_year + 1900);
      gtk_calendar_select_day(GTK_CALENDAR(calendar), timeinfo->tm_mday);
    }
    gtk_container_add(GTK_CONTAINER(content_area), calendar);

    gtk_widget_show_all(dialog);
    return dialog;
  }
  • notify_callback线程安全的回调通知。通过 g_main_context_invoke 把闭包投递到 GTK 主上下文中执行——因为 GTK 非线程安全,回调必须在主上下文内运行;数据以 std::pair<TodoCallback, std::string> 动态分配传递、执行后删除;
  • update_todo_row_label:重建列表行的文本标签(含格式化后的日期);
  • create_todo_dialog:创建"新增/编辑 Todo"对话框,包含文本输入框、日历控件,以及带键盘助记符的 _Cancel / _Save 按钮。若传入 existing_todo,会预填文本并让日历选中已有日期。

4.4 事件处理器(event handlers)

这段代码里唯一"Electron 特有"的部分,就是事件发生时向 JS 回调发通知:

  static void edit_action(GSimpleAction *action, GVariant *parameter, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));
    auto *row = gtk_list_box_get_selected_row(list);
    if (!row)
      return;

    gint index = gtk_list_box_row_get_index(row);
    auto size = static_cast<gint>(g_todos.size());
    if (index < 0 || index >= size)
      return;

    auto *dialog = create_todo_dialog(
        GTK_WINDOW(gtk_builder_get_object(builder, "window")),
        &g_todos[index]);

    if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT)
    {
      auto *entry = GTK_ENTRY(gtk_container_get_children(
                                  GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))))
                                  ->data);
      auto *calendar = GTK_CALENDAR(gtk_container_get_children(
                                        GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))))
                                        ->next->data);

      const char *new_text = gtk_entry_get_text(entry);

      guint year, month, day;
      gtk_calendar_get_date(calendar, &year, &month, &day);
      GDateTime *datetime = g_date_time_new_local(year, month + 1, day, 0, 0, 0);
      gint64 new_date = g_date_time_to_unix(datetime) * 1000;
      g_date_time_unref(datetime);

      g_todos[index].text = new_text;
      g_todos[index].date = new_date;

      update_todo_row_label(row, g_todos[index]);
      notify_callback(g_todoUpdatedCallback, g_todos[index].toJson());
    }

    gtk_widget_destroy(dialog);
  }

  static void delete_action(GSimpleAction *action, GVariant *parameter, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));
    auto *row = gtk_list_box_get_selected_row(list);
    if (!row)
      return;

    gint index = gtk_list_box_row_get_index(row);
    auto size = static_cast<gint>(g_todos.size());
    if (index < 0 || index >= size)
      return;

    std::string json = g_todos[index].toJson();
    gtk_container_remove(GTK_CONTAINER(list), GTK_WIDGET(row));
    g_todos.erase(g_todos.begin() + index);
    notify_callback(g_todoDeletedCallback, json);
  }

  static void on_add_clicked(GtkButton *button, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *entry = GTK_ENTRY(gtk_builder_get_object(builder, "todo_entry"));
    auto *calendar = GTK_CALENDAR(gtk_builder_get_object(builder, "todo_calendar"));
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));

    const char *text = gtk_entry_get_text(entry);
    if (strlen(text) > 0)
    {
      TodoItem todo;
      uuid_generate(todo.id);
      todo.text = text;

      guint year, month, day;
      gtk_calendar_get_date(calendar, &year, &month, &day);
      GDateTime *datetime = g_date_time_new_local(year, month + 1, day, 0, 0, 0);
      todo.date = g_date_time_to_unix(datetime) * 1000;
      g_date_time_unref(datetime);

      g_todos.push_back(todo);

      auto *row = gtk_list_box_row_new();
      auto *label = gtk_label_new((todo.text + " - " + TodoItem::formatDate(todo.date)).c_str());
      gtk_container_add(GTK_CONTAINER(row), label);
      gtk_container_add(GTK_CONTAINER(list), row);
      gtk_widget_show_all(row);

      gtk_entry_set_text(entry, "");

      notify_callback(g_todoAddedCallback, todo.toJson());
    }
  }

  static void on_row_activated(GtkListBox *list_box, GtkListBoxRow *row, gpointer user_data)
  {
    GMenu *menu = g_menu_new();
    g_menu_append(menu, "Edit", "app.edit");
    g_menu_append(menu, "Delete", "app.delete");

    auto *popover = gtk_popover_new_from_model(GTK_WIDGET(row), G_MENU_MODEL(menu));
    gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_RIGHT);
    gtk_popover_popup(GTK_POPOVER(popover));

    g_object_unref(menu);
  }

四个处理器的职责:

  • edit_action:取选中行及其索引,用该条 Todo 初始化编辑对话框;用户确认(GTK_RESPONSE_ACCEPT)后,把输入框文本与日历日期写回 g_todos[index],刷新行标签并通过 g_todoUpdatedCallback 通知 JS;
  • delete_action:从 GtkListBoxg_todos 中移除目标行,并发出 g_todoDeletedCallback
  • on_add_clicked:点击 Add 时校验文本非空,uuid_generate 生成唯一 ID,把日历选择转换为毫秒时间戳,push 进容器、动态创建列表行,随后清空输入框并发出 g_todoAddedCallback
  • on_row_activated:行被点击时基于 GMenu 模型弹出 popover 菜单(Edit / Delete 两个 action 对应 app.editapp.delete)。

4.5 GTK 应用初始化与 activate 处理

这一步"反直觉"的地方在于:Electron 本身是 GTK 应用,为什么原生代码还要再初始化一套 GTK 应用?原因在于这是运行在 Electron 进程旁边的原生 C++ 代码——它由 Electron 启动、拥有自己的原生窗口,因此在独立线程上运行自己的 GApplication 主循环:

  static gboolean init_gtk_app(gpointer user_data)
  {
    auto *app = static_cast<GtkApplication *>(user_data);
    g_application_run(G_APPLICATION(app), 0, nullptr);
    g_object_unref(app);
    if (g_main_loop)
    {
      g_main_loop_quit(g_main_loop);
    }
    return G_SOURCE_REMOVE;
  }

  static void activate_handler(GtkApplication *app, gpointer user_data)
  {
    auto *builder = gtk_builder_new();

    const GActionEntry app_actions[] = {
        {"edit", edit_action, nullptr, nullptr, nullptr, {0, 0, 0}},
        {"delete", delete_action, nullptr, nullptr, nullptr, {0, 0, 0}}};
    g_action_map_add_action_entries(G_ACTION_MAP(app), app_actions,
                                    G_N_ELEMENTS(app_actions), builder);

    gtk_builder_add_from_string(builder,
                                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
                                "<interface>"
                                "  <object class=\"GtkWindow\" id=\"window\">"
                                "    <property name=\"title\">Todo List</property>"
                                "    <property name=\"default-width\">400</property>"
                                "    <property name=\"default-height\">500</property>"
                                "    <child>"
                                "      <object class=\"GtkBox\">"
                                "        <property name=\"visible\">true</property>"
                                "        <property name=\"orientation\">vertical</property>"
                                "        <property name=\"spacing\">6</property>"
                                "        <property name=\"margin\">12</property>"
                                "        <child>"
                                "          <object class=\"GtkBox\">"
                                "            <property name=\"visible\">true</property>"
                                "            <property name=\"spacing\">6</property>"
                                "            <child>"
                                "              <object class=\"GtkEntry\" id=\"todo_entry\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"hexpand\">true</property>"
                                "                <property name=\"placeholder-text\">Enter todo item...</property>"
                                "              </object>"
                                "            </child>"
                                "            <child>"
                                "              <object class=\"GtkCalendar\" id=\"todo_calendar\">"
                                "                <property name=\"visible\">true</property>"
                                "              </object>"
                                "            </child>"
                                "            <child>"
                                "              <object class=\"GtkButton\" id=\"add_button\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"label\">Add</property>"
                                "              </object>"
                                "            </child>"
                                "          </object>"
                                "        </child>"
                                "        <child>"
                                "          <object class=\"GtkScrolledWindow\">"
                                "            <property name=\"visible\">true</property>"
                                "            <property name=\"vexpand\">true</property>"
                                "            <child>"
                                "              <object class=\"GtkListBox\" id=\"todo_list\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"selection-mode\">single</property>"
                                "              </object>"
                                "            </child>"
                                "          </object>"
                                "        </child>"
                                "      </object>"
                                "    </child>"
                                "  </object>"
                                "</interface>",
                                -1, nullptr);

    auto *window = GTK_WINDOW(gtk_builder_get_object(builder, "window"));
    auto *button = GTK_BUTTON(gtk_builder_get_object(builder, "add_button"));
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));

    gtk_window_set_application(window, app);

    g_signal_connect(button, "clicked", G_CALLBACK(on_add_clicked), builder);
    g_signal_connect(list, "row-activated", G_CALLBACK(on_row_activated), nullptr);

    gtk_widget_show_all(GTK_WIDGET(window));
  }

要点:

  • init_gtk_app:调用 g_application_run 真正跑起 GTK 应用,应用退出后解除循环;
  • activate_handler:构建 UI 时做四件事——
    1. GActionEntry 数组把 edit/delete 两个 action 注册到应用(与上文 popover 的 app.edit/app.delete 对应,注意把 builder 作为 user_data 传入 action);
    2. GTK XML 描述 UI(GtkBuilder 内联字符串)声明窗口结构:400×500 的主窗口、横向排列的输入控件条(GtkEntry + GtkCalendar + Add 按钮)、可滚动的单行选择 GtkListBox
    3. 通过 g_signal_connectbutton.clicked 接到 on_add_clicked、把 list.row-activated 接到 on_row_activated
    4. gtk_widget_show_all 显示窗口。

4.6 主 GUI 入口与线程管理

  void hello_gui()
  {
    if (g_gtk_thread != nullptr)
    {
      g_print("GTK application is already running.\n");
      return;
    }

    if (!gtk_init_check(0, nullptr))
    {
      g_print("Failed to initialize GTK.\n");
      return;
    }

    g_gtk_main_context = g_main_context_new();
    g_main_loop = g_main_loop_new(g_gtk_main_context, FALSE);

    g_gtk_thread = new std::thread([]()
                                   {
        GtkApplication* app = gtk_application_new("com.example.todo", G_APPLICATION_NON_UNIQUE);
        g_signal_connect(app, "activate", G_CALLBACK(activate_handler), nullptr);

        g_idle_add_full(G_PRIORITY_DEFAULT, init_gtk_app, app, nullptr);

        if (g_main_loop) {
            g_main_loop_run(g_main_loop);
        } });

    g_gtk_thread->detach();
  }

  void cleanup_gui()
  {
    if (g_main_loop && g_main_loop_is_running(g_main_loop))
    {
      g_main_loop_quit(g_main_loop);
    }

    if (g_main_loop)
    {
      g_main_loop_unref(g_main_loop);
      g_main_loop = nullptr;
    }

    if (g_gtk_main_context)
    {
      g_main_context_unref(g_gtk_main_context);
      g_gtk_main_context = nullptr;
    }

    g_gtk_thread = nullptr;
  }

生命周期管理:

  • hello_gui()(暴露给 JavaScript 的入口):
    1. 幂等保护:若 g_gtk_thread 已非空则直接返回;
    2. gtk_init_check 探测 GTK 初始化是否成功;
    3. 新建 GTK 主上下文与主循环;
    4. std::thread 内创建 GtkApplication(应用 ID com.example.todoG_APPLICATION_NON_UNIQUE 允许同应用多实例),连接 activate 信号;
    5. g_idle_add_fullinit_gtk_app 在主循环空闲时执行 g_application_run
    6. detach() 分离线程,使其独立运行、不被 C++ 线程析构影响。
  • cleanup_gui():先退出运行中的主循环,再按引用计数释放 GMainLoopGMainContext,并把线程指针置空。

再次强调线程分离的原因:把 GTK 主循环放进独立线程,才能避免其阻塞 Node.js 的事件循环,这是 GTK GUI 能嵌入 Electron 进程模型的前提。

4.7 回调注册

  void setTodoAddedCallback(TodoCallback callback)
  {
    g_todoAddedCallback = callback;
  }

  void setTodoUpdatedCallback(TodoCallback callback)
  {
    g_todoUpdatedCallback = callback;
  }

  void setTodoDeletedCallback(TodoCallback callback)
  {
    g_todoDeletedCallback = callback;
  }

三个 setter 把桥接层传入的回调写入对应全局变量,从而完成"桥接层 → 原生层"这一方向的事件注入。

至此,src/cpp_code.cc 已完整包含基础函数、数据结构、前向声明、全局状态、工具函数、事件处理器、应用初始化、线程生命周期与回调管理九个部分。原生侧与操作系统(GTK)打交道的代码已经完成,下一步进入与 JavaScript 世界桥接的部分。

5. 编写 Node.js Addon 桥接层(src/cpp_addon.cc)

5.1 最小骨架

#include <napi.h>
#include <string>
#include "cpp_code.h"

// Class to wrap our C++ code will go here

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  // We'll add code here later
  return exports;
}

NODE_API_MODULE(cpp_addon, Init)

这是使用 node-addon-api 的 Node.js addon 最小结构:加载时调用 InitNODE_API_MODULE 宏负责注册初始化函数。

5.2 包装类 CppAddon

把骨架中"Class to wrap our C++ code will go here"注释替换为如下类定义:

class CppAddon : public Napi::ObjectWrap<CppAddon>
{
public:
  static Napi::Object Init(Napi::Env env, Napi::Object exports)
  {
    Napi::Function func = DefineClass(env, "CppLinuxAddon", {
      InstanceMethod("helloWorld", &CppAddon::HelloWorld),
      InstanceMethod("helloGui", &CppAddon::HelloGui),
      InstanceMethod("on", &CppAddon::On),
      InstanceMethod("destroy", &CppAddon::Destroy)
    });

    Napi::FunctionReference *constructor = new Napi::FunctionReference();
    *constructor = Napi::Persistent(func);
    env.SetInstanceData(constructor);

    exports.Set("CppLinuxAddon", func);
    return exports;
  }

  CppAddon(const Napi::CallbackInfo &info)
      : Napi::ObjectWrap<CppAddon>(info),
        env_(info.Env()),
        emitter(Napi::Persistent(Napi::Object::New(info.Env()))),
        callbacks(Napi::Persistent(Napi::Object::New(info.Env()))),
        tsfn_(nullptr)
  {
    // We'll implement the constructor together with a callback struct later
  }

  ~CppAddon()
  {
    if (tsfn_ != nullptr)
    {
      napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
      tsfn_ = nullptr;
    }
  }

private:
  Napi::Env env_;
  Napi::ObjectReference emitter;
  Napi::ObjectReference callbacks;
  napi_threadsafe_function tsfn_;

  // Method implementations will go here
};

类通过继承 Napi::ObjectWrap<CppAddon> 获得 JS 对象包装能力:

  • static InitDefineClass 定义 JS 类 CppLinuxAddon,暴露四个实例方法:helloWorld(验证桥接)、helloGui(启动 GTK3 UI)、on(注册事件回调)、destroy(退出前释放所有持久引用);
  • 构造器持有三个关键成员:emitter(用于向 JS 发事件的接收对象)、callbacks(已注册 JS 回调函数表)、tsfn_(线程安全函数句柄,是 GTK3 线程通信的核心);
  • 析构函数在对象被 GC 时释放线程安全函数。

5.3 HelloWorld 与 HelloGui 方法实现

替换私有区"Method implementations will go here":

Napi::Value HelloWorld(const Napi::CallbackInfo &info)
{
  Napi::Env env = info.Env();

  if (info.Length() < 1 || !info[0].IsString())
  {
    Napi::TypeError::New(env, "Expected string argument").ThrowAsJavaScriptException();
    return env.Null();
  }

  std::string input = info[0].As<Napi::String>();
  std::string result = cpp_code::hello_world(input);

  return Napi::String::New(env, result);
}

void HelloGui(const Napi::CallbackInfo &info)
{
  cpp_code::hello_gui();
}

// On() method implementation will go here
  • HelloWorld():校验第一个参数必须是字符串,把 JS 字符串转成 C++ std::string,调用 cpp_code::hello_world 后返回 JS 字符串;
  • HelloGui():无参数直接转发给 cpp_code::hello_gui(),返回值 void,因为该方法只负责拉起窗口。

这里不断出现的 Napi::CallbackInfo 来自 node-addon-api(N-API 的 C++ 封装)。它封装了某次 JS 调用的全部信息,包括:传入参数、执行环境(info.Env())、this 值、参数个数(info.Length())。凡是可从 JS 调用的原生方法都会收到一个 CallbackInfo,C++ 代码借此完成参数校验后再处理——HelloWorld() 正是标准用法示例。

5.4 事件系统:TSFN 桥接 GTK 线程与 JS 线程

事件系统是原生开发最棘手的部分:cpp_code.cc 中的回调是在 GTK 线程 被触发的,而 JavaScript 只能在 Node.js/Electron 主线程 执行,必须通过线程安全函数把跨线程调用编排到 JS 线程。

先替换 On() method implementation will go here

Napi::Value On(const Napi::CallbackInfo &info)
{
  Napi::Env env = info.Env();

  if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction())
  {
    Napi::TypeError::New(env, "Expected (string, function) arguments").ThrowAsJavaScriptException();
    return env.Undefined();
  }

  callbacks.Value().Set(info[0].As<Napi::String>(), info[1].As<Napi::Function>());
  return env.Undefined();
}

On() 接受 (事件名, 函数) 并把 JS 回调存进 callbacks 表,供后续事件触发时查找。

接下来替换构造器中"…callback struct later"注释处,加入 CallbackData 结构体与完整的线程安全函数初始化逻辑:

  struct CallbackData
  {
    std::string eventType;
    std::string payload;
    CppAddon *addon;
  };

  CppAddon(const Napi::CallbackInfo &info)
      : Napi::ObjectWrap<CppAddon>(info),
        env_(info.Env()),
        emitter(Napi::Persistent(Napi::Object::New(info.Env()))),
        callbacks(Napi::Persistent(Napi::Object::New(info.Env()))),
        tsfn_(nullptr)
  {
    napi_status status = napi_create_threadsafe_function(
        env_,
        nullptr,
        nullptr,
        Napi::String::New(env_, "CppCallback"),
        0,
        1,
        nullptr,
        nullptr,
        this,
        [](napi_env env, napi_value js_callback, void *context, void *data)
        {
          auto *callbackData = static_cast<CallbackData *>(data);
          if (!callbackData)
            return;

          Napi::Env napi_env(env);
          Napi::HandleScope scope(napi_env);

          auto addon = static_cast<CppAddon *>(context);
          if (!addon)
          {
            delete callbackData;
            return;
          }

          try
          {
            auto callback = addon->callbacks.Value().Get(callbackData->eventType).As<Napi::Function>();
            if (callback.IsFunction())
            {
              callback.Call(addon->emitter.Value(), {Napi::String::New(napi_env, callbackData->payload)});
            }
          }
          catch (...)
          {
          }

          delete callbackData;
        },
        &tsfn_);

    if (status != napi_ok)
    {
      Napi::Error::New(env_, "Failed to create threadsafe function").ThrowAsJavaScriptException();
      return;
    }

    // Set up the callbacks here
    auto makeCallback = this
    {
      return this, eventType
      {
        if (tsfn_ != nullptr)
        {
          auto *data = new CallbackData{
              eventType,
              payload,
              this};
          napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
        }
      };
    };

    cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));
    cpp_code::setTodoUpdatedCallback(makeCallback("todoUpdated"));
    cpp_code::setTodoDeletedCallback(makeCallback("todoDeleted"));
  }

这是整个桥接层最复杂的部分,逐步拆解:

  1. CallbackData:一次跨线程调用所需携带的数据,包含事件类型、JSON 负载与 addon 实例指针;
  2. napi_create_threadsafe_function:创建线程安全函数。其回调会在 Node.js 主线程被调度执行,流程是:把 data 还原为 CallbackData,用 HandleScope 管理局部句柄,从 callbacks 表中按事件名取出对应 JS 函数,以 emitterthis 调用之,最后释放 data
  3. makeCallback lambda 工厂:为某事件生成一个可被 cpp_code 调用、且能安全投递到 JS 线程的闭包——内部把事件名、负载封装进堆上分配的 CallbackData,再以 napi_tsfn_blocking 模式调用 napi_call_threadsafe_function 入队;
  4. 构造器末尾把三个闭包分别注册给 cpp_codesetTodoAddedCallback/setTodoUpdatedCallback/setTodoDeletedCallback,对接 4.7 节的 setter。

为什么 TSFN 是刚需,三点原因:

  • 线程安全:Electron 中 JS 运行在单一线程,跨线程直接调用 JS 会崩溃或产生竞态;
  • 队列管理:TSFN 自动把跨线程调用排入队列、统一在 JS 线程上执行;
  • 资源管理:正确的引用计数保证对象在使用期间不会被 GC 回收。

它把 GTK3 事件循环与 Node.js 事件循环缝合起来,使 GUI 上发生的交互能安全触发 JS 回调。如果想深入了解,可参阅 N-API 官方文档中 napi_create_threadsafe_function 条目、node-addon-api 的 ThreadSafe Function 封装说明,以及 Node.js 官方关于"不要阻塞事件循环"的线程模型文章。

5.5 cpp_addon.cc 完整文件

把上述所有片段按序拼装后,src/cpp_addon.cc 的完整内容为:

#include <napi.h>
#include <string>
#include "cpp_code.h"

class CppAddon : public Napi::ObjectWrap<CppAddon>
{
public:
  static Napi::Object Init(Napi::Env env, Napi::Object exports)
  {
    Napi::Function func = DefineClass(env, "CppLinuxAddon", {
      InstanceMethod("helloWorld", &CppAddon::HelloWorld),
      InstanceMethod("helloGui", &CppAddon::HelloGui),
      InstanceMethod("on", &CppAddon::On),
      InstanceMethod("destroy", &CppAddon::Destroy)
    });

    Napi::FunctionReference *constructor = new Napi::FunctionReference();
    *constructor = Napi::Persistent(func);
    env.SetInstanceData(constructor);

    exports.Set("CppLinuxAddon", func);
    return exports;
  }

  struct CallbackData
  {
    std::string eventType;
    std::string payload;
    CppAddon *addon;
  };

  CppAddon(const Napi::CallbackInfo &info)
      : Napi::ObjectWrap<CppAddon>(info),
        env_(info.Env()),
        emitter(Napi::Persistent(Napi::Object::New(info.Env()))),
        callbacks(Napi::Persistent(Napi::Object::New(info.Env()))),
        tsfn_(nullptr)
  {
    napi_status status = napi_create_threadsafe_function(
        env_,
        nullptr,
        nullptr,
        Napi::String::New(env_, "CppCallback"),
        0,
        1,
        nullptr,
        nullptr,
        this,
        [](napi_env env, napi_value js_callback, void *context, void *data)
        {
          auto *callbackData = static_cast<CallbackData *>(data);
          if (!callbackData)
            return;

          Napi::Env napi_env(env);
          Napi::HandleScope scope(napi_env);

          auto addon = static_cast<CppAddon *>(context);
          if (!addon)
          {
            delete callbackData;
            return;
          }

          try
          {
            auto callback = addon->callbacks.Value().Get(callbackData->eventType).As<Napi::Function>();
            if (callback.IsFunction())
            {
              callback.Call(addon->emitter.Value(), {Napi::String::New(napi_env, callbackData->payload)});
            }
          }
          catch (...)
          {
          }

          delete callbackData;
        },
        &tsfn_);

    if (status != napi_ok)
    {
      Napi::Error::New(env_, "Failed to create threadsafe function").ThrowAsJavaScriptException();
      return;
    }

    // Set up the callbacks here
    auto makeCallback = this
    {
      return this, eventType
      {
        if (tsfn_ != nullptr)
        {
          auto *data = new CallbackData{
              eventType,
              payload,
              this};
          napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
        }
      };
    };

    cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));
    cpp_code::setTodoUpdatedCallback(makeCallback("todoUpdated"));
    cpp_code::setTodoDeletedCallback(makeCallback("todoDeleted"));
  }

  ~CppAddon()
  {
    if (tsfn_ != nullptr)
    {
      napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
      tsfn_ = nullptr;
    }
  }

private:
  Napi::Env env_;
  Napi::ObjectReference emitter;
  Napi::ObjectReference callbacks;
  napi_threadsafe_function tsfn_;

  Napi::Value HelloWorld(const Napi::CallbackInfo &info)
  {
    Napi::Env env = info.Env();

    if (info.Length() < 1 || !info[0].IsString())
    {
      Napi::TypeError::New(env, "Expected string argument").ThrowAsJavaScriptException();
      return env.Null();
    }

    std::string input = info[0].As<Napi::String>();
    std::string result = cpp_code::hello_world(input);

    return Napi::String::New(env, result);
  }

  void HelloGui(const Napi::CallbackInfo &info)
  {
    cpp_code::hello_gui();
  }

  Napi::Value On(const Napi::CallbackInfo &info)
  {
    Napi::Env env = info.Env();

    if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction())
    {
      Napi::TypeError::New(env, "Expected (string, function) arguments").ThrowAsJavaScriptException();
      return env.Undefined();
    }

    callbacks.Value().Set(info[0].As<Napi::String>(), info[1].As<Napi::Function>());
    return env.Undefined();
  }

  Napi::Value Destroy(const Napi::CallbackInfo &info)
  {
    callbacks.Reset();
    emitter.Reset();

    if (tsfn_ != nullptr)
    {
      napi_release_threadsafe_function(tsfn_, napi_tsfn_abort);
      tsfn_ = nullptr;
    }

    return info.Env().Undefined();
  }
};

Napi::Object Init(Napi::Env env, Napi::Object exports)
{
  return CppAddon::Init(env, exports);
}

NODE_API_MODULE(cpp_addon, Init)

与前面片段相比,完整文件里多出 Destroy() 的实现:它重置 callbacksemitter 的持久引用,并以 napi_tsfn_abort 模式释放线程安全函数——这会中止排队中的待执行调用,是退出清理的关键一步。模块注册处则把导出的 Init 转发给 CppAddon::Init

6. 创建 JavaScript 封装(js/index.js)

C++ 侧充满样板代码,许多逻辑(例如数据转换)放在 JavaScript 里更高效——不少生产应用都会在调用原生代码前先做数据整形,例如本封装把时间戳转成真正的 JS Date

const EventEmitter = require('events');

class CppLinuxAddon extends EventEmitter {
  constructor() {
    super()

    if (process.platform !== 'linux') {
      throw new Error('This module is only available on Linux');
    }

    const native = require('bindings')('cpp_addon')
    this.addon = new native.CppLinuxAddon()

    // Set up event forwarding
    this.addon.on('todoAdded', (payload) => {
      this.emit('todoAdded', this.parse(payload))
    });

    this.addon.on('todoUpdated', (payload) => {
      this.emit('todoUpdated', this.parse(payload))
    })

    this.addon.on('todoDeleted', (payload) => {
      this.emit('todoDeleted', this.parse(payload))
    })
  }

  helloWorld(input = "") {
    return this.addon.helloWorld(input)
  }

  helloGui() {
    return this.addon.helloGui()
  }

  destroy() {
    this.addon.destroy()
  }

  // Parse JSON and convert date to JavaScript Date object
  parse(payload) {
    const parsed = JSON.parse(payload)

    return { ...parsed, date: new Date(parsed.date) }
  }
}

if (process.platform === 'linux') {
  module.exports = new CppLinuxAddon()
} else {
  // Return empty object on non-Linux platforms
  module.exports = {}
}

封装层职责:

  • 继承 EventEmitter,为原生事件提供统一的 Node.js 事件接口;
  • 只在 Linux 平台加载真实实现,其余平台导出空对象作降级;
  • 构造器通过 bindings('cpp_addon') 定位编译产物并实例化原生类,随后把原生层的 todoAdded/todoUpdated/todoDeleted 三个事件逐一转发为自身事件,且在转发时用 parse() 把 JSON 负载解析为对象、把毫秒时间戳变成 Date 实例;
  • helloWorld / helloGui / destroy 对外暴露简洁方法。

[!IMPORTANT] 应用退出前(例如 will-quitbefore-quit 事件处理器中)必须调用 destroy()。否则对回调与线程安全函数的持久引用会阻止原生 addon 析构函数执行,导致 Electron 退出时挂起。

7. 构建并接入 Electron 应用

所有文件就位后,编译 addon:

npm run build

编译成功即得到一个可供 Electron 加载的 .node 模块。将 addon 作为依赖放入 Electron 工程后,需要按目标 Electron 版本执行重建(npm run build-electron 对应 electron-rebuild),原因与做法参见 Native Node Modules

使用示例

// In your Electron main process or renderer process
import cppLinux from 'cpp-linux'

// Test the basic functionality
console.log(cppLinux.helloWorld('Hi!'))
// Output: "Hello from C++! You said: Hi!"

// Set up event listeners for GTK GUI interactions
cppLinux.on('todoAdded', (todo) => {
  console.log('New todo added:', todo)
  // todo: { id: "uuid-string", text: "Todo text", date: Date object }
})

cppLinux.on('todoUpdated', (todo) => {
  console.log('Todo updated:', todo)
})

cppLinux.on('todoDeleted', (todo) => {
  console.log('Todo deleted:', todo)
})

// Launch the native GTK GUI
cppLinux.helloGui()

运行时的实际表现:

  1. helloWorld() 返回来自 C++ 的问候语;
  2. 三个事件监听器会在用户操作 GTK3 界面时被触发;
  3. helloGui() 打开一个原生 GTK3 窗口,包含:待办文本输入框、日期选择日历、"Add" 按钮、可滚动列表;列表行被激活时会弹出 Edit / Delete 上下文菜单。

对原生 GTK3 界面的一切操作都会触发对应的 JavaScript 事件,Electron 应用因此可以实时响应原生 GUI 行为。此外,读者可以在 Electron 仓库源码 中看到 Electron 自身在 Linux 侧如何使用 GTK3 处理图标主题、菜单与打印对话框(如 shell/browser/browser_linux.ccshell/browser/ui/gtk/menu_gtk.cc),以加深对"原生代码与 Chromium 共享 GTK3 运行时"这一前提的理解。

总结

你已完成一个面向 Linux 的完整 C++/GTK3 原生 addon:

  1. 打通 JavaScript 与 C++ 的双向桥接;
  2. 让原生 GTK3 GUI 在独立线程中运行,不阻塞 JS 事件循环;
  3. 实现一个具备增删改、日期选择与右键菜单的 Todo 示例应用;
  4. 采用与 Electron 内置 Chromium 运行时一致的 GTK3;
  5. 借助线程安全函数安全地处理 C++ → JavaScript 的回调。

这套基础可以继续扩展,用于在 Electron 中实现更复杂的 Linux 专属能力——访问系统特性、集成 Linux 原生库,或打造高性能原生界面。进一步了解 GTK3 可参考 GTK3 文档与 GLib/GObject 文档;扩展原生 addon 能力时可参考 N-API 与 node-addon-api 的官方说明。若需在其他平台实践类似模式,可继续阅读 Windows / C++ 篇macOS / Objective-C 篇macOS / Swift 篇

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
594
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
516
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388