下面以Mandelbrot 为例,看看如何用Qt实现多线程编程的。Mandelbrot展示了如何用工人线程执行繁重的计算工作而不阻塞主线程的事件循环。
这里大量的计算工作是Mandelbrot集合,它可能是世界上最著名的fractal(不规则碎片形体)。
现实中,这里描述的方法可以应用于大量的问题,包括同步网络I/O和数据库访问,在这种情况下,当执行一些繁重的操作时用户界面必须保持响应的状态。
这个应用程序支持缩放和使用鼠标或者键盘的滚动操作。为了避免冻结主线成的事件循环(一个后果就是应用程序的界面被冻结),我们将所有的fractal的计算工作放在一个单独的线程中。当它完成呈现fractal时,这个线程发送一个信号。
在工人线程计算fractal来反应新的缩放因子的期间,主线程简单的依据比例缩放前一个被渲染的图像来提供及时的反馈。这个结果看上去并不像工人线程最终提供的结果那样好,但是这至少让应用程序反应更迅速。下面一系列截图展示了原始图片、缩放的图片、以及被渲染后的图片。
相似的,当用户滚动界面时,前一个pixmap被迅速的滚动起来,当工人线程给图片渲染时,在pixmap的边界外显示未着色的区域。
这个应用程序由两个类组成:
1. RenderThread ,它是QThread的一个子类,负责渲染Mandelbrot集合。
2. MandelbrotWidget,它是QWidget的一个子类,负责显示Mandelbrot集合并允许用户缩放和滚动。
如果不了解Qt中的线程技术,建议先了解一下:Thread Support in Qt (在Qt帮助文档中,输入前面的关键字即可)。
RenderThread 类定义
我们就从RenderThread类的定义开始吧。
class RenderThread:public QThread
{
Q_OBJECT
public:
RenderThread(QObject *parent = 0);
~RenderThread();
void render(double centerX, double centerY, double scaleFactor, QSize resultSize);
signals:
void renderedImage(const QImage &image, double scaleFactor);
protected:
void run();
private:
uint rgbWaveLength(double wave);
QMutex mutex;
QWaitCondition condition;
double centerX;
double centerY;
double scaleFactor;
QSize resultSize;
bool restart;
bool abort;
enum{ColormapSize = 512};
uint colormap[ColormapSize];
};
这个类继承了QThread,因此它有能力去运行一个独立的线程。除了构造函数和析构函数,render()是唯一的公开的函数。无论这个线程何时完成对图片的渲染,它都会发送renderedImage()信号。
受保护的run()函数被重新实现。线程开启时它会被自动调用。
在私有区域,有一个QMutex,一个QWaitCondition和一些其他的数据成员。mutex保护着其他数据成员。
RenderThread 类的实现
RenderThread::RenderThread(QObject *parent)
: QThread(parent)
{
restart = false;
abort = false;
for(int i=0; i<ColormapSize; ++i)
{
colormap[i] = rgbFromWaveLength(380.0 + (i*400.0/ColormapSize));
}
}
在构造函数中,我们将restart和abort都初始化为false,这些变量控制着run()函数。
我们同样初始化colormap数组,这个数组包含一系列RGB颜色。
RenderThread::~RenderThread()
{
mutex.lock();
abort = true;
condition.wakeOne();
mutex.unlock();
wait();
}
当线程是活跃(active)状体时,析构函数可以在任何地方被调用。我们将abort设置为true,就是是告诉run() 函数尽快停止运行。我们也可以调用QWaitCondition::wakeOne()来唤醒沉睡中的线程。(当线程无事可做时进入睡眠状态)
在此着重注意 run() 函数是在它自己的线程中执行的(即工人线程),而 RenderThread 构造函数和析构函数(也包括 render() )被创建了工人线程的线程调用。因此,我们需要一个互斥量来保护那些访问 abort 和 condition变量的操作,但是 abort 和 condition变量可以随时被 run() 函数访问。
在析构函数的最后,我们调用 QThread::wait() 函数来等待,直到 run() 函数在父类的析构函数被调用前退出。
void RenderThread::render(double centerX, double centerY, double scaleFactor, QSize resultSize)
{
QMutexLocker locker(&mutex);
this->centerX = centerX;
this->scaleFactor = scaleFactor;
this->resultSize = resultSize;
if (!isRunning()) {
start(LowPriority);
} else {
restart = true;
condition.wakeOne();
}
}
render() 函数被 MandelbrotWidget 调用,只要是在它需要产生一个新的Mandelbrot集合的图片的时候。centerX,centerY,以及 scaleFactor 参数指定了 fractal 的部分参数,resultSize 指定了产生的QImage。
这个函数将参数存储在成员变量中。如果线程没有运行,这个函数将会开启线程,否则,它会将restart赋值为true(即告诉 run() 函数停下任何未完成的计算,并且使用新参数重新开启线程)并唤醒线程,因为线程有可能在睡眠状态。
void RenderThread::run()
{
forever{
mutex.lock();
QSize resultSize = this->resultSize;
double scaleFactor = this->scaleFactor;
double centerX = this->centerX;
double centerY = this->centerY;
mutex.unlock();
run() 函数比较长,所以将其打成几段来解读。
函数体是个无限循环,开始将渲染参数存储在局部变量中。通常,我们将会保护对成员变量的访问,所以使用了mutex。将成员变量的值存储在局部变量中可以最小化受mutex保护的代码量。当住线程需要访问 RenderThread 的成员变量时(例如,在 render() 中),这保证了主线程永远不会阻塞太久。
永远的关键字,如 foreach, Qt pseudo-keyword。
int halfWidth = resultSize.width() / 2;
int halfHeight = resultSize.height() / 2;
QImage image(resultSize, QImage::Format_RGB32);
const int NumPasses = 8;
int pass = 0;
while (pass < NumPasses) {
const int MaxIterations = (1 << (2 * pass + 6)) + 32;
const int Limit = 4;
bool allBlack = true;
for (int y = -halfHeight; y < halfHeight; ++y) {
if (restart)
break;
if (abort)
return;
uint *scanLine =
reinterpret_cast<uint *>(image.scanLine(y + halfHeight));
double ay = centerY + (y * scaleFactor);
for (int x = -halfWidth; x < halfWidth; ++x) {
double ax = centerX + (x * scaleFactor);
double a1 = ax;
double b1 = ay;
int numIterations = 0;
do {
++numIterations;
double a2 = (a1 * a1) - (b1 * b1) + ax;
double b2 = (2 * a1 * b1) + ay;
if ((a2 * a2) + (b2 * b2) > Limit)
break;
++numIterations;
a1 = (a2 * a2) - (b2 * b2) + ax;
b1 = (2 * a2 * b2) + ay;
if ((a1 * a1) + (b1 * b1) > Limit)
break;
} while (numIterations < MaxIterations);
if (numIterations < MaxIterations) {
*scanLine++ = colormap[numIterations % ColormapSize];
allBlack = false;
} else {
*scanLine++ = qRgb(0, 0, 0);
}
}
}
if (allBlack && pass == 0) {
pass = 4;
} else {
if (!restart)
emit renderedImage(image, scaleFactor);
++pass;
}
}
下面就到了核心算法。我们多次传值和产生更精确的近似fractal。
如果我们深入到循环内部发现 restart 已经被置为 true(通过 render() ),我们就立马停止循环,以使控制迅速返回到外循环的顶端(forever loop)并且获取新的渲染参数。相似的,如果我们发现 abort 被置为 true (通过 RenderThread 的析构函数)了,我们就立马从函数返回,终止线程。
核心算法超出了本教程的范围。
mutex.lock();
if (!restart)
condition.wait(&mutex);
restart = false;
mutex.unlock();
}
}
一旦我们完成了所有的迭代,如果 restart 不为true, 我们就调用 QWaitCondition::wait() 函数使线程进入睡眠状态。当没什么事可做的时候没必要使线程无限的循环。
uint RenderThread::rgbFromWaveLength(double wave)
{
double r = 0.0;
double g = 0.0;
double b = 0.0;
if (wave >= 380.0 && wave <= 440.0) {
r = -1.0 * (wave - 440.0) / (440.0 - 380.0);
b = 1.0;
} else if (wave >= 440.0 && wave <= 490.0) {
g = (wave - 440.0) / (490.0 - 440.0);
b = 1.0;
} else if (wave >= 490.0 && wave <= 510.0) {
g = 1.0;
b = -1.0 * (wave - 510.0) / (510.0 - 490.0);
} else if (wave >= 510.0 && wave <= 580.0) {
r = (wave - 510.0) / (580.0 - 510.0);
g = 1.0;
} else if (wave >= 580.0 && wave <= 645.0) {
r = 1.0;
g = -1.0 * (wave - 645.0) / (645.0 - 580.0);
} else if (wave >= 645.0 && wave <= 780.0) {
r = 1.0;
}
double s = 1.0;
if (wave > 700.0)
s = 0.3 + 0.7 * (780.0 - wave) / (780.0 - 700.0);
else if (wave < 420.0)
s = 0.3 + 0.7 * (wave - 380.0) / (420.0 - 380.0);
r = pow(r * s, 0.8);
g = pow(g * s, 0.8);
b = pow(b * s, 0.8);
return qRgb(int(r * 255), int(g * 255), int(b * 255));
}
rgbFromWaveLength() 函数是个帮助函数,它将一个波长转成一个兼容32位QImage 的 RGB值。它是在构造函数中被调用来初始化 colormap数组的。
MandelbrotWidget 类定义
MandelbrotWidget类使用RenderThread 类在屏幕上画 MandelbrotWidget 集合。下面是类定义:
class MandelbrotWidget : public QWidget
{
Q_OBJECT
public:
MandelbrotWidget(QWidget *parent = 0);
protected:
void paintEvent(QPaintEvent *event);
void resizeEvent(QResizeEvent *event);
void keyPressEvent(QKeyEvent *event);
#ifndef QT_NO_WHEELEVENT
void wheelEvent(QWheelEvent *event);
#endif
void mousePressEvent(QMouseEvent *event);
void mouseMoveEvent(QMouseEvent *event);
void mouseReleaseEvent(QMouseEvent *event);
private slots:
void updatePixmap(const QImage &image, double scaleFactor);
void zoom(double zoomFactor);
private:
void scroll(int deltaX, int deltaY);
RenderThread thread;
QPixmap pixmap;
QPoint pixmapOffset;
QPoint lastDragPos;
double centerX;
double centerY;
double pixmapScale;
double curScale;
};
这个部件实现了许多QWidget的事件。另外,它有一个 updatePixmap() 槽,我们将它与工人线程的 renderedImage() 信号连接,一旦有接收到新数据就更新界面。
在私有变量中,我们 RenderThread 类型的线程,pixmap,pixmap包含了最后被渲染的图片。
MandelbrotWidget 类实现
const double DefaultCenterX = -0.637011f;
const double DefaultCenterY = -0.0395159f;
const double DefaultScale = 0.00403897f;
const double ZoomInFactor = 0.8f;
const double ZoomOutFactor = 1 / ZoomInFactor;
const int ScrollStep = 20;
一开始定义了一些常量,后面有用到。
MandelbrotWidget::MandelbrotWidget(QWidget *parent)
: QWidget(parent)
{
centerX = DefaultCenterX;
centerY = DefaultCenterY;
pixmapScale = DefaultScale;
curScale = DefaultScale;
connect(&thread, SIGNAL(renderedImage(QImage,double)), this, SLOT(updatePixmap(QImage,double)));
setWindowTitle(tr("Mandelbrot"));
#ifndef QT_NO_CURSOR
setCursor(Qt::CrossCursor);
#endif
resize(550, 400);
}
构造函数中有趣的部分是调用qRegisterMetaType() 和 QObject::connect()。让我们从 connect() 开始吧。
尽管它看起来像一个标准的连接两个QObject的信号-槽连接器,因为信号是由其他的线程发送的,而不是接收者发送的,连接器是一个有效的队列。这些连接是异步的(如,non-blocking),意味着在某种程度上emit语句执行后将会调用槽。更重要的是,槽将会在接收者所在的线程中被调用。在这里,信号是有工人线程发送的,当控制回到事件循环中时槽是是GUI线程中执行的。
使用队列化连接,Qt必须存储传递给信号的参数的副本,这样信号就可以将这些参数传递给槽。Qt知道如何许多C++的副本和Qt类型,但是QImage例外。我们必须在使用 QImage 作为连接信号-槽的参数之前调用模板函数 qRegisterMetaType()。
void MandelbrotWidget::paintEvent(QPaintEvent * /* event */)
{
QPainter painter(this);
painter.fillRect(rect(), Qt::black);
if (pixmap.isNull()) {
painter.setPen(Qt::white);
painter.drawText(rect(), Qt::AlignCenter, tr("Rendering initial image, please wait..."));
return;
}
在 paintEvent() 中,我们从设置背景为黑色开始。如果我们没东西来画时(pixmap 为 null),我们在部件上打印一条信息来提示用户要耐心并迅速返回函数。
if (curScale == pixmapScale) {
painter.drawPixmap(pixmapOffset, pixmap);
} else {
double scaleFactor = pixmapScale / curScale;
int newWidth = int(pixmap.width() * scaleFactor);
int newHeight = int(pixmap.height() * scaleFactor);
int newX = pixmapOffset.x() + (pixmap.width() - newWidth) / 2;
int newY = pixmapOffset.y() + (pixmap.height() - newHeight) / 2;
painter.save();
painter.translate(newX, newY);
painter.scale(scaleFactor, scaleFactor);
QRectF exposed = painter.matrix().inverted().mapRect(rect()).adjusted(-1, -1, 1, 1);
painter.drawPixmap(exposed, pixmap, exposed);
painter.restore();
}
如果pixmap有正确的比例因子,我们就直接在部件上画pixmap。否则,我们在画pixmap之前按比例缩放和转换坐标系统。使用比例缩放painter矩阵来反向映射部件的矩形,我们同样确定只画了暴露出来的pixmap区域。调用 QPainter::save() 和 QPainter::restore() 能确保接下来的任何画的动作是使用了标准的坐标系统的。
QString text = tr("Use mouse wheel or the '+' and '-' keys to zoom. "
"Press and hold left mouse button to scroll.");
QFontMetrics metrics = painter.fontMetrics();
int textWidth = metrics.width(text);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(0, 0, 0, 127));
painter.drawRect((width() - textWidth) / 2 - 5, 0, textWidth + 10, metrics.lineSpacing() + 5);
painter.setPen(Qt::white);
painter.drawText((width() - textWidth) / 2, metrics.leading() + metrics.ascent(), text);
}
在画事件的结尾处,我们在 fractal 上画一个文本和一个半透明的矩形。
void MandelbrotWidget::resizeEvent(QResizeEvent * /* event */)
{
thread.render(centerX, centerY, curScale, size());
}
无论何时用户缩放部件,我们都调用 render() 来开始产生一个新的图片,它拥有相同的 centerX ,centerY 和 curScale 值,但拥有新的部件大小。
注意到了当第一次显示部件第一次生成图像时,我们依赖 Qt自动调用 resizeEvent() 的特性。
void MandelbrotWidget::keyPressEvent(QKeyEvent *event)
{
switch (event->key()) {
case Qt::Key_Plus:
zoom(ZoomInFactor);
break;
case Qt::Key_Minus:
zoom(ZoomOutFactor);
break;
case Qt::Key_Left:
scroll(-ScrollStep, 0);
break;
case Qt::Key_Right:
scroll(+ScrollStep, 0);
break;
case Qt::Key_Down:
scroll(0, -ScrollStep);
break;
case Qt::Key_Up:
scroll(0, +ScrollStep);
break;
default:
QWidget::keyPressEvent(event);
}
}
键盘事件处理函数
为那些没有用鼠标的用户提供了一些键盘绑定。zoom() 和 scroll() 函数之后将会介绍。
void MandelbrotWidget::wheelEvent(QWheelEvent *event)
{
int numDegrees = event->delta() / 8;
double numSteps = numDegrees / 15.0f;
zoom(pow(ZoomInFactor, numSteps));
}
车轮事件处理函数被重新实现来让鼠标的滚轮控制缩放值。QWheelEvent::delta()返回鼠标滚轮移动的角度,1/8度为一个单位。对于大多数鼠标,滚轮移动一步相当于15度。我们发现鼠标移动了多少步,就决定了缩放因子的大小。例如,如果我们让鼠标正向滚动两步,(就是 +30度),缩放因子就变成了ZoomInFactor的二次方(即0.0*0.8=0.64 )。
void MandelbrotWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
lastDragPos = event->pos();
}
当用户按下鼠标左键时,我们就像鼠标箭头的位置记录在 lastDragPos 中。
void MandelbrotWidget::mouseMoveEvent(QMouseEvent *event)
{
if (event->buttons() & Qt::LeftButton) {
pixmapOffset += event->pos() - lastDragPos;
lastDragPos = event->pos();
update();
}
}
当用户按住鼠标并移动时,我们调整 pixmapOffset 值在一个移动后位置来画pixmap,并调用 QWidget::update() 来强制重画。
void MandelbrotWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
pixmapOffset += event->pos() - lastDragPos;
lastDragPos = QPoint();
int deltaX = (width() - pixmap.width()) / 2 - pixmapOffset.x();
int deltaY = (height() - pixmap.height()) / 2 - pixmapOffset.y();
scroll(deltaX, deltaY);
}
}
当释放鼠标左键时,我们更新pixmapOffset 值,就像我们在鼠标移动时一样,重设 lastDragPos 为默认值。然后,调用 scroll() 来渲染所处新位置的新图像。(调整 pixmapOffset 还是不够的,因为显示的区域在拖动时被画成了黑色)
void MandelbrotWidget::updatePixmap(const QImage &image, double scaleFactor)
{
if (!lastDragPos.isNull())
return;
pixmap = QPixmap::fromImage(image);
pixmapOffset = QPoint();
lastDragPos = QPoint();
pixmapScale = scaleFactor;
update();
}
当工人线程已经完成渲染图像时调用updatePixmap() 槽。通过检查一个拖动是否有效后我们才开始,并且这种情况下不做任何操作。在正常情况下,我们在pixmap中存储图像,并且重新初始化一些其他的成员变量。最后,调用QWidget::update() 来刷新界面。
此时,你可能会好奇为什么我们使用 QImage 作为参数,QPixmap作为数据成员,为什么不使用一种类型呢?原因是QImage是唯一的一个支持直接像素操作的类,而这是我们在工人线程中所需要的。另一方面,在屏幕画一个图像前,它必须转化成 pixmap 。最好在这里转换一遍并且这样一劳永逸,而不是在 paintEvent() 中做转换。
void MandelbrotWidget::zoom(double zoomFactor)
{
curScale *= zoomFactor;
update();
thread.render(centerX, centerY, curScale, size());
}
在 zoom() 中, 我们重新计算 curScale。然后调用 QWidget::update() 来画一个已经按比例缩放了的pixmap,并且我们要求工人线程渲染一个新的图像来响应心的 curScale 值。
void MandelbrotWidget::scroll(int deltaX, int deltaY)
{
centerX += deltaX * curScale;
centerY += deltaY * curScale;
update();
thread.render(centerX, centerY, curScale, size());
}
scroll() 和 zoom() 类似,除了参数是 centerX 和 centerY。
Main() 函数
应用程序的多线程性质并不影响它的 main() 函数,它依旧简单:
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
MandelbrotWidget widget;
widget.show();
return app.exec();
}