填坑日记:QLayout::replaceWidget使用注意事项

最近写了一个可以动态插入行、删除行、调整行序的表状参数设置对话框。中间做插入行时,期望使用QLayout::replaceWidget来实现QWidget(包括它的各种继承类)对象在QLayout中的便捷移动时,出现了一些奇怪的情况。

坑的描述 QLayout::replaceWidget(QWidget *pFrom, QWidget *pTo)未完全实现窗口类对象的替换
根本原因 QLayout::replaceWidget(QWidget *pFrom, QWidget *pTo)执行后,pFrom会被pTo替换,被替换后的pFrom将不受当前QLayout管理
填坑进度 已解决

问题描述:

为了方便描述问题,先简单介绍下这个表状参数设置对话框。对话框样例如下图所示:

表状参数设置对话框样例

在这个示例中,除了首行(属性名)外,每一行的四个设置项及右侧的四个按钮组成一个CExtensibleSettingGroup对象(QObject的子类);右侧的按钮从左到右依次表示插入、删除、上移、下移;其中插入操作定义为插入在当前行之后。为了方便描述,使用Name这一属性来表示各个行对象。例如0行的“插入”按钮(上图中带有深蓝色框线的按钮)被按下后,0行之后将插入一个新行,当前1、2、3三行依次后移一行。预期效果如下图所示:

点击Name==0的行后预期的效果

结果变成这个鬼样子:
结果出现了奇怪的现象

原因分析:

代码执行后,观察到原先的第1、2、3行都“浮于”父窗口之上,而不在预期的由QLayout管理的位置。这个现象是Qt中典型的layout错误,在以下情況下会出现:当某个窗口设定了父窗口,却没有加入到父窗口的任一个QLayout中管理。写一个小类CQLayoutRepalceWidgetTestMainWindow继续进行测试,代码如下:

[cpp]
#include "qlayoutrepalcewidgettestmainwindow.h"

#include <QWidget>
#include <QLayout>
#include <QLabel>
#include <QPushButton>
#include <QTextEdit>

CQLayoutRepalceWidgetTestMainWindow::CQLayoutRepalceWidgetTestMainWindow(QWidget *parent)
: QMainWindow(parent)
{
SetupUI();
CreateConnects();
}

CQLayoutRepalceWidgetTestMainWindow::~CQLayoutRepalceWidgetTestMainWindow()
{
}

void CQLayoutRepalceWidgetTestMainWindow::SetupUI()
{
this->setMinimumSize(640, 480);

QWidget *pCentralWidget = new QWidget(this);
this->setCentralWidget(pCentralWidget);

m_pMainLayout = new QGridLayout(pCentralWidget);

for(int iRowIdx = 0; iRowIdx < 4; ++iRowIdx)
{
for(int iColIdx = 0; iColIdx < 4; ++iColIdx)
{
m_pLabels[iRowIdx][iColIdx] = new QLabel(QString("%1, %2").arg(iRowIdx).arg(iColIdx), pCentralWidget);
m_pMainLayout->addWidget(m_pLabels[iRowIdx][iColIdx], iRowIdx, iColIdx);
}
}

m_pLabelReplaceFrom = new QLabel("from", pCentralWidget);
m_pMainLayout->addWidget(m_pLabelReplaceFrom, 4, 1);

m_pBtnRoll = new QPushButton("Roll", pCentralWidget);
m_pMainLayout->addWidget(m_pBtnRoll, 4, 3);

m_pBtnRepalce = new QPushButton("Replace", pCentralWidget);
m_pMainLayout->addWidget(m_pBtnRepalce, 4, 4);

m_pLabelReplaceTo = new QLabel("to", pCentralWidget);

m_pTextReport = new QTextEdit(pCentralWidget);
m_pTextReport->setMinimumSize(480, 360);
m_pMainLayout->addWidget(m_pTextReport, 5, 0, 1, 4);
}

void CQLayoutRepalceWidgetTestMainWindow::CreateConnects()
{
connect(m_pBtnRoll, &QPushButton::clicked, this, &CQLayoutRepalceWidgetTestMainWindow::slotRoll);
connect(m_pBtnRepalce, &QPushButton::clicked, this, &CQLayoutRepalceWidgetTestMainWindow::slotReplace);
}

