跳转至

原生代码与 Electron:Swift(macOS)

本教程基于原生代码与 Electron 的通用介绍,并专注于使用 Swift 为 macOS 创建原生插件。

Swift 是一种现代且功能强大的语言,专为安全性和性能而设计。虽然你不能像 Electron 使用 Node.js N-API 那样直接使用 Swift,但你可以使用 Objective-C++ 创建一个桥接,将 Swift 与你 Electron 应用中的 JavaScript 连接起来。

为了说明如何在 Electron 应用中嵌入原生 macOS 代码,我们将构建一个基本的原生 macOS GUI(使用 SwiftUI),并与 Electron 的 JavaScript 进行通信。

本教程最适合已经熟悉 Objective-C、Swift 和 SwiftUI 开发的读者。你应该理解一些基本概念,例如 Swift 语法、可选值、闭包、SwiftUI 视图、属性包装器,以及 Objective-C/Swift 互操作机制,例如 @objc 属性和桥接头文件。

[!NOTE] 如果你还不熟悉这些概念,Apple 的 Swift 编程语言指南、SwiftUI 文档 和 Swift 与 Objective-C 互操作指南 是很好的起点。

要求

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

  • 已安装 Xcode(可从 Mac App Store 获取)
  • Xcode 命令行工具(可在终端中运行 xcode-select --install 安装)

1) 创建包

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

swift-native-addon/
├── binding.gyp            # Build configuration
├── include/
│   └── SwiftBridge.h      # Objective-C header for the bridge
├── js/
│   └── index.js           # JavaScript interface
├── package.json           # Package configuration
└── src/
    ├── SwiftCode.swift    # Swift implementation
    ├── SwiftBridge.m      # Objective-C bridge implementation
    └── swift_addon.mm     # Node.js addon implementation

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

package.json
{
  "name": "swift-macos",
  "version": "1.0.0",
  "description": "A demo module that exposes Swift 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": {
    "bindings": "^1.5.0",
    "node-addon-api": "^8.3.0"
  },
  "devDependencies": {
    "node-gyp": "^11.1.0"
  }
}

2) 设置构建配置

在关注其他原生语言的其他教程中,我们可以使用 node-gyp 构建全部代码。对于 Swift,情况要稍微复杂一些:我们需要先构建,然后再链接我们的 Swift 代码。这是因为 Swift 有自己的编译模型和运行时要求,无法直接与 node-gyp 面向 C/C++ 的构建系统集成。

该过程包括:

  1. 将 Swift 代码单独编译为静态库(.a 文件)
  2. 创建一个 Objective-C 桥接,以暴露 Swift 功能
  3. 将编译后的 Swift 库与我们的 Node.js 插件链接
  4. 管理 Swift 运行时依赖

这种两步编译过程确保 Swift 的高级语言特性和运行时得到正确处理,同时仍允许我们通过 Node.js 的原生插件系统将这些功能暴露给 JavaScript。

让我们从添加一个基本结构开始:

binding.gyp
{
  "targets": [{
    "target_name": "swift_addon",
    "conditions": [
      ['OS=="mac"', {
        "sources": [
          "src/swift_addon.mm",
          "src/SwiftBridge.m",
          "src/SwiftCode.swift"
        ],
        "include_dirs": [
          "<!@(node -p \"require('node-addon-api').include\")",
          "include",
          "build_swift"
        ],
        "dependencies": [
          "<!(node -p \"require('node-addon-api').gyp\")"
        ],
        "libraries": [
          "<(PRODUCT_DIR)/libSwiftCode.a"
        ],
        "cflags!": [ "-fno-exceptions" ],
        "cflags_cc!": [ "-fno-exceptions" ],
        "xcode_settings": {
          "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
          "CLANG_ENABLE_OBJC_ARC": "YES",
          "SWIFT_OBJC_BRIDGING_HEADER": "include/SwiftBridge.h",
          "SWIFT_VERSION": "5.0",
          "SWIFT_OBJC_INTERFACE_HEADER_NAME": "swift_addon-Swift.h",
          "MACOSX_DEPLOYMENT_TARGET": "11.0",
          "OTHER_CFLAGS": [
            "-ObjC++",
            "-fobjc-arc"
          ],
          "OTHER_LDFLAGS": [
            "-Wl,-rpath,@loader_path",
            "-Wl,-install_name,@rpath/libSwiftCode.a"
          ],
          "HEADER_SEARCH_PATHS": [
            "$(SRCROOT)/include",
            "$(CONFIGURATION_BUILD_DIR)",
            "$(SRCROOT)/build/Release",
            "$(SRCROOT)/build_swift"
          ]
        },
        "actions": []
      }]
    ]
  }]
}

