对于MFC程序员来说做UI开发是痛苦的事情,不过大多数情况下我们都需要做这件事情,因为MFC自带的控件实在是太简陋了。这时候我们多半会涉及到自绘控件,随之而来的很可能就是窗口和控件的闪烁问题。这篇文章希望对MFC的窗口和控件闪烁问题做一个尽量全面的总结。
一、闪烁的原因
引起闪烁的原因很多,以至于网上有n多种解决闪烁问题的方法;如果你按照某一种方法做了仍然没有解决你的问题,请不要认定这个方法有问题,而是你没有对上号。如果你对这个解释不满意的话,我们就来深究一下到底是什么引起了闪烁。从原理上讲,闪烁是因为屏幕上连续的两次或多次输出画面差别比较大引起的,这是最根本的原因。因此如果窗口绘制差别不大,即使刷新再频繁,也不会引起闪烁。但是差别较大的画面输出一定会引起闪烁吗?还有一个因素要考虑进来,就是屏幕的刷新频率。根据显卡和显示器的不同,屏幕的刷新周期是不一样的,虽然这个参数的差别对界面开发的影响几乎可以忽略,但是如果你真的从思想上理解了这一点,你就会立即明白为什么双缓冲技术能够帮助我们解决一部分闪烁问题。
二、再谈闪烁的原因
虽然第一部分的描述对我们有一些启发,但我们还是应该更深入一些!哪些情况下会导致我们的窗口或控件输出连续的差别较大的绘制界面呢?
1、绘制界面太复杂,一个刷新周期内绘制不完,每次都输出一部分绘制结果,导致几次刷新闪烁。
我们的绘制过程都是通过很多个绘制语句组成的,如果这些语句加起来的时间大于一个刷新周期,那么就很可能引起闪烁。通常的解决办法是去掉中间过程的刷新,直到最后整体绘制完毕再一次性刷新。是不是似曾相识,这就是双缓冲技术的原理!但是有些情况是双缓冲也无能为力的,后面再讲。
2、绘制过程很简单,但是需要频繁刷新。
这种情况下我们首先需要弄清楚频繁刷新的原因是什么,不同的原因对应不同的解决办法。但是归根结底,我们还是为了减少刷新的次数或者尽量去掉中间输出差别较大的绘制输出。
3、刷新过程。
对于窗口或控件的界面显示,windows系统有一套绘制和刷新的规则,绘制或刷新的时机选择也是影响闪烁的重要因素。如果再与上面两条结合起来,某些情况下引起闪烁的原因确实非常复杂。只有我们分析出问题所在,才能用正确的方法解决之。
三、几种消除闪烁的解决方案
1、尽量减少重复绘制
MFC的窗口和控件刷新有一套很复杂的规则,如果我们能深入理解,正确应用的话就能避免一部分闪烁。比如尽量用 InvalidateRect() 函数代替 Invalidate() 函数,InvalidateRect() 函数只刷新界面上指定的区域,如果我们的界面上只有一小部分需要频繁刷新,那么用这个函数代替 Invalidate() 的话,解决闪烁问题的效果是非常明显的。这个函数已经封装到MFC的CWnd类中(也有API函数)。
void InvalidateRect(LPCRECT lpRect, BOOL bErase = TRUE);
其中,lpRect指向一个方形区域,该区域将被添加到需要更新的区域列表中,bErase指定刷新时是否更新区域背景。
如果我们需要刷新的区域是不规则的,比如是几个区域的组合,或者是某区域中去掉一部分,这时候用 InvalidateRect() 不能满足我们的需求,我们可以用 InvalidateRgn() 函数。
void InvalidateRgn (CRgn* pRgn, BOOL bErase = TRUE);
其中,pRgn指向需要刷新的区域。下面是一段示例代码:
Crect rectClient;
CRgn rgn1, rgn2;
GetClientRect(rectClient);
rgn1.CreateRectRgnIndirect(rectClient);
rgn2.CreateRectRgnIndirect(m_rectButton);
rgn1.CombineRgn(&rgn1, &rgn2, RGN_XOR);
InvalidateRgn(&rgn1, FALSE);
有的时候我们的窗口上有很多控件,如果是由我们负责控件刷新(比如窗口设置了WS_CLIPCHILDREN风格),我们最好判断不同情况下确实需要刷新的控件,而不是简单的将所有控件全部刷新一遍,以此将闪烁的影响减小到最小。
2、正确选择窗口重绘时机
Windows有很多刷新和重绘的函数,但是他们的特性和运行方式不尽相同,我们需要了解调用这些函数的注意事项,否则很可能因为实际情况跟我们的预期不同而引起闪烁。
Windows系统是通过WM_PAINT消息来通知界面重绘的,该消息一般由系统自动产生,比如当窗口被创建、改变大小、最大化、移动、覆盖等等,另外当UpdateWindow等函数被调用时也会产生WM_PAINT消息。
当窗口重绘时,并不一定整个窗口区域都需要刷新,而只是需要更新的那一部分,这部分区域叫做“无效区域”。系统在发现消息队列空闲时会检查无效区域,如果存在就会发送WM_PAINT消息进行刷新。
Invalidate()、InvalidateRect()、InvalidateRgn()这些函数都只是产生无效区域,而并没有发送WM_PAINT消息,也就是说我们调用这些 Invalidate() 函数时,并不一定会使窗口立即刷新,而是要等到下次WM_PAINT消息进入到消息队列时才行。如果要使重绘立即执行,可以调用 UpdateWindow() 函数或者 RedrawWindow() 函数强制刷新。
Windows的窗口重绘时,会首先判断是否需要刷新背景,如果需要则首先刷新窗口背景,然后进入OnPaint()函数进行窗口内容的绘制。这个过程中如果操作不当,也有可能引起闪烁。当我们遇到闪烁问题,可以从以上窗口绘制机制中查找是否某些步骤的操作引起了闪烁。比如我们在对一个CListCtrl控件进行频繁操作时(比如添加多个项或者修改内容),可以先调用 SetRedraw(FALSE),在操作全部完成后,再调用 SetRedraw(TURE) 完成一次性刷新。
3、控制窗口背景刷新
Windows窗口背景刷新默认情况下是系统帮你完成的,如果我们的窗口绘制内容和背景差别比较大,或者在刷新背景和刷新窗口绘制之间有一个明显的时间间隔,就有可能引起闪烁。
这个时候我们可能要禁止系统默认的背景绘制,而在窗口绘制函数中自行处理背景。这时只要重载 OnEraseBkgnd() 函数,并直接返回TRUE就可以了,代码如下:
BOOL CMyWnd::OnEraseBkgnd(CDC* pDC)
{
return TRUE;
// return CWnd::OnEraseBkgnd(pDC); // 注释掉默认语句
}
4、双缓冲
也许你已经听说过双缓冲这种方法了,的确,多数情况下双缓冲能很好的解决我们的窗口闪烁问题,尤其是涉及到窗口自绘的时候。双缓冲的基本原理是首先将复杂的绘制结果输出到内存DC上,然后再一次性输出到真正的窗口DC,这样就避免了由于绘制时间占用多个刷新周期,而导致一次绘制引起短时间多次输出产生闪烁。双缓冲方法结合上一个方法,可以解决大部分自绘窗口的闪烁问题。具体的双缓冲示例代码如下:
void CMyWnd::OnPaint()
{
CPaintDC dc(this);
CRect rectClient;
GetClientRect(&rectClient);
CDC dcMem;
CBitmap bmpMem;
dcMem.CreateCompatibleDC(&dc);
bmpMem.CreateCompatibleBitmap(&dc, rectClient.Width(), rectClient.Height());
dcMem.SelectObject(&bmp);
// 此处将绘制内容输出到dcMem上
// dcMem.FillRect(rectClient, &brush);
dc.BitBlt(0, 0, rectClient.Width(), rectClient.Height(), &dcMem, 0, 0, SRCCOPY);
bmpMem.DeleteObject();
dcMem.DeleteDC();
}
5、合理设置WS_CLIPCHILDREN和WS_CLIPSIBLINGS风格
当我们的窗口界面有多层窗口组成时(比如包含多个控件的对话框),用到自绘窗口可能会经常碰到闪烁问题。因为多层窗口会涉及到很多遮挡,重绘时一般涉及到主窗口和子窗口等多个窗口,而这些窗口的刷新可能不会在一个刷新周期内完成,从而引起闪烁。这时我们可以通过设置WS_CLIPCHILDREN和WS_CLIPSIBLINGS这两个窗口风格来控制刷新行为。
Clip是裁剪的意思,两个属性的具体含义如下:
带有WS_CLIPCHILDREN风格表示裁剪掉子窗口的区域,即当该窗口重绘时,它的子窗口区域不刷新,而留给子窗口自己去刷新;
带有WS_CLIPSIBLINGS风格(只用于子窗口)表示裁剪掉兄弟窗口的区域,即当该窗口重绘时,与兄弟窗口重叠的区域将不会被刷新。
根据这些窗口行为,我们就能优化我们的界面刷新,控制一些窗口的刷新时机,或者减少重叠区域的重复刷新。比如当对话框窗口放置了大量控件时,我们可以给对话框加上WS_CLIPCHILDREN风格来阻止一些不必要的刷新。
6、多层次窗口调整大小
如果窗口包含很多子窗口,当我们调整窗口大小时,可能要同时调整子窗口的位置和大小。此时若使用 MoveWindow() 或 SetWindowPos() 等函数进行调整,由于这些函数会等窗口刷新完才返回,因此当有大量子窗口时,这个过程肯定会引起闪烁。
这时我们可以应用 BeginDeferWindowPos(), DeferWindowPos() 和 EndDeferWindowPos() 三个函数解决。首先调用 BeginDeferWindowPos(),设定需要调整的窗口个数;然后用 DeferWindowPos() 移动窗口(并非立即移动窗口);最后调用 EndDeferWindowPos() 一次性完成所有窗口的调整。
7、拖动和调整大小时的虚线框
当以上方法无效或者实现起来过于复杂,有没有更统一更简洁的方法呢?可能你曾经注意到Windows操作系统有这样一种视觉效果(右击我的电脑-> 属性-> 高级-> 设置 -> 视觉效果-> 自定义,去掉“拖拉时显示窗口内容”选项),当你拖动和调整窗口大小时,并不是即时显示窗口内容,而是出现一个虚线框,当调整结束时才一次性绘制最终界面。这时一个非常好的防止闪烁的方法,我们来看看怎么实现这种效果。
比较复杂的方法是自己画虚线框,响应WM_MOVING消息画虚线框,响应WM_MOVE消息绘制窗口内容,不过这个方法的难度可想而知,具体内容可以查看这个讨论帖 http://topic.csdn.net/u/20070519/13/c4f0e32a-552c-4b66-9e9e-1b68f6c7c104.html。
有没有简单的方法呢?调用 SystemParametersInfo 这个API函数可以改变系统“拖拉时显示窗口内容”项的设置,但是如果我们设置以后,系统其他窗口的行为也将被改变。其实我们只要判断什么时候需要绘制虚线框,此时调用SystemParametersInfo(SPI_SETDRAGFULLWINDOWS, FALSE, NULL, SPIF_SENDWININICHANGE),然后在拖动完毕需要绘制的时候调用 SystemParametersInfo(SPI_SETDRAGFULLWINDOWS, TRUE,
NULL, SPIF_SENDWININICHANGE) 恢复设置就可以了。当然如果希望完全不影响系统原来的设置,我们只要每次都先查询一下系统原设置,然后恢复设置就可以了。
具体处理过程是在CDialog的OnNcLButtonDown消息响应函数中,当用户点击对话框的非客户区时该函数会被调用,而我们移动窗口或者调整窗口大小都是要点击非客户区(标题栏或边框)触发该消息。拖动过程中的处理是在CDialog::OnNcLButtonDown(nHitTest, point)中完成的,因此,我们只要按如下代码实现即可:
void CMyDlg::OnNcLButtonDown(UINT nHitTest, CPoint point) { // 1,查询当前系统“拖动显示窗口内容”设置 SystemParametersInfo(SPI_GETDRAGFULLWINDOWS, 0, &m_bDragFullWindow, NULL); // 2,如果需要修改设置,则在每次进入CDialog::OnNcLButtonDown默认处理之前修改 if(m_bDragFullWindow) SystemParametersInfo(SPI_SETDRAGFULLWINDOWS, FALSE, NULL, NULL); // 3,默认处理,系统会自动绘制虚框 CDialog::OnNcLButtonDown(nHitTest, point); // 4,默认处理完毕后,还原系统设置 if(m_bDragFullWindow) SystemParametersInfo(SPI_SETDRAGFULLWINDOWS, TRUE, NULL, NULL); }