保存 Windows 窗体状态(序列化)
描述了如何保存窗体上所有控件的状态,并在稍后恢复它们
引言
当用户关闭窗体时保存数据是一个相当普遍的要求。如今,在许多应用程序中,这是通过数据库完成的。重新打开窗体涉及从数据库中读取数据,并用数据填充窗体的所有控件。除了编写所有这些代码所需的时间之外,维护它可能是一场噩梦,尤其是在窗体上有很多控件的情况下。每次更改窗体时,都必须更改保存数据的代码。
我最近在一个不适合使用数据库的应用程序上工作,但我希望保存窗体的数据以供下次使用。最直接的方法是将窗体对象序列化到磁盘。NET Framework 允许您用 [Serializable]
标记类,然后该框架会为您完成(几乎)所有繁重的工作。
不幸的是,这不适用于 Windows 窗体,因为它们没有被标记为 [Serializable]
,因此如果您尝试用自己的窗体进行此操作,编译器会抛出错误。
难道没有人做过这个吗?
在徒劳地搜索了几个小时之后,我得出的结论是,我找不到现成的解决方案,于是我决定编写自己的窗体序列化器。我希望它足够通用,可以轻松地集成到任何项目中,并以最小的努力保存一个窗体。
我对结果感到满意,因此愿意在此发布,以备不时之需。下载中包含的测试项目有一个主窗体,上面只有一个按钮

点击此按钮会打开一个子窗体,其中包含一些(相当愚蠢的)控件,用于询问您的精神状态!