我们包含 Objective-C++ 文件(sources),指定所需的 macOS 框架,并设置 C++ 异常和 ARC。我们还设置了各种 Xcode 标志:

  • GCC_ENABLE_CPP_EXCEPTIONS: 在原生代码中启用 C++ 异常处理。
  • CLANG_ENABLE_OBJC_ARC: 为 Objective-C 内存管理启用自动引用计数(ARC)。
  • SWIFT_OBJC_BRIDGING_HEADER: 指定桥接 Swift 和 Objective-C 代码的头文件。
  • SWIFT_VERSION: 将 Swift 语言版本设置为 5.0。
  • SWIFT_OBJC_INTERFACE_HEADER_NAME: 为自动生成的、将 Swift 代码暴露给 Objective-C 的头文件命名。
  • MACOSX_DEPLOYMENT_TARGET: 设置所需的最低 macOS 版本(11.0/Big Sur)。
  • OTHER_CFLAGS: 额外的编译器标志:-ObjC++ 指定 Objective-C++ 模式。-fobjc-arc 在编译器层面启用 ARC。

然后,通过 OTHER_LDFLAGS,我们设置链接器标志:

  • -Wl,-rpath,@loader_path:设置库的运行时搜索路径
  • -Wl,-install_name,@rpath/libSwiftCode.a:配置库的安装名称
  • HEADER_SEARCH_PATHS:编译期间搜索头文件的目录。

你可能还会注意到,我们在 JSON 中添加了一个当前为空的 actions 数组。在下一步中,我们将编译 Swift。

设置 Swift 构建配置

我们将添加两个操作:一个用于编译我们的 Swift 代码(以便它可以被链接),另一个用于将其复制到文件夹中使用。用以下 JSON 替换上面的 actions 数组:

binding.gyp
{
  // ...other code
  "actions": [
    {
      "action_name": "build_swift",
      "inputs": [
        "src/SwiftCode.swift"
      ],
      "outputs": [
        "build_swift/libSwiftCode.a",
        "build_swift/swift_addon-Swift.h"
      ],
      "action": [
        "swiftc",
        "src/SwiftCode.swift",
        "-emit-objc-header-path", "./build_swift/swift_addon-Swift.h",
        "-emit-library", "-o", "./build_swift/libSwiftCode.a",
        "-emit-module", "-module-name", "swift_addon",
        "-module-link-name", "SwiftCode"
      ]
    },
    {
      "action_name": "copy_swift_lib",
      "inputs": [
        "<(module_root_dir)/build_swift/libSwiftCode.a"
      ],
      "outputs": [
        "<(PRODUCT_DIR)/libSwiftCode.a"
      ],
      "action": [
        "sh",
        "-c",
        "cp -f <(module_root_dir)/build_swift/libSwiftCode.a <(PRODUCT_DIR)/libSwiftCode.a && install_name_tool -id @rpath/libSwiftCode.a <(PRODUCT_DIR)/libSwiftCode.a"
      ]
    }
  ]
  // ...other code
}

这些操作:

  • 使用 swiftc 将 Swift 代码编译为静态库
  • 从 Swift 代码生成 Objective-C 头文件
  • 将编译后的 Swift 库复制到输出目录
  • 使用 install_name_tool 修复库路径,通过设置正确的安装名称,确保动态链接器在运行时能够找到该库

3) 创建 Objective-C 桥接头文件

我们需要在 Swift 代码和原生 Node.js C++ 插件之间设置一个桥接。让我们首先在 include/SwiftBridge.h 中创建桥接的头文件:

include/SwiftBridge.h
#ifndef SwiftBridge_h
#define SwiftBridge_h

#import <Foundation/Foundation.h>

@interface SwiftBridge : NSObject
+ (NSString*)helloWorld:(NSString*)input;
+ (void)helloGui;

+ (void)setTodoAddedCallback:(void(^)(NSString* todoJson))callback;
+ (void)setTodoUpdatedCallback:(void(^)(NSString* todoJson))callback;
+ (void)setTodoDeletedCallback:(void(^)(NSString* todoId))callback;
@end

