跳转至

原生代码与 Electron:Objective-C(macOS)

本教程基于原生代码与 Electron 的通用介绍,重点介绍如何使用 Objective-C、Objective-C++ 和 Cocoa 框架为 macOS 创建原生插件。为了说明如何在 Electron 应用中嵌入原生 macOS 代码,我们将构建一个基本的原生 macOS GUI(使用 AppKit),并与 Electron 的 JavaScript 进行通信。

具体来说,我们将集成两个 macOS 框架:

  • AppKit - macOS 应用的主要 UI 框架,提供窗口、按钮、文本字段等组件。
  • Foundation - 提供数据管理、文件系统交互和其他基本服务的框架。

本教程对已经熟悉 Objective-C 和 Cocoa 开发的读者最有帮助。你应该理解 macOS 开发中常用的基本概念,例如委托、NSObjects 和目标-动作模式。

[!NOTE] 如果你还不熟悉这些概念,Apple 的 Objective-C 文档 是一个很好的起点。

需求

与我们的原生代码与 Electron 通用介绍一样,本教程假设你已安装 Node.js 和 npm,以及编译 macOS 原生代码所需的基本工具。你需要:

  • 已安装 Xcode(可从 Mac App Store 获取)
  • Xcode Command Line Tools(可以在 Terminal 中运行 xcode-select --install 进行安装)

1) 创建包

你可以复用我们在原生代码与 Electron教程中创建的包。本教程不会重复其中描述的步骤。让我们先设置基本插件文件夹结构:

my-native-objc-addon/
├── binding.gyp
├── include/
│   └── objc_code.h
├── js/
│   └── index.js
├── package.json
└── src/
    ├── objc_addon.mm
    └── objc_code.mm

我们的 package.json 应该如下所示:

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

2) 设置构建配置

对于使用 Objective-C 的 macOS 专用插件,我们需要修改 binding.gyp 文件,以包含适当的框架和编译器标志。我们需要:

  1. 确保插件仅在 macOS 上编译
  2. 包含必要的 macOS 框架(Foundation 和 AppKit)
  3. 为 Objective-C/C++ 支持配置编译器
binding.gyp
{
  "targets": [
    {
      "target_name": "objc_addon",
      "conditions": [
        ['OS=="mac"', {
          "sources": [
            "src/objc_addon.mm",
            "src/objc_code.mm"
          ],
          "include_dirs": [
            "<!@(node -p \"require('node-addon-api').include\")",
            "include"
          ],
          "libraries": [
            "-framework Foundation",
            "-framework AppKit"
          ],
          "dependencies": [
            "<!(node -p \"require('node-addon-api').gyp\")"
          ],
          "xcode_settings": {
            "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
            "CLANG_CXX_LIBRARY": "libc++",
            "MACOSX_DEPLOYMENT_TARGET": "11.0",
            "CLANG_ENABLE_OBJC_ARC": "YES",
            "OTHER_CFLAGS": [
              "-ObjC++",
              "-std=c++17"
            ]
          },
          "defines": [
            "NODE_ADDON_API_CPP_EXCEPTIONS"
          ]
        }]
      ]
    }
  ]
}

注意关键的 macOS 专用设置:

  • 源文件的 .mm 扩展名:这表示 Objective-C++ 文件,可以混合使用 Objective-C 和 C++。
  • libraries:此部分包含 Foundation 和 AppKit 框架
  • xcode_settings 包含:
  • CLANG_ENABLE_OBJC_ARC:"YES" 启用自动引用计数,便于内存管理
  • OTHER_CFLAGS:-ObjC++ 以正确处理 Objective-C++ 编译
  • MACOSX_DEPLOYMENT_TARGET:此标志指定支持的最小 macOS 版本。你通常希望它与应用支持的最低 macOS 版本保持一致。

3) 定义 Objective-C 接口

让我们在 include/objc_code.h 中定义接口:

include/objc_code.h
#pragma once
#include <string>
#include <functional>

namespace objc_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);

} // namespace objc_code

此头文件:

  • 包含通用教程中的基本 hello_world 函数
  • 添加 hello_gui 函数以创建原生 macOS GUI
  • 定义 Todo 操作的回调类型
  • 提供这些回调的设置函数

