tirsdag den 8. november 2011

cxGridExtentionU - En faktor 60 !

Det er vist ingen hemmelighed, at jeg er stor fan af Developer Express, og bruger deres komponenter alle de steder, jeg kan komme afsted med det. Enkelte steder er der nogle "uhensigtsmæssigheder", som kunne være gjort smartere/bedre/hurtigere, men det ER ikke mange steder. Og generelt er der så mange ting, vi får foræret, når vi bruger DevExpress, at det på ingen måde ødelægger "billedet".

Når man udvikler database-applikationer af alverdens slags, er der som regel altid brug for at præsentere data i grids. Det bliver pænest og mest overskueligt, når data-kolonnerne er spredt ud over hele grid'et - i stedet for at være klumpet sammen i venstre side. Og jeg vil også gerne have, at når jeg resize'r min form - og dermed også grid'et, skal kolonnerne "strækkes" med.

I slutningen af mit sidste indlæg afsluttede jeg med at fortælle om nogle performance-problemer i et TcxGrid, når der skal laves en ApplyBestFit på en kolonne. Med udgangspunkt i det givne dataset tog det næsten 14 sekunder for et TcxGrid at tilpasse kolonne-bredden til data. Personligt finder jeg det for uacceptabelt lang tid.

Som jeg skrev, har jeg en løsning på problemet. Den samlede tid kan bringes ned på omkring 250 ms for selv samme handling. Dette blogindlæg handler om den løsning.

Først skal jeg bruge nogle data, dem henter jeg fra "min" baseball database. Denne gang har jeg eksporteret to tabeller, BattingPost og Batting, for at vise et master/detail-forhold mellem to tabeller - samt at vise hvordan man udfører en ApplyBestFit på et detail grid.

Først skal jeg have hentet data ind. De første 5 felter i tekstfilen "BattingPost.txt" og de første 5 felter i "Batting.txt" danner datagrundlag for det følgende.

Igen her benytter jeg en TStringList til parsning. Jeg har tidligere beskrevet i detaljer hvordan jeg gør, så det vil jeg udelade denne gang. Jeg har taget en TcxGrid-komponent og sat to niveauer på med hver deres view. De to views har jeg bundet sammen i et master-detail forhold, hvor RecID er primær nøglen i master-dataset'et, og PlayerId er fremmed nøgle. En temmelig triviel opgave, som jeg ikke vil gå i detalje med her. I stedet vil jeg blot vise min applikation:


Som det fremgår er kolonne-bredden ikke tilpasset ikke tilpasset mit grid. Dette kan gøres med en ApplyBestFit på hver række. I mit eksempel vil jeg implementere den på GridResize-eventet, idet jeg hermed opnår en skalerbar applikation. Det gøres nemt og enkelt sådan her:

procedure TMainForm.cxGrid1Resize(Sender: TObject);
begin
  cxGrid1DBTableView1.ApplyBestFit;
end; 

Problemet er bare, at der går en evighed hver gang. Efter at have gravet lidt (lidt meget) i kildekoden til cxGrid har jeg lokaliseret problemet: Problemet er at TcxGrid måler bredden på indholdet i hver celle med en Canvas.TextWidth(). Hvis man kigger efter i TcxCustomGridTableItem.CalculateBestFitWidth 
vil man hurtigt opdage, at dette er en stærkt forsimplet udgave af virkeligheden, men det udstiller meget godt problemet.

Jeg har derfor skrevet en procedure, som gør det anderledes. Jeg finder den længste streng, måler længden på den - for så at lægge lidt "luft" til, så teksten ikke kommer til at stå helt klods op ad højre/venstre celle-kant. Som det ses i den efterfølgende procedure, har det været nødvendigt at indføre et par "hacker-kontroller", altså nogle nedarvninger af eksisterende datatyper, for at gøre protected properties tilgængelige.

type
  THackPainter = class(TcxCustomGridPainter);
  THackColumn = class(TcxGridColumn);

procedure FastColumnApplyBestFit(GridColumn: TcxGridColumn);
var
  i, FIndex: Integer;
  AValue: Variant;
  ARecord: TcxCustomGridRecord;
  AIsCalcByValue: Boolean;
  SaveValue: string;
