C++轻量级多级撤销重做框架:UndoManager实战指南

发布时间:2026/10/22 13:31:56
C++轻量级多级撤销重做框架:UndoManager实战指南
简介本资源是一套面向C桌面应用开发者的撤销/重做Undo/Redo功能完整实现方案适用于文本编辑器、富文本处理工具等需状态回溯能力的中高级项目。压缩包共61个文件含28个.cpp源文件如UndoManager.cpp、RETypingAction.cpp、REDropAction.cpp等承载核心操作封装与状态管理逻辑、31个.h头文件定义Action基类、视图接口、文本范围与样式结构等另含1个说明文档和1个资源脚本整体仅70KB轻量但结构严谨。已有155人学习下载适合希望深入理解命令模式、栈式历史管理、细粒度操作建模打字、替换、拖放、对象插入等的开发者。代码采用模块化设计支持RichEdit与BetterEdit双视图体系涵盖多步菜单交互MultipleUndoRedoMenu、跨平台字符串处理UMString.h及变更日志记录可直接集成或作为教学范例用于GUI架构实践。1. undo_manager.zip 是什么一个能跑通多级撤销/重做、支持 RichEdit 与自定义编辑器的 C 实现包不是 Demo是可嵌入生产环境的轻量级 UndoManager 框架你有没有遇到过这种场景在开发一个类 Word 的富文本编辑控件时用户按 CtrlZ 却只撤销了光标跳动、没撤回刚输的三行字或者替换操作一撤销段落样式全乱了再重做又崩出乱码这不是玄学——是 Undo/Redo 系统没把「操作语义」和「状态快照」解耦清楚。undo_manager.zip就是为解决这类问题而生的它不是一个教科书式单栈 demo而是一套经过真实编辑视图RicherEditView / BetterEditView验证的、分层抽象的 C 撤销管理框架。核心不靠深拷贝整个文档树而是用 Action 对象封装「做什么 怎么反向做」配合 UndoManager 统一调度支持 Typing、Replace、Drop、InsertObject、Style 修改等 12 类原子操作且每类操作都自带execute()/undo()/redo()三态接口。它不依赖 MFC 或 Qt纯 Win32 STL 实现头文件干净.h全部内聚源码可读性高——我曾在某高校图像标注工具中直接剥离UndoManager.h/cpp和Action.h3 小时内就接入了 ROI 标注框的移动/缩放撤销逻辑。适合需要快速构建可控撤销能力的桌面端 C 开发者尤其当你正在维护一个老系统、不能引入 Boost 或现代 C17 特性时这套代码就是你的后悔药。2. 撤销系统怎么搭从 Action 抽象到 UndoManager 注册四步完成最小可运行链路2.1 Action 是什么不是命令模式的玩具而是带上下文感知的可逆操作实体Action是整个框架的基石但它的设计远超普通命令模式。看Action.h定义class Action { public: virtual ~Action() default; virtual bool execute() 0; // 执行正向操作如插入文本 virtual bool undo() 0; // 执行反向操作如删除刚插的文本 virtual bool redo() 0; // 重执行常等价于 execute但某些场景需区分 virtual const char* getName() const 0; // 用于 UI 显示如 Typing: hello virtual size_t getMemorySize() const 0; // 用于内存控制避免栈爆掉 };关键点在于execute()不是简单地改数据它必须主动通知 UndoManager 自己已生效undo()也不是无脑回滚它要能处理「操作被截断」的情况比如用户在 Typing 过程中突然 CtrlV此时 TypingAction 需知道当前输入未完成。所以你看BETypingAction.cpp里有这样一段bool BETypingAction::execute() { if (!m_pView || m_text.empty()) return false; // 1. 真正插入文本 m_pView-insertText(m_pos, m_text.c_str()); // 2. 记录插入后的新光标位置供 undo 时精准还原 m_newPos m_pos m_text.length(); // 3. 主动注册自己到管理器 —— 这步不能漏 if (m_pUndoManager) { m_pUndoManager-addAction(this); // 注意this 被托管非裸指针 } return true; }提示addAction(this)是触发点。很多新手以为只要继承 Action 就自动进栈其实必须显式调用。框架不接管生命周期但要求UndoManager持有std::shared_ptrAction见UndoManager.h中m_undoStack类型所以你在execute()里传this前得确保该 Action 是堆上分配且由智能指针管理——这是第一道安全阀。2.2 UndoManager 如何持栈双栈结构 内存水位线不是简单 push/popUndoManager.cpp的核心是两个std::stackstd::shared_ptrActionm_undoStack和m_redoStack。但它比教科书实现多三件事操作合并Coalescing连续多个BETypingAction比如用户狂敲字母会被合并成一个 Action避免栈爆炸。合并逻辑在UndoManager::addAction()中判断void UndoManager::addAction(const std::shared_ptrAction action) { if (m_undoStack.empty()) { m_undoStack.push(action); return; } auto top m_undoStack.top(); // 合并条件同类型、同视图、时间间隔 300ms、且前一个未被 redo 过 if (top-getName() action-getName() top-getOwnerView() action-getOwnerView() GetTickCount64() - m_lastActionTime 300) { // 调用 top-merge(action)将新输入追加到旧 action 的 m_text 中 top-merge(action); } else { m_undoStack.push(action); } m_lastActionTime GetTickCount64(); }内存保护通过getMemorySize()累计栈内总内存超过阈值默认 5MB自动 trim 最老的若干 Action。这个阈值可 runtime 修改m_maxMemoryKB 10 * 1024;状态同步每次undo()后m_undoStack弹出m_redoStack压入redo()则相反。但clearRedo()会清空m_redoStack——这点在实现「撤销后新操作清空重做栈」时至关重要。2.3 视图如何接入RicherEditView 是参考样板BetterEditView 展示扩展能力RicherEditView.cpp是标准 Windows RichEdit 控件的封装它在OnChar()、OnKeyDown()、OnPaste()等消息处理中创建对应 Action// RicherEditView.cpp void RicherEditView::OnChar(UINT nChar, UINT nRepCnt, UINT nFlags) { if (nChar \b || nChar VK_DELETE) { // 删除操作 → 创建 BEReplaceAction覆盖删除逻辑 auto action std::make_sharedBEReplaceAction(this, getSelectionRange(), _T()); action-execute(); // 自动注册 } else if (isPrintable(nChar)) { // 输入字符 → 创建 BETypingAction auto action std::make_sharedBETypingAction(this, getCaretPos(), CString(nChar, 1)); action-execute(); } }而BetterEditView.cpp更进一步它支持对象插入图片、表格、段落样式ParagraphStyle、文本样式TextStyle所以它的OnDrop()会创建REDropActionOnApplyStyle()创建REStyleAction。二者共用同一套UndoManager实例证明框架天然支持多视图协同撤销——比如你在 BetterEditView 插入一张图再切到 RicherEditView 打字CtrlZ 会先撤回打字再撤回插图。2.4 菜单如何联动MultipleUndoRedoMenu 是真·生产级菜单不是 MessageBox 演示MultipleUndoRedoMenu.cpp实现了 Windows 原生菜单的动态更新它监听WM_INITMENUPOPUP并实时填充「Undo 3 steps」「Redo 2 steps」等子项。关键不在 UI而在它如何获取可撤销项列表// MultipleUndoRedoMenu.cpp void MultipleUndoRedoMenu::UpdateUndoMenu(HMENU hMenu) { // 清空原有 undo 子项 RemoveMenu(hMenu, 0, MF_BYPOSITION); // 取出最多 5 个 undo 项避免菜单过长 size_t count std::min(m_pUndoManager-getUndoStackSize(), (size_t)5); for (size_t i 0; i count; i) { auto action m_pUndoManager-getUndoActionAt(i); // 注意非 top是倒序第 i 个 CString label; label.Format(_T(Undo %s), action-getName()); // 如 Undo Typing: abc AppendMenu(hMenu, MF_STRING, ID_UNDO_BASE i, label); } }这里getUndoActionAt(i)是重点它不返回栈顶而是返回「如果连续 undo i 步第 i 步会执行哪个 Action」。这要求UndoManager内部缓存了栈底到栈顶的完整 Action 列表实际用std::vectorstack双备份否则无法实现多步菜单。很多开源 Undo 库只支持单步就是因为没做这层缓存。3. 避坑指南五个血泪经验总结全是线上翻车现场复盘3.1 现象CtrlZ 撤销后光标位置错乱有时跳到文档开头原因TextRange.cpp中getSelectionRange()返回的坐标是屏幕像素或字符索引但BETypingAction存储的是EM_GETSEL获取的字节偏移。RichEdit 在 UTF-16 下一个汉字占 2 字节若 Action 直接存EM_GETSEL返回的lParam字节偏移而undo()时用EM_SETSEL设置相同值就会因宽字符导致光标偏移。解决统一使用ITextRange接口ITextDocument::Range()获取逻辑位置。TextRange.cpp已封装getLogicalStart()/getLogicalEnd()所有 Action 必须调用这两个方法存位置而非裸EM_GETSEL。3.2 现象执行 Replace 操作后Undo 时只删了旧文本新文本没恢复原因REReplaceAction.cpp的execute()中先delete old再insert new但undo()只做了insert old漏了delete new。更糟的是若 replace 跨越段落delete old可能触发段落合并undo()时仅插入旧文本无法还原段落结构。解决REReplaceAction必须在execute()前用ParagraphTable::saveState()拍摄段落快照在undo()中调用restoreState()。查看ParagraphTable.cpp第 217 行saveState()实现它序列化m_paragraphsvector 和每个段落的m_styleId、m_indent等字段restoreState()则逐字段比对并 patch。3.3 现象拖放图片后撤销图片消失但占位框残留再重做报访问违规原因REDropAction.cpp中execute()创建了CImageList对象并存入ObjectTable但undo()只从ObjectTable移除了 ID未释放CImageList内存redo()时尝试复用已释放的句柄。解决ObjectTable.cpp的removeObject()必须调用CImageList::DeleteImage()和Destroy()且REDropAction构造时应传入CImageList*的 raw pointerexecute()中用new CImageList分配undo()中deleteredo()中重新new—— 不共享句柄。ObjectData.h第 89 行有m_bOwnsImageList标志位设为true即启用自动释放。3.4 现象开启多级 Undo 菜单后点击「Undo 3 steps」只执行了第一步就停住原因MultipleUndoRedoMenu.cpp的OnCommand()处理ID_UNDO_BASE i时错误地调用了m_pUndoManager-undo()一次而非循环i1次。且未检查中间某步undo()返回false如 Action 执行失败就继续。解决OnCommand()中需用for (int j 0; j i; j) { if (!m_pUndoManager-undo()) break; }且每次undo()后刷新菜单UpdateUndoMenu()因为栈深度已变。3.5 现象程序退出时崩溃在UndoManager::~UndoManager()堆栈显示std::shared_ptr释放异常原因UndoManager析构时m_undoStack和m_redoStack中的shared_ptrAction仍在持有RicherEditView*成员指针而 View 对象已先于 UndoManager 销毁导致 Action 的undo()调用野指针。解决在RicherEditView::~RicherEditView()中必须显式调用m_pUndoManager-clearAll()清空双栈并置空所有 Action 的m_pView成员且Action基类中m_pView应声明为weak_ptrEditView而非裸指针。BEAction.h第 42 行已定义std::weak_ptrEditView m_wpView所有子类构造时需传入shared_ptr并 lock() 使用。4. 多视图协同撤销实战让 RicherEditView 和 BetterEditView 共享同一个 UndoManager 实例4.1 共享实例的必要性为什么不能各搞一套想象一个典型场景用户在BetterEditView支持图文混排中插入一张流程图然后切换到RicherEditView纯文本撰写说明文字。此时若两视图用独立UndoManagerCtrlZ 会先撤回文本再切回 BetterEditView 才能撤回图片——完全违背直觉。真实需求是「撤销栈全局有序」图片插入Action A→ 文本输入Action B→ 样式修改Action C无论在哪视图触发Undo 都按 A→B→C 逆序执行。这就要求UndoManager是单例或由顶层窗口统一创建并注入给所有视图。4.2 注入方式用 SetUserData 回调函数解耦视图与管理器UndoManager.h提供了setUserData(void* p)和getUserData()但直接传this指针有生命周期风险。更安全的做法是顶层窗口如CMainFrame创建UndoManager实例然后通过SetWindowLongPtr将其句柄存入各视图窗口// CMainFrame.cpp m_pUndoManager std::make_sharedUndoManager(); // 注入到 RicherEditView SetWindowLongPtr(m_richEditView.m_hWnd, GWLP_USERDATA, reinterpret_castLONG_PTR(m_pUndoManager.get())); // 注入到 BetterEditView SetWindowLongPtr(m_betterEditView.m_hWnd, GWLP_USERDATA, reinterpret_castLONG_PTR(m_pUndoManager.get()));各视图在构造时读取// RicherEditView.cpp RicherEditView::RicherEditView() { m_pUndoManager reinterpret_castUndoManager*( GetWindowLongPtr(m_hWnd, GWLP_USERDATA)); // 若为空则 fallback 创建私有实例保底 if (!m_pUndoManager) { m_pUndoManager new UndoManager(); m_bOwnsManager true; } }注意m_bOwnsManager标志位决定析构时是否 delete避免重复释放。4.3 冲突检测当两视图同时修改同一段文本时如何避免 Undo 错乱这是最危险的边界。假设RicherEditView和BetterEditView都绑定了同一份CString数据源用户在 RicherEditView 中删了第 10 行同时 BetterEditView 中改了第 10 行样式——UndoManager栈里会有两个 Action 作用于同一位置undo()顺序错则数据不一致。解决方案是引入「数据版本号」机制。在TextRange.cpp中每个TextRange持有一个m_versionuint64_t初始为 0每次execute()前Action 查询当前TextRange的m_version并存入自身undo()时先校验TextRange.m_version action.m_expectedVersion不匹配则拒绝执行并报错。TextRange.cpp第 156 行updateVersion()在每次文本变更后自增getVersion()返回当前值。所有Action子类如BETypingAction在execute()开头必须调用m_range.updateVersion()并在成员中保存m_expectedVersion m_range.getVersion()。4.4 跨视图重做同步Redo 栈如何保证视图状态一致Redo比Undo更难——因为Undo是线性回退而Redo是向前推进需确保目标视图处于能接收该 Action 的状态。例如REDropAction的redo()需要目标视图已初始化CImageList否则Create()失败。框架用「懒加载 状态预检」解决UndoManager::redo()不直接调用action-redo()而是先调用action-canRedo()虚函数默认返回true子类可重写。REDropAction::canRedo()会检查m_pView-getImageList() ! nullptr若否返回false并触发m_pView-initImageList()。UndoManager收到false后会跳过此 Action继续下一个直到找到可执行的。这保证了 Redo 链的鲁棒性。5. 生产环境加固内存监控、日志审计、热替换调试三板斧5.1 内存水位线实战配置不只是防 OOM更是性能调优开关UndoManager的m_maxMemoryKB默认 51205MB但这只是起点。我们曾在一个标注工具中实测单张 4K 图片插入生成REDropAction其getMemorySize()返回约 1.2MB含图片像素数据若用户连续插入 5 张栈内存立刻超限。此时不能粗暴clearAll()而是动态降级// UndoManager.cpp void UndoManager::addAction(const std::shared_ptrAction action) { size_t newSize m_currentMemory action-getMemorySize(); if (newSize m_maxMemoryKB * 1024) { // 策略1尝试压缩对 TypingAction 合并 if (auto typing std::dynamic_pointer_castBETypingAction(action)) { if (!tryMergeToTop(typing)) { // 策略2踢出最老的非 Typing Action保留用户输入流 dropOldestNonTyping(); } } else { // 策略3直接丢弃该 Action图片/对象类操作不入栈 return; // 静默丢弃UI 上提示 Undo history full } } m_undoStack.push(action); m_currentMemory newSize; }dropOldestNonTyping()遍历m_undoStack底部需用vector辅助找到第一个!dynamic_castBETypingAction的 Action 并移除。这样既保住了用户连续输入的 Undo 流又腾出了大块内存给图片操作。5.2 操作审计日志不是为了监控而是为了快速定位 Undo 失败根因UndoManager内置日志开关#define UNDO_LOG_ENABLED 1开启后每步execute()/undo()/redo()都输出结构化日志到undo_log.txt[2023-10-05 14:22:31.123] EXECUTE: BETypingAction0x000002A1F1D2E8A0 | View:0x000002A1F1D2E7B0 | Pos:123 | Text:hello | Size:24B [2023-10-05 14:22:32.456] UNDO: BETypingAction0x000002A1F1D2E8A0 | Result:SUCCESS | Time:12ms [2023-10-05 14:22:33.789] REDO: BETypingAction0x000002A1F1D2E8A0 | Result:FAILED | Reason:ViewDestroyed关键字段ResultSUCCESS/FAILED、Time毫秒级耗时、Reason失败原因字符串。Reason来自 Action 子类的getLastFailReason()如REDropAction在redo()失败时会设置m_failReason CImageList not initialized。日志按日期滚动最大 5 个文件每个不超过 10MB——这是某公司 QA 团队提的需求他们靠日志 3 分钟内复现了客户反馈的「撤销后样式丢失」问题发现是REStyleAction::undo()中漏调了ParagraphTable::applyStyle()。5.3 热替换调试法不用重启进程实时替换 Action 行为验证修复效果这是我在某跨平台项目中摸索出的技巧把UndoManager的addAction()替换为可动态 hook 的函数指针运行时用LoadLibrary加载调试 DLLDLL 中导出hook_addAction()在其中拦截特定 Action 并打补丁。步骤如下UndoManager.h中声明using AddActionHook bool(*)(const std::shared_ptrAction); extern AddActionHook g_addActionHook;UndoManager.cpp中addAction()开头if (g_addActionHook !g_addActionHook(action)) { return; // 被 hook 拦截不入栈 }编写debug_hook.dll导出extern C __declspec(dllexport) bool hook_addAction(const std::shared_ptrAction action) { if (auto rep std::dynamic_pointer_castREReplaceAction(action)) { // 临时修复强制在 undo 前保存段落状态 ParagraphTable::saveState(); // 调用原框架函数 } return true; // 放行 }主程序启动后LoadLibrary(Ldebug_hook.dll)然后g_addActionHook (AddActionHook)GetProcAddress(...)。这样修复REReplaceAction的段落状态问题无需编译整个项目改完 DLL 一替换CtrlF5 就生效。从那以后我每次改 Undo 逻辑都先写个 hook DLL 验证再合入主干——省下 70% 的编译等待时间。希望帮到你。本文还有配套的精品资源点击获取