К вопросу о реализации VCL

© 2001 Евгений Каснерик

За те шесть лет, что существует Delphi и ее VCL, еще не было серьезных нареканий на качество проектирования последней. Однако иногда при самых документированных подходах к использованию поведение приложения оказывается несколько неожиданным. В настоящей статье описаны обнаруженные на практике случаи неоднозначной реализации отдельных мест внутри VCL.

При разработке приложений каждый вправе решить – будет ли он вносить эти изменения в исходные тексты VCL при выходе последующих версий Delphi либо будет просто обходить сомнительные места на уровне собственного исходного кода; в любом случае, описание потенциально опасных мест представляется полезным для всех пользователей библиотеки.

Все рассматриваемые случаи относятся к VCL в Delphi 6, однако описанные изменения применимы и к предыдущим версиям.

Модуль Controls

Метод TControl.ChangeScale

Изменение размеров и положения элементов управления при изменении числа пикселов на дюйм (далее – PPI) является нормой для современного интерфейса. Но последовательное добавление новых возможностей в VCL упустило кое-что из виду. Так, совершенно естественно не масштабируется размер шрифта в элементах, у которых свойство ParentFont равно True – все необходимые изменения выполняются у родителя. Но с появлением свойства DesktopFont не было учтено то обстоятельство, что при его значении True также не следует масштабировать размер шрифта – все уже сделано на более ранних стадиях. В принципе, наступить на эти грабли было бы достаточно сложно – мало кто из разработчиков использует привязку к шрифтам рабочего стола, но VCL содержит TStatusBar, у которого по умолчанию установлено это свойство. В итоге при масштабированиях сначала у элемента управления устанавливается увеличенный на уровне ОС размер шрифта, а затем еще и VCL вносит свою лепту, изменяя размер еще раз.

Увидеть это довольно легко: поставьте на форму TStatusBar, задайте в нем панель с некоторым текстом, откомпилируйте приложение и запустите его в при различных PPI. Для сравнения размера шрифта рядом со строкой статуса можно поместить обычную строку ввода.

Чтобы устранить описанную особенность, достаточно изменить следующую строку в реализации:

if not ParentFont and (sfFont in Flags) then
  Font.Size := MulDiv(Font.Size, M, D);

на

if not ParentFont and not DesktopFont and (sfFont in Flags) then
  Font.Size := MulDiv(Font.Size, M, D);

Т.е. мы обеспечили однообразное поведение в случае использования шрифта родительского элемента управления и рабочего стола.

Метод TControl.SetZOrderPosition

Внутри реализации содержится обязательное требование к наличию родительской формы, у которой при наличии в ее же статусе флага палитры (csPalette) будет нотифицировано изменение в палитре.

ParentForm := ValidParentForm(Self);
if csPalette in ParentForm.ControlState then
  TControl(ParentForm).PaletteChanged(True);

Проблема состоит в том, что исключение, возникающее в отсутствие родительской формы, не совсем уместно: если нет формы, то нет нужды проверять статус csPalette и куда-либо нотифицировать. Поэтому логичнее использовать реализацию, не требующую больше, чем нужно для решения задачи:

ParentForm := GetParentForm(Self);
if Assigned(ParentForm) and (csPalette in ParentForm.ControlState) then
  TControl(ParentForm).PaletteChanged(True);

В итоге становится возможным изменение взаимного расположения элементов управления и в отсутствие родительской формы; на практике необходимость такой операции возникает не часто, т.к. при загрузке форм из файлов/ресурсов родительская форма создается первой.

Модуль StdCtrls

Метод TCustomCombo.CNCommand

Метод содержит в обработке сообщений следующий фрагмент кода:

procedure TCustomCombo.CNCommand(var Message: TWMCommand);
begin
  case Message.NotifyCode of
    CBN_DBLCLK:
      DblClick;
{...}