void CQLayoutRepalceWidgetTestMainWindow::ReplaceWidgetInLayout(QWidget *pFrom, QWidget *pTo)
{
QString strReport;

int iIndexFrom = m_pMainLayout->indexOf(pFrom);
QLayoutItem *pItemFrom = nullptr;
int iRowIdxFrom = -1, iColIdxFrom = -1, iRowSpanFrom = -1, iColSpanFrom = -1;
if(iIndexFrom >= 0)
{
pItemFrom = m_pMainLayout->itemAt(iIndexFrom);
m_pMainLayout->getItemPosition(iIndexFrom, &iRowIdxFrom, &iColIdxFrom, &iRowSpanFrom, &iColSpanFrom);
}
int iIndexTo = m_pMainLayout->indexOf(pTo);
QLayoutItem *pItemTo = nullptr;
int iRowIdxTo = -1, iColIdxTo = -1, iRowSpanTo = -1, iColSpanTo = -1;
if(iIndexTo >= 0)
{
pItemTo = m_pMainLayout->itemAt(iIndexTo);
m_pMainLayout->getItemPosition(iIndexTo, &iRowIdxTo, &iColIdxTo, &iRowSpanTo, &iColSpanTo);
}

strReport.append("before:\r\n");
strReport.append(QString("\tfrom: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, addr = %6, item = %7\r\n")
.arg(iIndexFrom).arg(iRowIdxFrom).arg(iColIdxFrom).arg(iRowSpanFrom).arg(iColSpanFrom).arg((long)pFrom).arg((long)pItemFrom));
strReport.append(QString("\tto: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, addr = %6, item = %7\r\n")
.arg(iIndexTo).arg(iRowIdxTo).arg(iColIdxTo).arg(iRowSpanTo).arg(iColSpanTo).arg((long)pTo).arg((long)pItemTo));

QLayoutItem *pItemReplaced = m_pMainLayout->replaceWidget(pFrom, pTo);

iIndexFrom = m_pMainLayout->indexOf(pFrom);
if(iIndexFrom >= 0)
{
pItemFrom = m_pMainLayout->itemAt(iIndexFrom);
m_pMainLayout->getItemPosition(iIndexFrom, &iRowIdxFrom, &iColIdxFrom, &iRowSpanFrom, &iColSpanFrom);
}
else
{
pItemFrom = nullptr;
iRowIdxFrom = -1;
iColIdxFrom = -1;
iRowSpanFrom = -1;
iColSpanFrom = -1;
}
iIndexTo = m_pMainLayout->indexOf(pTo);
if(iIndexTo >= 0)
{
pItemTo = m_pMainLayout->itemAt(iIndexTo);
m_pMainLayout->getItemPosition(iIndexTo, &iRowIdxTo, &iColIdxTo, &iRowSpanTo, &iColSpanTo);
}
else
{
pItemTo = nullptr;
iRowIdxTo = -1;
iColIdxTo = -1;
iRowSpanTo = -1;
iColSpanTo = -1;
}
strReport.append("after:\r\n");
strReport.append(QString("\tpItemReplaced = %1\r\n").arg((long)pItemReplaced));
strReport.append(QString("\tfrom: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, item = %6\r\n")
.arg(iIndexFrom).arg(iRowIdxFrom).arg(iColIdxFrom).arg(iRowSpanFrom).arg(iColSpanFrom).arg((long)pItemFrom));
strReport.append(QString("\tto: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, item = %6\r\n")
.arg(iIndexTo).arg(iRowIdxTo).arg(iColIdxTo).arg(iRowSpanTo).arg(iColSpanTo).arg((long)pItemTo));

m_pTextReport->append(strReport);

update();
}

void CQLayoutRepalceWidgetTestMainWindow::slotReplace()
{
ReplaceWidgetInLayout(m_pLabelReplaceFrom, m_pLabelReplaceTo);
}

