原生代码与 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 应该如下所示:
{
"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++ 的构建系统集成。
该过程包括:
- 将 Swift 代码单独编译为静态库(.a 文件)
- 创建一个 Objective-C 桥接,以暴露 Swift 功能
- 将编译后的 Swift 库与我们的 Node.js 插件链接
- 管理 Swift 运行时依赖
这种两步编译过程确保 Swift 的高级语言特性和运行时得到正确处理,同时仍允许我们通过 Node.js 的原生插件系统将这些功能暴露给 JavaScript。
让我们从添加一个基本结构开始:
{
"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 数组:
{
// ...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 中创建桥接的头文件:
#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 桥接本身:
#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 世界使用的格式的简单辅助方法。
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 代码的这一部分:
- 声明一个带有
@objc属性的类,使其可以从 Objective-C 访问 - 实现
helloWorld方法 - 为 todo 操作添加回调设置器
- 包含一个辅助方法,用于将 Swift 对象编码为 JSON 字符串
实现 helloGui()¶
让我们继续介绍 helloGui 方法和 SwiftUI 实现。从这里开始,我们将向屏幕添加用户界面元素。
// 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 方法:
- 创建一个托管在
NSHostingView中的 SwiftUI 视图。这是一个关键的桥接组件,允许在 AppKit 应用程序中使用 SwiftUI 视图。NSHostingView充当容器,包装我们的 SwiftUIContentView,并处理 SwiftUI 声明式 UI 系统与 AppKit 命令式 UI 系统之间的转换。这使我们能够利用 SwiftUI 的现代 UI 框架,同时仍然与传统 macOS 窗口管理系统集成。 - 设置回调,以便在 todo 项发生变化时通知 JavaScript。我们稍后会设置实际回调,现在如果可用就只是调用它们。
- 创建并显示一个原生 macOS 窗口。
- 激活应用程序,将窗口置于前台。
实现 Todo 项¶
接下来,我们将定义一个包含 ID、文本和日期的 TodoItem 模型。
// 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 特有的。
// 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)
}
}
}
}
}
}
}
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 中完成这一点。
#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...
第一部分:
- 定义一个继承自
Napi::ObjectWrap的 C++ 类 - 创建一个静态
Init方法,用于将我们的类注册到 Node.js - 定义四个方法:
helloWorld、helloGui、on和destroy
回调机制¶
接下来,让我们实现回调机制:
// 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;
}
这部分:
- 定义一个结构体,用于在线程之间传递数据
- 为我们的插件设置构造函数
- 创建一个线程安全函数,用于处理来自 Swift 的回调
让我们继续设置 Swift 回调:
// 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;
}
}
这部分:
- 创建一个辅助函数,用于生成 Objective-C 块,这些块将用作 Swift 事件的回调。这个 lambda 函数
makeCallback接收一个事件类型字符串,并返回一个捕获事件类型和负载的 Objective-C 块。当 Swift 调用这个块时,它会创建一个包含事件信息的 CallbackData 结构体,并将其传递给线程安全函数,该函数在 Swift 线程和 Node.js 事件循环之间进行安全桥接。 - 为 todo 操作设置精心构造的回调
- 实现析构函数以清理资源
实例方法¶
最后,让我们实现实例方法:
// 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)
这部分最终代码做了多件事:
- 代码为环境、事件发射器、回调存储以及线程安全函数定义了私有成员变量,这些是插件运行所必需的。
- HelloWorld 方法的实现从 JavaScript 接收一个字符串输入,将其传递给 Swift 代码,并将处理后的结果返回到 JavaScript 环境。
HelloGui方法的实现提供了一个简单的包装器,用于调用 Swift UI 创建函数以显示原生 macOS 窗口。On方法的实现允许 JavaScript 代码注册回调函数,这些回调函数会在原生 Swift 代码中发生特定事件时被调用。Destroy方法会释放所有持久引用(回调和发射器),并中止线程安全函数。必须在应用退出前调用它,以便析构函数能够运行并防止进程挂起。- 代码设置了模块初始化过程,该过程将插件注册到 Node.js,并使其功能可供 JavaScript 使用。
最终完整的 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