GDI 编程的 MFC 替代方案






4.62/5 (16投票s)
2006年1月12日
9分钟阅读

82768

1377
探索 MFC GDI 类的内部工作原理并提出替代方案
目录
引言
在我开始学习 MFC GDI 相关类之后,我产生了开发这个项目的想法。我看到了很多我从未用过的特性代码。于是,我想进行一个实验,验证一下使用一个精简版的 GDI 类是否能带来更好的绘制性能。正如你将在结论中看到的,我并没有获得我所期望的结果,并且我曾犹豫是否要写一篇关于我编写的代码的文章。我最终认为,如果另一位程序员正在寻找一个非 MFC 项目中的面向对象的 GDI 封装,那么这段代码可能会对他有所帮助。在本文中,我将首先描述我不喜欢 MFC 代码的地方,突出新类集的要点,介绍演示程序,最后通过展示实验结果来结束。
MFC 的 GDI 类特性并不怎么有用
CDC::m_hAttribDC
就是这些特性之一。大多数时候 m_hAttribDC == m_hDC
,m_hAttribDC
的存在只是给 CDC
代码增加了多余的开销。这里有一个例子
CPoint CDC::MoveTo(int x, int y)
{
ASSERT(m_hDC != NULL);
CPoint point;
if (m_hDC != m_hAttribDC)
VERIFY(::MoveToEx(m_hDC, x, y, &point));
if (m_hAttribDC != NULL)
VERIFY(::MoveToEx(m_hAttribDC, x, y, &point));
return point;
}
此外,与 m_hAttribDC
相关的是,CMetafileDC
类存在缺陷。它强制用户显式设置 m_hAttribDC
,并且 CMetafileDC
禁止您将 m_hAttribDC
设置为 m_hDC
,但这却是错误的!它应该起作用,因为 CreateEnhanced()
的第一个参数是
pDCRef
- 它标识增强型图元文件的参考设备
当它为 NULL
时,参考设备将是显示器。MFC 忽略 pDCRef
并将 m_hAttribDC
设置为 NULL
的原因是,可能是因为 CMetaFileDC
也支持旧的 Windows 1.0 图元文件格式,而这些图元文件没有参考设备的概念。CMetaFileDC
中重写的大多数 CDC
函数都是 virtual
的,在几乎所有情况下,这样做只是为了强制执行图元文件属性 DC 不应等于输出 DC 的错误规则。因此,所有的 CDC
对象都被无谓地应用了 virtual
函数调用的开销。
最后,MFC GDI 类中开销的最后一个来源是句柄映射。句柄映射对于窗口对象至关重要,但我还没有见过 GDI 对象需要句柄映射的场景。当调用 Attach()
、Detach()
和 FromHandle()
函数时,会调用句柄映射。您可能会认为,如果您不调用这些函数,那么您就没有使用它们,对吗?嗯,这是错误的。每次对象被选择到 CDC
对象中时,SelectObject()
函数返回的对象指针都来自 CGdiObject::FromHandle()
。要了解这种开销的规模,请考虑 CGdiObject::FromHandle()
的伪代码
- 尝试在永久对象映射中查找请求的句柄。
- 如果未找到,尝试在临时对象映射中查找请求的句柄。
- 如果未找到,创建一个临时对象。
所有这些临时对象将在 MFC 框架下一次进入空闲函数时被删除。
OLIGDI 特性亮点
OLIGDI
由以下类组成
ODrawObj
OBitmap
OPen
OBrush
OFont
ORgn
OIC
ODisplayInfo
ODC
OClientDC
OWindowDC
OPaintDC
OMemDC
OFlickerFreeDC
OMetaFileDC
OLIGDI
并不声称是一个完整的解决方案,因为只有极少数的 GDI 函数被实现。为每个函数实现包装器只是为了尝试验证我的实验概念会太乏味。然而,框架已经搭建好,并且可以非常容易地根据需要添加函数。
这个新类集的首要设计要求是保持与 MFC 相同的 API 以便兼容。有了这个要求,将代码从使用 MFC 对象修改为使用 OLIGDI 对象就很容易了。只需要更改变量声明语句中的对象类型并重新编译即可。第二个要求是移除 MFC 中所有不需要的功能。这包括
m_hAttribDC
virtual
函数- 句柄映射
此外,OLIGDI
引入了从 Paul Dilascia 所著书籍 Windows++ 中借鉴的两个新特性。第一个改进是,在 MFC 中,如果您想重用一个对象来存储一个不同的 GDI 句柄,您必须先显式调用 DeleteObject()
。这很容易出错,因为如果您忘记调用此函数,将会导致 GDI 资源泄露。在 OLIGDI
中,这一点在每个创建函数中都得到了明确的实现
inline BOOL OFont::CreateFontIndirect(CONST LOGFONT *lf)
{
HFONT hRes;
LASTERRORDISPLAYD( hRes = ::CreateFontIndirect(lf) );
DeleteObj();
set(hRes,OTRUE);
return (BOOL)hRes;
}
下一个借鉴的特性是 DC 对象在被 SelectObject()
调用替换了默认对象后,能够记住这些默认对象,并在对象销毁时将它们重新选入 DC。这个特性消除了用户跟踪默认对象的负担。以下是此特性的相关代码
class OIC
{
public:
/*
* Each type of drawing object has an ID, used as offset to store
* handle in a table.
*/
enum WHICHOBJ { SELPEN=0, SELFONT, SELBRUSH, SELBITMAP,
NDRAWOBJ };
protected:
HDC m_hDC; // Windows handle to DC
BOOL m_del;
HANDLE m_origObj[NDRAWOBJ]; // original drawing objects
int m_anySelected; // whether any new objects are selected
// Other stuff omitted
};
/*
* OIC::select function
*
* Protected method to select a display object
* Destroys old selected object if required.
* "which" specifies whether object is a pen, brush, etc.
* "del" specifies whether to delete this object.
*/
HGDIOBJ OIC::select(WHICHOBJ which, HGDIOBJ h)
{
HGDIOBJ old;
WINASSERTD(h);
old = ::SelectObject(m_hDC, h);
WINASSERTD(old && old != HGDI_ERROR);
if( m_origObj[which] == NULL )
{
m_origObj[which] = old;
m_anySelected++;
WINASSERTD( m_anySelected <= NDRAWOBJ );
}
else if( m_origObj[which] == h )
{
m_origObj[which] = NULL;
m_anySelected--;
WINASSERTD( m_anySelected >= 0 );
}
return old;
}
OIC::~OIC()
{
if (m_hDC)
{
restoreSelection();
if( m_del )
{
LASTERRORDISPLAYD(::DeleteDC(m_hDC));
}
}
}
/*
* OIC::restoreSelection function
*
* Restore selected display objects (pens, brushes, etc.).
*/
void OIC::restoreSelection(void)
{
for (int i = 0; m_anySelected && i < NDRAWOBJ; i++)
{
restoreSelection((WHICHOBJ)i);
}
}
inline void OIC::restoreSelection(WHICHOBJ which)
{
if( m_origObj[which] )
{
WINASSERTD(m_hDC != NULL);
::SelectObject(m_hDC, m_origObj[which]);
m_origObj[which] = NULL; // don't restore twice!
m_anySelected--;
WINASSERTD( m_anySelected >= 0 );
}
}
有一个情况您需要小心。如果 DC 对象和选中的 GDI 对象位于堆栈上,我认为析构函数的调用顺序将与变量声明的顺序相反
void foo(void)
{
OPen p(PS_SOLID,1,RGB(255,0,0));
OClientDC dc(hwnd);
dc.SelectObject(&p);
// Ok, OClientDC destructor will be called first
}
void foo2(void)
{
OClientDC dc(hwnd);
OPen p(PS_SOLID,1,RGB(255,0,0));
dc.SelectObject(&p);
// Boom, the pen will be destructed before being unselected
}
为了避免这类问题,可以在函数末尾显式调用 restoreSelection()
。
一句警告:如果您计划在绘制例程和打印代码之间共享代码,使用 OLIGDI
并不是一个好主意。除非您在 OLIGDI
之上编写自己的打印预览代码,否则 MFC 提供了一个名为 CPreviewDC
的特殊类,它大大改变了 CDC 的打印预览行为,如果您想在这一领域使用 MFC,您将无法重用为 OLIGDI
编写的代码。话虽如此,如果您有绘制性能问题,并且认为窗口绘制的频率远高于打印文档的次数,那么使用 OLIGDI
可能仍然是明智的。
演示程序
基本上,演示程序需要做的是通过使用 OLIGDI
或 MFC 来绘制大量东西,并对操作进行计时,显示两种绘制方法之间的差异。我的演示程序的起点是 Charles Petzold 为他的书 Programming Windows 编写的可爱三叶草程序。他的三叶草程序用线条和一个复杂的裁剪区域绘制了一个三叶草。在演示程序的菜单中,您可以选择三种显示方法:OLIGDI
、MFC
和 Alternate
。前两种方法可以让用户通过调整窗口大小来主观地观察两种绘制方法之间的差异。第三种选项 Alternate
,借助定期强制重绘窗口的计时器选项,允许演示程序计算两种绘制模式之间的差异。计时是通过这个小的辅助类完成的
class cHighResolutionTimer
{
public:
cHighResolutionTimer();
void start();
double stop();
private:
LARGE_INTEGER frequency, startTime;
};
cHighResolutionTimer::cHighResolutionTimer()
{
startTime.QuadPart = 0;
LASTERRORDISPLAYD(QueryPerformanceFrequency(&frequency));
}
void cHighResolutionTimer::start()
{
LASTERRORDISPLAYD(QueryPerformanceCounter(&startTime));
}
double cHighResolutionTimer::stop()
{
LARGE_INTEGER stopTime;
LASTERRORDISPLAYD(QueryPerformanceCounter(&stopTime));
return
(double)(stopTime.QuadPart - startTime.QuadPart)/frequency.QuadPart;
}
编写演示程序中最具挑战性的部分是,从计时测量中输出有意义的数字。我在开发过程中注意到的一点是,多次测量相同的绘制方法会导致计时出现很大的差异。这可能是由多种因素引起的,例如软件不一致(任务切换)和硬件不一致(GDI 设备驱动程序需要等待显卡刷新周期的特定时刻才能执行写入)。由于计时差异与速度差异的量级相同,我在突出这种差异方面遇到了很大的困难。经过多种方法的多次尝试,我设计了以下方案
- 对每种方法进行
NUMSAMP
次测量。 - 对测量结果进行排序。
- 废弃
NUMSAMP/3
个最小和NUMSAMP/3
个最大的测量值。 - 返回剩余测量值的平均值。
#define NUMSAMP 12
class CTimingStat
{
public:
CTimingStat()
{ reset(); }
void reset(void) { m_nSamples = 0; }
void set(double s) { m_samplArr[m_nSamples++] = s; }
const UINT getnSamples(void) const { return m_nSamples; }
double getAverage(void);
private:
double m_samplArr[NUMSAMP];
UINT m_nSamples;
static int __cdecl compare(const void *elem1,
const void *elem2);
};
double CTimingStat::getAverage(void)
{
int a;
double xa = 0.0;
qsort(m_samplArr,NUMSAMP,sizeof(double),
CTimingStat::compare);
for( a = NUMSAMP/3; a < (2*NUMSAMP/3); a++ )
{
xa += m_samplArr[a];
}
xa /= NUMSAMP/3.0;
return xa;
}
int CTimingStat::compare(const void *elem1,
const void *elem2)
{
return (int)(*(double *)elem1 - *(double *)elem2);
}
为了完成演示程序的描述,有一个有趣的 bug 逃过了我的注意。在使用内存 DC 作为双缓冲区来消除闪烁时,几乎所有时候绘制都很正常,除非只需要重绘窗口的一小部分。您可以调整窗口大小,重绘会完美执行,但如果您打开“关于”对话框并将其拖动到客户区,重绘就会全部搞砸。问题在于裁剪区域是为整个客户区计算的,并且内存 DC 窗口原点被设置为无效矩形左上角。当窗口调整大小时,整个客户区都会失效,一切都匹配,但当只有客户区的一小部分失效时,内存 DC 窗口原点不是 (0,0),并且需要移动裁剪区域来考虑这个差异。要自己查看这个问题,只需注释掉 OffsetClipRgn()
调用,然后从菜单中选择双缓冲区选项。
dc.SelectClipRgn((HRGN)RgnClip.GetSafeHandle());
/*
* Since Clip region is in device point, it is important to offset
* it because the double buffering DC window origin is set at the top
* corner of the invalidated rect.
*/
dc.OffsetClipRgn(p.x,p.y);
结论
结果非常令人失望。在我的机器上,我获得了 1% 到 3% 的微小改进。结果似乎很大程度上取决于运行演示程序的硬件;我在不同的机器上测试过,除了少数几台机器上看到了 10%-15% 的改进,总体上改进率都在 5% 以下。没有测量,这种差异在视觉上是无法感知的。从这个实验可以得出的结论是,尽管 MFC 存在开销,但与 GDI 函数本身花费的时间相比,它是微不足道的。
就是这样!希望您喜欢这篇文章,如果您喜欢并且觉得它很有用,请花几秒钟给它评分。您可以在文章底部进行评分。另外,如果您在您的机器上获得了惊人的演示程序结果,或者您找到了这段代码的应用场景,我很想听听您的想法!
参考文献
- Paul DiLascia, Windows++ Addison Wesley, 1992
- Charles Petzold, Programming Windows, Fifth Edition Microsoft Press, 1999
- Jeff Prosise, Programming Windows with MFC, Second Edition Microsoft Press, 1999
- George Shepherd, Scot Wingo, MFC Internals Addison Wesley, 1996
历史
- 06-19-2007
- 更新下载:已修复 bug
- 01-09-2006
- 原文
许可证
本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。
作者可能使用的许可证列表可以在此处找到。