Такая реализация своеобразно проявляет себя при стиле выпадающего списка csSimple – метод DblClick вызывается дважды: сначала в WMMouseDown, а затем в СNCommand. Отсутствие эффекта на других стилях объясняется просто: по выпадающей части списка невозможно дважды щелкнуть мышью – список сворачивается после первого же нажатия.

Убедиться в этом можно так: установить на форму TComboBox со стилем csSimple, в обработчике события OnDblClick наращивать значение счетчика. При двойных щелчках мышью по “выпавшей навсегда” части получим двойное увеличение значения счетчика.

Для устранения эффекта достаточно исключить вызов DblClick:

procedure TCustomCombo.CNCommand(var Message: TWMCommand);
begin
  case Message.NotifyCode of
  // CBN_DBLCLK:
  // DblClick;
{...}

Метод TCustomCombo.Focused

Неправильно определяется состояние наличия фокуса на элементе управления для стиля csDropDownList.

function TCustomCombo.Focused: Boolean;
var
  FocusedWnd: HWND;
begin
  Result := False;
  if HandleAllocated then
    begin
      FocusedWnd := GetFocus;
      Result := (FocusedWnd = FEditHandle) or (FocusedWnd = FListHandle);
    end;
end;

На первый взгляд, все правильно, но не учтено то обстоятельство, что при стиле csDropDownList поля FEditHandle и FListHandle равны нулю (см. метод TCustomCombo.CreateWnd) и, следовательно, критерий наличия фокуса должен быть таким же, как у обычного элемента управления. Кстати сказать, перекрытие метода было вызвано именно спецификой ComboBox’а, состоящего из двух частей – поля редактирования и списка значений; при csDropDownList поля редактирования нет и handle единственный.

Измененная реализация может быть такой:

function TCustomCombo.Focused: Boolean;
var
  FocusedWnd: HWND;
begin
  Result := inherited Focused;
  if not Result and HandleAllocated then {т.е. GetFocus <> Handle }
    begin
      FocusedWnd := GetFocus;
      Result := (FocusedWnd = FEditHandle) or (FocusedWnd = FListHandle);
    end;
end;

Модуль ImgList

В uses, расположенный в implementation зачем-то включен модуль Forms, хотя из него ничего не используется – зато к консольному приложению, работающему со списками изображений (редко, но бывает необходимость написать и такие), будет пристегнута и поддержка форм на 150-200К. Можно не обращать на это внимания, а можно поправить список используемых модулей.

Модуль Grids

Метод TCustomGrid.WMCommand

В обработчике поступающих сообщений, вероятно, пропущен вызов метода родительского класса:

procedure TCustomGrid.WMCommand(var Message: TWMCommand);
begin
  with Message do
    begin
      if (FInplaceEdit <> nil) and (Ctl = FInplaceEdit.Handle) then
        case NotifyCode of
          EN_CHANGE: UpdateText;
        end;
    end;
end;

В приложениях это приводит к тому, что к элементам управления, для которых Grid является родительским, не возвращается их собственная нотификация. Попробуйте установить для ComboBox родителем Grid, а затем заставить выпасть список значений – не получится ни с клавиатуры, ни с кнопки, а поставьте в исходном тексте первым вызов inherited метода – и поведение станет ожидаемым.

Эта особенность осталась незамеченной в силу того, что в дизайнере Grid не может стать чьим-то родителем, да и программно такие вещи делаются редко – например, при расширении поведения, реализуемого в TInplaceEdit.

Метод TCustomGrid.MoveColumn

Реализация метода при сравнении с, казалось бы, симметричным MoveRow выглядит намного более развернутой – почему-то при перемещении колонок разработчики VCL сочли необходимым выполнить обновление изображения таблицы.

Чем провинились строки, что их перемещение не выполнили таким же образом – сказать сложно. Наверное, здесь снова торчат уши DB-функциональности: порядок отображения полей, как правило, можно изменить перетаскиванием, а отдельные записи менять местами таким образом не принято. Впрочем, это только гипотеза.