#endif

该头文件定义了我们用于在 Swift 代码和 Node.js 插件之间进行桥接的 Objective-C 接口。它包括:

  • 一个简单的 helloWorld 方法,它接受字符串输入并返回字符串
  • 一个 helloGui 方法,它将显示原生 SwiftUI 界面
  • 用于设置 todo 操作(添加、更新、删除)回调的方法

4) 实现 Objective-C 桥接

现在,让我们在 src/SwiftBridge.m 中创建 Objective-C 桥接本身:

src/SwiftBridge.m
#import "SwiftBridge.h"
#import "swift_addon-Swift.h"
#import <Foundation/Foundation.h>

@implementation SwiftBridge

static void (^todoAddedCallback)(NSString*);
static void (^todoUpdatedCallback)(NSString*);
static void (^todoDeletedCallback)(NSString*);

+ (NSString*)helloWorld:(NSString*)input {
    return [SwiftCode helloWorld:input];
}

+ (void)helloGui {
    [SwiftCode helloGui];
}

+ (void)setTodoAddedCallback:(void(^)(NSString*))callback {
    todoAddedCallback = callback;
    [SwiftCode setTodoAddedCallback:callback];
}

+ (void)setTodoUpdatedCallback:(void(^)(NSString*))callback {
    todoUpdatedCallback = callback;
    [SwiftCode setTodoUpdatedCallback:callback];
}

+ (void)setTodoDeletedCallback:(void(^)(NSString*))callback {
    todoDeletedCallback = callback;
    [SwiftCode setTodoDeletedCallback:callback];
}

@end

该桥接:

  • 导入 Swift 生成的头文件(swift_addon-Swift.h)
  • 实现我们在头文件中定义的方法
  • 简单地将调用转发到 Swift 代码
  • 将回调存储在静态变量中以供后续使用,使它们能够在整个应用程序生命周期中持久存在。这确保了当 todo 项被添加、更新或删除时,可以随时调用 JavaScript 回调。

5) 实现 Swift 代码

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

为了让本教程更容易跟随,我们将从基本结构开始,并逐步添加功能。

设置基本结构

让我们从基本结构开始。这里,我们只是设置变量、一些基本回调方法,以及一个稍后用于将数据转换为适合 JavaScript 世界使用的格式的简单辅助方法。

src/SwiftCode.swift
import Foundation
import SwiftUI

@objc
public class SwiftCode: NSObject {
    private static var windowController: NSWindowController?
    private static var todoAddedCallback: ((String) -> Void)?
    private static var todoUpdatedCallback: ((String) -> Void)?
    private static var todoDeletedCallback: ((String) -> Void)?

    @objc
    public static func helloWorld(_ input: String) -> String {
        return "Hello from Swift! You said: \(input)"
    }

    @objc
    public static func setTodoAddedCallback(_ callback: @escaping (String) -> Void) {
        todoAddedCallback = callback
    }

    @objc
    public static func setTodoUpdatedCallback(_ callback: @escaping (String) -> Void) {
        todoUpdatedCallback = callback
    }

    @objc
    public static func setTodoDeletedCallback(_ callback: @escaping (String) -> Void) {
        todoDeletedCallback = callback
    }

    private static func encodeToJson<T: Encodable>(_ item: T) -> String? {
        let encoder = JSONEncoder()

        // Encode date as milliseconds since 1970, which is what the JS side expects
        encoder.dateEncodingStrategy = .custom { date, encoder in
            let milliseconds = Int64(date.timeIntervalSince1970 * 1000)
            var container = encoder.singleValueContainer()
            try container.encode(milliseconds)
        }

        guard let jsonData = try? encoder.encode(item),
              let jsonString = String(data: jsonData, encoding: .utf8) else {
            return nil
        }
        return jsonString
    }

    // More code to follow...
}

我们 Swift 代码的这一部分:

  1. 声明一个带有 @objc 属性的类,使其可以从 Objective-C 访问
  2. 实现 helloWorld 方法
  3. 为 todo 操作添加回调设置器
  4. 包含一个辅助方法,用于将 Swift 对象编码为 JSON 字符串

实现 helloGui()

让我们继续介绍 helloGui 方法和 SwiftUI 实现。从这里开始,我们将向屏幕添加用户界面元素。

