Qt中利用可部分擦除的Item在Scene中搭建白板的画笔、橡皮擦等功能的设计思路

前言

本篇文章建立在我之前所写的可部分擦除Item的抽象概念解析

意在真正利用这个思路完成一个可以使用的白板软件。

这里介绍的是,我们如何在一个GraphicsScene中,完成我们所想要的:画线、橡皮擦、荧光笔等功能的实现。

基于我所使用过的白板笔记软件,我还为其提供了背景(这个背景可以是横线、也可以是一个PDF页面),方便我们后续进行一些功能的扩展。

同时,我也基本完成了Scene中撤销、重做功能的设计。

这里说明一下,如何将一个或多个Scene以PDF阅读器那样的格式展示出来,是View层该关心的问题,View负责渲染和组织Scene,而Scene只需要管好Item。

Scene层次

Scene层次主要是检测事件,并且应该做到不同工具下,对某个事件进行不同的处理。

因此,工具集成类型集成于该层次,对不同工具触发的事件进行反应,让外界可以设置不同工具等等有关工具的内容都是在Scene中实现的。

Scene层次功能实现演示

工具的实现

对于钢笔、荧光笔、激光笔、橡皮擦这些工具,首先我们需要有一个变量标识当前的Scene正在使用什么工具。

Scene在处理某些事件的时候,会根据不同的工具,完成不同的操作,这些不同的操作中,对应钢笔的操作需要使用到钢笔的属性、对应荧光笔的操作需要用到一些荧光笔的属性。因此,我们需要将钢笔、荧光笔、激光笔、橡皮擦这些工具的属性封装起来,并且这些属性能够被记录下来,还能够在打开软件的时候读取这些记录下来的属性,所以关键在于:

  1. 将工具的属性封装成类对象。
  2. 类对象的属性能够保存在用户本地。
  3. 保存在用户本地的类对象属性必须能够在打开白板时重新得到加载。

这里涉及到配置系统的实现,需要将应用程序的一些信息保存到本地中,并在应用程序在第一次安装使用时,提供默认的工具属性。

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

钢笔

利用lastPospos,结合press事件中创建的ContrlGroupObserver子类,完成现有path和新添加的path之间的融合。

荧光笔

曲线绘制模式:

利用lastPospos,结合press事件中创建的ContrlGroupObserver子类,完成现有path和新添加的path之间的融合。


直线绘制模式:

利用pressPos(press事件中记录的位置)和当前位置pos,画出一条直线填充区域图元BaseGraphicsItem,这个填充区域图元随着pos的变化而变化,但pressPos是不变的。

激光笔

利用lastPospos,结合press事件中创建的ContrlGroupObserver子类,完成现有path和新添加的path之间的融合。过了一段时间后,激光笔会消失在屏幕上,如果一定时间内再次进行激光笔按压、移动,那么消失的时间将会被重置,直至有一次倒计时完成,所有激光笔笔画都会释放。

橡皮擦

将press创建的图形项移动到pos。将被碰撞擦除的图形项收集起来。

mouseReleaseEvent()

鼠标的释放事件意味着图形项的形成结束了。

释放时,需要将新增的图元记录下来,同时产出一个Memento,记录RedoUndo以为后续的撤销和还原功能打下基础。Qt中有完成好的QUndoStackQUndoCommand给我们利用,可以继承QUndoCommand实现自己的Redo和Undo操作,然后让QUndoStack来进行管理。

钢笔

结束信息采集,将相应的变量重置。

荧光笔

结束信息采集,将相应的变量重置。

激光笔

结束信息采集,将相应的变量重置。

橡皮擦

将收集的被擦除了部分的图形项,进行分解使其分离的部分独立,而原来的图元将被删除。

  • Undo操作,将所以擦除涉及的图形项的状态(填充区域、颜色、以及各种QGraphicsItem的属性复制下来保存),重新创建一个新的BaseGraphicsItem与其对应,并将原来分裂的图形项全部从Scene中删除。
  • Redo操作,将因Undo而合并的图形项全部删除,然后将分裂的图形项重新添加到Scene中。

最后,将橡皮擦删除。

Scene的类层次结构