begin
  AIsCalcByValue := (@GridColumn.OnGetDataText = nil) or (GridColumn.GetProperties.GetEditValueSource(False) = evsValue);
  FIndex := GridColumn.Index;
  SaveValue := GridColumn.Caption;

  for i := 0 to THackColumn(GridColumn).ViewData.RowCount - 1 do
  begin
    ARecord := GridColumn.GridView.ViewData.Records[i];

    if AIsCalcByValue then
      AValue := ARecord.Values[FIndex]
    else
      AValue := ARecord.DisplayTexts[FIndex];

    if Length(SaveValue) < Length(AValue) then
      SaveValue := AValue;
  end;

  GridColumn.Width := THackPainter(GridColumn.GridView.Painter).Canvas.TextWidth(SaveValue);
  if GridColumn.Options.Filtering then
    GridColumn.Width := GridColumn.Width + 25
  else
    GridColumn.Width := GridColumn.Width + 10;
end;

Så skal den bare kaldes, f.eks. med PlayerID-kolonnen:

FastColumnApplyBestFit(cxGrid1DBTableView1PlayerID);


Når jeg vil have kolonnerne spredt ud over hele grid'et til enhver tid, så gælder det også, hvis grid'et bliver mindre en det, som data fylder. Det betyder, at jeg ønsker at alle rækkerne skal tilpasse sig data på nær én, som jeg ønsker skal fylde "resten"... eller som er den kolonne, der skal "spises" af, når ikke der er plads til at vise alle data.

Det har jeg også skrevet en procedure der kan. Den benytter sig internt af FastColumnApplyBestFit. Implementeringen er triviel, så den vil jeg bringe her uden yderligere kommentarer.

procedure VirtualGridColumnApplyBestFit(const aView: TOriginalcxGridTableView; 
  const RestGridColumn: TOriginalcxGridColumn);
var
  i: Integer;
  Tmp: Integer;
begin
  if aView.VisibleColumnCount = 0 then
  begin
    aView.OptionsView.ColumnAutoWidth := True;
    Exit;
  end
  else
    aView.OptionsView.ColumnAutoWidth := False;

  Tmp := 0;

  for i := 0 to aView.VisibleColumnCount - 1 do
    if RestGridColumn <> aView.VisibleColumns[i] then
    begin
      FastColumnApplyBestFit(aView.VisibleColumns[i]);
      inc(Tmp, aView.VisibleColumns[i].Width);
    end;

  // Antager at grupperede kolonner altid er 17 pixels brede.
  inc(Tmp, aView.GroupedColumnCount * 17);

  if RestGridColumn <> nil then
  begin
    // Workarround for BUG:
    // aView.ViewInfo.ClientWidth bliver først opdateret når en Col 
    // ændre størrelse.
    RestGridColumn.Width := RestGridColumn.Width + 1;
    RestGridColumn.Width := aView.ViewInfo.ClientWidth - Tmp;
  end;
end; 

Nu ville det være rart, hvis cxGrid selv kaldte de nye og hurtigere metoder. Dette kan opnås ved hjælp af en nedarvning af TcxGridDBColumn og TcxGridColumn. Men her kommer så et smart lille trick...

I stedet for at oprette en ny klasse sådan her:

type
  TMyGridDBColumn = cxGridDBTableView.TcxGridDBColumn;
   
- så kalder jeg den nedarvede klasse det samme som originalen:

type
  TcxGridDBColumn = cxGridDBTableView.TcxGridDBColumn;

Det betyder nemlig, at jeg ikke skal lave casts frem og tilbage, når de skal benyttes. Og hvis man blot lægger den slags kode i en unit for sig selv er det til at styre.

På et tidspunkt risikerer jeg at få brug for "original-klasserne", så for at kunne kende forskel på "min" udgave af TcxGridDBColumn og den originale, laver jeg en kopi af alle de datatyper, som jeg senere vil nedarve.

type
  TOriginalcxGridTableView = cxGridTableView.TcxGridTableView;
  TOriginalcxGridDBTableView = cxGridDBTableView.TcxGridDBTableView;
  TOriginalcxGridColumn = cxGridTableView.TcxGridColumn;
  TOriginalcxGridDBColumn = cxGridDBTableView.TcxGridDBColumn;


