(更快)Silverlight 滚动速度






4.98/5 (21投票s)
提高 Silverlight 中的滚动性能
引言
如果您从事软件应用程序的设计和/或开发,我建议您关注 Silverlight。尽管它最初被定位为 Flash 的替代品,但最近发布的 Silverlight 2 明确表明其目标更为宏大。针对 Silverlight 运行时,开发人员可以编写复杂的 C# 应用程序,这些应用程序具有跨平台和浏览器无关的特性。
本文的动机源于最近的项目需求——我一直在使用 Silverlight 重新设计一个数据切片和分片应用程序的用户界面。系统的一个方面是它允许用户导入任意文本编码的数据集(CSV/Excel/XML/等)。用户需要在数据导入到 SQL Server 表之前预览数据。当我使用 `ListBox` 或 `DataGrid` 尝试此操作时,整个界面很快变得无法使用。加载时间很长,滚动性能迅速下降到无法使用的水平。
本文将演示一种提高 Silverlight 控件滚动性能的简单技术。我将逐步介绍一个完整的项目,该项目可以成为 Win32 样式网格控件的**核心**。请注意,它**不是**一个完整的网格实现——我仍在为此努力。
它的性能提高了多少?当使用滚动条滑块滚动 3000 行数据时,示例控件可以获得约 45FPS 的持续帧速率,而使用更显而易见的机制时,帧速率低于 2FPS。

上面的快照显示了 Silverlight 页面中的示例控件以及 2 个组合框。左侧的组合框允许您选择要显示的数据行数(30/300/3000),而右侧的组合框选择显示模式。
请注意,FPS 值似乎只出现在 Internet Explorer 7 中(左下角)——我的 FireFox 3 和 Chrome 安装没有显示帧计数器。
探索代码
控件层次结构
核心要求是,我们有一个带有水平和垂直滚动条的网格,以及标准的行和列标题。基本设计足够简单:
- 按照 Silverlight 项目的标准做法,从 `UserControl` 派生新类 `ScrollViewerEx`
- 使用 `Grid` 以灵活可调整大小的方式布置 5 个控件元素。该网格将是 `LayoutRoot`。
- 使用 `Canvas` 控件 `ElementContent` 显示数据行
- 拥有 2 个 `Scrollbar` 控件,一个水平方向,一个垂直方向,分别命名为 `HScroll` 和 `VScroll`
- 拥有 2 个额外的 `ScrollPanels`,可用于管理行和列标题
我使用 Microsoft Blend 进行初始设计——这是布局的快照

如上所述,各种 `Canvas` 和 `ScrollBar` 控件是使用 `Grid` 排列的。这意味着我们可以利用 Silverlight 中自动布局系统,这使得主控件容器移动和调整大小时,布局能够很好地流动和调整大小。我们无需为此编写任何代码,布局定位和调整大小行为在 XAML 标记中指定。
`CanvasClipper` 控件实例有两个任务——首先是确保主内容在绘制时被裁剪到中央网格单元格。我们还利用该控件来帮助简化滚动条大小和范围的计算。
建模控件内容
将网格内容建模为行集合似乎最自然,每行管理一个单元格集合。为了演示的目的,我将省略诸如可变单元格宽度和列高度之类的细节,而专注于以最有效的方式排列、显示和滚动数据的基本知识。
初始实现很简单:
- 创建 N 行内容并添加到主画布的子列表中。
- 将主画布的大小设置为等于所有内容行的总高度。
- 裁剪画布,以便可视矩形将限制在中央布局网格单元格的大小内。
- 将滚动条的范围设置为等于总行数/列数减去裁剪矩形中可见的行数/列数。
- 当滚动事件触发时,只需通过对画布的 TranslateTransform 应用 RenderTransform 属性 来偏移画布视口
此处值得注意的一个细节是——`Canvas` 控件在绘制时不会裁剪其内容。出于在文章后面会变得更清晰的原因,我将裁剪的责任推迟到了另一个 `Canvas` 派生控件,名为 `CanvasClipper`。从 XAML 标记可以看出,这两个控件之间的关系很简单
<local:CanvasClipper x:Name="ElementContentClipper" Grid.Row="1" Grid.Column="1">
<Canvas x:Name="ElementContent"
Grid.Row="1" Grid.Column="1"
Background="White"></Canvas>
</local:CanvasClipper>
这种概念布局可以在下面更具体的形式中看到。内容行作为 `ElementContent` 画布控件的子级垂直堆叠排列。裁剪器以半透明蓝色显示,代表视口,其中包含实际适合任意大小窗口的那些行。因此,被裁剪的行位于 `ElementContentClipper` 矩形之外,不显示。

