自定义服务器控件(控件状态和事件)
ASP.NET 使用 Web 控件在 HTTP 和 HTML 的底层细节之上创建了一个面向对象的抽象层。这个抽象的两个基础是视图状态(多次请求之间保存信息的机制)和回传(使页面把表单数据集合回传到相同 URL 的技术)。
视图状态
控件需要保存状态信息,就像网页所做的那样。所有的控件都提供了 ViewState 属性来让你存取信息,就像你对一个网页所做的那样。在一次回传之后,你需要使用 ViewState 集合来恢复私有信息。
一个常用的 Web 控件设计模式是:在属性过程中访问 ViewState 集合。
以我们前面所说的 LinkWebControl 为例,那个控件不使用视图状态,如果通过编程改变了它的 Text 和 HyperLink 属性,这些修改在后续的回传过程中将会丢失。(注意,样式属性又不同,它们会被自动保存在视图状态里)
为了修改这个控件以使用试图状态,下面重写这两个属性:
public string Text
{
get { return (string)ViewState["Text"]; }
set { ViewState["Text"] = value; }
}
public string HyperLink
{
get { return (string)ViewState["HyperLink"]; }
set
{
if (value.IndexOf("http://") == -1)
{
throw new ApplicationException("Specify HTTP as the protocol");
}
else
{
ViewState["HyperLink"] = value;
}
}
}
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
if (ViewState["HyperLink"] == null)
ViewState["HyperLink"] = "http://www.google.com";
if (ViewState["Text"] == null)
ViewState["Text"] = "Click to search";
}
现在,LinkWebControl 控件可以使用 ViewState 视图状态了,通过编程修改的属性在后续的回传之中不会丢失了。
在控件初始化的时候,通过调用 Page.RegisterRequiresViewStateEncryption()方法可以请求页面加密视图状态信息,这在需要保存敏感信息时很有用:
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
Page.RegisterRequiresViewStateEncryption();
......
}
有一点很重要,控件的 ViewState 属性和页面的 ViewState 属性是完全分开的。换言之,这两者的 ViewState 是互不干扰的。
控件里的视图状态很容易使用,但仍需考虑许多问题:
- 不要在视图状态里保存大型对象。例如,支持数据绑定的 ASP.NET 控件不会在 ViewState 中保存 DataSource 属性。这些数据只是存放在内存中,直到调用 DataBind()方法。你不得不在每次回传之后重新绑定数据控件。虽然这样在编程上显得有些笨拙,但却确保了网页不至于变得过分臃肿。
- 它受包容它的页面的限制。如果页面将控件的 EnableViewState 设置为 false,则控件视图状态信息在每次回传之后都将丢失。如果你有确保控件能正常工作的重要信息,应该保存在控件状态信息里(下面会介绍控件状态信息)。
- 你不能假设数据就在 ViewState 中。如果尝试读取一个不存在的项你会得到一个异常。你需要在 Onit()方法中或自定义控件的构造函数中检查该项是否为 null 值或者设置默认的视图状态信息。(LinkWebControl 控件不会得到一个空引用,因为它使用 Onit()设置了初始的视图状态信息)
注解 1
即使 EnableViewState 属性被设置为 false,ViewState 集合仍能正常工作。唯一的区别在于,一旦控件被处理完,并且页面被呈现完,你设置在该集合中的信息就会被丢弃。
注解 2
尽管 WebControl 类提供了 ViewState 属性,但它并未提供 Cache、Session、Application 等属性。但如果需要这些对象来存取数据,你可以通过静态的 HttpContext.Current 属性来访问这些属性。
偶尔,你会需要更大的灵活性来定制怎么存储视图状态信息。你可以通过覆盖 LoadViewState()和 SaveViewState ()来实现。
- SaveViewState()总是在控件被呈现成 HTML 之前被调用,它返回一个单一的可序列化的对象,这个对象被保存在视图状态里。
- LoadViewState()在后续的回传中,控件被创建时被调用。它接收一个参数保存的对象,用这个对象来配置控件的属性。
简单的控件里并不需要覆盖这些方法,然而,有时覆盖这些方法又是很有用的。
当你开发了一个在视图状态中使用单一对象保存多项信息的更紧凑的方式时;当你从一个现存的控件派生新控件,并且想阻止其保存它的状态的时候;当你管理如何保存一个复杂控件的内嵌子控件的状态时;你需要覆盖这两个方法。
控件状态
控件状态用于存储控件正在使用的数据。从技术上讲,控件状态的工作方式和视图状态相同。控件状态保存那些可序列化的信息,这些信息在呈现页面的时候被塞入到一个隐藏字段。
实际上,ASP.NET 把视图状态信息和控件状态信息放在同一个隐藏字段里。
控件状态不受 EnableViewState 属性设置的影响,即使为 false,控件仍能从控件状态里存取信息。
因为控件状态不能被禁用,所以应该认真的限制允许保存在其中的信息量。通常,仅限于关键信息,例如当前页面的索引或者数据的键值。要使用控件状态,必须以覆盖 OnInit()为开端,并调用 Page.RegisterRequiresControlState()来表明你的控件需要访问控件状态。
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
Page.RegisterRequiresControlState(this);
}
与视图状态不同,你不能通过一个集合来直接访问控件状态(这个设置比较恶心,可能是出于不想让开发者滥用控件状态的考虑)。你必须再覆盖两个方法 SaveControlState()和 LoadControlState()。这些方法使用了一种稍微不寻常的模式。基本思想是,你想要得到被基类序列化的任意控件状态,并且将这些来自基类的控件状态与包含你的新的可序列化对象的对象合并,通过 System.Web.Pair 类实现:
string someData;
protected override object SaveControlState()
{
// Get the state from the base class.
object baseState = base.SaveControlState();
// Combine it with the state object you want to store,
// and return final object.
return new Pair(baseState, someData);
}
这个方法只允许你存储单个对象。如果需要存储很多信息,可以考虑使用封装所有信息的自定义类,并确保它包含 Serializable 属性。
也可以创建许多的 Pair 对象:
string stringData;
int intData;
protected override object SaveControlState()
{
object baseState = base.SaveControlState();
Pair pair1 = new Pair(stringData, intData);
Pair pair2 = new Pair(baseState, pair1);
return pair2;
}
在 LoadControlState()中,你把基类的控件状态传入,并把 Pair 对象的一部分强制转换为合适的类型:
protected override void LoadControlState(object savedState)
{
Pair p = savedState as Pair;
if (p != null)
{
// Give the base class its state
base.LoadControlState(p.First);
// Now you can process the state you saved
Pair pair1 = p.Second as Pair;
stringData = (string)pair1.First;
intData = (int)pair1.Second;
}
}
回传数据和 Change 事件
视图状态和控件状态可以帮助跟踪控件中的内容,但对于输入控件,这些还不够。这是因为输入控件允许用户更改它们的数据。例如,考虑代表 <input> 标签的表单中的文本框。当页面回传发生的时候,<input> 标签里的数据是控件集合的一部分。TextBox 控件需要取得这个信息并且相应的更新它的状态。
为了在自定义控件里处理被传送到页面的数据,需要实现 IPostBackDataHandler 借口。通过实现这个接口,你向 ASP.NET 标明,当一个回传发生的时候,控件需要一个时机来检查回传数据,你的控件将会得到这个机会,无论实际上触发这次回传的是哪个控件。
IPostBackDataHandler 定义了两个方法:
- LoadPostData():当页面回传时,ASP.NET 在任何控件事件被激发之前调用这个方法。这允许你检查被回传的数据,并且相应的更新控件的状态。不过,你不应该在这个时候激发 change 事件,因为其他控件还不会被更新。
- RaisePostDataChangedEvent():在页面上所有输入控件都被初始化了之后,如果必要,ASP.NET 会调用这个方法来给你机会机会激发 change 事件。
理解这个原理的最好方式是通过一个简单的示例。
下一个控件模仿一个基本的 TextBox 控件。这里给出一个基本的控件定义:
public class CustomTextBox : WebControl,IPostBackDataHandler
{
// 构造函数里初始化为空字符串, 将基本标签设置为 <input>
public CustomTextBox()
: base(HtmlTextWriterTag.Input)
{
Text = "";
}
// 这个控件仅仅需要一个属性 Text, 值被保存在视图状态里
public string Text
{
get { return (string)ViewState["Text"]; }
set { ViewState["Text"] = value; }
}
// 为 <input> 标签添加特性 type 和 value, 你就能够处理所有的事情了
// 必须使用 name 特性为控件添加 UniqueID, ASP.NET 将这个字符串与回传数据做比较
// 如果不添加这个 UniqueID, LoadPostData() 方法将不会被调用!
protected override void AddAttributesToRender(HtmlTextWriter writer)
{
writer.AddAttribute(HtmlTextWriterAttribute.Type, "text");
writer.AddAttribute(HtmlTextWriterAttribute.Value, Text);
writer.AddAttribute("name", this.UniqueID);
base.AddAttributesToRender(writer);
}
// 参数一: 当前控件数据的键值
// 参数二: 回传到页面的值的集合
public bool LoadPostData(string postDataKey, System.Collections.Specialized.NameValueCollection postCollection)
{
// Get the posted value and the most recent view state value.
string postedValue = postCollection[postDataKey];
string viewstateValue = Text;
// If the value changed, then reset the value of the text property
// and return true so the RaiseDataChangedEvent will be fired.
if (viewstateValue != postedValue)
{
Text = postedValue;
return true;
}
else
{
return false;
}
}
public void RaisePostDataChangedEvent()
{
throw new NotImplementedException();
}
}
RaisePostDataChangedEvent()的任务相对简单些,就是激发事件。然而,大多数 ASP.NET 控件使用另外一层,通过这一层,RaisePostDataChangedEvent()方法调用 OnXXX()方法来引发事件。这一层让其他开发人员可以从你的控件派生一个新控件,并且通过覆盖 OnXXX()方法来改变控件的行为。
public event EventHandler TextChanged;
public void RaisePostDataChangedEvent()
{
// Call the method to raise the change event
OnTextChanged(new EventArgs());
}
public virtual void OnTextChanged(EventArgs e)
{
// Check for at least one listener, and then raise the event.
if (TextChanged != null)
{
TextChanged(this, e);
}
}
触发回传
通过实现 IPostBackDataHandler 接口,你就能够参与每一个回传活动,并且读取你的控件的回传数据。
但是,如果想触发回传,该怎么做呢?
类似最简单的例子就是 Button 控件,以 HTML 表单标准,“提交”按钮总是回传页面,但是许多其他富控件如 Calendar 和 GridView 都允许你通过单击呈现出的 HTML 中的一个元素或者某个链接来触发一个回传。
这可以通过另一个 ASP.NET 机制提供:JavaScript 函数 _doPostBack()。_doPostBack()函数接受 2 个参数:触发回传的控件的名字和一个代表额外回传数据的字符串。
ASP.NET 提供了 Page.ClientScript.GetPostBackEventReference()方法来简化对 _doPostBack()函数的访问。这个方法创建一个对客户端的 _doPostBack()函数的引用,这样你就可以把这个引用呈现到控件里。通常,你会将这个函数引用放置在控件的 HTML 元素的 onClick 特性中。这样,当那个 HTML 元素被单击时,_doPostBack()函数就被触发了。
下面,创建一个简单的例子来理解回传机制。这个例子展示一个可单击的图像,单击它页面就会回传,没有任何额外的数据:
public string ImageUrl
{
get { return (string)ViewState["ImageUrl"]; }
set { ViewState["ImageUrl"] = value; }
}
public CustomImageButton()
: base(HtmlTextWriterTag.Img)
{
ImageUrl = "";
}
你唯一需要定制的工作是添加一些要呈现出来的特性,包括唯一的控件名称、图像的 URL 和一个将图像与 _doPostBack()函数关联起来的 onClick 特性:
protected override void AddAttributesToRender(HtmlTextWriter writer)
{
writer.AddAttribute("name",this.UniqueID);
writer.AddAttribute("src", this.ImageUrl);
writer.AddAttribute("onClick",
Page.ClientScript.GetPostBackEventReference(this,string.Empty));
}
这对于触发回传是足够的,但是为了参与回传并激发一个事件,还需要多做几步。这次,需要实现 IPostBackEventHandler 接口,实现其定义的 RaisePostBackEvent():
public class CustomImageButton : WebControl,IPostBackEventHandler
{ ... }
当页面被回传的时候,ASP.NET 判断哪一个控件触发了回传(通过查看每个控件的 UniqueID),并且,如果那个控件实现了 IPostBackEventHandler 事件处理程序,ASP.NET 就以事件数据调用 RaisePostBackEvent()方法。此时,页面上所有控件都被初始化了,这时触发一个事件很安全:
public event EventHandler ImageClicked;
public void RaisePostBackEvent(string eventArgument)
{
OnImageClicked(new EventArgs());
}
public virtual void OnImageClicked(EventArgs e)
{
if (ImageClicked != null)
{
ImageClicked(this, e);
}
}
创建测试页面,效果如下:
这个控件本身并未提供现存的 ASP.NET Web 控件没有的功能。例如,ImageButton。然而,这对于创建一些更加有用的控件来说是一个好的起点。
另外,很多时候并不需要把整个页面都回传,可以使用回调从服务器端取回一些特定的信息。