4) 实现 Objective-C 代码

现在,让我们在 src/objc_code.mm 中实现 Objective-C 代码。这里我们将使用 AppKit 创建原生 macOS GUI。

我们总是将代码添加到文件底部。为了让本教程更容易跟随,我们将从基本结构开始,逐步添加功能。

设置基本结构

src/objc_code.mm
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <string>
#import <functional>
#import "../include/objc_code.h"

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

static TodoCallback g_todoAddedCallback;

// More code to follow later...

这会导入所需的框架并定义回调类型。静态变量 g_todoAddedCallback 将存储我们的 JavaScript 回调函数。

定义窗口控制器接口

在 objc_code.mm 的底部,添加以下代码以定义我们的窗口控制器类接口:

src/objc_code.mm
// Previous code...

// Forward declaration of our custom classes
@interface TodoWindowController : NSWindowController
@property (strong) NSTextField *textField;
@property (strong) NSDatePicker *datePicker;
@property (strong) NSButton *addButton;
@property (strong) NSTableView *tableView;
@property (strong) NSMutableArray<NSDictionary*> *todos;
@end

// More code to follow later...

这声明了我们的 TodoWindowController 类,它将负责管理窗口和 UI 组件:

  • 一个文本字段(NSTextField),用于输入待办事项文本
  • 一个日期选择器(NSDatePicker),用于选择日期
  • 一个“添加”按钮(NSButton)
  • 一个表格视图,用于显示待办事项(NSTableView)
  • 一个数组,用于存储待办事项(NSMutableArray)

实现窗口控制器

在 objc_code.mm 的底部,添加以下代码,开始实现窗口控制器,并包含一个初始化方法:

src/objc_code.mm
// Previous code...

// Controller for the main window
@implementation TodoWindowController

- (instancetype)init {
    self = [super initWithWindowNibName:@""];
    if (self) {
        // Create an array to store todos
        _todos = [NSMutableArray array];
        [self setupWindow];
    }
    return self;
}

// More code to follow later...

这会初始化我们的控制器。我们未使用 nib 文件,因此向 initWithWindowNibName 传入一个空字符串。我们创建一个空数组来存储待办事项,并调用 setupWindow 方法,该方法将在下一步实现。

此时,我们的完整文件如下所示:

src/objc_code.mm
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <string>
#import <functional>
#import "../include/objc_code.h"

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

static TodoCallback g_todoAddedCallback;

// Forward declaration of our custom classes
@interface TodoWindowController : NSWindowController
@property (strong) NSTextField *textField;
@property (strong) NSDatePicker *datePicker;
@property (strong) NSButton *addButton;
@property (strong) NSTableView *tableView;
@property (strong) NSMutableArray<NSDictionary*> *todos;
@end

// Controller for the main window
@implementation TodoWindowController

- (instancetype)init {
    self = [super initWithWindowNibName:@""];
    if (self) {
        // Create an array to store todos
        _todos = [NSMutableArray array];
        [self setupWindow];
    }
    return self;
}

// More code to follow later...

创建窗口和基本 UI

现在,我们将添加一个 setupWindow() 方法。这个方法乍一看可能有点复杂,但它实际上只是实例化多个 UI 控件,然后将它们添加到我们的窗口中。

src/objc_code.mm
// Previous code...

- (void)setupWindow {
    // Create a window
    NSRect frame = NSMakeRect(0, 0, 400, 300);
    NSWindow *window = [[NSWindow alloc] initWithContentRect:frame
                                         styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable
                                         backing:NSBackingStoreBuffered
                                         defer:NO];
    [window setTitle:@"Todo List"];
    [window center];
    self.window = window;

    // Set up the content view with auto layout
    NSView *contentView = [window contentView];

    // Create text field
    _textField = [[NSTextField alloc] initWithFrame:NSMakeRect(20, 260, 200, 24)];
    [_textField setPlaceholderString:@"Enter a todo..."];
    [contentView addSubview:_textField];

    // Create date picker
    _datePicker = [[NSDatePicker alloc] initWithFrame:NSMakeRect(230, 260, 100, 24)];
    [_datePicker setDatePickerStyle:NSDatePickerStyleTextField];
    [_datePicker setDatePickerElements:NSDatePickerElementFlagYearMonthDay];
    [contentView addSubview:_datePicker];

    // Create add button
    _addButton = [[NSButton alloc] initWithFrame:NSMakeRect(340, 260, 40, 24)];
    [_addButton setTitle:@"Add"];
    [_addButton setBezelStyle:NSBezelStyleRounded];
    [_addButton setTarget:self];
    [_addButton setAction:@selector(addTodo:)];
    [contentView addSubview:_addButton];

    // More UI elements to follow in the next step...
}

