插件热加载与版本管理 / Plugin Hot Reloading and Version Management
📅 创建时间:2026-07-13 🏷️ 标签:#热加载 #live-reload #版本管理 #SemVer #ABI兼容 📚 前置知识:runtime dynamic loading, plugin architecture patterns
📋 本章目标
- 理解热加载的完整流程:检测变化 → 卸载旧版 → 加载新版 → 恢复状态
- 掌握文件变化检测方案:inotify(Linux)和 ReadDirectoryChangesW(Windows)
- 理解热加载中的状态保持问题和解决方案
- 掌握 SemVer 在插件版本管理中的运用
- 学会接口版本协商与向后兼容的接口演化策略
第1部分:热加载的原理与流程
1.1 完整流程
┌─────────────────────────────────────────────────────────────────────────────┐
│ 热加载完整流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 检测变化 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ inotify / 轮询 / signal → 发现 plugin.so 文件被修改 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 2. 保存状态 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 从旧插件实例中提取当前状态 → 序列化为 JSON / 二进制 blob │ │
│ │ (如果插件是"无状态"的,这步可以跳过) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 3. 卸载旧版 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ plugin->shutdown() → destroy(plugin) → dlclose(handle) │ │
│ │ ⚠️ 确保没有任何线程还在使用旧的插件实例! │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 4. 加载新版 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ dlopen(new_path) → dlsym create → plugin->initialize() │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 5. 恢复状态 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 把保存的状态注入新插件实例 → 新插件恢复工作状态 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘1.2 文件变化检测
// ====== Linux: inotify 方案 ======
#include <sys/inotify.h>
#include <unistd.h>
#include <thread>
class FileWatcher {
int fd;
std::unordered_map<int, std::string> wd_to_path;
std::function<void(const std::string&)> callback;
public:
void watch(const std::string& path) {
fd = inotify_init();
int wd = inotify_add_watch(fd, path.c_str(),
IN_CLOSE_WRITE | IN_MOVED_TO); // 文件写完或被移入
wd_to_path[wd] = path;
}
void start() {
std::thread([this] {
char buf[4096];
while (true) {
int len = read(fd, buf, sizeof(buf));
auto* event = (inotify_event*)buf;
if (event->mask & IN_CLOSE_WRITE) {
callback(wd_to_path[event->wd] + "/" + event->name);
}
}
}).detach();
}
};// ====== 通用方案:轮询文件 mtime ======
class PollWatcher {
std::unordered_map<std::string, time_t> last_mtime;
std::chrono::seconds interval{1};
public:
bool check(const std::string& path) {
auto ftime = std::filesystem::last_write_time(path);
auto t = decltype(ftime)::clock::to_time_t(ftime);
if (last_mtime[path] != t) {
last_mtime[path] = t;
return true;
}
return false;
}
};1.3 状态保持 —— 热加载最棘手的问题
┌─────────────────────────────────────────────────────────────────────────────┐
│ 热加载中的状态问题 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 问题:旧插件里有运行时状态(计算结果缓存、打开的连接、用户偏好等) │
│ 卸载旧插件 → 状态丢失 → 新插件从空白开始 → 用户体验断裂 │
│ │
│ 解决方案: │
│ │
│ 方案A:序列化/反序列化 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ IPlugin 接口增加: │ │
│ │ virtual const char* saveState() = 0; // 旧插件 → JSON │ │
│ │ virtual bool loadState(const char*) = 0; // JSON → 新插件 │ │
│ │ │ │
│ │ 流程:saved = old->saveState() → 卸载旧 → 加载新 │ │
│ │ → new->loadState(saved) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 方案B:外部状态存储(推荐) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 插件不持有状态 → 状态由宿主统一管理 │ │
│ │ 插件是无状态的纯函数 → 热加载时不需要迁移状态 │ │
│ │ 这是函数式编程思想在插件系统中的体现 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 方案C:双缓冲 + 原子切换 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 1. 单独加载新版插件(不卸载旧版) │ │
│ │ 2. 用新版处理新请求,旧版继续服务已有连接 │ │
│ │ 3. 旧版连接全部结束 → 卸载旧版 │ │
│ │ 类似 Nginx 的"优雅重载" │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘1.4 原子替换 —— 防止读到半写入的文件
# ❌ 危险做法:直接覆盖
cp new_plugin.so plugins/plugin.so # 如果程序在这时加载 → 读到半截文件
# ✅ 安全做法:写入临时文件 + rename
cp new_plugin.so plugins/.plugin.so.tmp
mv plugins/.plugin.so.tmp plugins/plugin.so # rename 是原子操作!第2部分:版本管理
2.1 SemVer 在插件中的运用
┌─────────────────────────────────────────────────────────────────────────────┐
│ Semantic Versioning 在插件系统中的意义 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ MAJOR.MINOR.PATCH (如 2.1.3) │
│ │
│ MAJOR (主版本) —— 不兼容的 API 变更 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 1.x.x → 2.0.0: 接口签名变了、删除了旧函数、行为不兼容 │ │
│ │ 依赖该插件的其他插件可能需要修改 │ │
│ │ Soname: libfoo.so.1 → libfoo.so.2(Linux 侧) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ MINOR (次版本) —— 向后兼容的新功能 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 1.0.x → 1.1.0: 增加了新函数、旧的函数行为不变 │ │
│ │ 依赖方不需要修改,自动受益 │ │
│ │ Soname 不变:libfoo.so.1(Linux 侧) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ PATCH (补丁版本) —— 向后兼容的 Bug 修复 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 1.1.0 → 1.1.1: 只修 bug,不改变行为 │ │
│ │ 通常可以安全热加载 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ PATCH 升级 → 可以直接热加载(安全) │
│ MINOR 升级 → 可以热加载(向后兼容) │
│ MAJOR 升级 → 不能热加载(接口不兼容,需要走完整的 version migration) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘2.2 接口版本协商
// 插件声明自己支持哪些 API 版本
class IPlugin {
public:
// 返回此插件支持的最高 API 版本
virtual int getApiVersion() { return 1; }
// 宿主可以据此决定如何与插件交互
};
// 宿主中:
int negotiate_version(IPlugin* plugin) {
int plugin_version = plugin->getApiVersion();
int host_version = HOST_API_VERSION;
// 取交集的最低版本
return std::min(plugin_version, host_version);
}2.3 COM 风格的接口查询
// IPlugin 基类增加 queryInterface 方法
class IPlugin {
public:
// 允许宿主查询"你是否支持 ICalculatorV2 接口?"
virtual void* queryInterface(const char* interface_name) {
return nullptr; // 默认不支持任何扩展接口
}
};
// 宿主中:
auto* calc = create_calculator();
auto* v2 = static_cast<ICalculatorV2*>(calc->queryInterface("ICalculatorV2"));
if (v2) {
v2->advancedFeature(); // 使用 v2 新功能
} else {
calc->basicFeature(); // fallback 到基本功能
}核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ 热加载与版本管理核心速查 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 热加载五步:检测 → 保存状态 → 卸载 → 加载 → 恢复状态 │
│ │
│ 文件检测:Linux inotify / 通用轮询 mtime │
│ │
│ 原子替换:先写 temp 再 rename(原子操作!) │
│ │
│ SemVer:MAJOR.MINOR.PATCH │
│ PATCH → 直接热加载 │
│ MINOR → 可以热加载(增功能) │
│ MAJOR → 不能热加载(API 不兼容,需要完整迁移) │
│ │
│ 接口演化策略: │
│ 1. 版本号工厂(create(int version)) │
│ 2. queryInterface(COM 风格) │
│ 3. 只增虚函数到末尾 + 不改签名 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:热加载顺序
为什么热加载时要按 shutdown → destroy → dlclose 的顺序,不能直接 dlclose?
测试2:原子替换
为什么热加载场景下,覆盖插件文件要用"写 temp + rename"而不是直接覆盖?
测试3:SemVer
一个插件从 1.2.3 升级到 1.3.0。按 SemVer,接口是否兼容?可以热加载吗?从 1.3.0 升级到 2.0.0 呢?
参考答案
测试1答案
答案:直接 dlclose 会导致资源泄漏和潜在崩溃:(1) 插件可能在 initialize 时分配了内存、打开了文件、注册了回调——不先 shutdown 就直接卸载,这些资源就泄漏了;(2) 如果其他线程正在使用插件实例 → dlclose 后虚函数表可能无效 → 崩溃;(3) 正确的关闭顺序是 shutdown(清理资源 + 通知其他组件)→ destroy(delete 实例)→ dlclose(卸载库文件)。
测试2答案
答案:直接覆盖不是原子操作,如果文件的写入速度慢于程序读取的速度(或写入一半时崩溃),程序可能加载到一个不完整的 .so 文件。而 rename() 系统调用是原子的——它只修改目录项中的文件名→inode 映射,不修改文件数据。要么指向旧文件,要么指向新文件,不存在"半新旧"的中间状态。
测试3答案
答案:1.2.3 → 1.3.0:是 MINOR 升级(次版本号变了,主版本号不变),按 SemVer 是向后兼容的——旧的 API 行为不变,只增加了新功能。可以安全热加载。1.3.0 → 2.0.0:是 MAJOR 升级,API 不兼容(删除了旧函数、改了签名/行为),不能直接热加载,需要走完整的版本迁移流程。
相关笔记
- runtime dynamic loading - 运行时加载基础
- plugin architecture patterns - 插件生命周期管理
- plugin security - 热加载的安全考虑
下一步学习
- [ ] 阅读 12 - 插件安全与沙箱
学习状态:🟡 开始学习
工程深化:把本篇知识落到项目里
这一节不是为了凑篇幅,而是把《插件热加载与版本管理 / Plugin Hot Reloading and Version Management》从“知道概念”推进到“能在项目里稳定使用”。 阅读时可以把每个知识点都追问成四件事:它保护什么边界,失败时有什么现象,怎样最小复现,怎样写进团队流程。
concept -> boundary -> failure signal -> minimal proof -> project rule工程切片 1:最小可复现样例
**场景。**围绕 load/unload 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 load/unload、句柄 还是 入口函数 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> load/unload -> 句柄 -> 入口函数 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 2:接口边界
**场景。**围绕 句柄 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 句柄、入口函数 还是 引用计数 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 句柄 -> 入口函数 -> 引用计数 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 3:失败注入
**场景。**围绕 入口函数 建一个很小的案例,不要一开始就放进完整业务系统。故意制造一个常见错误,让日志、断言或测试先失败,再用修复后的证据说明规则生效。
**边界。**先判断这里讨论的是 入口函数、引用计数 还是 错误码 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 入口函数 -> 引用计数 -> 错误码 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 4:跨平台差异
**场景。**围绕 引用计数 建一个很小的案例,不要一开始就放进完整业务系统。至少比较 Windows、Linux 或不同编译器下的一个差异,记录差异属于标准、实现还是平台约定。
**边界。**先判断这里讨论的是 引用计数、错误码 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 引用计数 -> 错误码 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 5:性能观察
**场景。**围绕 错误码 建一个很小的案例,不要一开始就放进完整业务系统。用小规模和压力规模各跑一次,区分算法成本、同步成本、I/O 成本和工具链配置成本。
**边界。**先判断这里讨论的是 错误码、生命周期 还是 load/unload 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 错误码 -> 生命周期 -> load/unload -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 6:生命周期
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。标出资源创建、转移、共享、停止和销毁的顺序,特别关注错误返回和提前退出路径。
**边界。**先判断这里讨论的是 生命周期、load/unload 还是 句柄 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> load/unload -> 句柄 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 7:诊断证据
**场景。**围绕 load/unload 建一个很小的案例,不要一开始就放进完整业务系统。保留命令、日志、符号、栈、测试输出或截图,让结论可以被另一个环境重新验证。
**边界。**先判断这里讨论的是 load/unload、句柄 还是 入口函数 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> load/unload -> 句柄 -> 入口函数 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 8:维护策略
**场景。**围绕 句柄 建一个很小的案例,不要一开始就放进完整业务系统。把一次性经验沉淀到 README、CI、脚本、示例工程或检查表里,避免只存在个人记忆中。
**边界。**先判断这里讨论的是 句柄、入口函数 还是 引用计数 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 句柄 -> 入口函数 -> 引用计数 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 9:版本演进
**场景。**围绕 入口函数 建一个很小的案例,不要一开始就放进完整业务系统。为未来变更预留兼容策略:版本号、特性位、弃用窗口、迁移脚本和回滚入口。
**边界。**先判断这里讨论的是 入口函数、引用计数 还是 错误码 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 入口函数 -> 引用计数 -> 错误码 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 10:最小消费者
**场景。**围绕 引用计数 建一个很小的案例,不要一开始就放进完整业务系统。从使用者角度写一个小程序或小工程,只依赖公开接口,验证安装包、头文件和运行时行为。
**边界。**先判断这里讨论的是 引用计数、错误码 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 引用计数 -> 错误码 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 11:边界复盘
**场景。**围绕 错误码 建一个很小的案例,不要一开始就放进完整业务系统。用一张表说明问题发生在源码、编译、链接、加载、运行、关闭或发布中的哪一层。
**边界。**先判断这里讨论的是 错误码、生命周期 还是 load/unload 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 错误码 -> 生命周期 -> load/unload -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 12:反例训练
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。写出一个看似能工作但不可维护的方案,再说明它在规模、团队协作或发布时会怎样失败。
**边界。**先判断这里讨论的是 生命周期、load/unload 还是 句柄 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> load/unload -> 句柄 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 13:最小可复现样例
**场景。**围绕 load/unload 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 load/unload、句柄 还是 入口函数 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> load/unload -> 句柄 -> 入口函数 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 14:接口边界
**场景。**围绕 句柄 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 句柄、入口函数 还是 引用计数 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 句柄 -> 入口函数 -> 引用计数 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
本篇收束
掌握《插件热加载与版本管理 / Plugin Hot Reloading and Version Management》的标志,不是记住所有名词,而是能把 load/unload、句柄、入口函数、引用计数、错误码、生命周期 放进一条可验证的工程链路。 先用最小样例建立判断,再用测试和脚本固定判断,最后把失败证据留给未来的自己和团队。