在实际实现方面,我们最终得到了 3 个新类。它们是:
1. 裁剪器
// to help with clipping
public class CanvasClipper : Panel
{
private RectangleGeometry _clippingRectangle;
public CanvasClipper()
{
_clippingRectangle = new RectangleGeometry();
}
// Let the canvas arrange itself. Once it has worked out
// the right size cache the clipping rectangle dimensions in the
// ClippingRect property so the main control can establish how many
// rows and columns to display and what ranges to set for the scroll bars
protected override Size ArrangeOverride(Size finalSize)
{
// the base class does all the dirty work for us
finalSize = base.ArrangeOverride(finalSize);
ClippingRect = new Rect(0, 0, finalSize.Width, finalSize.Height);
_clippingRectangle.Rect = ClippingRect;
// set the actual graphical clipping rectangle property
Clip = _clippingRectangle;
return finalSize;
}
// so we know the final size of the clipping rect
// and thus the true display size
public Rect ClippingRect
{
get; set;
}
}
这里没什么争议——我们拥有一个新的派生类,唯一的原因是简化对其裁剪矩形的访问。一旦调用了 `base.ArrangeOverride()`,我们就知道裁剪器的最终大小——我们将该矩形保存在 `ClippingRect` 属性中,以便我们的滚动控件可以根据需要访问它。
2. ScrollRowCanvas
目前,这实际上只是一个简单的占位符。在构造时,我们让它创建并排列固定数量的 `TextBlock` 控件,这些控件表示网格中的一个单元格。每个单元格都有固定的宽度和高度,我们让 `ScrollRowCanvas` 自动生成一些文本用于显示。
这里再次没什么不寻常的——`TextBlock` 实例沿着画布的 X 轴以固定的 `cellWidth` 间隔绝对定位。`ScrollRowCanvas` 类本身是从 Canvas 派生而来,因此它也可以以绝对方式定位。
举例来说,我添加了一些鼠标事件处理程序,以表明连接带动画效果的 Storyboarding 或更传统的东西(例如更改单元格悬停效果的背景画刷颜色)相对简单。
public class ScrollRowCanvas : Canvas
{
// what is our row number?
private int _row;
// constructor - column count and dimensions are fixed at the time of creation
// for demo purposes
public ScrollRowCanvas(int row,int cols, int cellWidth, int cellHeight)
{
_row = row;
double xoff = 0;
double yoff = 0;
for (int col = 0; col < cols; col++)
{
TextBlock tb = new TextBlock();
tb.Text = "TextBlock " + row.ToString() + "." + col.ToString();
tb.Width = cellWidth;
tb.Height = cellHeight;
tb.HorizontalAlignment = HorizontalAlignment.Left;
// add to the canvas
base.Children.Add(tb);
// position the new textblock on the x axis
// equivalent to <TextBlock Canvas.Left="xoff"
tb.SetValue(Canvas.LeftProperty, xoff);
// equivalent to <TextBlock Canvas.Top="yoff"
tb.SetValue(Canvas.TopProperty, yoff);
xoff += cellWidth;
}
// set the total canvas width and height
this.Width = cols * cellWidth;
this.Height = cellHeight;
// add some event handlers so we get debug spew at runtime
MouseLeftButtonDown += delegate(object sender, MouseButtonEventArgs e)
{
OnMouseLeftButtonDown(e);
};
MouseEnter += delegate(object sender, MouseEventArgs e)
{
OnMouseEnter(e);
};
MouseLeave += delegate(object sender, MouseEventArgs e)
{
OnMouseLeave(e);
};
}
public bool IsMouseOver { get; private set; }
/// <summary>
/// Called when the user presses the left mouse button over the ListBoxItem.
/// </summary>
/// <param name="e">The event data.</param>
protected virtual void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
if (!e.Handled)
{
e.Handled = true;
Debug.WriteLine("OnMouseLeftButtonDown: " + _row.ToString());
}
}
/// <summary>
/// Called when the mouse pointer enters the bounds of this element.
/// </summary>
/// <param name="e">The event data.</param>
protected virtual void OnMouseEnter(MouseEventArgs e)
{
IsMouseOver = true;
Debug.WriteLine("OnMouseEnter " + _row.ToString());
}
/// <summary>
/// Called when the mouse pointer leaves the bounds of this element.
/// </summary>
/// <param name="e">The event data.</param>
protected virtual void OnMouseLeave(MouseEventArgs e)
{
IsMouseOver = false;
Debug.WriteLine("OnMouseLeave " + _row.ToString());
}
}
3. ScrollViewer
我们示例中的主要参与者是 `ScrollViewerEx` 控件。该类中的关键方法是:
- 构造函数
- 用于键盘和鼠标交互的事件处理程序
- 一个用于 `ArrangeOverride` 的自定义处理程序
- 在运行时切换滚动策略的代码——简化性能比较
- 一些辅助代码,使控件能够接收 MouseWheel 事件——参见 *MouseWheel.cs*
请注意,这里没有显式代码来连接滚动条的事件处理程序。这实际上是在 XAML 标记中完成的,而且由于我使用的是 Blend,它为我生成了存根函数等。
3.1 构造函数
/// <summary>
/// ScrollViewerEx - hook up basic event handlers,
/// listener for the mouse wheel etc
///
/// </summary>
public ScrollViewerEx()
{
InitializeComponent();
Debug.Assert(ElementContent != null);
Debug.Assert(ElementContentClipper != null);
// Page_Loaded will be fired when the control is visible
this.Loaded += new RoutedEventHandler(Page_Loaded);
// event handlers
KeyDown += delegate(object sender, KeyEventArgs e)
{
OnKeyDown(e);
};
GotFocus += delegate(object sender, RoutedEventArgs e)
{
OnGotFocus(e);
};
LostFocus += delegate(object sender, RoutedEventArgs e)
{
OnLostFocus(e);
};
// list of *all* row items we manage
VisibleItems = new List<UIElement>();
// apply the scrolling translation
Translation = new TranslateTransform();
// mouse wheel listener
WheelMouseListener.Instance.AddObserver(this);
}
3.2 初始布局
`Page_Loaded` 事件处理程序在启动时完成大部分布局工作。我们创建固定数量的数据行进行显示,并按照“控件层次结构”部分中讨论的方式将它们布局在主画布上。
/// Initialiser code - lay out the content
void Page_Loaded(object sender, RoutedEventArgs e)
{
// this is our recursion lock
Locked = true;
// x and y offsets for each row
double xoff = 0;
double yoff = 0;
for (int row = 0; row < rows; row++)
{
// create a new item
ScrollRowCanvas sr = new
ScrollRowCanvas(row, cols, cellWidth, cellHeight);
// magic - see section 3.3
sr.Visibility = Visibility.Collapsed;
// add to the canvas
ElementContent.Children.Add(sr);
// equivalent to <ScrollRowCanvas Canvas.Left="xoff">
sr.SetValue(Canvas.LeftProperty, xoff);
// equivalent to <ScrollRowCanvas Canvas.Top="yoff">
sr.SetValue(Canvas.TopProperty, yoff);
// next vertical slot
yoff += cellHeight;
}
// no recursion lock
Locked = false;
// use the fast mode
FastMode = true;
// switch strategies to FastMode
SwitchStrategy(false);
}
3.3 设置裁剪和滚动范围
本节讨论我们自定义 `ArrangeOverride` 虚函数的实现。这里的事件序列很简单,并且在某种程度上反映了我们在 `CanvasClipper` 类中所做的工作。
- 让基类确定布局网格的整体大小。
- 查询 `CanvasClipper` 实例并获取其尺寸。
- 使用裁剪器的宽度和高度设置滚动条的范围。
- 我们希望每个垂直滚动增量等于 1 行数据
- 4. 意味着垂直滚动范围设置为总行数 - 页面上的行数。
- 水平滚动条使用相同的计算。
请注意对 `ApplyLayoutOptimiser()` 的调用。接下来将讨论它。
// establish how many rows and columns we can display and
// set scroll bars accordingly
protected override Size ArrangeOverride(Size finalSize)
{
// let the base class handle the arranging
finalSize = base.ArrangeOverride(finalSize);
// here's the magic ...
ApplyLayoutOptimiser();
//
return finalSize;
}
3.4 优化
最后是问题的核心:滚动性能似乎与画布中可滚动项的数量成正比地下降。当有 30 行时,性能足够流畅;当有 300 行时,滚动条拇指开始滞后于鼠标;当有 3000 行数据时,控件完全无法使用。
解决这个问题的方法很简单:**隐藏**所有内容,直到绝对需要时才显示。
简而言之,`ApplyLayoutOptimiser()` 确保只有视口内的数据行是实际可见的。也就是说,代码遍历子项列表并根据需要修改可见性状态。由此产生的性能提升令人惊叹——正如介绍中控件快照所示,在管理数千行数据时,滚动帧速率在 40 到 50 FPS 之间。
这里的代码实际上可以进一步优化。如果我们跟踪要显示的第一行和最后一行,则无需维护单独的可见项目列表——如果这些值存储在对 `ApplyLayoutOptimiser()` 的调用之间,我们只需折叠已滚动出视图的行,并使滚动到视图中的行可见。这是一个显而易见的下一步,可能会带来每秒更多的帧数。
/// change the visibility of rows to be displayed
private void ApplyLayoutOptimiser()
{
// recursion guard - this is a property of the control
if (Locked == False)
{
//
Locked = True;
// set up the scroll bar ranges
SetScrollRanges();
// hide the visible items
foreach (UIElement uie in VisibleItems)
{
uie.Visibility = Visibility.Collapsed;
}
// remove from list
VisibleItems.Clear();
// layout a page worth of rows
int maxRow = System.Math.Min(
VertPosition + RowsPerPage,
ElementContent.Children.Count);
for (int row = VertPosition; row < maxRow; row++)
{
// get the item at the row index
UIElement uie = ElementContent.Children[row];
// make it visible - this triggers recursive calls to
// ArrangeOverride, hence the guard
uie.Visibility = Visibility.Visible;
//
VisibleItems.Add(uie);
}
// finally scroll things
HandleScrolling();
//
Locked = False;
}
}
3.5 滚动
为完整起见,这里是实际滚动画布的函数。这通过对画布的 `RenderTransform` 属性应用 X 和 Y 方向的平移来完成。在 Win32 术语中,这与通过在运行时移动视口原点来滚动设备上下文是相同的。为了向下滚动一行,我们将 `TranslateTransform` 的 Y 值设置为垂直滚动条值的负值乘以一行的高度。负的 Y 平移将整个画布向上移动,从而产生滚动效果。
/// <summary>
/// Set the vertical and horizontal scroll bar ranges
/// </summary>
protected void SetScrollRanges()
{
// what is the view-port size?
Rect clipRect = ElementContentClipper.ClippingRect;
// how many integral lines can we display ?
int rowsPerPage = (int)(clipRect.Height / cellHeight);
// set the scroll count
VScroll.Maximum = (_rows - rowsPerPage);
// and do the same for the columns
int colsPerPage = (int)(clipRect.Width / cellWidth);
HScroll.Maximum = (_cols - colsPerPage);
}
/// <summary>
/// transform according to h and v scroll bar settings
/// <summary>
protected void HandleScrolling()
{
// create a transform which translates in X and Y
TranslateTransform tr = new TranslateTransform();
// offset by scroll positions
tr.X = -(HScroll.Value * cellWidth);
tr.Y = -(VScroll.Value * cellHeight);
// apply the transform to the content container
ElementContent.RenderTransform = tr;
}
3.6 总结
这几乎涵盖了基本问题。示例项目包含更多代码以适应两种显示方法——您可以运行它并查看这两种方法在不同数据量下的表现。
结论与后续事宜
本文讨论了更快 Silverlight 滚动控件的实现。通过示例项目,可以很容易地证明滚动性能与画布控件的可见子元素数量呈线性关系下降。通过确保只有当前视口内的子元素可见,可以保持滚动和绘图性能达到预期水平。这意味着可以使用滚动条滑块或鼠标滚轮实时滚动大型数据集。
用于提升性能的机制很简单,我相信可以应用于许多现有的列表和网格实现。了解默认行为为何如此糟糕会很有趣。欢迎任何来自知情读者的评论。
我也很想知道这个演示在不同硬件上的运行情况。这里的快照都是在 Windows XP 机器上拍摄的(四核 Q6600,4GB 内存,1GB 显存)。
Silverlight 需要更多性能和内存分析工具。内部没有可用于测量执行时间的高性能计时器。没有记录的机制用于检查资源分配。唯一易于访问的监控工具是非常基本的帧率计数器(尽管有关更复杂解决方案,请参阅下面的参考资料 3)。
而且,尽管有大量的网络资料,Silverlight 仍然需要相当于“Windows 编程”的资料——一种有助于推广最佳实践,同时保持“好东西”一致、简洁和集中的东西。尽管 Silverlight 社区充满活力,但许多最好的信息都是通过 Silverlight 博客获得的——有很多这样的博客,有些可以轻松下载示例代码,而另一些则不可能(例如基于视频的演练)。同样,Silverlight.Net 网站涵盖了大量问题,但其中大部分都锁定在小线程中——如果有一个文档主管可以将所有这些资料整理起来进行分发,那就太棒了。
解决这些问题将有助于确保业务关键型 Silverlight 解决方案的成功部署。
参考文献
- Silverlight 网站应该是提出问题等的首要途径。
- 有关 Silverlight 工具和开发套件等,请访问此处。
- MSDN 关于 Silverlight 性能技巧的文章值得一看。
- 了解帧率计数器技巧很有用——我最初是在这里了解到如何操作的。
- 对于使用 Vista 和 Windows Server 2003 的用户,有 XPerf。
历史
- 版本 1.00 2008 年 12 月 4 日