对于几乎所有的数据表现web应用来说,组织好数据的显示方式、避免给用户带来混乱的感觉就是最主要的目标之一。每个页面显示20条记录当然是可以接受的,但每页显示10000条记录就很容易给用户带来不便了。将数据分成多个页面显示,即对数据进行分页,是解决此类问题的最常见的办法。
一、慨述
asp.net本身只提供了一个支持数据分页的控件,即datagrid分页控件,不过它比较适合intranet环境使用,对于internet环境来说,datagrid分页控件提供的功能似乎不足以构造出灵活的web应用。其中一个原因是,datagrid控件对web设计者放置分页控件的位置和分页控件的外观都有限制,例如,datagrid控件不允许垂直放置分页控件。另一个能够发挥分页技术优势的控件是repeater,web开发者可以利用repeater控件快速配置数据的显示方式,但分页功能却需要开发者自己实现。数据源在不断地变化,数据表现方式也千差万别,如果针对这些不断变动的条件分别定制分页控件,显然太浪费时间了,构造一个不限于特定表现控件的通用分页控件将极大地有利于节省时间。
一个优秀的通用数据控件不仅提供常规的分页功能,而且还要能够:
⑴ 提供“首页”、“上一页”、“下一页”、“末页”分页导航按钮。
⑵ 根据数据显示情况调整自身的状态,即具有数据敏感性。如果分页控件被设置成每页显示10个记录,但实际上只有9个记录,那么分页控件不应该显示出来;在数据分成多页显示的情况下,第一个页面的“首页”、“上一页”按钮不应显示出来,最后一个页面的“下一页”、“末页”按钮也不应该显示出来。
⑶ 不能依赖于特定的数据显示控件。
⑷ 具有适应各种现有、将有数据源的能力。
⑸ 应当能够方便地配置显示方式,轻松地集成到各种应用之中。
⑹ 当分页就绪时,提醒其他控件。
⑺ 即使是缺乏经验的web设计者,也要能够毫无困难地使用。
⑻ 提供有关分页信息的属性数据。
目前市场上已经有一些提供上述功能的商业性控件,不过都价格不菲。对于许多开发者来说,自己构造一个通用的分页控件是最理想的选择。
图一显示了本文通用分页控件的运行界面,其中用于显示的控件是一个repeater控件。分页控件由两类部件构成:四个导航按钮,一组页面编号链接。
用户可以方便地改换显示控件、改变分页控件本身的外观。例如,和分页控件协作的显示控件换成了一个datagrid控件,页面编号链接和四个导航按钮分两行显示。
asp.net支持创建定制web控件的三种方式:用户控件,复合控件,自定义控件。第三种控件即自定义控件的名称很容易引起误解。实际上,所有这三种控件都应该算是自定义控件。复合控件和微软所谓的自定义控件的不同之处在于,前者要用到createchildcontrols()方法,createchildcontrols()方法允许控件根据某些事件重新绘制自身。对于本文的通用分页器,我们将使用复合控件。
下面的uml序列图概括了通用分页控件的一般机制。
虽然我们的目标是让通用分页控件不依赖于表现数据的控件,但很显然,总得有某种方法让分页控件访问数据。每一个从control类继承的控件都提供一个databinding事件。我们把分页器本身注册成databinding事件的监听器,分页器就可以获知数据的情况并修改数据。由于所有从control类继承的控件都有这个databinding事件,所以分页器控件达到了不依赖于特定数据表现控件的目标——换句话说,分页器控件可以绑定到所有从control类派生的控件,即它能够绑定到几乎所有的web控件。
二、核心功能
当表现控件触发databinding事件,分页控件就可以获取datasource属性。遗憾的是,微软没有提供所有数据绑定类实现的接口,诸如idatasourceprovider之类,而且并非所有从control或webcontrol类继承的控件都有一个datasource属性,因此向上定型成control类没有意义,唯一可行的办法是通过reflection api直接操作datasoruce属性。在讨论事件句柄方法之前,应该指出的是,为了注册事件句柄,首先必须获得一个表现控件的引用。分页控件显露了一个简单的字符串属性bindtocontrol:
| public string bindtocontrol { get { if (_bindcontrol == null) throw new nullreferenceexception(“在使用分页控件之前,请先通过设置bindtocontrol属性绑定到一个控件。”); return _bindcontrol;} set{_bindcontrol=value;} } |
这个方法非常重要,所以最好能够抛出一个含义更明确的信息,而不是抛出标准的nullreferenceexception异常。在分页控件的oninit方法中,我们解析了对表现控件的引用。本例应当用oninit事件句柄(而不是构造函数)来确保jit编译的aspx页面已经设置了bindtocontrol。
| protected override void oninit(eventargs e) { _boundcontrol = parent.findcontrol(bindtocontrol); boundcontrol.databinding += new eventhandler(boundcontrol_databound); base.oninit(e); … } |
搜索表现控件的操作通过搜索分页控件的parent控件完成,在这里,parent就是页面本身。按照这种方式使用parent比较危险,举例来说,如果分页控件嵌入到了另一个控件之中,例如嵌入到了table控件之中,则parent引用实际上将是一个对table控件的引用。由于findcontrol方法只搜索当前的控件集合,除非表现控件就在该集合之中,否则不可能搜索到。一种比较安全的方法是递归地搜索各个控件集合,直至找到目标控件为止。
找到boundcontrol之后,我们将分页控件注册成为databinding事件的监听器。由于分页控件要操作数据源,所以该事件句柄应当是调用链中的最后一个,这一点很重要。不过,只要表现控件在oninit事件句柄中注册databinding的事件句柄(默认行为),分页控件操作数据源时就不会出现问题。
databound事件句柄负责获取表现控件的datasource属性。
| private void boundcontrol_databound(object sender,system.eventargs e) { if (hasparentcontrolcalleddatabinding) return; type type = sender.gettype(); _datasource = type.getproperty(“datasource”); if (_datasource == null) throw new notsupportedexception(“分页控件要求表现控件必需包含一个datasource。”); object data = _datasource.getgetmethod().invoke(sender,null); _builder = adapters[data.gettype()]; if (_builder == null) throw new nullreferenceexception(“没有安装适当的适配器来处理下面的数据源类型:”+data.gettype()); _builder.source = data; applydatasensitivityrules(); |
在databound中,我们尝试通过reflection api获得datasource属性,然后返回实际数据源的一个引用。现在虽然已经获知了数据源,但分页控件还必须知道如何操作该数据源。为了让分页控件不依赖于特定的表现控件,问题复杂了很多。不过,如果让分页控件依赖于特定的数据源,那就背离了设计一个灵活的分页控件的目标。我们要通过一个接插式的体系结构来确保分页控件能够处理各种数据源,无论是.net提供的数据源,还是自定义的数据源。
为了提供一个健壮的、可伸缩的接插式体系结构,我们将利用[gof] builder模式构造出一个解决方案。
图四
idatasourceadapter接口定义了分页控件操作数据所需的最基本的元素,相当于“插头”。
| publicinterface idatasourceadapter { int totalcount{get;} object getpageddata(int start,int end); } |
totalcount属性返回在处理数据之前数据源所包含元素的总数,而getpageddata方法返回原始数据的一个子集,例如:假设数据源是一个包含20个元素的数组,分页控件将数据显示成每页10个元素,则第一页的元素子集是数组元素0-9,第二页的元素子集是数组元素10-19。dataviewadapter提供了一个dataview类型的插头:
| internal class dataviewadapter:idatasourceadapter { private dataview _view; internal dataviewadapter(dataview view) for (int i = start;i<=end && i<= totalcount;i++) |
dataviewadapter实现了idatasourceadapter的getpageddata方法,该getpageddata克隆原始的datatable,将原始datatable中的数据导入到新的datatable。该类的可见性有意地设置成internal,目的是为了向web开发者隐藏实现细节,进而通过builder类提供一个更简单的接口。
| public abstract class adapterbuilder { private object _source; private void checkfornull() |
adapterbuilder抽象类为idatasourceadapter类型提供了一个更容易管理的接口,由于提高了抽象程度,我们不必再直接使用idatasourceadapter,同时adapterbuilder还提供了在分页数据之前执行预处理的指令。另外,该builder还使得实际的实现类,例如dataviewadapter,对分页控件的用户透明:
| public class datatableadapterbuilder:adapterbuilder { private dataviewadapter _adapter; private dataviewadapter viewadapter private dataviewadapter viewadapter |
dataview类型和datatable类型的关系是如此密切,所以构造一个通用性的dataadapter可能是有意义的,其实只要加入另一个处理datatable的构造函数就足够了。遗憾的是,当用户需要不同的功能来处理某个datatable时,就必须替换或继承整个类。如果我们构造一个使用同一idatasourceadapter的新builder,用户在选择如何实现适配器时就拥有更多的自由。
在分页控件中,寻找适当builder类的操作由一个类型安全的集合完成。
| public class adaptercollection:dictionarybase { private string getkey(type key) { return key.fullname; } public adaptercollection() {} publicvoid add(type key,adapterbuilder value) { dictionary.add(getkey(key),value); } publicbool contains(type key) { return dictionary.contains(getkey(key)); } publicvoid remove(type key) { dictionary.remove(getkey(key)); } public adapterbuilder this[type key] { get{return (adapterbuilder)dictionary[getkey(key)];} set{dictionary[getkey(key)]=value;} } } |
adaptercollection依赖于datasource类型,datasource通过boundcontrol_databound巧妙地引入。这里使用的索引键是type.fullname方法,确保了每一种类型索引键的唯一性,同时这也把保证每一种类型只有一个builder的责任赋予了adaptercollection。将builder查找加入boundcontrol_databound方法,结果如下:
| public adaptercollection adapters { get{return _adapters;} } private bool hasparentcontrolcalleddatabinding private void boundcontrol_databound(object sender,system.eventargs e) applydatasensitivityrules(); |
boundcontrol_databound方法利用hasparentcontrolcalleddatabinding检查是否已经创建了builder,如果是,则不再执行寻找适当builder的操作。adapters表的初始化在构造函数中完成:
| public pager() { selectedpager=new system.web.ui.webcontrols.style(); unselectedpager = new system.web.ui.webcontrols.style(); _adapters = new adaptercollection(); _adapters.add(typeof(datatable),new datatableadapterbuilder()); _adapters.add(typeof(dataview),new dataviewadapterbuilder()); } |
最后一个要实现的方法是bindparent,用来处理和返回数据。
| private void bindparent() { _datasource.getsetmethod().invoke(boundcontrol, new object[]{_builder.adapter.getpageddata(startrow,resultstoshow*currentpage)}); } |
这个方法很简单,因为数据处理实际上是由adapter完成的。这一过程结束后,我们还要用一次reflection api,不过这一次是设置表现控件的datasource属性。
三、界面设计
至此为止,分页控件的核心功能已经差不多实现,不过如果缺少适当的表现方式,分页控件不会很有用。
为了有效地将表现方式与程序逻辑分离,最好的办法莫过于使用模板,或者说得更具体一点,使用itemplate接口。实际上,微软清楚地了解模板的强大功能,几乎每一个地方都用到了模板,甚至页面解析器本身也不例外。遗憾的是,模板并不象有些人认为的那样是一个简单的概念,需要花些时间才能真正掌握它的精髓,好在这方面的资料比较多,所以这里就不再赘述了。返回来看分页控件,它有四个按钮:首页,前一页,后一页,末页,当然另外还有各个页面的编号。四个导航按钮选自imagebutton类,而不是linkbutton类,从专业的web设计角度来看,图形按钮显然要比单调的链接更有用一些。
| public imagebutton firstbutton{get {return first;}} public imagebutton lastbutton{get {return last;}} public imagebutton previousbutton{get {return previous;}} public imagebutton nextbutton{get {return next;}} |
页面编号是动态构造的,这是因为它们依赖于数据源中记录数量的多少、每个页面显示的记录数量。页面编号将加入到一个panel,web设计者可以通过panel来指定要在哪里显示页面编号。有关创建页面编号的过程稍后再详细讨论,现在我们需要为分页控件提供一个模板,使得用户能够定制分页控件的外观。
| [template container(typeof(layoutcontainer))] public itemplate layout { get{return (_layout;} set{_layout =value;} } public class layoutcontainer:control,inamingcontainer |
layoutcontainer类为模板提供了一个容器。一般而言,在模板容器中加入一个定制id总是不会错的,它将避免处理事件和进行页面调用时出现的问题。下面的uml图描述了分页控件的表现机制。
图五
创建模板的第一步是在aspx页面中定义布局:
| <layout> <asp:imagebutton id=”first” runat=”server” imageurl=”play2l_dis.gif” alternatetext=”首页”></asp:imagebutton> <asp:imagebutton id=”previous” runat=”server” imageurl=”play2l.gif” alternatetext=”上一页”></asp:imagebutton> <asp:imagebutton id=”next” runat=”server” imageurl=”play2.gif” alternatetext=”下一页”></asp:imagebutton> <asp:imagebutton id=”last” runat=”server” imageurl=”play2_dis.gif” alternatetext=”末页”></asp:imagebutton> <asp:panel id=”pager” runat=”server”></asp:panel> </layout> |
这个布局例子不包含任何格式元素,例如表格等,实际应用当然可以(而且应该)加入格式元素,请参见稍后的更多说明。
itemplate接口只提供了一个方法instantiatein,它解析模板并绑定容器。
| private void instantiatetemplate() { _container = new layoutcontainer(); layout.instantiatein(_container); first = (imagebutton)_container.findcontrol(“first”); previous = (imagebutton)_container.findcontrol(“previous”); next = (imagebutton)_container.findcontrol(“next”); last = (imagebutton)_container.findcontrol(“last”); holder = (panel)_container.findcontrol(“pager”); this.first.click += new system.web.ui.imageclickeventhandler(this.first_click); this.last.click += new system.web.ui.imageclickeventhandler(this.last_click); this.next.click += new system.web.ui.imageclickeventhandler(this.next_click); this.previous.click += new system.web.ui.imageclickeventhandler(this.previous_click); } |
控件的instatiatetemplate方法要做的第一件事情是实例化模板,即调用layout.instantiatein(_container)。容器其实也是一种控件,用法也和其他控件相似。instantiatetemplate方法利用这一特点寻找四个导航按钮,以及用来容纳页面编号的panel。导航按钮通过它们的id找到,这是对分页控件的一点小小的限制:导航按钮必须有规定的id,分别是first、previous、next、last,另外,panel的id必须是pager,否则就会找不到。遗憾的是,就我们选定的表现机制而言,这似乎是较好的处理方式了;但可以相信的是,只要提供适当的说明文档,这一小小限制不会带来什么问题。另外一种可选择使用的办法是:让每一个按钮从imagebutton类继承,从而也就定义了一个新的类型;由于每一个按钮是一种不同的类型,在容器中可以实现一个递归搜索来寻找各种特定的按钮,从而不必再用到按钮的id属性。
找到四个按钮之后,再把适当的事件句柄绑定到这些按钮。在这里必须做一个重要的决定,即何时调用instantiatetemplate。一般地,这类方法应当在createchildcontrols方法中调用,因为createchildcontrols方法的主要用途就是这一类创建子控件的任务。由于分页控件永远不会修改其子控件,所以它不需要createchildcontrols提供的功能来根据某些事件修改显示状态。显示子控件的速度总是越快越好,因此调用instantiatetemplate方法的比较理想的位置是在oninit事件中。
| protected override void oninit(eventargs e) { _boundcontrol = parent.findcontrol(bindtocontrol); boundcontrol.databinding += new eventhandler(boundcontrol_databound); instantiatetemplate(); controls.add(_container); base.oninit(e); } |
oninit方法除了调用instantiatetemplate方法,它的另一个重要任务是将容器加入分页控件。如果不将容器加入到分页器的控件集合,由于render方法永远不会被调用,所以模板就不可能显示出来。
模板还可以用编程的方式通过实现itemplate接口定义,这一特性除了可作为提高灵活性的措施之外,还可以提供一个默认的模板,以便在用户没有通过aspx页面提供模板时使用。
| public class defaultpagerlayout:itemplate { private imagebutton next; private imagebutton first; private imagebutton last; private imagebutton previous; private panel pager; public defaultpagerlayout() next.id=”next”; next.alternatetext=”下一页”;next.imageurl=”play2.gif”; control.controls.add(table); |
defaultpagerlayout通过编程的方式提供了所有的导航元素,并将它们加入到aspx页面,不过这一次导航元素用标准的html表格设置了格式。现在,如果用户没有提供一个表现模板,程序将自动提供一个默认的模板。
| [templatecontainer(typeof(layoutcontainer))] public itemplate layout { get{return (_layout == null)? new defaultpagerlayout():_layout;} set{_layout =value;} } |
下面再来看看生成各个页面编号的过程。分页控件首先需要确定一些属性值,通过这些属性值来确定要生成多少不同的页面编号。
| public int currentpage { get { string cur = (string)viewstate[“currentpage”]; return (cur == string.empty || cur ==null)? 1 : int.parse(cur); } set { viewstate[“currentpage”] = value.tostring();} } public int pagerstoshow public int resultstoshow |
currentpage保存的实际上是页面编号的viewstate中的当前页面,pagerstoshow方法定义的属性允许用户指定要显示多少页面,而resultstoshow定义的属性则允许用户指定每页要显示多少记录,默认值是10。
numberofpagerstogenerate返回当前应当生成的页面编号的数量。
| private int pagersequence { get { return convert.toint32 (math.ceiling((double)currentpage/(double)pagerstoshow));} } private int numberofpagerstogenerate private int totalpagestoshow |
totalpagestoshow方法返回要显示的总页面数量,由用户预设的resultstoshow属性调整。
虽然asp.net定义了一些默认的样式,不过对于分页控件的用户它们可能不是很实用。用户可以通过自定义样式来调整分页控件的外观。
| public style unselectedpagerstyle {get {return unselectedpager;}} public style selectedpagerstyle {get {return selectedpager;}} |
unselectedpagerstyle提供了页面编号未选中时所用的样式,而selectedpagerstyle提供了页面编号被选中时所用的样式。
| private void generatepagers(webcontrol control) { control.controls.clear(); int pager = (pagersequence-1)* pagerstoshow +1; for (;pager<=numberofpagerstogenerate && pager<=totalpagestoshow;pager++) control.controls.add(link); private void generatepagers() |
generatepagers方法动态地创建所有页面编号,页面编号是linkbutton类型的按钮。各个页面编号的标签和id属性通过循环赋值,同时,点击事件被绑定到适当的事件句柄。最后,页面编号被加入到一个容器控件——在本例中是一个panel对象。按钮id起到了标识哪一个按钮触发点击事件的作用。下面是事件句柄的定义:
| private void pager_click(object sender, system.eventargs e) { linkbutton button = (linkbutton) sender; currentpage = int.parse(button.id); raiseevent(pagechanged, this,new pagechangedeventargs(currentpage,pagedeventinvoker.pager)); update(); } private void next_click(object sender, system.web.ui.imageclickeventargs e) private void previous_click(object sender, system.web.ui.imageclickeventargs e) private void last_click(object sender, system.web.ui.imageclickeventargs e) |
这些事件句柄都相似,它们首先更改分页控件的当前页面,然后刷新绑定的控件。
| private void update() { if (!hasparentcontrolcalleddatabinding) return; applydatasensitivityrules(); bindparent(); boundcontrol.databind(); } |
首先,分页控件通过调用hasparentcontrolcalleddatabinding方法检查是否已经初始化了必要的适配器。如果是,则将前面指出的根据数据显示情况自动调整控件的规则应用到当前的控件,这些规则使得分页控件根据boundcontrol中数据的不同情况表现出不同的行为。虽然这些规则由分页控件内部控制,但必要时可以用[gof] state模式方便地移出到控件之外。
| public bool isdatasensitive { get{return _isdatasensitive;} set{_isdatasensitive = value;} } private bool ispagervisible private bool ispreviousvisible private bool isnextvisible private void applydatasensitivityrules() |
applydatasensitivityrules方法实施预定义的规则,诸如ispagervisible、ispreviousvisible和isnextvisible。默认情况下,分页控件将启用这些规则,但用户可以通过设置isdatasensitive属性来关闭这些规则。
至此为止,分页控件的显示部分基本设计完毕。最后剩下的结束工作是提供几个事件句柄,使得用户能够在各种分页控件事件出现时进行必要的调整。
| public delegate void pagedelegate(object sender,pagechangedeventargs e);
public enum pagedeventinvoker{next,previous,first,last,pager} public class pagechangedeventargs:eventargs public pagechangedeventargs(int newpage):base() |
由于分页控件需要返回自定义的事件参数,所以我们定义了一个专用的pagechangedeventargs类。pagechangedeventargs类返回pagedeventinvoker类型,pagedeventinvoker类型是可能触发事件的控件的枚举量。为了处理自定义的事件参数,我们定义了一个新的delegate,即pagedelegate。事件按照下面的形式定义:
| public event pagedelegate pagechanged; public event eventhandler dataupdate; |
当事件没有对应的事件监听器时,asp.net会抛出一个异常。分页控件定义了下列raiseevent方法。
| private void raiseevent(eventhandler e,object sender) { this.raiseevent(e,this,null); } private void raiseevent(eventhandler e,object sender, pagechangedeventargs args) private void raiseevent(pagedelegate e,object sender, pagechangedeventargs args) |
现在事件句柄可以通过调用各个raiseevent方法来触发事件了。
四、应用实例
至此为止,分页控件的设计已经全部完成,可以正式使用了。要使用该分页控件,只要把它绑定到一个表现控件即可。
| <asp:repeater id=”repeater” runat=”server”> <itemtemplate> 列1: <%# convert.tostring(databinder.eval(container.dataitem,”column1″))%> <br> 列2: <%# convert.tostring(databinder.eval(container.dataitem,”column2″))%> <br> 列3: <%# convert.tostring(databinder.eval(container.dataitem,”column3″))%> <br> <hr> </itemtemplate> </asp:repeater> <cc1:pager id=”pager” resultstoshow=”2″ runat=”server” bindtocontrol=”repeater”> <selectedpagerstyle backcolor=”yellow” /> </cc1:pager> |
上面的aspx页面将分页控件绑定到一个repeater控件,设置每页显示的记录数量为2,选中的页面编号颜色为黄色,使用默认的布局,效果如图一。下面是另一个例子,它将分页控件绑定到一个datagrid,效果如图二。
| <asp:datagrid id=”grid” runat=”server”></asp:datagrid> <cc1:pager id=”pagergrid” resultstoshow=”2″ runat=”server” bindtocontrol=”grid”> <selectedpagerstyle backcolor=”red”></selectedpagerstyle> <layout> <asp:imagebutton id=”first” runat=”server” imageurl=”play2l_dis.gif” alternatetext=”首页”></asp:imagebutton> <asp:imagebutton id=”previous” runat=”server” imageurl=”play2l.gif” alternatetext=”上一页”></asp:imagebutton> <asp:imagebutton id=”next” runat=”server” imageurl=”play2.gif” alternatetext=”下一页”></asp:imagebutton> <asp:imagebutton id=”last” runat=”server” imageurl=”play2_dis.gif” alternatetext=”末页”></asp:imagebutton> <asp:panel id=”pager” runat=”server”></asp:panel> </layout> </cc1:pager> |
测试表明,分页控件并不依赖于特定的表现控件,它可以方便地处理不同的数据源,而且很容易使用,请读者下载本文后面的源代码参见完整的例子。
虽然学习开发自定义web控件不是一件轻松的事情,但掌握这项技能带来的好处不言而喻,只要稍微增加一些工作量,开发者就可以将普通的web控件改换成多用途的通用控件,数十倍地提高工作效率,本文的分页控件只是创建通用控件来满足现有和将来表现需要的其中一个例子而已。