// More code to follow later...

该方法:

  1. 创建一个带有标题和标准窗口控件的窗口
  2. 将窗口居中显示在屏幕上
  3. 创建一个用于输入待办事项文本的文本字段
  4. 添加一个日期选择器,并配置为仅显示日期(不显示时间)
  5. 添加一个“添加”按钮,点击时将调用 addTodo: 方法

我们仍然缺少用于显示待办事项的表格视图。让我们将其添加到 setupWindow() 方法的底部,也就是上面代码中标注为 More UI elements to follow in the next step... 的位置。

src/objc_code.mm
// Previous code...

- (void)setupWindow {
  // Previous setupWindow() code...

  // Create a scroll view for the table
    NSScrollView *scrollView = [[NSScrollView alloc] initWithFrame:NSMakeRect(20, 20, 360, 230)];
    [scrollView setBorderType:NSBezelBorder];
    [scrollView setHasVerticalScroller:YES];
    [contentView addSubview:scrollView];

    // Create table view
    _tableView = [[NSTableView alloc] initWithFrame:NSMakeRect(0, 0, 360, 230)];

    // Add a column for the todo text
    NSTableColumn *textColumn = [[NSTableColumn alloc] initWithIdentifier:@"text"];
    [textColumn setWidth:240];
    [textColumn setTitle:@"Todo"];
    [_tableView addTableColumn:textColumn];

    // Add a column for the date
    NSTableColumn *dateColumn = [[NSTableColumn alloc] initWithIdentifier:@"date"];
    [dateColumn setWidth:100];
    [dateColumn setTitle:@"Date"];
    [_tableView addTableColumn:dateColumn];

    // Set the table's delegate and data source
    [_tableView setDataSource:self];
    [_tableView setDelegate:self];

    // Add the table to the scroll view
    [scrollView setDocumentView:_tableView];
}

// More code to follow later...

这扩展了我们的 setupWindow 方法,使其能够:

  1. 创建一个滚动视图来容纳表格
  2. 创建一个表格视图,包含两列:一列用于待办事项文本,另一列用于日期
  3. 将数据源和委托设置为此类
  4. 将表格添加到滚动视图

至此,setupWindow() 中的 UI 元素已完成,我们现在可以继续处理业务逻辑。

实现“添加待办事项”功能

接下来,让我们实现 addTodo: 方法,以处理添加新的待办事项。这里我们需要执行两组操作:首先,我们需要处理原生 UI,并执行诸如从 UI 元素中获取数据或重置它们之类的操作。然后,我们还需要通知 JavaScript 世界关于新添加的待办事项。

为了让本教程易于跟随,我们将分两步完成。

src/objc_code.mm
// Previous code...

// Action method for the Add button
- (void)addTodo:(id)sender {
    NSString *text = [_textField stringValue];
    if ([text length] > 0) {
        NSDate *date = [_datePicker dateValue];

        // Create a unique ID
        NSUUID *uuid = [NSUUID UUID];

        // Create a dictionary to store the todo
        NSDictionary *todo = @{
            @"id": [uuid UUIDString],
            @"text": text,
            @"date": date
        };

        // Add to our array
        [_todos addObject:todo];

        // Reload the table
        [_tableView reloadData];

        // Reset the text field
        [_textField setStringValue:@""];

        // Next, we'll notify our JavaScript world here...
    }
}

// More code to follow later...

该方法:

  1. 从文本框中获取文本
  2. 如果文本不为空,则创建一个具有唯一 ID、输入文本和所选日期的新待办事项
  3. 将待办事项添加到我们的数组中
  4. 重新加载表格以显示新的待办事项
  5. 清空文本框,以便进行下一次输入

