状态池 HXStatePool
IMGUI 框架有一个经典哲学:每一帧都从头画。好处是简单、直观、状态即代码;但麻烦也来了——那些需要跨帧记住的东西怎么办?比如文本输入框的光标位置、下拉框的展开状态、滑块的拖动中标记……
HiEasyX 的答案是 HXStatePool:一个以字符串为键、类型安全为盾的跨帧状态池。
核心原理
class HXStatePool {
public:
template<typename T>
T& Get(const HXString& Id, const T& defaultValue = T{});
template<typename T>
T* Find(const HXString& Id);
void Remove(const HXString& Id);
void Clear();
};
内部实现上,HXStatePool 是一个 std::unordered_map<HXString, std::any>。每个状态条目用一个唯一的字符串 Id 做键,值被包装在 std::any 里,从而支持任意类型的存储。
为什么不用 static 变量?
static 确实能跨帧保留数据,但它是全局唯一的。IMGUI 的控件可能在多个窗口、多个实例里重复出现,每个实例都需要独立的状态。用 Id 索引的状态池天然支持多实例,而且生命周期由框架管理,控件销毁时清理更干净。
API 详解
Get<T>:取或创建
template<typename T>
T& Get(const HXString& Id, const T& defaultValue = T{});
- 如果
Id已存在,返回对应状态的引用。 - 如果不存在,用
defaultValue初始化一个,插入池中,然后返回引用。
// 保存一个计数器,初始值为 0
int& clickCount = HX::GetStatePool().Get<int>(HXStr("my_button_clicks"), 0);
clickCount++;
// 保存字符串
HXString& inputText = HX::GetStatePool().Get<HXString>(HXStr("search_box_text"), HXStr(""));
Get 是最常用的方法。控件内部基本都是这么干的:先按自己的 Id 去池子里取状态,没有就新建一个默认值。对用户来说这一切是透明的——你不需要手动初始化。
Find<T>:只查不创
template<typename T>
T* Find(const HXString& Id);
- 如果
Id存在且类型匹配,返回指针。 - 如果不存在或类型不匹配,返回
nullptr。
if (auto* ptr = HX::GetStatePool().Find<float>(HXStr("slider_value"))) {
// 之前存过,复用
float oldValue = *ptr;
} else {
// 第一次出现,走初始化逻辑
}
Remove:删除指定状态
HX::GetStatePool().Remove(HXStr("obsolete_state"));
适合在重置界面、关闭面板时手动清理,避免池子无限膨胀。
Clear:清空全部
HX::GetStatePool().Clear();
慎用 Clear()!这会删掉所有控件的状态,相当于给整个 UI 做了一次“失忆”。通常只在切换完整页面、加载新布局时调用。日常帧循环里千万别写,否则所有输入框、滑块、下拉框都会瞬间归零。
使用场景:摆脱 static 变量
假设你以前写 IMGUI 控件时习惯这样:
// 不推荐:static 无法支持多实例
void DrawCounter() {
static int count = 0; // 全局唯一,两个窗口共用同一个 count!
if (HX::Button(HXStr("+1"), bp)) count++;
HX::Text(HX::ToHXString(count));
}
改成状态池后,天然支持多实例:
// 推荐:每个 Id 独立一份状态
void DrawCounter(const HXString& id) {
int& count = HX::GetStatePool().Get<int>(id + HXStr("_count"), 0);
if (HX::Button(HXStr("+1"), bp)) count++;
HX::Text(HX::ToHXString(count));
}
// 两个独立的计数器
DrawCounter(HXStr("counter_a"));
DrawCounter(HXStr("counter_b"));
重要警告:std::any 的堆分配陷阱
std::unordered_map<HXString, std::any> Data;
std::any 在存储小对象时可以用内部缓冲区(通常 16~32 字节),但存储大对象(比如 std::vector、自定义结构体)时可能会触发堆分配。
不要存大对象进 StatePool,更不要存指针进去当长期引用!
// 错误示范:存了 vector,每次 Get 都可能触发拷贝或堆操作
auto& bigVec = HX::GetStatePool().Get<std::vector<float>>(HXStr("huge_data"));
// 危险示范:存指针,指向 StatePool 内部的数据
float* ptr = &HX::GetStatePool().Get<float>(HXStr("value"));
// 下一帧如果另一个状态插入导致 map rehash,ptr 就悬空了!
std::unordered_map 在插入新元素时可能发生 rehash,所有已有元素的地址都会变。任何指向 StatePool 内部的指针、引用、迭代器,都不能跨调用保留。
正确做法:
- 只存轻量状态:
bool、int、float、HXString、小的struct。 - 需要大对象时,在 StatePool 里存
std::shared_ptr<YourData>,把生命周期托管给智能指针。 - 每次需要用时重新
Get<T>()取引用,不要缓存指针。
与 Profile 的关系
很多控件的 Profile 结构里看起来只有样式参数,但内部实现可能会偷偷用 HXStatePool 保存运行时状态:
struct TextInputProfile {
HXString Text; // 当前文本(用户可见)
// 内部还会用 StatePool 存:
// - 光标位置
// - 选区起点/终点
// - IME 组合字符串
// - 上一次的鼠标点击时间(用于双击检测)
};
这些内部状态通常以控件的 Id 为前缀生成子键,比如 "my_input_cursor_pos",用户不需要关心,但你要知道:Profile 里放不下的运行时状态,都在 StatePool 里。
这也解释了为什么 HiEasyX 的 TextEditor 要求传入固定的稳定 Id——如果 Id 每帧都变(比如用循环索引拼接),StatePool 就找不到上一帧的光标位置、双击计时器,双击选词、光标闪烁等功能全部失效。
一句话总结
| 方法 | 作用 | 注意 |
|---|---|---|
Get<T>(Id, default) | 取状态,没有就创建 | 最常用,返回引用 |
Find<T>(Id) | 只查找,不创建 | 找不到返回 nullptr |
Remove(Id) | 删除单个状态 | 关闭面板时清理 |
Clear() | 清空全部 | 相当于 UI 失忆,慎用 |
HXStatePool 是 HiEasyX 实现 “每一帧都从头画” 的秘诀。用好它,你的控件就能既保持 IMGUI 的简洁,又拥有传统 GUI 的持久交互能力。记住三句话:Id 要稳定、对象要轻量、指针不缓存。