流体几何 - 动画库和配置应用程序





5.00/5 (46投票s)
对动画库及其使用的应用程序进行面向任务的审查
引言
本文介绍了一个在几个月内开发的动画应用程序。该应用程序名为“流体几何”,并通过 www.fluidgeometry.com 向公众发布。它使用 C# 和 Visual Studio .NET 2003 编写。
流体几何提供了一种直观的方式,让用户创建可以保存和稍后查看的动画“场景”。场景由一个或多个在场景中移动的“路径”组成。路径包含一个或多个导航路径的“移动器”。您可以使用多种类型的路径来构建场景,例如正弦波、椭圆、无穷符号等。每种路径都有各种设置,您可以进行配置以自定义其行为。您可以以创造一些真正令人惊叹的动画模式的方式组合路径。有关流体几何的更多信息,请随时访问 www.fluidgeometry.com。
流体几何分为三个物理部分
- FluidGeometryLib.dll - 该应用程序的核心是一个提供渲染服务的动画库。如果您愿意,可以在自己的应用程序中使用该程序集。
- Fluid Geometry.exe - 用户运行此可执行文件以启动配置窗体,用户可以在其中创建、修改、保存、删除和查看自定义场景。
- Fluid Geometry Screensaver.scr - 一个显示场景的屏幕保护程序。
本文结构
考虑到应用程序的大小和适度的复杂性,要写遍它的每一个方面(更不用说乏味了)将是适得其反的。因此,我决定最好将本文组织成一系列编程任务。我选择了我认为是应用程序中最有趣或最具挑战性的方面,并对其进行了重点介绍。本文包含多个主题,每个主题包含一个或多个部分。在深入了解某些工作原理的细节之前,我们将首先简要概述动画库的架构。这将为理解事物为何以这种方式实现提供背景。
我希望这种基于任务的格式对寻求快速答案的实用主义者和更全面的“从头到尾”阅读者都有益。下面,您将找到本文所探讨的各种编程任务的链接集合。
- 使用反射创建可扩展的用户界面
- 在运行时发现路径类型
- 查询路径类型以获取自定义设置
- 基于路径特定的设置动态创建控件
- 将输入值验证为任意约束
- 使用反射更新路径设置
- 序列化/反序列化
- 使用 NonSerialized 属性
- 使用反射进行自定义路径序列化
- 反序列化复杂的对象图
- 使用 GDI+ 进行自定义双缓冲渲染
- 将场景与其宿主分离 (ISceneHost)
在阅读本文其余部分之前,我建议您先下载并试用流体几何。您可以从本文顶部的链接或从 www.fluidgeometry.com 下载文件。如果您已经亲身体验了该应用程序,那么本文的其余部分将更有意义。而且,它很有趣!首次使用流体几何配置应用程序时,请务必阅读 **状态栏** 中有关将鼠标光标移到窗体上不同控件的有用说明。
架构
构成动画库的类很简单。下面是一个简化的类图,说明了关键类及其关系。
如上所示,动画逻辑只涉及很少的核心类型。以下是对图中引用的不同类型的简要概述。
Scene
Scene
类是关键,它代表用户通过配置应用程序创建的整个动画场景。Scene
包含对 ISceneHost
、MovementManager
和 PathCollection
的引用。它存储路径的默认设置;例如默认移动器图像、默认移动器速度等。Scene
还公开了将场景保存到/从磁盘加载的方法。
ISceneHost
ISceneHost
是一个接口,用于将 Scene
类与任何特定的渲染表面分离开。Scene
通过此接口检索用于渲染的 Graphics
对象以及其他宿主特定的信息。我们将在后面的“将场景与其宿主分离 (ISceneHost)”部分更深入地探讨为什么这个接口很重要。
MovementManager
MovementManager
负责以固定的时间间隔指示场景中的路径移动。由于同一场景中的不同路径可以以不同的速度移动,因此该类负责确保每条路径仅在应移动时移动,并且不会擦除较慢移动路径的部分。
PathCollection
此类是 ArrayList
的类型安全包装器,包含一些额外的成员。与上图相反,PathCollection
可以包含任意数量的路径,而不仅仅是两个。
Path
Path
类代表场景中的一条路径。Path
是一个 `abstract` 类,它提供了所有路径类型所需的通用功能。Path
和 Path
派生类是动画库中唯一包含数学逻辑的地方,这些逻辑生成了 Mover
遵循的形状。您可以开发自己的 Path
派生类,只要应用一些属性,它们就会自动集成到配置应用程序中。目前有五种 Path
派生类可用于场景。Path
主要负责协调其 MoverCollection
中 Mover
的运动。
MoverCollection
此类是 `ArrayList` 的类型安全包装器,包含一些额外的成员。与上图相反,`MoverCollection` 最多可以包含五百个 `Mover`,而不仅仅是三个。
Mover
Mover
代表存在于路径上的视觉对象。除了场景的背景颜色外,Mover
是动画库中唯一具有视觉表示的类。顾名思义,Mover
沿着它们所属的路径“移动”(“移动”实际上只是通过三角学方程操作坐标的结果)。Mover
以图像显示,该图像可以在场景中指定,或者可选地在每个路径上指定。给定路径上的每个移动器都与该路径上的其他每个移动器具有相同的图像和大小。
我将不讨论配置应用程序的设计,因为它基本上是一个单体的 `Form` 派生类,带有一些辅助类。然而,配置应用程序中有一些非常有趣的工作正在进行。下一个主题将对此进行讨论。
使用反射创建可扩展的用户界面
阅读本主题后,您将了解
- 应用程序如何受益于其用户界面与底层对象模型松散耦合
- 如何查询运行时程序集以获取有关其包含类型的信息
- 如何发现用自定义属性装饰的类型的属性
- 如何获取和设置动态发现的属性的值
- 如何基于通过反射找到的属性集动态创建和加载控件
- 如何根据仅在运行时已知的信息验证用户输入
整个项目中最具挑战性的方面之一是创建一个用户界面,该界面既能让用户轻松创建场景,又不需要在新路径类型创建时进行更新。主要目标是让新的 `Path` 派生类自动“插入”到现有基础结构中。如下面的屏幕截图所示,当向场景添加路径时,可以通过“**路径属性**”组框进行配置。
当用户选择添加到场景中的一个路径时,“**路径属性**”组框会填充代表所选路径公开的各种设置的控件。请记住,不同类型的路径具有不同的自定义设置,因此控件的数量和类型取决于所选路径的类型。动态加载的控件会显示并更新其相应属性的值。代表具有数值数据类型的属性的动态加载的 `TextBox` 控件会根据运行时确定的约束进行验证。
此功能净结果是路径不需要在设计时与一组控件关联。实际上,路径对如何在其设置在配置应用程序中显示一无所知,而配置应用程序对它显示的路径设置几乎一无所知。当创建新的 `Path` 派生类时,无需在设计时安排一组代表该新类型可自定义属性的控件。这种自动呈现路径设置的方法还确保了用户界面在所有路径类型之间保持一致,从而使用户更容易学习和导航应用程序的用户界面。
本文接下来的几个部分将讨论这个灵活的用户界面是如何实现的。
在运行时发现路径类型
流体几何配置应用程序在运行时不知道不同类型的路径。使用反射来确定运行时存在的 `Path` 派生类。当配置应用程序加载时,它会在 `FluidGeometryLib.dll` 程序集中搜索任何 `Path` 派生类,并将找到的类填充到“路径类型” `ListBox` 中,如下所示。
private void LoadPathTypesData()
{
// If the "Path Types" ListBox is empty,
// fill it with the names of any Path-derived
// types found in the assembly containing
// the Path type (a.k.a. FluidGeometryLib.dll).
//
if( this.lstPathTypes.Items.Count == 0 )
foreach( Type type in Assembly.GetAssembly( typeof(Path) ).GetTypes() )
if( typeof(Path).IsAssignableFrom( type ) && ! type.IsAbstract )
this.lstPathTypes.Items.Add( new PathTypeWrapper( type ) );
}
PathTypeWrapper
类包装了一个 `Type` 对象以及有关特定 `Path` 派生类的其他信息。“路径类型” `ListBox` 用 `PathTypeWrapper` 对象填充,以便当用户向场景添加路径时,创建该路径所需的所有信息都集中在一个方便的位置。
查询路径类型以获取自定义设置
一旦配置窗体加载完毕,“**路径类型**” `ListBox` 中填充了所有可用的路径类型,用户可能会向正在创建的场景中添加一条路径。例如,用户可能会双击“**路径类型**” `ListBox` 中的“**椭圆**”项。响应该双击,将创建一个 `EllipsePath` 对象并将其添加到场景中。此时,“场景中的路径” `ListBox` 会获得一个代表新创建的椭圆路径的项,并且该项将被选中。一旦选中该项,就有必要显示所选路径所有设置的控件。
然而,在加载控件之前,有必要了解所选路径提供哪些自定义设置。并非所有路径类型都具有相同的设置,因此再次使用反射来发现符合条件的属性。这是通过将 `PathSettingAttribute` 属性应用于路径类上应由用户配置的属性来实现的,然后在运行时,查询所选路径类型是否具有该属性的装饰。下面的代码片段演示了这种技术。
// This code is from the get method
// of the CustomSettings property in the PathTypeWrapper class.
ArrayList propsWithAttribute = new ArrayList();
foreach( Type type in this.TypeHierarchy )
{
PropertyInfo[] propInfos = type.GetProperties(
BindingFlags.DeclaredOnly |
BindingFlags.Public |
BindingFlags.Instance );
foreach( PropertyInfo pi in propInfos )
{
object[] objArr =
pi.GetCustomAttributes( typeof(PathSettingAttribute),
true );
if( objArr.Length > 0 )
{
PathSettingAttribute pathSetting =
objArr[0] as PathSettingAttribute;
propsWithAttribute.Add( new
PathSettingInfo( pi, pathSetting ) );
}
}
}
// Helper property in PathTypeWrapper class.
private Type[] TypeHierarchy
{
get
{
ArrayList typeHierarchy = new ArrayList();
Type t = this.Type;
while( t != null )
{
typeHierarchy.Add( t );
t = t.BaseType;
}
typeHierarchy.Reverse();
return typeHierarchy.ToArray( typeof(Type) ) as Type[];
}
}
// An example of a custom setting
// on the SineWavePath class using the PathSetting attribute.
// The arguments to the PathSetting constructor
// are the friendly name of the setting, the
// minimum value, and the maximum value.
[PathSetting("Number of Wave Peaks", 0, 40)]
public int NumPeaks
{
get
{
//...
}
set
{
//...
}
}
如上代码所示,`CustomSettings` 属性遍历 `Path` 派生类的类型层次结构中的每个类型,并存储有关用 `PathSetting` 属性装饰的每个属性的信息。类型层次结构中的各个类型会逐一检查,以确保找到每个符合条件的属性。它们从根类型到最派生类型的顺序进行检查,以便首先找到抽象 `Path` 类中的自定义设置。首先找到这些属性可确保代表它们的控件始终出现在“**路径属性**” `GroupBox` 中的相同相对位置。
基于路径特定的设置动态创建控件
一旦确定了所选路径的自定义设置,就可以创建用于显示和修改这些设置值的控件。 “**路径属性**” `GroupBox` 中的一些控件在设计时就存在;例如,表示移动器直径的 `TrackBar`、显示移动器图像的 `PictureBox` 等。每条路径都有一些共同的设置,因此这些设置的控件不会在运行时添加或删除(除了一些例外)。动态加载的控件被父级化到“**路径属性**” `GroupBox` 中的一个 `Panel`。
为自定义设置创建的控件类型基于设置的数据类型(即属性的类型)。布尔属性会得到 `CheckBox`,`Enum` 属性会得到 `ComboBox`,其他所有类型都会得到 `TextBox`。由于仅仅显示控件没有意义,因此会在 `ComboBox` 和 `TextBox` 旁边添加 `Label`,以便用户知道控件代表哪个设置。`Label` 显示在设置属性上应用的 `PathSetting` 属性中指定的设置的“友好名称”。下面是当选择 `SineWave` 路径时,“**路径设置**” `GroupBox` 中一些动态加载的控件的屏幕截图。
下面是创建和加载路径特定控件的逻辑。
private void LoadControlsForPreviewPath()
{
PathTypeWrapper wrapper =
this.lstPathsInScene.SelectedItem as PathTypeWrapper;
Path path = this.PreviewPath;
if( wrapper == null || path == null )
return;
// First remove the existing controls that
// were loaded for the previous Preview Path.
this.RemoveDynamicallyLoadedControls();
const int GAP = 6;
Control ctrl = null;
int tabIdx = this.pnlPermanentPathSettingsControlHost.TabIndex;
Point pt = new Point(
this.pnlPermanentPathSettingsControlHost.Location.X,
this.pnlPermanentPathSettingsControlHost.Location.Y +
this.pnlPermanentPathSettingsControlHost.Height + GAP );
foreach( PathSettingInfo settingInfo in wrapper.CustomSettings )
{
if( settingInfo.PropertyInfo.PropertyType == typeof(bool) )
{
#region Create CheckBox
CheckBox chkBox = new CheckBox();
chkBox.FlatStyle = FlatStyle.System;
chkBox.Checked = (bool)settingInfo.GetValue( path );
chkBox.CheckedChanged +=
new EventHandler( OnCheckBoxCheckedChanged );
chkBox.Text = settingInfo.PathSetting.FriendlyName;
ctrl = chkBox;
#endregion // Create CheckBox
}
else if( settingInfo.PropertyInfo.PropertyType.IsEnum )
{
#region Create ComboBox
ComboBox combo = new ComboBox();
combo.DropDownStyle = ComboBoxStyle.DropDownList;
string[] names =
Enum.GetNames( settingInfo.PropertyInfo.PropertyType );
string currentValue =
settingInfo.GetValue( path ).ToString();
foreach( string name in names )
{
int idx = combo.Items.Add( name );
if( name == currentValue )
combo.SelectedIndex = idx;
}
combo.SelectedIndexChanged +=
new EventHandler( OnComboBoxSelectedIndexChanged );
ctrl = combo;
#endregion // Create ComboBox
}
else
{
#region Create TextBox
TextBox txt = new TextBox();
txt.Text = settingInfo.GetValue( path ).ToString();
txt.Enter += new EventHandler( this.OnTextBoxEnter );
txt.KeyPress +=
new KeyPressEventHandler( this.OnTextBoxKeyPress );
txt.TextChanged +=
new EventHandler( this.OnTextBoxTextChanged );
txt.Leave += new EventHandler( this.OnTextBoxLeave );
ctrl = txt;
#endregion // Create TextBox
}
if( ctrl is CheckBox == false )
{
#region Add Label To Panel
Label lbl = new Label();
this.pnlPathAttributesControlHost.Controls.Add( lbl );
lbl.Text = settingInfo.PathSetting.FriendlyName;
lbl.FlatStyle = FlatStyle.System;
lbl.AutoSize = true;
lbl.Location = pt;
pt.Offset( 0, lbl.Height + 1 );
#endregion // Add Label To Panel
}
#region Add Control To Panel
this.pnlPathAttributesControlHost.Controls.Add( ctrl );
ctrl.Tag = settingInfo;
ctrl.Width = this.txtNumMoversOnPath.Width;
ctrl.Location = pt;
ctrl.TabIndex = tabIdx++;
this.AttachMouseEnterAndLeaveHandlers( ctrl );
pt.Offset( 0, ctrl.Height + GAP );
#endregion // Add Control To Panel
}
}
上面的代码遍历 `PathTypeWrapper` 类 `CustomSettings` 属性返回的 `PathSettingInfo` 对象数组。每个自定义设置都由一个 `PathSettingInfo` 对象描述。根据设置的数据类型,会创建和配置特定类型的控件。如果控件没有“内置标签”(如 `CheckBox`),则布局逻辑会将 `Label` 定位在控件上方。设置的显示名称从 `PathSettingInfo` 对象的 `FriendlyName` 属性中检索。每个控件的位置由 `pt` `Point` 变量确定,该变量在每次将控件添加到 `Panel` 后都会偏移。为了方便用户,每个控件的 `TabIndex` 都设置为比前一个控件创建的控件高一。这允许直观的键盘导航。
最后一点需要注意,每个控件的 `Tag` 都设置为代表该控件所代表的相同设置的 `PathSettingInfo` 对象。这将在以后用户编辑动态加载控件的值时很重要。`PathSettingInfo` 对象将提供更新路径上相应属性并执行输入验证所需的上下文。接下来的两个部分将讨论如何验证自定义设置的输入值以及如何更新设置。
将输入值验证为任意约束
如前所述,具有数值数据类型的路径设置在“**路径属性**” `GroupBox` 中以 `TextBox` 控件显示。由于用户可以在 `TextBox` 中键入任何值,因此有必要将输入限制在给定范围内的数字。如果属性的数据类型是整数(即非浮点数),则只能允许输入整数。
这些情况带来了一些有趣的问题
- 灵活编辑 - 用户不应被强制按特定顺序输入/删除字符。验证系统必须足够灵活,允许用户按其认为合适的方式输入值。有些人先删除旧数字,然后输入新数字,其他人先输入新数字,然后删除旧数字。
- 验证 - 必须动态发现并强制执行特定于设置的数字范围。如果 `TextBox` 没有输入焦点,它永远不应该有一个无效值。
- 通知用户 - 用户需要一种知道输入值何时无效的方法,并且还必须能够轻松找出设置的最小值和最大值。
上一节列出的代码演示了如何创建动态加载的 `TextBox` 并将其添加到控件层次结构中。创建 `TextBox` 控件时,`ConfigurationForm` 会为其一些事件附加处理程序,如下所示。
TextBox txt = new TextBox();
txt.Enter += new EventHandler( this.OnTextBoxEnter );
txt.KeyPress += new KeyPressEventHandler( this.OnTextBoxKeyPress );
txt.TextChanged += new EventHandler( this.OnTextBoxTextChanged );
txt.Leave += new EventHandler( this.OnTextBoxLeave );
当进入 `TextBox` 时,将调用 `ConfigurationForm` 的 `OnTextBoxEnter` 方法。它只是将 `TextBox` 的当前值缓存到成员变量中。由于规则是 `TextBox` 在没有输入焦点时不能有无效值,因此当 `TextBox` 获得输入焦点时,它将具有一个有效值。如果用户输入了无效值然后离开控件,该值可能稍后会使用。这是 Enter 事件处理程序。
private void OnTextBoxEnter(object sender, System.EventArgs e)
{
this.lastValidValueInTextBox = (sender as TextBox).Text;
}
下面是动态加载的 `TextBox` 的 `KeyPress` 事件处理程序。
private void OnTextBoxKeyPress( object sender, KeyPressEventArgs e )
{
// IsControl returns true for the Backspace key,
// which is an allowable key.
if( ! Char.IsDigit( e.KeyChar ) && e.KeyChar != '.'
&& ! Char.IsControl( e.KeyChar ))
e.Handled = true;
}
上面的代码阻止 `TextBox` 收到非数字字符、句点“.”或“控制键”(如 Backspace)以外的任何按键。这是第一层输入验证。由于这些 `TextBox` 只应包含数字值,因此阻止任何非数字字符通过键盘进入它们是有意义的。此逻辑不会阻止用户将非数字数据粘贴到 `TextBox` 中,但其他验证层会处理该问题。
一旦 `TextBox` 中的文本被更改,就会调用 `ConfigurationForm` 的 `OnTextBoxTextChanged` 处理程序。如果该处理程序确定 `TextBox` 代表自定义路径设置,它会调用以下方法,该方法执行大部分数据验证。
private void UpdateSettingInPreviewPath( TextBox textBox )
{
// This is necessary because otherwise it would be impossible
// to clear out the textbox or start a decimal number with a
// decimal point (as opposed to starting it with "0.").
//
string text = textBox.Text.Trim();
if( text == "" || text == "." )
return;
PathSettingInfo settingInfo = textBox.Tag as PathSettingInfo;
ValueType val = this.ConvertPathSettingInputValue( text, settingInfo );
if( val != null )
{
this.isPathSettingValueValid = true;
settingInfo.SetValue( this.PreviewPath, val );
this.lastValidValueInTextBox = textBox.Text;
this.errorProvider.SetError( textBox, String.Empty );
}
else
{
this.isPathSettingValueValid = false;
if( settingInfo.PathSetting.HasMaxValue &&
settingInfo.PathSetting.HasMinValue )
{
string errorMsg = String.Format(
"This value must be between {0} and {1}.",
settingInfo.PathSetting.MinValue.ToString(),
settingInfo.PathSetting.MaxValue.ToString() );
this.errorProvider.SetError( textBox, errorMsg );
}
}
}
private ValueType ConvertPathSettingInputValue(string inputText,
PathSettingInfo settingInfo)
{
bool isValid = true;
ValueType val = null;
try
{
Type propType = settingInfo.PropertyInfo.PropertyType;
val = Convert.ChangeType( inputText, propType ) as ValueType;
IComparable comparableVal = val as IComparable;
if( settingInfo.PathSetting.HasMinValue )
{
ValueType min = Convert.ChangeType(
settingInfo.PathSetting.MinValue, propType ) as ValueType;
isValid = comparableVal.CompareTo( min as IComparable ) >= 0;
}
if( isValid && settingInfo.PathSetting.HasMaxValue )
{
ValueType max = Convert.ChangeType(
settingInfo.PathSetting.MaxValue, propType ) as ValueType;
isValid = comparableVal.CompareTo( max as IComparable ) <= 0;
}
}
catch
{
isValid = false;
}
return isValid ? val : null;
}
上面的代码测试输入值是否在可接受范围内。如果是,则更新选定路径(也称为“预览路径”)上的设置,并缓存新的有效值。如果新值无效,则指示 `ErrorProvider` 组件在 `TextBox` 旁边显示错误图标。错误图标的工具提示会告知用户设置的可接受范围。与立即拒绝无效值相比,使用 `ErrorProvider` 允许灵活编辑,因为用户能够暂时在 `TextBox` 中输入无效值。它还作为一种告知用户正在修改的设置的有效值范围的方法。
ConvertPathSettingInputValue
方法是对输入值进行有效性测试的地方。如果输入值无法从 `string` 转换为要设置的属性的类型,则立即认为它无效。如果路径设置具有 `MinValue` 或 `MaxValue`,则将转换后的输入值与它们进行比较。路径设置的最小值和最大值在 `PathSettingAttribute` 构造函数中指定,如下所示。
[PathSetting("Number of Wave Peaks", 0, 40)]
public int NumPeaks
{
get
{
//...
}
set
{
//...
}
}
这些值作为公共属性公开在 `PathSettingAttribute` 类上,这就是在验证期间检索它们的方式。
最后,当用户将输入焦点移到另一个控件时,会处理 `TextBox` 的 `Leave` 事件。如果 `TextBox` 中包含的值已知无效或丢失,则会恢复缓存的有效值。以下代码演示了这一点。
private void OnTextBoxLeave(object sender, System.EventArgs e)
{
TextBox textBox = sender as TextBox;
if( ! this.isPathSettingValueValid || textBox.Text.Length == 0 )
{
textBox.Text = this.lastValidValueInTextBox;
}
}
一旦输入值成功验证,路径设置就会使用新值进行更新。执行更新的代码是从 `UpdateSettingInPreviewPath` 方法调用的,如上所示。下一节将探讨如何设置属性,即使属性名称在编译时未指定。
使用反射更新路径设置
一旦用户为路径设置指定了新值,就有必要将相应的属性设置为该新值。由于配置应用程序在编译时不知道路径类型和自定义路径设置,因此有必要使用反射来设置属性。所有相关的反射代码都在 `PathSettingInfo` 类中。当 `PathTypeWrapper` 创建 `PathSettingInfo` 对象时,它会将 `System.Reflection.PropertyInfo` 对象传递给它,如“查询路径类型以获取自定义设置”部分所示。这是相关代码片段。
foreach( PropertyInfo pi in propInfos )
{
object[] objArr =
pi.GetCustomAttributes( typeof(PathSettingAttribute), true );
if( objArr.Length > 0)
{
PathSettingAttribute pathSetting = objArr[0] as PathSettingAttribute;
propsWithAttribute.Add( new PathSettingInfo( pi, pathSetting ) );
}
}
PathSettingInfo
使用该 `PropertyInfo` 对象来获取和设置特定路径对象上属性的值。SetValue
方法是实际更新路径上值的方法,如下所示。
public void SetValue( Path path, object value )
{
this.PropertyInfo.SetValue( path, value, new object[0] );
}
SetValue
方法只是将工作委托给 `PropertyInfo.SetValue` 方法。
实现这种间接的路径配置方法是值得额外付出的努力。配置应用程序无需在编译时了解不同的路径类型。借助反射和自定义属性,可以将配置逻辑与动画库解耦。您可以创建和修改 `Path` 派生类,而无需担心破坏配置应用程序。
序列化/反序列化
阅读本主题后,您将了解
- 什么是序列化和反序列化,以及它们可以解决的一些问题。
- 如何序列化/反序列化对象图。
- 为什么以及如何排除某些对象进行序列化。
- 为什么以及何时应使用自定义序列化/反序列化。
- 如何使用 `ISerializable` 接口实现自定义基于反射的序列化/反序列化。
- 如何使用 `DeserializationCallback` 接口执行反序列化后任务。
如果配置应用程序不允许用户保存场景并在稍后查看它,那么它将非常令人沮丧。如果无法将场景保存到磁盘,流体几何屏幕保护程序将无法显示用户定义的场景。基本上,场景需要是可序列化的。
对于不熟悉序列化和反序列化概念的各位,希望以下解释能使这些概念清晰明了。
序列化 类似于在运行时拍摄对象图的快照。快照以紧凑的格式(通常是二进制或 SOAP)编写,该格式描述了图中每个对象的值。该快照被放入流中,然后您可以对其进行任何操作。通常,流的内容被刷新到文件并保存到磁盘。请记住,序列化的对象图在序列化过程发生后仍然被使用。序列化只是记录了对象图在给定时间点的状态。
反序列化 是获取序列化期间创建的快照并将其转换回“实时对象”的过程,即内存中程序可以使用的实际对象。
本文接下来的几节将深入探讨场景被序列化和反序列化所需的代码。路径类需要自定义序列化和反序列化,这将在最后两节中介绍。
场景序列化/反序列化服务可通过 Scene
类的 `static` 方法获得。下面是用于保存和加载场景的代码。
public static bool Save( Scene scene, Stream stream )
{
bool success = false;
try
{
bool active = scene.IsActive;
if( active )
scene.Pause();
new BinaryFormatter().Serialize( stream, scene );
if( active )
scene.Resume();
success = true;
}
catch( Exception ex )
{
Debug.Fail( "Failed to save Scene.Reason: " + ex.Message );
}
return success;
}
public static Scene Load( Stream stream, bool pathsAreVisible )
{
if( stream == null || ! stream.CanRead )
throw new ArgumentException( "The stream passed" +
" to Scene.Load is invalid." );
Scene scene = null;
try
{
scene = new BinaryFormatter().Deserialize( stream ) as Scene;
foreach( Path path in scene.Paths )
path.Visible = pathsAreVisible;
}
finally
{
if( stream != null )
stream.Close();
}
return scene;
}
Save
和 Load
方法没有什么需要解释的。接下来的几节将检查序列化和反序列化场景所涉及的细节。
使用 NonSerialized 属性
某些类型可以序列化,其他类型则不能。某些对象应序列化,其他对象则不应。确定哪些类型可以序列化很容易,因为只有应用了 `Serializable` 属性的类型才能序列化。另一方面,确定哪些对象应该序列化并不总是很清楚。出于两个原因,您会故意排除某些对象进行序列化。
- 不可序列化类型 - 如果对象图中的对象类型未应用 `Serializable` 属性,则必须将该对象排除在序列化过程之外。如果在序列化过程中,.NET 序列化器(即 `BinaryFormatter` 和 `SoapFormatter`)遇到不可序列化类型,它们将抛出异常。例如,`Scene` 类不会序列化其宿主,因为 `ISceneHost` 接口不可序列化(事实上,接口永远不能应用 `Serializable` 属性)。这意味着当场景被反序列化时,它必须被赋予一个指向新宿主的引用。
- 可推断信息 - 如果要序列化的对象图的一部分包含在反序列化后容易推断或重新生成的信息,则没有理由序列化该数据。这主要是一种简化序列化过程中生成的信息的方法。例如,路径上的移动器不会被序列化,因为在反序列化路径后,它们包含的所有信息都可以快速轻松地重新生成。因此,序列化场景的内存占用可以大大减少,特别是如果场景中的路径每条都包含数百个移动器。
您可以通过将 `NonSerializedAttribute` 属性应用于字段声明,告知 .NET 序列化器不应序列化类型中的某些字段。以下代码对此进行了演示。
[NonSerialized]
private ISceneHost host;
NonSerialized
属性是为 .NET 序列化器设计的,但也可以供您的代码使用。在下一节中,我们将讨论路径使用的自定义序列化逻辑,该逻辑利用了该属性。
使用反射进行自定义路径序列化
使用 .NET 序列化类(`BinaryFormatter` 和 `SoapFormatter`)提供的默认序列化服务的一个主要缺点是它们非常脆弱。所谓“脆弱”,我的意思是序列化对象的类型在反序列化成功之前不能添加、删除或修改字段。例如,假设路径类使用了默认序列化服务,并且包含 `EllipsePath` 的场景已被序列化并保存到磁盘。如果您然后向 `EllipsePath` 类添加一个新字段并重新编译,则保存的场景将无法成功反序列化。 .NET 序列化器要求序列化对象的字段必须与反序列化为该类型的字段完全匹配。在我们上面的例子中,`EllipsePath` 类被赋予了一个新的成员变量的事实足以导致反序列化过程停止并抛出异常。
这种强大的版本亲和性对 `Path` 派生类来说非常成问题。在流体几何动画库中,路径是新功能的焦点。路径可以获得新的能力;例如旋转、膨胀、振荡等能力。当实现这些新功能时,有必要向 `Path` 或 `Path` 派生类添加字段。仅仅因为给路径增加了一种扭曲或转弯的新方式,就导致您的一些或所有已保存场景变得无法使用,这将非常令人恼火。
幸运的是,.NET 序列化基础架构的明智架构师为这种情况提供了充足的支持。一个类可以通过应用 `Serializable` 属性并实现 `System.Runtime.Serialization.ISerializable` 接口来向 .NET 序列化器表明它将自行处理序列化和反序列化。手动执行序列化/反序列化的好处是,您的逻辑允许在新字段存在于类中时反序列化该类的实例。这使我们能够在路径类中实现新功能,而无需担心加载先前保存场景的能力。
需要注意的是,如果您选择此路线,您将负责类的实例的序列化和反序列化,您不能只做其中一个。对象的序列化在 `ISerializable` 接口的 `GetObjectData` 方法中执行。此接口很特别,因为它隐式要求实现它的类型具有具有特定签名的构造函数。该构造函数(我称之为“反序列化构造函数”)是您执行自定义反序列化逻辑的地方。本文的下一节将讨论路径的自定义反序列化。
下面是 `Path` 类实现的 `ISerializable.GetObjectData` 方法及其辅助方法。
public void GetObjectData( SerializationInfo info,
StreamingContext context )
{
// Cache the number of Movers on the Path.
// This information is used during deserialization.
//
this.totalMoversOnPath = this.Movers.Count;
// The serialization process is broken
// into two phases because the reflection API
// does not allow you to access
// the private fields of a type's base class.
// Since we do not want all of the Path class'
// fields to be protected for
// this reason, we need to serialize
// out the Path partial first. This allows
// the Path class to have private fields
// that get serialized. If a new type of
// path is created which indirectly
// derives from Path, this logic will need to be
// modified so that it loops over every
// partial, not just Path and most derived type.
//
this.GetObjectDataHelper( info, typeof(Path) );
this.GetObjectDataHelper( info, this.GetType() );
}
private void GetObjectDataHelper( SerializationInfo info, Type typeToProcess )
{
BindingFlags flags = this.GetSerializationBindingFlags( typeToProcess );
FieldInfo[] fields = typeToProcess.GetFields( flags );
// Save the value of every non-constant field which does not
// have the NonSerialized attribute applied to it.
//
foreach( FieldInfo field in fields )
if( ! field.IsLiteral && ! field.IsNotSerialized )
info.AddValue( field.Name, field.GetValue( this ) );
}
private BindingFlags GetSerializationBindingFlags( Type typeToProcess )
{
BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;
if( typeToProcess != typeof(Path) )
flags |= BindingFlags.DeclaredOnly;
return flags;
}
GetObjectData
方法很简单。它缓存路径上的移动器数量,这在反序列化路径后是必需的。然后它调用一个辅助方法两次,该方法执行将路径状态保存到 `SerializationInfo` 参数中的实际工作。SerializationInfo
本质上只是一个键值列表,它允许您将一个对象与一个标识符(通常是字段的名称)关联起来。稍后,反序列化逻辑将遍历列表中每个条目的名称,并将成员变量设置为已保存的值,如下一节详细讨论的。
路径的类型层次结构中的每个类都会被单独检查,因为反射 API 不允许您获取或设置类基类型中的 `private` 字段的值。如果序列化不是以这种方式执行的,那么 `Path` 类将无法序列化其 `private` 字段。考虑到非私有成员变量相关的重大失误,我不得不付出额外的努力。GetObjectDataHelper
方法检索当前正在序列化的类型中每个非公共非静态字段的列表。如果类型是从 `Path` 类派生的,它会使用 `‘DeclaredOnly‘` 标志来指示不应将从 `Path` 类继承的任何 `protected` 字段包含在列表中。然后迭代字段列表,并且每个非常量且未用 `NonSerialized` 属性装饰的字段都会被添加到 `SerializationInfo` 对象的 `values` 列表中。这就是 `NonSerialized` 属性被 .NET 序列化服务以外的代码使用的地方,如上一节所述。Path
和 Path
派生类的字段可以被装饰 `NonSerialized` 属性,并且 `Path` 类中的自定义序列化逻辑将知道避免保存它们的值。FieldInfo
类公开了一个方便的属性 `IsNotSerialized`,如果字段应用了 `NonSerialized` 属性,则返回 `true`。
当前自定义序列化逻辑的实现有一个限制。`Path` 派生类不能有一个与 `Path` 类中的字段同名的字段。这是因为只有字段名存储在 `SerializationInfo` 对象中。如果 `Path` 类有一个名为 `‘foo‘` 的 `private` 字段,并且一个 `Path` 派生类也声明了一个名为 `‘foo‘` 的字段,那么反序列化逻辑将无法知道哪个 `‘foo‘` 是哪个。我对此不担心。如果这成为一个问题,那么解决方案是将声明类的名称后跟一个分隔符添加到字段名称前面,例如 `Path` 的 `‘foo‘` 字段的“Path+foo”。这将作为 `Path` 在 `SerializationInfo` 对象中的 `‘foo‘` 值的键。反序列化逻辑需要按分隔符拆分键,然后它将有足够的信息来设置正确类上的变量。
最后一点需要注意,上面的序列化逻辑位于抽象的 `Path` 类中。Path
派生类也必须被装饰 `Serializable` 属性并实现 `ISerializable`。`.NET` 序列化器要求要序列化的对象的运行时类型显式指示它是可序列化的。在 `Path` 派生类上实现这一点很简单。
[Serializable]
public class SpiralPath : Path, ISerializable
{
// Other members omitted...
void ISerializable.GetObjectData( SerializationInfo info,
StreamingContext context )
{
base.GetObjectData( info, context );
}
protected SpiralPath( SerializationInfo info,
StreamingContext context )
: base( info, context )
{
}
}
既然我们已经看到了场景及其路径是如何被序列化的,现在是时候关注另一面了。下一节将检查反序列化场景所需的代码,包括执行路径自定义反序列化的复杂任务。
反序列化复杂的对象图
如果 `Path` 不使用自定义序列化,则反序列化场景将非常简单。就像在本文开头主题的 `Load` 方法中看到的,只需调用 `BinaryFormatter` 的 `Deserialize` 方法即可。然而,由于 `Path` 和 `Path` 派生类实现了 `ISerializable` 接口,该接口要求它们自己处理序列化和反序列化,因此情况比单个方法调用要复杂。
在前一节中,提到实现 `ISerializable` 接口意味着必须实现一个特殊的构造函数,我称之为“反序列化构造函数”。如果实现 `ISerializable` 的类型没有反序列化构造函数,您不会收到编译器错误,但当您尝试反序列化该类型的对象时,您将收到运行时异常。这种情况可以归因于接口定义不能包含构造函数或非公共成员,但为了避免安全和版本问题,用于反序列化的方法必须是非公共构造函数。 .NET Framework 文档在此主题上提供了更多信息,在此。
Path
类的反序列化构造函数提供了所有路径类型使用的反序列化逻辑。它接收一个 `SerializationInfo` 对象,其中包含在序列化过程中存储的值。使用反射将 `Path` 的成员变量的值设置为在 `SerializationInfo` 参数中找到的已保存值。Path
类的反序列化构造函数及其辅助方法如下所示。
protected Path( SerializationInfo info, StreamingContext context )
{
// Refer to GetObjectData for an explanation
// of why this is performed in multiple steps.
//
this.DeserializationCtorHelper( info, typeof(Path) );
this.DeserializationCtorHelper( info, this.GetType() );
}
private void DeserializationCtorHelper( SerializationInfo info,
Type typeToProcess )
{
BindingFlags flags = this.GetSerializationBindingFlags( typeToProcess );
foreach( SerializationEntry entry in info )
{
FieldInfo field = typeToProcess.GetField( entry.Name, flags );
if( field != null )
field.SetValue( this, entry.Value );
}
}
private BindingFlags GetSerializationBindingFlags( Type typeToProcess )
{
BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;
if( typeToProcess != typeof(Path) )
flags |= BindingFlags.DeclaredOnly;
return flags;
}
反序列化逻辑与上一节中看到的序列化逻辑非常相似。Path
类层次结构中的每个类型都会被单独检查,因为反射 API 不允许您访问类基类型的私有成员。与序列化逻辑不同,在反序列化时,无需检查要设置的字段是否为常量或是否应用了 `NonSerialized` 属性,因为在序列化过程中只保存了非常量可序列化值。
另一个复杂因素是路径上的移动器未被序列化,这在本文主题的第一节中已讨论过。作为该优化的结果,在反序列化过程中有必要将适当数量的移动器添加回 `Path` 的 `MoverCollection` 中。移动器可以由反序列化构造函数添加回来,但我选择改用 `IDeserializationCallback` 接口来处理此任务。IDeserializationCallback
是 .NET Framework 提供的接口,它允许在对象反序列化完成后得到通知。该接口包含一个方法:`OnDeserialization`。Path
类实现了此接口,并在该方法中将移动器添加回 `MoverCollection`。下面是 `Path` 实现 `IDeserializationCallback` 的代码。
void IDeserializationCallback.OnDeserialization( object sender )
{
// Since this.movers is not serialized, we need to add the Movers
// back into the Path once deserialization is complete.Not serializing
// the Movers reduces the memory footprint of a serialized Scene.
//
for( int i = 0; i < this.totalMoversOnPath; ++i )
this.Movers.Add( new Mover( this ) );
}
我选择使用此接口,而不是将上面的代码放在反序列化构造函数中,因为此任务涉及的不仅仅是恢复简单成员变量的状态。IDeserializationCallback
的文档说明:“如果一个对象需要对其子对象执行代码,它可以延迟此操作,实现 IDeserializationCallback
,并仅在调用此接口时执行代码。”当调用 `OnDeserialization` 方法时,您可以确信对象已完全反序列化并处于有效状态。
本主题解释了何时以及为何值得为类实现自定义序列化。没有必要使用反射来执行这些任务,但这样做可以防止您在修改类的字段集时需要更新序列化逻辑。此外,使用这种基于反射的方法可能在正在维护自定义序列化的类是由不太有经验的开发人员进行维护的情况下有所帮助,这些开发人员可能不知道自定义序列化逻辑。
使用 GDI+ 进行自定义双缓冲渲染
阅读本主题后,您将了解
- 如何以及为何使用双缓冲渲染。
- 流体几何如何以及为何实现自定义双缓冲渲染。
许多 WinForms 应用程序无需执行大量自定义绘图。通常,应用程序的用户界面中涉及的所有渲染都由窗体上的控件处理。这尤其适用于业务应用程序,其中用户界面的主要焦点是业务数据的创建、修改、传输、分析和删除。该类性质的应用程序通常不需要绘制许多虚线、蓝色矩形等。然而,在某些情况下,应用程序需要执行自定义渲染。如果应用程序执行的自定义渲染需要频繁更新,则可以使用双缓冲来减少闪烁。
对于不熟悉双缓冲概念的各位,希望以下解释能帮助您理解。
双缓冲 是一种绘图技术,用于帮助减少频繁绘制操作引起的闪烁量。应用程序不是直接绘制到屏幕,而是首先绘制到内存中的缓冲区。一旦整个图像被渲染,缓冲区就会被一次性复制到屏幕上。由于直接绘制到屏幕非常慢,因此最好在内存中尽可能多地绘制,然后将该缓冲图像直接复制到屏幕上。在正在渲染的图像是许多重叠视觉元素的组合的情况下,这尤其有益,因为一次将图像的一个图层绘制到屏幕上会增加屏幕执行的绘图操作的总数。屏幕执行的绘图操作越多,观察到的闪烁就越多。请注意,双缓冲不一定会使渲染过程更快,实际上它可能会使其变慢。双缓冲渲染的冲动,或存在的理由,仅仅是为了减少频繁绘制操作引起的闪烁。
.NET Framework 通过 `Control` 类的 `protected` `SetStyle` 方法提供双缓冲支持。由于 `Form` 类间接派生自 `Control`,因此它也可以使用 `SetStyle` 方法为自身启用双缓冲渲染。当您在 `Form`(或任何控件)上启用双缓冲时,传递到其 `OnPaint` 方法和 `Paint` 事件处理程序中的 `Graphics` 对象实际上将绘制到内存中的缓冲区。网上有很多关于如何使用标准的 .NET 双缓冲技术的精彩文章,因此我们在这里不再深入探讨。
流体几何绝对需要双缓冲,但它不能依赖 .NET Framework 提供的标准双缓冲。它需要双缓冲,因为场景每秒重绘数百次。它不能使用标准的 .NET 双缓冲,因为场景不知道谁将托管它,如下一个主题将详细介绍的。由于场景不知道谁将托管它,因此无法确保其宿主支持双缓冲。这需要动画库实现自定义双缓冲,作为附带好处,这使得场景宿主永远不需要明确支持双缓冲。实际上,如果场景宿主支持双缓冲,它就需要通过一番周折才能确保场景渲染到正确的 `Graphics` 对象。
下图说明了动画库中双缓冲的基本工作原理。
场景有一个屏幕外缓冲区,场景中的移动器会渲染到其中。在所有路径完成移动器渲染后,缓冲区会被复制到场景宿主提供的 `Graphics` 对象。然后,该 `Graphics` 对象以原子操作的形式将新生成的图像绘制到屏幕上。
下面是 `Scene` 类中提供渲染表面的逻辑。
private Bitmap RenderingSurface
{
get
{
if( this.renderingSurface == null )
this.renderingSurface = new Bitmap(
Math.Max( this.Size.Width, 1 ),
Math.Max( this.Size.Height, 1 ) );
return this.renderingSurface;
}
}
internal Graphics OffscreenGraphics
{
get
{
if( this.grfxRenderingSurface == null )
this.grfxRenderingSurface =
Graphics.FromImage( this.RenderingSurface );
return this.grfxRenderingSurface;
}
}
private void OnHostResize(object sender, EventArgs e)
{
bool wasActive = this.IsActive;
this.Stop( true );
if( this.renderingSurface != null )
{
this.renderingSurface.Dispose();
this.renderingSurface = null;
}
if( this.grfxRenderingSurface != null )
{
this.grfxRenderingSurface.Dispose();
this.grfxRenderingSurface = null;
}
foreach( Path path in this.Paths )
path.Reinitialize();
if( wasActive )
this.Start();
}
上面的代码创建一个与场景宿主提供的显示区域大小相同的缓冲区(`System.Drawing.Bitmap` 对象)。移动器永远不会直接引用该 `Bitmap`,而是通过 `OffscreenGraphics` 属性在其上绘制。该属性只是缓存一个可以绘制到缓冲区上的 `Graphics` 对象并返回其引用。当宿主大小调整时,会调用 `OnHostResize` 事件处理程序,以确保屏幕外缓冲区的大小与场景宿主提供的实际显示区域大小相同。
当需要将缓冲区复制到屏幕时,会调用 `Scene` 类中的以下方法。
internal void UpdateView()
{
if( this.host == null || this.isDisposed )
return;
try
{
this.host.SceneGraphics.DrawImageUnscaled( this.RenderingSurface,
Point.Empty );
}
catch( Exception ex )
{
// In case something goes wrong, stop the Scene.
//
this.Stop( false );
}
}
缓冲区通过 `DrawImageUnscaled` 方法复制到屏幕,因为它非常快,并且缓冲区的大小与显示区域的大小相同。Point.Empty
参数指定图像将在屏幕上绘制的位置的左上角坐标(Point.Empty
表示 (0, 0) 坐标)。
Mover
类绘制到屏幕外缓冲区,如下所示。
protected virtual void Draw( bool visible )
{
if( ! this.Scene.IsActive || ! this.IsInView )
return;
if( visible )
this.Scene.OffscreenGraphics.DrawImage( this.Path.MoverImage,
this.Bounds );
else
this.Scene.OffscreenGraphics.FillRectangle( this.Scene.BackgroundBrush,
this.EraseBounds );
}
Mover
使用 `DrawImage` 方法而不是 `DrawImageUnscaled` 有几个原因。一个原因是 `DrawImageUnscaled` 不接受浮点坐标,而浮点坐标对于保留移动器在路径上的精确位置是必需的。此外,如果路径上的移动器显示用户提供的图像,则必须缩放该图像,因为图像的大小可能与显示它的移动器的大小不同。缩放图像可确保它占据渲染它的所有空间(可以将其视为“拉伸”图像)。
MovementManager
类是渲染引擎的核心。我们不会在这里详细介绍它是如何工作的,但该类中的以下方法显示了整个渲染过程是如何联系在一起的。
private void OnTimerTick(object sender, EventArgs e)
{
++this.tickCount;
if( this.ShouldCreateMotion )
{
PathCollection visiblePaths = this.Scene.VisiblePaths;
foreach( Path path in visiblePaths )
if( this.PathNeedsToMove( path ) )
path.Erase();
foreach( Path path in visiblePaths )
if( this.PathNeedsToMove( path ) )
path.Move();
// Draw every visible path whether it was moved or not
// because the erasing performed by moved Paths will
// leave "holes" in paths lower in the Z order.
foreach( Path path in visiblePaths )
path.Draw();
this.Scene.UpdateView();
}
}
此方法响应 `Timer` 的 `Tick` 事件,该事件每毫秒触发一次。如果它确定场景中至少有一条路径需要移动,它会告诉相应的路径擦除、移动和绘制。当一条路径被告知擦除、移动或绘制自身时,它只是告诉它包含的每个移动器执行相同的操作。在路径更新并且场景的屏幕外缓冲区包含新图像后,会调用场景的 `UpdateView` 方法,以便将新图像复制到屏幕。
本主题讨论了双缓冲的概念以及何时应使用它。对于大多数可以从使用双缓冲渲染中受益的应用程序,使用 .NET Framework 内置的双缓冲支持就足够了。考虑到流体几何的图形密集型性质以及渲染逻辑作为托管服务公开的事实,流体几何需要使用自定义双缓冲。下一个主题将探讨 `Scene` 类是如何以及为何被托管的。
将场景与其宿主分离 (ISceneHost)
阅读本主题后,您将了解
- 松散耦合可重用类型与其使用者类型的好处。
- 如何使用接口来松散耦合应用程序的不同部分。
流体几何动画库能够通过任何对象显示场景,前提是该对象实现了 `ISceneHost` 接口。实现 `ISceneHost` 接口的对象被称为场景的“宿主”。宿主提供场景运行所需的几条信息。Scene
不关心哪个对象在托管它,只要它实现了 `ISceneHost`。这种技术的优点是任何应用程序都可以将场景显示在任何它认为合适的渲染表面上。例如,流体几何配置应用程序在两个不同的地方显示场景。“路径预览” `GroupBox` 包含所选路径的预览,并且当单击“**查看场景**”按钮时,会启动一个单独的窗口来显示场景。ConfigurationForm
为预览路径的显示实现了 `ISceneHost`。SceneForm
(实际上位于动画库程序集中)也实现了 `ISceneHost`。
下面是 `ISceneHost` 接口的声明。
/// <summary>
/// ISceneHost provides services needed by a Scene.
/// </summary>
public interface ISceneHost
{
/// <summary>
/// Returns the Graphics object to be used
/// when rendering into the scene. Do NOT dispose of this object.
/// </summary>
System.Drawing.Graphics SceneGraphics { get; }
/// <summary>
/// Gets the size of the visual area available for the scene.
/// </summary>
System.Drawing.Size Size { get; }
/// <summary>
/// The color of the scene's background.
/// </summary>
System.Drawing.Color BackColor { get; }
/// <summary>
/// Fires when the scene needs to be repainted.
/// </summary>
event System.Windows.Forms.PaintEventHandler Paint;
/// <summary>
/// Fires when the size of the scene changes.
/// </summary>
event EventHandler Resize;
}
该接口最重要的两个成员是 `SceneGraphics` 和 `Size`。场景绘制到通过 `SceneGraphics` 属性公开的 `Graphics` 对象。`Size` 属性用于确定场景的物理尺寸。
以下是 `ConfigurationForm` 对 `ISceneHost` 接口的实现。请记住,‘pnlSceneHost
’ 成员变量是用于渲染预览路径的 `Panel` 控件。
Color ISceneHost.BackColor
{
get { return this.pnlSceneHost.BackColor; }
}
Graphics ISceneHost.SceneGraphics
{
get
{
if( this.grfxSceneHost == null )
this.grfxSceneHost = this.pnlSceneHost.CreateGraphics();
return this.grfxSceneHost;
}
}
Size ISceneHost.Size
{
get { return this.pnlSceneHost.Size; }
}
需要注意的是,如果一个 `Form` 派生类实现了这个接口,您需要使用显式接口实现来避免与具有相同名称的现有属性(如 `BackColor` 和 `Size`)发生名称冲突。由于 `ConfigurationForm` 继承了 `Paint` 和 `Resize` 事件,因此无需再次实现它们。一个需要注意的点是 `SceneGraphics` 属性缓存了用于在 `Panel` 上渲染的 `Graphics` 对象的引用。考虑到场景访问它的频率,这是一个重要的优化。
当实例化(或反序列化)一个 `Scene` 时,在它开始动画之前必须为其提供一个宿主。Scene
类为此目的公开了一个 `public` 方法。
public void AttachHost( ISceneHost host )
{
if( host == null )
throw new ArgumentNullException( "host",
"The Scene's host cannot be null." );
if( this.host != null )
this.DetachHost();
this.host = host;
this.host.Paint +=
new System.Windows.Forms.PaintEventHandler( this.RepaintScene );
this.host.Resize += new EventHandler( this.OnHostResize );
}
如果需要删除或更改场景的宿主,可以使用 `DetachHost` 方法。
public ISceneHost DetachHost()
{
if( this.host == null )
return null;
ISceneHost sceneHost = this.host;
this.host.Paint -=
new System.Windows.Forms.PaintEventHandler( this.RepaintScene );
this.host.Resize -= new EventHandler( this.OnHostResize );
this.host = null;
return sceneHost;
}
本主题讨论了将场景与其宿主解耦的优势。如果流体几何动画库不打算可重用,那么就没有必要泛化场景与其渲染表面之间的关系。Scene
类本可以只接受一个 `Panel` 控件作为其宿主。然而,由于动画库应在各种上下文中可重用,因此有必要将场景与其宿主之间的关系抽象成一个正式的契约,该契约可以根据宿主的意愿实现。
结论
本文涵盖的主题仅构成实现流体几何过程中克服的挑战中的一小部分。我希望探讨的主题对您有用和/或有趣。如果您决定探索源代码并有任何疑问、评论、建议等,请随时在本文相关的消息板上发表您的想法。如果需要,我会尽力及时回复。感谢您花时间阅读我的文章,希望它是值得的。J
历史
- 2005年9月13日:初版