当用户调整我的对话框大小时,我该如何强制Windows不重新绘制对话框中的任何内容?

33
当用户调整可调大小窗口的角落并移动它时,Windows首先移动窗口内容,然后向被调整大小的窗口发出WM_SIZE消息。
因此,在我想要控制各种子控件移动并消除闪烁的对话框中,用户首先看到Windows操作系统认为窗口将会变成什么样子(因为在发送WM_SIZE之前,这个操作系统使用位块传输方法来移动窗口内部的东西)——只有然后我的对话框才能处理移动其子控件或调整它们的大小等操作,之后必须强制刷新,这会导致闪烁(至少)。
我的主要问题是:有没有一种方法可以强制Windows不执行这个愚蠢的位块传输操作? 它在具有随着窗口调整而移动的控件或随着其父窗口调整而调整大小的窗口中肯定是错误的。 无论哪种情况,让操作系统预先绘制只会搞乱一切。
我曾经认为它可能与CS_HREDRAW和CSVREDRAW类标志有关。 但实际情况是,我不希望操作系统要求我清除窗口 - 我只想自己重新绘制,而不是操作系统首先更改我的窗口内容(即,我希望显示在用户开始调整大小之前就是原样的——不需要任何来自操作系统的位块传输)。 除非确实有一个在调整大小时被遮挡或显示的控件(这种情况下我才希望操作系统告诉每个控件它需要被重绘)。
我真正想要的是:
  1. 在屏幕上更新任何内容之前移动和调整大小子控件。
  2. 完全绘制所有移动或调整大小的子控件,使它们以新的大小和位置呈现出来,没有任何伪影。
  3. 绘制子控件之间的空间而不影响它们本身。

注意:步骤2和3可以交换顺序。

当我使用DeferSetWindowPos()与标记为WS_CLIPCHILDREN的对话框资源结合使用时,上述三个步骤似乎都能够正确进行。

如果我可以将上述操作应用于内存DC,然后仅在WM_SIZE处理程序的最后执行单个位块传输,我将获得额外的小好处。

我已经尝试过这个问题一段时间了,但无法摆脱两件事:

  1. 我仍然无法阻止Windows进行“预测性位块传输”。答案:请参见下面的解决方案,覆盖WM_NCCALCSIZE以禁用此行为。

  2. 我不知道如何构建一个对话框,其中其子控件绘制到双缓冲区。答案:请参见以下John的答案(标记为答案),了解如何要求Windows OS将您的对话框双缓冲(注意:根据文档,这会禁止任何GetDC()在绘画操作之间)。


我的最终解决方案(感谢所有做出贡献的人,尤其是John K.):

经过长时间的努力,我发现以下技术可以完美地工作,在Aero和XP中或在禁用Aero时都可以。 闪烁不存在(1)。

  1. 挂钩对话框处理程序。
  2. 覆盖WM_NCCALCSIZE以强制Windows验证整个客户端区域,并且不进行任何位块传输。
  3. 覆盖WM_SIZE以使用BeginDeferWindowPos / DeferWindowPos / EndDeferWindowPos为所有可见窗口执行所有移动和调整大小。
  4. 确保对话框窗口具有WS_CLIPCHILDREN样式。
  5. 不要使用CS_HREDRAW | CS_VREDRAW(对话框不会,因此通常不是问题)。

布局代码由您决定-可以很容易地在CodeGuru或CodeProject上找到布局管理器的示例,或者自己编写。

以下是一些代码摘录,应该可以帮助您完成大部分工作:

LRESULT ResizeManager::WinProc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
    switch (msg)
    {
    case WM_ENTERSIZEMOVE:
        m_bResizeOrMove = true;
        break;

    case WM_NCCALCSIZE:
        // The WM_NCCALCSIZE idea was given to me by John Knoeller: 
        // see: https://dev59.com/8-o6XIcBkEYKwwoYTzAw
        // 
        // The default implementation is to simply return zero (0).
        //
        // The MSDN docs indicate that this causes Windows to automatically move all of the child controls to follow the client's origin
        // and experience shows that it bitblts the window's contents before we get a WM_SIZE.
        // Hence, our child controls have been moved, everything has been painted at its new position, then we get a WM_SIZE.
        //
        // Instead, we calculate the correct client rect for our new size or position, and simply tell windows to preserve this (don't repaint it)
        // and then we execute a new layout of our child controls during the WM_SIZE handler, using DeferWindowPos to ensure that everything
        // is moved, sized, and drawn in one go, minimizing any potential flicker (it has to be drawn once, over the top at its new layout, at a minimum).
        //
        // It is important to note that we must move all controls.  We short-circuit the normal Windows logic that moves our child controls for us.
        //
        // Other notes:
        //  Simply zeroing out the source and destination client rectangles (rgrc[1] and rgrc[2]) simply causes Windows 
        //  to invalidate the entire client area, exacerbating the flicker problem.
        //
        //  If we return anything but zero (0), we absolutely must have set up rgrc[0] to be the correct client rect for the new size / location
        //  otherwise Windows sees our client rect as being equal to our proposed window rect, and from that point forward we're missing our non-client frame

        // only override this if we're handling a resize or move (I am currently unaware of how to distinguish between them)
        // though it may be adequate to test for wparam != 0, as we are
        if (bool bCalcValidRects = wparam && m_bResizeOrMove)
        {
            NCCALCSIZE_PARAMS * nccs_params = (NCCALCSIZE_PARAMS *)lparam;

            // ask the base implementation to compute the client coordinates from the window coordinates (destination rect)
            m_ResizeHook.BaseProc(hwnd, msg, FALSE, (LPARAM)&nccs_params->rgrc[0]);

            // make the source & target the same (don't bitblt anything)
            // NOTE: we need the target to be the entire new client rectangle, because we want windows to perceive it as being valid (not in need of painting)
            nccs_params->rgrc[1] = nccs_params->rgrc[2];

            // we need to ensure that we tell windows to preserve the client area we specified
            // if I read the docs correctly, then no bitblt should occur (at the very least, its a benign bitblt since it is from/to the same place)
            return WVR_ALIGNLEFT|WVR_ALIGNTOP;
        }
        break;

    case WM_SIZE:
        ASSERT(m_bResizeOrMove);
        Resize(hwnd, LOWORD(lparam), HIWORD(lparam));
        break;

    case WM_EXITSIZEMOVE:
        m_bResizeOrMove = false;
        break;
    }

    return m_ResizeHook.BaseProc(hwnd, msg, wparam, lparam);
}

调整大小实际上是由Resize()成员完成的,如下所示:
// execute the resizing of all controls
void ResizeManager::Resize(HWND hwnd, long cx, long cy)
{
    // defer the moves & resizes for all visible controls
    HDWP hdwp = BeginDeferWindowPos(m_resizables.size());
    ASSERT(hdwp);

    // reposition everything without doing any drawing!
    for (ResizeAgentVector::const_iterator it = m_resizables.begin(), end = m_resizables.end(); it != end; ++it)
        VERIFY(hdwp == it->Reposition(hdwp, cx, cy));

    // now, do all of the moves & resizes at once
    VERIFY(EndDeferWindowPos(hdwp));
}

也许最后一个棘手的部分可以看作是ResizeAgent的Reposition()处理程序:

HDWP ResizeManager::ResizeAgent::Reposition(HDWP hdwp, long cx, long cy) const
{
    // can't very well move things that no longer exist
    if (!IsWindow(hwndControl))
        return hdwp;

    // calculate our new rect
    const long left   = IsFloatLeft()   ? cx - offset.left    : offset.left;
    const long right  = IsFloatRight()  ? cx - offset.right   : offset.right;
    const long top    = IsFloatTop()    ? cy - offset.top     : offset.top;
    const long bottom = IsFloatBottom() ? cy - offset.bottom  : offset.bottom;

    // compute height & width
    const long width = right - left;
    const long height = bottom - top;

    // we can defer it only if it is visible
    if (IsWindowVisible(hwndControl))
        return ::DeferWindowPos(hdwp, hwndControl, NULL, left, top, width, height, SWP_NOZORDER|SWP_NOACTIVATE);

    // do it immediately for an invisible window
    MoveWindow(hwndControl, left, top, width, height, FALSE);

    // indicate that the defer operation should still be valid
    return hdwp;
}