Scene的大致UML类图结构
完成了Scene层次的概要设计,明确了后续的开发方向:

  1. 完成Scene的背景BackgroundItem及其子类。

  2. 完成WhiteBoardPen WhiteBoardHighlightPen WhiteBoardLaserPen WhiteBoardEraser一系列工具的属性,为后续开发奠定基础。

  3. 完成QUndoCommand衍生出适合本项目的子类,为后续的事件逻辑处理打下基础。该方向涉及到和Item层次以及Scene的交互,所以需要慎重考虑其实现。这个过程也会逼着你去思考业务逻辑应该是怎样的,然后你才能在业务逻辑上嵌入这些Undo命令的创建。

    这里需要BaseGraphicsItem有产出自己状态的能力,具体如何设计需要借助Memento设计模式的思想。

  4. 完成WhiteBoardScene事件业务逻辑的开发。本阶段的任务最复杂,需要前期进行算法的详细考虑之后,再开始开发,防止准备不足而导致的频繁修改导致Scene结构的代码混乱。

在这过程中,需要对Item层次的设计进行修改和查漏补缺。

BackgroundItem的设计

在Scene中,BackgroundItem只有一个。

BackgroundItem具有背景颜色,而与这个BackgroundItemQImage还是QPainterPath无关。这个背景颜色是交由QGraphicsScene来渲染的,BackgroundItem只是指明了这个颜色,只要BackgroundItem被加入QGraphicsScene中,就会立即将Scene的颜色设置成自己所存储的颜色。包括后续Background的颜色发生变化,这种变化会同时反应在Scene上。

BackgroundItem的类层次
BackgroundItem的会拥有一个默认的矩形大小,而子类来决定是否更改这个大小。同时,在BackgroundItem被添加到Scene中的那一刻,BackgroundItem会立即将自身的大小反应到Scene上,让Scene刚好包括BackgroundItemBackgroundItem的大小通常在初始化时指定,后续不可再变。

  • PathBackgroundItem:在BackgroundItem的基础上,绘制一些基础的线,这些线可能是一条条平行线,也可能是纵横交错的线等等。
  • ImageBackgroundItem:在BackgroundItem的基础上,更改其大小为QImage的大小。

PathBackgroundItem类似于这种效果(我已经对这个东西进行了简单的搭建和测试):

BackgroundPathItem例子
ImageBackgroundItem类似于这种效果,它随着背景图片的不同而不同,这完全取决于你将它指定为什么:

BackgroundImageItem例子

简陋的工具类设计

由于白板的工具只需要提供属性而不需要提供相关的方法,所以这里简单封装了对应工具的属性到结构体中:

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呢?我做出如下分析:

  1. undo操作将Item从Scene中移除后,将该Item析构掉。这样的话,redo操作如果想要添加原有的Item到Scene中,command就必须事先记录下来Item的状态(通常是使用Memento模式,让Item自己产出Memento,然后利用该Memento恢复原有的状态),然后redo利用该状态新创建一个Item。

    这种做法需要关心:undo之后但未redo时,程序结束或者QUndoStack舍弃了该Command,Item需要在析构函数中释放。

  2. 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与之前的DeleteItemCommandAddItemCommand一样,它为了方便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那样进行处理。

举一个例子可能比较好理解这个算法的思想:

以上图片中,黄色框对应EraseItemCommandundo和redo操作的整体单位。

一开始,原始Item被橡皮擦碰撞,然后分裂成两个Item添加到Scene中,自己则被从Scene中删除。这里的添加和删除采用AddItemCommandDeleteItemCommand

我们EraseItemCommand::undo()要做什么?分裂Item1分裂Item2对应的AddItemCommand::undo()需要被调用,而原始Item对应的DeleteItemCommand::undo()需要被调用。

我们EraseItemCommand::redo()要做什么?分裂Item1分裂Item2对应的AddItemCommand::redo()需要被调用,而原始Item对应的DeleteItemCommand::redo()需要被调用。

从而实现整体的undoredo

当原始Item分裂成其他Item时,如果在橡皮擦的擦除周期(press-move-release)中分裂的Item也被擦除,就会有多个EraseItemCommand产生。如上图片所示,一个原始的Item最终被分裂成了Item1-1 Item1-2 Item2-1 Item2-2,并产生了三个EraseItemCommand。此时,我们想要将Scene的状态还原成擦除周期发生之前,该如何做?

