这几天老板把我“外包”给QA Team,帮他们解决一个ASP.NET中碰到的性能问题。该站点是一个用ASP.NET写的自动化测试平台,里面有一个界面上放了一个TreeView,加载从数据库读出来的DataSet作为数据源显示树形结构。他们觉得速度太慢不能忍,我的任务就是帮他们优化这个TreeView的性能。由于该站点用的是ASP.NET 2.0,所以本文所叙述的环境也基于ASP.NET 2.0,其他版本可能略有出入。
该Demo放在 http://hackerzhou.googlecode.com/svn/trunk/CSharp/TreeViewDemo/ 上了,有需要的可以去checkout,使用VS2010,低版本的VS可以考虑直接拷贝代码文件。
要求实现的TreeView需要满足:
- 载入/选中/展开速度快
- 节点选中状态改变时其子节点的状态要跟着一起改变
- 当节点被选中时,其祖先节点都要被选中
- 当节点被取消选中时,如果其兄弟节点都没有被选中,则取消其父节点的选中状态
看了一下老的代码,原来的实现方式是一下子把一个TreeView全部一次性发送到本地,每次状态发生改变需要Post Back的时候,整个页面都会重新从服务器接收一遍,Tree大概有2000个Node,而且由于没有开gzip压缩,传输上的开销很大。于是尝试用gzip压缩数据,每次返回的数据从5M减少到2M左右,响应时间从5秒减少到3秒。我用的方法是在Page Load的时候判断客户端是否支持gzip,如果支持,就用gzip来编码。
protected void Page_Load(object sender, EventArgs e)
{
//Use gzip to compress the web page and asynchronous callback content.
//Check whether client can handle gzip encoding.
if (Request.Headers["Accept-Encoding"].ToLower().Contains("gzip"))
{
Response.Filter = new GZipStream(Response.Filter, CompressionMode.Compress);
Response.AppendHeader("Content-Encoding", "gzip");
}
}
这样优化还远远不够,于是想做成on demand载入节点的。查了MSDN之后发现TreeNode有PopulateOnDemand这个属性,用来使得节点点开时Post Back到服务器请求数据,然后加载其子节点,这样能大大改善性能。因为点击一个子节点需要取其子节点的子节点来判定其子节点是否有元素(好绕。。。),没有的话就不应该显示那个加号,所以需要展开两层。这样做又会碰到一个问题,就是Load过的节点的展开不会引起Post Back,我的解决方法是增加Expanded事件的处理器,Expand会在每次节点展开时被触发,用来判断其子节点是否有子节点,如果没有,则将该子节点的PopulateOnDemand置为false,加号自然消失了。之前也尝试过在Expand的时候调用Populate来处理,结果发现会出bug,有一些节点会被加到跟节点上去,很是奇怪。
Populate和Expanded事件处理函数以及用来加载子节点的函数如下:
protected void TreeView1_TreeNodePopulate(object sender, TreeNodeEventArgs e)
{
//Load child nodes on demand.
ExpandTreeNode(e.Node, e.Node.ChildNodes, e.Node.Value, e.Node.Checked, 2);
}
protected void TreeView1_TreeNodeExpanded(object sender, TreeNodeEventArgs e)
{
if (IsPostBack)
{
//Check whether current node's child nodes have child nodes, if not, set
// PopulateOnDemand = false in order to avoid showing the expand button.
foreach (TreeNode n in e.Node.ChildNodes)
{
if (data.Tables[0].Select("ParentId='" + n.Value + "'").Length == 0)
{
n.PopulateOnDemand = false;
}
}
}
}
/// <summary>
/// Expand the TreeNode, one level by one level.
/// </summary>
/// <param name="node">a given tree node, set to null if want to add to the root node</param>
/// <param name="childNodes">the collection where add nodes to</param>
/// <param name="parentId">the parent node information</param>
/// <param name="isChecked">whether nodes are checked</param>
/// <param name="level">use in recursive call</param>
private void ExpandTreeNode(TreeNode node, TreeNodeCollection childNodes, string parentId, bool isChecked, int level)
{
if (level <= 0)
{
return;
}
if (childNodes.Count != 0)
{
childNodes.Clear();
}
if (node != null)
{
node.PopulateOnDemand = false;
}
foreach (DataRow row in data.Tables[0].Select("ParentId='" + parentId + "'"))
{
TreeNode tmpNd = new TreeNode();
tmpNd.Value = row["Id"].ToString();
tmpNd.Text = row["Name"].ToString();
tmpNd.ShowCheckBox = true;
tmpNd.Checked = isChecked;
tmpNd.PopulateOnDemand = true;
childNodes.Add(tmpNd);
ExpandTreeNode(tmpNd, tmpNd.ChildNodes, tmpNd.Value, isChecked, level - 1);
}
}
然后要处理CheckBox选中的问题,为了满足需求,新增加了CheckChanged事件处理器,查了资料发现CheckChanged事件并不会在客户端选中时触发,而会在页面Post Back的时候触发。于是就使用如下代码来启用客户端脚本,使得用户点击CheckBox的时候引发一个Post Back,从而触发CheckChanged事件。
//Use EnableClientScript and add onclick to TreeView in order to post back
// on CheckBox's state changed.
tv.EnableClientScript = true;
tv.Attributes.Add("onclick", "postBackByObject(event)");
在aspx页面中增加如下javascript引发Post Back(借鉴了网上的版本,不过貌似都没有做浏览器兼容,我特地做了一下):
<script type="text/javascript" language="javascript">
function postBackByObject(event) {
var o = (window.event) ? window.event.srcElement : event.target;
if (o.nodeName == "INPUT" && o.type == "checkbox") {
__doPostBack("", "");
}
}
</script>
事件处理函数如下(比较繁琐,不过不怎么复杂,基本上就是按照那几个需求的逻辑来做):
protected void TreeView1_TreeNodeCheckChanged(object sender, TreeNodeEventArgs e)
{
//Recursive change its children's checked states.
RecursiveCheckCheckBox(e.Node, e.Node.Checked, true);
//If current node is unchecked then check whether all its sibling are
// unchecked, if so, unchecked its parent node.
TreeNode current = e.Node;
while (!current.Checked && current.Parent != null && !IsChildNodesChecked(current.Parent))
{
current.Parent.Checked = false;
current = current.Parent;
}
//If current node is checked then recursive checked its ancestor nodes.
if (e.Node.Checked)
{
RecursiveCheckCheckBox(e.Node, e.Node.Checked, false);
}
}
/// <summary>
/// Check a node's direct children is checked.
/// This function needn't be a recursive one, because I find that I only
/// need to check its direct children's state, it will be much faster
/// than use recursive.
/// </summary>
/// <param name="n">a given node</param>
/// <returns>true if its direct children is checked, false otherwise</returns>
private bool IsChildNodesChecked(TreeNode n)
{
bool result = false;
if (n.ChildNodes.Count == 0)
{
result = n.Checked;
}
foreach (TreeNode t in n.ChildNodes)
{
if (result |= t.Checked)
{
break;
}
}
return result;
}
/// <summary>
/// Recursive set a node's child nodes' checked states to it's parent's.
/// </summary>
/// <param name="n">a given node</param>
/// <param name="isChecked">whether nodes are checked</param>
/// <param name="topDown">whether to go topdown or downtop</param>
private void RecursiveCheckCheckBox(TreeNode n, bool isChecked, bool topDown)
{
if (topDown)
{
foreach (TreeNode l in n.ChildNodes)
{
l.Checked = isChecked;
if (l.ChildNodes.Count != 0)
{
RecursiveCheckCheckBox(l, isChecked, topDown);
}
}
}
else
{
n.Checked = isChecked;
if (n.Parent != null)
{
RecursiveCheckCheckBox(n.Parent, isChecked, false);
}
}
}
最后,精益求精下,把SelectedNodeChanged事件也处理了,这样点击一个节点(不点加号)都能展开该节点。
protected void TreeView1_SelectedNodeChanged(object sender, EventArgs e)
{
if (sender is TreeView)
{
TreeView tv = sender as TreeView;
TreeNode node = tv.SelectedNode;
node.Expand();
}
}