ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Delphi Virtual Treeview 虚拟树视图:大数据量节点渲染与性能优化实战

Delphi Virtual Treeview 虚拟树视图:大数据量节点渲染与性能优化实战

简介:Virtual-Treeview 是一套面向 Delphi 与 Pascal 开发者的虚拟树视图控件资源,适合需要在桌面应用中实现高性能、可定制树形结构的中高级开发者。它从底层重新设计,在节点渲染、选择模式、背景绘制与层级展示等方面提供了比传统控件更灵活的方案,并附带 C++ Builder 包维护与 FireMonkey、Lazarus/FPC 移植相关的说明,便于跨平台与多编译器场景参考。压缩包共 368 个文件,约 5.13MB,包含 45 个 pas 单元、28 个 dproj 工程、22 个 dpk 包、17 个 dfm 窗体、14 个 cpp 源文件,以及 bmp、jpg、cur、ico 等界面素材和 dtx、res、chm 等辅助资源,覆盖源码、示例、文档与图标素材。目前已有 1305 人学习下载,读者可据此研究虚拟树的数据组织、绘制机制与包结构,并参考示例工程快速集成到自己的 Delphi 项目中。

1. 当百万行数据塞进树视图:Virtual Treeview 到底解决了什么

做过 Delphi 桌面端的人大概率都遇到过这个场景:一个设备台账、一份物料清单、一层组织架构,数据量一上来,标准TTreeView就开始卡,展开一个节点要等两三秒,滚动条拖一下界面直接白屏。这不是代码写得烂,而是TTreeView底层依赖 Windows 原生控件,每个节点都是一个真实的窗口句柄,节点数上万之后系统资源就被吃光了。Virtual Treeview 这个控件从设计之初就是冲着这个问题去的——它不创建真实节点,只在需要绘制时才向你的代码「要数据」,也就是所谓的虚拟模式。标题里说的「Virtual Treeview 是一个 Delphi 树视图控件」,重点不在「树视图」,而在「Virtual」这个词上。它适合谁?适合手里有大数据量表格、树形结构、需要自定义绘制单元格,又不想换技术栈的 Delphi 开发者。这篇文章不讲空泛概念,直接拆开它的数据请求机制、节点管理、绘制流程和实际落地时的参数配置,让你看完能自己搭一个能跑万级节点的树。

2. 虚拟模式的数据请求机制:为什么它敢不存节点

2.1 从 OnGetText 和 OnInitNode 看「按需取数」

标准树视图的思路是「先建节点,再显示」,Virtual Treeview 反过来,是「先告诉它有多少节点,显示到谁再问谁要内容」。核心就两个事件:OnGetText负责给单元格填文字,OnInitNode负责在节点第一次被访问时初始化它的结构信息。你不需要在内存里维护一棵完整的树,只需要维护一份扁平的数据源,比如一个TList或者数据库游标。

// 假设 FData 是一个 TList<TItemData>,每个元素存了 ID、名称、父ID procedure TFormMain.VSTInitNode(Sender: TBaseVirtualTree; ParentNode, Node: PVirtualNode; var InitialStates: TVirtualNodeInitStates); var Data: PItemData; begin // 向控件声明:这个节点还有子节点,先别画展开图标 Data := FData[Node.Index]; if Data.HasChildren then InitialStates := InitialStates + [ivsHasChildren]; end; procedure TFormMain.VSTGetText(Sender: TBaseVirtualTree; Node: PVirtualNode; Column: TColumnIndex; TextType: TVSTTextType; var CellText: string); var Data: PItemData; begin Data := FData[Node.Index]; case Column of 0: CellText := Data.Name; 1: CellText := Data.Code; 2: CellText := FormatDateTime('yyyy-mm-dd', Data.CreatedAt); end; end;

