记 QGraphicsView 中一个 bug 的 workaround

原文:http://zhuanlan.zhihu.com/p/31310711 摘录如下,自己要用到,感谢提主


最近在瞎捣鼓 Qt,算上 C++ 的学习时间差不多有两三天吧,由于之前一直在做 Android 开发(再之前是 iOS,要点满客户端应用开发技能树的节奏,开玩笑的...),所以很多应用的设计模型都不是很适应。

今天在研究 Qt 的 Graphics View Framework,这是一套很强大的图元显示框架,这里贴一段官方的介绍,不熟悉的朋友可以了解一下:

Graphics View provides a surface for managing and interacting with a large number of custom-made 2D graphical items, and a view widget for visualizing the items, with support for zooming and rotation.

The framework includes an event propagation architecture that allows precise double-precision interaction capabilities for the items on the scene. Items can handle key events, mouse press, move, release and double click events, and they can also track mouse movement.

Graphics View uses a BSP (Binary Space Partitioning) tree to provide very fast item discovery, and as a result of this, it can visualize large scenes in real-time, even with millions of items.

这个框架的异常强大,甚至都自带了区域选择和拖拽等功能,所以我简单尝试了一下,但是在我的电脑上却发现了一个问题:

可以看到,当我拖拽一个区域进行选择的时候,整个视图会因为不完全刷新而产生这样的“残影”,可以说这在 Windows 桌面应用中非常常见,但 Qt 这里为什么会出现这样的问题呢,看了几个官方 Demo 都没有出现这样的问题,但我注意到,官方绝大多数 Demo 中都并没有开启高 DPI 支持,当我把我自己应用的高 DPI 支持关掉后(用宏定义来做开关):

int main(int argc, char *argv[])
{
#ifdef HIGH_DPI_SUPPORT
#if (QT_VERSION >= QT_VERSION_CHECK(5, 6, 0))
    QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
#endif
#endif
    QApplication a(argc, argv);

    MainWindow w;
    w.show();

    return a.exec();
}

效果是这样的:

无论如何框选都不会再出现残影了。

既然问题都已经确定了,下面就从源码里找找原因吧。


首先我定位到 QGraphicsView 这个类,猜测 RubberBand(Qt 里用这个 term 表示框选时显示的那个蓝框)的绘制逻辑是写在那里面的。通过搜索这个关键词,果然找到了相关的代码:

void QGraphicsViewPrivate::updateRubberBand(const QMouseEvent *event)
{
    Q_Q(QGraphicsView);
    if (dragMode != QGraphicsView::RubberBandDrag || !sceneInteractionAllowed || !rubberBanding)
        return;
    // Check for enough drag distance
    if ((mousePressViewPoint - event->pos()).manhattanLength() < QApplication::startDragDistance())
        return;

    // Update old rubberband
    if (viewportUpdateMode != QGraphicsView::NoViewportUpdate && !rubberBandRect.isEmpty()) {
        if (viewportUpdateMode != QGraphicsView::FullViewportUpdate)
            q->viewport()->update(rubberBandRegion(q->viewport(), rubberBandRect));
        else
            updateAll();
    }

    // ...
}

可以看到,为了渲染效率,QGraphicsView 在鼠标拖拽的时候并不会对整个 widget 进行重绘,而是只会绘制有变化的区域,这在 Windows 桌面应用中也是一个很常见的现象,完全不同于 Android 和 iOS(毕竟在 Windows 上默认使用 GDI++ 这种软件渲染的方式)。

为了抹去上一次绘制的 RubberBand,这里使用 update 方法对之前的 RubberBand 区域进行了重绘请求(Qt 中有 update 和 repaint 两种重绘请求的方式,update 是 repaint 的 async 版本,只会将重绘事件 enqueue,所以是可重入的)。

而上面我遇到的问题应该是由于更新区域不准确导致的,由于 DPI 缩放的介入,QPainter(类似 Android 里的 Canvas,iOS 里的 CGContext,可以使用绘图命令自己绘制图形)绘制时可能会因为浮点运算存在的精度损失,出现与更新区域大小不同的问题。


既然问题找到了,下一步就是尝试去解决它了。修改 Qt 源码再重新编译显然是不现实的事,而 RubberBand 重绘这块逻辑又是 private 的,所以继承重写也行不通,而且我个人是比较反对滥用继承的。通过看文档发现 Qt 强大的事件处理系统提供了 Event Filter 这种东西,简单来说 Qt 在把平台相关的事件分发给各个窗口或 widgets (实际上只要是 QObject 都可以,只不过在 GUI 中比较常见)前,可以预先通过用户 install 的 Event Filter,进行处理并可以选择拦截这次事件。

所以我三下五除二写了一个 filter:

class GraphicsViewPatch : public QObject {
    Q_OBJECT

public:
    explicit GraphicsViewPatch(QObject *parent = nullptr)
        : QObject(parent) {}

protected:
    bool eventFilter(QObject *watched, QEvent *event)
    {
        if (event->type() == QEvent::MouseMove) {
            QWidget *w = qobject_cast<QWidget *>(watched);
            Q_ASSERT(w);

            QGraphicsView *v = qobject_cast<QGraphicsView *>(w->parent());
            Q_ASSERT(v);

            w->update(v->rubberBandRect().marginsAdded(QMargins(1, 1, 1, 1)));
        }
        return QObject::eventFilter(watched, event);
    }
};

install 到界面中的 QGraphicsView 对象上:

ui->graphicsView->installEventFilter(new GraphicsViewPatch(ui->graphicsView));


运行了一下,发现什么都没发生,依然有残影。


......


很尴尬,难道我刚才的猜测是错的?但是加了个断点发现整个 filter 并没有执行!准确的说是没有处理鼠标相关的事件。纳尼??