现在,让我们扩展 addTodo: 方法,以便在添加待办事项时通知 JavaScript。我们会在方法底部执行此操作,当前那里写着“接下来,我们将在这里通知我们的 JavaScript 世界...”。

src/objc_code.mm
// Previous code...

// Action method for the Add button
- (void)addTodo:(id)sender {
    NSString *text = [_textField stringValue];
    if ([text length] > 0) {
        // Previous addTodo() code...

        // Call the callback if it exists
        if (g_todoAddedCallback) {
            // Convert the todo to JSON
            NSError *error;
            NSData *jsonData = [NSJSONSerialization dataWithJSONObject:@{
                @"id": [uuid UUIDString],
                @"text": text,
                @"date": @((NSTimeInterval)[date timeIntervalSince1970] * 1000)
            } options:0 error:&error];

            if (!error) {
                NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
                std::string cppJsonString = [jsonString UTF8String];
                g_todoAddedCallback(cppJsonString);
            }
        }
    }
}

// More code to follow later...

这添加了一段代码,用于执行大量转换(以便 N-API 最终能够将此数据转换为可供 V8 和 JavaScript 世界使用的结构)——然后调用我们的 JavaScript 回调。具体来说,它执行以下操作:

  1. 检查是否已注册回调函数
  2. 将待办事项转换为 JSON 格式
  3. 将日期转换为自纪元以来的毫秒数(JavaScript 日期格式)
  4. 将 JSON 转换为 C++ 字符串
  5. 使用 JSON 字符串调用回调函数

我们现在已完成 addTodo: 方法,可以继续下一步:表格视图的数据源。

实现表格视图数据源

让我们实现表格视图数据源方法,以显示我们的待办事项:

src/objc_code.mm
// Previous code...

// NSTableViewDataSource methods
- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView {
    return [_todos count];
}

- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row {
    NSDictionary *todo = _todos[row];
    NSString *identifier = [tableColumn identifier];

    if ([identifier isEqualToString:@"text"]) {
        return todo[@"text"];
    } else if ([identifier isEqualToString:@"date"]) {
        NSDate *date = todo[@"date"];
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        [formatter setDateStyle:NSDateFormatterShortStyle];
        return [formatter stringFromDate:date];
    }

    return nil;
}

@end

// More code to follow later...

这些方法:

  • 为表格视图返回待办事项的数量
  • 为表格中的每个单元格提供文本或格式化后的日期

实现 C++ 函数

最后,我们需要实现头文件中声明的 C++ 命名空间函数:

src/objc_code.mm
// Previous code...

namespace objc_code {

std::string hello_world(const std::string& input) {
    return "Hello from Objective-C! You said: " + input;
}

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

void hello_gui() {
    // Create and run the GUI on the main thread
    dispatch_async(dispatch_get_main_queue(), ^{
        // Create our window controller
        TodoWindowController *windowController = [[TodoWindowController alloc] init];

        // Show the window
        [windowController showWindow:nil];

        // Keep a reference to prevent it from being deallocated
        // Note: in a real app, you'd store this reference more carefully
        static TodoWindowController *staticController = nil;
        staticController = windowController;
    });
}

} // namespace objc_code

这些函数:

  1. 实现 hello_world 函数,返回问候字符串
  2. 提供一种设置待办事项添加回调函数的方式
  3. 实现 hello_gui 函数,创建并显示我们的原生 UI
  4. 最后,我们还保留一个静态引用,以防止窗口控制器被释放

注意,我们使用 GCD(Grand Central Dispatch)将任务调度到主线程,这是 UI 操作所必需的。本教程不会花更多时间讨论线程安全,但这里快速提醒一下:在 macOS/iOS 中,所有 UI 更新都必须在主线程上发生。主线程是应用程序运行事件循环并处理用户界面事件的主要执行路径。在我们的代码中,当 JavaScript 调用 hello_gui() 函数时,该调用可能来自 Node.js 工作线程,而不是主线程。使用 GCD,我们可以安全地将窗口创建代码重定向到主线程,从而确保正确的 UI 行为。

