创建动态数据输入用户界面

Scott Mitchell
4GuysFromRolla.com

摘要:Scott Mitchell 演示一种根据编辑的数据动态生成 ASP.NET 数据输入窗体的方法。

下载 MSDNDynUI.msi 文件

*
本页内容
简介 简介
ASP.NET 中的动态控件入门 ASP.NET 中的动态控件入门
构建动态数据输入用户界面引擎 构建动态数据输入用户界面引擎
小结 小结

简介

当创建数据驱动的 Web 站点时,Web 开发人员遇到的最常见的任务之一就是创建数据输入窗体。数据输入窗体是为系统用户提供数据输入方法的 Web 页。创建特定数据输入窗体的任务通常应该先从需求分析入手,即,明确指出需要从用户收集何种信息。需求确定之后,下一步是设计数据输入 Web 窗体,包括创建图形用户界面以及编写根据用户输入更新数据库的代码。

当数据输入窗体需求事先已知,并且此数据输入窗体对系统中所有用户都一样时,创建这样的输入窗体就毫无挑战性。然而,如果需要动态的数据输入窗体,任务就会变得更艰巨。例如,考虑一个公司的 Internet Web 应用程序,其目的是收集客户购买的产品的信息;一种产品在线注册系统。对于这样的应用程序,向用户提出的问题可能会因购买不同产品而异,或者因从店铺购买还是从公司 Web 站点购买而异。

如上面提到的例子,当遇到需要提供动态数据输入用户界面时,一种选择可能是“强加”一种解决方案。您可以为公司销售的每种产品创建独立的 Web 页,每张页面包含需要的特定数据输入元素。这种原始方法的问题是当发布新产品时,就需要添加新的页面。虽然创建这些新页面可能不会很困难,但是却很耗时,而且如果没有充分的调试和测试时间就很容易出错。

理想情况下,当发布新产品时,应由某个非技术人员通过易用的、基于 Web 的界面来指定需要提出什么问题。这样的系统对 ASP.NET 来说是可以实现的,因为它具备在运行时往 ASP.NET Web 页动态加载控件的能力。只需要在开发和测试时投入少量的初期投资,您就可以创建一个可重复使用的、动态的数据输入用户界面引擎。即使对计算机了解甚少的用户,通过这种技术都可以轻松地创建自定义数据输入窗体。在本文中,我们会介绍使用 ASP.NET 中的动态控件的基础知识,然后介绍一个完整的、可运转的动态数据输入系统,可以轻松地对它进行自定义和扩展。

返回页首返回页首

ASP.NET 中的动态控件入门

众所周知,ASP.NET Web 页由两部分组成:

HTML 部分,它包含静态的 HTML 标记和 Web 控件,通过声明性语法来添加。

代码部分,可以作为独立的类文件实现(如采用 Visual Studio .NET),或者包含在 HTML 文件的 <script runat="server"> 块中。

ASP.NET Web 页的 Web 控件是在设计时通过声明性语法来添加的,它明确指出了要添加的 Web 控件及其初始属性值,如:

<asp:WebControlName 
runat="server" 
prop1="Value1" 
prop2="Value2" 
...
propN="ValueN">
</asp:WebControlName>

要理解的一个重点是,当第一次访问 ASP.NET 页面,或者当其 HTML 部分修改后第一次访问时,ASP.NET 引擎会自动将混合的静态 HTML 内容和 Web 控件语法转换成一个类。这个自动生成的类的作用是创建控件层次结构。这个控件层次结构是组成页面的控件集 — 静态的 HTML 标记转换成 LiteralControl 实例,而 Web 控件转换成相应类类型的实例(例如, 转换成 System.Web.UI.WebControls 命名空间中的 TextBox 类的实例)。

之所以称为控件层次结构是因为它是控件的真正的层次结构。每个 ASP.NET 服务器控件可以有一组子控件和一个父控件。当自动生成的类构造控件层次结构时,它会将代表 ASP.NET 页面的 Page 类实例放在层次结构的顶层。Page 类的子控件是那些在页面的 HTML(通常是一些静态的 HTML 标记以及 Web 窗体的服务器控件)中定义的顶级服务器控件。(ASP.NET 页面的 Web 窗体 — 也就是 <form runat="server">标记 — 是作为 HtmlForm 类的实例实现的,可以在 System.Web.UI.HtmlControls 命名空间中找到这个类。)

和任何其他服务器控件一样,这个 Web 窗体可以包含子控件。Web 窗体的子控件是那些在该 Web 窗体本身中发现的控件。甚至 Web 窗体中的控件本身还可能有子控件:Panel 控件的内容构成了其子控件;当将数据绑定到一个 DataGrid 时,产生的内容构成了它的子控件集。因为顶级 Page 类可能有子控件,子控件又有子控件,子控件又有子控件,等等,这组控件就构成了控件层次结构。

为了帮助彻底理解这个概念(理解它对使用动态控件是至关重要的),请想象您有一个 ASP.NET 页面,它在 HTML 部分有以下内容:

<html>
<body>
  <h1>Welcome to my Homepage!</h1>
  <form runat="server">
    What is your name?
    <asp:TextBox runat="server" ID="txtName"></asp:TextBox>
    <br />What is your gender?
    <asp:DropDownList runat="server" ID="ddlGender">
      <asp:ListItem Select="True" Value="M">Male</asp:ListItem>
      <asp:ListItem Value="F">Female</asp:ListItem>
      <asp:ListItem Value="U">Undecided</asp:ListItem>
    </asp:DropDownList>
    <br />
    <asp:Button runat="server" Text="Submit!"></asp:Button>
  </form>
