ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++命令模式实战:从撤销重做到游戏开发的核心设计

C++命令模式实战:从撤销重做到游戏开发的核心设计 如果你拆过任何一个开源编辑器的源码或者在游戏引擎里跟过输入系统的实现大概率会频繁撞见一个名词命令模式。这个设计模式在C社区里的地位挺微妙的——它不像单例、工厂那样张口就来但几乎所有需要把“动作”变成“数据”的地方少了它就直接翻车。我用C写过小游戏、搓过编辑器原型、也处理过调度系统每次回头复盘命令模式都是最让人省心的那一层。这篇文章我想从一个实际项目切入把命令模式在C里的落地写法、撤销重做的完整实现、还有那些只有踩过坑才知道的细节一次讲透。先快速对齐一下目标读者如果你已经知道类、继承、虚函数是什么但看到std::function和一坨回调就头疼或者你面试被问过“命令模式是什么”却只能背出“把请求封装成对象”这一句——那这篇正合适。如果你是老手也可以直接跳到第3、4章看撤销栈和对象池相关的经验那些是我在实际项目里反复改出来的结论。1. 命令模式到底在解决什么问题1.1 从最朴素的需求说起先看一个写游戏时最常见的场景玩家按Q放火球按W放冰霜新星。第一版代码很多人会写成这样if (input Key::Q) { player.CastFireball(); } else if (input Key::W) { player.CastFrostNova(); }单看这一小段没什么问题但需求一旦膨胀这坨if/else就开始失控。比如——玩家想自定义按键把Q改成W把W改成Q这个映射关系应该放在哪里又比如游戏要支持连招回放把玩家一局的操作录下来然后精确重播你该怎么录再比如AI要模拟玩家操作程序得能“按下”虚拟按键这个“按下”动作怎么伪造问题不在于if/else不够快而在于请求的发起者和执行者被焊死在一起。UI层知道CastFireball是玩家对象的方法玩家对象也知道自己正被UI直接调用两者互相绑定想插入一层“记录”、“回放”、“延迟执行”的逻辑完全没有缝隙。命令模式的核心动作就是把这层强耦合拆开不要去调player.CastFireball()而是构造一个FireballCommand对象它内部知道自己该对谁执行什么。UI层只负责创建和派发这个命令对象玩家对象只负责接收命令并执行——中间人可以插进来做日志、做撤销、做排队。1.2 函数指针和 Lambda 为什么不够用很多人到这里会有疑问既然只是把“调用”延迟一下我用函数指针或std::function不就行了我确实在某次原型里试过用std::function代替命令对象但很快就撞到了几面墙。函数指针也好Lambda也好它们天然能表达的只有“调用”表达不了状态和上下文。举个具体的例子我要实现一个撤销操作用户插入了一段文字撤销时需要知道“到底删掉哪几个字符”。Lambda可以捕获参数所以第一层是够的但撤销通常需要反向操作这个反向动作的上下文更复杂得保存执行前的完整状态快照。你可以硬把快照捕进Lambda但那个Lambda会变得臃肿而且无法序列化、无法组合、无法统一管理生命周期。更关键的是Lambda没有身份。你没法标记“这是一个插入命令”、“这是一个技能命令”栈里存的全都是一团闭包出了问题连日志都没法好好打。命令对象本质上是给“动作”挂上了类名和成员变量让它可以被存储、被检查、被转移、被序列化。游戏引擎和编辑器最终都选择完整的Command对象而不是Lambda不是因为Lambda性能差而是因为对象能承载的东西更多。2. 从零开始核心类设计与第一个可用版本2.1 定义最精简的 Command 接口命令模式的标准结构有四个角色Command接口、ConcreteCommand具体命令、Receiver执行者、Invoker调用者。实际工程中我不建议把接口设计得很复杂能干活的最小接口就是两个纯虚函数——执行和撤销class Command { public: virtual ~Command() default; virtual void Execute() 0; virtual void Undo() 0; };有些教科书还会加一个Redo()或者CanUndo()但我建议先把最核心的Execute和Undo做出来后面需要再扩。原因是命令模式最容易翻车的点就是接口膨胀——你要什么GetName()、GetPriority()那是在给未来每一行代码上枷锁。我的习惯是先把Execute、Undo跑通什么时候真正需要序列化了再加Serialize/Deserialize接口。顺带提一个设计细节Undo不应该默认“一定能撤销”。有的命令天生不能撤销比如“播放一个音效”这时候我倾向于返回bool或者抛异常来标示失败而不是硬塞一个空实现。后面讲撤销栈时你会看到这个设计会直接影响栈的使用策略。2.2 一个具体的 Receiver 与命令实现理论聊完了来点实际的。我用一个迷你文本编辑器作为例子这个场景足够简单但又包含撤销、重做、宏命令等几乎所有关键要素。首先定义一个Document类作为接收者。它就是这个模式里的Receiver负责实际修改数据class Document { public: void InsertText(size_t pos, const std::string text) { if (pos content_.size()) pos content_.size(); content_.insert(pos, text); } void EraseText(size_t pos, size_t len) { if (pos len content_.size()) return; content_.erase(pos, len); } const std::string GetContent() const { return content_; } private: std::string content_; };然后是具体命令。插入命令需要保存“往哪插”和“插什么”这两个参数撤销时反向删除class InsertCommand : public Command { public: InsertCommand(Document* doc, size_t pos, std::string text) : doc_(doc), pos_(pos), text_(std::move(text)) {} void Execute() override { doc_-InsertText(pos_, text_); } void Undo() override { // 参数必须在构造时就快照而不是执行时才去取 doc_-EraseText(pos_, text_.size()); } private: Document* doc_; size_t pos_; std::string text_; };这里有一个特别容易踩的坑我特意在上面的注释里标出来了命令的参数必须在构造时快照下来。什么意思如果用户先选中一段文字然后执行“删除”命令删除命令的构造时就要把被删文本和位置记录下来。如果你偷懒到Execute调用时才去Document里读“当前选中内容”那一旦有多条命令排队或者延迟执行现场已经变了你会删错东西。命令模式的生命线就是“构造时冻结现场执行时只消费快照”这条一定不能违背。2.3 Invoker 与撤销历史栈的雏形现在需要一个东西来“接住”这些命令对象。在编辑器场景里UI、快捷键处理器、脚本系统都算是Invoker。我的做法是给编辑器窗口配一个CommandHistory它同时承担Invoker和撤销栈两个职责class CommandHistory { public: void Execute(std::unique_ptrCommand cmd) { cmd-Execute(); undo_stack_.push(std::move(cmd)); // 新的执行会清空重做栈防止历史分叉 while (!redo_stack_.empty()) redo_stack_.pop(); } void Undo() { if (undo_stack_.empty()) return; auto cmd std::move(undo_stack_.top()); undo_stack_.pop(); cmd-Undo(); redo_stack_.push(std::move(cmd)); } void Redo() { if (redo_stack_.empty()) return; auto cmd std::move(redo_stack_.top()); redo_stack_.pop(); cmd-Execute(); undo_stack_.push(std::move(cmd)); } private: std::stackstd::unique_ptrCommand undo_stack_; std::stackstd::unique_ptrCommand redo_stack_; };注意我用了std::stackstd::unique_ptrCommand而不是裸指针。理由很简单命令对象的生命周期应该被严格管理栈容器天然是先进后出配合智能指针可以保证遗忘回收。这里有一个典型的移动语义用法——std::move(cmd)把所有权从栈里搬到局部变量执行完后再搬回去整个过程零拷贝。这里顺带解释一个新手经常搞不明白的问题为什么撤销栈里面存的是“已执行的命令”而不是“可执行的操作”因为命令对象里保存的不仅仅是“怎么做”还保存了撤销所必须的反向信息。比如插入命令保存了位置和文本撤销时就知道要删哪个范围。这是一个很自然的设计但很多人第一次看到时容易绕不过弯。3. 进阶实战撤销、重做、宏命令与延迟队列3.1 撤销栈的两个隐藏炸弹上面的CommandHistory看起来很简单实际项目里却有两个隐藏问题都是我调试到怀疑人生才总结出来的。第一个问题是重做栈的清理时机。我在第2.3小节的代码中已经写了“新的执行会清空重做栈”为什么假设你执行了A、B两个操作撤销到A这时如果执行了新的操作C那B的“重做”历史就永远不该存在了。因为操作序列变成了A、CB已经不属于当前历史分支。如果你不清空重做栈用户点重做时会执行一个上下文完全对不上的老命令轻则数据错乱重则直接崩溃。我见过不止一个项目因为忘记这一行代码而出诡异Bug。第二个问题是撤销栈无界增长导致的内存膨胀。如果编辑器开着几个月不关用户疯狂操作十万次每个命令对象里都存着一大段被插入的文本内存会一路涨到失控。解决方案很粗暴但有效给栈设置最大容量比如500步超出就把最底部的命令丢掉再入栈。这里我推荐用std::deque而不是std::stack来实现因为deque支持从头部弹出。实现代码很简单但边界条件要想清楚——丢弃最旧命令时如果那个命令正在被某个宏引用要确保引用计数不为零否则悬空。3.2 宏命令命令里嵌套命令宏录制的本质是把一串命令包装成一个大的命令这就是组合模式在命令模式里的自然应用我把这辈子的组合美学都砸在这了。注意下面的宏命令接口需要重新谈生命周期管理一个宏命令里如果持有多个子命令对象Undo时要按逆序来回滚所有子命令这个顺序错了的话撤销结果会一片混乱。因为咱们预设的接口是std::unique_ptrCommand所以子命令容器理所当然地用std::vectorstd::unique_ptrCommand。写代码前先想清楚为什么宏命令里可以持有子命令而不会死锁因为宏命令的Execute会调用子命令的Execute子命令不应该再调用宏命令的Execute只要不在子命令里反向引用宏对象这就不会递归爆栈。3.3 延迟执行、任务队列与线程安全命令模式的另一个高频使用场景是延迟执行。当你在编辑器里执行“保存文件”这种重操作时通常不希望它阻塞UI线程而是丢给后台线程去跑。命令对象这时候就变成了一个完美的“任务单元”——它自带所有执行参数可以扔进队列由工作线程取出来执行。我用过最典型的方案是这样的一个全局的线程安全任务队列接收std::unique_ptrCommand后台线程池循环取任务执行。这个方案的好处是业务代码只管构造命令对象并提交完全不关心它什么时候被真正执行、在哪个线程执行。尤其是当任务执行完需要回到主线程更新UI时可以把“更新UI”也封装成另一个命令对象再丢回主线程队列形成闭环。线程安全这块要特别小心。CommandHistory本身并不线程安全我的经验是负责修改文档状态的命令必须在主线程执行线程池里只跑重活命令比如资源加载、网络请求。如果你让两个线程同时执行一条“插入文本”命令StB库和自研容器会同时踩踏同一块内存结果就是玄学崩溃。需要同步的地方用std::mutex锁住队列本身而不是锁命令内部的Document锁的粒度太大会拖死性能。4. 命令模式实际项目中的选型权衡4.1 命令对象会带来多少性能开销很多人看到命令模式第一反应是哎呀每个操作都要new一个命令对象太浪费了吧说实话对于业务层来说这个开销完全可以忽略。一次new加一次虚函数调用撑死几微秒你插入一个字符的耗时都远超这个数。真正需要关注的是高频路径比如一个游戏里每个tick都要生成的输入命令或者一个编辑器里每次按键都触发的命令。如果你发现命令对象已经成了性能瓶颈判断一下是析构开销还是分配开销。如果是分配开销对象池是常用解法预分配一批命令对象用完重置再放回池子。但是——对象池有一个很麻烦的问题就是撤销栈持有命令对象如果某条命令被撤销栈引用着你贸然把它回收撤销时就会用到一个被清空的命令。我的建议是先不做这个优化实测profiler确定是瓶颈了再说。我压过很多次绝大多数项目根本到不了这一步。另一个性能相关但容易被忽略的点是内存布局。如果是把命令对象存进std::vector尽量保证它只持有数据和指针不要在命令对象内部放一堆虚表之外的复杂成员否则拷贝和移动成本都会上升。这里恰好是std::unique_ptr的用武之地——栈里存的是指针移动指针是极廉价的。4.2 命令的粒度怎么定命令粒度的选择是一个很微妙的权衡。粒度太大撤销时会一把推掉用户做过的好几件事体验很差粒度太小又会让命令对象膨胀成满天星处处都是几行小命令维护起来也闹心。我在编辑器项目里总结的经验是以“用户意图”为单位定义命令而不是以“底层操作”为单位。用户删了三个字那是一次“删除字符”命令而不是三次“删除单个字符”命令。如果用户先输入一个单词再删除它那么这两个操作分别入栈撤销时先恢复删除、再恢复输入。这样做的好处是撤销栈里的每一步都对应一个可理解的语义调试和日志输出都清晰不会出现撤销一次结果只回退半个词组的尴尬场景。4.3 什么时候不要用命令模式再好的模式也有不适合的场景。如果你只是做一个很小的工具脚本总共只有三个按钮、两个函数那用一堆Command类和扫描注册表纯属给自己加戏。这种情况下直接写if/else或者Lambda回调代码读起来更顺。另一个明显的反向信号是——你的命令对象里保存的参数几乎用不到或者Undo永远都是一个空实现。如果一个项目里超过一半的命令都不支持撤销那命令模式给你带来的更多是仪式感而不是价值。我见过有的团队为了“统一架构”把所有函数调用全包成命令结果代码量翻倍、调用链变长、IDE跳转变难这就是过度设计。命令模式最适合的场景是你的操作性需求里确实存在延迟、队列、撤销、回放、日志这几样中的至少两样。一样都没有就别硬套。5. 常见问题与排查技巧实录5.1 撤销命令时崩溃悬空引用是头号嫌疑人我用命令模式这么久崩溃类问题里最频繁的就是悬空引用。拿上文InsertCommand举例它持有Document* doc_这个裸指针如果Document被提前销毁撤销时调doc_-EraseText()直接访问野指针程序基本必崩。排查思路很简单却容易被忽略先在命令的Execute和Undo里加断言判doc_ ! nullptr再检查文档对象的生命周期是不是被某个容器掌管有没有可能在历史栈还引用它时就被释放。一个比较工程化的解法是给Receiver对象配一个std::shared_ptr而命令对象持有std::weak_ptr执行时用lock()提升成shared_ptr再操作。如果提升失败说明Receiver已经销毁命令应该安全地自我放弃而不是硬闯空门。这个方案也不是没有代价——std::weak_ptr的使用让代码变得啰嗦每次都要lock。我的习惯是在低风险场景对象生命周期几乎是全局的用裸指针简化在高风险的插件系统里坚持弱引用。别怕在代码里来回切换安全性和简洁性的平衡本来就是架构师的核心工作。5.2 调试命令树的实战技巧当命令嵌套了好几层比如宏命令里有子命令子命令又调用了别的命令一旦出问题录日志就成了最重要的调试手段。我在日志方面吃过亏一开始想“哪个命令出错就在哪个命令方法里打印”结果每处都要加加的又多又乱崩溃时还经常没打到最后一行。后来我学乖了把日志集中在CommandHistory和MacroCommand这两个入口里打。每一次Execute都打印“执行什么命令、参数是什么、当前栈深度”每一次Undo都打印“撤销了哪条命令、还剩多少条”。这样一条命令链的完整轨迹就清清楚楚地展现在日志里了。这里分享一个日志细节命令对象的Execute()里要打印参数最好重载一个ToString()不然你打印出来的全是“InsertCommand0x7f...”这种无意义地址。ToString()不是给用户看的是给你自己调试用的不要省。5.3 命令模式跨模块协作的边界问题最后说一个我自己跪过的坑。之前开发一个编辑器插件的时候有一个命令对象需要跨动态库传递。当时我没留意直接通过动态库接口把std::unique_ptrCommand传出去结果在另一个动态库里析构时崩了。排查到最后才发现两个模块使用的是不同编译选项下的C标准库new和delete的配对已经错乱才导致了崩溃。解决这个问题的标准姿势是按组件维度的“模块边界”来重新审视命令对象的传递。跨模块边界时不要直接传unique_ptr最好用纯接口指针加显式的Release函数让创建模块全权负责生命周期。或者更彻底点跨模块时直接改用std::function和轻量参数不传完整命令对象。这个经验听起来有点土但真实项目里就是这么折腾人的。6. 命令模式在游戏开发中的实战场景6.1 灵活的技能与输入系统写老滚5那种自定义按键系统最容易上头的就是一开始按死按键写代码后面想支持改键就苦逼。用命令模式设计游戏技能系统的输入层是个很好的例子技能在初始化时被封装成了一个个SkillCommand比如FireballCommand、HealCommand这些命令被登记到输入映射表里。按下Q键时程序不是去调用某个具体函数而是查映射表找到对应的SkillCommand对象然后执行。这个方式让“换键”变成了一件令人舒适的事玩家设置界面改一下映射表Q键从“火球术”换成“冰霜新星”底层根本不用动。你的输入层从“动作绑定”升级成了“数据驱动”这比在if/else里改一百次按键映射舒服太多了。顺手说一下角色状态机配合命令模式的思路如果技能执行需要消耗法力、冷却中不可用这些判断我通常把这些前置检查放在命令对象的CouldExecute()里而不是让SkillCommand的执行者去判断。这样UI层可以依据CouldExecute()来决定技能按钮置灰、高亮而不用关心具体角色状态机的内部实现。6.2 回放与录像系统的实现路径游戏回放系统我个人觉得是命令模式价值体现得最淋漓尽致的地方。回放的本质是“把一系列操作重新执行一遍”和命令模式的“延迟执行”思想简直严丝合缝。实现思路是这样的玩家每做一次操作比如走动、跳、攻击游戏就生成一个对应的MoveCommand、JumpCommand、AttackCommand随后把这个命令序列化存储。回放时把存储的字节流反序列化成命令对象再逐个执行这些命令。因为你保存的是“操作意图”而不是“渲染帧截图”回放文件会小得多同时还能支持倍速、单步、跳转等功能这些渲染层根本给不了你。这里有个需要警惕的细节——命令对象里不能保存运行时才有意义的东西比如“当前帧率”、“半帧延迟”。回放环境里的游戏状态可能和录制时的环境不一致命令模式解决的是逻辑回放而不是逐帧物理模拟。如果你的游戏强依赖物理引擎的浮点随机性回放命令时要格外小心尽量连随机种子一起保存否则同一个命令可能跑出不一样的效果。6.3 AI模拟玩家操作的接线魔法命令模式最酷的应用之一是让AI去模拟玩家操作。既然玩家的每个操作都被封装成了命令对象AI完全可以动态构造同类型的命令对象塞进输入队列让游戏逻辑“以为”是玩家在操作。我做过一个简单的AI训练场景副本AI回合开始前从技能库随机选出一组命令组合成宏命令比如“先蓄力再闪避再输出”把这个宏命令投进输入队列。由于命令队列完全复用了玩家的输入路径AI和玩家的行为在系统眼里完全等价。这种设计的好处是测试AI时根本不用动游戏逻辑层只需要更换命令组合策略。如果你用传统函数调用的方式写输入想模拟几百个AI玩家的操作那输入层代码会被污染得面目全非。7. 命令模式与C其他特性的结合7.1 泛型命令模板写多了你会发现很多命令类的结构非常相似持有Receiver指针执行时调某个函数撤销时调另一个函数。手写几十个子类会写到手软这时候用模板可以一劳永逸。templatetypename Receiver, typename ExecFunc, typename UndoFunc class LambdaCommand : public Command { public: LambdaCommand(Receiver* receiver, ExecFunc exec, UndoFunc undo) : receiver_(receiver), exec_(std::move(exec)), undo_(std::move(undo)) {} void Execute() override { exec_(receiver_); } void Undo() override { undo_(receiver_); } private: Receiver* receiver_; ExecFunc exec_; UndoFunc undo_; };这样子类就能用Lambda和成员函数指针去拼装特别适合那种只执行一次、也没必要单独建类的低频命令。但你也要警觉这个通用命令的Undo函数是外部传入的如果作者忘了传入正确的撤销逻辑这条命令就废了。所以我通常规定只有确实简单的命令才能用LambdaCommand一切行为复杂的命令还是老实写独立类。模板是效率工具不是设计漏洞的遮羞布。7.2 序列化与网络同步在联机游戏中命令模式的另一个大杀器是网络同步。你不再需要同步每个游戏对象的位置和状态只需要同步玩家发起的命令让所有客户端执行同一条命令流就能保证逻辑一致。序列化这步我用过最简单的方案是给每个命令分配一个唯一的CommandId字符串然后在序列化库做一个注册表// 伪代码注册表实现命令的反序列化 CommandRegistry::Register(InsertText, []() { return std::make_uniqueInsertCommand(); });反序列化时读到CommandId找到对应的工厂函数construct参数再组装出命令对象。有一定反射框架基础的读者会发现这不就是手写了一个迷你反射系统吗是的而且你不需要引入整个重型反射库就是这个注册表就够用。不过要注意网络同步场景对命令参数的一致性要求极高序列化格式要保持稳定老命令的序列化格式一旦变更旧回放文件就会彻底废掉。7.3 状态快照与命令日志的取舍最后聊一个和撤销有关的宏观取舍——命令日志和状态快照到底选哪个。我在实际项目中曾经两者都做过最后的经验是底层操作密集、状态量小的场景用状态快照操作稀疏、状态量大的场景用命令日志。状态快照的思路是每隔一段时间把整个文档存一份撤销时直接恢复到上一个快照。好处是实现简单粗暴恢复快缺点是需要额外的存储空间而且无法做“逐步撤销”只能撤销到快照点。命令日志则正好相反存储空间小、支持逐步撤销但撤销过程中要依次执行很多条命令越撤越慢。如果你正在做一个大型文档编辑器我建议做“快照命令日志”的混合方案内存里维护一个基础的快照比如每100条命令打一个快照点撤销时先恢复到最近的快照点再重放快照点之后剩余的少量命令。这套思路和数据库的checkpoint redo log是一模一样的如果你懂数据库恢复机制上手就毫无压力。这种工程视野纯靠背设计模式八股是学不来的。8. 维护宏命令与编辑器项目时的心得聊了这么多代码级细节收个尾分享一点做编辑器项目维护时的心得。第一个心得是命令模式的模块边界搞不清等于没搞。我在自己项目里划定了一条铁律命令对象只允许访问Receiver的公有接口禁止深挖内部成员。这条规矩看起来不起眼但真的执行一年后你会发现命令层极其稳定无论底层数据结构怎么换、缓存策略怎么调命令层一行都不用改。第二个心得是给每个命令写一行注释写明它的“用户语义”。比如“插入文字撤销时删除这段文字”这种注释并不是给机器看的而是给三个月后忘光的自己看的。命令模式的大量类会让代码文件爆炸如果每类命令都不解释自己代表什么操作后面维护的人光是找“删除”相关的类就要翻半天。第三个心得是关于骨架代码的通过串行化调用GetBuffer()、SetDirty()等接口命令层可以天然地和UI状态同步。我在历史栈里增加了一个“已修改”标记每次Execute或Undo都会触发UI重绘。这样一来文档的未保存状态标志就不需要额外维护变量完全可以从历史栈有没有变化推导出来。说实话这是我自己开发时很得意的一个设计用命令模式做一次后面省了非常多事。坦白讲命令模式是我认为C设计模式里“性价比”最高的几个之一。它不炫技、不依赖多少奇技淫巧核心就那么张牙舞爪的几个概念但一旦用对了地方代码的扩展性立刻上一个档次。如果你正在写编辑器、游戏输入系统、任务调度器或者准备面试被问到设计模式真心建议你把这一套完整写一遍别光看书。写完之后你对“面向对象设计”的理解绝对比背一百个八股强得多。
返回列表