Jeg viser kun her for TcxGridColumn i det koden for en TcxDBGridColumn vil være magen til:

type
  ...

  TcxGridColumn = class(TOriginalcxGridColumn)
  public
    procedure ApplyBestFit(ACheckSizingAbility: Boolean = False; 
      AFireEvents: Boolean = False); override;
  end;

...

implementation

...

procedure TcxGridColumn.ApplyBestFit(ACheckSizingAbility, AFireEvents: Boolean);
begin
  FastColumnApplyBestFit(Self);
end;

Der foruden har jeg tilføjet en metode ColumnsApplyBestFit på TcxGridDBTableView, som bare kalder  VirtualGridColumnApplyBestFit. Her lister jeg den komplette unit, som jeg har opbygget.

Hvis man ønsker at se hele det komplette eksempel med data, kan det findes her.

unit cxGridExtentionU;

interface

uses
  cxGrid, cxGridLevel, cxGridTableView, cxGridDBTableView, cxGridCustomTableView;

{$M+}

type
  TOriginalcxGridTableView = cxGridTableView.TcxGridTableView;
  TOriginalcxGridDBTableView = cxGridDBTableView.TcxGridDBTableView;
  TOriginalcxGridColumn = cxGridTableView.TcxGridColumn;
  TOriginalcxGridDBColumn = cxGridDBTableView.TcxGridDBColumn;

  TcxGridColumn = class(TOriginalcxGridColumn)
  public
    procedure ApplyBestFit(ACheckSizingAbility: Boolean = False; AFireEvents: 
     Boolean = False); override;
  end;

  TcxGridDBColumn = class(TOriginalcxGridDBColumn)
  public
    procedure ApplyBestFit(ACheckSizingAbility: Boolean = False; AFireEvents: 
      Boolean = False); override;
  end;

  TcxGridDBTableView = class(TOriginalcxGridDBTableView)
  public
    procedure ColumnsApplyBestFit(RestGridColumn: TOriginalcxGridColumn = nil); 
      overload;
  end;

procedure VirtualGridColumnApplyBestFit(
  const aView: TOriginalcxGridTableView; 
   const RestGridColumn: TOriginalcxGridColumn); overload;
procedure FastColumnApplyBestFit(GridColumn: TOriginalcxGridColumn);

implementation

uses
  cxDataUtils // evsValue
    , cxGridCustomView // TcxCustomGridPainter
    ;

{ TcxGridDBTableView }

procedure TcxGridDBTableView.
  ColumnsApplyBestFit(RestGridColumn: TOriginalcxGridColumn);
begin
  VirtualGridColumnApplyBestFit(Self, RestGridColumn);
end;

{ TcxGridColumn }

procedure TcxGridColumn.ApplyBestFit(ACheckSizingAbility, AFireEvents: Boolean);
begin
  FastColumnApplyBestFit(Self);
end;

{ TcxGridDBColumn }

procedure TcxGridDBColumn.ApplyBestFit(ACheckSizingAbility, AFireEvents: Boolean);
begin
  FastColumnApplyBestFit(Self);
end;

type
  THackPainter = class(TcxCustomGridPainter);
  THackColumn = class(TOriginalcxGridColumn);
  THackTableView = class(TOriginalcxGridTableView);

procedure FastColumnApplyBestFit(GridColumn: TOriginalcxGridColumn);
var
  i, FIndex: Integer;
  AValue: Variant;
  ARecord: TcxCustomGridRecord;
  AIsCalcByValue: Boolean;
  SaveValue: string;
begin
  AIsCalcByValue := (@GridColumn.OnGetDataText = nil) or 
    (GridColumn.GetProperties.GetEditValueSource(False) = evsValue);
  FIndex := GridColumn.Index;
  SaveValue := GridColumn.Caption;

  for i := 0 to THackColumn(GridColumn).ViewData.RowCount - 1 do
  begin
    ARecord := GridColumn.GridView.ViewData.Records[i];

    if AIsCalcByValue then
      AValue := ARecord.Values[FIndex]
    else
      AValue := ARecord.DisplayTexts[FIndex];

    if Length(SaveValue) < Length(AValue) then
      SaveValue := AValue;
  end;

  GridColumn.Width := 
    THackPainter(GridColumn.GridView.Painter).Canvas.TextWidth(SaveValue);
  if GridColumn.Options.Filtering then
    GridColumn.Width := GridColumn.Width + 25
  else
    GridColumn.Width := GridColumn.Width + 10;
