Jamebo

导航

翻译: 绘制窗体时防止闪烁

 

这是我第一次尝试翻译英文技术文章,不正确的地方还希望大家给与指出,谢谢:)

原文在这里:如何在绘画窗口时防止闪烁

http://www.catch22.net/tuts/flicker.asp

====================================================================================================================================================

如果一个程序出现闪烁现象,会让人觉得程序编写人员很马虎,缺乏对细节的足够重视。Windows程序的任何部分都没有任何理由出现闪烁现象。这篇文章的目的是告诉读者如何使用相关的技术防止窗口出现闪烁效果。

什么是闪烁

闪烁可以这样定义:当后面一幅图像以很快的速度画在前面一幅图像上时,在后面图像显示前,你可以很快看到前面那一个图像,这样的现象就是闪烁。我认为,闪烁会让使用者对程序很不满,原因是:如果用户接口编码如此糟糕,那么程序的其他部分呢,如何能相信数据的正确性呢?一个具有平滑,快速相应的程序会给用户带来信心,这个道理很简单。

程序出现闪烁可以由多种形式造成,最常见的原因是窗口大小发生改变时,其内容重画造成闪烁。

仅仅画一次

这是一个黄金法则,在任何计算机(Windows或者你使用的任何操作系统)上处理画法逻辑都需要遵循,即永远不要将同一像素画两次。一个懒惰的程序员常常不愿意在画法逻辑上投入过多精力,而是采用简单的处理逻辑。要避免闪烁,就需要确保不会出现重复绘制的情况发生。现在,WIndows和计算机还是很笨的,除非你给他们指令,否则他们不会做任何事情。如果闪烁的现象发生,那是因为你的程序刻意地多绘制了屏幕的某些区域造成的. 这个现象可能是因为一些明确的命令,或者一些被你忽视了的地方。如果程序有闪烁的现象出现,你需要你知道如何找到好的方案去解决这个问题。

WM_ERASEBKGND

通常,首先需要怀疑的是WM_ERASEBKGND消息。当一个窗口的背景需要被擦除时,这个消息会被发送。这是因为窗口的绘画通常经历了两个过程

  • WM_ERASEBKGND: 清除背景
  • WM_PAINT: 在上面绘制内容

这两个过程让窗体在绘制内容时变得很简单,即:每次当收到WM_PAINT消息时,你知道已经有了一个新画布等待去绘制。然而,画窗口两次(一次是通过WM_ERASEBKGND画背景,另外一次是WM_PAINT)将会导致窗口出现比较糟糕的闪烁现象。只要看看标准的编辑框-打开Windows的写字板并改变窗口大小,就可以看到那种闪烁的效果。

那么,如何避免窗口背景的重刷呢?有如下两种方法:

  • 设置窗口背景刷子为NULL(当注册Windows类时,设置WNDCLASS结构中的hbrBackground成员为零)
  • 在WM_ERASEBKGND消息处理时 返回非零值

以上任何一种方法都可以阻止WM_ERASEBKGND 消息去清除窗口。其中,第二个方案的通常可以以如下代码实现:

case WM_ERASEBKGND:
    return 1;

当你标记窗口内容无效并试图更新时,还有如下办法可以防止WM_ERASEBKGND消息:InvalidateRect函数的最后一个参数可以指明在下一次窗口重画时,是否窗口的部分背景会被重刷。将该参数置为False可以防止当窗口需要重画时系统发出WM_ERASEBKGND消息。

InvalidateRect(hwnd, &rect, FALSE);

不该画的时候一定不要画

有一个比较普遍的现象:即使窗口中只有一个小的部分发生了改变,往往所有的部分都会被重画。比如,经常地,当窗口大小被改变时,一些(不是所有)的程序会重画所有的窗口。通常,这是个是不必要的,这是因为当窗口大小被改变时,经常是之前窗口的内容是不变的,仅仅是改变大小造成的一个小的边界区域需要重画。此时,没有必要重画所有区域。如果在这里多注意,多考虑,就可以使用好的算法以使得一次只有最小的部分被画。

系统中每个窗口都有更新区域。这个区域描述了窗口中变得无效需要重画的地方。如果一个窗口仅仅其需要更新的区域,不多绘制其他地方,那么窗口的绘制效果将会非常快。

