跳转到主要内容

原生代码与 Electron:Swift (macOS)

本教程建立在 原生代码和 Electron 的通用介绍 的基础上,重点介绍使用 Swift 为 macOS 创建原生插件。

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

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

本教程对那些已经熟悉 Objective-C、Swift 和 SwiftUI 开发的人最有帮助。您应该理解 Swift 语法、可选类型、闭包、SwiftUI 视图、属性包装器等基本概念,以及 Objective-C/Swift 互操作机制,例如 @objc 属性和桥接头文件。

注意

如果您还不熟悉这些概念,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 内存管理启用自动引用计数。
  • 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 代码(以便链接),另一个用于将其复制到一个文件夹以供使用。将上面的 actions 数组替换为以下 JSON:

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 界面。
  • 用于设置待办事项操作(添加、更新、删除)回调的方法。

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 代码。
  • 将回调存储在静态变量中以供以后使用,从而使它们能够在应用程序的整个生命周期内持久存在。这确保了当待办事项被添加、更新或删除时,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. 为待办事项操作添加回调设置器。
  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. 设置回调,以便在待办事项发生更改时通知 JavaScript。我们将稍后设置实际的回调,目前我们只在其中一个可用时调用它们。
  3. 创建并显示一个原生的 macOS 窗口。
  4. 激活应用程序以将窗口带到前面。

实现待办事项

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

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)
}
}
}
}
}
}
}

代码的这一部分:

  • 创建一个 SwiftUI 视图,其中包含一个用于添加新待办事项的表单,具有一个待办事项描述的文本字段、一个用于设置截止日期的日期选择器,以及一个“添加”按钮,该按钮会验证输入,创建一个新的 TodoItem,将其添加到本地数组,触发 onTodoAdded 回调以通知 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
}

@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++ 在 Objective-C 和 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)
});

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. 定义三个方法:helloWorldhelloGuion

回调机制

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

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. 设置精心构造的待办事项操作回调。
  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::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. 代码设置了模块初始化过程,该过程将插件注册到 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)
});

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::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/index.js
const EventEmitter = require('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()
}

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. 解析 JSON 有效负载并将时间戳转换为 JavaScript Date 对象。

7) 构建和测试插件

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

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 实现 Swift 代码以实现原生 UI。
  • 创建 Objective-C 桥接以连接 Swift 和 Node.js。
  • 使用回调和事件设置双向通信。
  • 配置自定义构建过程来编译 Swift 代码。

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