src/SwiftCode.swift
// Other code...

@objc
public class SwiftCode: NSObject {
    // Other code...

        @objc
    public static func helloGui() -> Void {
        let contentView = NSHostingView(rootView: ContentView(
            onTodoAdded: { todo in
                if let jsonString = encodeToJson(todo) {
                    todoAddedCallback?(jsonString)
                }
            },
            onTodoUpdated: { todo in
                if let jsonString = encodeToJson(todo) {
                    todoUpdatedCallback?(jsonString)
                }
            },
            onTodoDeleted: { todoId in
                todoDeletedCallback?(todoId.uuidString)
            }
        ))
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 500, height: 500),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: false
        )

        window.title = "Todo List"
        window.contentView = contentView
        window.center()

        windowController = NSWindowController(window: window)
        windowController?.showWindow(nil)

        NSApp.activate(ignoringOtherApps: true)
    }
}

这个 helloGui 方法:

  1. 创建一个托管在 NSHostingView 中的 SwiftUI 视图。这是一个关键的桥接组件,允许在 AppKit 应用程序中使用 SwiftUI 视图。NSHostingView 充当容器,包装我们的 SwiftUI ContentView,并处理 SwiftUI 声明式 UI 系统与 AppKit 命令式 UI 系统之间的转换。这使我们能够利用 SwiftUI 的现代 UI 框架,同时仍然与传统 macOS 窗口管理系统集成。
  2. 设置回调,以便在 todo 项发生变化时通知 JavaScript。我们稍后会设置实际回调,现在如果可用就只是调用它们。
  3. 创建并显示一个原生 macOS 窗口。
  4. 激活应用程序,将窗口置于前台。

实现 Todo 项

接下来,我们将定义一个包含 ID、文本和日期的 TodoItem 模型。

src/SwiftCode.swift
// Other code...

@objc
public class SwiftCode: NSObject {
    // Other code...

    private struct TodoItem: Identifiable, Codable {
        let id: UUID
        var text: String
        var date: Date

        init(id: UUID = UUID(), text: String, date: Date) {
            self.id = id
            self.text = text
            self.date = date
        }
    }
}

实现视图

接下来,我们可以实现实际的视图。Swift 在这里相当冗长,因此如果你刚接触 Swift,下面的代码可能看起来有些吓人。许多行代码掩盖了其中的简单性——我们只是在设置一些 UI 元素。这里没有任何内容是 Electron 特有的。

src/SwiftCode.swift
// Other code...

@objc
public class SwiftCode: NSObject {
    // Other code...

    private struct ContentView: View {
        @State private var todos: [TodoItem] = []
        @State private var newTodo: String = ""
        @State private var newTodoDate: Date = Date()
        @State private var editingTodo: UUID?
        @State private var editedText: String = ""
        @State private var editedDate: Date = Date()

        let onTodoAdded: (TodoItem) -> Void
        let onTodoUpdated: (TodoItem) -> Void
        let onTodoDeleted: (UUID) -> Void

