在DBGrid中移动列似乎会移动附加的DataSet字段

12
上周我观察到了一些意外的情况,我将在下面描述。我很好奇为什么会发生这种情况。这是TDataSet类内部的原因,还是TDBGrid的副作用,或者其他什么原因呢?
一个打开的ClientDataSet中字段的顺序发生了改变。具体来说,我通过在FieldDefs中定义结构后调用CreateDatatSet在代码中创建了一个ClientDataSet。这个ClientDataSet结构中的第一个字段是一个名为StartOfWeek的日期字段。只有片刻之后,我编写的代码假定StartOfWeek字段在零位置(ClientDataSet.Fields[0]),但失败了,因为StartOfWeek字段不再是ClientDataSet中的第一个字段。
经过一些调查,我了解到,可能每个ClientDataSet中的字段在某个时刻都可能出现在与创建ClientDataSet时的原始结构不同的位置。我不知道这种情况可能发生,Google搜索也没有提到这种效果。
发生的事情并不神奇。字段不会自己改变位置,它们也不会根据我的代码进行任何更改。导致字段在ClientDataSet中物理上看起来改变位置的原因是用户更改了与ClientDataSet相关联的DbGrid中列的顺序(当然是通过DataSource组件)。我在Delphi 7、Delphi 2007和Delphi 2010中复制了这种效果。
我创建了一个非常简单的Delphi应用程序来演示这种效果。它由一个带有一个DBGrid、一个DataSource、两个ClientDataSets和两个按钮的单个表单组成。这个表单的OnCreate事件处理程序如下所示。
procedure TForm1.FormCreate(Sender: TObject);
begin
  with ClientDataSet1.FieldDefs do
  begin
    Clear;
    Add('StartOfWeek', ftDate);
    Add('Label', ftString, 30);
    Add('Count', ftInteger);
    Add('Active', ftBoolean);
  end;
  ClientDataSet1.CreateDataSet;
end;

Button1标签为“显示ClientDataSet结构”,其包含以下OnClick事件处理程序。

procedure TForm1.Button1Click(Sender: TObject);
var
  sl: TStringList;
  i: Integer;
begin
  sl := TStringList.Create;
  try
    sl.Add('The Structure of ' + ClientDataSet1.Name);
    sl.Add('- - - - - - - - - - - - - - - - - ');
    for i := 0 to ClientDataSet1.FieldCount - 1 do
      sl.Add(ClientDataSet1.Fields[i].FieldName);
    ShowMessage(sl.Text);
  finally
    sl.Free;
  end;
end;

为了展示移动字段效果,请运行此应用程序并单击标记为“显示ClientDataSet结构”的按钮。您应该会看到类似于此处所示的内容:
The Structure of ClientDataSet1
- - - - - - - - - - - - - - - - - 
StartOfWeek
Label
Count
Active

接下来,拖动DBGrid的列以重新排列字段的显示顺序。再次单击“显示ClientDataSet结构”按钮。这一次,您将看到类似于这里显示的内容:

The Structure of ClientDataSet1
- - - - - - - - - - - - - - - - - 
Label
StartOfWeek
Active
Count