</body>
</html>

当第一次访问该页面时,会自动生成一个类,这个类包含以编程方式构建控件层次结构的代码。这个示例的控件层次结构如图 1 所示。


图 1. 控件层次结构

以编程方式使用控件层次结构

正如前面提到的,每个 ASP.NET 服务器控件可以包含一组子控件和一个父控件。子控件可通过类型为 ControlCollection 的服务器控件的 Controls 属性访问。ControlCollection 类提供了以下功能:

使用 Count 只读属性来确定有多少子控件。

使用 Add() 或 AddAt() 方法向控件集合添加新项。

通过 Clear() 方法删除所有子控件,或者通过 Remove() 或 RemoveAt() 方法删除特定控件。

要将一个控件作为 X 控件的子控件添加到控件层次结构中,只需创建该控件的相应类实例并添加到 X 控件的 Controls 集合中。例如,要向 Page 类的 Controls 集合添加一个 Label 控件,可以使用下列代码:

'Create a new Label instance
Dim lbl as New Label

'Add the control to the Page's Controls collection
Page.Controls.Add(lbl)

'Set the Label's Text property to the current date/time
lbl.Text = DateTime.Now

在 Page 的 Controls 集合尾部添加控件会使该控件出现在 Web 页的底部。如果您需要的控件比动态添加的控件的位置多,您可以在页面中添加一个 PlaceHolder Web 控件,在层次结构中指定要添加一个或多个动态控件的位置。要在该位置中添加动态控件,只需将它们添加到 PlaceHolder 的 Controls 集合中。例如,如果您想将 Label 放在 Web 窗体中的某个点,您可以按如下方式添加一个 PlaceHolder 控件:

<html>
<body>
   ...
   <form runat="server">
     ...
     <asp:PlaceHolder runat="server" id="dateTimeLabel"></asp:PlaceHolder>
     ...
    </form>
  </body>
</html> 

要在上一个示例中添加动态的 Label,不应该使用 Page.Controls.Add(lbl),而应该使用 dateTimeLabel.Controls.Add(lbl),从而将该 Label 添加到 PlaceHolder 的 Controls 集合中,而不是添加到 Page 的 Controls 集合中。图 2 图示了将动态 Label 添加到 PlaceHolder 的 Controls 集合前后的控件层次结构。


图 2. 图示了添加动态 Label 前后的控件层次结构

通常,最好的方式是使用 Add() 方法将动态控件添加到 Controls 集合的尾部,而不是使用 AddAt() 将其添加到集合中的特定位置。其原因在于,视图状态的保存方式是每个控件记录自己的视图状态及其子控件的视图状态。当保存其子控件的视图状态时,每个控件记录子控件的视图状态及该控件在 Controls 集合中的序号索引。

在回发过程中,当重新加载视图状态时,将反向执行这一过程,同时每个控件加载其子控件的视图状态。重新加载视图状态的控件通过视图状态信息枚举,在 Controls 集合的指定位置应用该控件的视图状态。如果您在视图状态加载之前在 Controls 集合的非尾部位置插入一个控件,则会出现问题,因为每个子控件的视图状态信息是与 Controls 集合中的特定索引相连的。

要查看在非尾部位置添加动态控件为何会导致重新加载视图状态的问题,请参考图 3。图 3 显示了一个服务器控件 p,它具有三个子控件:c0c1c2,其中控件 c1 有一些视图状态在回发过程中保持不变。如果在回发过程中向 p 的 Controls 集合前端添加一个动态控件 c,则当重新加载视图状态时,p 会试图重新加载索引 1 中的 c1 的视图状态,而它现在已被 c0 所占用。


图 3. 具有三个子控件的服务器控件 p

当删除控件时,也同样会出现与视图状态相关的问题。当然,这一切都取决于在页面生命周期的什么时候添加或删除控件。有关视图状态、页面生命周期,以及添加和删除动态控件与视图状态的相关问题等的更详细讨论,请务必阅读我以前的文章 Understanding ASP.NET View State

访问动态添加的控件

当向 ASP.NET 页面添加静态 Web 控件时,Visual Studio .NET 会自动在代码隐藏类中添加对 Web 控件的引用。这些对 Web 控件的引用允许对控件、其属性及方法进行强类型访问。当处理动态添加的控件时,可以使用两种技术来访问控件的属性、方法和事件。

一种方法是通过对控件层次结构进行彻底的检查,从而发现动态控件。例如,以下代码演示了如何递归循环访问以指定控件为根的控件层次结构。例如,如果已将大量 DropDownList 控件动态添加到指定的 PlaceHolder 中,则这样的代码就十分有用。在这种情况下,您可以通过调用 RecurseThroughControlHierarchy(PlaceHolderControl) 来枚举 PlaceHolder 的控件子代,在“Do whatever it is you need to do with the current control, c 的类型是否是 DropDownList,如果是,就采取某种操作。

Private Sub RecurseThroughControlHierarchy(ByVal c as Control)
  'Do whatever it is you need to do with the current control, c

  'Recurse through c's children controls
  For Each child as Control in c.Controls
    RecurseThroughControlHierarchy(child)
  Next
End Sub