单纯使用最顶层的EraseItemCommand::undo()肯定会导致错误,因为Item1Item2已经因为擦除分裂而从Scene中移除了,AddItemCommand::undo()无法从Scene中移除Item1Item2。如果想要执行最顶层的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。

对于定时消失的功能,我现在的想法是,专门开一个子线程,然后子线程负责计时。设计的要点如下:

  1. 如果激光笔在Release写完之后的 T 时间内如果没有触发Press事件,那么就会将这些激光笔Item删除;
  2. 如果激光笔在Release写完之后的 T 时间内再次触发Press事件,那么就重置这些这个计时,将需要被删除的激光笔Item记录下来;
  3. 因为激光笔突然消失对用户可能不太友好,所以我们在计时的最后几秒,逐渐让这些激光笔Item透明度逐渐下降,最后再删除,以实现一种激光笔Item消失的动画效果。
橡皮擦功能

由于我在Item层次提供的设计方案中,Item的碰撞检测方案我就默认采用QGraphicsItem中实现的方案了。

也就是:

  1. 碰撞模式为:Qt::IntersectsItemShape
  2. 碰撞检测的一些方法采用默认实现,如QGraphicsItem::collidesWithItem()

那么我们需要做的,仅仅是提供一个strokePath,注意这里的strokePath必须是在Item的本地坐标系中的。

注意,在更新strokePath的时候需要调用prepareGeometry()来通知Scene:本Item的形状发生改变了,你更新一下。不然,Scene是认为你前面是什么样后面也是什么样,而不管你中间发生了什么变化。这就会导致碰撞检测出现偏差。
这个问题Debug了我一个小时,我说怎么线段的前半段可以检测碰撞而后半段就无法检测碰撞。最终是在重新查看QGraphicsItem的一些特性时回想起了这个特性。

我想要为橡皮擦提供两种形态:

  1. 只擦除和橡皮擦碰撞的笔画部分,这部分由于我们Item的设计为此打下了基础,所以实现起来的思路是:碰撞检测出某个Item与橡皮擦发生碰撞,然后Item将碰撞分裂产生的新笔画的QPainterPath形态返回,我们将原始笔画删除,然后添加所有分裂的新笔画到Scene中。这其实就是EraseItemCommand的实现思路。
  2. 擦除与橡皮擦碰撞的笔画的全部部分,这个其实scene中的方法有提供碰撞检测,你通过该方法拿到与橡皮擦碰撞的Item们之后,对它们使用DeleteItemCommand即可。

具体如何进行操作读者大可自行发挥。

源码地址

这里给出源码的地址,感兴趣的读者可以自行研究。

白板的GitHub代码

总结

预估代码需要1500行。

事后诸葛亮

事后总结UML图:
Scene实现中提取出来的UML类图
在实际实现Scene层次的一些功能的时候,我发现之前所做的许多设计其实并不够准确。

因为这些不准确,考虑的不周到,所以导致代码结构历经了几次改动。

下面的就说明一下我在实现这个Scene层次的时候遇到的几个问题。

1. Item的内存释放

在刚开始实现的时候,我使用原始指针来对Item进行内存管理。

在前面的设计中,我说明了添加和删除Item我都会通过AddItemCommandDeleteItemCommand这些类对象来完成,这些Command对象最终会被添加到UndoStack中,其释放时机由QUndoStack来决定。

QUndoStack对于Item的释放是通过调用Command的析构函数来实现的。

我们使用撤销功能的时候,其普遍有这么一种特性:当你撤销很多次操作后,新进行了一次操作,那么你之前撤销的操作就会被舍弃。对于QUndoStack来说撤销的Command会被析构。

这样会带来Item的内存释放问题。比如:假如一个Item先被添加到UndoStack中,此时该Item被一个AddItemCommand以原始指针的形式引用。然后这个Item又被删除,此时该Item被一个DeleteItemCommand引用。

DeleteItemCommandAddItemCommand都会在析构函数中释放它们所引用的Item。那么,如果我们对上述的AddItemCommandDeleteItemCommand进行了撤销操作,结果会如何?答案是:AddItemCommand无法知道其引用的Item是否被释放。这就出现了内存管理上的错误。

