一、引言
在应用系统开发中,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:数据准备,给定数据集。 一般来说数据集遵循这样的格式,即(TagValue,ContentValue,ParentID); l Step 1:给定待增加子节点的节点(初始时一般为根节点),记作CurNode,以及待增加节点的ParentID值(初始时为根节点的ParentID),记作CurParentID; l Step 2:在数据集中查找具有指定ParentID值的所有节点,得到节点集objArr[], if (objArr == null) return; else { //遍历节点集 for(int i=0; i<objArr.Length;i++) { 将objArr[i]添加为CurNode的子节点,同时递归(即将objArr[i]作为CurNode,objArr[i]的TagValue作为CurParentID,goto Step 1); } } |
最终可以得到下图所示的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[], if (objArr == null) return; else { //遍历节点集 for(int i=0; i<objArr.Length;i++) { Step 2.1 查找objArr[i]的父节点,若无父节点,直接加入,goto Step 2.2;若有父节点,先查找父节点是否已在Hashtable中。若有,将其从Hashtable中移出并记为tempParNode;否则生成新节点tempParNode;goto Step 2.3; Step 2.2 若当前节点objArr[i]不在Hashtable中,在Hashtable中添加objArr[i];continue; Step 2.3 若当前节点objArr[i]不在Hashtable中,根据objArr[i]生成节点tempNode;否则,将其从Hashtable中移出,并记为tempNode;将tempNode插到tempParNode中,并将存入Hashtable。 } } 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: 本文最初我想在刊物上发表的,篇幅很长,这里有所删节。欢迎指正、交流!