“棘手的”问题是我们避免尝试干扰已被销毁的任何窗口,并且我们不会对不可见的窗口延迟SetWindowPos(因为这被记录为“将失败”)。
我在一个隐藏了一些控件并使用相当复杂布局的真实项目中测试了上述内容,取得了非常成功的效果。即使没有Aero,甚至在使用对话框窗口的左上角进行调整大小时(大多数可调整大小的窗口在抓住该句柄时会显示最多的闪烁和问题-如IE,FireFox等),也没有任何闪烁。
如果有足够的兴趣,我可以被说服在CodeProject.com或类似网站上编辑我的发现,并提供真实的示例实现。请私信联系我。
请注意,无法避免覆盖以前的任何内容。对于对话框中未更改的每个部分,用户什么都看不到(没有任何闪烁)。但是,在事物发生变化的地方,用户会看到变化-这是不可避免的,并且是100%的解决方案。

1
这种技术在Win10下不再适用(可能在Win8下也不适用)。很遗憾。 我们又回到了观察控件“走动”到它们应该在的位置,而不是立即跳转到正确的位置。 - Mordachai
既然你问了,当然,我想看一个真实的实现例子。这是我感兴趣的事情,特别是如果它是一个完整的winapi(即非MFC)解决方案。MFC也很有趣。有关Windows 10的任何更新吗? - jrh
1
很遗憾,Windows 8+破坏了这一技术,我没有动力去弄清楚如何让它再次运作(即使可能)。以上方法适用于XP、Vista、Win 7等操作系统。但是在8+上,它只会让它像没有这些努力一样愚蠢。 - Mordachai
8个回答

16

在调整大小时无法防止绘制,但是可以(小心地)防止重新绘制,这就是闪烁的来源。首先看看bitblt。

停止bitblt有两种方法。

如果您拥有顶层窗口的类,则只需使用 CS_HREDRAW | CS_VREDRAW 样式注册它。这将使您的窗口调整大小后使整个客户区域无效,而不是试图猜测哪些位不会更改并进行bitblting。

如果您没有拥有该类,但确实具有控制消息处理的能力(对于大多数对话框框而言都是如此)。默认处理WM_NCCALCSIZE是处理类样式CS_HREDRAWCS_VREDRAW的地方,默认行为是在类拥有CS_HREDRAW | CS_VREDRAW时从处理WM_NCCALCSIZE返回WVR_HREDRAW | WVR_VREDRAW

因此,如果您可以拦截WM_NCCALCSIZE,则可以在调用DefWindowProc来执行其他正常处理之后强制返回这些值。

您可以侦听WM_ENTERSIZEMOVEWM_EXITSIZEMOVE以了解窗口调整大小开始和停止的时间,并使用它来临时禁用或修改您的绘图和/或布局代码的工作方式,以最小化闪烁。您要修改此代码做什么完全取决于您的正常代码在WM_SIZE WM_PAINTWM_ERASEBKGND中通常执行的操作。

当您绘制对话框框的背景时,您需要不要在任何子窗口后面绘制。确保对话框具有WS_CLIPCHILDREN即可解决此问题,因此您已经处理好了这个问题。

移动子窗口时,请确保使用BeginDeferWindowPos/EndDeferWindowPos,以便所有重绘操作一次完成。否则,在每个SetWindowPos调用中,每个窗口都会重新绘制其非客户区域,从而导致闪烁。