解决方法也很简单:使用智能指针来引用Item,就不会出现引用错误的情况了。

但真的那么简单吗?事实告诉我,内存的管理仍旧出现了错误!那这是什么原因导致的呢?

经过我的Debug排查,我发现这样的现象:

  1. 我们在创建某个Item的时候使用智能指针对该Item进行封装,然后交给AddItemCommand进行处理。
  2. Command最终将该Item加入WhiteBoardScene,但其加入的方式是:QGraphicsScene::addItem(QGraphicsItem*)
  3. 我们从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*);为了获取方便所以使用这个方法。

我们做出一下规定:

  1. 创建的Item必须使用智能指针来管理。
  2. 必须通过addItem(QSharedPointer)将Item添加到Scene中,以确保所有在Scene中的Item都可以被正确地引用。
  3. 获取Item时必须使用getItem方法。

通过上述规定,才真正解决了关于Command对Item的错误释放问题。

经验总结

Qt本质还是C++,虽然QWidget这些东西可以通过父子关系来管理器释放,但是有些业务功能不能利用这种便利。那么在内存管理的时候,我们还是要善用智能指针

2. 事件处理集成在Scene中

在一开始的设计中,工具类对象只是拥有着工具的相关参数,然后其所有功能的处理都放在了Scene中。

比如,对于一个mousePressEvent(),普通笔、荧光笔、激光笔、橡皮擦等都会有不同的事件,于是我就在Scene中添加了这样的方法:normalPen_press() highlightPen_press() laserPen_press()。然后在mousePressEvent()通过预先定义好的枚举变量(该变量标识现在使用的工具),决定不同的事件处理逻辑。

同理于mouseMoveEvent()mouseReleaseEvent()

这样做非常不利于功能的扩展,当我想要实现一个图形笔(专门用来画矩形、椭圆等图形的笔),你需要做什么:

  1. 定义一个保存图形笔相关参数的类。
  2. 在Scene中初始化它。
  3. 在Scene中标识工具类型的枚举变量中标识它的枚举量。
  4. 在Scene中图形笔添加处理press、move、release事件的函数。
  5. 在scene的mousePressEvent() mouseMoveEvent() mouseReleaseEvent()中,添加根据图形笔枚举量转发具体实现的代码逻辑。

这一串操作下来,我发现这违背了“开闭原则”。如果我们后续还要扩展多个功能,我可不想重新回来改这么一大堆东西。

因此,我最终做出了这样一种实现:

工具类的层次结构
工具引用Scene,然后利用Scene所给的一些信息进行事件的处理。

这样的话,Scene只需要保存一个指向WhiteBoardAbstractTool的指针,在mouseMoveEvent()中可以使用多态来定位具体的实现。如果想要切换工具,就是将工具的实现进行替换。这算是“策略模式”,切换工具其实就是在切换不同的事件处理策略,至少我是这么认为的。

通过这种设计,当我们添加一种新Item的时候,我们需要在Scene中完成一件事:

  • 添加一个切换到该工具的方法useNewTool()。将原本的的工具实现替换掉。

这相比于原来的一套复杂流程可简单太多了。

经验总结

由于一开始并没有对事件的处理和工具类实现进行一定的设计,只想着快速开启编码。但没有一点设计的结果就是:想怎么写怎么写,只想快速完成功能,而不是从结构方向上考虑,导致了违背了开闭原则。所以,不要忽视任何一个你认为不重要的地方。

对于过长笔画的处理

当我一笔画10个相互重叠的圈时,QPainterPath会出现错误,导致绘图的极度不准确。但并不会导致程序停止。我猜测这是QPainterPath没办法处理太过于复杂的重叠情况,所以导致它罢工了。

对比开源的白板软件OpenBoard,它的情况跟本项目的差别不大。

但是,当我将自己的白板软件和华为平板上自带的笔记软件对比时,我发现华为平板需要足足70圈才会导致程序崩溃。

因为10多圈是很容易被用户画出来的,而70圈则不然,这是一个巨大进步,让我不禁想:还有什么更优的解决方案吗?

或许由圆点Polygon组成线的解决方案会更加好?

答案我还不知道,等到将来我找到了这个方法,我会发博客说明自己的思路的。

评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值