【问题标题】:TVirtualStringTree - resetting non-visual nodes and memory consumptionTVirtualStringTree - 重置非可视节点和内存消耗
【发布时间】:2011-02-18 08:44:49
【问题描述】:

我有一个应用程序,它从二进制日志文件加载记录并将它们显示在虚拟 TListView 中。一个文件中可能有数百万条记录,并且显示可以被用户过滤,所以我不会一次加载内存中的所有记录,并且ListView项索引不是一对一的关系文件记录偏移量(例如,列表项 1 可能是文件记录 100)。我使用 ListView 的 OnDataHint 事件仅加载 ListView 实际感兴趣的项目的记录。当用户滚动时,由 OnDataHint 指定的范围发生变化,允许我释放不在新范围内的记录,并分配新记录根据需要。

这个很好用,速度还可以,而且内存占用很低。

我目前正在评估 TVirtualStringTree 作为 TListView 的替代品,主要是因为我想添加扩展/折叠跨越多行的记录的功能(我可以通过动态增加/减少项目计数来使用 TListView 捏造它,但是这不像使用真正的树那么简单)。

在大多数情况下,我已经能够移植 TListView 逻辑并让一切按我的需要工作。不过,我注意到 TVirtualStringTree 的虚拟范例有很大不同。它不具有与 TListView 相同的 OnDataHint 功能(我可以使用 OnScroll 事件来伪造它,这允许我的内存缓冲区逻辑继续工作),并且我可以使用 OnInitializeNode 事件将节点与分配的记录相关联.

但是,一旦树节点被初始化,它就会看到它在树的整个生命周期内都保持初始化状态。这对我不好。当用户滚动并从内存中删除记录时,我需要重置这些非可视节点而不将它们从树中完全删除,或者丢失它们的展开/折叠状态。当用户将它们滚动回视图时,我可以重新分配记录并重新初始化节点。基本上,就虚拟化而言,我想让 TVirtualStringTree 的行为尽可能像 TListView。

我已经看到 TVirtualStringTree 有一个 ResetNode() 方法,但是每当我尝试使用它时都会遇到各种错误。我一定是用错了。我还想在每个节点内存储一个数据指针到我的记录缓冲区,然后分配和释放内存,相应地更新这些指针。最终效果也不是那么好。

更糟糕的是,我最大的测试日志文件中有大约 500 万条记录。如果我一次用这么多节点初始化 TVirtualStringTree(当日志显示未过滤时),树的节点内部开销会占用高达 260MB 的内存(还没有分配任何记录)。而使用 TListView,加载相同的日志文件及其背后的所有内存逻辑,我可以只使用几 MB。

有什么想法吗?

