为大型列表控件使用缓冲技术






4.67/5 (3投票s)
利用缓冲技术高效无缝地渲染列表控件中的大量项目
引言
本文旨在强调在使用缓冲技术渲染大量数据时的有效性。结合多种不同的渲染处理时间增加方法,即使列表控件拥有数百万个条目,也能实现条目的实时渲染。这更适用于自定义所有者绘制控件,例如磁贴组件或自定义列表视图组件。
背景
在开发 Metro 风格组件框架时,我在开发自定义 ListView
组件时偶然发现了对缓冲的需求。ListView
可以包含任意数量的条目,渲染过程应无缝且无闪烁。为了实现这一点,我们聚合了不同层级的缓冲,直到渲染操作变得显而易见地瞬时完成。
缓冲方法
要在自定义控件中实现缓冲,有几种不同的可用选项可以提供此功能。以下概述了缓冲 GDI 时核心概念及其目的。
双缓冲
public class MyControl : UserControl
{
public MyControl()
{
DoubleBuffered = true;
SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
}
}
许多组件都实现了 protected DoubleBuffered
属性。此属性在内部指示组件应在单独的内存缓冲区上执行任何绘图操作。
这意味着 Paint
事件并非直接在组件的图形句柄上执行,而是先在内存中创建一个单独的 Graphics
缓冲区,然后在该缓冲区上执行绘图操作。在内存缓冲区上的绘图操作完成后,该缓冲区将被绘制到组件的图形句柄上。
这样做的目的是通过一次操作来提交完成的绘图操作。例如,假设一个组件负责在一次绘图操作中渲染 1,000 个条目,每个条目的渲染时间为 0.0004 秒。一次绘图操作将耗费 0.4 秒(400 毫秒)才能完成。在这 400 毫秒期间,控件将在绘图操作进行时积极更新,导致部分图形更新在操作进行过程中被提交到可见屏幕。这是一种称为“撕裂”的后果,即组件的一部分正在更新,而旧的图形仍然可见。
双缓冲通过在单独的句柄上完成 400 毫秒的绘图操作来消除此问题,然后一次性替换整个组件的可见状态,从而消除了撕裂问题,并使得更新看起来是无缝完成的。
禁用 WM_ERASEBKGND
public class MyControl : UserControl
{
private const int WM_ERASEBKGND = 0x0014;
protected override void WndProc(ref Message m)
{
if (m.Msg != WM_ERASEBKGND)
base.WndProc(ref m);
}
}
在 Windows 中,WM_ERASEBKGND
消息对应于当组件的背景需要被擦除时发送给活动组件的请求。此消息仅负责擦除背景,因此在我们开始实现自定义绘制控件时,它变得多余。
此 Windows 消息增加了自定义控件的负担,迫使系统在任何绘图操作之前销毁现有背景。这增加了实际执行任何绘图操作之前的工作量,因此,禁用此 Windows 消息并用背景色填充 Paint
图形句柄,复制相同的过程,已成为一种常见的做法。
缓冲图形(和缓冲图形上下文)
.NET Framework 提供了 BufferedGraphics
类。实例化此类后,它将负责维护一个活动的图形句柄,该句柄可以重用于绘图操作。使用此类的主要优点是,可以保留一个单独的、昂贵的绘图操作的内存,这样重复的绘图操作就不需要重新渲染所有相关的条目。
每当图形界面需要更新时,都应该处理并重置 BufferedGraphics
对象,例如:
- 控件大小调整;可能需要渲染更多信息,或需要更新背景色以填充新区域。
- 控件滚动;需要移动控件中可见条目的列表以适应滚动。
- 控件鼠标移动:可见条目可能处于“悬停”状态,导致绘制不同的颜色。
这些情况需要处理现有的图形句柄,以便将状态更改提交到控件图形句柄。请参阅下面使用 BufferedGraphics
对象的示例。
public class MyControl : UserControl
{
private BufferedGraphics buffer;
private BufferedGraphicsContext bufferContext;
public MyControl()
{
// initialize the BufferedGraphics object to null by default
buffer = null;
// initialize the BufferedGraphicsContext object which is the
// graphics context for the current application domain
bufferContext = BufferedGraphicsManager.Current;
}
protected override void OnPaint(PaintEventArgs e)
{
// check whether our buffered graphics have been allocated yet,
// if not then we need to create it and render our graphics
if (buffer == null)
{
Rectangle bounds = new Rectangle(0, 0, Width, Height);
// allocate our graphics buffer, which matches the dimensions of
// our control, and uses the paint Graphics object as a base for
// generating the Graphics handle in the buffered graphics
buffer = bufferContext.Allocate(e.Graphics, bounds);
// fill our graphics buffer with a white color
buffer.Graphics.FillRectangle(Brushes.White, bounds);
}
// render our graphics buffer onto the target graphics handle for
// the paint operation
buffer.Render(e.Graphics);
}
protected override void OnResize(EventArgs e)
{
// check whether we have an existing graphics buffer which uses
// some old dimensions (prior to the resize)
if (buffer != null)
{
// dispose of the graphics buffer object and reset to null to force
// the invalidation to re-create our graphics object
buffer.Dispose();
buffer = null;
}
// invalidate our control which will cause the graphics buffer to be
// re-created and drawn with the new dimensions
Invalidate();
}
}
切分大型数据
为了在渲染自定义列表控件时利用上述双缓冲技术,重要的是要认识到没有现实可行的方法可以渲染集合中的所有条目。当控件拥有超过一百万行时,绘图操作将变得过于密集,以至于双缓冲毫无意义。
相反,为了减少绘图的工作量,需要对集合进行切分,以便我们只需要渲染一小部分所需数据。这可以通过使用数学公式来确定控件视图区域顶部可见的项目,并计算从顶部到底部可见的项目数量来实现。
为了实现任何此类功能,重要的是有一个常量或可配置的字段或属性可用于定义单个项目的高度(或尺寸)。下面,我们允许一个属性(ItemHeight
)供用户指定自定义高度。
public class MyControl : UserControl
{
private int itemHeight;
public MyControl()
{
itemHeight = 18;
}
[Browsable(true)]
public int ItemHeight
{
get { return itemHeight; }
set { if (itemHeight != value) { itemHeight = value; Invalidate(); } }
}
}
默认项目高度为 18 像素,可以在设计时或运行时进行配置。每次更改项目高度时,控件都会失效。
下一步是创建集合,该集合将存储我们有兴趣在控件中显示的条目。在本教程中,控件将显示 String
对象列表。如果我们希望正确监视集合中的添加、删除或移动项目,我们需要在任何 Add
、Remove
等方法中实现相应的方法以回溯到我们的控件。
为简化起见,我们的控件将使用 ObservableCollection<>
类,该类实现了 INotifyCollectionChanged
接口,我们将挂钩到 CollectionChanged
事件来更新控件。
public class MyControl : UserControl
{
private int itemHeight;
private ObservableCollection<string> items;
public MyControl()
{
itemHeight = 18;
items = new ObservableCollection<string>();
items.CollectionChanged +=
new NotifyCollectionChangedEventHandler(ItemsCollectionChanged);
}
[Browsable(true)]
public int ItemHeight
{
get { return itemHeight; }
set { if (itemHeight != value) { itemHeight = value; Invalidate(); } }
}
[Browsable(false)]
public ObservableCollection<string> Items
{
get { return items; }
}
protected virtual void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
}
}
下一步是实现一个算法,以便当控件中可见的项目数量超过视图高度时能够启用滚动。这可以通过使用项目高度属性、项目数量和控件的当前高度来计算。
protected virtual void CalculateAutoScroll()
{
// ensure that autoscrolling is enabled in the control for scrollbar visibility
AutoScroll = true;
// update the minimum size for the scrolling procedure, which ensures that the
// maximum scroll value vertically is equal to the item height x item count
AutoScrollMinSize = new Size(0, itemHeight * items.Count);
}
这将显示一个滚动条,它可以适应集合中的任何数量的项目。此方法应在集合更改时调用,因此我们必须修改 ItemsCollectionChanged
方法以引发此计算。
protected virtual void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
// recalculate the auto-scroll region to accommodate the changes
CalculateAutoScroll();
// invalidate the control so that any removed or moved items are updated
Invalidate();
}
当项目高度属性更改时,也应引发相同的方法,以便将新的项目高度应用于算法。
[Browsable(true)]
public int ItemHeight
{
get { return this.itemHeight; }
set
{
if (itemHeight != value)
{
itemHeight = value;
CalculateAutoScroll();
Invalidate();
}
}
}
配置完这些之后,就可以开始切分数据操作了。使用算法可以非常容易地计算起始和结束索引。
必须重写控件的 Paint
方法,以便我们可以正确地渲染条目。
protected override void OnPaint(PaintEventArgs e)
{
}
要计算起始索引,必须考虑滚动位置才能正确生成正确的索引。基本索引是基于滚动位置和每个项目的高度计算的。
int start = (int)Math.Floor((double)Math.Abs(AutoScrollPosition.Y) / (double)itemHeight;
可视图项目的数量是根据控件的高度和每个项目的高度计算的。
int count = (int)Math.Ceiling((double)Height / (double)itemHeight);
此外,如果用户滚动到的位置只有半个项目在控件顶部可见,我们必须考虑该位置的偏移量。为了弥补这一点,我们必须计算滚动位置除以每个项目高度后剩余的像素。
int offset = -(int)(Math.Abs(AutoScrollPosition.Y) % itemHeight);
下图突出了计算索引和偏移量时各个值的含义。
最后,利用这些值,我们可以确定控件中需要渲染哪些项目。
protected override void OnPaint(PaintEventArgs e)
{
int start = (int)Math.Floor((double)Math.Abs(AutoScrollPosition.Y) / (double)itemHeight);
int count = (int)Math.Ceiling((double)Height / (double)itemHeight);
int offset = -(int)(Math.Abs(AutoScrollPosition.Y) % itemHeight);
for (int i = 0; count > i && start + i < items.Count && 0 <= i; i++)
{
int index = start + i;
string value = items[index];
int x = 0;
int y = (i * itemHeight) + offset;
e.Graphics.DrawString(
value,
this.Font,
Brushes.Black,
new Rectangle(x, y, Width, itemHeight)
);
}
}
此代码的结果是一个小巧简单的控件,它可以处理从集合中渲染数据切片,而不是尝试将所有数据渲染到控件中。上面概述的过程类似于“裁剪”效果,其中顶部或底部不可见的项根本不渲染。
结合使用各种方法
如引言所述,本文旨在重点介绍如何使用这些渲染方法在一个简单的控件中表示大量数据。我们可以将缓冲图形方法与数据切片相结合,为数百万行提供接口,而开销非常小。
本文附带两个核心文件:UI.cs 和 CustomBufferedList.cs。CustomBufferedList.cs 文件包含我们列表控件的代码,该控件结合了双缓冲和数据切片技术。
CustomBufferedList
类中实现的一个方法支持识别控件中给定位置的行。为了计算某个位置的项目,使用了与之前的 OnPaint
实现类似的方法,其中计算起始索引和可见项目数,然后遍历可见项目以确定边界是否落在该位置内。
public int GetItemAt(Point location)
{
// calculate the starting index based on the scroll position
int start = itemHeight > 0 ?
(int)Math.Floor((double)Math.Abs(AutoScrollPosition.Y) / itemHeight) : 0;
// calculate the total number of items visible in the control
int visible = (int)Math.Ceiling((double)Height / (double)itemHeight);
// calculate the rectangle for the first item bounds
Rectangle bounds = new Rectangle(
0,
-(int)(Math.Abs(AutoScrollPosition.Y) % itemHeight),
Width,
itemHeight
);
// increment through the items to match the item at the location
for (int i = 0; 0 <= start + i && start + i < items.Count && visible > i; i++)
{
// check whether the mouse location falls within these bounds
if (bounds.Contains(location))
return start + i;
// increment the position of the bounds based on the item height
bounds.Y += itemHeight;
}
// failed to match an item at this location
return -1;
}
文件中的所有其他内容负责处理使用缓冲技术和数据切片的过程。OnInvalidateBuffer()
方法负责处理 BufferedGraphics
对象的失效,并且还在更新暂停时支持忽略方法调用。
在向 CustomBufferedList
添加大量数据时,最好调用 BeginUpdate()
和 EndUpdate()
方法。这些方法分别暂时挂起组件的绘制,然后恢复并使组件失效。
// begin an update on the list, which suspends the layout
list.BeginUpdate();
// populate the list with a massive amount of values
for (int i = 0; 1000000 > i; i++)
list.Items.Add(string.Format("Custom buffered list item {0}", i + 1));
// end the update to commit the changes to the buffer
list.EndUpdate();
关注点
项目中包含的 CustomBufferedList
组件还实现了 OnMouseMove
封装,用于处理项目高亮显示。基于类似的行为实现选择过程一样简单,这将提供 ListView
组件的功能。
如果我遗漏了任何重要内容或某些部分的文章显得仓促,敬请原谅。我没有太多时间写文章,所以这是对如何实现开头设定的目标的概述。
历史
- 版本 1.0 - 初稿