Но не правы те, кто считает, что содержимое будет всегда корректно обновляться: при TopRow > 0 обновление будет частичным, т.к. область, подлежащая обновлению, определена так:

procedure TCustomGrid.MoveColumn(FromIndex, ToIndex: Longint);
var
  Rect: TGridRect;
begin
  {...}
  Rect.Top := 0;
  Rect.Bottom := VisibleRowCount;
  if FromIndex < ToIndex then
    begin
      Rect.Left := FromIndex;
      Rect.Right := ToIndex;
    end
    else
    begin
      Rect.Left := ToIndex;
      Rect.Right := FromIndex;
    end;
  InvalidateRect(Rect);

В то время, как на самом деле все должно быть несколько иначе:

if FromIndex < ToIndex then
  begin
    Rect.Left := FromIndex;
    Rect.Right := ToIndex;
  end
  else
  begin
    Rect.Left := ToIndex;
    Rect.Right := FromIndex;
  end;

if FixedRows > 0 then
  begin
  {
Обновляем только фиксированные ячейки }
    Rect.Top := 0;
    Rect.Bottom := FixedRows - 1;
    InvalidateRect(Rect);
  end;
  { Обновляем не фиксированные ячейки }
  Rect.Top := TopRow;
  Rect.Bottom := Rect.Top + VisibleRowCount;

  InvalidateRect(Rect);

Наверное, с точки зрения проектирования самым правильным было вообще не обновлять содержимое таблицы при любых перемещениях, возложив эту обязанность на наследников, которые определили бы наиболее адекватную область изменения (тот же StringGrid перекрывает ColumnMoved и RowMoved, полностью перерисовывая свое изображение – сомнительно по оптимальности, зато свое!) но в итоге имеем то, что имеем.

Модуль ComCtrls

Метод TCustomUpDown.SetAssociate

При установке ассоциации между экземпляром UpDown и каким-либо другим элементом управления производится такая проверка:

  if {...}
    not (Value is TCustomTreeView) and not (Value is TCustomListView) and
    not IsClass(Value.ClassType, 'TDBEdit') and
    not IsClass(Value.ClassType, 'TDBMemo') then {...}

Таким образом, при использовании TUpDown к телу программы будет прилинкован и код классов TCustomTreeView и TCustomListView, даже при отсутствии реального использования их экземпляров (точнее, не весь код, но очень массивная его часть, вытаскиваемая по виртуальным методам и RTTI). В то же время проверка DB-элементов управления выполняется не по is, а с использованием строкового имени класса, что избавляет от необходимости подключать модуль DBCtrls. Понятно, что двигало разработчиками стремление не подключать посторонний модуль (что особенно актуально в personal-версии Delphi, не содержащей средств разработки баз данных). Однако нет никаких видимых причин, которые препятствовали бы проверке типа объекта по имени во всех случаях – хотя бы из соображений единообразия в подходах. Например, так:

  if {...}
    not IsClass(Value.ClassType, 'TCustomTreeView') and
    not IsClass(Value.ClassType, 'TCustomListView') and
    not IsClass(Value.ClassType, 'TDBEdit') and
    not IsClass(Value.ClassType, 'TDBMemo') then {...}

В проверке типа не по RTTI, а по имени класса единственная неоднозначность состоит в возможности наличия класса, имя которого будет совпадать со сравниваемым, но который при этом не будет таковым. Впрочем, документация на VCL не содержит и намека на то, что ассоциация между TUpDown и элементом управления не будет установлена, если один из родительских классов этого элемента имеет имя TDBEdit или TDBMemo. Условие довольно дикое, но это то, что имеет место в современной VCL.

Кроме потенциального включения неиспользуемого кода, эта особенность реализации VCL никак не сказывается на работе приложений.

Метод TToolBar.ChangeScale