有几种方法可以获得窗口的更新区域。通过GetUpdateRgn 函数可以获得准确的更新区域,这个函数返回的结果可以使矩形的区域也可以是非矩形的区域。通过GetUpdateRect 函数可以获得需要更新的最小矩形区域。通常使用矩形的更新区域比较容易。第三个方法是在BeginPaint/EndPaint中得到PAINTSTRUCT 结构,从而得到准确的更新区域信息。

一个常规的画法函数是这样的:

PAINTSTRUCT  ps;
HDC          hdc;
case WM_PAINT:
    hdc = BeginPaint(hwnd, &ps);
    // do painting
    EndPaint(hwnd, &ps);
    return 0;

BeginPaint函数初始化PS(PAINTSTRUCT)结构,其中,成员rcPaint是一个RECT结构,描述了包含了需要更新的最小矩形区域(就像GetWindowRect函数)。

如果仅仅在这个矩形区域上绘制窗口,速度上绘有很好地提高。

现在,当使用BeginPaint/EndPaint时Windows会自动剪切掉画在更新区域外面的部分。这意味着,你没有机会画到更新区域以外的地方。可能你会认为,如果是这样的话,花功夫确保代码不试图画到更新区域外是没有意义的,反正没有画出任何东西来。然而,你仍然可以避免不必要的API调用和相关计算,所以,我认为放一些精力在如何工作地更快上是绝对值得的。

如果还是不能解决

 有些时候,当你花了很多努力去考虑非常好的画法时,发现窗口还是会被全部刷新。这通常是由两个Window 类的属性造成的:CS_VREDRAWCS_HREDRAW。如果有其中一个标志被设置时,那么当窗口水平或者竖直方向有大小被改变时,其内容每次都会被重新刷新。所有,你需要关掉这两个标志,解决的唯一的方式是在创建窗体和窗体类被注册时,确保这两个属性不被设置。

WNDCLASSEX wc;
wc.cbSize  = sizeof(wc);
wc.style   = 0; /* CS_VREDRAW | CS_HREDRAW; */
...
RegisterClassEx(&wc);

上面的例子描述了当窗体类被注册时,这两个属性不被设置的实现方法。

有一点需要注意:如果主窗体有了这两个属性,即使子窗体没有重画标志,会导致所有子窗体在其大小被改变时会被重绘。可以通过以下方式避免这个情况发生:

剪切子窗体
有时,闪烁的原因是因为当重画时,父窗体没有剪切其子窗体区域。这样的结果导致,整个父窗口内容被重画,而子窗体又被显示在了上面(造成闪烁)。这个可以通过在父窗体上设置WS_CLIPCHILDREN 来解决。当这个标志被设置时,被子窗体占据的任何区域将会被排除在更新区域外。因此,即使你尝试在子窗体所在的位置上绘制(父窗口的内容),BeginPaint中的剪切区域也会阻止其绘制效果。
双缓冲和内存设备描述表(Memory Device Context, 简称Memory-DC)

常见的彻底避免闪烁的方法是使用双缓冲。其基本的思路是:将窗体的内容画在屏幕外的一个缓冲区内,然后,将该缓冲区的内容再传递到屏幕上(使用BilBlt函数)。这是一个非常好的减少闪烁的方法,但是经常被滥用,特别是当程序员并不真正地理解如何有效地绘制窗口时。

典型的双缓冲代码如下:

HDC          hdcMem;
HBITMAP      hbmMem;
HANDLE       hOld;
PAINTSTRUCT  ps;
HDC          hdc;
....
case WM_PAINT:
// Get DC for window
hdc = BeginPaint(hwnd, &ps);
// Create an off-screen DC for double-buffering
hdcMem = CreateCompatibleDC(hdc);
hbmMem = CreateCompatibleBitmap(hdc, win_width, win_height);
hOld   = SelectObject(hdcMem, hbmMem);
// Draw into hdcMem
// Transfer the off-screen DC to the screen
BitBlt(hdc, 0, 0, win_width, win_height, hdcMem, 0, 0, SRCCOPY);
// Free-up the off-screen DC
SelectObject(hdcMem, hOld);
DeleteObject(hbmMem);
DeleteDC    (hdcMem);
EndPaint(hwnd, &ps);
return 0; 

这个方法比较慢,因为在每次窗体需要重画的时候内存设备描述表(Memory-DC)都需要被重新创建。更有效的方法是,仅仅创建内存设备描述表(Memory-DC)一次,并使其足够大到能满足任何时候的整个窗体刷新。当程序结束时,再销毁这个内存设备描述表(Memory-DC)。这两种方法都存在对内存开销的问题,特别是如果内存设备描述表(Memory-DC)是针对真个屏幕的大小。双缓冲也需要两倍的时间去画。这是因为其第一次是在内存设备描述表(Memory-DC)上画,然后再使用BitBlt画回到屏幕上。当然,好的显卡会使BitBlt更快,但是仍然会耗CPU 时间。