1
在创建模态对话框时,是否有一种选择窗口类的技术,而不是让操作系统选择标准窗口类?我当然可以挂接对话框的wndproc,但是否有更好的方法? - Mordachai
2
嗯,我不确定该怎么看待它,但是我似乎有了解决我的初衷的方法:无闪烁调整大小。如果我在WM_SIZE处理程序中移动子控件时使用DeferSetWindowPos,则窗口调整大小几乎完美(我还强制对话框本身具有WS_CLIPCHILDREN)。我仍然觉得所有这些都有点黑魔法。为什么管理子窗口重绘顺序如此困难?为什么DeferSetWindowPos循环成功而手动移动控件,然后使其失效失败?唉... - Mordachai
1
谢谢John,这有助于澄清为什么SetWindowPos比MoveWindow(...,FALSE)更好。但是,我仍然不明白如何抑制位块传输行为。尽管您似乎认为这是不可能的,并且允许位块传输发生,但对于带有可调整大小控件的对话框来说,该位块传输将始终是错误的。仅当子窗口不调整大小并且以与父对话框的起点锁定步骤移动它们的原点时才是正确的,这并不一定是真实的情况(它们可能由我的布局管理器锚定到右下角、右上角等)。 - Mordachai
1
@Mordachai:bitblt 受 WM_NCCALCSIZE 消息控制,微软并没有充分记录这个消息,以便完全使用。但是,通过正确填写 WM_NCCALCSIZE 中的 3 个矩形,您可以为 bitblt 设置不同的原点。这是我个人从未做过的事情。 - John Knoeller
1
我真的不想去弄这个WM_NCCALCSIZE的事情,但我似乎已经差不多找到了一个解决方案。明天我会在这里详细发布它 - 但基本上,如果我构造两个矩形nccs_params->rgrc[0]和[1],并显式地使用WVR_ALIGNLEFT|WVR_ALIGNTOP,那么我就不会有任何位块传输(或者至少它是良性的,因为它是在自身的顶部进行位块传输,因此是一个视觉无操作)。但是然后我必须明确处理所有子窗口的移动,而不仅仅是那些没有锚定到左上角的窗口。这看起来非常令人兴奋!;) - Mordachai
显示剩余8条评论

4

@John:即使存在子窗口,它也可以应用。Windows不会自动调整子窗口的大小。应用程序可以选择延迟这些调整大小操作。 - jamesdlin
@jamesdlin:子窗口的绘制不受父窗口控制,因此,如果存在子窗口,该技术就无法使用,除非您在调整大小开始时进行一些棘手的操作,例如隐藏它们。 - John Knoeller
@Jamesdlin:要清楚,为了避免调整大小时出现闪烁或绘图异常,您需要推迟子元素的调整大小和重绘。仅推迟调整大小只能解决部分问题。 - John Knoeller
该文章已不再可用。 - Paul Ogilvie
1
页面引用的文章已经不存在了。然而,这似乎是相同的代码:https://github.com/pauldotknopf/WindowsSDK7-Samples/tree/master/winui/fulldrag - Dan
显示剩余4条评论

2
这是一篇2018年的更新,因为我刚刚经历了和你一样的艰辛。
在你的问题中提到的“最终解决方案”以及相关答案,提到了使用WM_NCCALCSIZECS_HREDRAW|CS_VREDRAW技巧可以有效地防止Windows XP/Vista/7在调整大小时对客户区域进行BitBlt操作。可能还有一个类似的技巧值得一提:你可以拦截WM_WINDOWPOSCHANGING消息(首先将其传递给DefWindowProc),并设置WINDOWPOS.flags |= SWP_NOCOPYBITS,这将禁用Windows在调整窗口大小期间在内部调用SetWindowPos()时进行的BitBlt操作。这最终产生的效果是跳过了BitBlt操作。
有些人提到你的WM_NCCALCSIZE技巧在Windows 10中不再起作用了。我认为这可能是因为你编写的代码返回了WVR_ALIGNLEFT|WVR_ALIGNTOP,而应该返回WVR_VALIDRECTS,以便Windows使用你构建的两个矩形(nccs_params->rgrc[1]nccs_params->rgrc[2]),至少根据MSDN页面上对WM_NCCALCSIZENCCALCSIZE_PARAMS的非常简略的文档来看是这样的。可能Windows 10对返回值更加严格,可以尝试一下。
然而,即使我们假设我们能说服Windows 10不在SetWindowPos()内部执行BitBlt,仍然存在一个新的问题...
Windows 10(可能也包括Windows 8)在旧版XP / Vista / 7的基础上增加了另一层客户区域破坏。在Windows 10下,应用程序不直接绘制到帧缓冲区,而是绘制到离屏缓冲区,由Aero窗口管理器(DWM.exe)合成。事实证明,DWM有时会决定通过在客户区域上绘制自己的内容来“帮助”您(类似于BitBlt但更为恶劣且更加失控)。因此,为了避免客户区域的破坏,我们仍然需要控制WM_NCCALCSIZE,同时还需要防止DWM干扰像素。我曾经遇到过完全相同的问题,并创建了一个问题/答案汇总,汇集了这个主题上10年的帖子,并提供了一些新的见解(在此问题中无法粘贴内容)。自Windows Vista以来,上述提到的BitBlt已不再是唯一的问题。享受:
如何在调整窗口大小时平滑处理丑陋的抖动/闪烁/跳跃,特别是拖动左侧/顶部边框(Win 7-10;背景、位块传输和 DWM)?

