BallMove:基于Qt的GUI小项目
简介
在暑假学科目二的时候,看着 Qt学习之路2 学了Qt的绘制系统,做这个项目的初心就是用来巩固学到的Qt绘制系统。
今天写下这篇博客,是为了记录下自己在这个项目中学到的东西,方便自己以后使用Qt的时候能够快速上手。
这个项目基于QGraphicsScene + QGraphicsItem + QGraphicsView
, 模拟了一个小球在现实世界中的运动。
项目地址:cfla1638/BallMove
设定
功能:实现了实心球的运动。
考虑的因素有:
- 用户给予的加速度
- 重力
- 摩擦力
- 空气阻力
细节:
该项目模拟了一个半径0.04米的实心钢铁球,其密度为 7850 kg / m^3^ 。
重力加速度为 9.8 m/s^2^ 。
静摩擦系数为 0.3。
空气阻力使用公式:F = 1/2 CρSv^2^ ,其中C取0.3,ρ取1.293,S取 3.14 0.04^2^ 。
尺寸换算:1m = 250px。
基础知识
Qt的绘制系统由
QGraphicsScene + QGraphicsItem + QGraphicsView
相互配合使用。QGraphicsScene
提供一个场景(Scene),所有要显示的实体都可以放到这个场景中。QGraphicsItem
是在QGraphicsScene
中实体对象类的父类,在本项目中的实体类都要继承这个类。继承这个类的实体可以被添加到QGraphicsScene
,进行显示和管理。QGraphicsView
提供了一个观察的视角,配合QGraphicsScene
进行显示。这个类可以被设为QMainWindow
的CentralWidget
。只有
QObject
的子类可以使用信号槽的机制QTimer
类提供计时器功能,使用timer.start(sec)
功能开始计时,这里timer是一个QTimer
类的一个对象,每经过sec时间后,timer就会发出一个timeout()
信号。想要实现物体的运动,就要让物体的位置在每一帧里进行改变/刷新(本程序的帧率是120),而
QGraphicsScene
提供一个advance()
函数,该函数会调用所有在场景里的QGraphicsItem
对象的advance()
函数,因此我们只要每经过 1000 / 120 毫秒 就调用一次QGraphicsScene::advance()
函数,就可以进行场景刷新,从而实现物体的运动。每个
QGraphicsItem::advance(int phase)
都会被一个QGraphicsScene::advance()
调用两次。在第一次时,Item已经准备好刷新,此时传入的phase = 0。第二次QGraphicsScene::advance()
将phase = 1 传入函数并调用。基于此,我们只需要在phase = 1是进行处理,phase = 0 是我们什么也不做。否则我们程序的帧率会变成原来的二倍。
Qt 的坐标系统。在本程序中,我们用到了Qt的两套坐标。第一个是在
QGraphicsScene
中的全局坐标系,它记录了每个QGraphicsItem
的位置。第二个是每个QGraphicsItem
对象都会有的本地坐标系。在我们绘制每个部件时,我们就会基于本地坐标系绘制。每个继承了
QGraphicsItem
的类都要重载以下四个函数。1
2
3
4QRectF boundingRect() const;
QPainterPath shape() const;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr);
void advance(int phase);boundingRect()
返回一个包裹住QGraphicsItem
对象的矩形(比对象大一点或很多)。这个矩形使用的是全局坐标系,用于碰撞检测等功能。shape()
返回图型的准确形状。如对于我们的小球来说,这个函数返回一个圆轨迹。void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr)
该函数一般由
QGraphicsScene
自动调用,绘制该项目。这个函数一般这样写
1
2
3
4
5
6
7
8
9
10void Ball::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
painter->save(); // 保存画笔状态
//设置新的画笔状态
painter->setRenderHint(QPainter::Antialiasing); // 设置反走线(抗锯齿)
painter->fillPath(shape(), Qt::blue); // 绘制图型
painter->restore(); // 还原画笔状态
}我们要保证该函数调用过之后,画笔的状态不被改变,因此使用save和restore。
painter->fillPath(shape(), Qt::blue);
这一行绘制我们在shape()
中返回的图型,在此之前我们可以设置画笔的状态。编写头文件的建议
如果我们在头文件中#include 了另一个文件,那么我们的这个头文件就依赖另一个文件。如果我们依赖的文件发生改变,本文件也要重新编译。
为了减少编译时间,我们要减少头文件的依赖。为此我们使用前置声明法。
即,在头文件中,我们尽量使用需要使用到的类的指针,并将此类的声明写在头文件前,这样我们就不需要在头文件中include 这个类,也就减少了头文件的依赖。
项目设计
首先来看一下运行效果:
1 | 在这颗树中,一个类是另一个类的子树,就表示这个类是另一个类的私有对象 |
自下而上,首先来看Controller下的三个类,
Controller下有三个类,分别是Ball, Balldata, Ground,他们都是QGraphicsItem
的子类,分别对应运行结果中的蓝色小球,上面绿色的信息显示和下面黑色的地面。
这三个类定义了自身的形状和他们的运动逻辑。以Ball为例,Ball类有四个上述提到的函数,用来绘制他的形状,还有记录它自身加速度和速度的私有类型。
值得一提的是,Controller类有一个引用,QGraphicsScene - Reference,它其实是MainWindow的私有对象,但由于Controller类需要经常使用这个对象,为了方便,我们就将它的引用放在了Controller类中。
接着是Controller类。
顾名思义,Controller类是我们程序的控制器。
Controller类可以控制程序的开始结束,控制界面的刷新,对Qt的时间进行处理。
最后我们看一下最外层的MainWindow类,他是程序的主窗口。它下面的QGraphicsScene和QGraphicsView 分别是画布和观察窗口。
当程序开始执行时,首先执行MainWindow类的构造函数,分别构造QGraphicsScene,QGraphicsView和Controller。在Controller类进行构造时,Controller下的物体就被添加到了Scene中,程序也就开始运行了。当MainWindow下的对象都构造完成时。main.cpp 的QMainWindow::show()
和 QApplication :: exec()
函数分别将程序主窗口显示,并且开始程序的事件循环,接受事件,交给Controller进行处理。
项目实现
文件结构
首先看一下我们的文件结构
1 | BallMove |
Ball类的实现
首先看一下ball类的实现
1 | class Ball : public QGraphicsItem |
首先我们来看Ball类的私有变量:
由于我们运动的平面是二维的,因此我们使用一个二维数组qreal a[2][4]
来存储加速度。其中qreal就是double类型。如果我们想要访问小球x方向的空气阻力产生的加速度,我们可以使用在constants.h 中定义的枚举类型,即a[xAxis][AirResistance]
。如果想要引用y方向的重力加速度,就使用a[yAxis][Gravity]
。
qreal vx, vy
即小球的x方向速度,和y方向的速度。
int forceCount
即我们考虑的力的数目,这个变量用于将这些力合成的时候进行计数。
接着我们来看一下Ball类中声明的函数:
第一个函数getPos()
很好理解,即返回小球在全局坐标系中的坐标。
接着我们仔细考察一下之前我们在基础知识部分介绍的四个函数:paint() shape() advance()
boundingRect()
boundingRect
:
1 | QRectF Ball::boundingRect() const |
QRectF类即矩形类,这个函数返回了一个包裹小球矩形。需要注意的是,这个矩形的坐标是在本地坐标系的坐标。
BALL_SIZE
定义在constants.h 代表小球的直径。
shape()
:
1 | QPainterPath Ball::shape() const |
这个函数返回了一个绘画轨迹类QPainterPath,并在里面添加了一个圆形轨迹。需要注意的是,这个坐标也是在小球本地坐标系中的。
paint()
:
1 | void Ball::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) |
在第五行设置了画笔的抗锯齿,紧接着下一行绘制了小球的准确形状。
advance()
:
1 | void Ball::advance(int phase) |
由于我们设置了帧率为120帧,因此这个函数在每秒钟会被调用120次。
在这个函数里,我们依次计算了小球受到的合力,改变了小球的速度,根据小球的速度对小球进行了移动。
需要注意,一、因为我们的速度、加速度定义为m/s和m/s^2^。 因此我们在更新速度和位置时,要将速度和加速度除以帧率,这样经过120次调用,速度和加速度才改变了1s的量。
二、在设置了小球的位置之后,需要使用update()
函数更新小球的位置。
constants.h 参数存储文件
constants.h 文件中存放了项目的各种参数
1 | const int FRAME_RATE = 120; // 帧率:120帧 帧率过低时碰撞会穿透地面 |
需要注意是两个枚举类型,通过定义枚举类型,可以使用枚举变量引用数组元素,可读性更好。
Balldata & ground
这两个类都继承了QGraphicsItem
和Ball类类似,因此我们不在赘述。
Controller类
首先看一下Controller类的定义
1 | class Controller : QObject |
Controller类有以下几点功能:
处理键盘事件:如上下左右移动,打开/关闭 轨迹显示,清屏。
相关地函数有:
1
2
3bool eventFilter(QObject *atched, QEvent *event);
void handleKeyPressed(QKeyEvent * event);
void handleKeyReleased(QKeyEvent * event);控制程序的开始与结束
相关地函数有:
1
2void resume();
void pause();控制程序的一步步地推进,即让小球动起来。
相关地函数有:
1
void advance();
其他功能
相关地函数有:
1
2
3void track(bool);
void gravity();
void drawLineOfTrack();
在这里我们只介绍advance()
函数
1 | void Controller::advance() |
由于每次更新,我们都要对碰撞进行检测,因此我们首先调用场景地advance函数。
等运动结束之后,我们使用Qt地碰撞检测函数collidesWithItem()
看小球是否接触地面。
接着我们根据碰撞检测地结果,依次处理摩擦力和空气阻力。
其实我们还可以将处理各种力地程序写到小球地advance()中,但为了让我们地程序更符合controller控的概念,我们为controller也添加了advance()函数,由它调用scene的advance()函数。
MainWindow 类
MainWindow类的代码如下
1 | class MainWindow : public QMainWindow |
唯一需要解释的函数是它的构造函数,其他的函数都会在构造函数里被调用。
1 | MainWindow::MainWindow(QWidget *parent) |
在构造函数中,我们首先初始化了scene、view和controller,紧接着我们将view设为了窗口的centralWidget
并调整了窗口大小。
在倒数第二步,调用initScene
对scene进行调整。
以上的这些步骤都不会有什么问题,只有最后一步令人困惑:
QTimer::singleShot(0, this, SLOT(adjustViewSize()));
首先解释singleShot()
函数,这个函数的声明如下
void QTimer::singleShot(int msec, const QObject *receiver, const char *member)
它的含义是在msec 毫秒后调用receiver的槽函数member,这是一个很方便的函数,这样你就可以在不去手动计时的情况下达到同样的效果。
那么为什么要使用这个功能呢?
这里有两个解释
那么,
QTimer::signleShot(0, ...)
意思是,在下一次事件循环开始时,立刻调用指定的槽函数。在我们的例子中,我们需要在视图绘制完毕后才去改变大小(视图绘制当然是在paintEvent()
事件中),因此我们需要在下一次事件循环中调用adjustViewSize()
函数。这就是为什么我们需要用QTimer
而不是直接调用adjustViewSize()
。如果熟悉 flash,这相当于 flash 里面的callLater()
函数。
这个解释来自《Qt学习之路2》 但我并不清楚这里的 “下一次事件循环” 的意思。
依我的理解,应该是这个意思:某个QObject
发出 paintEvent()
事件,但对这个事件的处理比较耗时,如果此时直接调用adjustViewSize()
,视图还没有绘制完成,自然就不能起到调节视图的效果。事实也是这样,如果直接调用adjustViewSize()
,我们会发现视图小小的挤在屏幕的中间。
后来我又在网上发现了这篇博客:QTimer::singleShot(0, this, slot函数); ,意思和我理解的大概相似。
但还是挖个坑,等我以后学精了一定回来解释清楚。