wxWidgets滚动窗口绘图总结
问题:从wxScrolledWindow派生一个类CXCanvas,作为绘图的画布。画布的尺寸可能非常大,远远超出屏幕的大小,绘制的内容可能非常多,全部绘制一遍非常耗时,当滚动条滚动时,覆盖对话框移动时,以及窗口尺寸变换时要让窗口更新竟可能的快,并且要减少闪烁。
分析:这个问题涉及到滚动窗口中的绘制,部分更新和减少闪烁。
1)滚动窗口绘制,和普通的DC绘制只有一个区别,就是要调用DoPrepareDC,这样还是按照画布的原点进行绘制就可以,不用考虑窗口滚动到哪儿了,或者直接使用OnDraw,默认的Paint事件处理器内部已经调用了DoPrepareDC,并调用OnDraw。
另外一个办法是用GetViewStart和GetScrollPixelsPerUnit(就是SetScrollRate设置的值)计算出滚动条的位置,然后用SetDeviceOrigin(-x,-y)手动设置原点位置(如果调用了DePrepareDC这一切都自动完成了)。
虚拟窗口的尺寸设置是SetVirtualSize,滚动条每次滚动的像素值设置用SetScrollRate。
2)消除闪烁。闪烁的根源是同一屏的画面不是一次呈现在眼前,让眼睛看到了整个绘制的过程从而感到闪烁。消除的办法是使用buffer,先在buffer上绘制,完成后一次性贴到窗口上。使用buffer能消除闪烁,但不能提高效率,因为往buffer上画和直接往窗口上画是一样慢的。
在wx中使用buffer很方便,最简单的是用wxBufferedPaintDC代替wxPaintDC,并且设置画布的SetBackgroundStyle(wxBG_STYLE_CUSTOM)。而且要自己处理EVT_ERASE_BACKGROUND事件,处理函数里面什么也不做就可以。
经过以上两步,可以实现在一个滚动窗口里面绘图,不需要考虑滚动条位置,且无闪烁。关键代码片段:
BEGIN_EVENT_TABLE(CXCanvas, wxScrolledWindow)
EVT_ERASE_BACKGROUND(CXCanvas::OnEraseBackground)
EVT_PAINT(CXCanvas::OnPaint)
END_EVENT_TABLE()
CXCanvas::CXCanvas(wxWindow* parent)
:wxScrolledWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxALWAYS_SHOW_SB | wxHSCROLL | wxVSCROLL)
{
SetVirtualSize(4096,4096); //作为测试,在构造函数里设置了画布的尺寸
SetScrollRate(1,1);
SetBackgroundStyle(wxBG_STYLE_CUSTOM);
}
void CXCanvas::OnEraseBackground(wxEraseEvent& evt)
{
(void)evt; //什么也不做
}
void CXCanvas::OnPaint(wxPaintEvent& evt)
{
wxBufferedPaintDC dc(this);
DoPrepareDC(dc); //对于滚动窗口很重要,去掉后坐标就不对了
dc.SetBackground(*wxWHITE_BRUSH);
dc.Clear();
// 在Paint里面绘制整张画布的内容
Paint(dc);
}
具体使用时,绘制的代码放在Paint()里面,当Paint内容改变时,调用Refresh()刷新窗口,将画布整个重绘一遍。
需要注意的是,在重画事件中创建的 wxPaintDC设备上下文会自动将自己限制在需要重画的区域(即用GetUpdateRegio得到的区域),但这是针对窗口覆盖,resize,scroll这些系统能判断出来的情况的,如果你自己改变了画布的内容,必须自己调用Refresh或RefreshRect,因为wx并不知道要重绘哪些东西。另外Refresh只是让系统发送一个重绘事件,但不一定立刻重绘,可以调用Update立刻重绘。对于滚动条滚动的情况,在Windows平台上,wx使用了API ScrollWindow进行物理滚动,这样需要更新的区域就是新滚动进入的一块。所以滚动时不会更新整个客户区的。可以使用EnableScrolling(false,false)禁用物理滚动,这样当滚动条滚动时,Update region会包含整个客户区(当然一般没必要这样做,我的原则是自己知道要更新就refresh,否则让系统自己处理update region)。
3)滚动窗口内检测是否在客户区内
以上的做法对于普通的应用已经够了。考虑我们的情况,绘制整张画布一遍非常耗时。尽管wx在底层为我们处理了只针对更新区域绘制,以及滚动时采用物理滚动减少更新区域,但是将画布完全重绘一遍还是很耗时的(因为底层做像素是否要绘制的检测也很耗时),所以应该在绘制前就排除不在客户区里的物体,对于CXCanvas,由于他不知道Paint里面会画什么,所以无能为力,只有在使用CXCanvas时,在Paint里面得到客户区的尺寸已经滚动后的原点来计算出一个需要绘制的区域,然后用比较好的方法检测是否要绘制。
计算滚动窗口位置的方法是:
int vx, vy;
GetViewStart(&vx,&vy);
int px, py;
GetScrollPixelsPerUnit(&px,&py);
int x = vx*px;
int y = vy*py;
或者直接使用wxDCBase::GetDeviceOrigin,这个函数文档里没有
wxCoord x, y;
dc.GetDeviceOrigin(&x, &y);
x = -x; //因为原点位置肯定是<=0的
y = -y;
得到的x,y就是当前窗口区域的原点在整个虚拟画布上的位置。
得到客户区大小的方法:
int cl_width, cl_height;
GetClientSize(&cl_width, &cl_height);
这样虚拟画布上需要绘制的范围就是(x,y)-> (x+cl_width,y+cl_height),绘制时检测一下,或者根据这个范围计算出绘制的物体的范围。
进一步的优化,可以检测更新区域,但更新区域是个列表,检测时会遍历多遍,从而多次调用自己的paint,这样并不方便。可以在paint里面调用IsExposed来判断是否要绘制:
// 只有在需要的时候才重画以便提高效率
wxRect rectToDraw(i, j, tileSize, tileSize); //我要画一个tile,这是他在画布上的坐标
CalcScrolledPosition(rectToDraw.x, rectToDraw.y, & rectToDraw.x, & rectToDraw.y);
//因为窗口滚动了,而我们是在虚拟坐标上绘制,必须转成滚动后窗口的客户区坐标
if (!IsExposed(rectToDraw)) //检测tile所在的区域是否需要绘制,否则忽略
continue;
经过这个优化,能快不少,特别是滚动时和覆盖的窗口移动时感觉很明显。
到目前为止,对于大多数的应用,应该够用了,提高效率的关键还是检测方法,以及尽量使用RefreshRect来确定更新区域。比如画布上已经有很多东西了,往上面添加一个东西,只要绘制后RefreshRect这个物体的边框大小就可以了。否则整个客户区内的物体全画一遍可能还是很慢的。
4)画布buffer的解决方案
如果客户区内的东西还是很多怎么办? 可以建立一个虚拟画布大小的位图作为buffer。在OnPaint里面,只是将这个buffer贴到屏幕上,需要绘制时直接像这个buffer上绘制,这样只有第一次绘制整个画布时比较慢,画好后就能很快的绘制了(内容不改变时,只绘制一次),特别是滚动和覆盖窗口移动时速度比上面优化后的还快很多。这个方法的主要缺点是很费内存。如果使用客户区大小作为buffer的大小?那滚动的时候就比较麻烦了,因为buffer只有客户区那么大,滚动时必然要更新buffer的内容,如果简单的重画就和wx提供的BufferedDC没什么差别了,除非自己处理整体的像素移动,将保留的部分整体移动,并绘制新加入的部分。