end;

procedure VirtualGridColumnApplyBestFit
  (const aView: TOriginalcxGridTableView; 
     const RestGridColumn: TOriginalcxGridColumn);
var
  i: Integer;
  Tmp: Integer;
const
  GroupByButtonWidth = 17;
begin
  if aView.VisibleColumnCount = 0 then
  begin
    aView.OptionsView.ColumnAutoWidth := True;
    Exit;
  end
  else
    aView.OptionsView.ColumnAutoWidth := False;

  Tmp := 0;

  for i := 0 to aView.VisibleColumnCount - 1 do
    if RestGridColumn <> aView.VisibleColumns[i] then
    begin
      FastColumnApplyBestFit(aView.VisibleColumns[i]);
      inc(Tmp, aView.VisibleColumns[i].Width);
    end;

  // Antager at grupperede kolonner altid er 17 pixels brede.
  inc(Tmp, aView.GroupedColumnCount * GroupByButtonWidth);

  if RestGridColumn <> nil then
  begin
    // Workarround for BUG:
   // aView.ViewInfo.ClientWidth bliver først opdateret når en Col ændre størrelse.

    RestGridColumn.Width := RestGridColumn.Width + 1;
    RestGridColumn.Width := aView.ViewInfo.ClientWidth - Tmp - 
      (TcxGridLevel(THackTableView(aView).Level).Count * GroupByButtonWidth);
  end;
end;

end.

Når koden skal anvendes i formen, gøres det således:
Tilføj cxGridExtentionU i den øverste uses sektion, men EFTER cxGrid. Så sker trylle kunsten af sig selv, og et kald til cxGrid1DBTableView1.ApplyBestFit som før tog 14 sekunder, tager nu 250 ms UDEN at man skal lave andre modifikationer i koden.

Som jeg skrev før, ønsker man som oftest at en kolonne skal udfylde resten af sit grid, efter de resterende kolonner har tilpasset sig efter data. Dette kan passende gøres på GridResize-eventet:

procedure TMainForm.cxGrid1Resize(Sender: TObject);
begin
 cxGrid1DBTableView1.ColumnsApplyBestFit(cxGrid1DBTableView1PlayerID); 
end;


Til slut er der blot at få vores detail view tilpasset. "Problemet" med et detail view, er at det i sagens natur kun findes på runtime. At man kan oprette og tilpasse et detail view på design time, er kun af hensyn til den generelle indstilling/opsætning. På runtime bliver detail views autogenereret, bl.a. fordi der jo kan være flere af dem. Af samme årsag kan vi ikke sådan lige nemt tilgå det/dem, med det navn det har på designtime.

Detail viewet skal i stedet "fanges", når man pakker det ud. Det sker i eventet
cxGrid1DBTableView1DataControllerDetailExpanded. Klik på cxGrid1DBTableView1 og find eventet via  DataController-->OnDetailExpanded i event-fanen i object inspectoren.

Koden ses her:

procedure TMainForm.cxGrid1DBTableView1DataControllerDetailExpanded
  (ADataController: TcxCustomDataController; ARecordIndex: Integer);
var
  DetailGridView: TcxGridDBTableView;
  DetailDataController: TcxGridDBDataController;
begin
  DetailDataController := ADataController.GetDetailDataController(ARecordIndex, 0
as TcxGridDBDataController;
  DetailGridView := DetailDataController.GetOwner as TcxGridDBTableView;
  VirtualGridColumnApplyBestFit(DetailGridView, 
   DetailGridView.GetColumnByFieldName('PlayerID'));
end;




I det ovenstående har jeg dels vist, hvordan jeg kunne øge hastistigheden på en ApplyBestfit med næsten 60 gange, ved hjælp af et par enkelte tiltag. Endivdere har jeg vist, hvordan man lavet et cxGrid skalerbart. Slutteligt er der blot tilbage at vise et billede af applikationen:


Og så skal jeg da lige minde om, at den komplette kode med data kan hentes her.

Ingen kommentarer:

Send en kommentar