这是 macOS/iOS 开发中常见的模式——任何涉及 UI 的代码都需要在主线程上执行,而 GCD 提供了一种简洁的方式来确保这一点。

objc_code.mm 的最终版本如下:

src/objc_code.mm
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <string>
#import <functional>
#import "../include/objc_code.h"

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

static TodoCallback g_todoAddedCallback;

// Forward declaration of our custom classes
@interface TodoWindowController : NSWindowController
@property (strong) NSTextField *textField;
@property (strong) NSDatePicker *datePicker;
@property (strong) NSButton *addButton;
@property (strong) NSTableView *tableView;
@property (strong) NSMutableArray<NSDictionary*> *todos;
@end

// Controller for the main window
@implementation TodoWindowController

- (instancetype)init {
    self = [super initWithWindowNibName:@""];
    if (self) {
        // Create an array to store todos
        _todos = [NSMutableArray array];
        [self setupWindow];
    }
    return self;
}

- (void)setupWindow {
    // Create a window
    NSRect frame = NSMakeRect(0, 0, 400, 300);
    NSWindow *window = [[NSWindow alloc] initWithContentRect:frame
                                         styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable
                                         backing:NSBackingStoreBuffered
                                         defer:NO];
    [window setTitle:@"Todo List"];
    [window center];
    self.window = window;

    // Set up the content view with auto layout
    NSView *contentView = [window contentView];

    // Create text field
    _textField = [[NSTextField alloc] initWithFrame:NSMakeRect(20, 260, 200, 24)];
    [_textField setPlaceholderString:@"Enter a todo..."];
    [contentView addSubview:_textField];

    // Create date picker
    _datePicker = [[NSDatePicker alloc] initWithFrame:NSMakeRect(230, 260, 100, 24)];
    [_datePicker setDatePickerStyle:NSDatePickerStyleTextField];
    [_datePicker setDatePickerElements:NSDatePickerElementFlagYearMonthDay];
    [contentView addSubview:_datePicker];

    // Create add button
    _addButton = [[NSButton alloc] initWithFrame:NSMakeRect(340, 260, 40, 24)];
    [_addButton setTitle:@"Add"];
    [_addButton setBezelStyle:NSBezelStyleRounded];
    [_addButton setTarget:self];
    [_addButton setAction:@selector(addTodo:)];
    [contentView addSubview:_addButton];

    // Create a scroll view for the table
    NSScrollView *scrollView = [[NSScrollView alloc] initWithFrame:NSMakeRect(20, 20, 360, 230)];
    [scrollView setBorderType:NSBezelBorder];
    [scrollView setHasVerticalScroller:YES];
    [contentView addSubview:scrollView];

    // Create table view
    _tableView = [[NSTableView alloc] initWithFrame:NSMakeRect(0, 0, 360, 230)];

    // Add a column for the todo text
    NSTableColumn *textColumn = [[NSTableColumn alloc] initWithIdentifier:@"text"];
    [textColumn setWidth:240];
    [textColumn setTitle:@"Todo"];
    [_tableView addTableColumn:textColumn];

    // Add a column for the date
    NSTableColumn *dateColumn = [[NSTableColumn alloc] initWithIdentifier:@"date"];
    [dateColumn setWidth:100];
    [dateColumn setTitle:@"Date"];
    [_tableView addTableColumn:dateColumn];

    // Set the table's delegate and data source
    [_tableView setDataSource:self];
    [_tableView setDelegate:self];

    // Add the table to the scroll view
    [scrollView setDocumentView:_tableView];
}

// Action method for the Add button
- (void)addTodo:(id)sender {
    NSString *text = [_textField stringValue];
    if ([text length] > 0) {
        NSDate *date = [_datePicker dateValue];

        // Create a unique ID
        NSUUID *uuid = [NSUUID UUID];

        // Create a dictionary to store the todo
        NSDictionary *todo = @{
            @"id": [uuid UUIDString],
            @"text": text,
            @"date": date
        };

        // Add to our array
        [_todos addObject:todo];

        // Reload the table
        [_tableView reloadData];

        // Reset the text field
        [_textField setStringValue:@""];

        // Call the callback if it exists
        if (g_todoAddedCallback) {
            // Convert the todo to JSON
            NSError *error;
            NSData *jsonData = [NSJSONSerialization dataWithJSONObject:@{
                @"id": [uuid UUIDString],
                @"text": text,
                @"date": @((NSTimeInterval)[date timeIntervalSince1970] * 1000)
            } options:0 error:&error];

            if (!error) {
                NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
                std::string cppJsonString = [jsonString UTF8String];
                g_todoAddedCallback(cppJsonString);
            }
        }
    }
}