void CQLayoutRepalceWidgetTestMainWindow::slotRoll()
{
for(int iRowIdx = 0; iRowIdx < 3; ++iRowIdx)
{
ReplaceWidgetInLayout(m_pLabels[iRowIdx + 1][iRowIdx + 1], m_pLabels[iRowIdx][iRowIdx]);
}
}
[/cpp]

CQLayoutRepalceWidgetTestMainWindow类运行后如下图所示:

CQLayoutRepalceWidgetTestMainWindow初始界面

该类包含两个按钮,即Roll和Replace。Replace用于测试使用m_pLabelReplaceTo替换m_pLabelReplaceFrom 的操作;Roll用于模拟原有窗口类循环替换多个QWidget对象的操作(0,0→1,1→2,2→3,3)。初始状态时,以“to”命名的m_pLabelReplaceTo不受m_pMainLayout管理,以“悬浮”状态出现在主窗口的左上角。

slotReplace与slotRoll都调用了ReplaceWidgetInLayout(QWidget *pFrom, QWidget *pTo)函数,该函数做了以下几件事:

  • 执行replaceWidget的操作
  • 输出该操作执行前后,pFrom和pTo这两个对象的一些信息
  • 刷新窗口显示。

除了replaceWidget(这个后面重点分析),还用到两个函数,int QLayout::indexOf(QWidget *widget) constvoid QGridLayout::getItemPosition(int index, int *row, int *column, int *rowSpan, int *columnSpan) const。官方文档说明如下:

int QLayout::indexOf(QWidget *widget) const 
Searches for widget widget in this layout (not including child layouts).
Returns the index of widget, or -1 if widget is not found.
The default implementation iterates over all items using itemAt()

捡重点说,就是如果QWidget对象不被QLayout对象管理,那么索引号会返回-1。

void QGridLayout::getItemPosition(int index, int *row, int *column, int *rowSpan, int *columnSpan) const
Returns the position information of the item with the given index.
The variables passed as row and column are updated with the position of the item in the layout, and the rowSpan and columnSpan variables are updated with the vertical and horizontal spans of the item.
See also itemAtPosition() and itemAt().

取回指定QWidget对象在QGridLayout中的行列坐标,以及跨行列参数。当然,这里index不可以传入-1,否则程序当场去世。

slotReplace与slotRoll代码执行之后,出现了一些有趣的现象,如下图所示:

widget对象浮于主窗口上层

Replace和Roll操作执行之后的界面

先说“Replace”操作。名为"to"的对象,移动到了"from"原有的位置,这是符合预期的。奇怪的是“from”变成了不跟随主窗口大小变化而移动位置的“悬浮”状态。

再说“Roll”,程序的本意是对角线上的对象逐个移位,也就是“0,0”移动到“1,1”的位置,“1,1”移动到“2,2”的位置,“2,2”移动到“3,3”的位置,“3,3”不动。实际情况是“0,0”确实移动到“1,1”的位置,但是“1,1”、“2,2”、“3,3”却“悬浮”了。

考察出乎意料的几个对象,它们共同点在于,都是在调用QLayoutItem *QLayout::replaceWidget(QWidget *from, QWidget *to, Qt::FindChildOptions options = Qt::FindChildrenRecursively)函数时,作为from参数传入的。根据打印出来的信息可以看出,执行“Replace”操作前,代码执行前,m_pLabelReplaceTo由于未添加入m_pMainLayout,其对应的QLayoutItem是空;代码执行后,其对应的QLayoutItem非空,m_pLabelReplaceFrom对应的QLayoutItem却变为空;执行“Roll”操作后,相关的几个QLabel对象对应的 QLayoutItem竟然全变成空了。莫非对这个函数的理解出现了偏差?赶紧去查replaceWidget函数的官方文档:

QLayoutItem *QLayout::replaceWidget(QWidget *from, QWidget *to, Qt::FindChildOptions options = Qt::FindChildrenRecursively)
Searches for widget from and replaces it with widget to if found. Returns the layout item that contains the widget from on success. Otherwise nullptr is returned. If options contains Qt::FindChildrenRecursively (the default), sub-layouts are searched for doing the replacement. Any other flag in options is ignored.
Notice that the returned item therefore might not belong to this layout, but to a sub-layout.
The returned layout item is no longer owned by the layout and should be either deleted or inserted to another layout. The widget from is no longer managed by the layout and may need to be deleted or hidden. The parent of widget from is left unchanged.
This function works for the built-in Qt layouts, but might not work for custom layouts.
This function was introduced in Qt 5.2.
See also indexOf().

