Undo/Redo框架(C++,带源码)

 

目录

 

前言

框架设计

代码实现

单元测试

后记

参考资料

 

前言

 

终于结束赋闲在家的状态,又走上研发经理的岗位。老板“教导”我说:“作为‘空降’的管理者,要想得到团队中其他成员的信任和认可,必须身先士卒,去解决开发中难题。”言下之意很明显,得先干Hands-on的工作。于是我便有了做现有系统图形操作的撤销和恢复(Undo/Redo)功能的任务,因为这项工作被其他人认为是比较难啃的骨头(原因是你要在现有功能的实现代码中加入这个 Undo/Redo,而这些代码是由多人写的,要读懂它们就得费不少功夫,最多的一个操作2000多行代码,还不算间接调用的函数)。

 

当然,实现具体功能的Undo/Redo之前,首先要搭建一套Undo/Redo的框架。不过好在实现Undo/Redo框架还不是那么复杂。以前看到过一些关于如何实现Undo/Redo功能的书和网页,带过的团队也曾做过Undo/Redo,但是自己亲自下手,还是头一回。下面就把设计、实现和测试的过程回顾一下,算是做个总结。

 

框架设计

 

一、最基本的,当然是使用命令(Command)设计模式。见下面的类图:

 

clip_image002

 

如果用C#,可能用接口(interface)来定义它们比较好,比如定义ICommandICommandManager。但C++中没有interface,所以用抽象类(Abstract Class)来实现,所有方法都声明为纯虚函数。

 

至于Command设计模式,无需多说,无非这里的Command 模式带Undo/Redo功能。

 

二、下一步当然是BaseCommandManager的实现子类CommandManager,见下面的类图:

 

clip_image004

 

CommandManager内部会维护着2个栈(Undo StackRedo Stack),并增加相应的操作栈的私有方法,如:PushUndoCommandPopUndoCommand等。CommandManager基于这个数据结构来实现BaseCommandManager声明的所有纯虚函数。

 

三、下面是BaseCommand的实现子类Command。根据《Head First设计模式》里说得,这里有两种方案,一种是“傻瓜式”的Command,即Command持有一个接收者(Receiver)的指针,所有具体的命令都由Receiver来处理,Command对如何处理命令一无所知,像“傻瓜”一样。另一种是“聪明”的CommandBaseCommand的子类知道如何处理命令并直接处理。这里我采用了“傻瓜式”的Command(当然此框架也支持“聪明”的Command,但Command子类需要根据客户程序的需要由框架的使用者自己来实现了),见下面类图:

 

clip_image006

 

BaseCommandReceiver也是个抽象类,声明一个纯虚函数ActionCommandExecuteUnexecute方法分别用falsetrue作为参数调用虚方法Action以执行命令。

 

客户程序在生成Command的同时,应该给其赋予一个BaseCommandReceiver的指针m_pReceiver。缺省地,Command销毁的时候会一并销毁m_pReceiver,但客户程序也可通过设置bAutoDeletefalse,来自己销毁。

 

BaseCommandReceiver的子类负责实际的命令处理。

 

四、很多书中介绍Command设计模式的时候,都会提到组合命令。用组合命令可以实现命令的“批处理”。我们这里也需要,所以要实现BaseCommand的另一个子类MacroCommand。见下面类图:

 

clip_image008

 

MacroCommand维护一个Command的集合(这里用std::vector实现),客户程序可以添加命令(AddCommand)和删除命令(DeleteCommand)。当然MacroCommand也要实现BaseCommandExecuteUnexecute函数,具体的实现就是遍历vector中的Command,逐个执行和逐个撤销。

 

五、支持Undo/Redo的应用程序,一般都有“Undo”和“Redo”两个按钮,那么当命令的Undo/Redo栈内容变化的时候,两个按钮会根据是否“可撤销”和“可恢复”相应地变化为EnabledDisabled状态。那么框架支持这个功能是通过观察者(Observer)设计模式来完成的。Observer模式也无需多说,见下面的类图:

 

clip_image010

 

简单的说,当CommandManagerUndoRedo栈由空变为不空时,或由不空变为空时,都会调用SubjectNotify函数通知所有观察者,来更新UI(比如:Undo/Redo按钮的Enabled/Disabled状态)。

 

六、这个框架支持的五个基本操作:执行命令、UndoRedo、清除Undo/Redo历史记录、Undo/Redo状态改变的时序图依次如下:

 

clip_image012 clip_image014 clip_image016 clip_image018clip_image020

 

代码实现

 

BaseCommandManager.h

BaseCommand.h

 

在使用此框架时,可能会产生一些BaseCommand的子类。这里使用对象工厂(Object Factory)设计模式来实现BaseCommand子类的创建,目的是解除这些子类与框架的耦合。(关于对象工厂,参见本人另一篇博客《对象工厂设计模式》。Factory.h的代码见下)

 

Factory.h

 

所以在BaseCommand中定义RegisterCommandClass模板类来支持BaseCommand子类向工厂的注册。并且在BaseCommand类里增加CreateCommand静态函数(包括宏定义CREATECOMMAND),通过工厂在运行时可以“动态”生成BaseCommand的子类。

 

CommandManager.h

CommandManager.cpp

 

