在使用 EnTT 进行跨库或插件开发时 (尤其是 Windows 或 GNU/Linux 默认隐藏符号的情况下), 了解其符号可见性和类型擦除的处理方式是非常重要的
实现
历史问题
EnTT 在以下场景中曾遇到过限制:
Windows: 跨动态库或应用程序边界使用时Linux: 当设置默认符号可见性为hidden时
问题的根源在于 EnTT 使用的内部工具来为不同类型分配唯一且连续的标识符
幸运的是, 这些问题已经得到解决, 只要正确配置编译选项, EnTT 现在可以顺畅地跨边界运行
类型擦除与类型识别
类型擦除 是 EnTT 用于管理 实体 和 组件 的一项重要技术, 由于类型被擦除, 必须通过某种机制来识别它们, EnTT 提供了 type_hash 类模板来处理, 为被擦除的类型生成唯一标识符, 标识符冲突虽然极少发生, 但确实可能出现, 如果默认方案不适合需求, EnTT 提供了自定义的解决方案
符号导出与导入
在使用 动态链接库 时, 使用 ENTT_API_EXPORT 和 ENTT_API_IMPORT 宏来控制符号 导出/导入 以保证一切顺利跨边界运行
ENTT_API_EXPORT: 用于从共享库导出符号ENTT_API_IMPORT: 用于在应用程序或其他库中导入符号
如果是 不导出符号的共享库 (如插件), 则通常无需额外配置, 因为不涉及符号 导出/导入 的问题
测试用例
EnTT 的 测试用例 中包含了许多关于跨边界使用的示例, 在源码的 lib 目录下, 可以找到以下内容
- 使用
ENTT_API_EXPORT和ENTT_API_IMPORT处理动态链接库的示例 - 插件形式使用 (不导出符号) 的示例
- 解决类型哈希冲突或自定义标识符的用例
这些测试用例覆盖了大部分常见场景, 可以作为开发的基础参考
跨边界的 Meta 上下文共享
在使用 EnTT 的运行时反射系统时, 跨边界使用场景需要特别注意上下文 context 的共享问题
EnTT 的反射系统依赖于一个静态上下文, 所有的反射元素都会附加到这个上下文上
- 不同的上下文是独立的, 互不相关
- 如果不共享上下文, 则跨边界无法识别对方的反射类型
为了解决这个问题, 必须在多个边界间共享同一个上下文, 以确保可以使用相同的 meta 类型
共享上下文的方法非常简单, 分两步完成
- 获取当前上下文句柄
在 发送端 获取本地上下文的句柄
auto handle = entt::locator<entt::meta_ctx>::handle();此时, handle 保存了当前上下文的引用
- 在 接收端 设置上下文 将获取到的上下文句柄传递到另一端, 并将其设置为默认上下文
entt::locator<entt::meta_ctx>::reset(handle);这一步会将接收端的本地上下文替换为共享的上下文, 如果有必要, 可以将本地上下文存储起来以备后用
从这一刻起, 所有新的 meta 类型都会附加到共享上下文, 无论它们是在何处创建的
注意事项
-
更换上下文不会自动同步更改
如果一端替换了上下文, 这种更改不会自动传播到另一端, 上下文的替换会导致两个端之间的 解耦, 从而引发内容的分歧 -
上下文共享是单向的
共享上下文仅在初始设置时有效, 之后的所有更改需要确保对共享的上下文直接操作,而不是替换
示例代码
发送端
// 获取当前上下文句柄
auto handle = entt::locator<entt::meta_ctx>::handle();
// 传递 handle 给接收端(假设通过某种 IPC 或者直接调用)
sendContextToReceiver(handle);接收端
// 接收到句柄并设置为默认上下文
entt::locator<entt::meta_ctx>::reset(receivedHandle);此后, 发送端和接收端都使用相同的上下文, 所有 meta 类型的注册和查询都在共享的上下文上完成
内存管理问题
在使用 EnTT 时, 内存管理可能会引发一些微妙的问题, 尤其是在组件或事件等对象通过动态方式按需创建的情况下, 这些问题通常出现在以下场景:
- 插件使用场景
- 静态链接的运行时 statically linked runtime
当 EnTT 的 注册表 或 池 在不同的边界上共享时, 内存管理就可能变得复杂
问题来源
以一个典型场景为例:
- 主程序 创建了一个
registry实例, 并将其共享给 插件 - 插件处理一个主程序未知的组件, 注册表为此组件按需动态创建了一个 池
问题: 这个池是在插件的一侧创建的, 但却附加到了主程序的注册表中
由于不同边界使用不同的内存分配机制 (如不同的运行时库或分配器), 这种情况可能导致内存管理冲突, 例如
- 插件释放了主程序分配的内存 (或反之),导致 崩溃
- 隐藏的内存泄漏或数据不一致
解决方案
使用明确的接口
为了避免跨边界的内存管理问题, 需要通过接口传递基本类型, 而非直接共享 EnTT 的内部实例, 也就说 将 EnTT 的实例 (如注册表或池) 隔离在特定边界内, 并通过接口暴露其功能
class IRegistryInterface {
public:
virtual void createEntity(int entityId) = 0;
virtual void assignComponent(int entityId, const std::string &componentData) = 0;
virtual ~IRegistryInterface() = default;
};插件和主程序都实现接口, 而不是直接共享 registry 实例
使用统一的内存分配策略
确保插件和主程序使用相同的动态运行时库 (如 MSVC 的动态运行时 /MD 或 /MDd), 如果可能, 使用 共享内存分配器 custom memory allocators, 让内存管理行为一致
隔离注册表实例
在跨边界时, 避免直接共享 registry, 相反, 可以
- 在每个边界维护独立的
registry实例 - 通过接口或序列化机制传递数据
实例
以下示例展示如何通过接口隔离注册表
主程序实现接口
在这种情况下, 接口的实现完全在主程序中, 插件只负责调用主程序暴露的接口, 适用于插件只需要调用功能, 而不需要提供自己的实现
接口定义
class IRegistryInterface {
public:
virtual void createEntity(int entityId) = 0;
virtual void assignComponent(int entityId, const std::string &componentData) = 0;
virtual ~IRegistryInterface() = default;
};主程序
#include "IRegistryInterface.h"
class MainRegistry : public IRegistryInterface {
public:
void createEntity(int entityId) override {
registry.create(entityId);
}
void assignComponent(int entityId, const std::string &componentData) override {
// 序列化或解析组件数据
registry.emplace<ComponentType>(entityId, componentData);
}
private:
entt::registry registry;
};插件
#include "IRegistryInterface.h"
void pluginFunction(IRegistryInterface ®istryInterface) {
registryInterface.createEntity(1);
registryInterface.assignComponent(1, "some_component_data");
}通过接口交互避免了直接共享 EnTT 实例, 从而隔离了内存管理
插件实现接口
适用于插件需要扩展功能, 或者插件需要提供不同的行为, 由插件实现接口, 并在运行时由主程序动态加载, 主程序只依赖接口, 不直接与插件交互, 而是通过接口调用插件功能
接口定义 (所有二进制都依赖)
class IRegistryInterface {
public:
virtual void registerPluginComponent() = 0;
virtual ~IRegistryInterface() = default;
};插件实现接口
class PluginRegistry : public IRegistryInterface {
public:
void registerPluginComponent() override {
// 注册插件特有的组件
registry.emplace<PluginComponent>(1);
}
private:
entt::registry registry;
};主程序加载插件
#include <memory>
#include "IRegistryInterface.h"
void loadPlugin(std::shared_ptr<IRegistryInterface> plugin) {
plugin->registerPluginComponent();
}主程序通过接口加载插件的功能, 而不关心具体实现, 插件完全控制自己的 registry, 避免直接依赖主程序的内存管理