如果程序需要显示相当复杂的信息,比如像网页,那么你应该使用内存设备描述表(Memory-DC)。比如IE,如果不使用双缓冲,是没有办法在绘制网页时不闪烁的。

没有必要将双缓冲技术用于整个窗体的绘制中。可以这样设想,窗口中仅仅有一个小部分包含了复杂的图形对象(比如半透明的位图或者其他)。你应该将内存设备描述表(Memory-DC)仅仅用于着一个小区域,其他区域使用常规的方法。 有时,通过仔细的思考,经常可以避免使用双缓冲而直接将结果画到屏幕上。只要你不破坏黄金法则,即“永远不要将一个像素画两次”,就可以防止闪烁的出现。

避免过度绘制

我想说的关于这个话题是这样的:有一个需要自己定义画法的窗体的标题栏。首先,你画了标题,接着在上面画一些其他的图形。现在,只要标题需要被重画,就会出现闪烁现象。这是因为你没有合乎黄金法则。这里,标题被很快地显示在其他图形在上面绘制时,导致了闪烁。

有两种技术可以组织这种类型的闪烁。第一个是使用剪切,第二个是使用你的大脑。

使用剪切时,你可以使用ExcludeClipRect 函数在设备描述表中去标记一个特定的区域。当一个区域被标记上时,即使在该区域上面重画也不会产生效果。一旦背景已经被绘制了,可以通过SelectClipRgn移掉该标记的区域,其他图形能被画到前面标记的区域上。通过准确的标记(剪切),可以在很多时候被避免过度绘制。

另外一个方案就是找更聪明的解决办法。比如,当你需要画一个表格,通常应该先画空的背景,再画网格线从而产生表格。但是,这个方法会使网格线产生闪烁,这是因为在网格线被画之前,下面背景被很快地显示了一下。然而可以使用不同的做法达到想要的结果。即,不是一次画一个大的空背景,而是画一系列的空方块,每一个方块边是被一个像素的宽度分开。这样,当画网格线时,他们刚好能被画到一个之前没有画过的地方。其结果是不会有闪烁现象,因为没有像素被画了超过两次。

使用你的头脑去想一个好的算法可能需要长一点的时间,但是却是值得的,因为这能让结果更好。

结论

希望你再也不会问:“为什么我的窗体会闪烁”这样的问题。我已经讲解了闪烁的主要原因和解决办法。如果你遇到了闪烁的问题,你应该能找到原因并且使用这里提到的技术来解决了。

=============

附原文:

Flicker is the sign of sloppy programming and a lack of attention to detail. There is no reason why any part of a Windows program should flicker. The aim of this article is to present the reader (that's you) with the techniques used to prevent their windows applications from flickering.

What is flickering?

Flicker is simply this: the display of one image over the top of another in rapid succession. The result of this is screen flicker, where you can see one image briefly before another one is shown on top. Personally I find applications that "flicker" annoying to use, for this one reason: If the user-interface has been badly coded, then what does this say about the rest of the application, the part that you trust your data with? An application that has a smooth, fast user interface inspires confidence in it's users - it's as simple as that.

An application can flicker in many ways. The most common cause is when a window is resized, causing the contents to flicker badly as it is redrawn.

Only draw things once

This is the golden rule when doing any kind of painting on a computer, be it Windows or whatever OS you are using. You must never draw over the same pixel twice. A lazy programmer will often avoid putting any thought into the painting process, instead opting to take the easy route.

With the case of flickering, it is your responsiblity to ensure that no "overdraw" occurs. Now, Windows and your computer are fundamentally stupid; they won't do anything unless you instruct them explicitly. If any flickering is occuring, it is because some part of your program has deliberately overdrawn some area of the screen.

This may be because of some explicit command, or something which you have neglected to do. In either case, if your Windows program has a flickering problem, you need to understand how best to remove the problem.

WM_ERASEBKGND

The prime suspect is usually the WM_ERASEBKGND message. This message is sent to a window when it's background needs to be erased. This happens because windows are usually painted using a 2-stage process:

  • WM_ERASEBKGND: Clear the background
  • WM_PAINT: Draw the contents on top

This makes it easy to draw a window's contents: Every time you receive a WM_PAINT message, you know that you have a nice fresh canvas to draw on. However, drawing a window twice (once with WM_ERASEBKGND, once again with WM_PAINT) will cause the window to badly flicker. Just take a look at the standard Edit control in Windows - open up Notepad.exe and resize the window, and see how the contents flicker as it is redrawn.

Right then, how do we avoid erasing the background of a window? There are two methods.

  • Set the window's background brush to NULL. (Set the hbrBackground member of the WNDCLASS structure to zero when you register the window class).
  • Return non-zero in the WM_ERASEBKGND message handler.

Any one of these will steps will prevent the WM_ERASEBKGND message from clearing the window. The last option is usually easiest to implement:

case WM_ERASEBKGND:
return 1;

It is also possible to prevent WM_ERASEBKGND when you invalidate and update a window. The InvalidateRect API call's last parameter specifies whether or not a portion of a window is to have it's background erased when it is next redrawn. Specifying FALSE for this paramter prevents WM_ERASEBKGND from being sent when the window is redrawn.

InvalidateRect(hwnd, &rect, FALSE);
Don't draw things when you don't have to

It is quite common for a Windows application to redraw it's entire window contents, even if only a small part of it changed. This is most usually the case when a window is resized - some (but not all) programs redraw the whole window. This is normally not necessary, because when a window is resized, more often than not the previous window contents is left unchanged, and the resize has just uncovered a small border which needs painting. It is not necessary to redraw the entire contents in this case. If a little thought and care is used, the painting algorithms can be written so that only the bare minimum is painted at any one time.

Every window in the system keeps an update region. This region describes the area of a window that has become invalidated and needs repainting. If a windows only updates the required area, and no more, then the window will draw much quicker as a result.

There are several ways to retrieve the update region for a window. The GetUpdateRgn API call retrieves the exact region, be it rectangular, or a more irregular shape. The GetUpdateRect API call retrieves the smallest bounding rectangle that encloses the update region. It is usually easier to just work with a rectangular area like this. The third method is to use the PAINTSTRUCT structure in conjunction with the BeginPaint/EndPaint API calls.

A normal painting procedure looks like this:

PAINTSTRUCT  ps;
HDC          hdc;
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);
// do painting
EndPaint(hwnd, &ps);
return 0;

BeginPaint initializes the ps (PAINTSTRUCT) structure. One member, rcPaint, is a RECT structure which describes the smallest bounding rectangle that encloses the update region (Just like the GetWindowRect API call). By only limiting drawing to just this rectangular region, painting can be dramatically sped up.

Now, Windows automatically clips any drawing you perform outside the update region when you use BeginPaint/EndPaint. This means that there is no way you can draw outside the update region even if you tried. You might think that it is pointless to make sure your code doesn't try to draw outside the update region, even when nothing will be drawn anyway. However, you are still avoiding unnecessary API calls and calculations, so I think it is always worth putting in a little more effort to get things working as fast as possible.

When you just can't help it

There are occasions when you spend alot of time and effort getting your super-duper drawing code working, only to find that your window is still getting redrawn in it's entirety. This is usually the cause of two window class styles - CS_VREDRAW and CS_HREDRAW. When a window class has either of these two styles set, the window contents will be completely redrawn every time it is resized either vertically or horizontally (or both). So, you need to turn off these two class styles. The only way to do this is to make sure your window isn't created with them in the first place, and to prevent this from happening, you have to make sure that CS_HREDRAW and CS_VREDRAW aren't included when the window class is registered.

WNDCLASSEX wc;
wc.cbSize  = sizeof(wc);
wc.style   = 0; /* CS_VREDRAW | CS_HREDRAW; */
...
RegisterClassEx(&wc);

The above example is just to help illustrate the point that these two styles must not be included when the window class is registered.

Just a word of warning here: If the main window in an application has these two class styles set, then this will cause all child windows to be redrawn during a resize, even if those children don't have the redraw flags set. This can be avoided by following the next step:

Clipping child windows

Sometimes flickering occurs because a parent window doesn't clip it's children when it paints itself. This results in the entire parent window contents being shown, and the the child windows being displayed on top (causing flicker). This can be easily solved by setting the WS_CLIPCHILDREN style on the parent window.

When a window has this style set, any areas that its child windows occupy are excluded from the update region. So, even if you try to draw over a child control, the clipping region that BeginPaint assigns will prevent you from doing so.

Double-buffing and memory-DC's