如果您填写了一些值,然后单击“确定”按钮,窗体将关闭。如果“序列化?”复选框被选中,那么您输入的数据将保存在 XML 文件中。如果您现在单击主窗体上的“打开”按钮,将打开子窗体,并用相同的数据重新填充。如果您更改数据,取消选中“序列化?”复选框,然后再次单击“确定”,则数据不会被序列化,因此当您单击主窗体上的“打开”按钮时,子窗体将再次打开,但不会显示先前实例中的数据。如果您删除 XML 文件,那么当子窗体打开时,控件将为空。
为了尽可能简单化,保存窗体数据只需调用一次序列化方法,恢复数据只需再调用一次。无论您的窗体有多复杂,一次调用就足够了。
Using the Code
要在您自己的项目中使用此代码,您只需要将 FormSerialisor.cs 文件放入您的项目,然后在您希望使用该代码的任何类的顶部添加以下行
using FormSerialisation;
FormSerialisor
类是 static
的,因此您无需创建其实例。您只需调用 static
方法,并将您想要序列化的窗体(或控件,稍后讨论)以及您要用于保存数据的 XML 文件的完整路径传递进去。
现在序列化和反序列化非常容易。您可以从窗体本身内部进行,也可以从调用窗体的代码中进行。例如,如果您希望一个窗体自行序列化,只需使用以下行
FormSerialisor.Serialise(this, Application.StartupPath + @"\serialise.xml");
这将以 serialise.xml
文件的名称将窗体的数据保存在可执行文件所在的同一文件夹中。
如果您想在窗体下次加载时恢复数据,则可以将以下代码添加到 Form_Load
事件处理程序中
FormSerialisor.Deserialise(this, Application.StartupPath + @"\serialise.xml");
这将查找 XML 文件,如果文件存在,则使用文件中的数据填充窗体的控件。如果文件不存在,则不会发生任何事情。
那么它是如何工作的?
代码相当直接,但有几个小问题需要我考虑。序列化是通过枚举窗体上的所有控件,然后依次保存每个控件的数据来完成的。
Serialise(Control c, string XmlFileName)
方法创建一个 XmlTextWriter
对象来处理 XML 文件的写入,然后调用递归方法 AddChildControls
,并将窗体作为参数传递。请注意,此方法的签名将 Control
指定为第一个参数。由于 Form
本质上是一种 Control
,因此如果您传递的是窗体,这就能正常工作,但这意味着您不必序列化整个窗体,如果您不想的话。在我最初使用此代码的应用程序中,我传递了一个 TabControl
,因为我只想序列化该控件上的控件,而不包括主窗体上的工具栏控件等。
如果控件有子控件(例如,如果是 Panel
或 GroupBox
),则会枚举并保存子控件。这一切都是通过递归调用 AddChildControls
并将当前容器控件作为参数传递来完成的。
我决定不序列化 Label
控件,因为这些控件不是用户可更改的,而且(以我的经验)在代码中也不常更改。序列化 Label
控件没有任何坏处,只是会使 XML 文件稍大一些。如果您在代码中更改了 Label
控件的文本,您可能需要从 AddChildControls
方法中删除以下行(以及对应的闭括号)
if (!(childCtrl is Label)) {
您可以通过更改此行来阻止序列化其他类型的控件。例如,如果您不想序列化 Button
控件的状态(这在很多情况下也很常见),您可以将该行更改为
if (!(childCtrl is Label) && !(childCtrl is Button)) {
事实上,如今磁盘空间非常便宜,这真的没有必要,但我还是保留了它。我曾经在一个有大约 250 个控件的窗体上使用过这段代码,即使包括 Label
控件,XML 文件也只从大约 77KB 增加到大约 92KB,所以增加的文件大小可能不值得担心。
处理不同类型的控件
所有控件的 Visible
属性都会被保存,因为这是一个所有控件都共有的属性,而且我在代码中经常更改它。AddChildControls
方法会单独处理不同类型的控件,以便可以序列化控件特定的属性
if (childCtrl is TextBox) {
//...
} else if (childCtrl is ComboBox) {
//...
} else if (childCtrl is ListBox) {
//...
} else if (childCtrl is CheckBox) {
//...
}
按照目前的写法,它只处理最常见的控件,并且只保存最重要的属性。修改代码来处理其他控件和/或属性会很容易,但这里显示的都是我需要的。
这里需要注意的一点是,代码不会保存 ComboBox
或 ListBox
控件的列表项。这是因为我的应用程序没有动态填充它们。如果您也需要保存列表项,您可以使用与保存 ListBox
的 SelectedIndex
属性非常相似的代码。由于 ListBox
可能有多个 SelectedIndex
,我使用循环来保存它们。如果需要,您也可以对列表项执行相同的操作。
SplitContainer 控件的问题
在发布此代码的初始版本后,有人留下了评论(见下文),说它不能与 SplitContainer
控件一起使用。由于我从未使用过这些控件,所以我没有注意到这个问题。
事实证明,SplitContainer
控件有两个子 Panel
,它们没有 Name
属性。当您将 SplitContainer
控件添加到窗体时,框架会自动命名它们,但您自己无法设置。由于它们没有明确的名称,序列化代码在写入 XML 文件时给它们一个空名称。这在窗体反序列化时会导致错误。
事实证明解决这个问题很容易,但确实需要将 SplitContainer
控件作为特例来处理,而我通常不喜欢这样做。在序列化窗体时(在 AddChildControls
方法的末尾),我们需要检查 SplitContainer
,而不是序列化所有子控件,而是直接传递两个 Panel
控件...
if (childCtrl is SplitContainer) {
// handle this one as a special case
AddChildControls(xmlSerialisedForm, ((SplitContainer)childCtrl).Panel1);
AddChildControls(xmlSerialisedForm, ((SplitContainer)childCtrl).Panel2);
} else {
AddChildControls(xmlSerialisedForm, childCtrl);
}
然后,在反序列化时,如果我们处理的是 SplitContainer
控件,GetImmediateChildControl
方法需要检查父级的父级,而不仅仅是父级...
private static Control GetImmediateChildControl(Control[] ctrl, Control currentCtrl) {
Control c = null;
for (int i = 0; i < ctrl.Length; i++) {
if ((ctrl[i].Parent.Name == currentCtrl.Name)
|| (currentCtrl is SplitContainer &&
ctrl[i].Parent.Parent.Name == currentCtrl.Name)) {
c = ctrl[i];
break;
}
}
return c;
}
这解决了问题。
可见或不可见 - 这是一个非常有趣的问题!
如果您玩一下测试项目,您会看到点击“您开心吗?” CheckBox
会导致“开心” GroupBox
显示或隐藏。这是通过以下(相当简单的)事件处理程序来实现的,用于 CheckBox
的 Click
事件
private void chkHappy_CheckedChanged(object sender, EventArgs e) {
grpHappy.Visible = chkHappy.Checked;
}
当我第一次编写代码时,我使用以下行来序列化当前控件的 Visible
属性
xmlSerialisedForm.WriteElementString("Visible", childCtrl.Visible.ToString());
在我玩下载中包含的测试项目时,我注意到了一些奇怪的事情。测试项目有一个子窗体,看起来像这样

如果您取消选中“您开心吗?” CheckBox
,则“开心” GroupBox
将被隐藏。当窗体被序列化、反序列化,然后再次选中“您开心吗?” CheckBox
时,窗体将显示如下

请注意,“为什么?” Label
和旁边的 ComboBox
都消失了。点击“您开心吗?” CheckBox
会显示和隐藏 GroupBox
,但由于事件处理程序代码没有更改两个子控件的可见性,它们仍然是隐藏的。在这种简单的情况下,您可以通过在上面的事件处理程序中手动设置子控件的可见性来轻松解决问题,但这有两个缺点,一个较小,一个较大。
较小的问题是您必须编写更多代码。这几乎只是令人烦恼,并且不应该是必需的。
主要问题出现在您有子控件时,这些子控件可能不一定与容器处于相同的可见性状态。例如,在我开发此序列化代码时编写的应用程序中,我有许多容器包含子容器,或者只是单独的控件,它们的可见性根据其他控件的状态动态设置。简而言之,这意味着一些子控件将可见,一些则不可见。除非在反序列化窗体时运行所有相关代码,否则没有简单的方法可以知道。在许多情况下,这并不可行,而在其他情况下则非常难以维护。
经过大量调查,我发现当您将容器的 Visible
属性设置为 false
时,框架也会将所有包含控件的 Visible
属性设置为 false
。当窗体反序列化时,所有这些控件都隐藏了。当您单击 CheckBox
时,只有 GroupBox
被显示出来,而其他控件仍然隐藏。这让我好奇框架是如何知道控件可见性的真实状态的,因为如果没有序列化/反序列化问题,当容器的可见性被设置时,子控件的可见性就被正确设置了。
一位朋友给我指了 Stack Overflow 网站上的一个讨论,其中有人问了一个非常相似的问题。有人在回答中跟踪控件堆栈的代码,以查看框架实际上在做什么。看起来它使用了一个未文档化的方法,称为 GetState
,参数为 2
,以获取真实的可见性。
我复制了他的代码,并且运行正常。现在 Visible
属性的序列化看起来像这样
bool visible = (bool)typeof(Control).GetMethod("GetState", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(childCtrl, new object[] { 2 }); xmlSerialisedForm.WriteElementString("Visible", visible.ToString());
这稍微复杂一些,并且使用了未文档化的调用(这让我有点紧张),但它确实能正确工作。
同名控件的问题
虽然一个 Form
或任何其他容器只能包含一个名为 TextBox1
的控件,但没有什么能阻止用户控件包含另一个同名的控件。由于用于枚举子控件的代码会深入到用户控件内部(这很好,因为它省去了单独处理它们的麻烦),这意味着在控件层次结构的任何一点上,您都可能拥有比您下面多于一个同名的控件。
在反序列化时,代码会尝试找到要使用的正确控件,如下一行
Control[] ctrl = currentCtrl.Controls.Find(controlName, true);
如果名为 controlName
的控件是此控件层次结构部分中唯一具有该名称的控件,那么 ctrl
数组将只有一个条目,一切都很简单。但是,如果您的窗体有一个名为 TextBox1
的 TextBox
,并且您在窗体上有两个用户控件的实例,并且您在用户控件中添加了一个 TextBox
并将其名称保留为 TextBox1
,那么您将在层次结构中有三个名为 TextBox1
的控件。ctrl
数组将有三个条目,您必须决定哪个是正确的控件。
我对此的第一个解决方案是计算从 ctrl[i]
到 currentCtrl
的路径长度,然后选择最短的。写完这个之后,我才意识到(假设窗体被序列化和反序列化之间的层次结构没有改变),那么您想要的控件一定是当前容器控件的直接子控件。由于此容器中的只有一个控件可以具有您正在寻找的名称,因此查找正确控件的任务变得容易得多。您只需要遍历 ctrl
数组,查找一个父级是当前容器控件的控件。这是一个更优雅的解决方案,并且封装在 GetImmediateChildControl
方法中。
如果发生错误怎么办?
我仔细考虑了这个问题。我的第一个版本的代码在出现问题时(例如,反序列化时找不到控件,或者 XML 文件中的类型与窗体上的控件类型不匹配)使用了 MessageBox
调用。显然,这在一般情况下不是一个实用的方法,因为我想要一个可以重用的通用类,并且弹出 MessageBox
并非总是正确的做法(有些人会说从来都不是,但这又是另一天的话题)。它在开发和调试时是可以的,但必须更改为最终代码。
我尝试在出现问题时抛出异常,但最终放弃了这个想法,因为它变得太复杂了。最后,我只是忽略了错误,这意味着如果找不到控件,或者控件类型错误,则什么也不做。对我来说,这是最好的解决方案。对于在大团队工作的人来说,您可能对其他开发人员做什么几乎没有控制权,最好在此代码中添加异常,并警告其他开发人员进行捕获。
实际上,只要 XML 文件能够正常读写,我就从未遇到过非我自己错误引起过的异常。一旦我修复了那些错误,代码就无误地运行了。我唯一能预见到问题的时机是当控件层次结构发生变化,并且您尝试从过时的 XML 文件反序列化窗体时。在这种情况下,某些控件可能不会被重新填充。下次窗体被序列化时,数据将正确写入。
注意
下载中包含的测试项目是使用 Visual C# 2010 Express 编写的。如果您无法在您的 Visual Studio 版本中打开它,只需将 FormSerialisor.cs 文件复制到您的项目中,然后按照上面的说明在您自己的测试项目中使用它。您不需要任何花哨的东西。该代码已在 .NET Framework 的 2.0 和 4.0 版本上进行了测试,并且两者都能正常工作。
历史
- 2010 年 6 月 8 日:撰写初始文章
- 2010 年 6 月 13 日:更新代码以应对
SplitContainer
的奇特行为(请参阅下面的“SplitContainer
中的控件”评论)。我还将 FormSerialisor.cs 文件作为单独的下载提供,供那些没有 Visual C# 2010 Express 或不想要测试项目的人使用。