也就是说,执行replaceWidget之后,会返回原先from这个QWidget对象对应的QLayoutItem对象,但是同时这个QWidget也将被移出QLayout管理。换句话说,这里做了一个QLayoutItem和QWidget的解绑。这样可以解释“Replace”操作的现象:

  • 期望“to”移动到“from”的位置,因此执行replaceWidget(from, to);
  • 函数执行前,“from”的QLayoutItem非空,“to”不受QLayout管理,其QLayoutItem是空;
  • 函数执行后,“from”对应的QLayoutItem正确地被清空,“from”变成“悬浮”;“to”的QLayoutItem变为非空,且“to”出现在“from”原先的位置。

单从代码来看,理所当然“Roll”只是多次“Replace”的组合,可是尝试解释“Roll”操作时出现了问题:

  • 第一步,期望“0,0”移动到“1,1”的位置,因此“1,1”作为from,“0,0”作为to,调用replaceWidget函数。函数执行后,“1,1”对应的QLayoutItem正确地被清空。
  • 第二步,期望“1,1”移动到“2,2”的位置,因此“2,2”作为from,“1,1”作为to,调用replaceWidget函数。函数执行后,“2,2”对应的QLayoutItem正确地被清空,但是此时“1,1”的QLayoutItem却仍为空,与“Replace”操作的现象有区别;
  • 第三步的情况与第二步类似,不再展开。

文档没有更进一步的说明,看看QT源码里面有什么线索。

[cpp]
QLayoutItem *QLayout::replaceWidget(QWidget *from, QWidget *to, Qt::FindChildOptions options)
{
Q_D(QLayout);
if (!from || !to)
return 0;

int index = -1;
QLayoutItem *item = 0;
for (int u = 0; u < count(); ++u) {
item = itemAt(u);
if (!item)
continue;

if (item->widget() == from) {
index = u;
break;
}

if (item->layout() && (options & Qt::FindChildrenRecursively)) {
QLayoutItem *r = item->layout()->replaceWidget(from, to, options);
if (r)
return r;
}
}
if (index == -1)
return 0;

QLayoutItem *newitem = new QWidgetItem(to);
newitem->setAlignment(item->alignment());
QLayoutItem *r = d->replaceAt(index, newitem);
if (!r)
delete newitem;
else
addChildWidget(to);
return r;
}
[/cpp]

根据之前代码的返回结果,只需分析返回非空指针的情况。如代码所示,可能发生在第22、35行。其中第22行是childLayout时的递归调用返回,所以实际仅有第35行返回的这种情况,即d->replaceAt(dinex, newitem)返回非空指针。此处需要考察replaceAt和addChildWidget这两个函数。

[cpp]
QLayoutItem* QGridLayoutPrivate::replaceAt(int index, QLayoutItem *newitem) Q_DECL_OVERRIDE
{
if (!newitem)
return 0;
QLayoutItem *item = 0;
QGridBox *b = things.value(index);
if (b) {
item = b->takeItem();
b->setItem(newitem);
}
return item;
}
[/cpp]

当things.value(index)返回非空,且b->takeItem()返回非空时,该函数就返回非空。此处没有什么神奇的。