CommandManager实现为单件(这不是必须的,只不过我们的系统需要这样做)。这个单件由Singleton模板类实现(Singleton<CommandManager>::Instance())。顺便说一句,把Singleton做成模板类的好处是:单件有许多变种(Mayers单件、Phoenix单件、带寿命的单件和双检测锁定单件等),当需要修改单件的实现方法时,只需改这个模板类即可,不用每个单件类都去修改。Singleton模板类代码如下(这里Singleton不是线程安全的,需要加双检测锁定才能支持多线程):

 

Singleton.h

 

CommandManager里有个内嵌类UndoRedoStateInspector,它的作用相当于一个“门卫”,“守卫”在CommandManagerCallCommandClearAllCommandsUndoRedo函数的“门口”,它可以在进入函数时(即构造UndoRedoStateInspector时)保存CanUndoCanRedo的状态,当退出函数时(即析构UndoRedoStateInspector时),检查2个状态是否改变,如改变则通知观察者(Observer)们状态已改变。这个做法非常类似于多线程编程中经常使用的Lock对象(即在构造时获得Mutex,析构时释放Mutex)。

 

CommandManager的最后还定义了几个宏CALLCOMMANDUNDOREDO,目的是方便调用(可以使调用者少敲一些字符)。

 

Command.h

Command.cpp

 

作为BaseCommand的子类,Command向工厂注册自己。

 

RegisterCommandClass<Command> RegisterCommandClass(ClassNameToString(Command));

 

在客户程序创建Command对象时,代码可能像下面这个样子:

 

Command * pCommand = (Command *)CREATECOMMAND(Command);

 

MacroCommand.h

MacroCommand.cpp

Util.h

 

同样,作为BaseCommand的子类,MacroCommand也需要向工厂注册自己。

 

RegisterCommandClass<MacroCommand> RegisterCommandClass(ClassNameToString(MacroCommand));

 

MacroCommand的析构函数要清理m_vecCommands,所以写了个ContainerDeleter。这个模板函数其实可以胜任任何支持迭代器的容器的清理工作(delete元素和clear容器)。

 

BaseCommandReceiver.h

 

在使用此框架时,可能会产生一些BaseCommandReceiver的子类。所以与BaseCommand类似,BaseCommandReceiver也使用对象工厂(Object Factory)设计模式来实现子类的创建。BaseCommandReceiver中定义RegisterCommandReceiverClass模板类来支持BaseCommandReceiver子类向工厂的注册。并且在BaseCommandReceiver类里增加CreateCommandReceiver静态函数(包括宏定义CREATECOMMANDRECEIVER),通过工厂在运行时可以“动态”生成BaseCommandReceiver的子类。

 

最后是观察者设计模式(SubjectObserver)的代码,见下:

 

Subject.h

Subject.cpp

Observer.h

Observer.cpp

 

SubjectObserver都不是线程安全的,如果要支持多线程, SubjectObserver的函数都要加互斥体(Mutex)。

 

单元测试

 

这里使用Google Test作为单元测试的框架。(说明一下:以下的单元测试并没有对每个单独的类做单元测试,只是对整个框架做单元测试。)

 

使用一个测试装置(Test Fixture)来测试:声明一个Invoker类,维护一个元素为int型的list,并负责压入和弹出元素、清除list、显示list、“观察”Undo/Redo状态变化等工作。我们的测试将对这个list以及对其追加数据的命令(见下面MockCommandReceiver)而展开。

 

Invoker.h

Invoker.cpp

 

声明MockCommandReceiver来实现对list追加数据的操作。MockCommandReceiverBaseCommandReceiver 的子类。

 

MockCommandReceiver.h

MockCommandReceiver.cpp

 

下面的测试用例中,有个对命令执行失败情况的测试,所以声明MockCommand来模拟执行成功和失败。

 

MockCommand.h

MockCommand.cpp

 

要测试的内容包括:

 

1.       简单命令的调用、撤销和恢复

2.       组合命令的调用、撤销和恢复

3.       清除所有命令

4.       在撤销一个命令后调用另一个命令

5.       失败的命令调用、撤销和恢复

6.       大量的命令调用、撤销和恢复

7.       以上操作后,Undoable/Redoable的状态

 

每个用例的目的、步骤和期望结果就不赘述了,看代码吧。

 

TEST_F(Invoker, TestUndoRedoFramework)

 

后记

 

有人说:“你罗罗嗦嗦地说这么多,不就是个Undo/Redo框架么,至于这么费劲么?”不错,说得确实有点罗嗦。不过,在实际的工作中,对以上每一个技术细节的思考都是不可缺少的。当你的代码将被别人使用的时候,多费点精力在稳定性、可复用性、可扩展性等方面,还是很值得的。

 

以上内容,如有谬误,敬请指出,先谢过了!

 

请点击此处下载源代码

 

参考资料

 

《设计模式 - 可复用面向对象软件的基础》5.2 Command(命令)- 对象行为型模式

Head First设计模式》6 封装调用:命令模式

《敏捷软件开发 - 原则、模式与实践(C#版)》第21 COMMAND模式

C++设计新思维》部分章节

Getting started with Google C++ Testing Framework

 

原文地址:https://www.cnblogs.com/wanghui9072229/p/2158960.html