如果您有大量相似的服务器控件需要共同处理,则上述方法行得通。但在很多情况下,您可能有大量不同的控件,需要在不同时间分别访问并对每个控件执行不同的操作。要以编程方式处理特定的动态添加的控件,您可以使用 FindControl(ID) 方法,根据控件的 ID 搜索控件。FindControl() 方法是在 System.Web.UI.Control 类中定义的,所以所有的 服务器控件,从 TextBox 到 PlaceHolder,再到 Web 窗体,都有这个方法。

调用一个控件的 FindControl() 方法并不需要搜索该控件的所有子代控件。FindControl() 只搜索当前的命名容器 (naming container)。实现 INamingContainer 的控件行为上就像一个命名容器,意味着它们在控件层次结构中创建自己的 ID 命名空间。例如,DataGrid 控件是一个命名容器。给定一个 ID 为 myDataGrid 的 DataGrid,其子控件的 ID 以父控件的 ID 为前缀,如 myDataGrid:childID。重要的是认识到 FindControl() 只枚举子控件集或命名容器中的控件,而非控件层次结构中父控件的所有子代。(另外,要使搜索范围超越命名容器中的第一级控件,您需要使用作用域恰当的 ID。)其要点是,当使用 FindControl() 来查寻动态添加的控件时,要从该动态控件的父控件(通常是 PlaceHolder 控件)调用 FindControl()。

当使用 FindControl() 方法时,可以使用如下代码来分配一个唯一的 ID 给动态添加的控件,然后引用上述控件。

'When adding the control, set the ID property
Dim tb As New TextBox
PlaceHolderID.Controls.Add(tb)
tb.ID = "dynTextBox"

'At some later point in the page lifecycle, 
'reference the dynamic TextBox
Dim dTB As TextBox
dTB = CType(PlaceHolderID.FindControl("dynTextBox"), TextBox)

由于 FindControl() 方法使用控件的 ID 来定位控件,所以当使用这种技术来访问动态添加的控件时,为每个动态添加的控件的 ID 属性分配一个唯一可识别的值是很重要的。根据情况的不同,可以使用不同的方法。我们在本文后面也将看到,当检查动态数据输入用户界面引擎时,每个动态问题都由数据库中的一行表示,它包含一个唯一的主键字段。这个主键字段值即在 ASP.NET 页面中作为每个动态添加的控件的 ID 使用。如果您不需要区分动态添加的控件,则可以使用另一种技术,该技术向这些控件提供递增的编号作为 ID,如 myDynCtrl1 用于第一个动态添加的控件,myDynCtrl2 用于第二个,等等。

页面生命周期和动态控件

任何时候访问一个 ASP.NET Web 页(不管是初始页面访问还是回发),每次 ASP.NET 引擎自动生成的类都会从头开始重新构建控件层次结构。不仅重新构造控件层次结构,而且将控件的事件重新连接到其指定事件处理程序。因此,当向 ASP.NET 页面添加动态控件时,确保在每次 页面访问添加这些控件是很重要的。许多开发人员在开始添加动态控件时都使用以下模式来实现:

'In the Page_Load event handler...
If Not Page.IsPostBack Then
  'Add dynamic controls...
End If

这段代码的问题是它只在第一次页面访问时添加动态控件,而在后续回发时则没有添加。如果您尝试使用这段代码,您会发现,只要发生回发,您的动态控件就会从页面中消失。因此,您必须确保在所有页面访问中添加所有动态控件,方法是将这段代码移到 If Not Page.IsPostBack 条件语句外面。

添加动态控件引出的一个重要问题是此类控件应在页面生命周期的什么时候添加。正如我在 Understanding ASP.NET View State 中讨论的,只要一个请求到达,ASP.NET 页面就要经历许多步骤。让我们花点时间概述一下页面生命周期内几个紧密相连的阶段。为了能够更深入理解,要确保先阅读一下关于视图状态的文章,重点关注那篇文章中的 The ASP.NET Page Lifecycle 部分。

ASP.NET 页面生命周期回顾

页面生命周期中的第一个阶段是实例化,在这个阶段中,自动生成的类会根据页面的 HTML 部分中定义的静态控件构建控件层次结构。构造控件层次结构时,声明性语法中指定的值会赋给添加的每个控件的属性。实例化之后是初始化阶段,在这个阶段,静态控件层次结构已经构造,但还没重新加载视图状态(假定页面请求是回发)。如果页面请求是回发,则在初始化之后是加载视图状态阶段。在这个阶段中,页面会过滤出在隐藏的 VIEWSTATE 窗体字段中发现的视图状态数据,如果需要,控件层次结构中的每个控件会更新自己的状态。

如果页面请求是回发,则在加载视图状态阶段之后是加载回发数据阶段。这个阶段会检查发送的窗体字段值,并据此更新相应控件的属性。例如,通过 POST 机制(发出信号表示 TextBox 控件的名称和用户输入的值),来回送用户在 TextBox Web 控件中输入的文本。页面获得这些值,在控件层次结构中定位恰当的 TextBox,并将接收的值赋给它的 Text 属性。

下一个阶段是加载阶段,发生在 Page_Load 事件处理程序激发时。加载阶段之后还有更多阶段,如引发回发事件、保存视图状态和呈现 Web 页,但这些与动态控件的主题无关,因此不加以讨论。图 4 图示了页面在生命周期内所经历的事件。


图 4. 页面生命周期

确定在页面生命周期的什么时候添加动态控件

关于在页面生命周期的什么时候添加动态控件的问题可以归纳如下:动态控件需要在加载视图状态和重新加载回发数据之前添加,因为我们想要正确添加特定于动态控件的任何视图状态或回发值。考虑到这些限制,添加动态控件的正常时间是在初始化阶段,因为它发生在加载视图状态阶段和加载回发数据阶段之前。