[cpp]
/*!
This function is called from \c addWidget() functions in
subclasses to add \a w as a managed widget of a layout.

If \a w is already managed by a layout, this function will give a warning
and remove \a w from that layout. This function must therefore be
called before adding \a w to the layout's data structure.
*/
void QLayout::addChildWidget(QWidget *w)
{
QWidget *mw = parentWidget();
QWidget *pw = w->parentWidget();

//Qt::WA_LaidOut is never reset. It only means that the widget at some point has
//been in a layout.
if (pw && w->testAttribute(Qt::WA_LaidOut)) {
QLayout *l = pw->layout();
if (l && removeWidgetRecursively(l, w)) {
#ifdef QT_DEBUG
if (layoutDebug())
qWarning("QLayout::addChildWidget: %s \"%s\" is already in a layout; moved to new layout",
w->metaObject()->className(), w->objectName().toLocal8Bit().data());
#endif
}
}
if (pw && mw && pw != mw) {
#ifdef QT_DEBUG
if (layoutDebug())
qWarning("QLayout::addChildWidget: %s \"%s\" in wrong parent; moved to correct parent",
w->metaObject()->className(), w->objectName().toLocal8Bit().data());
#endif
pw = 0;
}
bool needShow = mw && mw->isVisible() && !(w->isHidden() && w->testAttribute(Qt::WA_WState_ExplicitShowHide));
if (!pw && mw)
w->setParent(mw);
w->setAttribute(Qt::WA_LaidOut);
if (needShow)
QMetaObject::invokeMethod(w, "_q_showIfNotHidden", Qt::QueuedConnection); //show later
}
[/cpp]

在这里发现了个没见过的东西。Qt::WA_LaidOut,这个东西QT的官方文档都没有提到,也未能查到其他什么有用的信息。根据QT源码里的注释,Qt::WA_LaidOut从来不会重置,只是用来表示这个widget“曾经”被Layout管理过。受其启发,关注不同对象中这个属性的区别和变化。改代码如下:

[cpp]
void CQLayoutRepalceWidgetTestMainWindow::ReplaceWidgetInLayout(QWidget *pFrom, QWidget *pTo)
{
QString strReport;

int iIndexFrom = m_pMainLayout->indexOf(pFrom);
QLayoutItem *pItemFrom = nullptr;
int iRowIdxFrom = -1, iColIdxFrom = -1, iRowSpanFrom = -1, iColSpanFrom = -1;
if(iIndexFrom >= 0)
{
pItemFrom = m_pMainLayout->itemAt(iIndexFrom);
m_pMainLayout->getItemPosition(iIndexFrom, &iRowIdxFrom, &iColIdxFrom, &iRowSpanFrom, &iColSpanFrom);
}
int iIndexTo = m_pMainLayout->indexOf(pTo);
QLayoutItem *pItemTo = nullptr;
int iRowIdxTo = -1, iColIdxTo = -1, iRowSpanTo = -1, iColSpanTo = -1;
if(iIndexTo >= 0)
{
pItemTo = m_pMainLayout->itemAt(iIndexTo);
m_pMainLayout->getItemPosition(iIndexTo, &iRowIdxTo, &iColIdxTo, &iRowSpanTo, &iColSpanTo);
}

strReport.append("before:\r\n");
strReport.append(QString("\tfrom: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, addr = %6, item = %7, WALayout = %8\r\n")
.arg(iIndexFrom).arg(iRowIdxFrom).arg(iColIdxFrom).arg(iRowSpanFrom).arg(iColSpanFrom).arg((long)pFrom).arg((long)pItemFrom).arg(pFrom->testAttribute(Qt::WA_LaidOut)));
strReport.append(QString("\tto: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, addr = %6, item = %7, WALayout = %8\r\n")
.arg(iIndexTo).arg(iRowIdxTo).arg(iColIdxTo).arg(iRowSpanTo).arg(iColSpanTo).arg((long)pTo).arg((long)pItemTo).arg(pTo->testAttribute(Qt::WA_LaidOut)));

QLayoutItem *pItemReplaced = m_pMainLayout->replaceWidget(pFrom, pTo);

iIndexFrom = m_pMainLayout->indexOf(pFrom);
if(iIndexFrom >= 0)
{
pItemFrom = m_pMainLayout->itemAt(iIndexFrom);
m_pMainLayout->getItemPosition(iIndexFrom, &iRowIdxFrom, &iColIdxFrom, &iRowSpanFrom, &iColSpanFrom);
}
else
{
pItemFrom = nullptr;
iRowIdxFrom = -1;
iColIdxFrom = -1;
iRowSpanFrom = -1;
iColSpanFrom = -1;
}
iIndexTo = m_pMainLayout->indexOf(pTo);
if(iIndexTo >= 0)
{
pItemTo = m_pMainLayout->itemAt(iIndexTo);
m_pMainLayout->getItemPosition(iIndexTo, &iRowIdxTo, &iColIdxTo, &iRowSpanTo, &iColSpanTo);
}
else
{
pItemTo = nullptr;
iRowIdxTo = -1;
iColIdxTo = -1;
iRowSpanTo = -1;
iColSpanTo = -1;
}
strReport.append("after:\r\n");
strReport.append(QString("\tpItemReplaced = %1\r\n").arg((long)pItemReplaced));
strReport.append(QString("\tfrom: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, item = %6, WALayout = %7\r\n")
.arg(iIndexFrom).arg(iRowIdxFrom).arg(iColIdxFrom).arg(iRowSpanFrom).arg(iColSpanFrom).arg((long)pItemFrom).arg(pFrom->testAttribute(Qt::WA_LaidOut)));
strReport.append(QString("\tto: index = %1, rowIdx = %2, colIdx = %3, rowSpan = %4, colSpan = %5, item = %6, WALayout = %7\r\n")
.arg(iIndexTo).arg(iRowIdxTo).arg(iColIdxTo).arg(iRowSpanTo).arg(iColSpanTo).arg((long)pItemTo).arg(pTo->testAttribute(Qt::WA_LaidOut)));

m_pTextReport->append(strReport);

update();
}
[/cpp]