// NSTableViewDataSource methods
- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView {
    return [_todos count];
}

- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row {
    NSDictionary *todo = _todos[row];
    NSString *identifier = [tableColumn identifier];

    if ([identifier isEqualToString:@"text"]) {
        return todo[@"text"];
    } else if ([identifier isEqualToString:@"date"]) {
        NSDate *date = todo[@"date"];
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        [formatter setDateStyle:NSDateFormatterShortStyle];
        return [formatter stringFromDate:date];
    }

    return nil;
}

@end

namespace objc_code {

std::string hello_world(const std::string& input) {
    return "Hello from Objective-C! You said: " + input;
}

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

void hello_gui() {
    // Create and run the GUI on the main thread
    dispatch_async(dispatch_get_main_queue(), ^{
        // Create our window controller
        TodoWindowController *windowController = [[TodoWindowController alloc] init];

        // Show the window
        [windowController showWindow:nil];

        // Keep a reference to prevent it from being deallocated
        // Note: in a real app, you'd store this reference more carefully
        static TodoWindowController *staticController = nil;
        staticController = windowController;
    });
}

} // namespace objc_code

5) 创建 Node.js 插件桥接

我们现在已经有了可以正常工作的 Objective-C 代码。为了确保它能够从 JavaScript 世界被安全且正确地调用,我们需要在 Objective-C 和 C++ 之间构建一座桥,这可以通过 Objective-C++ 来完成。我们将在 src/objc_addon.mm 中完成它。

请耐心一点:这类桥接代码通常相当冗长,看起来也可能难以理解。就现代桌面开发而言,它相当底层,所以请对自己有耐心——在桥接真正“通顺”之前,可能需要一点时间。

基本类定义

src/objc_addon.mm
#include <napi.h>
#include <string>
#include "../include/objc_code.h"

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

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

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

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

    // More code to follow later...
    // Specifically, we'll add ObjcAddon here in the next step
};

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

NODE_API_MODULE(objc_addon, Init)

这段代码:

  1. 定义了一个继承自 Napi::ObjectWrap 的 ObjcAddon 类
  2. 创建了一个静态 Init 方法,用于注册我们的 JavaScript 方法
  3. 定义了一个 CallbackData 结构体,用于在线程之间传递数据
  4. 设置了 Node API 模块初始化

构造函数和线程安全函数设置

接下来,让我们实现构造函数,以设置我们的线程安全回调机制:

src/objc_addon.mm
ObjcAddon(const Napi::CallbackInfo& info)
    : Napi::ObjectWrap<ObjcAddon>(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_, "ObjcCallback"),
        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<ObjcAddon*>(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
    auto makeCallback = [this](const std::string& eventType) {
        return [this, eventType](const std::string& payload) {
            if (tsfn_ != nullptr) {
                auto* data = new CallbackData{
                    eventType,
                    payload,
                    this
                };
                napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
            }
        };
    };

    objc_code::setTodoAddedCallback(makeCallback("todoAdded"));
}