然而,在初始化阶段,视图状态和回发数据都还没还原,因此不建议访问或设置可能存储在视图状态或被回发值修改的控件属性(不管是动态还是静态控件),因为这些值将被生命周期后续阶段的视图状态和回发值所覆盖。当处理动态控件时我使用了以下模式:

在初始化阶段,我向控件层次结构添加动态控件并设置 ID 属性

在加载阶段,我在 If Not Page.IsPostback 条件语句中为动态控件赋予任何需要的初始值。

我需要在每次回发时添加动态控件,但只在第一次页面加载时设置属性值,因为这些值会保留在视图状态中。以下代码片段说明了这种模式:

'In the Init event of the Page, add a dynamic TextBox
Dim tb as New TextBox
PlaceHolderID.Controls.Add(tb)
tb.ID = "dynTextBox"

'In the Page_Load event handler, set the properties 
'of the TextBox
If Not Page.IsPostBack Then
  Dim dTB As TextBox
  dTB = CType(PlaceHolderID.FindControl("dynTextBox"), TextBox)
  dTB.Text = "Some initial value"
  dTB.BackColor = Color.Red  'initial BackColor
End If

除了在初始化阶段加载动态控件外,您还可以在加载阶段添加,这样不会有什么负面影响。当将控件添加到另一个控件的 Controls 集合时,所添加的控件会立即在其新父控件的生命周期内被确立。例如,如果父控件处于初始化阶段,则会引发所添加控件的 Init 事件,使该控件与其父控件保持同步。如果父控件处于加载阶段或以后的阶段,则所添加的子控件会立即经历初始化阶段、加载视图状态阶段、加载回发数据阶段和加载阶段。

当在加载阶段添加控件时,有一个警告需要注意。当一个控件完成其加载视图状态阶段后,它就开始跟踪对其视图状态的更改。这意味着加载视图状态阶段之后 的任何属性更改都会自动保留在控件的视图状态中。在一个控件开始跟踪其视图状态的更改之前,属性值更改不会保留在视图状态中。如果您在初始化阶段添加控件然后在加载阶段设置其属性,则不会有问题,因为在初始化阶段和加载阶段之间已经发生了加载视图状态阶段,控件的跟踪视图状态更改标志已设置。也就是说,如果在初始化阶段添加动态控件,则从运行加载阶段起,动态控件的属性赋值会保留在视图状态中。

页面开发人员无法修改“跟踪视图状态更改标志”。System.Web.UI.Control(所有 ASP.NET 服务器控件都由此派生)只提供对该标志的受保护访问。具体来说,有一个名为 IsTrackingViewState 的受保护只读属性来指示是否在跟踪视图状态,还有一个受保护的 TrackViewState() 方法来指示应该开始跟踪视图状态。所有控件在初始化阶段结束时都会自动调用这个方法。

然而,如果您直到加载阶段才添加动态控件,则该动态控件的任何属性只有在将该控件添加到控件层次结构之后 才能设置,这一点很重要。为了帮助理解其中原因,请考虑如果在加载阶段执行以下代码会发生什么:

Dim tb as New TextBox

If Not Page.IsPostBack Then
  tb.BackColor = Color.Red  'initial BackColor
End If

PlaceHolderID.Controls.Add(tb)

正如您所看到的,在每次页面加载时都会创建一个 TextBox。只有在第一次页面加载时才会将 TextBox 的 BackColor 属性设置为 Red,而在以后的每次页面加载都会将该控件添加到控件层次结构中。虽然在第一次页面加载时 TextBox 的背景颜色确实为 Red,但问题是在回发时 TextBox 的背景颜色会还原为默认值(没有背景颜色)。这是因为 TextBox 的 BackColor 属性赋值没有保留到视图状态中,所以在回发时丢失。丢失的原因是 TextBox 与其他任何服务器控件一样,只有在加载视图状态阶段之后才开始跟踪视图状态。但是 TextBox 只有在被添加到控件层次结构之后才会经历这个阶段,所以 BackColor 赋值没有保留到视图状态中。若要更正这个问题,请确保将控件添加到控件层次结构中,使其提前经历加载视图状态阶段,然后再对其属性赋值,如下所示:

Dim tb as TextBox
PlaceHolderID.Controls.Add(tb)

If Not Page.IsPostBack Then
  tb.BackColor = Color.Red  'initial BackColor
End If

如果您在初始化阶段添加动态控件,则与上述细节无关。有关这个问题的更深入讨论,请参考 my blog 条目 Control Building and View State Lesson for the Day

事件和动态控件

与静态服务器控件一样,动态添加的控件也可以将事件与事件处理程序相关联。正如每次页面访问都必须将控件添加到控件层次结构中,每次页面访问也都需要将动态控件的事件与指定事件处理程序连接起来。这样做的部分挑战是您需要在类中定义适当的事件处理程序。如果您的控件确实是动态的,则如何知道代码隐藏类需要什么样的事件处理程序呢?依我的经验,我发现处理事件和动态控件的最好办法是使用用户控件,而不要使用单独的 Web 控件。对于用户控件,我可以在用户控件的代码部分嵌入特定事件处理程序和程序设计逻辑。我们将在下一节介绍如何动态添加用户控件。