        private func todoTextField(_ text: Binding<String>, placeholder: String, maxWidth: CGFloat? = nil) -> some View {
            TextField(placeholder, text: text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(maxWidth: maxWidth ?? .infinity)
        }

        private func todoDatePicker(_ date: Binding<Date>) -> some View {
            DatePicker("Due date", selection: date, displayedComponents: [.date])
                .datePickerStyle(CompactDatePickerStyle())
                .labelsHidden()
                .frame(width: 100)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }

        var body: some View {
            VStack(spacing: 16) {
                HStack(spacing: 12) {
                    todoTextField($newTodo, placeholder: "New todo")
                    todoDatePicker($newTodoDate)
                    Button(action: {
                        if !newTodo.isEmpty {
                            let todo = TodoItem(text: newTodo, date: newTodoDate)
                            todos.append(todo)
                            onTodoAdded(todo)
                            newTodo = ""
                            newTodoDate = Date()
                        }
                    }) {
                        Text("Add")
                            .frame(width: 50)
                    }
                }
                .padding(.horizontal, 12)
                .padding(.vertical, 8)

                List {
                    ForEach(todos) { todo in
                        if editingTodo == todo.id {
                            HStack(spacing: 12) {
                                todoTextField($editedText, placeholder: "Edit todo", maxWidth: 250)
                                todoDatePicker($editedDate)
                                Button(action: {
                                    if let index = todos.firstIndex(where: { $0.id == todo.id }) {
                                        let updatedTodo = TodoItem(id: todo.id, text: editedText, date: editedDate)
                                        todos[index] = updatedTodo
                                        onTodoUpdated(updatedTodo)
                                        editingTodo = nil
                                    }
                                }) {
                                    Text("Save")
                                        .frame(width: 60)
                                }
                            }
                            .padding(.vertical, 4)
                        } else {
                            HStack(spacing: 12) {
                                Text(todo.text)
                                    .lineLimit(1)
                                    .truncationMode(.tail)
                                Spacer()
                                Text(todo.date.formatted(date: .abbreviated, time: .shortened))
                                    .foregroundColor(.gray)
                                Button(action: {
                                    editingTodo = todo.id
                                    editedText = todo.text
                                    editedDate = todo.date
                                }) {
                                    Image(systemName: "pencil")
                                }
                                .buttonStyle(BorderlessButtonStyle())
                                Button(action: {
                                    todos.removeAll(where: { $0.id == todo.id })
                                    onTodoDeleted(todo.id)
                                }) {
                                    Image(systemName: "trash")
                                        .foregroundColor(.red)
                                }
                                .buttonStyle(BorderlessButtonStyle())
                            }
                            .padding(.vertical, 4)
                        }
                    }
                }
            }
        }
    }
}
src/SwiftCode.swift
import Foundation
import SwiftUI

@objc
public class SwiftCode: NSObject {
    private static var windowController: NSWindowController?
    private static var todoAddedCallback: ((String) -> Void)?
    private static var todoUpdatedCallback: ((String) -> Void)?
    private static var todoDeletedCallback: ((String) -> Void)?

    @objc
    public static func helloWorld(_ input: String) -> String {
        return "Hello from Swift! You said: \(input)"
    }

    @objc
    public static func setTodoAddedCallback(_ callback: @escaping (String) -> Void) {
        todoAddedCallback = callback
    }

    @objc
    public static func setTodoUpdatedCallback(_ callback: @escaping (String) -> Void) {
        todoUpdatedCallback = callback
    }

    @objc
    public static func setTodoDeletedCallback(_ callback: @escaping (String) -> Void) {
        todoDeletedCallback = callback
    }

    private static func encodeToJson<T: Encodable>(_ item: T) -> String? {
        let encoder = JSONEncoder()

        // Encode date as milliseconds since 1970, which is what the JS side expects
        encoder.dateEncodingStrategy = .custom { date, encoder in
            let milliseconds = Int64(date.timeIntervalSince1970 * 1000)
            var container = encoder.singleValueContainer()
            try container.encode(milliseconds)
        }

        guard let jsonData = try? encoder.encode(item),
              let jsonString = String(data: jsonData, encoding: .utf8) else {
            return nil
        }
        return jsonString
    }

    @objc
    public static func helloGui() -> Void {
        let contentView = NSHostingView(rootView: ContentView(
            onTodoAdded: { todo in
                if let jsonString = encodeToJson(todo) {
                    todoAddedCallback?(jsonString)
                }
            },
            onTodoUpdated: { todo in
                if let jsonString = encodeToJson(todo) {
                    todoUpdatedCallback?(jsonString)
                }
            },
            onTodoDeleted: { todoId in
                todoDeletedCallback?(todoId.uuidString)
            }
        ))
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 500, height: 500),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: false
        )

        window.title = "Todo List"
        window.contentView = contentView
        window.center()

        windowController = NSWindowController(window: window)
        windowController?.showWindow(nil)