这段代码的逻辑很直白:控件画到哪个节点,就通过Node.Index去你的数据源里取对应记录。OnInitNode里的ivsHasChildren是关键,它告诉控件「这个节点下面还有东西」,控件才会画那个+号。如果你漏了这个状态,节点永远展不开,这是新手第一个翻车点。参数上要注意Node.Index是控件内部维护的序号,不是你的业务 ID,所以你的数据源顺序必须和控件添加节点的顺序严格一致,否则取错数据。

2.2 RootNodeCount 和 ChildCount 的配合逻辑

虚拟模式下的节点数量不是一次性全告诉控件的,而是分层给的。根节点数量通过RootNodeCount属性设置,子节点数量则在OnGetChildrenCount或者OnInitChildren事件里动态返回。这种设计的好处是,你打开一个万级根节点的树,控件只问你要根节点的数量,不会去遍历所有子节点。

procedure TFormMain.FormCreate(Sender: TObject); begin VST.NodeDataSize := SizeOf(TItemData); // 每个节点关联的数据大小 VST.RootNodeCount := FData.Count; // 先告诉控件根层有多少条 end; procedure TFormMain.VSTInitChildren(Sender: TBaseVirtualTree; Node: PVirtualNode; var ChildCount: Cardinal); var Data: PItemData; begin Data := FData[Node.Index]; ChildCount := Data.ChildCount; // 只返回数量,不建节点 end;

NodeDataSize这个属性容易被忽略。如果你设成 0,控件不会为每个节点分配额外内存,Node.Data指针就是空的;如果你设成SizeOf(TItemData),控件会为每个可见节点分配一块内存,你可以把业务数据直接挂上去。我一般建议设成 0,数据统一放在外部TList里,通过Node.Index索引,这样内存布局更可控,也方便和数据库查询结果做映射。RootNodeCount和ChildCount配合起来,控件就知道该画多少个节点占位符,但具体内容还是等到绘制时才问你要。

2.3 节点句柄 PVirtualNode 的生命周期陷阱

PVirtualNode是一个指针,指向控件内部维护的节点结构。它的生命周期完全由控件管理,你展开、折叠、滚动,控件都可能回收和重建这些指针。血泪经验是:千万不要把PVirtualNode存到你的业务对象里长期持有,今天存的指针明天可能就指向了别的节点,或者直接变成野指针。

// 错误做法:把 Node 指针存到外部列表 FMyList.Add(Node); // 过一会儿这个 Node 可能已经失效 // 正确做法:只存 Index,需要时再通过 Index 反查 FMyList.Add(Node.Index); // 要用的时候: // Node := VST.GetFirstChild(VST.RootNode); // while Assigned(Node) do begin // if Node.Index = TargetIndex then Break; // Node := VST.GetNextSibling(Node); // end;

如果你确实需要频繁通过业务 ID 定位节点,建议在外部维护一个ID -> Index的哈希表,而不是缓存指针。控件提供了GetFirstChild、GetNextSibling、GetPreviousSibling这些遍历方法,配合Index使用是安全的。另外,OnFreeNode事件触发时,如果你在Node.Data里挂了对象,记得在那里释放,否则就是内存泄漏。

3. 从零搭一个可运行的虚拟树:最小工程与关键属性

3.1 安装包获取与 IDE 版本匹配

Virtual Treeview 的源码包在 Delphi 社区里流传很广,常见做法是下载后把Source目录加入项目的搜索路径,或者编译成设计期包安装到 IDE 工具栏。这里有个坑:不同 Delphi 版本对应的包文件不一样,老版本用VirtualTreesD7.dpk这类命名,新版本(如 10.x 之后)通常用VirtualTreesR.dpk和VirtualTreesD.dpk两个包,R 是运行期,D 是设计期。如果你只装了 R 没装 D,工具栏上不会出现控件图标,但代码里手动创建TVirtualStringTree是能跑的。

// 纯代码创建,不依赖设计期包 uses VirtualTrees; procedure TFormMain.FormCreate(Sender: TObject); var VST: TVirtualStringTree; begin VST := TVirtualStringTree.Create(Self); VST.Parent := Self; VST.Align := alClient; VST.NodeDataSize := 0; VST.OnGetText := VSTGetText; VST.OnInitNode := VSTInitNode; VST.OnInitChildren := VSTInitChildren; VST.RootNodeCount := FData.Count; end;

