----哆啦刘小洋 原创,转载需说明出处 2022-12-28
SegeX MemDC
- 1 简介
- 2 基础双缓存技术
- 2.1 MFC绘图机制
- 2.1.1 Window绘图消息
- 2.1.2 背景刷新与屏幕闪烁
- 2.2 双缓存技术消除屏幕闪烁
- 2.3 封装
- 3 更加实用的扩充
1 简介
在VC中用MFC绘制图像时,为避免屏幕闪烁,一般使用双缓存技术(MemDC),也就是绘制时,现将所有的元素绘制到内存句柄,然后一次性显示出来,同时禁用VC自带的背景填充。原理比较简单,网上也有很多现成的代码使用,但一般都只具备基本功能,且只适用于映射模式为MM_TEXT的情况,复杂的场景,比如视图需要放大、缩小,这时很可能要出现问题。
本文旨在详细介绍实际应用的MemDC需要解决的各种问题。将从基础开始,一步一步讲解技术的实现过程、解决问题以及为什么要这么做。
本文附免费的源代码下载,源代码为SegeX组件之一,本次为首次公开。
2 基础双缓存技术
为了统一,我们以视图上的绘制为例,当然在对话框或控件窗口上绘图也是一样的,MFC均针对CDC类实现绘制操作。
2.1 MFC绘图机制
为了保持文件的完整性,先介绍一下MFC绘图的机制,如果你已经很熟悉了,可跳过2.1。
假定工程中的视图类是CcsdnBlogView,与绘制相关的地方有两处:
void CcsdnBlogView::OnDraw(CDC* pDC) 和 BOOL CcsdnBlogView::OnEraseBkgnd(CDC* pDC)。
2.1.1 Window绘图消息
我们可以很轻松的在OnDraw函数中绘制,但OnDraw是怎么实现绘制响应的呢。其实不管是CView、CWnd、CStatic…等等各种窗口,其绘图的时机均来自于Windows消息WM_PAINT,该消息不传送任何参数,只是告诉你屏幕需要绘制了,绘制原因可能是覆盖在上面的窗口移走了,或是你需要更新图像发送了WM_PAINT消息等等,这个我们不用多考虑,MFC和Windows操作系统基本上帮我们处理好了。
我们可以通过类向导添加WM_PAINT消息,添加后会生成消息函数和消息映射入口项,如下:
函数: afx_msg void OnPaint(); void CDlgTest::OnPaint()
消息映射:在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间多了一行:ON_WM_PAINT()
本文不介绍消息映射原理。CcsdnBlogView中的OnDraw函数也是这样来的,但要隐蔽一点,它响应WM_PAINT消息是在基类CView中,代码大概是这样的:
void CView::OnPaint()
{CPaintDC dc(this); OnPrepareDC(&dc); OnDraw(&dc);
}
看到没有,OnDraw是从这里来的。这里又多了个东西CPaintDC和函数OnPrepareDC:
CPaintDC:就是一个CDC,也即是MFC绘图操作的对象,本文不过多纠结。
OnPrepareDC:这是一个虚函数,是MFC架构的一个东西,从名字就可以知道,目的是让你在真正绘图前,做一些可能需要的额外准备工作,比如设置视图滚动条的范围、视图比例等等。在CcsdnBlogView中如果你需要额外准备,可通过类向导添加此虚函数。
2.1.2 背景刷新与屏幕闪烁
但是,为什么要用MemDC呢?背景刷新就和MemDC相关了。
MFC在执行OnPaint之前,会将绘制区域进行刷新处理,也就是将绘制区域清理掉,这个一般是必须的,除非你绘制的东西可以充满绘制区域,不然你第二次绘制的东西和屏幕上以前的东西混在一起了,那就没法看了。因此,VC有个消息来执行这个动作:WM_ERASEBKGND。缺省CcsdnBlogView是不会添加这个消息的响应的,或者说缺省为基类去处理了。
BOOL CcsdnBlogView::OnEraseBkgnd(CDC* pDC)
{// TODO: 在此添加消息处理程序代码和/或调用默认值return CView::OnEraseBkgnd(pDC);
}
基类是怎么处理的呢,如上述代码,在CView::OnEraseBkgnd(pDC)中,很简单,用背景色(一般都是白色)把绘制区域整个重新画一遍。形象的比喻就是,相当于我们在画布画画,有一块要重画了,我们就把那一块用白纸贴上,然后再画。这个处理机制很合理,也是必须的。但是MFC给出的处理方式简单粗暴,会出现屏幕闪烁!!!为什么呢?其实前面的机制已经给出答案了,但为初学者快速理解,这里再啰嗦一下:
1)绘制前贴了一张白纸;
2)在白纸上绘制;
下一次:
1)绘制前又贴了一张白纸;
2)在白纸上绘制;
执行代码的顺序就是:
OnEraseBkgnd();
OnDraw();
...
OnEraseBkgnd();
OnDraw();
所以!首先屏幕先变白,然后再显示图案,因此闪烁出现了。
2.2 双缓存技术消除屏幕闪烁
如果简单粗暴的去掉背景清除,如下:
void CcsdnBlogView::OnEraseBkgnd(CDC* pDC)
{return TRUE;//CView::OnPrepareDC(pDC, pInfo);
}
就会出现绘制重叠,甚至显示后面的其他窗口内容。
怎么办呢?如果是画画,我们会怎么办?我们不先贴纸,而是先画在小纸上,然后直接将画好的纸贴上去不就行了吗?正是如此!这个就是双缓存技术的思路(比喻和实际还是有差异)。具体实现如下:
1)不用MFC提供的清除背景操作。在OnEraseBkgnd消息函数中直接返回TRUE即可。
2)在OnDraw时,先创建另一个CDC对象(相当于那小张白纸),名字就叫CMemDC吧,先将CMemDC的背景清除。(由于CMemDC是我们临时创建的一个内存DC,所以CMemDC上的所有操作都暂时不会反应到真实屏幕)
3)在CMemDC上绘制(相当于在小张白纸上画画)。
4)将CMemDC上整个区域拷贝到屏幕相关的CDC上。(相当于把画好的小纸贴到画布上)。
思路清楚了,接下来要解决下问题:
1)如何准备CMemDC,比如小白纸要多大,清除背景用什么颜色(我们需要小白纸的颜色和画布的背景颜色一样),等等。
2)如何让小白纸上画画就和在整张纸上画画的感觉一样,相当于我们小白纸虽然不先贴到大纸上,但要放到大纸上正确的位置。
3)CMemDC上画好了,如何拷贝到屏幕CDC上去。
代码如下:
void CcsdnBlogView::OnDraw(CDC* pDC)
{CDC MemDC;CBitmap bitmap;CBitmap* pOldBitmap;CRect rectDev;COLORREF bkclr = pDC->GetBkColor();pDC->GetClipBox(&rectDev); //获取需要重画的区域MemDC.CreateCompatibleDC(pDC); //创建的MemDC是和pDC适配的 bitmap.CreateCompatibleBitmap(pDC, rectDev.Width(), rectDev.Height());//按重画的区域大小,创建一个位图pOldBitmap = MemDC.SelectObject(&bitmap);//MemDC绘制基于bitmap MemDC.SetBkColor(bkclr);//设置背景色 MemDC.FillSolidRect(rectDev, bkclr);// 填充背景(也即是清除背景)//do drawingMemDC.MoveTo(10, 10);for(int i=0; i< 1000; ++i)MemDC.LineTo(rand() % 1000, rand() % 600);//将画好的MemDC拷贝到屏幕CDC(pDC)pDC->BitBlt(rectDev.left, rectDev.top, rectDev.Width(), rectDev.Height(),&MemDC, rectDev.left, rectDev.top, SRCCOPY);//清理MemDC.SelectObject(&pOldBitmap);
}
尝试逐句解释一下:
1)pDC->GetClipBox(&rectDev):获取需要重画的区域,相当于小纸片要多大。大多数时候窗口不需要全部重绘,比如窗口局部被遮挡部分移走了,又比如你自己想重画窗口中局部地方。这是Windows处理的机制,目的是提高绘制效率。
2)MemDC.CreateCompatibleDC(pDC):创建临时的MemDC,就这么用就行了,是固定语式。
3)bitmap.CreateCompatibleBitmap(pDC, rectDev.Width(), rectDev.Height()):CBitmap是位图,Windows内部运行用的就是这种图像格式。就这么用就行了。
4)pOldBitmap = MemDC.SelectObject(&bitmap):将创建的位图加载到MemDC。MemDC绘制时,实际上是绘制在bitmap上的。
5)MemDC.SetBkColor(bkclr):保持MemDC和pDC的背景色一样。
6)MemDC.FillSolidRect(rectDev, bkclr):设置MemDC的背景,相当于让小纸片和画布的颜色一样。
中间的代码是画画。我们随机方式画了1000条线:
7)pDC->BitBlt(rectDev.left, rectDev.top, rectDev.Width(), rectDev.Height(), &MemDC, rectDev.left, rectDev.top, SRCCOPY):将画好的MemDC拷贝到屏幕CDC(pDC)。
这样就实现了基本的双缓存技术。双缓存指的就是这里的MemDC。为什么叫双呢?呃,…我也不知道。或许把OnDraw传递的pDC作为了屏幕的第一道缓存吧?
2.3 封装
我们利用C++特性,通过一个类的构造和析构来实现1)~6)和第7)步。
class CEvwMemDC : public CDC
{
private:CDC *m_pDC;CBitmap m_bitmap;CBitmap* m_pOldBitmap;CRect m_rectDev;public:CEvwMemDC(CDC* pDC) //1)~6)步{pDC->GetClipBox(&m_rectDev); //获取需要重画的区域CreateCompatibleDC(pDC); //创建的MemDC是和pDC适配的 m_bitmap.CreateCompatibleBitmap(pDC, m_rectDev.Width(), m_rectDev.Height());//按重画的区域大小,创建一个位图m_pOldBitmap = SelectObject(&m_bitmap);//MemDC绘制基于bitmapFillSolidRect(m_rectDev, pDC->GetBkColor());// 填充背景(也即是清除背景)m_pDC = pDC;}~CEvwMemDC() //第7)步{//将画好的MemDC拷贝到屏幕CDC(pDC)m_pDC->BitBlt(m_rectDev.left, m_rectDev.top, m_rectDev.Width(), m_rectDev.Height(),this, m_rectDev.left, m_rectDev.top, SRCCOPY);SelectObject(m_pOldBitmap);}};void CcsdnBlogView::OnDraw(CDC* pDC)
{CEvwMemDC MemDC(pDC);//do drawingMemDC.MoveTo(10, 10);for(int i=0; i< 1000; ++i)MemDC.LineTo(rand() % 1000, rand() % 600);
}
封装之后,主代码OnDraw简洁了,并且类可以复用。
3 更加实用的扩充
如果你绘制的窗口没有滚动条,也不会移动视图,映射模式也是MM_TEXT(一个像素对应一个逻辑点),视图也不会放大或缩小,并且永远不用打印,那么上述代码就够了。
如果需要应对以上各种场景,我们需要进一步完善代码。我这里尝试直接给出最终代码,再用解释的方式来介绍。
//*****************************************************
// CEvwMemDC
//*****************************************************
class CEvwMemDC : public CDC
{
private:CBitmap m_bitmap; CBitmap* m_pOldBitmap; CDC* m_pDC; CRect m_rectLogi; BOOL m_bMemDC; public:CEvwMemDC( CDC* pDC , CRect* pRectClipLogi = NULL //需要绘制的矩形区域(逻辑坐标)){CRect rectDev;ASSERT(pDC != NULL);m_pDC = pDC;m_pOldBitmap = NULL;m_bMemDC = !pDC->IsPrinting();// Get the rectangle to drawif (pRectClipLogi == NULL)pDC->GetClipBox(&m_rectLogi);elsem_rectLogi = *pRectClipLogi;m_rectLogi.NormalizeRect();rectDev = m_rectLogi;pDC->LPtoDP(&rectDev);rectDev.NormalizeRect();// Create a Memory DCif (m_bMemDC){CreateCompatibleDC(pDC);//create bitmapm_bitmap.CreateCompatibleBitmap(pDC, rectDev.Width(), rectDev.Height());m_pOldBitmap = SelectObject(&m_bitmap);SetMapMode(pDC->GetMapMode());SetWindowExt(pDC->GetWindowExt());SetViewportExt(pDC->GetViewportExt());CPoint ptWinOrg = pDC->GetWindowOrg();CPoint ptViewOrg = pDC->GetViewportOrg();ptViewOrg.x -= rectDev.left; ptViewOrg.y -= rectDev.top;SetWindowOrg(ptWinOrg);SetViewportOrg(ptViewOrg);// paint backgroundFillSolidRect(m_rectLogi, pDC->GetBkColor());}else {m_bPrinting = pDC->m_bPrinting;m_hDC = pDC->m_hDC;m_hAttribDC = pDC->m_hAttribDC;}}~CEvwMemDC(){if(m_pDC != NULL && m_bMemDC){m_pDC->BitBlt(m_rectLogi.left, m_rectLogi.top, m_rectLogi.Width(), m_rectLogi.Height(),this, m_rectLogi.left, m_rectLogi.top, SRCCOPY);SelectObject(m_pOldBitmap);} }
};
上述代码主要扩充了逻辑坐标和设备坐标的转换处理:
创建MemDC时,CreateCompatibleDC(pDC)函数创建了一个和pDC适配的DC,但其实有很多还不适配,比如逻辑坐标和设备坐标的对应关系,原点位置等。不知道微软为什么这么做,在我看来,CreateCompatibleDC(pDC)函数时,应该基本完全复制pDC,最起码也要提供一个更适配的CreateCompatibleDC版本吧。
上面大量代码做的就是这个事情。考虑了几个方面:逻辑坐标和设备坐标的比例、坐标方向。设备坐标总是以左上角为原点,向右和向下方向为坐标增大方向,这和我们的常用数学坐标在y方向是反的,而很多视图采用了数学坐标方向,这时而逻辑坐标和设备坐标的y方向相反,因此代码中要注意这一点。
另外介绍一下设置原点的方式,举例说明,比如最开始设置的视图比例是1:1,即一个设备坐标对应一个逻辑坐标,当通过改变设备坐标和逻辑坐标的比例来放大视图时,一个逻辑坐标就对应多个设备坐标了,这时如果设置原点的方式以设备坐标为基础,则在边缘会出现没有刷新的问题,必须以逻辑坐标为基础。如果你的程序要支持视图的放大缩小,建议初始比例不要设为1:1,而是采用0.01MM或0.001MM的映射模式,反正一个设备坐标对应大数值的逻辑坐标,然后对视图放大的比例做限制,最多放大到设备坐标和逻辑坐标1:1,这样还可以避免很多别的麻烦(这里就不多讲了)。
最后说点感想,MFC没落了,没落也是有道理的,MFC虽然十分十分庞大,但就是不给你好用,其方便性和易用性总感觉差一点,我觉得在不牺牲任何性能的前提下完全可以做的更好,可惜30余年,微软就从没有这么做,后来又转投.Net去了。另外,说实话,都2022年了,我这篇文章写得太晚了,实际上没有多大实际价值了,现在谁还去考虑什么VC、双缓存技术,特别是这个双缓存技术,本来就应该是MFC该做的事情啊!我们用户的精力应该是面对问题,而你MFC还要我们花费大量精力来写这些基础代码,不没落才怪!当然,这个也不是完全没有好处,这些基础的东西会让我们更深入理解Windows的运行机制,从而也拓宽、提高了我们的编程思路、能力,只是代价稍微大一点。
下载完整的代码资源。本资源完全免费,不需要积分。如果你觉得还好,请点个赞支持。