跳到主要内容

状态池 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 内部的指针、引用、迭代器,都不能跨调用保留

提示

正确做法

  • 只存轻量状态:boolintfloatHXString、小的 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 要稳定对象要轻量指针不缓存