这个例子值得注意的是,DBGrid的列正在移动,但显然对ClientDataSet中字段的位置产生了影响,因此在某一点上位于ClientDataSet.Field[0]位置的字段可能在几秒钟后并不在那里。不幸的是,这不是明显的ClientDataSet问题。我使用基于BDE的TTables和基于ADO的AdoTables进行了相同的测试,并获得了相同的效果。
如果您从未需要引用在DBGrid中显示的ClientDataSet中的字段,则无需担心此效果。对于其他人,我可以想到几种解决方案。
最简单但不一定是可取的避免此问题的方法是防止用户在DBGrid中重新排序字段。这可以通过从DBGrid的Options属性中删除dgResizeColumn标志来实现。虽然这种方法有效,但从用户的角度来看,它消除了一个潜在有价值的显示选项。此外,删除此标志不仅限制了列重排序,还防止了列调整大小。(要了解如何在不删除列调整大小选项的情况下限制列重排序,请参见http://delphi.about.com/od/adptips2005/a/bltip0105_2.htm。)
第二种解决方法是避免根据字面位置引用数据集的字段(因为这是问题的本质)。换句话说,如果您需要引用Count字段,则不要使用DataSet.Fields[2]。只要您知道字段的名称,就可以使用类似于DataSet.FieldByName('Count')的东西。
然而,使用FieldByName存在一个相当大的缺点。具体来说,该方法通过迭代DataSet的Fields属性并根据字段名称查找匹配项来识别字段。由于每次调用FieldByName都会执行此操作,因此在需要多次引用字段(例如在导航大型DataSet的循环中)的情况下应避免使用此方法。如果确实需要多次引用字段(且数量较大),请考虑使用以下代码片段:
var
  CountField: TIntegerField;
  Sum: Integer;
begin
  Sum := 0;
  CountField := TIntegerField(ClientDataSet1.FieldByName('Count'));
  ClientDataSet1.DisableControls;  //assuming we're attached to a DBGrid
  try
    ClientDataSet1.First;
    while not ClientDataSet1.EOF do
    begin
      Sum := Sum + CountField.AsInteger;
      ClientDataSet1.Next;
    end;
  finally
    ClientDataSet1.EnableControls;
  end;

有第三种解决方案,但只适用于DataSet是ClientDataSet的情况,就像我的原始示例中的那个一样。在这些情况下,您可以创建原始ClientDataSet的克隆版本,它将具有原始结构。因此,无论用户对显示ClientDataSets数据的DBGrid做了什么,创建在零位置的任何字段都将保留在该位置。
以下代码演示了这一点,它与标记为“显示克隆的ClientDataSet结构”的按钮的OnClick事件处理程序相关联。
procedure TForm1.Button2Click(Sender: TObject);
var
  sl: TStringList;
  i: Integer;
  CloneClientDataSet: TClientDataSet;
begin
  CloneClientDataSet := TClientDataSet.Create(nil);
  try
    CloneClientDataSet.CloneCursor(ClientDataSet1, True);
    sl := TStringList.Create;
    try
      sl.Add('The Structure of ' + CloneClientDataSet.Name);
      sl.Add('- - - - - - - - - - - - - - - - - ');
      for i := 0 to CloneClientDataSet.FieldCount - 1 do
        sl.Add(CloneClientDataSet.Fields[i].FieldName);
      ShowMessage(sl.Text);
    finally
      sl.Free;
    end;
  finally
    CloneClientDataSet.Free;
  end;
end;

如果您运行此项目并点击标有“显示克隆的 ClientDataSet 结构”的按钮,您将始终获得 ClientDataSet 的真实结构,如下所示。
The Structure of ClientDataSet1
- - - - - - - - - - - - - - - - - 
StartOfWeek
Label
Count
Active

附加说明:

需要注意的是,底层数据的实际结构不受影响。具体而言,如果在DBGrid中更改列的顺序后,调用ClientDataSet的SaveToFile方法,则保存的结构是原始(真实内部)结构。同样,如果将一个ClientDataSet的Data属性复制到另一个ClientDataSet中,则目标ClientDataSet也显示真实结构(这类似于源ClientDataSet克隆时观察到的效果)。

同样地,与其他已测试的数据集(包括TTable和AdoTable)绑定的DBGrid的列顺序更改实际上也不会影响底层表的结构。例如,从Delphi中附带的customer.db示例Paradox表中显示数据的TTable实际上不会更改该表的结构(你也不应该期望它会更改)。

我们可以得出的结论是,数据集本身的内部结构保持完整。因此,我必须假设有一个第二个表示数据集结构的副本。并且,它必须要么与数据集关联(这似乎过于复杂,因为并非所有使用数据集的情况都需要这种结构),要么与DBGrid关联(这更合理,因为DBGrid正在使用此功能,但不支持观察到TField重新排序似乎仍然与数据集本身相关),或者是其他东西。

另一个选择是,该效果与TGridDataLink相关,它是为多行感知控件(如DBGrids)提供数据感知能力的类。但是,我倾向于拒绝这种解释,因为此类与网格而不是数据集相关联,同样因为该效果似乎仍然与数据集类本身相关。

这让我回到最初的问题。这个效果是TDataSet类内部的东西,还是TDBGrid的产物,或者是其他什么东西?

请允许我在这里强调一些事情,我已经在下面的评论中添加了。最重要的是,我的帖子旨在让开发人员意识到,当他们使用可以更改列顺序的DBGrid时,他们的TFields顺序也可能会改变。这种现象可能会引入间歇性和严重的错误,这些错误可能很难识别和修复。而且,不,我不认为这是Delphi的错误。我怀疑一切都按照设计工作。只是我们中的许多人不知道这种行为正在发生。现在我们知道了。


3
非常信息丰富,但这里是否有一个问题? - Mason Wheeler
谢谢@Cary,我之前完全不知道这个问题,而且我经常使用DataSet.Field[x]的结构。我认为你应该将其报告给Embarcadero网站作为一个bug。 - Wodzu
有一个问题,它出现在第二句话中:“这是TDataSet类内部的某个东西,还是TDBGrid的产物,或者其他什么?”我花了一些时间(大约一个小时)搜索了TCustomGrid和TDataSet的源代码,但没有找到这个问题发生的地方。更重要的是,这就是为什么我的帖子如此之长,我至少想让Delphi开发人员意识到这种有趣的行为。对于任何使用DBGrid或其他类似网格并且会导致TFields顺序改变的开发人员来说,这可能是一个间歇性且难以找到的错误的来源。 - Cary Jensen
我不认为这是一个错误。它可能是意外的,因为它是一个鲜为人知的工件,但它看起来并不像一个错误。 - Cary Jensen
是的,你说得对。我已经咨询过了,似乎这是 Delphi 1 设计时就已经完成的。 - Wodzu
在寻找禁用列重新排序的方法时,我偶然发现了这个问题。最终从问题中学到的比从答案中学到的更多。是否有一个适用于长问题的SO徽章?也许有一个“马拉松打字员”的徽章? - Kenneth Cochran
3个回答

3
显然这种行为是有意设计的。实际上,它与dbgrid无关。它只是列设置字段索引的副作用。例如,以下语句,
ClientDataSet1.Fields [0] .Index:= 1;
将导致“显示ClientDataSet结构”按钮的输出相应更改,无论是否存在网格。TField.Index的文档说明如下:
“通过更改Index的值来更改数据集中字段的位置顺序。更改Index值会影响在数据网格中显示字段的顺序,但不会影响字段在物理数据库表中的位置。”
应该得出相反的结论,即在网格中更改字段顺序应该导致字段索引的更改。
造成这种情况的代码位于TColumn.SetIndex中。TCustomDBGrid.ColumnMoved为移动的列设置了新索引,而TColumn.SetIndex为该列的字段设置了新索引。
procedure TColumn.SetIndex(Value: Integer);
[...]
        if (Col <> nil) then
        begin
          Fld := Col.Field;
          if Assigned(Fld) then
            Field.Index := Fld.Index;
        end;
[...]

1

卡里,我想我已经找到了解决这个问题的方法。我们需要使用 Recordset COM 对象的内部 Fields 属性,而不是使用 VCL 包装器 Fields。

以下是引用它的方式:

qry.Recordset.Fields.Item[0].Value

这些字段不受您之前描述的行为影响。因此,我们仍然可以按其索引引用这些字段。

测试一下并告诉我结果。对我来说有效。

编辑:

当然,它只适用于ADO组件,而不适用于TClientDataSet...

编辑2:

Cary,我不知道这是否是您问题的答案,但我一直在Embarcadero论坛上推动人们,并且Wayne Niddery给了我关于所有这些字段移动的相当详细的答案。

长话短说:如果您在TDBGrid中显式定义列,则字段索引不会移动!现在有点更有意义了,是吧?

在此处阅读完整线程: https://forums.embarcadero.com/post!reply.jspa?messageID=197287


你的解决方案很好,因为你仍然可以可靠地通过底层DataSet结构中已知的物理位置引用特定的TFields。但是,正如你所指出的那样,这仅适用于ADO DataSet。然而,你让我重新搜索了一次TFields类的相应成员。这个类有一个FieldByNumber方法,它与相关网格中列的顺序无关。FieldByNumber就像你的Item属性。我已经添加了一个答案来解释FieldByNumber比这里提供的更详细。 - Cary Jensen
阅读行间,我们可以得出结论:DBGrid是这种效果的源头,以某种方式与DataSet的TFields交互。我将基于这个结论接受你的答案。但我认为我们仍然可以深入挖掘。我想更多地了解负责这一机制的精确机理,因为它可能是其他鲜为人知的副作用的源头。谢谢,Dimitrij。 - Cary Jensen

1

Wodzu发布了一个特定于ADO DataSet的重新排序字段问题的解决方案,但他引导我找到了一种类似的解决方案,并且适用于所有DataSets(它是否在所有DataSets中正确实现是另一个问题)。请注意,无论是这个答案还是Wodzu的答案,都不是原始问题的答案。相反,它是对所述问题的解决方案,而问题涉及此工件的起源。

Wodzu的解决方案引导我找到的解决方案是FieldByNumber,它是Fields属性的一个方法。使用FieldByNumber有两个有趣的方面。首先,您必须使用DataSet的Fields属性来限定其引用。其次,与采用从零开始的索引器的Fields数组不同,FieldByNumber是一个方法,它采用一个基于1的参数来指示您要引用的TField的位置。

以下是我在原始问题中发布的Button1事件处理程序的更新版本。此版本使用FieldByNumber。

procedure TForm1.Button1Click(Sender: TObject);
var
  sl: TStringList;
  i: Integer;
begin
  sl := TStringList.Create;
  try
    sl.Add('The Structure of ' + ClientDataSet1.Name +
      ' using FieldByNumber');
    sl.Add('- - - - - - - - - - - - - - - - - ');
    for i := 0 to ClientDataSet1.FieldCount - 1 do
      sl.Add(ClientDataSet1.Fields.FieldByNumber(i + 1).FieldName);
    ShowMessage(sl.Text);
  finally
    sl.Free;
  end;
end;

对于示例项目,无论相关DBGrid中的列的方向如何,此代码都会产生以下输出:
The Structure of ClientDataSet1 using FieldByNumber
- - - - - - - - - - - - - - - - - 
StartOfWeek
Label
Count
Active

重申一遍,需要引用Fields才能限定FieldByNumber所需的基础TField。此外,该方法的参数必须在1到DataSet.FieldCount范围内。因此,要引用DataSet中的第一个字段,您可以使用以下代码:
ClientDataSet1.Fields.FieldByNumber(1)

与Fields数组类似,FieldByNumber返回一个TField引用。因此,如果您想引用特定于特定TField类的方法,则必须将返回值转换为适当的类。例如,要将TBlobField的内容保存到文件中,您可能需要执行以下代码:
TBlobField(MyDataSet.Fields.FieldByNumber(6)).SaveToFile('c:\mypic.jpg');

请注意,我并不建议您在使用整数字面量引用数据集中的TFields。个人而言,使用通过一次性调用FieldByName初始化的TField变量更易读,并且对表结构的物理顺序更改免疫(尽管无法免疫字段名称的更改!)。
然而,如果您有与可以重新排序列的DBGrid关联的数据集,并且使用整数字面量作为字段数组的索引器引用这些数据集的字段,则可能要考虑将代码转换为使用DataSet.Fields.FieldByName方法。

2
这个问题赢得了 Stack Overflow 上“最长的 Delphi 问题”的称号。 :-) - Warren P
@Cary,请看一下我的更新答案。这个“问题”还有另一个解决方案。 - Wodzu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接