如果您必须将动态添加的 Web 控件的事件与事件处理程序相关联,请确保在每次页面访问时都进行关联。以下代码(包含在本文下载中)演示了如何将一个动态添加的 Button Web 控件的 Click 事件与一个现有的事件处理程序相关联。(ph 是页面中 PlaceHolder 控件的名称。有关用 C# 将事件与事件处理程序连接起来的示例,以及在 .NET Framework 中进行事件处理的更详细信息,请参阅 Peter Bromberg 的文章 Delegates to the Event。)

Private Sub Page_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load
   Dim b As New Button
   ph.Controls.Add(b)

   If Not Page.IsPostBack Then
      b.Text = "Click Me"
   End If

   AddHandler b.Click, New EventHandler(AddressOf Me.ButtonClickEventHandler)
End Sub

Private Sub ButtonClickEventHandler(ByVal sender As Object, ByVal e As EventArgs)
   Response.Write("The button has been clicked!")
End Sub
返回页首返回页首

构建动态数据输入用户界面引擎

在过去几年里,我参与过的大量项目都需要动态数据输入用户界面,即依赖于一个或多个受用户影响因素的用户界面。所有这些项目的一个基本要求是需要这些动态界面能够由非电脑通的用户轻松地创建、更新和删除。经过这些项目的锻炼,我开发了一个动态数据输入用户界面引擎,它允许开发人员创建用户界面生成块,然后由非开发人员将它们拼凑在一起以形成特定于特殊用户的用户界面。

在本文的剩下部分,我将循序渐进地介绍这个引擎的简化版本。特别是,本文中的演示说明了如何根据客户的类型产生针对客户的独特数据输入用户界面。例如,显示给普通客户的用户界面与在线客户 (online-only) 或批量购买的客户不同。

动态数据输入用户界面引擎的基本组成是:

用户界面生成块:用户界面生成块是用户控件,由小组中的开发人员负责创建。这些生成块的设计只特定于它们收集的信息的类型,而不特定于所请求的数据。例如,本演示中包含的一个 UI 生成块是一个提示用户输入整数值的生成块。该用户控件包含一个 TextBox 和一个 CompareValidator,用于确保用户输入的是有效的整数值。只要将生成块与一个问题(如“您多大岁数了?”或“您家到办公室有多少公里?”)相关联,该生成块就可以组合进一个动态数据输入用户界面。

问题:问题是自定义生成块,是由非电脑通用户通过基于 Web 的界面创建的。一个问题将一些文本和一个 UI 生成块相关联。

区分变量:每个动态数据输入用户界面以一个或多个变量为基础。例如,对于在线产品注册 Web 站点,用户界面可能与购买何种产品有关。对于雇员信息的数据输入,UI 可能因雇员部门而异。对于本文所提出的引擎,区分变量被硬编码为客户类型。

动态问题:对于给定的区分变量,指定了一组问题。问题和区分变量的组合映射形成了系统的动态问题。

动态答案:当给定客户的动态数据输入窗体完成时,该客户的信息必须保存到数据库中。给定客户的答案集就是系统中的动态答案。

在 ASP.NET 应用程序中,动态数据输入用户界面引擎的用户界面生成块部分是作为用户控件实现的。其余部分则是作为数据库实体来实现的。图 5 显示了引擎的实体关系图,描述了各个部分在数据库中如何表示。


图 5. 实体关系图

当查看图 5 时,首先注意到 dq_Questions 表。表中的记录表示系统中的问题。每个问题都有一些与问题相关的文本 (QuestionText) 和一个用户控件 (ControlSrc)。ControlSrc 字段包含了用户控件的文件名,如 DQIntegerInput.ascx。其次,在左下角有一个 dq_Customers 表。每个客户都有一个特定的客户类型,dq_CustomerTypes 表中明确指出了所有这些类型。

动态问题(问题和客户类型之间的映射集)通过 dq_DynamicQuestions 表来实现。其中,一个问题与一种客户类型和一种排序顺序相关联,后者指示对于特定客户类型提出问题的顺序。最后,动态答案存储在 dq_DynamicAnswers 表中,它将每个动态问题与一个特定客户相关联。因为我们不能确定给定问题的答案类型(它可能是字符串、布尔值、整数等),所以 dq_DynamicAnswers 表有六列,每列对应系统允许的一种数据类型。给定的问题可以只有一种类型,对于其答案,相应的字段有答案的值,而其他列则为 NULL 值。

数据模型方面有几点亟需注意。我决定在 dq_DynamicQuestions 表中使用一个综合主键 (DynamicQuestionID) 而不是将 CustomerTypeID 和 QuestionID 作为组合主键使得特定的客户类型允许有重复的问题。例如,一个问题可能是“其他意见”并使用包含多行 TextBox 的用户界面生成块。因为您可能需要在其他许多问题之后有“其他意见”问题,所以我决定允许有重复的问题。dq_Questions 表有着最简单的格式。在过去的项目中我来回使用以下两种做法:保留非常简单的一个表并将详细信息嵌入到用户界面生成块中,或者将附加字段添加到与 UI 交互的这个表中。例如,一个应用程序可能需要能够指示一些问题是必选的,而其他问题是可选的。在这样的系统中有两种方式可以解决这个问题。第一种是将责任交给 UI 生成块。也就是说,不是创建单一的 UI 生成块(比如整数输入),而是创建两个 — 一个使用 RequiredFieldValidator 来确保输入了一个值,另一个则不强加此类条件。当组织这样的问题时,管理员可以根据问题是否为必选来选择使用哪个 UI 生成块。一种替代办法是将 Required 字段添加到 dq_Questions 表中,并只使用一个 UI 生成块。当采用第二种方法时,每个 UI 生成块都需要一个 Required 属性,并负责根据这个属性值启用或禁用适当的验证控件。

最后是 dq_DynamicAnswers 表,它有六个与答案相关的字段,并仅允许来自一个 UI 生成块的标量答案。也就是说,一个 UI 生成块的答案可以是字符串、整数、双精度型、日期、货币或布尔值。但如果我们需要 UI 生成块有更复杂的答案,如本身可能含有几个字段的地址,则怎么办呢?对于这样复杂的答案,当返回答案时需要由 UI 生成块将其序列化为一个可接受的类型。当显示答案时,需要对这类结果进行相应的反序列化。要实现这一点您可以依赖 .NET 固有的二进制序列化能力,但要这样做您可能需要在这个表中添加一个类型为 binary 的 BinaryAnswer 字段。

用户界面生成块的设计规则

为了方便生成具有开发人员设计的用户控件的真正动态数据输入用户界面,作为 UI 生成块使用的用户控件能够提供一个基本级别的功能是很重要的。IUIBuildingBlock 接口明确指出了这种基本级别的功能。这个接口定义了三个属性,所有 UI 生成块都必须实现:

DataType:一个只读属性,返回 UI 生成块所提供的答案的数据类型。必须是来自 DQDataTypes 枚举的一个值。

QuestionText:要在 UI 生成块中显示的问题文本。

答案:该 UI 生成块的答案。

为了阐述如何使用这些属性,我们来看一个简单的 UI 生成块。假设我们想要创建一个 UI 生成块,它提示用户输入一个整数。我们可以这样实现:创建一个新的用户控件,它在 HTML 部分包含以下内容:

<asp:Label id="dqQuestion" runat="server" 
CssClass="DQQuestionText"></asp:Label>:
<asp:TextBox id="dqAnswer" runat="server" CssClass="DQAnswer" Columns="4"></asp:TextBox> 
<asp:CompareValidator id="CompareValidator1" runat="server" CssClass="DQErrorMessage" 
ErrorMessage="You must enter a number here."
   ControlToValidate="dqAnswer" Type="Integer" Operator="DataTypeCheck"></asp:CompareValidator>

这个标记包括:

一个 Label Web 控件 (dqQuestion),显示 UI 生成块的 QuestionText 属性;

TextBox (dgAnswer),用户在其中输入整数值

CompareValidator,确保输入的确实是一个整数。

该用户控件的源代码部分相当简单。它让用户控件的类实现 IUIBuildingBlock 接口,并为三个必需的属性提供逻辑:

Public Class DQIntegerQuestion
    Inherits System.Web.UI.UserControl
    Implements IUIBuildingBlock

    ...

    Public ReadOnly Property DataType() As DQDataTypes Implements IUIBuildingBlock.DataType
        Get
            Return DQDataTypes.Integer
        End Get
    End Property

    Public Property Answer() As Object Implements IUIBuildingBlock.Answer
        Get
            If dqAnswer.Text.Trim() = String.Empty Then
                Return DBNull.Value
            Else
                Return dqAnswer.Text
            End If
        End Get
        Set(ByVal Value As Object)
            dqAnswer.Text = Value
        End Set
    End Property

    Public Property QuestionText() As String Implements IUIBuildingBlock.QuestionText
        Get
            Return dqQuestion.Text
        End Get
        Set(ByVal Value As String)
            dqQuestion.Text = Value
        End Set
    End Property
End Class

DataType 只读属性返回用户控件所返回的数据类型 — 整数。QuestionText 属性只从 dqQuestion Label 控件的 Text 属性读取或向其写入,而 Answer 属性则从 dgAnswer TextBox 的 Text 属性读取或向其写入。所有这些都包含在 UI 生成块中。对于简单的 UI 生成块(就像这一个),代码和 HTML 标记很少,但不要让如此简单的示例掩饰了 UI 生成块的真正强大之处。因为用户控件可以有多个包含事件处理程序等的 Web 控件,所以您可以构建丰富的 UI 生成块。包含在本文代码下载中的一个 UI 生成块阐述了如何在一个 UI 生成块中拥有两个依赖 DropDownList。

当创建 UI 生成块时,确保将它们都放在同一个目录中。不过具体放在哪个目录并无关系。在 Web.config 文件中,您可以找到一个键名为 buildingBlockPath 的 元素。这个设置需要提供对用户控件目录的引用。在代码下载中,其默认路径是 ~/UserControls/,但您可以随意进行更改(如果喜欢的话)。

有关使用带动态加载用户控件的界面的好处的详细信息,请务必阅读 Tim Stall 的文章 Understanding Interfaces and their Usefulness

生成问题并将它们与客户类型相关联

为了使创建动态数据输入用户界面成为非开发人员也能轻松执行的任务,我创建了一个基于 Web 的管理界面,可以用它来生成问题并将它们与客户类型相关联。该界面可以在本文的代码下载中获得。

管理界面中有两个紧密相关的页面。第一个是 CreateQuestion.aspx,它允许管理员生成新的问题。回顾一下,一个问题就是特定的问题文本加上 UI 生成块。该 Web 页非常简单,它提供了让用户输入问题文本和从 UI 生成块目录(其路径在 Web.config 文件中指定)中选择一个用户控件的方法。图 6 显示了该页面的一个快照。


图 6. 为非开发人员设计的基于 Web 的界面

管理界面中的下一屏允许管理员指定将什么问题以及按照什么顺序与每个客户类型关联起来。该界面(如图 7 所示)一看就明白。管理员从最顶部的 DropDownList 选择一个客户类型,然后就可以从第二个 DropDownListBox 添加问题。DataGrid 列出了选定的客户类型的当前问题,它允许用户从列表中删除问题或者通过向上和向下箭头对它们进行重排序。


图 7. 用于选择问题顺序的 Web UI

显示动态问题和保存结果

一旦系统管理员已生成问题并将它们映射到特定客户类型后,就可以输入客户的数据。EnterData.aspx 页面通过查询字符串获取客户的 ID,并构建对应于该客户的客户类型的动态数据输入用户界面。这个页面有三个需要关注的方法:

BuildDynamicUI():这个方法是在 Page_Init 事件处理程序(它在页面生命周期中的初始化阶段执行)中调用的,它构建对应于适当客户类型的动态控件。正如前面讨论的,BuildDynamicUI() 只是将必要的控件添加到控件层次结构中。

Page_Load:该 Page_Load 事件处理程序为动态添加的 Web 控件赋予初始的默认值。例如,如果用户已经为特定客户提供一些值,则当访问页面时,这些值就会填充到适当的动态控件中。这些属性只在第一次页面访问时设置,以后的回发将不再设置。

btnSaveValues_Click:该方法与 Save 按钮的 Click 事件相连接。它枚举动态添加的控件并更新数据库。

让我们简要看一下这三个方法。BuildDynamicUI() 方法是在 Page_Init 事件处理程序中调用的。(该事件处理程序由 Visual Studio .NET 在“Web Form Designer Generated Code”区域自动添加。)该方法通过查询字符串捕捉客户 ID,然后用对应于指定客户类型的动态问题填充一个 SqlDataReader。然后会循环访问此 SqlDataReader。对于每条记录,指定的用户控件会加载并添加到 dynamicControls PlaceHolder。为每个动态控件提供一个 dqDynamicQuestionID 形式的 ID。

Private Sub BuildDynamicUI()    'Called from Page_Init
    CustomerID = Convert.ToInt32(Request.QueryString("ID"))

    ...

    'Get the list of dynamic controls for the specified customer
    reader = SqlHelper.ExecuteReader(connectionString, _
                 CommandType.StoredProcedure, _
                 "dq_GetDynamicQuestionsForCustomerType", _
                 New SqlParameter("@CustomerTypeID", CustomerTypeID))

    'For each question, add the necessary user control
    While reader.Read
        Dim dq As UserControl = _
             LoadControl(ResolveUrl(buildingBlockPath & _
                                     reader("ControlSrc")))
        CType(dq, IUIBuildingBlock).QuestionText = reader("QuestionText")
        dq.ID = String.Concat("dq", reader("DynamicQuestionID"))

        dynamicControls.Controls.Add(dq)
        dynamicControls.Controls.Add(New LiteralControl(""))
    End While

    reader.Close()
End Sub

在本文所包括的示例代码中,我使用 Microsoft Data Access Application Block (DAAB) 2.0 版来访问数据库。该 DAAB 的 SqlHelper 类提供了一个包装,可以用于通过一行代码从 Microsoft SQL Server 数据库访问数据。有关 DAAB 的更多信息,请务必访问 Data Access Application Block for .NET 官方页面,以及阅读 John Jakovich 的文章 Examining the Data Access Application Block

另外,如代码所示,要动态加载一个用户控件,您需要使用 LoadControl(UserControlPath) 方法而不是创建用户控件类的新实例。有关其中原因的详尽讨论以及对用户控件的深入介绍,请务必阅读 An Extensive Examination of User Controls

接下来,在 Page_Load 事件处理程序中,从数据库检索针对动态控件的客户当前答案并进行循环访问。引用相应的动态控件,并将其 Answer 属性设置为从数据库获得的答案。这只在第一次页面访问时执行,在以后回发时不执行,因为我们不想覆盖用户在其中一个窗体字段中输入的值。

'Get the answers for this customer
'Get the list of dynamic controls for the specified customer
Dim reader As SqlDataReader = _
       SqlHelper.ExecuteReader(connectionString, _
               CommandType.StoredProcedure, _
               "dq_GetDynamicAnswersForCustomer", _
               New SqlParameter("@CustomerID", CustomerID))

While reader.Read
    Dim dq As IUIBuildingBlock = dynamicControls.FindControl(String.Concat("dq", reader("DynamicQuestionID")))
    If Not dq Is Nothing Then
        Select Case dq.DataType
            Case DQDataTypes.String
                dq.Answer = reader("StringAnswer").ToString()
            Case DQDataTypes.Integer
                dq.Answer = Convert.ToInt32(reader("IntegerAnswer"))
            Case DQDataTypes.Double
                dq.Answer = Convert.ToSingle(reader("DoubleAnswer"))
            Case DQDataTypes.Date
                dq.Answer = Convert.ToDateTime(reader("DateAnswer"))
            Case DQDataTypes.Currency
                dq.Answer = Convert.ToDecimal(reader("CurrencyAnswer"))
            Case DQDataTypes.Boolean
                dq.Answer = Convert.ToBoolean(reader("BooleanAnswer"))
        End Select
    End If
End While

最后,当用户单击 Save 按钮时,会枚举 dynamicControls PlaceHolder 的 Controls 集合,对于已经做出回答的每个动态添加的控件,答案会写回到数据库中。

'Create the needed parameters
Dim stringParam As New SqlParameter("@StringAnswer", SqlDbType.NText)
Dim integerParam As New SqlParameter("@IntegerAnswer", SqlDbType.Int)
Dim doubleParam As New SqlParameter("@DoubleAnswer", SqlDbType.Decimal)
Dim dateParam As New SqlParameter("@DateAnswer", SqlDbType.DateTime)
Dim currencyParam As New SqlParameter("@CurrencyAnswer", SqlDbType.Money)
Dim booleanParam As New SqlParameter("@BooleanAnswer", SqlDbType.Bit)

'Enumerate each answer and save it back to the database
For Each c As Control In dynamicControls.Controls
    If TypeOf c Is IUIBuildingBlock Then
        'Mark all of the parameters as NULL
        stringParam.Value = DBNull.Value : integerParam.Value = DBNull.Value
        doubleParam.Value = DBNull.Value : dateParam.Value = DBNull.Value
        currencyParam.Value = DBNull.Value : booleanParam.Value = DBNull.Value

        'Determine which parameter needs to be set
        Dim uib as IUIBuildingBlock = CType(c, IUIBuildingBlock)
        Select Case uib.DataType
            Case DQDataTypes.String
                stringParam.Value = uib.Answer
            Case DQDataTypes.Integer
                integerParam.Value = uib.Answer
            Case DQDataTypes.Double
                doubleParam.Value = uib.Answer
            Case DQDataTypes.Date
                dateParam.Value = uib.Answer
            Case DQDataTypes.Currency
                currencyParam.Value = uib.Answer
            Case DQDataTypes.Boolean
                booleanParam.Value = uib.Answer
        End Select

        Dim dynamicQuestionID As Integer = Convert.ToInt32(c.ID.Substring(2))
        SqlHelper.ExecuteReader(connectionString, _
               CommandType.StoredProcedure, "dq_AddDynamicAnswer", _
               New SqlParameter("@CustomerID", CustomerID), _
               New SqlParameter("@DynamicQuestionID", dynamicQuestionID), _
               stringParam, integerParam, doubleParam, _
               dateParam, currencyParam, booleanParam)
    End If
Next

结束语

该动态数据输入用户界面引擎是为您的 Web 应用程序开发此类系统的一个好起点,但它不是为现有系统提供无缝集成而设计的。它的设计只是作为演示系统,而不是作为完整的工作系统。该系统的一个部分还不完善,那就是管理界面,虽然也实现了一定功能,但与完整的系统相比还差得很远。具体而言,在如何处理从特定客户类型删除动态问题方面还存在问题。例如,假设管理员对系统进行配置,使得可以对在线客户提出一个是非问题,“这是您在本公司购买的第一件产品吗?”

现在,假设有大量客户回答了这个问题。如果管理员决定从在线用户的问题集中删除这个问题,则将会发生什么呢?应该将相应的答案从 dq_DynamicAnswers 表中删除吗?应该保存它们以便提供对以前答案的历史视图吗?对于您的应用程序,您需要对这个问题做出回答。现在,当您删除一种客户类型的一个动态问题时,管理界面并没有做什么事情,这意味着如果有一个或多个客户回答了这个问题,就会产生异常而且不会删除问题,因为这样做与在数据库中建立的引用完整性相冲突。

返回页首返回页首

小结

在本文中,我们介绍了如何利用 ASP.NET 中的动态控件创建动态数据输入用户界面。正如本文前半部分所讨论的,ASP.NET 页面由一个控件层次结构组成,后者通常严格地由静态定义的控件组成。然而,我们可以在运行时操作该控件层次结构,方法是在该层次结构中的现有控件的 Controls 集合中添加动态控件。我们还了解了访问动态添加的控件的技术以及添加这些控件并与之进行交互的通用模式。

本文的后半部分介绍创建和使用动态数据输入用户界面的特定实现。所介绍的引擎允许非技术人员的用户根据用户界面生成块轻松地生成问题,该用户界面生成块是由开发人员创建的 ASP.NET 用户控件。针对这些问题,那些非技术人员的管理用户可以将一组问题与特定的客户类型相关联。一个单一的 Web 页,EnterData.aspx 根据访问页面的客户显示和保存适当的数据输入窗体字段和值。

该工具是一个强大实用的工具,它可以在运行时操作 ASP.NET 页面的控件层次结构,使得应用程序可以适应许多常见场景。通过阅读本文,您应该能够自信地在您的 ASP.NET 页面中使用动态控件。

尽情享受编程的乐趣吧!

特别感谢……

在将本文提交给我的 MSDN 编辑之前,有许多志愿者帮助我校对本文并为本文的内容、语法和目的提供反馈。本文审阅过程中的主要贡献者包括 Milan NegovanMarko Rangel、Hilton Giesenow、Carlos SantosDave Donaldson 和 Carl Lambrecht。如果您有兴趣加入到不断壮大的审阅者队伍中,请通过 mitchell@4guysfromrolla.com 给我发邮件。

Scott Mitchell 著有六本书,是 4GuysFromRolla.com 的创始人,也是一个各方面都很优秀的人才。他自 1998 年起就开始从事 Microsoft Web 技术。Scott 是一位独立顾问、培训师和作家。您可以通过 mitchell@4guysfromrolla.com 或他的网络日记 http://ScottOnWriting.NET 来与他取得联系。

posted on 2006-02-19 13:59  皮皮虾的blog  阅读(497)  评论(0编辑  收藏  举报