简介:本资源是一套面向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 的富文本编辑控件时,用户按 Ctrl+Z 却只撤销了光标跳动、没撤回刚输的三行字?或者替换操作一撤销,段落样式全乱了,再重做又崩出乱码?这不是玄学——是 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.h,3 小时内就接入了 ROI 标注框的移动/缩放撤销逻辑。适合需要快速构建可控撤销能力的桌面端 C++ 开发者,尤其当你正在维护一个老系统、不能引入 Boost 或现代 C++17 特性时,这套代码就是你的后悔药。
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 过程中突然 Ctrl+V,此时 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_ptr<Action>(见UndoManager.h中m_undoStack类型),所以你在execute()里传this前,得确保该 Action 是堆上分配且由智能指针管理——这是第一道安全阀。
2.2 UndoManager 如何持栈:双栈结构 + 内存水位线,不是简单 push/pop
UndoManager.cpp的核心是两个std::stack<std::shared_ptr<Action>>:m_undoStack和m_redoStack。但它比教科书实现多三件事:
操作合并(Coalescing):连续多个
BETypingAction(比如用户狂敲字母)会被合并成一个 Action,避免栈爆炸。合并逻辑在UndoManager::addAction()中判断:void UndoManager::addAction(const std::shared_ptr<Action>& 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_shared<BEReplaceAction>(this, getSelectionRange(), _T("")); action->execute(); // 自动注册 } else if (isPrintable(nChar)) { // 输入字符 → 创建 BETypingAction auto action = std::make_shared<BETypingAction>(this, getCaretPos(), CString(nChar, 1)); action->execute(); } }而BetterEditView.cpp更进一步:它支持对象插入(图片、表格)、段落样式(ParagraphStyle)、文本样式(TextStyle),所以它的OnDrop()会创建REDropAction,OnApplyStyle()创建REStyleAction。二者共用同一套UndoManager实例,证明框架天然支持多视图协同撤销——比如你在 BetterEditView 插入一张图,再切到 RicherEditView 打字,Ctrl+Z 会先撤回打字,再撤回插图。
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::vector+stack双备份),否则无法实现多步菜单。很多开源 Undo 库只支持单步,就是因为没做这层缓存。
3. 避坑指南:五个血泪经验总结,全是线上翻车现场复盘
3.1 现象:Ctrl+Z 撤销后光标位置错乱,有时跳到文档开头
原因: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 pointer,execute()中用new CImageList分配,undo()中delete,redo()中重新new—— 不共享句柄。ObjectData.h第 89 行有m_bOwnsImageList标志位,设为true即启用自动释放。
3.4 现象:开启多级 Undo 菜单后,点击「Undo 3 steps」只执行了第一步就停住
原因:MultipleUndoRedoMenu.cpp的OnCommand()处理ID_UNDO_BASE + i时,错误地调用了m_pUndoManager->undo()一次,而非循环i+1次。且未检查中间某步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_ptr<Action>仍在持有RicherEditView*成员指针,而 View 对象已先于 UndoManager 销毁,导致 Action 的undo()调用野指针。
解决:在RicherEditView::~RicherEditView()中必须显式调用m_pUndoManager->clearAll()(清空双栈并置空所有 Action 的m_pView成员),且Action基类中m_pView应声明为weak_ptr<EditView>而非裸指针。BEAction.h第 42 行已定义std::weak_ptr<EditView> m_wpView,所有子类构造时需传入shared_ptr并 lock() 使用。
4. 多视图协同撤销实战:让 RicherEditView 和 BetterEditView 共享同一个 UndoManager 实例
4.1 共享实例的必要性:为什么不能各搞一套?
想象一个典型场景:用户在BetterEditView(支持图文混排)中插入一张流程图,然后切换到RicherEditView(纯文本)撰写说明文字。此时若两视图用独立UndoManager,Ctrl+Z 会先撤回文本,再切回 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_shared<UndoManager>(); // 注入到 RicherEditView SetWindowLongPtr(m_richEditView.m_hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(m_pUndoManager.get())); // 注入到 BetterEditView SetWindowLongPtr(m_betterEditView.m_hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(m_pUndoManager.get()));各视图在构造时读取:
// RicherEditView.cpp RicherEditView::RicherEditView() { m_pUndoManager = reinterpret_cast<UndoManager*>( 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_version(uint64_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默认 5120(5MB),但这只是起点。我们曾在一个标注工具中实测:单张 4K 图片插入生成REDropAction,其getMemorySize()返回约 1.2MB(含图片像素数据),若用户连续插入 5 张,栈内存立刻超限。此时不能粗暴clearAll(),而是动态降级:
// UndoManager.cpp void UndoManager::addAction(const std::shared_ptr<Action>& action) { size_t newSize = m_currentMemory + action->getMemorySize(); if (newSize > m_maxMemoryKB * 1024) { // 策略1:尝试压缩(对 TypingAction 合并) if (auto typing = std::dynamic_pointer_cast<BETypingAction>(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_cast<BETypingAction>的 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: BETypingAction@0x000002A1F1D2E8A0 | View:0x000002A1F1D2E7B0 | Pos:123 | Text:"hello" | Size:24B [2023-10-05 14:22:32.456] UNDO: BETypingAction@0x000002A1F1D2E8A0 | Result:SUCCESS | Time:12ms [2023-10-05 14:22:33.789] REDO: BETypingAction@0x000002A1F1D2E8A0 | Result:FAILED | Reason:ViewDestroyed关键字段:Result(SUCCESS/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加载调试 DLL,DLL 中导出hook_addAction(),在其中拦截特定 Action 并打补丁。
步骤如下:
UndoManager.h中声明:using AddActionHook = bool(*)(const std::shared_ptr<Action>&); 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_ptr<Action>& action) { if (auto rep = std::dynamic_pointer_cast<REReplaceAction>(action)) { // 临时修复:强制在 undo 前保存段落状态 ParagraphTable::saveState(); // 调用原框架函数 } return true; // 放行 } - 主程序启动后,
LoadLibrary(L"debug_hook.dll"),然后g_addActionHook = (AddActionHook)GetProcAddress(...)。
这样,修复REReplaceAction的段落状态问题无需编译整个项目,改完 DLL 一替换,Ctrl+F5 就生效。从那以后我每次改 Undo 逻辑,都先写个 hook DLL 验证,再合入主干——省下 70% 的编译等待时间。
希望帮到你。
本文还有配套的精品资源,点击获取