引言
“撤销(undo)”是数字编辑软件(如word、ps、cad、记事本等)中必不可少的功能,它能让系统从当前的编辑状态返回至上一步状态,起到“反悔”的作用。而与之对应的一个操作叫“重做(redo)”,它是undo的逆操作,即撤销一步后又“反悔”,起到“恢复”的作用。undo和redo操作方便用户随时修改,非常有用。下面就分析下undo/redo功能,并对它的实现进行阐述。
开发思路
这两个操作的实现目测还是比较简单的,如果参与undo/redo的操作如果比较少,比如像“记事本”这种简单文字编辑软件,只有文字输入/删除的功能,那只用记录用户每一步输入/删除的文字,即可实现undo/redo操作。但如果参与undo/redo的操作特别多,比如像“绘图软件”这种,那我们可以有两种思路:
- 记录成图片:用户每进行一步操作,就把当前画布存储成一张图片,undo/redo时只用找到上一次的图片,然后刷新画布。
- 记录成操作数据:根据用户每一步操作的不同(是绘图?平移?缩放?旋转?删除?拷贝?还是填充?),记录下某一步需要的数据信息(比如平移操作,需要记录下平移前的pel、水平平移量dx、垂直平移量dy,填充操作需要记录下填充的颜色、填充前的颜色、填充扩展点的坐标等),以便分别为之实现undo回退和redo恢复操作。
上面两种方案,显然第2种更合理,因为第1种:图片占的内存很大,而Android系统的手机内存非常紧缺,很容易就堆栈溢出了,而若换种思路把图片存到SD卡上,那么存取图片的响应时间又特别长,而且这种方式非常不优雅,牺牲了SD卡,会为用户带来一些程序之外的麻烦。而第2种无论在时间和空间上都比较合理。
尽管第2种方案挺理想的,但一说起实现,问题又来了:绘图软件的操作很多,各式各样,因此回退或恢复操作也就需要逐一对应设计一套,这确实没有办法避免,哪叫功能这么多呢?那么,既然没有办法改变“每个操作对应一个实现”这个事实,我们就只能尽量设计好undo/redo的数据结构、基类子类等,让它尽量好维护、好扩展,尽量为后期工作减少不必要的麻烦。那具体该怎么设计呢?
抽取步骤类
用户的每次操作都对应一步(经归纳,这些操作包括:绘制、变换、删除、拷贝、填充图形),因此我们可以抽象出一个叫“步骤”的基类Step,如下所示:
public class Step
{
protected static List pelList=CanvasView.getPelList(); // 图形链表
protected static CanvasView canvasVi=MainActivity.getCanvasView(); //重绘用的画布
protected Pel curPel; //最初生成的图形
public Step(Pel pel) //构造
{
this.curPel=pel;
}
public void undoUpdate(){} //撤销时的更新(子类覆写)
public void redoUpdate(){} //重做时的更新(子类覆写)
}
它定义了undoUpdate()和redoUpdate()两个抽象方法(即回退与恢复需要的更新处理),等待具体子类Step去分别实现,然后像第1章的Touch类一样,利用多态去调用,增强代码复用性、维护性和扩展性,思想一样,这里就不多说了,如果没有弄清为什么突然要抽象出这样一个Step类,不妨先看看后面,自然就懂啦。Step类体系如下所示:
双栈结构
仅仅建立一个Step类族其实并没有什么用,他们目前只是孤立的存在着,还需要一个容器去控制他们的生成、转移、销毁等,那这个容器是怎样的一种数据结构呢?
不知道你有没有隐约察觉到什么,撤销和重做的行为都有一个特点——先进后出。没错,点击撤销后总是回退至最近一步操作,而最先进行的操作反而是最后才能被撤销掉,重做同理。那么显然,我们可以用一个“栈”来模拟“撤销”:每当进行一步操作,就把该操作步骤对象压入撤销栈中,当点击撤销时,栈顶对应的步骤对象出栈,并执行该步骤对象的undoUpdate()方法进行回退。
但不要忘了我们还需要实现“重做”。其实稍微想下就会知道,既然重做是撤销的逆操作,那也就是说重做能进行的前提条件是撤销了,而撤销了哪些步骤总得需要一个容器去存储它吧,而又知道重做也是先进后出性质的,那这个容器必然也是“栈”。哈哈,是不是想通了呢?仿照上面的“撤销栈”,我们就很容易构建出一个与之结构完全相同的“重做栈”,里面装的全是“撤销过的步骤对象”。
所以可以得到如下的双栈结构:
该双栈结构与用户的4类操作有关:画布操作、撤销、重做、清空画布。每类操作执行后,双栈内的数据流转如下:
- 画布操作:每对画布进行一种操作,就生成一个这个操作对应的步骤对象,压入撤销栈,如此进行
- 撤销:当用户点击撤销时,撤销栈栈顶步骤对象弹出,调用其undoUpdate()方法,接着同时压入重做栈,如此进行
- 重做:当用户点击重做时,重做栈栈顶步骤对象弹出,调用其redoUpdate()方法,接着同时压入撤销栈,如此进行
- 清空画布:当用户点击清空时,不仅要把画布上的图形数据全部清空,此时还需要把撤销栈和重做栈分别清空
Step类族定义
由于篇幅有限,这里仅给出绘制图形操作的步骤类DrawpelStep的定义,以及上面讲的4类操作的实现代码:
//绘制图形步骤
public class DrawpelStep extends Step
{
protected int location; //图形所在链表位置
public DrawpelStep(Pel pel) //构造
{
super(pel); //重写父类
location=pelList.indexOf(pel); //找到该图形所在链表的位置
}
@Override
public void undoUpdate() //撤销
{
pelList.remove(location); //删除链表对应索引位置的图形
canvasVi.updateSavedBitmap(); //刷新画布
}
@Override
public void redoUpdate() //重做
{
pelList.add(location,curPel); //在链表指定位置插入图形
canvasVi.updateSavedBitmap(); //刷新画布
}
}
生成Step对象
在对画布的操作完毕后(通常是在Touch类族的up()方法),需要生成步骤对象压入撤销栈。这里是绘制好图形后,手指抬起瞬间进行的处理,代码很简单,如下所示:
undoStack.push(new DrawpelStep(newPel)); //将步骤对象压入撤销栈
撤销
点击撤销按钮,执行如下代码:
public void onUndoBtn(View v)
{
if(!undoStack.empty()) //撤销栈不为空,即存在画布操作产生的步骤
{
Step step=undoStack.pop(); //从撤销栈弹出栈顶
step.undoUpdate(); //调用栈顶步骤对象的undo处理
redoStack.push(step); //继续压入重做栈
}
}
重做
点击重做按钮,执行如下代码:
public void onRedoBtn(View v)
{
if(!redoStack.empty()) //重做栈不为空,即存在撤销过的步骤
{
Step step=redoStack.pop(); //从重做栈弹出栈顶
step.redoUpdate();//调用栈顶步骤对象的redo处理
undoStack.push(step);//继续压入撤销栈
}
}
清空画布
点击清空按钮,执行如下代码:
public void onClearBtn(View v)
{
......
undoStack.clear();
redoStack.clear();
}
结语
本章的内容到此就结束了,undo/redo的实现也全部水落石出了,其实并不难,是不是?不知道有没有体会到面向对象和数据结构的魅力呢?反正我一直觉得这两门课超级有用,做项目时十有八九都会用到,而且还不少。
我是不是越写越水了点?好多类似的东西前面已经介绍过,所以我就忽略了…没事,如果有什么疑问,或者您有更好的方案和建议,欢迎邮箱联系我:[email protected]。该项目的完整代码在我的GitHub上。下一章我将介绍这个绘图软件中填充颜色的实现。