1
针对某些控件,您可以使用 WM_PRINT 消息将控件画在 DC 中。但是这并不能真正解决您的主要问题,即您希望 Windows 在调整大小期间不绘制任何内容,而是让您自己完成所有操作。
答案是,只要您有子窗口,就无法做到您想要的效果。
我最终在自己的代码中解决了这个问题,方法是切换到使用 无窗口控件。由于它们没有自己的窗口,它们总是与父窗口同时(且在相同的 DC 中)绘制。这使我可以使用简单的双缓冲完全消除闪烁。我甚至可以通过在父类绘制例程内部调用子控件的绘制例程来轻松地抑制子控件的绘制。
这是我知道的唯一完全消除调整大小操作期间闪烁和撕裂的方法。

如果这是真的,那就太不幸了。尝试重新实现Windows中的每个控件需要大量工作和持续的工作量。在我看来,“F!@#$ ton”是一个合理的标签;) 你有第三方库供应商提供这样的东西吗?在我的编程经验中,使用早期的Java实现非常糟糕。很大程度上是因为他们的无窗口控件没有做好。它们具有非Windows的感觉,并且行为足以成为我们和用户的重大困扰。但令我惊讶的是,Windows操作系统没有“不预测绘制”的类或窗口标志。 - Mordachai
@Mordachai:我们已经为我们关心的事情自己编写了控件,所以只需要在它们上面添加无窗口模式即可。我认为真正难以复制的唯一控件是编辑控件,但我们已经有了大约一年的程序员代码。看看关于WS_EX_COMPOSITED的答案。当我做无窗口控件时,这个标志还不存在,所以我不能确定它是否能解决问题,但它听起来很有前途。 - John Knoeller
我所知道的WS_EX_COMPOSITED最大的缺点是你不能直接绘制到窗口的DC上(只能在WM_PAINT期间)。此外,使用您提出的其他建议想出的解决方案无论如何都避免了这种需要 :) - Mordachai

0

看起来有效的方法:

  1. 在父对话框中使用WS_CLIPCHILDREN(可在WM_INITDIALOG中设置)
  2. 在WM_SIZE期间,使用DeferSetWindowPos()循环遍历子控件,移动和调整它们的大小。

在我的测试下,在Windows 7下使用Aero非常接近完美。


