ASP.NET 控件开发速成教程:生成复合控件
适用于:
Microsoft ASP.NET 2.0
Visual Basic 2005
Visual C# 2005
.NET Frameworks
Visual Web Developer 2005
摘要: Dino Esposito 一直在编写有关 ASP.NET 控件开发的系列教程,并在以下第四部分中介绍了如何使用和创建复合控件。
随本文提供了 Visual Basic 和 C# 两种源代码。请从 此处 下载。
本页内容
简介
复合控件的要点是什么?
复合控件的常见方案
复合控件的呈现引擎
用于解决设计时问题的 CompositeControl
生成数据绑定复合控件
结论
简介
复合控件只不过是普通的 ASP.NET 控件,还不属于要论及的另一种类型的 ASP.NET 服务器控件。既然这样,为什么在各书籍和文档中总要留出专门的章节来论述复合控件呢?ASP.NET 复合控件有什么特别之处呢?
顾名思义,复合控件是将多个其他控件聚集在某单一顶部和单一 API 下的控件。如果某个自定义控件由一个标签和一个文本框组成,就可以说该控件是一个复合控件。“复合”一词表明该控件本质上是由其他构成组件在运行时组合而成。复合控件所暴露的方法集和属性集通常(但不是必须)由构成组件的方法和属性提供,并加入一些新成员。复合控件也可以引发自定义事件,还可以处理并激起子控件所引起的事件。
复合控件在 ASP.NET 中如此特别并不是因为其有可能成为服务器控件新类型的代表。更确切的说是因为它在呈现时获得了 ASP.NET 运行时的支持。
复合控件是一个功能强大的工具,可以生成丰富复杂的组件,这些组件产生自活动对象的相互作用而不是某些字符串生成器对象的标记输出。复合控件以构成控件树的形式呈现,每个构成控件都有其自己的生命周期和事件,并且所有构成控件都联合构成一个全新的 API,并按需要尽可能地抽象化。
在本文中,我将论述复合控件的内部体系结构,以阐明它在多种情况下为您带来的好处。接下来,我将生成一个复合列表控件,与我在以前文章中所述控件的功能集相比,此控件的功能集更为丰富。
复合控件的要点是什么?
前一段时间,我曾经自己尝试在 ASP.NET. 中研究复合控件。我从 MSDN 文档学习理论和实践知识,并也设计出一些不错的控件。但是,只有当我有一次在纯属偶然的情况下看到以下示例时,我才真正领悟到复合控件的要点(和优点)。设想一下由两个其他控件(Label 和 TextBox)的组合生成的迄今为止最简单(也是最常见)的控件。以下介绍了一种编写这种控件的可行方法。我们将其命名为 LabelTextBox。
public class LabelTextBox :WebControl, INamingContainer { public string Text { get { object o = ViewState["Text"]; if (o == null) return String.Empty; return (string) o; } set { ViewState["Text"] = value; } } public string Title { get { object o = ViewState["Title"]; if (o == null) return String.Empty; return (string) o; } set { ViewState["Title"] = value; } } protected override void CreateChildControls() { Controls.Clear(); CreateControlHierarchy(); ClearChildViewState(); } protected virtual void CreateControlHierarchy() { TextBox t = new TextBox(); Label l = new Label(); t.Text = Text; l.Text = Title; Controls.Add(l); Controls.Add(t); } }
该控件具备两个公共属性(Text 和 Title)以及一个呈现引擎。这两个属性保存在视图状态中,并分别表示 TextBox 和 Label 的内容。该控件对于 Render 方法没有替换方法,并通过 CreateChildControls 替换方法来生成其自己的标记。我马上就会详述呈现阶段的例行过程。CreateChildControls 的代码首先清除子控件的集合,然后为当前控件输出的构成控件生成控件树。CreateControlHierarchy 是一种特定于控件的方法,不要求必须标记为受保护和虚拟。但请注意,大多数自带复合控件(例如 DataGrid)只是通过一个类似的虚拟方法来暴露用于生成控件树的逻辑。
CreateControlHierarchy 方法会根据需要实例化多个构成组件,然后合成最终输出。完成之后,各控件将被添加到当前控件的 Controls 集合。如果希望控件的输出结果是一个 HTML 表,则可以创建一个 Table 控件,并相应添加含有各自内容的行和单元格。所有行、单元格和所含控件都是最外部表的子项。这时,您只需将 Table 控件添加到 Controls 集合中即可。在上述代码中,Label 和 TextBox 是 LabelTextBox 控件的直接子项并直接添加到集合中。控件的呈现状态和运行状态都很正常。
单纯从性能上看,创建控件的暂态实例不如呈现一些纯文本的效率高。让我们考虑一种无需子控件就能编写上述控件的替代方法。这次让我们将其命名为 TextBoxLabel。
public class LabelTextBox :WebControl, INamingContainer { : protected override void Render(HtmlTextWriter writer) { string markup = String.Format( "<span>{0}</span><input type=text value='{1}'>", Title, Text); writer.Write(markup); } }
该控件具备同样的两个属性(Text 和 Title)并替换了 Render 方法。正如您所看到的那样,其实现过程相当简单并且代码运行速度也略胜一筹。您可以通过在字符串生成器中合成文本并为浏览器输出最终标记来取代合成子控件的这种方法。同样,此时控件的呈现状态良好。但我们真的可以说它的运行状态也同样良好吗?图 1 显示了在示例页中运行的两个控件。
在页面中启用跟踪功能并重新运行。当页面显示在浏览器中时,将其向下滚动并查看控件树。它将如下所示:
复合控件由构成组件的活动实例组成。ASP.NET 运行时会发现这些子控件,并可以在处理已发布数据时同它们进行直接通信。其结果是,子控件可以自己处理视图状态并自动激起事件。
对于基于标记合成的控件,情况则不同。如图中所示,该控件是一个带有空 Controls 集合的代码基本单位。如果标记在页面中注入交互元素(文本框、按钮、下拉式菜单),则 ASP.NET 在不涉及控件本身的情况下无法处理回发数据及事件。
尝试在两个文本框中输入一些文本并单击图 1 中的“刷新”按钮,这样就可以发生一个回发。第一个控件(即复合控件)在经过回发后会正确保留所分配的文本。使用 Render 方法的第二个控件在经过回发后会丢失新文本。为什么会这样呢?其中兼有两个原因。
第一个原因是,在上述标记中我没有为 <input> 标记命名。这样,它的内容就不会回发。请注意,必须使用 name 属性来为元素命名。让我们对 Render 方法做如下修改。
protected override void Render(HtmlTextWriter writer) { string markup = String.Format( "<span>{0}</span><input type=text value='{1}' name='{2}'>", Title, Text, ClientID); writer.Write(markup); }
注入客户端页面的 <input> 元素现在与服务器控件使用相同的 ID。页面回发时,ASP.NET 运行时可发现一个与已发布字段的 ID 相匹配的服务器控件。但它并不知道如何处理该控件。要使 ASP.NET 将所有的客户端更改都应用于服务器控件,该控件必须实现 IPostBackDataHandler 接口。
包含 TextBox 的复合控件无需担心回发问题,因为所嵌入的控件会使用 ASP.NET 自动解决该问题。呈现 TextBox 的控件需要与 ASP.NET 进行交互,以确保可以正确处理回发值并正常引发事件。以下代码表明了如何扩展 TextBoxLabel 控件以使其完全支持回发。
bool LoadPostData(string postDataKey, NameValueCollection postCollection) { string currentText = Text; string postedText = postCollection[postDataKey]; if (!currentText.Equals(postedText, StringComparison.Ordinal)) { Text = postedText; return true; } return false; } void IPostBackDataHandler.RaisePostDataChangedEvent() { return; }
复合控件的常见方案
复合控件是适合用于构建复杂组件的工具,在复合控件中,多个子控件聚合到一起,并在彼此之间以及与外部之间进行交互。呈现控件则只用于只读式控件聚合,其输出不包括交互元素(例如下拉框或文本框)。
如果您对事件处理和回发数据感兴趣,我强烈建议您选择复合控件。如果使用子控件,则生成复杂的控件树会更加轻松,而且最终结果也更清晰简洁。此外,只有需要提供附加功能时才需要处理回发接口。
呈现控件不但需要实现附加接口,还要将含有属性值的标记静态部分缝合到一起。
复合控件的优点还表现在可以呈现多个同类项,这与在 DataGrid 控件中的情况类似。将每个构成项作为活动对象启用使您可以引发创建事件并以编程方式访问它们的属性。在 ASP.NET 2.0 中,对于要完全实现实际的数据绑定复合控件(上述控件只是随便的举例)所需的样板代码,绝大部分都隐藏在新基类的折叠部分中:CompositeDataBoundControl。
复合控件的呈现引擎
在深入探讨 ASP.NET 2.0 编码技术之前,让我们回顾一下复合控件的内部例行过程。我们提到过,复合控件的呈现是集中围绕 CreateChildControls 方法进行的,该方法从 Control 基类继承而来。您可能会认为,要使服务器控件呈现其内容,替换 Render 方法是必不可少的一步。正如我们先前所看到的,如果 CreateChildControls 被替换,则并不总是需要执行这一步。但是,何时在控件调用栈中调用 CreateChildControls 呢?
如图中所示,在页面第一次显示时,会在预呈现阶段调用 CreateChildControls。
特别是,请求处理代码(在 Page 类中)在将 PreRender 事件引发至页面和每个子控件之前会直接调用 EnsureChildControls。换言之,如果控件树还未完全生成,则不会呈现任何控件。
以下代码段例示了 EnsureChildControls(在 Control 基础上定义的另一种方法)的伪代码。
protected virtual void EnsureChildControls() { if (!ChildControlsCreated) { try { CreateChildControls(); } finally { ChildControlsCreated = true; } } }
此方法可能会在页面和控件的生命周期内反复调用。为避免控件重复,ChildControlsCreated 属性被设为 true。如果此属性返回 true,则该方法会立即退出。
当页面回发时,ChildControlsCreated 会在周期前期调用。如图 4 所示,它在已发布数据处理阶段调用。
当 ASP.NET 页面开始处理从客户端发布的数据时,它会尝试查找一个其 ID 与已发布字段的名称相匹配的服务器控件。在执行此步骤期间,页面代码会调用 Control 类中的 FindControl 方法。反之,该方法需要确保在进行操作之前控件树已完全生成,因此它调用 EnsureChildControls 并按需要生成控件层次结构。
那么要在 CreateChildControls 方法内部执行的代码是怎样的呢?尽管没有正式的指南可供遵循,但通常认为 CreateChildControls 至少必须完成以下任务:清除 Controls 集合,生成控件树,并清除子控件的视图状态。并不严格要求必须从 CreateChildControls 方法内部设置 ChildControlsCreated 属性。实际上,ASP.NET 页面框架始终通过 EnsureChildControls(此方法可自动设置布尔标记)来调用 CreateChildControls。
用于解决设计时问题的 CompositeControl
随 ASP.NET 2.0 一同提供了一个名为 CompositeControl 的基类。因此,新的非数据绑定复合控件应该从该类派生而不是从 WebControl 派生。在开发控件方面,CompositeControl 的用法变动不大。您仍然需要替换 CreateChildControls 并按先前所述方式编码。那么 CompositeControl 的作用是什么?让我们先从其原型着手:
public class CompositeControl :WebControl, INamingContainer, ICompositeControlDesignerAccessor
使用该类就无需再用 INamingContainer 装饰控件,但这实际上并不是很重要,因为接口只是一个标记并且不包含任何方法。更为重要的是,该类实现了一个名为 ICompositeControlDesignerAccessor 的全新接口。
public interface ICompositeControlDesignerAccessor { void RecreateChildControls(); }
此接口由复合控件的标准设计器用于在设计时重建控件树。以下是 CompositeControl 中方法的默认实现过程。
void ICompositeControlDesignerAccessor.RecreateChildControls() { base.ChildControlsCreated = false; EnsureChildControls(); }
简言之,如果您从 CompositeControl 派生复合控件,就不会遇到设计时的故障,而且无需采用技巧和妙计就可以使控件在运行时和设计时都能正常运行。
要充分理解此接口的重要性,可试以寄存某 LabelTextBox 复合控件的示例页为例,并将其转换为设计模式。控件在运行时工作正常,但在设计时却不可见。
如果只是用 CompositeControl 替换 WebControl,则控件在运行时仍然保持正常工作,而在设计时也会运行良好。
生成数据绑定复合控件
大多数复杂的服务器控件都已绑定数据(也可能已经模板化),并且由各种子控件构成。这些控件保留了一个构成项(通常为表的行或单元格)的列表。该列表在经过回发后会保存在视图状态中,并且从绑定数据生成或从视图状态重建。该控件还在视图状态中保存其构成项的数量,以便在页面中其他控件引起回发时可以正确重建表结构。我将用 DataGrid 控件举例说明。
DataGrid 由一列行构成,每一行都代表绑定数据源中的一个记录。每个网格行都通过一个 DataGridRow 对象(从 TableRow 派生的一个类)表示。在各网格行创建完成并被添加到最终网格表时,诸如 ItemCreated 和 ItemDataBound 之类的相应事件将被引发至页面。当通过数据绑定创建 DataGrid 时,其行数由绑定项数和页面大小决定。如果带有 DataGrid 的页面回发会怎样?
这种情况下,如果是由 DataGrid 自身引起的回发(例如,用户单击以进行排序或标页),则新页面会再次通过数据绑定来呈现 DataGrid。这是显而易见的,因为 DataGrid 需要刷新数据进行显示。如果是主页回发,则情况就不同了,因为单击了页面上的另一个控件(例如某按钮)。这种情况下,DataGrid 不绑定到数据并且必须从视图状态进行重建。(如果禁用了视图状态,就是另外一种情况了,这时只能通过数据绑定显示网格。)
数据源不保存在视图状态中。作为复合控件,DataGrid 包含子控件,其中每个子控件都将自己的状态保存到视图状态并从视图状态恢复。DataGrid 只需跟踪在所有行和所包含控件从视图状态恢复之前它所必须重复执行的次数。此次数与所显示绑定项的数量一致,并且必须作为控件状态的一部分存储到视图状态中。在 ASP.NET 1.x 中,您必须自己学习并实现此模式。在 ASP.NET 2.0 中,从新类 CompositeDataBoundControl 派生您的复合控件就可以了。
让我们尝试使用一种显示可扩展数据绑定新闻标题行的网格类控件。在此过程中,我们将再度使用在前文中论及的 Headline 控件。
public class HeadlineListEx :CompositeDataBoundControl { : }
HeadlineListEx 控件包含了一个收集了所有绑定数据项的 Items 集合属性。该集合为公共集合,并且可在与多数列表控件一起运行时通过编程方式填充。对典型数据绑定的支持是通过一对属性(DataTextField 和 DataTitleField)实现的。这两个属性表明了数据源中将用于填充新闻标题和文本的字段。Items 集合被保存到视图状态中。
要将 HeadlineListEx 控件转换为真正的复合控件,您首先需要从 CompositeDataBoundControl 将其派生出来,然后再替换 CreateChildControls。有意思的是,你会注意到 CreateChildControls 是重载方法。
override int CreateChildControls() override int CreateChildControls(IEnumerable data, bool dataBinding)
第一个重载方法替换了在 Control 类中定义的方法。第二个重载方法是每个复合控件都必须替换的一种抽象方法。实际上,复合控件的开发工作简化为两大主要任务:
-
替换 CreateChildControls。
-
实现 Rows 集合属性以跟踪控件的所有构成项。
Rows 属性不同于 Items,因为它不保存在视图状态中,且具有与请求相同的生存期,并引用帮助程序对象而不是绑定数据项。
public virtual HeadlineRowCollection Rows { get { if (_rows == null) _rows = new HeadlineRowCollection(); return _rows; } }
Rows 集合在控件生成时填充。让我们看一下 CreateChildControls 的替换方法。该方法采用了两个参数:绑定项和一个布尔标记,其中布尔标记用于指明该控件是通过数据绑定创建还是通过视图状态创建。(请注意示例程序文件中的程序员注释使用的是英文,本文中将其译为中文是为了便于参考。)
override int CreateChildControls(IEnumerable dataSource, bool dataBinding) { if (dataBinding) { string textField = DataTextField; string titleField = DataTitleField; if (dataSource != null) { foreach (object o in dataSource) { HeadlineItem elem = new HeadlineItem(); elem.Text = DataBinder.GetPropertyValue(o, textField, null); elem.Title = DataBinder.GetPropertyValue(o, titleField, null); Items.Add(elem); } } } // 开始生成控件层次结构 Table t = new Table(); Controls.Add(t); Rows.Clear(); int itemCount = 0; foreach(HeadlineItem item in Items) { HeadlineRowType type = HeadlineRowType.Simple; HeadlineRow row = CreateHeadlineRow(t, type, item, itemCount, dataBinding); _rows.Add(row); itemCount++; } return itemCount; }
在数据绑定的情况下,首先要填充 Items 集合。遍历绑定集合,提取数据,然后填充 HeadlineItem 类的新建实例。接下来,遍历 Items 集合(该集合中可能包含以编程方式添加的附加项),并在控件中创建行。
HeadlineRow CreateHeadlineRow(Table t, HeadlineRowType rowType, HeadlineItem dataItem, int index, bool dataBinding) { // 为最外部表创建新行 HeadlineRow row = new HeadlineRow(rowType); // 为子控件创建单元格 TableCell cell = new TableCell(); row.Cells.Add(cell); Headline item = new Headline(); cell.Controls.Add(item); // 此时引发 HeadlineRowCreated 事件 // 将此行添加到所创建的 HTML 表 t.Rows.Add(row); // 处理数据对象绑定 if (dataBinding) { row.DataItem = dataItem; Headline ctl = (Headline) cell.Controls[0]; ctl.Text = dataItem.Text; ctl.Title = dataItem.Title; // 此时引发 HeadlineRowDataBound 事件 } return row; }
CreateHeadlineRow 方法会创建并返回 HeadlineRow 类(从 TableRow 派生而来)的一个实例。在这种情况下,此行会包含一个由 Headline 控件填充的单元格。在其他情况下,您可以更改此部分代码以根据需要添加多个单元格并相应填充内容。
重要的是,要将所需完成的任务分为两个不同的步骤:创建和数据绑定。首先,创建行的布局,引发行创建事件(如果有),并最后将其添加到父表中。接下来,如果要将控件绑定到数据,则设置对绑定数据敏感的子控件属性。完成操作后,则引发一个行数据绑定事件(如果有)。
请注意,该模式更准确描述了 ASP.NET 自带复合控件的内部体系结构。
可以使用以下代码来引发事件。
HeadlineRowEventArgs e = new HeadlineRowEventArgs(); e.DataItem = dataItem; e.RowIndex = index; e.RowType = rowType; e.Item = row; OnHeadlineRowDataBound(e);
请注意,只在要引发数据绑定事件时才设置 DataItem 属性。事件数据结构被任意设置为以下形式。如果您认为有必要,尽可以对其进行更改。
public class HeadlineRowEventArgs :EventArgs { public HeadlineItem DataItem; public HeadlineRowType RowType; public int RowIndex; public HeadlineRow Item; }
若要实际引发一个事件,通常的做法是使用一个如下定义的受保护方法。
protected virtual void OnHeadlineRowDataBound(HeadlineRowEventArgs e) { if (HeadlineRowDataBound != null) HeadlineRowDataBound(this, e); }
若要声明此事件,可在 ASP.NET 2.0 中使用新的一般事件处理程序委托。
public event EventHandler<HeadlineRowEventArgs> HeadlineRowDataBound;
在示例页中,一切均照常执行。您可在控件标记上定义处理程序并将某方法写入代码文件。示例如下。
<cc1:HeadlineListEx runat="server" ID="HeadlineListEx1" DataTextField="notes" DataTitleField="lastname" DataSourceID="MySource" OnHeadlineRowDataBound="HeadlineRowCreated" />
HeadlineRowCreated 事件处理程序的代码显示如下。
protected void HeadlineRowCreated(object sender, HeadlineRowEventArgs e) { if (e.DataItem.Title.Contains("Doe")) e.Item.BackColor = Color.Red; }
通过挂接数据绑定事件,所有含有 Doe 的项都将以红色背景呈现。
结论
复合控件是通过将其他控件聚合在某一公用 API 顶下创建而成的控件。复合控件将保留自己子控件的活动实例,并且不仅限于呈现这些实例。通过检查页面跟踪输出中的控件树部分,您就可以很容易看到这一点。使用复合控件可以带来几点好处,例如可以简化对事件和回发的处理。在 ASP.NET 1.x 中生成复杂的数据绑定控件有点棘手,需要您深入了解一些实现细节。在引进 CompositeDataBoundControl 基类的情况下,这种复杂性在 ASP.NET 中基本可以迎刃而解。最后,如果在 ASP.NET 2.0 中需要非数据绑定的复合控件,则可以使用 CompositeControl 基类。对于数据绑定复合控件,则可以改为考虑 CompositeDataBoundControl。无论是哪种情况,您都必须提供一个 CreateChildControls 的有效替换方法,这是所有复合控件的核心,用于创建子控件层次结构。
作者简介
Dino Esposito 是 Solid Quality Learning 顾问,并且是 "Programming Microsoft ASP.NET 2.0" (Microsoft Press, 2005)(英文)的作者。Dino 居住在意大利,经常就世界范围的行业事件发表评论。请通过 cutting@microsoft.com 或者加入博客 http://weblogs.asp.net/despos 与其联系。