这种写法适合不想动 IDE 环境的情况,也方便你把控件封装到自己的框架里。参数上Align := alClient让它填满父容器,NodeDataSize := 0表示不分配节点附加内存。事件绑定必须在设置RootNodeCount之前完成,否则控件初始化时找不到回调,会画出一片空白。

3.2 列定义与 Header 的初始化顺序

虚拟树的列不是在设计器里拖出来的,而是通过Header.Columns集合动态添加。顺序很重要:先加列,再设RootNodeCount,否则列宽和节点绘制会对不上。

procedure TFormMain.SetupColumns; var Col: TVirtualTreeColumn; begin VST.Header.Columns.Clear; Col := VST.Header.Columns.Add; Col.Text := '名称'; Col.Width := 200; Col := VST.Header.Columns.Add; Col.Text := '编码'; Col.Width := 120; Col := VST.Header.Columns.Add; Col.Text := '创建日期'; Col.Width := 100; VST.Header.Options := VST.Header.Options + [hoVisible]; end;

hoVisible必须加上,否则表头不显示。列宽建议设一个初始值,控件支持用户拖拽调整,但初始值太窄会导致文字被截断,体验很差。如果你要做多列排序,还需要在OnCompareNodes事件里写比较逻辑,这个后面进阶部分会提。

3.3 节点选中与多选模式的配置差异

默认情况下虚拟树是单选模式,按住 Ctrl 可以多选,但需要显式开启toMultiSelect。这个选项在TreeOptions.SelectionOptions里。

VST.TreeOptions.SelectionOptions := VST.TreeOptions.SelectionOptions + [toMultiSelect]; VST.TreeOptions.SelectionOptions := VST.TreeOptions.SelectionOptions + [toFullRowSelect];

toFullRowSelect让整行高亮,而不是只高亮文字区域,对表格型数据更友好。注意这两个选项会影响GetNextSelected的遍历行为,多选时遍历要用GetFirstSelected配合GetNextSelected,不能再用GetNext。

var Node: PVirtualNode; begin Node := VST.GetFirstSelected; while Assigned(Node) do begin // 处理选中节点 Memo1.Lines.Add(FData[Node.Index].Name); Node := VST.GetNextSelected(Node); end; end;

这段遍历代码在多选场景下是标准写法,GetNextSelected会自动跳过未选中的节点。如果你用GetNext去遍历,会把所有节点都走一遍,效率差很多。

4. 避坑与排查:虚拟树落地时最容易翻车的五个点

4.1 节点展开后子节点数量不对

现象:点击+号,展开后子节点数量比实际少,或者多出空白行。原因通常是OnInitChildren里返回的ChildCount和实际数据源不匹配,或者ivsHasChildren状态没设对。解决方法是先在OnInitNode里根据数据判断是否有子节点,再在OnInitChildren里返回准确数量,两个地方的数据源必须是同一份。

4.2 滚动时文字闪烁或错位

现象:快速滚动时单元格文字串行,或者出现残影。原因是OnGetText里用了异步数据源,比如在事件里现查数据库,导致绘制时数据还没返回。解决方法是把数据预加载到内存列表,OnGetText只做内存读取。如果数据量实在太大,用分页加载,但每页数据要先缓存好。

4.3 节点指针失效导致访问违规

现象:程序运行一段时间后,操作某个节点弹出「Access violation」。原因就是前面说的PVirtualNode被缓存后失效。解决方法是全面排查代码里有没有把Node存到外部变量或对象字段里,一律改成存Index,用的时候再遍历查找。

4.4 大量节点时内存持续增长