Aero 为您有效地执行了许多双缓冲。如果您关闭 Aero 或在 XP 上尝试它,您可能仍然会遇到很多闪烁问题。 - Adrian McCarthy
是的,关闭Aero后会更加华丽。我可以看到更多我最初抱怨的行为,并希望看到它们被消除。当我拖动对话框的调整大小角落时,我可以看到操作系统试图预测事物出现的位置(似乎是我的对话框以前内容的位块传输),在我的WM_SIZE处理程序期间快速覆盖了已更正的绘制。这并不可怕,但仍然让我感到烦恼,因为我似乎没有办法强制操作系统不要代表我执行错误的位块传输。 :( - Mordachai

0

如果你能找到一个插头的地方,CWnd::LockWindowUpdates() 将防止任何绘图发生,直到你解锁更新。

但请记住,这是一种hack方法,而且相当丑陋。在调整大小期间,你的窗口将看起来很糟糕。如果你遇到的问题是在调整大小期间闪烁,那么最好的方法是诊断闪烁,而不是通过阻止绘制来隐藏闪烁。

要查找的一件事是在调整大小期间过于频繁调用的重绘命令。如果你的窗口控件使用指定了RDW_UPDATENOW标志的RedrawWindow()进行调用,它将立即重新绘制。但你可以去掉该标志并改为指定RDW_INVALIDATE,这告诉控件无需重新绘制即可使窗口无效。它会在空闲时间重新绘制,保持显示新鲜而不会抽搐。


LockWindowUpdate可以防止同一个控件重复绘制,但无法防止不同控件之间的重叠绘制(当解锁时它们都会收到WM_PAINT消息),因此在某些情况下有帮助但并非全部。更多信息请参见此处:http://blogs.msdn.com/oldnewthing/archive/2007/02/19/1716211.aspx - peterchen
如果我在调整/移动开始时使用LockWindowUpdate(),并尝试为每个WM_SIZE迭代解锁它-这将导致无法看到窗口移动,也无法看到任何一个调整大小的迭代。它锁定整个窗口,包括其框架,而不仅仅是其内容。因此,对于我的目的来说,这实际上是一条死路。 - Mordachai

0

有各种方法,但我发现唯一通用的方法是双缓冲:绘制到离屏缓冲区,然后将整个缓冲区传输到屏幕。

这在Vista Aero及以上版本中是免费的,所以你的痛苦可能会很短暂。

我不知道XP下是否有通用的双缓冲实现窗口和系统控件,但是这里有一些可以探索的东西:

Keith Rule's CMemDC 用于使用GDI自行绘制的任何双缓冲内容
WS_EX_COMPOSITED 窗口样式(请参见备注部分,以及stackoverflow上的这篇文章


我不清楚父窗口如何强制其子窗口将自己绘制到其双缓冲区中。 - Mordachai
@Mordachai: 实际上这可能很有用。使用双缓冲以自下而上的绘制顺序来绘制窗口的所有子级,这里的"descendants"应该是指子窗口。无需进行任何显式的双缓冲操作,Windows会为您完成。 - John Knoeller

0

只有一种有效的方法来诊断重绘问题 - 远程调试。

获取第二台电脑。在其上安装 MSVSMON。添加一个后构建步骤或实用程序项目,将您的构建产品复制到远程 PC。

现在,您应该能够在 WM_PAINT 处理程序、WM_SIZE 处理程序等处设置断点,并跟踪您的对话框代码,以便在执行大小和重绘时进行跟踪。如果您从 MS 符号服务器下载符号,则可以看到完整的调用堆栈。

在 WM_PAINT、WM_ERAGEBKGND 处理程序中放置一些适当的断点,您应该能够很好地了解为什么在 WM_SIZE 周期早期同步重绘窗口。

系统中有很多窗口都由具有分层子控件的父窗口组成 - 浏览器窗口具有大量复杂的列表视图、树视图预览面板等。浏览器在调整大小时没有闪烁问题,因此可以清楚地实现父窗口的无闪烁调整大小:您需要做的是捕获重绘事件,找出引起它们的原因,并确保消除这个原因。


你是否发现,通过拥有远程机器来调试绘图代码比仅仅拥有第二个显示器,并在一个显示器上运行调试器,在另一个显示器上运行应用程序更加高效?我发现它们是等价的。 - Mordachai
另外,我的观察是,在运行带有Aero的Windows 7时,许多应用程序首先进行绘制,就好像所有的控件都移动了,然后再在它们正确的位置上重新绘制。Windows资源管理器是一个明显的例外,几乎没有重绘延迟显示。 - Mordachai
1
如果您在单台计算机上尝试调试窗口绘制代码,您会遇到由于调试器每次单步操作时激活而引起的效果。这往往会使窗口管理器恐慌,并开始绘制非客户区域,特别是在进程外部,因为它认为进程未响应(当进行单步操作时并不是没有道理的)。 - Chris Becke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接