~ObjcAddon() {
    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_;

这段代码:

  • 使用成员初始化设置构造函数
  • 使用 N-API 创建一个线程安全函数,它允许从任意线程进行安全回调
  • 定义了一个 lambda,用于为不同事件类型创建回调函数
  • 将 "todoAdded" 回调注册到我们的 Objective-C 代码中
  • 实现了一个析构函数,以便在插件销毁时清理资源

线程安全函数很重要,因为 Objective-C 中的 UI 事件可能发生在与 JavaScript 事件循环不同的线程上。该机制可以安全地跨越这些线程边界。

实现 JavaScript 方法

最后,让我们实现 JavaScript 将要调用的方法:

src/objc_addon.mm
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 = objc_code::hello_world(input);

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

void HelloGui(const Napi::CallbackInfo& info) {
    objc_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();
}

让我们看看这一步中添加了哪些内容:

  • HelloWorld():接收字符串输入,调用我们的 Objective-C 函数,并返回结果
  • HelloGui():对 Objective-C hello_gui 函数的简单封装
  • On:允许 JavaScript 注册事件监听器,当原生事件发生时会被调用
  • Destroy:释放所有持久引用(回调和 emitter),并中止线程安全函数,使插件在退出时能够被正确清理

On 方法尤其重要,因为它创建了一个事件系统,我们的 JavaScript 代码将使用它来接收来自原生 UI 的通知。

综合起来,这四个组件构成了我们的 Objective-C 代码与 JavaScript 世界之间的完整桥梁,允许双向通信。完成后的文件应该如下所示:

src/objc_addon.mm
#include <napi.h>
#include <string>
#include "../include/objc_code.h"

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

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

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

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

    ObjcAddon(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<ObjcAddon>(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_, "ObjcCallback"),
            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<ObjcAddon*>(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
        auto makeCallback = [this](const std::string& eventType) {
            return [this, eventType](const std::string& payload) {
                if (tsfn_ != nullptr) {
                    auto* data = new CallbackData{
                        eventType,
                        payload,
                        this
                    };
                    napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
                }
            };
        };

        objc_code::setTodoAddedCallback(makeCallback("todoAdded"));
    }

    ~ObjcAddon() {
        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 = objc_code::hello_world(input);

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

    void HelloGui(const Napi::CallbackInfo& info) {
        objc_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 ObjcAddon::Init(env, exports);
}

NODE_API_MODULE(objc_addon, Init)

6) 创建 JavaScript 封装

你已经非常接近了!我们现在已经有了可用的 Objective-C 和线程安全方式,用于向 JavaScript 暴露方法和事件。在最后一步中,让我们在 js/index.js 中创建一个 JavaScript 封装,以提供更友好的 API:

```js title='js/index.js' @ts-expect-error=[10] const EventEmitter = require('node:events')

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

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

const native = require('bindings')('objc_addon')
this.addon = new native.ObjcMacosAddon()

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

}

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

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

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

parse (payload) { const parsed = JSON.parse(payload)

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

} }

if (process.platform === 'darwin') { module.exports = new ObjcMacosAddon() } else { module.exports = {} }

此封装:

1. 扩展 EventEmitter 以提供事件支持
2. 检查是否正在 macOS 上运行
3. 加载原生插件
4. 设置事件监听器并转发它们
5. 为我们的函数提供简洁的 API
6. 提供 `destroy()` 方法以释放原生资源
7. 解析 JSON 负载并将时间戳转换为 JavaScript Date 对象

> [!IMPORTANT]
> 在应用退出前(例如在 `will-quit` 或 `before-quit` 事件处理程序中)必须调用 `destroy()`。否则,对回调和线程安全函数的持久引用会阻止原生插件析构函数运行,导致 Electron 在退出时挂起。

## 7) 构建和测试插件 {#7-building-and-testing-the-addon}

在所有文件就位后,你可以构建插件:

```sh
npm run build

请注意,你_不能_直接从 Node.js 调用此脚本,因为在 macOS 看来,Node.js 不会设置一个“app”。Electron 会这样做,因此你可以通过在 Electron 中 require 并调用它来测试代码。

结论

你现在已经使用 Objective-C 和 AppKit 为 macOS 构建了一个完整的原生 Node.js 插件。这为在 Electron 应用中构建更复杂的 macOS 特定功能奠定了基础,让你兼得两者之长:Web 技术的易用性与原生 macOS 代码的强大能力。

此处演示的方法允许你:

  • 使用 AppKit 创建原生 macOS UI
  • 在 JavaScript 和 Objective-C 之间实现双向通信
  • 利用 macOS 特定功能和框架
  • 与现有 Objective-C 代码库集成

如需了解有关使用 Objective-C 和 Cocoa 开发的更多信息,请参阅 Apple 开发者文档:

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 el/electron