A common method to completely eliminate flickering windows is to use a technique called double-buffering. This basic idea is to draw a window's contents into an off-screen buffer, and then transfer this buffer to the screen in one fell-swoop (using BitBlt). This is a pretty good way to reduce flicker, but is often overused, especially by programmers who don't really understand how get efficient drawing working.

The basic way double-buffering works is like this:

HDC          hdcMem;
HBITMAP      hbmMem;
HANDLE       hOld;
PAINTSTRUCT  ps;
HDC          hdc;
....
case WM_PAINT:
// Get DC for window
hdc = BeginPaint(hwnd, &ps);
// Create an off-screen DC for double-buffering
hdcMem = CreateCompatibleDC(hdc);
hbmMem = CreateCompatibleBitmap(hdc, win_width, win_height);
hOld   = SelectObject(hdcMem, hbmMem);
// Draw into hdcMem
// Transfer the off-screen DC to the screen
BitBlt(hdc, 0, 0, win_width, win_height, hdcMem, 0, 0, SRCCOPY);
// Free-up the off-screen DC
SelectObject(hdcMem, hOld);
DeleteObject(hbmMem);
DeleteDC    (hdcMem);
EndPaint(hwnd, &ps);
return 0; 

This method is a little slow, because the offscreen memory-DC is created from scratch every time the window needs to be drawn. A more efficient method would be to create the memory DC only once, big enough so that the entire window can be painted at any time. When the application terminates, the memory DC would then be destroyed. Both these methods are potentially quite memory-intensive, especially if the memory DC needs to be the size of a screen (1024 * 768 * 32 bytes=2.5 Mb).

Double-buffering will also be twice as slow as it needs to be. Because you are drawing once to the memory-DC, then again during the "blit", you are using up clock cycles when you don't need to. Granted, a fast graphics card will perform a BitBlt very quickly, but it's still wasted CPU.

If your application needs to display quite complicated information (say, like a web-page), then you would need to use the memory-DC method. Take Internet Explorer, for instance. There is no way it would be able to render a web-page with no flickering without using double-buffering.

Double-buffering doesn't have to be used to paint a whole window. Imagine that you had just a small portion of a window that contained a complex graphic object (maybe a semi-transparent bitmap or something). You could use an off-screen DC to draw just this one region, and BitBlt that to the screen, whilst drawing the rest of the window normally.

Sometimes though, with a little careful thinking, it is often possible to avoid double-buffering and draw straight to the screen. As long as you don't break the golden rule, "Never draw over the same pixel twice", you will achieve flicker-free drawing.

Avoiding deliberate overdraw

What I mean by this is the following type of situation. Say, you are custom-drawing the titlebar of a window. You draw the caption first, then draw some additional graphics over the top. Now, whenever the caption needs to be painted, it will flicker. This is because you haven't followed the "golden rule". In this case, the caption is being shown briefly before additional graphics are painted on top, which appear to flicker.

There are two techniques you can use to prevent this type of flickering. The first is to use clipping, the second is to use your brain.

In the case of clipping, you can use the ExcludeClipRect API call to mask out certain areas of a device context. When an area is masked, it is not affected when painted over. Once a background has been drawn, the clipping area can be removed with SelectClipRgn, and another graphic can be painted in the previously masked-out area. By using appropriate masking (or clipping), overdraw can be eliminated in alot of cases.

The other option is to take a more intelligent approach. Imagine you had to draw a grid. A grid would normally be painted by first drawing a blank background, and then drawing a series of lines (horizontal and vertical) to create the grid effect. The problem with this type of approach is that the grid lines will appear to flicker, because the background is briefly appearing underneath each line before the lines are drawn. However, the same effect can be achieved with a different approach. Instead of drawing a single blank background, draw a series of blank squares, separated by a pixel-wide space on each side. When you come to draw the grid lines, they can be placed in the pixel-wide gaps which haven't been painted over yet. The result is the same, but this time there is no flickering because no pixel has been painted over twice.

Using your brain to think around a problem may take slightly longer than the direct "no-brainer" approach, but I think it is worth the extra effort, because the results can be so much better.

Conclusion

Hopefully you should never have to ask the question "Why does my window flicker?" ever again. I have presented the major causes of flickering in a windows program, and also the techniques you can use to remove this flickering. If you encounter flickering in a program you are developing, you should be able to identify the possible causes, and use the techniques described in this tutorial to completely eliminate flicker from your applications.

posted on 2007-03-23 12:00  一直在学习...  阅读(4466)  评论(4编辑  收藏  举报