一、引言
在应用系统开发中,TreeView是一种使用频率很高的控件。它的主要特点是能够比较清晰地实现分类、导航、浏览等功能。因而,它的使用方法与编程技巧也一直受到技术人员的关注。随着应用需求的变化,在很多情况下我们需要实现数据显示的权限控制,即用户看到的数据是经过过滤的,或是连续值,或是一些离散的值。就TreeView而言,原先可能显示出来的是完整的具有严格父子关系得节点集,而经权限过滤后所要显示的节点可能会变得离散,不再有完整的继承关系。本文针对这一问题,通过对已有实现方法进行分析,提出改进算法。所附示例程序进一步解释了算法设计思想。
二、三种常见生成方式的简单分析
如文[2,3]所述,TreeView的生成基本上有三种方式:
1. 界面设计时在TreeView设计器或者代码中直接填充TreeView节点。
这种方式通过拖放控件的方式生成树,应用范围窄,是一种非编程方式;
2. 从XML文件中建立树形结构。
这种方式通过XML文件(串)生成树,从形式上来说,这种方式是比较直观的。因为XML本身就是一棵“树”,在.NET 平台下TreeView的自动生成代码中,TreeView的实际内容也是由XML表示的。此外,基于XML文件生成树对异构环境下的分布式应用具有重要意义。事实上,利用XML作为通用数据传递格式已得到普遍认可;
3. 从数据库中得到数据(在.NET中,我们可以理解为一个数据集),建立树形结构。
这种方式通过父子关系递归生成树,是最容易理解的一种编程实现方式。一般是自顶向下递归生成,得到广泛应用。
这里,我们不妨举一个实际的例子来说明一下,假设我们有这样的数据集(可以看作是一个公司的部门列表):
TagValue | ContentValue | ParentID |
G01 | 行销部 |
|
G02 | 顾问部 |
|
G03 | 研发部 |
|
G04 | 测试部 |
|
GS01 | 行销一部 | G01 |
GS02 | 行销二部 | G01 |
GS03 | 行销三部 | G01 |
GSL01 | 行销一部北京办 | GS01 |
GSL02 | 行销一部上海办 | GS01 |
GS04 | 顾问一部 | G02 |
GS05 | 顾问二部 | G02 |
GS06 | 研发一部 | G03 |
GS07 | 研发二部 | G03 |
GS08 | 测试一部 | G04 |
GS09 | 测试二部 | G04 |
GSL03 | 研发一部杭州分部 | GS06 |
GSL04 | 研发一部西安分部 | GS06 |
表1 示例数据集
其中,TagValue是节点的实际值,ContentValue是用户界面上节点显示的值或者说标签值,ParentID是节点的父节点的TagValue。若节点为根节点,一般设ParentID为空或等于本身的TagValue。
默认情况下,我们可以按照下面的算法把所有的节点装配成一棵树,
算法1:通过父子关系递归生成树基本算法 l Step 0:数据准备,给定数据集。 l Step 1:给定待增加子节点的节点(初始时一般为根节点),记作CurNode,以及待增加节点的ParentID值(初始时为根节点的ParentID),记作CurParentID; l Step 2:在数据集中查找具有指定ParentID值的所有节点,得到节点集objArr[], |
最终可以得到下图所示的TreeView:
图1 TreeView效果图
这种方法的缺陷在于"由父节点及子节点"的遍历顺序意味着每个子节点的父节点必须存在,否则将搜索不到,即可能出现断层现象。在很多实际应用中,我们发现这种实现方式不能完全奏效,最典型的情况就是当需要对众节点所表征的实际值(比如机构列表,人员列表,资源列表等)进行权限控制时,这时往往从数据库中筛选出来的数据集中节点会出现断层现象。比如我们假设设定权限时给定数据如表2,即把第一行“行销部”去掉(注:权限过滤操作已超出本文讨论的范围,这里假定数据集已准好),则运用算法1生成的TreeView如图2所示。
TagValue | ContentValue | ParentID |
G02 | 顾问部 |
|
G03 | 研发部 |
|
G04 | 测试部 |
|
GS01 | 行销一部 | G01 |
GS02 | 行销二部 | G01 |
GS03 | 行销三部 | G01 |
GSL01 | 行销一部北京办 | GS01 |
GSL02 | 行销一部上海办 | GS01 |
GS04 | 顾问一部 | G02 |
GS05 | 顾问二部 | G02 |
GS06 | 研发一部 | G03 |
GS07 | 研发二部 | G03 |
GS08 | 测试一部 | G04 |
GS09 | 测试二部 | G04 |
GSL03 | 研发一部杭州分部 | GS06 |
GSL04 | 研发一部西安分部 | GS06 |
表2 给定数据集
图2 TreeView效果图
可以看到,这里产生了节点遗漏现象。一般来说,我们可以从两方面入手去解决问题,一方面可以修正数据集,另一方面则可以修改生成树算法。显然直接修正数据集是很复杂的,且会带来效率问题。而单方面修改生成树算法也是不是很好(即把遗漏的节点直接插到根节点下),因为这时会出现父辈和晚辈同级的现象。
三、通过深度编号递归生成树算法
回顾到已有的一些方法(文[1~5]),其中基于节点深度生成树的方法给我们一些启发,我们在构造数据集时可以增加深度字段,但这里的深度不是简单的层级号,是一个扩展了的概念,具体地说其实是一个深度编号,它与父辈编号存在一定的对应关系。比如表1所示的数据集可以作如下编号:
TagValue | ContentValue | ParentID | DepthID |
G01 | 行销部 |
| a001 |
G02 | 顾问部 |
| a002 |
G03 | 研发部 |
| a003 |
G04 | 测试部 |
| a004 |
GS01 | 行销一部 | G01 | a001001 |
GS02 | 行销二部 | G01 | a001002 |
GS03 | 行销三部 | G01 | a001003 |
GSL01 | 行销一部北京办 | GS01 | a001001001 |
GSL02 | 行销一部上海办 | GS01 | a001001002 |
GS04 | 顾问一部 | G02 | a002001 |
GS05 | 顾问二部 | G02 | a002002 |
GS06 | 研发一部 | G03 | a003001 |
GS07 | 研发二部 | G03 | a003002 |
GS08 | 测试一部 | G04 | a004001 |
GS09 | 测试二部 | G04 | a004002 |
GSL03 | 研发一部杭州分部 | GS06 | a003001001 |
GSL04 | 研发一部西安分部 | GS06 | a003001002 |
表3 带深度编号的数据集
其中,DepthID即是节点的深度编号。生成深度编号的过程其实也不复杂,首先我们可以制定编号的规则,比如层级编号的前缀、编码长度、起始值等。当给某个节点编号时,只要找到所在层级的最大编号,然后增1。具体实现过程这里不再细述。
于是,我们很自然地想到借鉴算法1的思想设计基于深度编号的生成树程序。这时,我们可以根据当前节点的深度编号寻找其后代节点集,但要给出一个最大跨度(可以理解为最高级与最低级间的间隔级数),因为不可能无限制地找下去。这种方法可以部分程度上弥补"由父节点及子节点"的遍历的缺陷,因为当出现断层时会沿着编号继续往后找。但是还是会可能漏掉,比如我们给定数据集(把“研发一部”过滤掉):
TagValue | ContentValue | ParentID | DepthID |
G01 | 行销部 |
| a001 |
G02 | 顾问部 |
| a002 |
G03 | 研发部 |
| a003 |
G04 | 测试部 |
| a004 |
GS01 | 行销一部 | G01 | a001001 |
GS02 | 行销二部 | G01 | a001002 |
GS03 | 行销三部 | G01 | a001003 |
GSL01 | 行销一部北京办 | GS01 | a001001001 |
GSL02 | 行销一部上海办 | GS01 | a001001002 |
GS04 | 顾问一部 | G02 | a002001 |
GS05 | 顾问二部 | G02 | a002002 |
GS07 | 研发二部 | G03 | a003002 |
GS08 | 测试一部 | G04 | a004001 |
GS09 | 测试二部 | G04 | a004002 |
GSL03 | 研发一部杭州分部 | GS06 | a003001001 |
GSL04 | 研发一部西安分部 | GS06 | a003001002 |
表4 给定数据集
在生成树过程中,当从“研发部”(a003)往下找子节点时,找到的应该是“研发二部”(a003002),因为它是最近的节点。而下面的顺序就是沿着“研发二部”再往下找,显然不可能找到“研发一部杭州分部”和“研发一部西安分部”,因为编号规则不一样,这样生成的树同样会漏掉节点。
我们提出一种新的算法,即打破传统的遍历顺序,采用由底向上的遍历顺序。形象地说,传统的方法是通过一个既有根节点或父节点来不断衍生新的子节点(如图3(a)所示),而新的算法是通过不断聚集节点,形成子树集,最后汇成一棵树(如图3(b)所示)。
图3 TreeView节点生成流程示意图
算法2:由底向上按层次(深度)遍历法生成树算法 l Step 0:数据准备,给定数据集(TagValue,ContentValue,DepthID), TagValue是节点的实际值,ContentValue是节点显示的值或者说标签值,DepthID是节点的深度编号。若节点为根节点,一般其DepthID长度为最短。给定最大深度iMaxDepLen和最小深度iMinDepLen。给定用于存储当前子树的Hashtable; l Step 1:给定当前遍历的层级长度iCurDepLen,初始设为iMaxDepLen; l Step 2:在数据集中根据给定iCurDepLen查找满足条件的层级,得到该层级的节点集objArr[], l Step 3:若当前层级iCurDepLen大于最小层级iMinDepLen,则继续回溯,将iCurDepLen减1并作为当前iCurDepLen,goto Step 2;否则goto Step 4. l Step 4:在得到用Hashtable存储的节点表后(实际上是一子树表),遍历Hashtable,将各棵子树插入TreeView.
|
在该算法中,我们一开始便计算好数据集中节点深度编号的最小长度和最大长度,目的是为了不盲目搜索。但如果数据集中每一层级的深度编号是固定长的,则可以更简化搜索过程。存放临时子树的Hashtable的键值是当前子树根节点的Tag值,这样的好处是查找相当方便,不需要在TreeView中遍历一个个节点。所以,每次处理上一层级的节点,只需看其父节点在不在Hashtable中,若在将其插入子树,否则增加Hashtable项。
附录示例程序实现了这一算法,这里介绍一下关键的几个函数。
函数形式及其参数解释 | 功能 |
PopulateCompleteTree(ref System.Windows.Forms.TreeView objTreeView,DataSet dsSource,string strTreeCaption,int iTagIndex,int iContentIndex,int iDepthIndex) 1. objTreeView是最终要生成的TreeView; 2. dsSource是给定数据集; 3. strTreeCaption指定TreeView根节点的名称; 4. iTagIndex是数据集中TagValue字段的列号; 5. iContentIndex是数据集中ContentValue字段的列号; 6. iDepthIndex是数据集中DepthID字段的列号; | 1. 采用层次(深度)遍历法生成树主调函数; 2. 调用CollectNodes(DataSet dsSource,int iTagIndex,int iContentIndex,int iDepthIndex,int iCurDepLen,int iMinDepLen,ref Hashtable objArrNode) |
CollectNodes(DataSet dsSource,int iTagIndex,int iContentIndex,int iDepthIndex,int iCurDepLen,int iMinDepLen,ref Hashtable objArrNode) 1. dsSource,iTagIndex,iContentIndex,iDepthIndex同上; 2. iCurDepLen指当前层级深度编号长度; 3. iMinDepLen指最小深度即最顶层深度编号长度; 4. objArrNode指用于存放中间子树的Hashtable | 1. 从底往上聚集节点; 2. 调用 LookupParentNode(DataSet dsSource,int iDepthIndex,string strSubDepth,int iTagIndex,int iContentIndex) |
LookupParentNode(DataSet dsSource,int iDepthIndex,string strSubDepth,int iTagIndex,int iContentIndex) 1. dsSource,iTagIndex,iContentIndex,iDepthIndex同上; 2. strSubDepth指当前节点的深度编号(因为是递归查找) | 1. 查找最近的上控层级,因为有可能父节点层级不存在。
|
此时若给定数据集(我们把“研发部”和“行销一部”过滤掉),
TagValue | ContentValue | ParentID | DepthID |
G01 | 行销部 |
| a001 |
G02 | 顾问部 |
| a002 |
G04 | 测试部 |
| a004 |
GS02 | 行销二部 | G01 | a001002 |
GS03 | 行销三部 | G01 | a001003 |
GSL01 | 行销一部北京办 | GS01 | a001001001 |
GSL02 | 行销一部上海办 | GS01 | a001001002 |
GS04 | 顾问一部 | G02 | a002001 |
GS05 | 顾问二部 | G02 | a002002 |
GS07 | 研发二部 | G03 | a003002 |
GS08 | 测试一部 | G04 | a004001 |
GS09 | 测试二部 | G04 | a004002 |
GSL03 | 研发一部杭州分部 | GS06 | a003001001 |
GSL04 | 研发一部西安分部 | GS06 | a003001002 |
表5 给定数据集
则生成树如下图所示,
图4 TreeView效果图
这正是我们需要的结果。
当然,有时为了结构的需要,我们还会采取所谓“中立”的方法。比如对于本文所提的TreeView控件节点生成问题,如果不想再写算法去生成深度编号,那么我们还可以通过给数据集增加标志位的方法,即用标志位来标识数据是否已被筛选。在运用传统算法生成树后,再检查一下是否有未被筛选的数据,若有则查找其祖辈节点,将其插入祖辈节点。不过这里的“查找祖辈节点”是在TreeView上进行的,当节点很多时其效率肯定没有直接在数据集上搜索高。
另外,深度编号的引入不仅会给生成树带来方便,还可以让权限设置更灵活。具体到我们的示例来说,一般如果我们要把某些部门过滤掉,那么会把这些部门一个一个挑出来,我们称之为“离散值设置方式”。而当系统结构庞大时,我们更希望挑选一个区间,比如把一个部门及其下控的n级过滤掉,这是一个“连续值设置方式”,这时包含层级概念的深度编号可以很好地解决这个问题。实际的系统开发中,我们也发现采用这种方式是切实可行的。
四、其他TreeView生成方式
前面提到TreeView还可以通过XML文件(串)生成。这种方式实现的关键是构造出一个类似于TreeView的XML文档或字符串出来。其基本思想应该与前面讨论的算法是相似的,只是在程序实现上稍微复杂一些(其中,XML节点的索引可以基于文档对象模型(DOM)来做)。另外还要注意的是,有很多的第三方TreeView控件,他们所支持的XML文档的格式是不尽相同的。限于篇幅,本文不详细讨论具体实现过程。
五、小结
本文主要讨论了.NET平台下TreeView控件节点生成程序设计,结合已有方法和实际需求,对设计方法进行了研究,给出了比较完整的解决方法。
在树的具体应用中,除了生成树之外,节点的增、删、改、查甚至节点的升级和降级都是很常见的。本质上说,这些操作所涉及的是与业务相关的数据库操作,所以在采用“由底向上按层次(深度)遍历法”生成的TreeView中,这些操作的实现与传统方法是一致的,额外的操作无非是添加或修改深度编号。当然,实际需求是变化多端的,相应算法的设计与分析也是无止境的。
参考文献(Reference):
[1] Zane Thomas. DataViewTree for Windows Forms,http://www.abderaware.com/WhitePapers/ datatreeview.htm
[2] 李洪根. 树形结构在开发中的应用, http://www.microsoft.com/china/community/Column/ 21.mspx
[3] 李洪根. .NET平台下Web树形结构程序设计, http://www.microsoft.com/china/community/ Column/30.mspx
[4] Don Schlichting. Populating the TreeView Control from a Database, http://www.15seconds. com/issue/030827.htm
[5] HOW TO: Populate a TreeView Control from a Dataset in Visual Basic .NET, http://support. microsoft.com/?kbid=320755
[6] Scott Mitchell. Displaying XML Data in the Internet Explorer TreeView Control,http://aspnet. 4guysfromrolla.com/articles/051403-1.aspx
-------------
source code:
using System;
using System.Data;
using System.Windows.Forms;
using System.Collections;
namespace PopTreeView
{
/// <summary>
/// TreeOperator 的摘要说明。
/// </summary>
public class TreeOperator
{
public TreeOperator()
{
//
// TODO: 在此处添加构造函数逻辑
//
}
/// <summary>
/// 采用层次(深度)遍历法生成树
/// </summary>
/// <param name="objTreeView">目标树</param>
/// <param name="dsSource">数据集</param>
/// <param name="strTreeCaption">树显示名</param>
/// <param name="iTagIndex">值索引</param>
/// <param name="iContentIndex">内容索引</param>
/// <param name="iDepthIndex">层次索引</param>
public static void PopulateCompleteTree(ref System.Windows.Forms.TreeView objTreeView,DataSet dsSource,string strTreeCaption,int iTagIndex,int iContentIndex,int iDepthIndex)
{
//从底层开始遍历,开辟一个HashTable(以Tag值为关键字),存放当前计算的节点
objTreeView.Nodes.Clear();
int iMaxLen = GetMaxDepthLen(dsSource,iDepthIndex);
int iMinLen = GetTopDepthLen(dsSource,iDepthIndex);
Hashtable objArrNode = new Hashtable();
CollectNodes(dsSource,iTagIndex,iContentIndex,iDepthIndex,iMaxLen,iMinLen,ref objArrNode);
TreeNode objRootNode = new TreeNode(strTreeCaption);
//在得到节点表后,插入树
foreach(object objNode in objArrNode.Values)
{
TreeNode objNewNode = new TreeNode();
objNewNode = (TreeNode)objNode;
objRootNode.Nodes.Add(objNewNode);
}
objTreeView.Nodes.Add(objRootNode);
}
/// <summary>
/// 从底往上聚集节点
/// </summary>
/// <param name="dsSource"></param>
/// <param name="iTagIndex"></param>
/// <param name="iContentIndex"></param>
/// <param name="iDepthIndex"></param>
/// <param name="iCurDepLen"></param>
/// <param name="iMinDepLen"></param>
/// <param name="objArrNode"></param>
private static void CollectNodes(DataSet dsSource,int iTagIndex,int iContentIndex,int iDepthIndex,int iCurDepLen,int iMinDepLen,ref Hashtable objArrNode)
{
//收集节点
System.Data.DataView dv;
System.Windows.Forms.TreeNode tempNode;
//查找给定层节点
int i=iCurDepLen;
do
{
dv = new DataView(dsSource.Tables[0]);
string strExpr = "LEN(TRIM("+dsSource.Tables[0].Columns[iDepthIndex].ColumnName+"))="+Convert.ToString(i);
dv.RowFilter = strExpr;
i--;
}while(i>=iMinDepLen && dv.Count<=0);
iCurDepLen = i+1;
#region 逐层回溯,收集节点
foreach(System.Data.DataRowView drow in dv)
{
//查找父节点
string[] strArrParentInfo = LookupParentNode(dsSource,iDepthIndex,drow[iDepthIndex].ToString().Trim(),iTagIndex,iContentIndex);
string strTagValue = drow[iTagIndex].ToString().Trim();
string strContentValue = drow[iContentIndex].ToString();
//若无父节点,直接加入
if (strArrParentInfo == null)
{
//当前节点不在Hashtable中
if (objArrNode[strTagValue]==null)
{
//添加当前节点
tempNode = new TreeNode(strContentValue);
tempNode.Tag = strTagValue;
objArrNode.Add(strTagValue,tempNode);
}
}
else //有父节点,此时先查找父节点是否已在Hashtable中
{
string strParTagValue = strArrParentInfo[0].Trim();
string strParContentValue = strArrParentInfo[1].Trim();
//父节点已在Hashtable中
if (objArrNode[strParTagValue]!= null)
{
//当前节点不在Hashtable中
if (objArrNode[strTagValue]==null)
{
tempNode = new TreeNode(strContentValue);
tempNode.Tag = strTagValue;
}
else
{
//取出并移除该节点,然后插入父节点
tempNode = new TreeNode();
tempNode =(TreeNode)objArrNode[strTagValue];
objArrNode.Remove(strTagValue);
}
//插入到父节点中
TreeNode tempParNode = new TreeNode();
tempParNode = (TreeNode)objArrNode[strParTagValue];
tempParNode.Nodes.Add(tempNode);
objArrNode[strParTagValue] = tempParNode;
}
else //父节点不在Hashtable中
{
//当前节点不在Hashtable中
if (objArrNode[strTagValue]==null)
{
tempNode = new TreeNode(strContentValue);
tempNode.Tag = strTagValue;
}
else
{
//取出并移除该节点,然后插入父节点
tempNode = new TreeNode();
tempNode = (TreeNode)objArrNode[strTagValue];
objArrNode.Remove(strTagValue);
}
//创建父节点并将当前节点插入到父节点中
TreeNode tempParNode = new TreeNode(strParContentValue);
tempParNode.Tag = strParTagValue;
tempParNode.Nodes.Add(tempNode);
objArrNode.Add(strParTagValue,tempParNode);
}
}
}
#endregion
//还有未遍历的层
if (iCurDepLen>iMinDepLen)
{
CollectNodes(dsSource,iTagIndex,iContentIndex,iDepthIndex,iCurDepLen-1,iMinDepLen,ref objArrNode);
}
}
/// <summary>
/// 查找父亲节点
/// </summary>
/// <param name="dsSource"></param>
/// <param name="iDepthIndex"></param>
/// <param name="strSubDepth"></param>
/// <param name="iTagIndex"></param>
/// <param name="iContentIndex"></param>
/// <returns>找到返回由Tag值,内容值和深度值组成的字符串数组,否则返回null</returns>
private static string[] LookupParentNode(DataSet dsSource,int iDepthIndex,string strSubDepth,int iTagIndex,int iContentIndex)
{
System.Data.DataView dv;
int iSubLen = strSubDepth.Length;
if (iSubLen<=1)
{
return null;
}
int i=1;
do
{
dv = new DataView(dsSource.Tables[0]);
string strExpr ="TRIM("+dsSource.Tables[0].Columns[iDepthIndex].ColumnName+") = '"+strSubDepth.Substring(0,iSubLen-i)+"'";
dv.RowFilter = strExpr;
i++;
}while(i<iSubLen && dv.Count<=0);
if (dv.Count<=0)
{
return null;
}
else
{
string[] strArr = {dv[0][iTagIndex].ToString(),dv[0][iContentIndex].ToString(),dv[0][iDepthIndex].ToString()};
return strArr;
}
}
/// <summary>
/// 得到最大深度值(深度的长度)
/// </summary>
/// <param name="dsSource">数据集</param>
/// <param name="iDepthIndex">深度索引(列号)</param>
/// <returns>最大深度值</returns>
private static int GetMaxDepthLen(DataSet dsSource,int iDepthIndex)
{
DataRowCollection objRowCol = dsSource.Tables[0].Rows;
int iMax = objRowCol[0][iDepthIndex].ToString().Trim().Length;
foreach(DataRow objRow in objRowCol)
{
int iCurlen = objRow[iDepthIndex].ToString().Trim().Length;
if (iMax<iCurlen)
{
iMax = iCurlen;
}
}
return iMax;
}
/// <summary>
/// 得到最小深度值(深度的长度)
/// </summary>
/// <param name="dsSource">数据集</param>
/// <param name="iDepthIndex">深度索引(列号)</param>
/// <returns>最小深度值</returns>
private static int GetTopDepthLen(DataSet dsSource,int iDepthIndex)
{
DataRowCollection objRowCol = dsSource.Tables[0].Rows;
int iMin = objRowCol[0][iDepthIndex].ToString().Trim().Length;
foreach(DataRow objRow in objRowCol)
{
int iCurlen = objRow[iDepthIndex].ToString().Trim().Length;
if (iMin>iCurlen)
{
iMin = iCurlen;
}
}
return iMin;
}
}
}
-----------
memo: 本文最初我想在刊物上发表的,篇幅很长,这里有所删节。欢迎指正、交流!