前言
本篇文章建立在我之前所写的可部分擦除Item的抽象概念解析。
意在真正利用这个思路完成一个可以使用的白板软件。
这里介绍的是,我们如何在一个GraphicsScene中,完成我们所想要的:画线、橡皮擦、荧光笔等功能的实现。
基于我所使用过的白板笔记软件,我还为其提供了背景(这个背景可以是横线、也可以是一个PDF页面),方便我们后续进行一些功能的扩展。
同时,我也基本完成了Scene中撤销、重做功能的设计。
这里说明一下,如何将一个或多个Scene以PDF阅读器那样的格式展示出来,是View层该关心的问题,View负责渲染和组织Scene,而Scene只需要管好Item。
Scene层次
Scene层次主要是检测事件,并且应该做到不同工具下,对某个事件进行不同的处理。
因此,工具集成类型集成于该层次,对不同工具触发的事件进行反应,让外界可以设置不同工具等等有关工具的内容都是在Scene
中实现的。
Scene层次功能实现演示
工具的实现
对于钢笔、荧光笔、激光笔、橡皮擦这些工具,首先我们需要有一个变量标识当前的Scene
正在使用什么工具。
Scene
在处理某些事件的时候,会根据不同的工具,完成不同的操作,这些不同的操作中,对应钢笔的操作需要使用到钢笔的属性、对应荧光笔的操作需要用到一些荧光笔的属性。因此,我们需要将钢笔、荧光笔、激光笔、橡皮擦这些工具的属性封装起来,并且这些属性能够被记录下来,还能够在打开软件的时候读取这些记录下来的属性,所以关键在于:
- 将工具的属性封装成类对象。
- 类对象的属性能够保存在用户本地。
- 保存在用户本地的类对象属性必须能够在打开白板时重新得到加载。
这里涉及到配置系统的实现,需要将应用程序的一些信息保存到本地中,并在应用程序在第一次安装使用时,提供默认的工具属性。
Scene的背景
Scene的背景是一个图元,假设该图元的名字为BackgroundItem
,该BackgroundItem
需要在Scene构造时给定(也可以不给定,但这时我们需要给出默认实现)。
Scene的大小必须时时刻刻和BackgroundItem
的大小保持一致,并且BackgroundItem
必须永远处于所有其他图元的下层。BackgroundItem
不可以被移动,也不能用橡皮擦擦除。导入的PDF文档页面将作为BackgroundItem
。我想出了两种不同的BackgroundItem
的组成方式:
- 使用预制好的
QPainterPath
来绘制BackgroundItem
,这样用户可以自定义BackgroundItem
的颜色,而不影响需要绘制的轨迹。 - 使用
QImage
作为BackgroundItem
,这样的话填充颜色就没有了作用,用户不再能够自定义BackgroundItem
的颜色。
也就是说,BackgroundItem
可能会有两种实现,一种以QPainterPath
为绘制的基石,另一种以QImage
为绘制的基石。
对事件的处理逻辑
由于我们后续可能将白板软件移植到平板上,所以需要处理触摸事件,而触摸事件和鼠标事件是有差异的。
所以,我们对鼠标事件的处理方式是,将我们需要的信息抽象出来,这个信息无论是鼠标还是触摸都能收集到,通过这种方式将处理信息的业务逻辑集中到一处中。
各种工具创建的时候,必须考虑到工具本身的特性,比如:钢笔绘制的时候需要使用到没有透明度且有一定颜色的
BaseGraphicsItem
;荧光笔需要有透明度和颜色,除此之外,还要有直线绘制和曲线绘制两种模式;激光笔内部是白色而外围需要使用渐变的color来绘制,且一定时间后会消失。
mousePressEvent()
对于mousePressEvent()
,我们需要的信息就是:鼠标按下的位置pos
。
钢笔
利用pos
创建出一个以pos
为起点和终点的直线的填充区域图元path
(类型为BaseGraphicsItem
使用ControlGroupObserver
的某个子类进行塑形)。
荧光笔
曲线绘制模式:
利用pos
创建出一个以pos
为起点和终点的直线的填充区域图元path
(类型为BaseGraphicsItem
使用ControlGroupObserver
的某个子类进行塑形)。
直线绘制模式:
记录下初始pressPos = pos
,交由后续的事件使用
激光笔
利用pos
创建出一个以pos
为起点和终点的直线的填充区域图元path
(类型为BaseGraphicsItem
使用ControlGroupObserver
的某个子类进行塑形)。
橡皮擦
在pos
创建一个橡皮擦图元并记录下来。并把橡皮擦擦除的图元收集起来,在这时候这些图元都只是被擦除一部分,但即使它们因擦除而分离,此刻分离的部分已经同属于一个图形项,它们并不独立。
mouseMoveEvent()
对于mouseMoveEvent
,我们需要的信息就是:鼠标上次所在位置lastPos
,和当前位置pos
。
钢笔
利用lastPos
和pos
,结合press事件中创建的ContrlGroupObserver
子类,完成现有path
和新添加的path
之间的融合。
荧光笔
曲线绘制模式:
利用lastPos
和pos
,结合press事件中创建的ContrlGroupObserver
子类,完成现有path
和新添加的path
之间的融合。
直线绘制模式:
利用pressPos
(press事件中记录的位置)和当前位置pos
,画出一条直线填充区域图元BaseGraphicsItem
,这个填充区域图元随着pos
的变化而变化,但pressPos
是不变的。
激光笔
利用lastPos
和pos
,结合press事件中创建的ContrlGroupObserver
子类,完成现有path
和新添加的path
之间的融合。过了一段时间后,激光笔会消失在屏幕上,如果一定时间内再次进行激光笔按压、移动,那么消失的时间将会被重置,直至有一次倒计时完成,所有激光笔笔画都会释放。
橡皮擦
将press创建的图形项移动到pos
。将被碰撞擦除的图形项收集起来。
mouseReleaseEvent()
鼠标的释放事件意味着图形项的形成结束了。
释放时,需要将新增的图元记录下来,同时产出一个Memento
,记录Redo
和Undo
以为后续的撤销和还原功能打下基础。Qt中有完成好的QUndoStack
和QUndoCommand
给我们利用,可以继承QUndoCommand
实现自己的Redo和Undo操作,然后让QUndoStack
来进行管理。
钢笔
结束信息采集,将相应的变量重置。
荧光笔
结束信息采集,将相应的变量重置。
激光笔
结束信息采集,将相应的变量重置。
橡皮擦
将收集的被擦除了部分的图形项,进行分解使其分离的部分独立,而原来的图元将被删除。
- Undo操作,将所以擦除涉及的图形项的状态(填充区域、颜色、以及各种QGraphicsItem的属性复制下来保存),重新创建一个新的
BaseGraphicsItem
与其对应,并将原来分裂的图形项全部从Scene中删除。 - Redo操作,将因Undo而合并的图形项全部删除,然后将分裂的图形项重新添加到Scene中。
最后,将橡皮擦删除。
Scene的类层次结构
完成了Scene
层次的概要设计,明确了后续的开发方向:
-
完成Scene的背景
BackgroundItem
及其子类。 -
完成
WhiteBoardPen
WhiteBoardHighlightPen
WhiteBoardLaserPen
WhiteBoardEraser
一系列工具的属性,为后续开发奠定基础。 -
完成
QUndoCommand
衍生出适合本项目的子类,为后续的事件逻辑处理打下基础。该方向涉及到和Item层次以及Scene
的交互,所以需要慎重考虑其实现。这个过程也会逼着你去思考业务逻辑应该是怎样的,然后你才能在业务逻辑上嵌入这些Undo命令的创建。这里需要
BaseGraphicsItem
有产出自己状态的能力,具体如何设计需要借助Memento设计模式的思想。 -
完成
WhiteBoardScene
事件业务逻辑的开发。本阶段的任务最复杂,需要前期进行算法的详细考虑之后,再开始开发,防止准备不足而导致的频繁修改导致Scene结构的代码混乱。
在这过程中,需要对
Item
层次的设计进行修改和查漏补缺。
BackgroundItem的设计
在Scene中,BackgroundItem
只有一个。
BackgroundItem
具有背景颜色,而与这个BackgroundItem
是QImage
还是QPainterPath
无关。这个背景颜色是交由QGraphicsScene
来渲染的,BackgroundItem
只是指明了这个颜色,只要BackgroundItem
被加入QGraphicsScene
中,就会立即将Scene的颜色设置成自己所存储的颜色。包括后续Background
的颜色发生变化,这种变化会同时反应在Scene上。
BackgroundItem
的会拥有一个默认的矩形大小,而子类来决定是否更改这个大小。同时,在BackgroundItem
被添加到Scene中的那一刻,BackgroundItem
会立即将自身的大小反应到Scene上,让Scene刚好包括BackgroundItem
。BackgroundItem
的大小通常在初始化时指定,后续不可再变。
PathBackgroundItem
:在BackgroundItem
的基础上,绘制一些基础的线,这些线可能是一条条平行线,也可能是纵横交错的线等等。ImageBackgroundItem
:在BackgroundItem
的基础上,更改其大小为QImage
的大小。
PathBackgroundItem
类似于这种效果(我已经对这个东西进行了简单的搭建和测试):
ImageBackgroundItem
类似于这种效果,它随着背景图片的不同而不同,这完全取决于你将它指定为什么:
简陋的工具类设计
由于白板的工具只需要提供属性而不需要提供相关的方法,所以这里简单封装了对应工具的属性到结构体中:
struct WhiteBoardPen {
qreal width;
QColor color;
};
struct WhiteBoardHighlightPen {
qreal width;
QColor color;
qreal opacity;
bool openStraightLineMode;
};
struct WhiteBoardLaserPen {
QColor color;
};
struct WhiteBoardEraser {
qreal radius;
bool eraseWholeItem;
};
这些类的目的在于封装一些Scene中处理所需要的信息,增强代码的可读性,不然参数在Scene中乱写会导致Scene的代码很难懂。
UndoCommand的设计
AddItemCommand
AddItemCommand
对应了在Scene中添加一个Item的操作完成后的撤销和重做操作。
具体来说,AddItemCommand::undo()
就是从记录的Scene中删除该新添加的Item;AddItemCommand::redo()
则是将该新添加但被undo()
从Scene中移除的Item重新添加到Scene中。
对于undo操作应该以什么样的方式删除Item呢?我做出如下分析:
-
undo操作将Item从Scene中移除后,将该Item析构掉。这样的话,redo操作如果想要添加原有的Item到Scene中,command就必须事先记录下来Item的状态(通常是使用Memento模式,让Item自己产出Memento,然后利用该Memento恢复原有的状态),然后redo利用该状态新创建一个Item。
这种做法需要关心:undo之后但未redo时,程序结束或者QUndoStack舍弃了该Command,Item需要在析构函数中释放。
-
undo操作将Item从Scene中移除后,不对Item进行任何操作,将该Item原样保留。这样的话,redo操作可以直接添加原有的Item到Scene中,而不需要显式创建一个Item再添加回去。
这种做法需要关心:redo之后再undo后,程序结束或QUndoStack舍弃了该Command,Item需要在析构函数中得到释放。
无论哪种做法,Item都需要在Command处在特定阶段下进行释放。不同的是,在频繁进行undo和redo操作的情况下,第二种做法不需要频繁地进行Item的创建和状态复现。所以,后续采用了第2种做法对AddItemCommand
进行了设计。
Item会析构时会自动和父Item或Scene断绝关系(如果有的话)。
为了保证所有添加Item的操作必须封装成AddItemCommand
进行使用,以确command的完整性。AddItemCommand::execute()
提供了一种不需要undo即可redo的操作方法,这样做是为了让Scene将原本需要自己完成的各种添加Item的操作,交由AddItemCommand
进行处理,而Scene只需要关注自身业务逻辑而无需关注撤销和重做是如何实现的。
经过实践发现,
execute()
的处理完全可以放在Command的构造函数中,而不需要显式调用execute()
DeleteItemCommand
DeleteItemCommand
对应了在Scene中删除一个Item的操作完成后的撤销和重做操作。
具体来说,DeleteItemCommand::undo()
就是从记录的Scene中重新添加该Item的过程;DeleteItemCommand::redo()
则是将undo操作添加的Item重新从Scene中删除。
Scene不会直接调用removeItem()
之类的方法,而是将其自身封装成DeleteItemCommand
的一部分,我们会提供一个DeleteItemCommand::execute()
执行原始的删除操作,而Scene不需要关心我们如何进行删除,这样就避免Scene对复杂的删除Item的逻辑进行了解。Scene不需要要管它怎样删除Item,将Item删除后应该是delete还是不delete,它唯一知道的就是:将自己封装到DeleteItemCommand
中,然后执行DeleteItemCommand::execute()
,最后将DeleteItemCommand
加入UndoStack
即可。通过这样实现撤销的业务逻辑和Scene本身的业务逻辑进行了分离。
EraseItemCommand
EraseItemCommand
与之前的DeleteItemCommand
和AddItemCommand
一样,它为了方便Scene不需要管理撤销、重做功能,提供了一个execute(QPainterPath)
方法。构造时指定对象,然后execute()
指定
EraseItemCommand
需要的参数,一个Scene,一个BaseGraphicsItem。
进行擦除操作时,execute(QPainterPath)
不对原始的BaseGraphicsItem
进行碰撞,而是创建一个Item的复制品,用该复制品碰撞path取得碰撞后的分裂Item,并将该分裂Item加入Scene,原始的Item从Scene删除并保留在EraseCommand中。注意,这里的删除和添加操作时通过利用 DeleteItemCommand
AddItemCommand
实现的,复用我们之前的类以简化EraseItemCommand
的实现。
在橡皮擦进行擦除的一系列操作的过程(press-move-release),原始的Item被部分擦除后,其分裂的Item也可能被部分擦除。这时候我们对其的仍然是像原始的Item那样进行处理。
举一个例子可能比较好理解这个算法的思想:
以上图片中,黄色框对应EraseItemCommand
undo和redo操作的整体单位。
一开始,原始Item被橡皮擦碰撞,然后分裂成两个Item
添加到Scene中,自己则被从Scene中删除。这里的添加和删除采用AddItemCommand
和DeleteItemCommand
。
我们EraseItemCommand::undo()
要做什么?分裂Item1
和分裂Item2
对应的AddItemCommand::undo()
需要被调用,而原始Item
对应的DeleteItemCommand::undo()
需要被调用。
我们EraseItemCommand::redo()
要做什么?分裂Item1
和分裂Item2
对应的AddItemCommand::redo()
需要被调用,而原始Item
对应的DeleteItemCommand::redo()
需要被调用。
从而实现整体的undo
和redo
。
当原始Item分裂成其他Item时,如果在橡皮擦的擦除周期(press-move-release)中分裂的Item也被擦除,就会有多个EraseItemCommand
产生。如上图片所示,一个原始的Item最终被分裂成了Item1-1
Item1-2
Item2-1
Item2-2
,并产生了三个EraseItemCommand
。此时,我们想要将Scene的状态还原成擦除周期发生之前,该如何做?
单纯使用最顶层的EraseItemCommand::undo()
肯定会导致错误,因为Item1
和Item2
已经因为擦除分裂而从Scene中移除了,AddItemCommand::undo()
无法从Scene中移除Item1
和Item2
。如果想要执行最顶层的EraseItemCommand::undo()
那么其最底层的EraseItemCommand::undo()
必须先执行,恢复原来的状态,等到其他底层的所有EraseItemCommand::undo
都被执行了,最终才能undo
回擦除周期之前的状态。
总的来说,就是在擦除周期之内发生的EraseItemCommand
其实是存在先后关系的,而如果要将Scene中Item们的状态恢复到擦除周期之前,则必须逆着这个先后顺序执行undo
操作,否则就会出现错误。
最终,我们得出一个结论,一个擦除周期内的所有先后执行的EraseItemCommand
们需要封装成一个EraseItemsCommand
来完成整体的状态的恢复。
这里其实就是将一些EraseItemCommand
作为子命令加入到EraseItemsCommand
进行执行。
WhiteBoardScene业务逻辑设计
普通笔
利用好Item层次提供好的组件,配置Command
就可以很轻松地搭建起普通笔的业务逻辑。
比如,在press事件发生时,可以按如下代码进行处理:
template<typename T>
void WhiteBoardScene::devicePress(const QPointF& startPos, NormalPenTag)
{
m_eventTempItem = new BaseGraphicsItem(m_normalPen.width, QBrush(m_normalPen.color));
m_eventTempItem->setZValue(m_backgroundItem->zValue() + 1); // 保证item刚好在background之上
// 添加操作使用Command进行
AddItemCommand* command = new AddItemCommand(this, m_eventTempItem);
m_undoStack->push(command); // add操作真正执行是在push方法内
m_curveObserver = new ControlCurveObserver(m_eventTempItem);
// 后面的QSizeF是随便给的,因为我知道ControlCurveObserver在其formItem只处理leftTop点
m_curveObserver->addPointToCurve(startPos);
}
template<typename T>
void WhiteBoardScene::deviceMove(const QPointF& scenePos, const QPointF& lastScenePos, NormalPenTag)
{
Q_UNUSED(lastScenePos);
m_curveObserver->addPointToCurve(scenePos); // 移动时只需要添加点即可,剩下的工作observer帮我们解决
}
template<typename T>
void WhiteBoardScene::deviceRelease(NormalPenTag) // 事件结束释放临时资源
{
// 同一时间只处理同一个工具的一套(press-move-release)流程,所以不用担心同步问题
if (m_curveObserver) {// 已经没有用了,需要释放
delete m_curveObserver;
m_curveObserver = nullptr;
}
// item已经在本次事件中塑造完成,后续交给Scene管理
// m_eventTempItem不再能操作已经塑造完成的Item
m_eventTempItem = nullptr;
}
实现效果:
我知道这里的线画的有瑕疵,后续开发完整的功能之后,我才会想优化的问题,现在先用着。
这里差不多有Windows自带的画图板的水平吧,不需要想太复杂,后续有优化方案在回来优化,不可能每一步都是最优的。
荧光笔
继承ControlCurveObserver
新完成一个ControlLineObserver
用于实现直线模式。后者只会记录初始点,和第2,3…n次中的第n个点,并在这两个点中进行连线。
直线模式实现效果:

此模式与普通笔的实现原理一样,只是图形多了个透明度的属性。
曲线模式实现效果:
暂时搭建的简单的、用于调试的程序:
激光笔功能
其业务逻辑方面跟普通笔类似,但激光笔是一个白色的笔,且其外围有某种颜色的阴影。并且激光笔需要在一定时间没有操作后消失在Scene中。
以红色的激光笔为例,我们可以将一个Item作为父,一个作为子。
父Item负责渲染模糊的填充区域,而子Item的形状和父Item相同,不过其strokeWidth小于父Item,且其不会进行模糊,子Item将其填充区域渲染为白色。这样子Item周围就都是模式的红色的区域。这样就模拟出了激光笔的效果。
但实际实验中,我发现:当鼠标快速移动时,激光笔所画的线“直线化”现象比普通笔严重很多。这大概是因为渲染模糊特效需要耗费一定的性能。
这是我们以点和点之间连直线,试图通过连接足够多的直线来模拟曲线这样的算法导致的问题。想要优化必须使用一些算法,比如:贝塞尔曲线。
但贝塞尔曲线如何填充区域化的问题,我并没有解决,这个任务放在白板的Item,Scene,View都基本完成后再进行解决。现在的话,能用就行。
或者利用0偏移的阴影效果,这样子甚至不需要子Item:
简单起见,这里我采用的是QGraphicsDropShadowEffect
来实现激光笔的阴影效果,而不是QGraphicsBlurEffect
模糊父Item然后添加一个白色的子Item。
对于定时消失的功能,我现在的想法是,专门开一个子线程,然后子线程负责计时。设计的要点如下:
- 如果激光笔在Release写完之后的 T 时间内如果没有触发Press事件,那么就会将这些激光笔Item删除;
- 如果激光笔在Release写完之后的 T 时间内再次触发Press事件,那么就重置这些这个计时,将需要被删除的激光笔Item记录下来;
- 因为激光笔突然消失对用户可能不太友好,所以我们在计时的最后几秒,逐渐让这些激光笔Item透明度逐渐下降,最后再删除,以实现一种激光笔Item消失的动画效果。
橡皮擦功能
由于我在Item层次提供的设计方案中,Item的碰撞检测方案我就默认采用QGraphicsItem中实现的方案了。
也就是:
- 碰撞模式为:
Qt::IntersectsItemShape
。 - 碰撞检测的一些方法采用默认实现,如
QGraphicsItem::collidesWithItem()
。
那么我们需要做的,仅仅是提供一个strokePath
,注意这里的strokePath
必须是在Item的本地坐标系中的。
注意,在更新
strokePath
的时候需要调用prepareGeometry()
来通知Scene:本Item的形状发生改变了,你更新一下。不然,Scene是认为你前面是什么样后面也是什么样,而不管你中间发生了什么变化。这就会导致碰撞检测出现偏差。
这个问题Debug了我一个小时,我说怎么线段的前半段可以检测碰撞而后半段就无法检测碰撞。最终是在重新查看QGraphicsItem的一些特性时回想起了这个特性。
我想要为橡皮擦提供两种形态:
- 只擦除和橡皮擦碰撞的笔画部分,这部分由于我们Item的设计为此打下了基础,所以实现起来的思路是:碰撞检测出某个Item与橡皮擦发生碰撞,然后Item将碰撞分裂产生的新笔画的QPainterPath形态返回,我们将原始笔画删除,然后添加所有分裂的新笔画到Scene中。这其实就是EraseItemCommand的实现思路。
- 擦除与橡皮擦碰撞的笔画的全部部分,这个其实scene中的方法有提供碰撞检测,你通过该方法拿到与橡皮擦碰撞的Item们之后,对它们使用DeleteItemCommand即可。
具体如何进行操作读者大可自行发挥。
源码地址
这里给出源码的地址,感兴趣的读者可以自行研究。
总结
预估代码需要1500行。
事后诸葛亮
事后总结UML图:
在实际实现Scene层次的一些功能的时候,我发现之前所做的许多设计其实并不够准确。
因为这些不准确,考虑的不周到,所以导致代码结构历经了几次改动。
下面的就说明一下我在实现这个Scene层次的时候遇到的几个问题。
1. Item的内存释放
在刚开始实现的时候,我使用原始指针来对Item进行内存管理。
在前面的设计中,我说明了添加和删除Item我都会通过AddItemCommand
、DeleteItemCommand
这些类对象来完成,这些Command对象最终会被添加到UndoStack
中,其释放时机由QUndoStack
来决定。
QUndoStack对于Item的释放是通过调用Command的析构函数来实现的。
我们使用撤销功能的时候,其普遍有这么一种特性:当你撤销很多次操作后,新进行了一次操作,那么你之前撤销的操作就会被舍弃。对于QUndoStack来说撤销的Command会被析构。
这样会带来Item的内存释放问题。比如:假如一个Item先被添加到UndoStack
中,此时该Item被一个AddItemCommand
以原始指针的形式引用。然后这个Item又被删除,此时该Item被一个DeleteItemCommand
引用。
DeleteItemCommand
和AddItemCommand
都会在析构函数中释放它们所引用的Item。那么,如果我们对上述的AddItemCommand
和DeleteItemCommand
进行了撤销操作,结果会如何?答案是:AddItemCommand
无法知道其引用的Item是否被释放。这就出现了内存管理上的错误。
解决方法也很简单:使用智能指针来引用Item,就不会出现引用错误的情况了。
但真的那么简单吗?事实告诉我,内存的管理仍旧出现了错误!那这是什么原因导致的呢?
经过我的Debug排查,我发现这样的现象:
- 我们在创建某个Item的时候使用智能指针对该Item进行封装,然后交给
AddItemCommand
进行处理。 - Command最终将该Item加入
WhiteBoardScene
,但其加入的方式是:QGraphicsScene::addItem(QGraphicsItem*)
。 - 我们从
WhiteBoardScene
中取出碰撞的该Item,拿出来的是一个:QList<QGraphicsItem*>
。然后我们使用DeleteItemCommand
将其删除
可以发现,由于我们将BaseGraphicsItem*
通过QGraphicsScene
的方法获取出来的只能是一个原始指针,它无法继承我们创建Item时的智能指针的引用计数。
解决这个问题的关键在于:如何让获得原始指针BaseGraphicsItem*
的地方通过某种手段得知“已经被创建且被某个Command以智能指针的形式保存的Item的引用计数”。简单来说就是:我要通过QGraphicsScene
返回的QGraphicsItem*
(可以用dynamic_cast
转换成BaseGraphicsItem*
)获得Item创建时的智能指针。以此保证对于一个Item的引用计数是正确的。
为了完成这个目的,我添加了如下的东西:
-
QMap<BaseGraphicsItem*, QWeakPointer<BaseGraphicsItem>> m_existItemMap
。用来保存从原始指针到weakptr的映射。weakptr指向了原始指针指向的对象,但它并不增加引用计数,但它知道所指对象的引用计数,并且可以转换成sharedptr增加引用计数。这里使用weakptr是为了:如果在查找某个Item的过程中weakptr发现所指对象不存在了(说明该对象所有引用其的Command已经被析构,已经不再被需要),那么就将该键值对删除(如果使用SharedPointer,那么Item可以会和Scene一样长命)。由于查找不再被需要的Item的情况几乎不存在,所以可能会导致内存的浪费,因为键值对还在那里占用空间。我现在才想起来需要解决这个问题,现在的大致想法是:让Item在析构时发出信号,然后在Scene中接收这个信号,然后在map中删除与它相关的映射。
-
void addItem(QSharedPointer<BaseGraphicsItem>)
由于我们必须要保证添加新Item时,m_existItemMap
必须要记录下这个Item的智能指针,以备后续的通知其他Command:你想要使用的Item已经被引用了。这相当于是一个addItem(QGraphicsItem*)
的一个转发站。 -
QSharedPointer<BaseGraphicsItem> getItem(BaseGraphicsItem*);
为了获取方便所以使用这个方法。
我们做出一下规定:
- 创建的Item必须使用智能指针来管理。
- 必须通过
addItem(QSharedPointer)
将Item添加到Scene中,以确保所有在Scene中的Item都可以被正确地引用。 - 获取Item时必须使用
getItem
方法。
通过上述规定,才真正解决了关于Command对Item的错误释放问题。
经验总结
Qt本质还是C++,虽然QWidget
这些东西可以通过父子关系来管理器释放,但是有些业务功能不能利用这种便利。那么在内存管理的时候,我们还是要善用智能指针
2. 事件处理集成在Scene中
在一开始的设计中,工具类对象只是拥有着工具的相关参数,然后其所有功能的处理都放在了Scene中。
比如,对于一个mousePressEvent()
,普通笔、荧光笔、激光笔、橡皮擦等都会有不同的事件,于是我就在Scene中添加了这样的方法:normalPen_press()
highlightPen_press()
laserPen_press()
。然后在mousePressEvent()
通过预先定义好的枚举变量(该变量标识现在使用的工具),决定不同的事件处理逻辑。
同理于mouseMoveEvent()
和mouseReleaseEvent()
。
这样做非常不利于功能的扩展,当我想要实现一个图形笔(专门用来画矩形、椭圆等图形的笔),你需要做什么:
- 定义一个保存图形笔相关参数的类。
- 在Scene中初始化它。
- 在Scene中标识工具类型的枚举变量中标识它的枚举量。
- 在Scene中图形笔添加处理press、move、release事件的函数。
- 在scene的
mousePressEvent()
mouseMoveEvent()
mouseReleaseEvent()
中,添加根据图形笔枚举量转发具体实现的代码逻辑。
这一串操作下来,我发现这违背了“开闭原则”。如果我们后续还要扩展多个功能,我可不想重新回来改这么一大堆东西。
因此,我最终做出了这样一种实现:
工具引用Scene,然后利用Scene所给的一些信息进行事件的处理。
这样的话,Scene只需要保存一个指向WhiteBoardAbstractTool
的指针,在mouseMoveEvent()
中可以使用多态来定位具体的实现。如果想要切换工具,就是将工具的实现进行替换。这算是“策略模式”,切换工具其实就是在切换不同的事件处理策略,至少我是这么认为的。
通过这种设计,当我们添加一种新Item的时候,我们需要在Scene中完成一件事:
- 添加一个切换到该工具的方法
useNewTool()
。将原本的的工具实现替换掉。
这相比于原来的一套复杂流程可简单太多了。
经验总结
由于一开始并没有对事件的处理和工具类实现进行一定的设计,只想着快速开启编码。但没有一点设计的结果就是:想怎么写怎么写,只想快速完成功能,而不是从结构方向上考虑,导致了违背了开闭原则。所以,不要忽视任何一个你认为不重要的地方。
对于过长笔画的处理
当我一笔画10个相互重叠的圈时,QPainterPath会出现错误,导致绘图的极度不准确。但并不会导致程序停止。我猜测这是QPainterPath
没办法处理太过于复杂的重叠情况,所以导致它罢工了。
对比开源的白板软件OpenBoard,它的情况跟本项目的差别不大。
但是,当我将自己的白板软件和华为平板上自带的笔记软件对比时,我发现华为平板需要足足70圈才会导致程序崩溃。
因为10多圈是很容易被用户画出来的,而70圈则不然,这是一个巨大进步,让我不禁想:还有什么更优的解决方案吗?
或许由圆点Polygon组成线的解决方案会更加好?
答案我还不知道,等到将来我找到了这个方法,我会发博客说明自己的思路的。