于是发现了玄机:

Replace和Roll操作执行之后的界面_增加WA_LaidOut属性输出

尝试在执行replaceWidget之前设置相关属性,同时增加新旧widget的显示控制,代码如下:

[cpp]
void CQLayoutRepalceWidgetTestMainWindow::ReplaceWidgetInLayout(QWidget *pFrom, QWidget *pTo)
{
……

pTo->setAttribute(Qt::WA_LaidOut, false);
QLayoutItem *pItemReplaced = m_pMainLayout->replaceWidget(pFrom, pTo);
pFrom->setVisible(false);
pTo->setVisible(true);

……
}
[/cpp]

重新编译后,行为符合预期了。仔细查看QT源码,该属性的区别,将导致removeWidgetRecursively(l, w)函数没有执行。该函数的实现如下:

[cpp]
static bool removeWidgetRecursively(QLayoutItem *li, QWidget *w)
{
QLayout *lay = li->layout();
if (!lay)
return false;
int i = 0;
QLayoutItem *child;
while ((child = lay->itemAt(i))) {
if (child->widget() == w) {
delete lay->takeAt(i);
lay->invalidate();
return true;
} else if (removeWidgetRecursively(child, w)) {
return true;
} else {
++i;
}
}
return false;
}
[/cpp]

可以看到,该函数中执行了QLayoutItem对象的删除和无效化操作,这便是导致出现“悬浮”的根本原因。基于此判断,只要使该函数不被执行,则整个窗口的行为将符合预期。这样得出了第二种解决办法,即在执行replaceWidget之前,将pTo的parent设置为nullptr。改代码验证,确实也能得到符合预期的结果。

解决方案:

根据QT官方文档,再综合源码中的注释,我理解QT团队设计QLayout::replaceWidget函数的时候,原意是替换QLayout中的QWidget对象,被替换下来的对象后续不应再被使用,应该隐藏或者删除;同时Qt::WA_LaidOut这个属性作为内部标记,其实也并不希望被外部使用(官方文档甚至都没写)。希望QWidget对象被再次使用时,按QT的原意,应该是通过QLayout::addWidget将QWidget对象重新添加到QLayout中。

虽说通过一番折腾,可以实现想要的功能,但是毕竟不是官方推荐的方式,是否会引起其他问题,目前我无法给出结论。结合QT源码,可以发现replaceWidget找QWidget的操作,是遍历所有QLayoutItem来实现的,当有大量QWidget对象需要做替换时,程序的性能并不比removeWidget更优。这么看来,还不如直接使用removeWidget和addWidget的组合,先将被替换的QWidget对象移出QLayout,再根据需要的排列重新添加新的QWidget,代码可读性也更高。

 

二零二三年四月三十日 顾毅写于厦门