现象:反复展开折叠后,任务管理器里内存只涨不降。原因是NodeDataSize设了非零值,但OnFreeNode里没有释放挂载的对象。解决方法是设NodeDataSize := 0,或者确保每个Node.Data指向的对象在OnFreeNode里被 Free 掉。

4.5 排序后节点 Index 与数据源错位

现象:点击表头排序后,选中某行拿到的数据不对。原因是排序只改变了控件的显示顺序,Node.Index还是原来的值,但你的数据源没有跟着重排。解决方法是排序时同步重排外部数据源,或者用OnCompareNodes自定义比较,让控件自己排,但数据源也要用同样的规则排一遍。

5. 进阶技巧:用 OnCompareNodes 做多列排序与数据同步

多列排序是虚拟树里比较绕的一个点。控件本身提供了Header.SortColumn和Header.SortDirection,但真正的比较逻辑要你自己在OnCompareNodes里写。我一般会维护一个排序状态记录,然后在比较事件里根据当前排序列去取对应字段。

procedure TFormMain.VSTCompareNodes(Sender: TBaseVirtualTree; Node1, Node2: PVirtualNode; Column: TColumnIndex; var Result: Integer); var Data1, Data2: PItemData; begin Data1 := FData[Node1.Index]; Data2 := FData[Node2.Index]; case Column of 0: Result := CompareText(Data1.Name, Data2.Name); 1: Result := CompareText(Data1.Code, Data2.Code); 2: Result := CompareValue(Data1.CreatedAt, Data2.CreatedAt); end; if Sender.Header.SortDirection = sdDescending then Result := -Result; end;

这段代码里Column参数是当前点击的列索引,SortDirection控制升序降序。注意CompareText是不区分大小写的字符串比较,如果你需要区分大小写,用CompareStr。日期比较用CompareValue比直接减更安全,避免溢出。

排序之后,外部数据源FData的顺序并没有变,但控件的显示顺序变了。如果你后续要通过Node.Index去取数据,取到的还是排序前的记录,这就对不上了。我的做法是在排序事件触发后,同步对FData做一次同样的排序,保证两边顺序一致。

procedure TFormMain.VSTHeaderClick(Sender: TVTHeader; HitInfo: TVTHeaderHitInfo); begin if HitInfo.Column = NoColumn then Exit; if Sender.SortColumn = HitInfo.Column then begin if Sender.SortDirection = sdAscending then Sender.SortDirection := sdDescending else Sender.SortDirection := sdAscending; end else begin Sender.SortColumn := HitInfo.Column; Sender.SortDirection := sdAscending; end; // 同步排序外部数据源 SortDataList(FData, HitInfo.Column, Sender.SortDirection); VST.Invalidate; // 触发重绘 end;

SortDataList是你自己实现的排序函数,根据列索引和方向对TList排序。Invalidate会触发控件重绘,重绘时OnGetText会按新的Node.Index去取数据,因为两边顺序已经同步,所以取到的就是正确记录。

还有一个技巧是节点过滤。虚拟树支持IsFiltered事件,你可以在里面根据搜索关键词返回 True 或 False,控件会自动隐藏不匹配的节点。这个功能配合RootNodeCount使用,过滤时不需要重建节点,只触发重绘,性能很好。

procedure TFormMain.VSTIsFiltered(Sender: TBaseVirtualTree; Node: PVirtualNode; var Filtered: Boolean); var Data: PItemData; begin Data := FData[Node.Index]; Filtered := (FSearchText <> '') and (Pos(UpperCase(FSearchText), UpperCase(Data.Name)) = 0); end;

搜索框的OnChange里调用VST.Invalidate就能实时过滤。注意IsFiltered只对可见节点生效,折叠的子节点不会被过滤,展开时才会触发。

最后说一个我自己的习惯:每次改完数据源,先调VST.Clear再重新设RootNodeCount,不要试图局部更新节点。虚拟树的重绘成本很低,全量刷新比局部更新更不容易出错。这个控件我用了好几年,最大的教训就是别跟它的生命周期较劲,顺着它的按需模型走,代码反而简单。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进