【问题讨论】:

    标签: delphi c++builder vcl virtualtreeview


    【解决方案1】:

    您可能不应该切换到 VST,除非您至少可以使用标准列表框/列表视图所没有的 VST 的一些不错的功能。但与平面项目列表相比,内存开销当然很大。

    我认为使用TVirtualStringTree 只是为了能够展开和折叠跨多行的项目并没有真正的好处。你写

    主要是因为我想添加展开/折叠跨越多行的记录的功能(我可以通过动态增加/减少项目计数来使用 TListView 来伪造它,但这不像使用真正的树那么简单) .

    但您可以轻松实现,而无需更改项目数。如果将列表框的Style 设置为lbOwnerDrawVariable 并实现OnMeasureItem 事件,则可以根据需要调整高度以仅绘制第一行或所有行。手动绘制扩展三角形或树视图的小加号应该很容易。 Windows API 函数DrawText()DrawTextEx() 可用于测量和绘制(可选自动换行)文本。

    编辑:

    抱歉,我完全错过了您现在使用的是列表视图,而不是列表框的事实。事实上,没有办法在列表视图中拥有不同高度的行,所以这是没有选择的。您仍然可以使用顶部带有标准标题控件的列表框,但这可能不支持您现在从列表视图功能使用的所有内容,并且它本身可能与动态显示和隐藏列表视图行一样多甚至更多的工作模拟折叠和展开。

    【讨论】:

    • 我目前在 vsReport 模式下使用的是 TListView,而不是 TListBox。 TListView 不支持 vsReport 中的可变高度项。
    • 我暂时将此标记为答案,但这只是因为它告诉我不要切换到 VST。
    【解决方案2】:

    如果我理解正确的话,TVirtualStringTree 的内存需求应该是:

    nodecount * (SizeOf(TVirtualNode) + YourNodeDataSize + DWORD-align-padding)

    为了最小化内存占用,您也许可以仅使用指向内存映射文件的偏移量的指针来初始化节点。在这种情况下,似乎不需要重置已经初始化的节点 - 内存占用应该是 nodecount * (44 + 4 + 0) - 对于 500 万条记录,大约 230 MB。

    恕我直言,您无法更好地使用树,但使用内存映射文件可以让您直接从文件中读取数据,而无需分配更多内存并将数据复制到其中。

    您还可以考虑使用树结构而不是平面视图来呈现数据。这样,您可以按需初始化父节点的子节点(当父节点展开时)并在父节点折叠时重置父节点(因此释放其所有子节点)。也就是说,尽量不要在同一个层级有太多的节点。

    【讨论】:

    • 事实上,我已经使用了内存映射文件,但是由于文件可能的大小(最高可达 1+ GB),以及显示过滤的实现方式,我这样做了不要一次将整个文件映射到内存中。至于在代码中实现树结构,这不会有太大帮助,因为大多数日志文件项都没有子节点。这就是当前使用 ListView 进行显示的原因。支持子节点是为了适应一个功能,其中多行日志条目作为单独的记录存储在日志文件中,我将在内存中合并在一起以便于管理
    【解决方案3】:

    为了满足您的要求“展开/折叠跨越多行的记录”,我只需使用绘图网格。要检查它,请将绘图网格拖到表单上,然后插入以下 Delphi 6 代码。您可以折叠和展开 5,000,000 条多行记录(或您想要的任何数量),基本上没有开销。这是一种简单的技术,不需要太多代码,而且效果非常好。

    
    unit Unit1;
    
    interface
    
    uses
      Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms, Dialogs, Grids, StdCtrls;
    
    type
      TForm1 = class(TForm)
        DrawGrid1: TDrawGrid;
        procedure DrawGrid1DrawCell(Sender: TObject; ACol, ARow: Integer; Rect: TRect; State: TGridDrawState);
        procedure DrawGrid1SelectCell(Sender: TObject; ACol, ARow: Integer; var CanSelect: Boolean);
        procedure DrawGrid1TopLeftChanged(Sender: TObject);
        procedure DrawGrid1DblClick(Sender: TObject);
        procedure FormCreate(Sender: TObject);
      private
        procedure AdjustGrid;
      end;
    
    var
      Form1: TForm1;
    
    implementation
    
    {$R *.dfm}
    
    // Display a large number of multi-line records that can be expanded or collapsed, using minimal overhead.
    // LinesInThisRecord() and RecordContents() are faked; change them to return actual data.
    
    const TOTALRECORDS = 5000000; // arbitrary; a production implementation would probably determine this at run time
    
    // keep track of whether each record is expanded or collapsed
    var isExpanded: packed array[1..TOTALRECORDS] of boolean; // initially all FALSE
    
    function LinesInThisRecord(const RecNum: integer): integer;
    begin // how many lines (rows) does the record need to display when expanded?
    result := (RecNum mod 10) + 1; // make something up, so we don't have to use real data just for this demo
    end;
    
    function LinesDisplayedForRecord(const RecNum: integer): integer;
    begin // how many lines (rows) of info are we currently displaying for the given record?
    if isExpanded[RecNum] then result := LinesInThisRecord(RecNum) // all lines show when expanded
    else result := 1; // show only 1 row when collapsed
    end;
    
    procedure GridRowToRecordAndLine(const RowNum: integer; var RecNum, LineNum: integer);
    var LinesAbove: integer;
    begin // for a given row number in the drawgrid, return the record and line numbers that appear in that row
    RecNum := Form1.DrawGrid1.TopRow; // for simplicity, TopRow always displays the record with that same number
    if RecNum > TOTALRECORDS then RecNum := 0; // avoid overflow
    LinesAbove := 0;
    while (RecNum > 0) and ((LinesDisplayedForRecord(RecNum) + LinesAbove) &lt (RowNum - Form1.DrawGrid1.TopRow + 1)) do
      begin // accumulate the tally of lines in expanded or collapsed records until we reach the row of interest
      inc(LinesAbove, LinesDisplayedForRecord(RecNum));
      inc(RecNum); if RecNum > TOTALRECORDS then RecNum := 0; // avoid overflow
      end;
    LineNum := RowNum - Form1.DrawGrid1.TopRow + 1 - LinesAbove;
    end;
    
    function RecordContents(const RowNum: integer): string;
    var RecNum, LineNum: integer;
    begin // display the data that goes in the grid row.  for now, fake it
    GridRowToRecordAndLine(RowNum, RecNum, LineNum); // convert row number to record and line numbers
    if RecNum = 0 then result := '' // out of range
    else
      begin
      result := 'Record ' + IntToStr(RecNum);
      if isExpanded[RecNum] then // show line counts too
        result := result + ' line ' + IntToStr(LineNum) + ' of ' + IntToStr(LinesInThisRecord(RecNum));
      end;
    end;
    
    procedure TForm1.AdjustGrid;
    begin // don't allow scrolling past last record
    if DrawGrid1.TopRow > TOTALRECORDS then DrawGrid1.TopRow := TOTALRECORDS;
    if RecordContents(DrawGrid1.Selection.Top) = '' then // move selection back on to a valid cell
      DrawGrid1.Selection := TGridRect(Rect(0, TOTALRECORDS, 0, TOTALRECORDS));
    DrawGrid1.Refresh;
    end;
    
    procedure TForm1.DrawGrid1DrawCell(Sender: TObject; ACol, ARow: Integer; Rect: TRect; State: TGridDrawState);
    var s: string;
    begin // time to draw one of the grid cells
    if ARow = 0 then s := 'Data' // we're in the top row, get the heading for the column
    else s := RecordContents(ARow); // painting a record, get the data for this cell from the appropriate record
    // draw the data in the cell
    ExtTextOut(DrawGrid1.Canvas.Handle, Rect.Left, Rect.Top, ETO_CLIPPED or ETO_OPAQUE, @Rect, pchar(s), length(s), nil);
    end;
    
    procedure TForm1.DrawGrid1SelectCell(Sender: TObject; ACol, ARow: Integer; var CanSelect: Boolean);
    var RecNum, ignore: integer;
    begin
    GridRowToRecordAndLine(ARow, RecNum, ignore); // convert selected row number to record number
    CanSelect := RecNum &lt> 0; // don't select unoccupied rows
    end;
    
    procedure TForm1.DrawGrid1TopLeftChanged(Sender: TObject);
    begin
    AdjustGrid; // keep last page looking good
    end;
    
    procedure TForm1.DrawGrid1DblClick(Sender: TObject);
    var RecNum, ignore, delta: integer;
    begin // expand or collapse the currently selected record
    GridRowToRecordAndLine(DrawGrid1.Selection.Top, RecNum, ignore); // convert selected row number to record number
    isExpanded[RecNum] := not isExpanded[RecNum]; // mark record as expanded or collapsed; subsequent records might change their position in the grid
    delta := LinesInThisRecord(RecNum) - 1; // amount we grew or shrank (-1 since record already occupied 1 line)
    if isExpanded[RecNum] then // just grew
    else delta := -delta; // just shrank
    DrawGrid1.RowCount := DrawGrid1.RowCount + delta; // keep rowcount in sync
    AdjustGrid; // keep last page looking good
    end;
    
    procedure TForm1.FormCreate(Sender: TObject);
    begin
    Caption := FormatFloat('#,##0 records', TOTALRECORDS);
    DrawGrid1.RowCount := TOTALRECORDS + 1; // +1 for column heading
    DrawGrid1.ColCount := 1;
    DrawGrid1.DefaultColWidth := 300; // arbitrary
    DrawGrid1.DefaultRowHeight := 12; // arbitrary
    DrawGrid1.Options := DrawGrid1.Options - [goVertLine, goHorzLine, goRangeSelect] + [goDrawFocusSelected, goThumbTracking]; // change some defaults
    end;
    
    end.
    
    

    【讨论】:

    • 您正在根据需要添加/删除的行数来调整网格的 RowCount。我在原始消息中声明我已经知道该技术:“我可以通过动态增加/减少项目计数来使用 TListView 来伪造它”。这就是我要走的路。
    • @Remy:但你也说这“不是直截了当”。使用drawgrid,展开/折叠就像你能得到的一样简单,而且你永远不用担心任何加载/分配/释放/节点/父母/孩子/等。如果 listview 可以工作但很混乱,而 drawgrid 可以工作并且很简单,请使用 drawgrid。请务必尝试示例代码。如果您将它与 listview 中相同功能所需的代码并排比较,我认为大多数编码人员会发现 drawgrid 解决方案更易于遵循/维护/修改/等。
    • 不幸的是,由于我的数据加载和内存管理的动态特性,无论我使用 ListView 还是 DrawGrid 都不是一个简单的解决方案(此外,作为自定义控件,DrawGrid 有额外的内部标准 ListView 没有的开销)。
    • @Remy:抱歉,我猜不出是什么数据/内存问题阻止了使用简单的绘图网格。听起来你需要做的就是浏览过滤的日志文件。我只会让 RecordContents() 寻找并读取感兴趣的记录,可能通过索引,而不是加载或管理任何东西。我也不知道你所说的“自定义”或“额外开销”是什么意思。对我来说,listview 就像一个 stringgrid,但是 drawgrid 可以让你避免开销 + 做 jit 数据获取。我用它来即时浏览 5000 万条记录的文件,完全没有加载/内存问题。示例代码对您没有什么作用?
    • 在处理多 GB 文件时,对文件的单个记录进行随机搜索和读取可能会非常慢,尤其是在搜索量很大的情况下。我的代码有一个复杂的缓冲系统,因此一次只能访问一部分文件数据并在屏幕上显示(我还必须考虑到多行文本跨越日志文件中的多条记录,所以缓冲代码需要注意将它们重新组合在一起)...
    【解决方案4】:

    你不应该使用ResetNode,因为这个方法调用InvalidateNode并再次初始化节点,导致与预期相反的效果。 我不知道是否可以在不实际删除节点的情况下诱导 VST 释放 NodeDataSize 中指定的内存大小。但是为什么不将 NodeDataSize 设置为指针大小(Delphi, VirtualStringTree - classes (objects) instead of records)并自己管理数据呢?只是一个想法......

    【讨论】:

    • 这就是我现在所做的。事实上,我目前使用的是 NodeDataSize=0,但应用程序仍然使用了 200+ MB 的内存。这是因为 TVirtualNode 本身的最小大小是 44 字节,所以乘以 500 万个节点是 (44 x 5000000) = 220000000 = 214843.75 Kb = 209.81 MB,这仅是 TVirtualNode 内存,不包括开销VCL 内存管理器在此基础上使用。
    • 我认为在 VST 中子节点可能看起来与它们的父节点完全相同,因此用户无法察觉它们是子节点。您可以添加五千个顶级节点,每个顶级节点都有一千个子节点。
    【解决方案5】:

    试试“删除儿童”。以下是此过程的评论内容:

    // Removes all children and their children from memory without changing the vsHasChildren style by default.
    

    从未使用过它,但是当我阅读它时,您可以在 OnCollapsed 事件中使用它来释放分配给刚刚变得不可见的节点的内存。然后在 OnExpading 中重新生成这些节点,这样用户就永远不会知道节点从内存中消失了。

    但我不能确定,我从来不需要这种行为。

    【讨论】:

    • DeleteChildren() 没有解决我的担忧。大多数节点将没有任何子节点开始。我希望增加对少数人的支持。我需要的是为滚动时变得不可见的顶级节点释放内存。
    • TVirtualTree 中的所有可见节点都需要一个 TVirtualNode,因为用于获取与节点相关的内容的所有事件(例如:CellText)都期望一个 PVirtualNode 作为识别节点的唯一方法。如果这对您来说是个问题,那么您可能想要查看 TVTNodeMemoryManager(并且可能将其破解为由内存映射文件支持)或按照 mghie 的建议返回您的初始 ListView 解决方案。考虑到您的特殊需求,总有“100% 自定义控制”解决方案!自己制作可能比破解替代品更快。
    • 虽然自定义控件会很好,但我没有那种空闲时间来开发一个。我会看看 TVTNodeMemoryManager,但我认为它不会有太大帮助,因为仍然必须分配所有物理 TVirtualNode 项才能使树运行。
    • @Remy or Else:我遇到过这个问题,而 TListView 仅在视觉上支持给定的字符串长度为 256。因此,在 VT GetText 过程之上需要 OwnerData VT String 组件。
    猜你喜欢
    • 1970-01-01
    • 2013-09-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-01-10
    • 2021-11-20
    相关资源
    最近更新 更多