继续加断点,发现鼠标相关的事件是这样被传递的:

简单描述一下这个过程中发生了什么:

首先 Qt 事件系统从 Windows API 中拿到了一个鼠标事件,由于 Qt 采用类似 Direct UI 的思想,所有子控件都没有 HWND(窗口句柄),所以鼠标事件是相对于整个窗口的。

这个鼠标事件会通过 QGuiApplication 传递给当前活跃的窗口,当前窗口会根据坐标找到你所指向的那个 widget,然后再把事件直接发给那个 widget。(这里又跟 Android 和 iOS 不同了。Android 是通过 ViewGroup 一层一层的传递,然后再从最内层的 View 向外冒泡,中途可以被拦截。iOS 和 Qt 类似,都是事件直接分发给相应控件,可以使用 UIGestureRecognizer 拦截,但是具体是什么原理就不清楚了,毕竟 UIKit 不开源。)

这样分析下来,发现 QGraphicsView 并没有接受到鼠标事件。

纳尼??为什么这么 weird...

继续通读文档,发现 QGraphicsView 继承自 QAbstractScrollArea,这个抽象类提供了滚动的支持,然后被滚动的内容称作 viewport,可以看到刚才的调用栈中也有 viewport 的字眼,嗯,尝试从这里入手。

首先我要从 QGraphicsView 里找一下它的 viewport 是什么:

QGraphicsView::QGraphicsView(QGraphicsScene *scene, QWidget *parent)
    : QAbstractScrollArea(*new QGraphicsViewPrivate, parent)
{
    setScene(scene);
    setViewport(0);
    setAcceptDrops(true);
    setBackgroundRole(QPalette::Base);
    // Investigate leaving these disabled by default.
    setAttribute(Qt::WA_InputMethodEnabled);
    viewport()->setAttribute(Qt::WA_InputMethodEnabled);
}

是 0(nullptr),根据文档,QAbstractScrollArea 在 viewport 被设为空时会再分配一个 QWidget,当作 viewport,这一点从源码里也能看出:

void QAbstractScrollArea::setViewport(QWidget *widget)
{
    Q_D(QAbstractScrollArea);
    if (widget != d->viewport) {
        QWidget *oldViewport = d->viewport;
        if (!widget)
            widget = new QWidget;
        d->viewport = widget;
        d->viewport->setParent(this);
        d->viewport->setFocusProxy(this);
        d->viewport->installEventFilter(d->viewportFilter.data());
#if 1 // Used to be excluded in Qt4 for Q_WS_MAC
#ifndef QT_NO_GESTURES
        d->viewport->grabGesture(Qt::PanGesture);
#endif
#endif
        d->layoutChildren();
#ifndef QT_NO_OPENGL
        QWidgetPrivate::get(d->viewport)->initializeViewportFramebuffer();
#endif
        if (isVisible())
            d->viewport->show();
        setupViewport(widget);
        delete oldViewport;
    }
}

但这样一来,显然 viewport 的所有事件都不会被外界感知了,那 QGraphicsView 是怎么处理的事件的呢?仔细看源码,可以发现 Qt 为这个空 QWidget 也 install 了一个 event filter,然后这个 filter 是什么,又干了什么呢?

继续看源码:

void QAbstractScrollAreaPrivate::init()
{
    Q_Q(QAbstractScrollArea);
    viewport = new QWidget(q);
    viewport->setObjectName(QLatin1String("qt_scrollarea_viewport"));
    viewport->setBackgroundRole(QPalette::Base);
    viewport->setAutoFillBackground(true);
    
    // ...

    viewportFilter.reset(new QAbstractScrollAreaFilter(this));
    viewport->installEventFilter(viewportFilter.data());
   
    // ...
}

QAbstractScrollAreaFilter 定义:

class QAbstractScrollAreaFilter : public QObject
{
    Q_OBJECT
public:
    QAbstractScrollAreaFilter(QAbstractScrollAreaPrivate *p) : d(p)
    { setObjectName(QLatin1String("qt_abstractscrollarea_filter")); }
    bool eventFilter(QObject *o, QEvent *e) Q_DECL_OVERRIDE
    { return (o == d->viewport ? d->viewportEvent(e) : false); }
private:
    QAbstractScrollAreaPrivate *d;
};

所以这个 filter 又把 viewport 的事件通过 viewportEvent 方法传回 QAbstractScrollArea 了。

感叹一下 Qt 设计的精良。那说什么自己几千行写了个能秒 WPF 秒 Qt 的库的人,醒醒!

最后 QGraphicsView 对 viewportEvent 进行处理,挑出一些有用的事件特殊处理一下,把其余的事件交给 QAbstractScrollArea 处理,QAbstractScrollArea 会把鼠标事件再分发给自己的 mouseXXXEvent 中处理。

整理一下:

  1. QWidgetWindow 把事件发给鼠标指向的 widget,即 QGraphicsView 的 viewport
  2. viewport 的 event filter 把事件发给 QAbstractScrollArea 的 viewportEvent
  3. QGraphicsView 在 viewportEvent 中把鼠标事件等事件交给父类 QAbstractScrollArea
  4. QAbstractScrollArea 把鼠标事件等事件交给自己的 xxxxEvent 方法进行处理
  5. QGraphicsView 的 xxxxEvent 被调用


好了,都整明白了,我们需要修改的地方就是给 viewport 再加一个我自己的 event filter:

ui->graphicsView->viewport()->installEventFilter(new GraphicsViewPatch(ui->graphicsView));



问题解决了。


P.S. 终于写完了,,,知乎编辑器嗲屎吧!

「打赏?不存在的,初学 C++ 和 Qt 只求勿喷」

猜你喜欢

转载自blog.csdn.net/stevenkoh/article/details/78885219