        NSApp.activate(ignoringOtherApps: true)
    }

    private struct TodoItem: Identifiable, Codable {
        let id: UUID
        var text: String
        var date: Date

        init(id: UUID = UUID(), text: String, date: Date) {
            self.id = id
            self.text = text
            self.date = date
        }
    }

    private struct ContentView: View {
        @State private var todos: [TodoItem] = []
        @State private var newTodo: String = ""
        @State private var newTodoDate: Date = Date()
        @State private var editingTodo: UUID?
        @State private var editedText: String = ""
        @State private var editedDate: Date = Date()

        let onTodoAdded: (TodoItem) -> Void
        let onTodoUpdated: (TodoItem) -> Void
        let onTodoDeleted: (UUID) -> Void

        private func todoTextField(_ text: Binding<String>, placeholder: String, maxWidth: CGFloat? = nil) -> some View {
            TextField(placeholder, text: text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(maxWidth: maxWidth ?? .infinity)
        }

        private func todoDatePicker(_ date: Binding<Date>) -> some View {
            DatePicker("Due date", selection: date, displayedComponents: [.date])
                .datePickerStyle(CompactDatePickerStyle())
                .labelsHidden()
                .frame(width: 100)
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }

        var body: some View {
            VStack(spacing: 16) {
                HStack(spacing: 12) {
                    todoTextField($newTodo, placeholder: "New todo")
                    todoDatePicker($newTodoDate)
                    Button(action: {
                        if !newTodo.isEmpty {
                            let todo = TodoItem(text: newTodo, date: newTodoDate)
                            todos.append(todo)
                            onTodoAdded(todo)
                            newTodo = ""
                            newTodoDate = Date()
                        }
                    }) {
                        Text("Add")
                            .frame(width: 50)
                    }
                }
                .padding(.horizontal, 12)
                .padding(.vertical, 8)

                List {
                    ForEach(todos) { todo in
                        if editingTodo == todo.id {
                            HStack(spacing: 12) {
                                todoTextField($editedText, placeholder: "Edit todo", maxWidth: 250)
                                todoDatePicker($editedDate)
                                Button(action: {
                                    if let index = todos.firstIndex(where: { $0.id == todo.id }) {
                                        let updatedTodo = TodoItem(id: todo.id, text: editedText, date: editedDate)
                                        todos[index] = updatedTodo
                                        onTodoUpdated(updatedTodo)
                                        editingTodo = nil
                                    }
                                }) {
                                    Text("Save")
                                        .frame(width: 60)
                                }
                            }
                            .padding(.vertical, 4)
                        } else {
                            HStack(spacing: 12) {
                                Text(todo.text)
                                    .lineLimit(1)
                                    .truncationMode(.tail)
                                Spacer()
                                Text(todo.date.formatted(date: .abbreviated, time: .shortened))
                                    .foregroundColor(.gray)
                                Button(action: {
                                    editingTodo = todo.id
                                    editedText = todo.text
                                    editedDate = todo.date
                                }) {
                                    Image(systemName: "pencil")
                                }
                                .buttonStyle(BorderlessButtonStyle())
                                Button(action: {
                                    todos.removeAll(where: { $0.id == todo.id })
                                    onTodoDeleted(todo.id)
                                }) {
                                    Image(systemName: "trash")
                                        .foregroundColor(.red)
                                }
                                .buttonStyle(BorderlessButtonStyle())
                            }
                            .padding(.vertical, 4)
                        }
                    }
                }
            }
        }
    }
}

6) 创建 Node.js 插件桥接

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

src/swift_addon.mm
#import <Foundation/Foundation.h>
#import "SwiftBridge.h"
#include <napi.h>

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

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

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

    // More code to follow...

第一部分:

  1. 定义一个继承自 Napi::ObjectWrap 的 C++ 类
  2. 创建一个静态 Init 方法,用于将我们的类注册到 Node.js
  3. 定义四个方法:helloWorld、helloGui、on 和 destroy

回调机制

接下来,让我们实现回调机制:

src/swift_addon.mm
// Previous code...

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

    SwiftAddon(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<SwiftAddon>(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_, "SwiftCallback"),
            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<SwiftAddon*>(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;
        }

这部分:

  1. 定义一个结构体,用于在线程之间传递数据
  2. 为我们的插件设置构造函数
  3. 创建一个线程安全函数,用于处理来自 Swift 的回调

让我们继续设置 Swift 回调:

src/swift_addon.mm
// Previous code...

        auto makeCallback = [this](const char* eventType) {
            return ^(NSString* payload) {
                if (tsfn_ != nullptr) {
                    auto* data = new CallbackData{
                        eventType,
                        std::string([payload UTF8String]),
                        this
                    };
                    napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
                }
            };
        };

        [SwiftBridge setTodoAddedCallback:makeCallback("todoAdded")];
        [SwiftBridge setTodoUpdatedCallback:makeCallback("todoUpdated")];
        [SwiftBridge setTodoDeletedCallback:makeCallback("todoDeleted")];
    }

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

