背景:为什么需要插件系统
CvEditor 刚开始只有十几个节点,全部写在主程序里,编译一次就完事。但随着节点越来越多(现在 60+),我遇到了几个问题:
- 编译时间越来越长:加一个节点就要重编整个项目
- 某些节点依赖很重:比如 YOLO 节点依赖 ONNX Runtime,不用的人也要编进来
- 无法分享节点:我写了一个好用的节点,想分享给朋友,只能发源码
- 第三方扩展困难:别人想加自己的算子,得 fork 整个项目
插件系统就是解决这些问题的。让节点变成可选的、可插拔的、可分发的独立模块。
设计目标
- 零耦合:插件不需要链接主程序,只需要头文件
- 简单:实现一个接口 + 导出两个函数,就是一个插件
- 安全:插件崩溃不应该拖垮主程序
- 易分发:一个 DLL/SO 文件就是一个插件,复制即用
插件接口设计
IPlugin 接口
每个插件都需要实现 IPlugin 接口(include/editor/sdk/plugin.h):
cpp
class IPlugin {
public:
virtual ~IPlugin() = default;
// 返回插件信息
virtual PluginInfo GetInfo() const = 0;
// 初始化插件,注册节点
virtual bool Initialize(Factory* factory) = 0;
// 关闭插件,释放资源
virtual void Shutdown() = 0;
};
PluginInfo 是一个简单的结构体:
cpp
struct PluginInfo {
std::string name; // 插件名称
std::string version; // 版本号
std::string description; // 描述
std::string author; // 作者
};
Factory:节点工厂
Factory 是插件注册节点的入口,使用工厂模式将节点名称映射到创建函数:
cpp
class Factory {
public:
static Factory& Instance();
// 注册节点创建函数
void Register(const std::string& name,
std::function<NodePtr(Context*, const std::string&)> creator);
// 创建节点
NodePtr CreateNode(Context* context, const std::string& name);
// 加载/卸载插件
bool LoadPlugin(const std::string& path);
void UnloadPlugin(const std::string& path);
int LoadPluginsFromDirectory(const std::string& dir);
void UnloadAllPlugins();
// 查询已加载插件
std::vector<PluginInfo> GetLoadedPlugins();
};
完整示例:写一个自定义插件
1. 创建插件类
cpp
// my_plugin.h
#include "editor/sdk/plugin.h"
class MyPlugin : public editor::IPlugin {
public:
MyPlugin() = default;
~MyPlugin() override = default;
editor::PluginInfo GetInfo() const override {
return {
"My Plugin", // 名称
"1.0.0", // 版本
"My custom nodes", // 描述
"Your Name" // 作者
};
}
bool Initialize(editor::Factory* factory) override;
void Shutdown() override;
};
2. 实现自定义节点
插件的节点和内置节点写法完全一样:
cpp
// my_plugin.cpp
#include "my_plugin.h"
#include "editor/sdk/node.h"
#include "editor/sdk/attribute.h"
class MyCustomNode : public editor::Node {
public:
MyCustomNode(editor::Context* context, const std::string& name)
: editor::Node(context, name) {
SetWidth(160.0f);
// 添加输入属性
AddAttribute(std::make_shared<editor::IntInputAttribute>(
context, "input_value", 0));
// 添加输出属性
AddAttribute(std::make_shared<editor::IntOutputAttribute>(
context, "output_value", 0, true));
}
void Exec() override {
auto input = GetAttribute(1);
auto output = GetAttribute(2);
if (input && output) {
int value = input->Get<int>();
output->Set(value * 2); // 简单的翻倍逻辑
}
}
};
3. 注册节点
在 Initialize 方法中注册所有节点:
cpp
bool MyPlugin::Initialize(editor::Factory* factory) {
// 注册格式:"Category.nodeName"
factory->Register("Custom.myNode", [](editor::Context* context,
const std::string& name) {
return std::make_shared<MyCustomNode>(context, name);
});
// 可以注册多个节点
factory->Register("Custom.anotherNode", [](editor::Context* context,
const std::string& name) {
return std::make_shared<AnotherNode>(context, name);
});
return true;
}
void MyPlugin::Shutdown() {
// 清理插件分配的资源
}
4. 导出 C 接口
这是最关键的一步。插件必须导出两个 C 风格的函数,主程序通过它们来创建和销毁插件实例:
cpp
extern "C" {
editor::IPlugin* CreatePlugin() {
return new MyPlugin();
}
void DestroyPlugin(editor::IPlugin* plugin) {
delete plugin;
}
}
为什么要
extern "C"? 因为 C++ 的名称修饰(name mangling)会让导出的函数名变得不可预测。用 C 链接约定确保函数名不变,主程序才能通过dlsym/GetProcAddress找到它们。
5. 编译插件
Windows (Visual Studio):
bash
cl /LD /EHsc /I"path/to/cveditor/include" ^
/D"BUILD_EDITOR_LIB" /DNOMINMAX ^
my_plugin.cpp ^
/Fe:my_plugin.dll
Linux (GCC):
bash
g++ -shared -fPIC -std=c++17 \
-I"path/to/cveditor/include" \
-DEDITOR_API= \
my_plugin.cpp -o libmy_plugin.so
关键编译选项:
/LD或-shared:生成动态链接库/D"BUILD_EDITOR_LIB":正确导出符号-fPIC:生成位置无关代码(Linux 必须)
加载和使用插件
加载单个插件
cpp
#include "editor/sdk/factory.h"
auto& factory = editor::Factory::Instance();
if (!factory.LoadPlugin("plugins/my_plugin.dll")) {
printf("Failed to load plugin\n");
return 1;
}
// 创建插件注册的节点
auto node = factory.CreateNode(context.get(), "Custom.myNode");
if (node) {
context->AddNode(node);
}
批量加载
cpp
// 加载 plugins 目录下所有 .dll/.so 文件
int count = factory.LoadPluginsFromDirectory("plugins");
printf("Loaded %d plugins\n", count);
查询已加载插件
cpp
auto plugins = factory.GetLoadedPlugins();
for (const auto& info : plugins) {
printf("Plugin: %s v%s - %s\n",
info.name.c_str(),
info.version.c_str(),
info.description.c_str());
}
卸载插件
cpp
factory.UnloadPlugin("plugins/my_plugin.dll");
// 或
factory.UnloadAllPlugins();
节点属性参考
插件中可用的属性类型(editor/sdk/attribute.h):
| 类型 | 说明 |
|---|---|
IntAttribute |
整数输入 |
IntOutputAttribute |
整数输出 |
FloatAttribute |
浮点数输入 |
FloatOutputAttribute |
浮点数输出 |
StringAttribute |
字符串输入 |
StringInputAttribute |
字符串输入(可编辑) |
StringOutputAttribute |
字符串输出 |
PathInputAttribute |
路径输入 |
PathOutputAttribute |
路径输出 |
BoolAttribute |
布尔值 |
EnumAttribute |
枚举选择 |
ImageAttribute |
图像数据 |
注意:插件中的属性类型和内置节点略有不同,因为插件只能访问
editor/sdk/下的公共头文件。内置节点用的CvMatOutputAttribute、CvSizeInputAttribute等是在editor/node/下定义的,插件不可见。
实现细节:插件加载的底层逻辑
插件加载的核心是平台相关的动态库 API:
cpp
// factory.cpp (简化版)
bool Factory::LoadPlugin(const std::string& path) {
#ifdef _WIN32
HMODULE handle = LoadLibraryA(path.c_str());
#else
void* handle = dlopen(path.c_str(), RTLD_LAZY);
#endif
if (!handle) return false;
// 查找导出函数
using CreateFunc = IPlugin* (*)();
using DestroyFunc = void (*)(IPlugin*);
#ifdef _WIN32
auto create = (CreateFunc)GetProcAddress(handle, "CreatePlugin");
auto destroy = (DestroyFunc)GetProcAddress(handle, "DestroyPlugin");
#else
auto create = (CreateFunc)dlsym(handle, "CreatePlugin");
auto destroy = (DestroyFunc)dlsym(handle, "DestroyPlugin");
#endif
if (!create || !destroy) {
#ifdef _WIN32
FreeLibrary(handle);
#else
dlclose(handle);
#endif
return false;
}
// 创建插件实例并初始化
IPlugin* plugin = create();
if (!plugin->Initialize(this)) {
destroy(plugin);
return false;
}
// 保存信息
plugins_[path] = {handle, plugin, destroy, plugin->GetInfo()};
return true;
}
跨平台的部分用 #ifdef 处理。虽然不够优雅,但在业余项目里足够了。
踩坑记录
1. ABI 兼容性
这是最头疼的问题。插件和主程序必须用相同的编译器版本和运行时库编译。Visual Studio 2019 编译的主程序,用 VS2022 编译的插件大概率崩。
目前没有好办法,只能在文档里写清楚编译器版本要求。如果将来要支持第三方插件,可能需要提供 SDK 包含编译好的头文件和 lib。
2. 内存管理
插件里 new 出来的对象,必须在插件的 Shutdown() 里 delete。跨 DLL 边界的内存分配/释放会导致堆损坏。
cpp
void MyPlugin::Shutdown() {
// 释放插件分配的所有资源
// 不要指望主程序来释放!
}
3. 异常不要跨 DLL
C++ 异常不能安全地跨越 DLL 边界。插件里的异常必须自己 catch 住:
cpp
void MyCustomNode::Exec() override {
try {
// 可能抛异常的代码
} catch (const std::exception& e) {
// 记录日志,不要让异常逃逸
}
}
4. 导出符号问题
Windows 上默认不导出符号,必须用 __declspec(dllexport) 或 /D"BUILD_EDITOR_LIB" 宏。我在头文件里用了条件编译:
cpp
#ifdef BUILD_EDITOR_LIB
#define EDITOR_API __declspec(dllexport)
#else
#define EDITOR_API __declspec(dllimport)
#endif
Linux 上用 -fvisibility=hidden + __attribute__((visibility("default"))) 可以更精确地控制导出,但我偷懒没做。
5. 路径问题
Windows 用 \,Linux 用 /;DLL 后缀是 .dll,SO 后缀是 .so。LoadPluginsFromDirectory 需要处理这些差异。我用了一个简单的平台判断:
cpp
#ifdef _WIN32
const std::string ext = ".dll";
#else
const std::string ext = ".so";
#endif
现状和改进方向
目前的插件系统已经能用了,但还有很多可以改进的地方:
- 热加载:运行时卸载重载插件,不用重启程序
- 依赖声明:插件声明自己依赖哪些库版本,加载时检查
- 沙箱机制:限制插件的 API 访问权限,防止恶意插件
- 插件市场:类似 VS Code 的扩展市场(做梦ing)
- C API 层:提供纯 C 接口,让其他语言也能写插件