В VCL его переопределили таким образом, что панель инструментов не масштабировалась при изменениях значения PPI. И это верно, т.к. размер кнопок панели не зависит от этого значения. С одним “но”: если панель инструментов расположена не каноническим способом (прижатой к какому-либо краю окна при помощи свойства Align), то масштабировать ее свойства Left и Top все же нужно (если вы уверены, что такие панели управления вам не понадобятся, оставшуюся часть раздела можно пропустить).

Чтобы увидеть, как это проявляется, поставьте на форму панель инструментов с несколькими кнопками, установите свойство Align в значение alNone, и вытяните ее куда-нибудь на середину формы. Рядом поставьте для ориентира какой-нибудь TEdit. Скомпилируйте проект, а затем запустите его при разных значениях PPI (например, изменяя режим Large Fonts). Вопреки ожиданиям, при значениях PPI, отличных от того, при котором была сохранена форма, взаимное расположение панели инструментов и строки ввода будет различаться.

Представляется логичным следующий вывод: размер панели и высоту шрифта, возможно, масштабировать и не стоит, а координаты все же придется. Таким образом, измененная реализация ChangeScale будет аналогична реализации в TControl за исключением масштабирования размера и высоты шрифта:

procedure TToolBar.ChangeScale(M, D: Integer);

begin
  { Scaling isn't a standard behavior for toolbars. We prevent scaling from
  occurring here. }

  { Размер и шрифт, может, менять и не нужно, а позицию в окне - очень даже }
  if M <> D then
    begin
      if csLoading in ComponentState then
        Flags := ScalingFlags else
        Flags := [sfLeft, sfTop, sfWidth, sfHeight, sfFont];
      if sfLeft in Flags then
        X := MulDiv(Left, M, D) else
        X := Left;
      if sfTop in Flags then
        Y := MulDiv(Top, M, D) else
        Y := Top;

      SetBounds(X, Y, Width, Height);
    end;
  ScalingFlags := [];
end;

Модуль Forms

Метод TCustomForm.SetPosition

В исходной реализации при любом изменении свойства Position происходит вызов RecreateWnd, однако реально, если новое значение свойства poDesigned – пересоздание не требуется.

Работа с Actions

Когда приложение входит в состояние Idle, в числе прочих действий производится обновление состояния Actions. Однако вызывает сомнение целесообразность пропуска тех Actions, у которых свойство Visible равно False. С одной стороны, если нет никого, кто показывал бы такой Action, то незачем и занимать время, обновляя его состояние. Но с другой стороны, изменять состояние такого Action придется только явно, не полагаясь на то, что его попросят обновиться.

Учитывая то, что невидимых Action’ов сравнительно немного (по сравнению с видимыми), можно изменить методы TForm.UpdateActions так, чтобы обновление состояния выполнялось во всех случаях, независимо от состояния видимости. Впрочем, эта рекомендация все же является очень дискуссионной.

Модуль Printers

Содержит в себе курьезное объявление TPrinterCanvas, в котором все члены – published:

TPrinterCanvas = class(TCanvas)
  // в оригинале забыли, что в классе с RTTI по умолчанию все published
  // public – должно было бы быть...
  Printer: TPrinter;
  constructor Create(APrinter: TPrinter);
  procedure CreateHandle; override;
  procedure Changing; override;
  procedure UpdateFont;
end;

При обработке отмены задания в AbortProc используется вызов метод Application.ProcessMessages из модуля Forms – это единственное его использование. Т.е. приложение, работающее с принтером через VCL, потащит за собой поддержку форм вне зависимости от того, является оно консольным или нет. Правильнее было бы определить глобальный обработчик события OnProcessMessages, значение которого устанавливалось бы в Forms или даже в Controls, где и создается экземпляр TApplication.

Copyright© 2001 Евгений Каснерик  Специально для Delphi Plus

2011123456789101112
2010123456789101112
2009123456789101112
2008123456789101112
2007123456789101112
2006123456789101112
2005123456789101112
2004123456789101112
2003123456789101112
2002123456789101112
2001123456789101112
2000123456789101112
1999123456789101112

Последние статьи
Литература