这部分:

  1. 创建一个辅助函数,用于生成 Objective-C 块,这些块将用作 Swift 事件的回调。这个 lambda 函数 makeCallback 接收一个事件类型字符串,并返回一个捕获事件类型和负载的 Objective-C 块。当 Swift 调用这个块时,它会创建一个包含事件信息的 CallbackData 结构体,并将其传递给线程安全函数,该函数在 Swift 线程和 Node.js 事件循环之间进行安全桥接。
  2. 为 todo 操作设置精心构造的回调
  3. 实现析构函数以清理资源

实例方法

最后,让我们实现实例方法:

src/swift_addon.mm
// Previous code...

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>();
        NSString* nsInput = [NSString stringWithUTF8String:input.c_str()];
        NSString* result = [SwiftBridge helloWorld:nsInput];

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

    void HelloGui(const Napi::CallbackInfo& info) {
        [SwiftBridge helloGui];
    }

    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 SwiftAddon::Init(env, exports);
}

NODE_API_MODULE(swift_addon, Init)

这部分最终代码做了多件事:

  1. 代码为环境、事件发射器、回调存储以及线程安全函数定义了私有成员变量,这些是插件运行所必需的。
  2. HelloWorld 方法的实现从 JavaScript 接收一个字符串输入,将其传递给 Swift 代码,并将处理后的结果返回到 JavaScript 环境。
  3. HelloGui 方法的实现提供了一个简单的包装器,用于调用 Swift UI 创建函数以显示原生 macOS 窗口。
  4. On 方法的实现允许 JavaScript 代码注册回调函数,这些回调函数会在原生 Swift 代码中发生特定事件时被调用。
  5. Destroy 方法会释放所有持久引用(回调和发射器),并中止线程安全函数。必须在应用退出前调用它,以便析构函数能够运行并防止进程挂起。
  6. 代码设置了模块初始化过程,该过程将插件注册到 Node.js,并使其功能可供 JavaScript 使用。

最终完整的 src/swift_addon.mm 应如下所示:

src/swift_addon.mm
#import <Foundation/Foundation.h>
#import "SwiftBridge.h"
#include <napi.h>

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

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

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

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

    SwiftAddon(const Napi::CallbackInfo& info)
        : Napi::ObjectWrap<SwiftAddon>(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_, "SwiftCallback"),
            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<SwiftAddon*>(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;
        }

        auto makeCallback = [this](const char* eventType) {
            return ^(NSString* payload) {
                if (tsfn_ != nullptr) {
                    auto* data = new CallbackData{
                        eventType,
                        std::string([payload UTF8String]),
                        this
                    };
                    napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
                }
            };
        };

        [SwiftBridge setTodoAddedCallback:makeCallback("todoAdded")];
        [SwiftBridge setTodoUpdatedCallback:makeCallback("todoUpdated")];
        [SwiftBridge setTodoDeletedCallback:makeCallback("todoDeleted")];
    }

    ~SwiftAddon() {
        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>();
        NSString* nsInput = [NSString stringWithUTF8String:input.c_str()];
        NSString* result = [SwiftBridge helloWorld:nsInput];

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

    void HelloGui(const Napi::CallbackInfo& info) {
        [SwiftBridge helloGui];
    }

    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 SwiftAddon::Init(env, exports);
}

NODE_API_MODULE(swift_addon, Init)

6) 创建 JavaScript 包装器

你马上就要完成了!我们现在已经有了可用的 Objective-C、Swift,以及线程安全的方式,用于向 JavaScript 暴露方法和事件。在最后这一步中,让我们在 js/index.js 中创建一个 JavaScript 包装器,以提供更友好的 API:

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

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

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

const native = require('bindings')('swift_addon')
this.addon = new native.SwiftAddon()

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 () { 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 SwiftAddon() } else { module.exports = {} }

这个包装器:

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

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

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

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

```sh
npm run build

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

结论

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

这里演示的方法允许你:

  • 设置一个桥接 Swift、Objective-C 和 JavaScript 的项目结构
  • 使用 SwiftUI 实现用于原生 UI 的 Swift 代码
  • 创建一个 Objective-C 桥接,将 Swift 与 Node.js 连接起来
  • 使用回调和事件设置双向通信
  • 配置自定义构建流程以编译 Swift 代码

有关使用 Swift 和 SwiftUI 开发的更多信息,请参阅 Apple 的开发者文档:

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