尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

undo_manager源码解析:从命令模式到多步撤销的编辑器架构设计

发布时间:2026/9/26 8:49:52

资讯中心
01
ARTICLE

undo_manager源码解析:从命令模式到多步撤销的编辑器架构设计

undo_manager源码解析:从命令模式到多步撤销的编辑器架构设计
简介撤销/重做管理器源码包是一套面向桌面文本编辑器和富文本控件开发者的功能实现参考适合需要在自定义编辑器或文档应用中集成 Undo/Redo 机制的中级程序员。压缩包共 61 个文件、70KB以 C 头文件和实现文件为主31 个 .h、28 个 .cpp另含 1 个变更日志和 1 个资源文件覆盖操作封装、编辑视图、菜单交互等模块。核心撤销管理器利用操作历史栈统一管理键入、替换、拖放与段落样式等动作编辑器视图负责捕获用户输入并与撤销管理器联动具体操作则被封装为可回滚对象从而支持多步撤销和重做。已有 154 人学习下载。通过阅读这套轻量源码可以直观理解命令模式在文本编辑中的应用掌握撤销栈的构建思路并进一步扩展到复杂编辑操作的事务化回滚设计。1. 给编辑器加 CtrlZ 不是挂个栈就行undo_manager 这套源码到底管到哪一层做过文本编辑器的人应该都有这种经历刚接上 Undo/Redo 那会儿觉得特别简单不就是 push/pop 外加一个下标吗。等真正把打字、替换、拖放、样式修改全部灌进去你会发现一次操作被拆成了好几段Undo 一步退回去的状态根本不对Redo 到一半还会把界面弄花。这个 undo_manager 压缩包提供的就是一套从底层操作封装到上层菜单联动的完整撤销/重做框架核心在 UndoManager、Action 子类、以及 RicherEditView / BetterEditView 两个编辑视图的协作。它适合正在做文本编辑器、RichEdit 增强、或者想在现有 MFC 工程里补撤销功能的开发者。压缩包里 Action、REAction、BEAction 三个文件组表明它对普通编辑和增强编辑两套视图做了统一的动作抽象REReplaceAction、REDropAction、REStyleAction 这些文件又告诉我们替换、拖放、样式变更这类非文本操作也是用它来记录的。这套源码不是教学 demo是从真实编辑工程里抽出来的骨架值得你花一个晚上把它跑通然后照着改。2. UndoManager 核心操作栈、游标与命令模式的落地2.1 栈不是唯一的正确结构为什么需要一个 undo 游标直觉上做撤销就是维护一个后进先出的栈但真在编辑器里试过就会发现一个关键问题用户先撤销了三次然后从那个中间状态开始输入了新内容此时之前撤销掉的三步还留在栈顶。如果只是简单弹栈Redo 会把已经被新操作覆盖掉的旧内容恢复出来文档状态就乱了。所以 UndoManager 内部维护的是栈 游标结构。// UndoManager.h 核心结构按常见做法整理 class UndoManager { public: bool CanUndo() const { return m_undoIndex 0; } bool CanRedo() const { return m_undoIndex m_actions.size(); } void AddAction(std::shared_ptrAction action); void Undo(); void Redo(); void ClearHistory(); private: std::vectorstd::shared_ptrAction m_actions; size_t m_undoIndex 0; // 指向下一个可撤销的位置 size_t m_maxDepth 1000; // 历史上限 };这个结构中m_actions是总的历史记录m_undoIndex是当前状态与历史记录的分界线。CanUndo()判断游标是否大于 0CanRedo()判断游标是否没到数组末尾。关键点在AddAction的逻辑新动作进来时要先丢弃游标右侧那些已撤销的动作否则 Redo 的和新动作会掺杂在一起。void UndoManager::AddAction(std::shared_ptrAction action) { // 丢弃被撤销掉的分支 if (m_undoIndex m_actions.size()) { m_actions.erase(m_actions.begin() m_undoIndex, m_actions.end()); } m_actions.push_back(action); // 限制历史深度超过上限时丢弃最旧的动作 if (m_actions.size() m_maxDepth) { m_actions.erase(m_actions.begin()); } m_undoIndex m_actions.size(); }这里要说明两个参数的实际作用。m_maxDepth设为 1000 不是拍脑袋编辑场景下用户很少会连续撤销几百步但 Undo 栈里的每个Action都可能持有大段文本快照不做上限的话长文档编辑半小时内存就上去了。m_undoIndex的更新时机也很重要它必须始终指向下一个可撤销的位置也就是当前文档状态对应的历史边界。只有当Undo和Redo方法里同步移动游标AddAction才能保证新动作落在正确的位置。2.2 命令模式把编辑动作封装成 Action而不是直接改控件UndoManager 本身不关心你编辑的是文本框、表格还是段落样式它只认统一的 Action 接口。这就是命令模式的核心价值每个用户操作被封装成一个对象对象内部记录怎么做和怎么还原。// Action.h动作基类按压缩包中 Action.h 的角色设计 class Action { public: virtual ~Action() {} // 执行一遍内部完成实际修改 virtual void Execute() 0; // 撤销把文档状态恢复到执行前 virtual void Undo() 0; // 重做再次执行但要保证幂等 virtual void Redo() 0; // 合并判定新的输入是否能并入当前动作减少栈帧 virtual bool CanMerge(const ActionPtr next) const 0; // 返回菜单/日志里显示的动作描述 virtual std::wstring GetDescription() const 0; };这里最容易被忽略的是CanMerge。想象用户按住键盘输入一行字如果每个WM_CHAR都生成一个独立的 Action按下 CtrlZ 想删掉整行就得敲几十次。这个压缩包里 RETypingAction.cpp 和 BETypingAction.cpp 的职责就是解决这个问题它们通过CanMerge判断相邻两次输入是否属于同一段连续键入如果是就合并到同一个 Action 里撤销时一次退回整段。2.3 注册、执行与回滚UndoManager 的接口设计以 RETypingAction 为例看一个具体动作在 UndoManager 里怎么流转。void RicherEditView::OnChar(UINT nChar, UINT nRepCnt, UINT nFlags) { // 构造动作对象记录插入位置和文本 auto action std::make_sharedRETypingAction( this, m_caretPos, m_textBuffer, CString((TCHAR)nChar)); // 尝试与栈顶动作合并失败则入新栈帧 if (!m_undoManager-TryMergeWithTop(action)) { m_undoManager-AddAction(action); } action-Execute(); // 更新界面光标与滚动位置 UpdateCaretPos(); }这里TryMergeWithTop做的事情是拿到m_actions[m_undoIndex - 1]指向的动作对象调用它的CanMerge(action)如果返回 true 就把这次输入的文本累加到旧 Action 里而不是新建一个。这种先合并、后入栈的时序是保证撤销粒度合理的关键。如果先执行再合并动作对象里已经保存了修改前后两份文本合并时还得处理快照冲突麻烦得多。Execute()被调用的时机也有讲究动作对象先入栈、后执行这样即使执行过程中抛出异常UndoManager 里已经存在该动作后续Undo()仍然可以把它回滚掉不会出现文档改了但历史里没有记录的脏状态。3. Action 子类怎么拆打字、替换、段落样式、拖放各管一摊3.1 RETypingAction / BETypingAction合并同源输入避免一次击键一个栈帧两个文件成对出现不是冗余。RE 前缀对应 RicherEditView 的普通输入路径BE 前缀对应 BetterEditView 的增强输入路径。两种视图的输入来源不同但动作封装逻辑几乎一样。// RETypingAction.cpp简化后的合并逻辑 bool RETypingAction::CanMerge(const ActionPtr next) const { auto pNext std::dynamic_pointer_castRETypingAction(next); if (!pNext) return false; // 连续输入判定新动作的插入起点必须紧贴当前末尾 if (pNext-m_start ! m_start m_insertedText.GetLength()) return false; // 时间相近才合并防止插入-等待-插入被粘成一坨 if (pNext-m_tick - m_tick MERGE_INTERVAL_MS) return false; return true; } void RETypingAction::Merge(const ActionPtr next) { auto pNext std::dynamic_pointer_castRETypingAction(next); m_insertedText pNext-m_insertedText; m_tick pNext-m_tick; }两个参数值得注意。MERGE_INTERVAL_MS是合并窗口常见做法是 1000 毫秒到 1500 毫秒。窗口太短用户输入稍微停顿一下就被拆帧撤销次数爆炸窗口太长用户在文本框里先打几个字、停几秒再打几个字想只撤销第二次输入都不行只能全退。我自己一般用 1200 毫秒配合字符间隔判断能覆盖大部分人的输入节奏。m_start记录的是插入起点合并时必须校验新动作起点是否紧贴当前末尾这个校验能防止把用户在某段文本中间补插的字符错误合并。3.2 REReplaceAction替换不是删除再插入是一对原子操作很多人实现查找替换的撤销时把替换拆成删除旧文本 插入新文本两个动作推进 UndoManager结果 Redo 的时候界面会先闪一下删除状态再闪一下插入结果视觉上就像文档跳了两下。真正可控的做法是让 REReplaceAction 自己携带替换前后的完整文本一次动作完成所有事。// REReplaceAction.cpp替换动作的构造与执行 REReplaceAction::REReplaceAction(REView* view, const TextRange range, const CString newText) : m_view(view) , m_range(range) , m_newText(newText) { m_oldText view-GetText(range); // 构造时读取旧文本 } void REReplaceAction::Undo() { // 还原旧文本替换掉新文本 m_view-SetText(m_range, m_oldText); } void REReplaceAction::Redo() { // 再次写入新文本位置不变 m_view-SetText(m_range, m_newText); }构造时把m_oldText从视图中取出来缓存这是最重要的一步。如果等到 Undo 执行时才去读旧文本那时文档已经被新文本覆盖原始内容已经找不回来了。TextRange在这里充当位置定锚它记录了起始位置和长度前提是替换操作没有改变其前方的文本长度。这也是为什么替换操作必须被当做一个原子动作——只要中间插入一次回车或删除TextRange 记录的偏移量可能整体失效。3.3 REParagraphAction / REStyleAction对段落和样式的修改怎么回滚这类动作和文本输入最大的不同在于它们的操作对象不是连续的字符区间而是样式对象和段落对象。压缩包里 ParagraphTable、ParagraphStyle、ObjectTable 这些文件就是为这类操作服务的。操作类型记录的关键数据Undo 恢复策略段落属性修改段落索引 旧ParagraphStyle 新ParagraphStyle把旧样式写回段落索引处字符样式修改字符范围 旧TextStyle 新TextStyle在范围内恢复旧 TextStyle插入文本对象对象指针 插入位置从容器中移除该对象删除文本对象对象指针 原始位置 前后关联按关联关系插回原位段落索引的引用要格外小心。如果用户先删掉了第 5 段再对第 10 段做样式操作之前记录的段落索引已经指向了错误的段。所以 REParagraphAction 里通常还会保存一个段落的唯一标识比如段落对象的指针Undo 时先按指针找回段落再验证索引有效性。这在你接手这套源码时会看到相关的ParagraphTable文件它就是用来维护段落与样式的映射关系的。3.4 参数记录与 TextRange为什么用 TextRange 比裸 startlength 更安全TextRange.cpp 单独存在是有原因的。真实编辑过程里一个函数从接收输入到落盘中间要经过键盘处理、IME 组合、命令分发好几层。如果每层都用int start和int length两个参数传递某个中间层把 start 加了 1后面的替换逻辑就全错了。// TextRange.h 简化定义 struct TextRange { size_t start; size_t length; TextRange() : start(0), length(0) {} TextRange(size_t s, size_t l) : start(s), length(l) {} // 区间合法性 bool IsValid() const { return length 0; } // 转为字符串位置 LPCTSTR GetTextStart(Buffer* buf) const { return buf-GetText() start; } };把 start 和 length 打包成一个对象后编译器会强制你在修改位置时明确意识我改的是整个 TextRange而不是顺手调一下数字。这套源码里 TextRange 还会在 Undo/Redo 时参与偏移量修正——删除操作发生后位于其后的所有 TextRange 都要整体减少对应长度这个修正逻辑维护起来比散落的两个 int 清晰得多。4. 视图层与菜单接入把动作流装进 RicherEditView 和多步撤销菜单4.1 RicherEditView / BetterEditView键盘事件、IME 组合输入和焦点管理的接入点压缩包里RicherEditView.cpp与BetterEditView.cpp是两个编辑视图的实现它们不自己存文本而是通过调用 UndoManager 来记录一切修改动作。视图层最重要的职责是拦截输入事件把它翻译成 Action 对象。// RicherEditView.cpp键盘输入转换为动作 BOOL RicherEditView::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN pMsg-wParam VK_BACK) { TextRange range GetSelectionRange(); if (!range.IsValid()) { // 光标前一个字符 range.start m_caretPos - 1; range.length 1; } auto action std::make_sharedRETypingAction( this, range, CString(_T())); m_undoManager-AddAction(action); action-Execute(); return TRUE; } return CView::PreTranslateMessage(pMsg); }这里有两个细节值得琢磨。第一退格键在 PreTranslateMessage 里拦截而不是等 EN_CHANGE 通知因为 EN_CHANGE 只在控件内容变化后触发那时已经拿不到删除前的文本内容无法为 Undo 记录旧值。第二RETypingAction的构造参数里既传了range又传了空字符串这代表删除在底层被统一看作插入空文本的替换操作这样 RetingAction 就能同时覆盖字符插入和字符删除UndoManager 侧不需要另写一套删除逻辑。4.2 SimpleUndoRedoMenu 与 MultipleUndoRedoMenu两级菜单实现思路对比压缩包里同时出现SimpleUndoRedoMenu和MultipleUndoRedoMenu这是两种不同粒度的用户交互方案。Simple 版只提供撤销一步 / 重做一步两个按钮实现最直接Multiple 版则要在菜单里列出最近 N 条操作用户可以直接选择回退到任意一步。// MultipleUndoRedoMenu.cpp构建下拉列表的简化逻辑 void MultipleUndoRedoMenu::BuildMenu(CMenu* pMenu, UndoManager* mgr) { pMenu-AppendMenu(MF_STRING, ID_UNDO_LAST, _T(撤销)); int count 0; std::vectorstd::shared_ptrAction items; // 从栈顶往下遍历最多取 10 条 // 注意这里要复制出来不能直接持引用 size_t i mgr-GetUndoIndex(); while (i 0 count 10) { auto action mgr-GetAction(i - 1); items.push_back(action); i--; count; } for (size_t j 0; j items.size(); j) { CString desc items[j]-GetDescription(); // 超长描述截断为…形式 if (desc.GetLength() 32) desc desc.Left(32) _T(...); pMenu-AppendMenu(MF_STRING, ID_UNDO_TO j 1, CString(_T(撤销到)) desc); } }这段代码的要点在于取栈顶动作时必须用GetUndoIndex()来确定范围而不是简单地读m_actions的 size。因为用户撤销过几步之后栈尾有些动作属于已撤销分支直接遍历会把它们也显示出来。items用复制而非引用的原因是菜单构建是异步的菜单项点击时 Undo 可能已经执行过引用会被后续入栈操作重分配而失效。4.3 编辑视图切换时撤销栈的归属Bundle 里 RE 与 BE 两套视图共存还有一个容易踩的问题切换视图时 UndoManager 要不要清空。正确做法是两套视图共享同一个 UndoManager 实例这样用户用 RE 视图输入内容切换到 BE 视图后仍可以撤销之前的操作。但共享的前提是两套视图的内部状态能对上号——BE 视图如果对文档做了额外的格式化处理RE 视图记录的 TextRange 在 BE 视图里就会错位。所以压缩包里的 RE 和 BE 动作类都是成对文件本质上它们通过同一组TextRange和数据表来协调偏移量而不是各自为政。5. 避坑手册undo_manager 实战里最常见的五类问题5.1 合并逻辑失控导致撤销粒度崩坏现象用户连续输入一长段文字按下一次 CtrlZ 只退掉一个字符,想删掉整句话要按几十次。原因合并判断条件过严CanMerge里要求两个 Action 的m_tick完全一致或者MERGE_INTERVAL_MS设置得太小使合并几乎从不生效。解决把时间窗口放宽到 1000 毫秒以上并在CanMerge里同时校验输入字符是否属于普通可见字符。如果合并条件里加了不相关字段比如只允许英文输入合并不允许中文趁早去掉。真正的连续中文输入同样需要合并成一个大 Action否则撤销体验会非常差。5.2 替换操作拆成两步导致重做界面闪烁现象用户执行一次全文替换然后连续撤销/重做重做时界面先短暂变为旧文本已删、新文本未加入的空状态再闪出结果。原因把替换拆成了删除旧文本和插入新文本两个独立 Action 推入 UndoManagerRedo 时会依次执行两个动作。解决用 REReplaceAction 这种单 Action 原子对象。构造时一次性缓存旧文本和新文本Undo/Redo 各只做一次 SetText 调用。如果已经拆成两步了在替换逻辑入口处加一个聚合层先暂存两步操作等内部改造完成后再合并为一个REReplaceAction。5.3 REDropAction 拖放操作后 Undo 目标错位现象把一段文本从文档中部拖到结尾执行撤销文本回到了中间的位置但光标和选区状态完全乱了再次撤销直接把相邻段落也吞掉。原因拖放动作在 RE 视图里通常被实现为选中源文本 - 删除 - 在目标位置插入。目标位置的 TextRange 是在删除源文本之前记录的删除操作导致目标位置的实际偏移量整体前移插入时错位。解决拖放动作构造时不要直接用目标位置索引而是先在同一 UndoManager 里创建一个占位标记执行完删除后再把目标的 TextRange 重新换算一次。这部分逻辑在REDropAction.cpp里就是关键它需要在 Undo 时先删掉目标位置的文本再把源位置的旧文本放回。5.4 撤销后做新输入Redo 分支没有按预期清空现象用户撤销了三步输入了新内容之后打开 Redo 菜单发现先前撤销的三步还能选选中后文档直接跳回到一个完全不相关的状态。原因AddAction里没有做分支截断m_undoIndex之后的旧动作还留在m_actions中。解决严格在AddAction开头执行erase(begin() undoIndex, end())。这里要顺手检查一遍所有调用AddAction的代码路径确认没有绕开 UndoManager 直接操作m_actions数组的地方。有一个隐蔽路径值得注意某些刷新控件状态的代码会间接调用视图的 SetWindowText从而触发编辑器自身的 EN_CHANGE导致一个虚假的 Action 被创建。解决方案是在视图层维护m_bInUndoRedo标志Undo/Redo 执行期间忽略 EN_CHANGE 回调。5.5 Undo 栈内存泄漏与视图析构顺序现象关闭编辑器时程序崩溃断点指向未定义行为日志显示 stack 栈里某个 Action 的 m_view 已经是野指针。原因UndoManager 的析构顺序晚于视图对象栈里还有未执行的 Action 引用已销毁的视图。尤其当视图类持有 UndoManager 指针时视图的析构函数没有先调用ClearHistory()。解决在视图的析构函数里显式清理BetterEditView::~BetterEditView() { if (m_undoManager) { // 先清空历史避免后续 Action 析构时访问 m_view m_undoManager-ClearHistory(); } // 再释放其他资源 }同时建议 UndoManager 持有 View 的裸指针时在 Action 类里保存std::weak_ptrREView而不是裸指针Undo 执行前先lock()检查视图是否存活。这套压缩包里的 UndoManager 没有用智能指针管理视图引用接手的代码要自己补上这层防护。6. 验证与进阶用动作回放压测撤销链再把 UndoManager 抽到纯 C 层验证这套撤销框架是否可靠单靠手动点按钮不够。我常用的方法是写一个回放测试脚本模拟一串操作序列并记录每一步文档内容的哈希值然后做多轮 Undo/Redo最后比对哈希值是否回到原位。// 回放验证执行 N 步后全部撤销再全部重做 void VerifyUndoRedo(UndoManager* mgr, RicherEditView* view) { std::vectorsize_t snapshots; // 记录每步后的内容哈希 snapshots.push_back(HashView(view)); // 注入预定义操作序列模拟实际输入路径 for (int i 0; i 100; i) { auto action BuildDummyAction(view, i); mgr-AddAction(action); action-Execute(); snapshots.push_back(HashView(view)); } // 全部撤销 while (mgr-CanUndo()) { mgr-Undo(); size_t h HashView(view); ASSERT(h snapshots[mgr-GetUndoIndex()]); } // 全部重做 while (mgr-CanRedo()) { mgr-Redo(); size_t h HashView(view); ASSERT(h snapshots[mgr-GetUndoIndex()]); } }这个测试跑一遍能暴露大部分问题合并逻辑错误导致快照不匹配、分支未截断导致 Redo 内容错位、以及 TextRange 修正算法在边界条件下的失败。跑测试时注意把m_maxDepth调小到 50 左右这样可以验证历史上限截断逻辑是否会影响哈希比对。说到进阶用法这套 UndoManager 里唯一的工程耦合点就是UMString.h和UMResources.h这两个文件——前者封装了字符串操作后者集中定义了资源 ID。如果你想把它移植到 Qt 或纯 C 控制台程序第一步是把这两个文件里的 MFC 依赖干掉CString换成std::wstringCView指针换成抽象接口IEditView。动作类里的GetDescription可以直接复用菜单那部分逻辑就别带过去了Qt 的 QUndoStack 有自己的界面组件用不到。从那次回放测试抓出四五个崩溃点之后我每接一个编辑类项目都会先做一遍这个流程先建动作回放脚本再写快照校验最后才碰 UI。把那套流程跑通了Undo/Redo 的内存泄漏和状态错乱基本都能在开发阶段暴露出来不用等用户按几下 CtrlZ 再翻车。这套 undo_manager 源码的好处是核心框架和视图逻辑拆得干净按上述步骤验证一轮能省下不少排查功夫。希望这份拆解对你有用。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。