Записи с пометкой ·

Delphi

·...

Немного покритикую FireMonkey

Вдогонку к предыдущему посту еще немного поговорим о FireMonkey. Очень неприятная для меня вещь — ощущение, что при разработке стандартных компонентов никто и не задумывался, что от них кто-то будет наследоваться, пытаться расширять и изменять их поведение. Если сравнивать с VCL, то доступных компонентов FireMonkey сейчас очень и очень мало. И мне кажется, что судьба фреймворка сегодня во многом зависит именно от удобства разработки новых компонентов.

Разберу конкретный пример.

Продолжаю работу с TTabControl. На этот раз я хочу получить возможность размещать компоненты в неклиентской области. Признаюсь, хочу повторить функционал браузеров — разместить кнопку для открытия новых вкладок справа от них.

Проблемы быть не должно, так как в FireMonkey все стилизованные контролы могут быть контейнерами для других контролов. Особенность данной конкретной ситуации в том, что в этом месте просто напросто нет ничего, на чем можно было бы размещать компоненты.

Реализация очевидна. Нужен контрол с простейшим стилем (скажем, только TLayout), его расположим на TTabControl’е и будем позиционировать по аналогии с вкладками. Напомню, что вкладка здесь — это только сама кнопка, мне же хочется всего-лишь оказаться правее кнопок.

Смотрим на метод TTabControl.Realign. Метод длинный, но из песни слов не выкинешь. Можно перемотать, его суть в двух словах я перескажу чуть позже.

procedure TTabControl.Realign;
var
  Idx, i: Integer;
  CurX, CurY: Single;
  AutoWidth, MaxHeight: Single;
  B: TFmxObject;
begin
  if FDisableAlign then
    Exit;
  FDisableAlign := True;
  try
    { move all non TabItem to end of list }
    if FChildren <> nil then
      for i := 0 to FChildren.Count - 1 do
        if not(TFmxObject(FChildren[i]) is TTabItem) then
          TFmxObject(FChildren[i]).Index := FChildren.Count - 1;
    { calc max height }
    MaxHeight := 0;
    Idx := 0;
    if FChildren <> nil then
      for i := 0 to FChildren.Count - 1 do
        if TFmxObject(FChildren[i]) is TTabItem then
          with TTabItem(FChildren[i]) do
          begin
            if not Visible then
              Continue;
            FIndex := Idx;
            if Height + Padding.top + Padding.bottom > MaxHeight then
              MaxHeight := Height + Padding.top + Padding.bottom;
            Idx := Idx + 1;
          end;
    if Idx = 0 then
      MaxHeight := 0
    else if FTabHeight > 0 then
      MaxHeight := FTabHeight;
    { background }
    if FResourceLink <> nil then
    begin
      B := FResourceLink;
      if (B <> nil) and (B is TControl) then
        begin
          TControl(B).Align := TAlignLayout.alNone;
          TControl(B).SetBounds(TControl(B).Padding.left, MaxHeight + TControl(B).Padding.top,
            Width - TControl(B).Padding.left - TControl(B).Padding.top,
            Height - MaxHeight - TControl(B).Padding.top - TControl(B).Padding.bottom);
          TControl(B).BringToFront;
        end;
    end;
    { align }
    CurX := 0;
    CurY := 0;
    AutoWidth:= Width;
    if FBackground <> nil then
      AutoWidth := Width - FBackground.Margins.left - FBackground.Margins.right;
    if FFullSize and (Idx > 0) then
      AutoWidth := AutoWidth / Idx
    else
      AutoWidth := AutoWidth;

    if FChildren <> nil then
      for i := 0 to FChildren.Count - 1 do
        if TFmxObject(FChildren[i]) is TTabItem then
          with TTabItem(FChildren[i]) do
          begin
            if not Visible then
              Continue;
            Align := TAlignLayout.alNone;

            FContent.Align := TAlignLayout.alNone;
            FContent.Visible := Index = TabIndex;
            FContent.DesignVisible := (Index = TabIndex);
            FContent.ClipChildren := True;
            if FContent.Visible then
              FContent.BringToFront;

            if FFullSize then
              SetBounds(CurX + Padding.left, CurY + Padding.top, AutoWidth,
                MaxHeight - Padding.top - Padding.bottom)
            else
              SetBounds(CurX + Padding.left, CurY + Padding.top, Width,
                MaxHeight - Padding.top - Padding.bottom);
            CurX := CurX + Padding.left + Width + Padding.right;
          end;
  finally
    FDisableAlign := False;
  end;
  inherited;
end;

Здесь в общем-то никакой магии нет. Метод по большому счету делает только три важные вещи:

  1. Пробегает по списку вкладок, определяя максимальную высоту вкладки (MaxHeight);
  2. Располагает «тело» TTabControl’а на доступном ему пространстве, сдвигая его верхнюю часть вниз на эти самые MaxHeight пикселей;
  3. Таким образом сверху остается полоска свободного пространства и там спокойно можно расположить кнопки вкладок.

К этому всему теперь требуется добавить установку позиции моей новой неклиентской панели. Что для этого требуется? Значение MaxHeight, чтобы знать высоту данного пространства, и координаты крайней правой кнопки. К тому же нужно внести небольшие изменения в логику размещения кнопок, запретив им занимать всю ширину TTabControl’а — у вкладок не должно быть возможности сделать ширину неклиентской панели меньше какого-то установленного значения.

И, честно говоря, я не вижу способа сделать это красиво. Перегрузка Realign не поможет. В итоге придется продублировать довольно приличные по объему куски кода. Например, MaxHeight будет вычисляться дважды. Это конечно мелочь, но быстродействия не прибавит, а вот багов прибавить может.

Не буду предлагать ничего глобального, покажу только к чему мог бы привести маленький и очень простой рефакторинг. Давайте признаемся сами себе, что сейчас Realign если и не нарушает «single responsibility principle», то явно балансирует на грани.

Нужно разбить метод Realign на более мелкие части. Тем более, разработчик компонента уже сделал половину работы, разделив его на 4 части комментариями.

{ move all non TabItem to end of list }
{ calc max height }
{ background }
{ align }

Таким образом, получилось бы что-то вроде:

procedure TTabControl.Realign;
var
  MaxHeight: Single;
begin
  SortItems(...);
  MaxHeight := CalcMaxHeight(...);
  AlignBackground(MaxHeight, ...);
  AlignTabs(MaxHeight, ...);

  inherited;
end;

Итого 4 новых метода со старым кодом. Каждый из которых должен быть виртуальным. Тогда вся моя работа свелась бы к перегрузке метода AlignTabs. В нем бы и значение MaxHeight было доступно, и логику размещения вкладок я бы смог изменить, не затрагивая всего остального, ну и конечно же добавить новую панель.

К сожалению, описанная проблема — это не частный случай. Покажу еще один пример.

TMemo в FireMonkey реализован довольно интересно. Текст рисуется простым FillText’ом на канве, а для позиционирования каретки используется соответственно вычисление высоты строк и ширины фрагмента текущей строки. Возможности кастомизации на первый взгляд безграничны.

Мне показалось интересным попробовать реализовать простенькую подсветку синтаксиса. Рисование устроено просто: обработчику OnPaint канвы присваивается метод DoContentPaintWithCache, он когда нужно вызывает DoContentPaint для рисования, а когда можно просто рисует старую картинку из кэша.

Проблемы начинаются с того, что обе функции не виртуальны. То есть я не могу просто перегрузить DoContentPaint, я должен сначала скопировать в своем наследнике код DoContentPaintWithCache, заменив вызов DoContentPaint на свой новый метод рисования. А затем эту копию установить обработчиком OnPaint. Только что произошло дублирование ~50 строк кода. Одно лишь ключевое слово «virtual» в объявлении метода TMemo.DoContentPaint избавило бы от всего этого.

Но это еще не вся проблема. DoContentPaint — большой метод (~120 строк), а мне всего-лишь нужно заменить в нем простой FillText на что-то чуть более сложное. То есть снова необходимо скопировать весь код, заменив в нем пару вызовов.

Если идти дальше и изменять не только цвет отдельных слов, но и делать их полужирными (фактически изменять ширину), придется немного доработать логику позиционирования каретки. Метод TextWidth, который используется при вычислении позиции каретки в пикселях, в текущей реализации конечно же этих нюансов не учитывает. Но и этот метод не виртуален. К слову, он даже приватный. Непонятно почему.

Disclaimer: Не хочу быть неправильно понят, на самом деле затея с FireMonkey мне нравится. Но вот головой о стены я побился уже изрядно. Желаю Embarcadero терпения и удачи в доводке этого фреймворка до ума.

Расширяем TTabControl в FireMonkey

Наконец-то разжился Delphi XE2 и пробую работать с FireMonkey. Довольно интересно, FireMonkey выглядит гибкой штукой. Особенно привлекает кроссплатформенность. Расскажу о том, что успел понять.

Решил начать с простенькой кастомизации компонента, чтобы получше разобраться с тем, как все устроено внутри. Под руку попался TTabControl, мне не хватает «крестика» на каждой вкладке, чтобы их можно было закрывать как в браузере. Очевидно, что наследоваться необходимо от класса TTabItem.

Небольшое лирическое отступление. Вообще, все визуальные компоненты в FireMonkey делятся на две категории: примитивные (наследники TShape) и стилизованные (наследники TStyledControl). Примитивные не делают ничего особо полезного, кроме рисования различных квадратов и окружностей на своей канве, по сути очень напоминая старый добрый TGraphicControl в VCL. А вот стилизованные уже интереснее — они имеют стиль.

Важно отметить, что стиль — это не разноцветная «шкурка», а контрол. Например, стиль TButton состоит из набора прямоугольников, текста и эффектов к ним применяемых. Фактически, говоря в терминах VCL, в FireMonkey любой стилизованный компонент очень похож на фрейм. Т.е. имеет разделенные код и визуальное представление, которое в широких рамках можно менять.

Вернемся к вкладкам TTabControl’а. Довольно интересный для меня момент, что стиль вкладки не имеет ничего общего с её содержимым и описывает всего-лишь саму кнопку. В качестве родителя для компонентов, которые должны на ней располагаться, вкладка использует отдельный контейнер типа TContent. Это позволяет отрисовывать вкладку в два этапа: сначала рисовать кнопку, а затем располагать TContent на теле TTabControl’а-родителя, если эта вкладка активна. За подробностями отсылаю к методам TTabItem.Realign и TTabControl.Realign.

Становится ясно, что добавление «крестика» нужно начать с изменения стиля вкладки. Способ рисования я подсмотрел у TCheckBox и реализовал, добавив в стиль компонент TPath (неплохое описание этого компонента есть тут) с соответствующими свойствами. А также эффект для него, чтобы при наведении курсора «крестик» подсвечивался.

Способ привязки стиля к кастомному компоненту описан в справке. Суть в том, чтобы сохранить текст стиля в ресурс. Не очень удобно, честно говоря.

Сохраняю измененный стиль в файл ClosableTabItem.style. Текст стиля у меня в итоге получился вот такой, очень похоже на DFM, я не зря сравнил с фреймами :)

object TLayout
  StyleName = 'CloseableTabItemStyle'
  Position.Point = '(276,419)'
  Width = 145.000000000000000000
  Height = 22.000000000000000000
  object TRectangle
    Align = alContents
    Position.Point = '(0,2)'
    Locked = True
    Width = 148.000000000000000000
    Height = 20.000000000000000000
    Padding.Rect = '(0,2,-3,0)'
    HitTest = False
    Fill.Color = xFFEFEFEF
    Stroke.Color = xFF898C95
    Corners = [crTopLeft, crTopRight]
    Sides = [sdTop, sdLeft, sdRight]
    object TRectangle
      Align = alContents
      Width = 148.000000000000000000
      Height = 20.000000000000000000
      HitTest = False
      Fill.Kind = bkGradient
      Fill.Gradient.Points = <
        item
          Color = x24F4F4F4
        end
        item
          Color = x24EAEAEA
          Offset = 0.499000012874603300
        end
        item
          Color = x4E868686
          Offset = 0.500000000000000000
        end>
      Stroke.Color = xC84F4F4F
      Corners = [crTopLeft, crTopRight]
      Sides = [sdTop, sdLeft, sdRight]
      object TFloatAnimation
        Duration = 0.000000999999997475
        Trigger = 'IsSelected=false'
        StopValue = 1.000000000000000000
        PropertyName = 'Opacity'
      end
      object TFloatAnimation
        Duration = 0.000009999999747379
        Trigger = 'IsSelected=true'
        StartValue = 1.000000000000000000
        StopValue = 0.001000000047497451
        PropertyName = 'Opacity'
      end
    end
    object TColorAnimation
      Duration = 0.000009999999747379
      Trigger = 'IsMouseOver=true;IsSelected=false'
      StartValue = xFFEFEFEF
      StopValue = xFFA5D9FF
      PropertyName = 'Fill.Color'
    end
    object TColorAnimation
      Duration = 0.000009999999747379
      Trigger = 'IsMouseOver=false;IsSelected=false'
      StartValue = xFFA5D9FF
      StopValue = xFFEFEFEF
      PropertyName = 'Fill.Color'
    end
    object TColorAnimation
      Duration = 0.000000999999997475
      Trigger = 'IsSelected=false'
      StartValue = xFFFEFEFE
      StopValue = claWhite
      PropertyName = 'Fill.Color'
    end
    object TColorAnimation
      Duration = 0.000009999999747379
      Trigger = 'IsSelected=true'
      StartValue = claWhite
      StopValue = xFFFEFEFE
      PropertyName = 'Fill.Color'
    end
    object TRectAnimation
      Duration = 0.000009999999747379
      Trigger = 'IsSelected=true'
      StartValue.Rect = '(0,2,-1,0)'
      StopValue.Rect = '(0,0,-1,-1)'
      PropertyName = 'Padding'
    end
    object TRectAnimation
      Duration = 0.000009999999747379
      Inverse = True
      Trigger = 'IsSelected=false'
      StartValue.Rect = '(0,2,-1,-1)'
      StopValue.Rect = '(0,0,-1,0)'
      PropertyName = 'Padding'
    end
    object TText
      StyleName = 'text'
      Align = alClient
      Position.Point = '(3,3)'
      Locked = True
      Width = 121.000000000000000000
      Height = 15.000000000000000000
      Padding.Rect = '(3,3,3,2)'
      HitTest = False
      Text = 'item'
      WordWrap = False
    end
    object TPath
      StyleName = 'close'
      Align = alFitRight
      Position.Point = '(132,5)'
      Width = 11.000000000000000000
      Height = 10.000000000000000000
      Padding.Rect = '(5,5,5,5)'
      Data.Path = {
        1200000000000000000000000000000001000000CDCC4C3E0000000001000000
        0000003F9A99993E01000000CDCC4C3F00000000010000000000803F00000000
        010000000000803FCDCC4C3E010000003333333F0000003F010000000000803F
        CDCC4C3F010000000000803F0000803F01000000CDCC4C3F0000803F01000000
        0000003F3333333F01000000CDCC4C3E0000803F01000000000000000000803F
        0100000000000000CDCC4C3F010000009A99993E0000003F0100000000000000
        CDCC4C3E010000000000000000000000030000000000000000000000}
      object TGlowEffect
        Trigger = 'IsMouseOver=true'
        Enabled = False
        Softness = 0.400000005960464400
        GlowColor = claDarkred
        Opacity = 0.899999976158142100
      end
    end
  end
end

Сооветственно, нужен и RC-файл:

ClosableTabItemStyle RCDATA "ClosableTabItem.style"

Теперь, продолжая следовать справке, пишем метод загрузки объекта стиля:

function TClosableTabItem.GetStyleObject: TControl;
var
  S: TResourceStream;
const
  Style = 'ClosableTabItemStyle';
begin
  if (FStyleLookup = '') then
  begin
    if FindRCData(HInstance, Style) then
    begin
      S := TResourceStream.Create(HInstance, Style, RT_RCDATA);
      try
        Result := TControl(CreateObjectFromStream(nil, S));
        Exit;
      finally
        S.Free;
      end;
    end;
  end;
  Result := inherited GetStyleObject;
end;

Вот и все. Почти. Но уже можно писать тестовое приложение. Бросаем на форму TTabControl и кнопку, которая будет добавлять вкладки:

procedure TForm1.Button1Click(Sender: TObject);
var
  NewTab: TClosableTabItem;
begin
  NewTab := TClosableTabItem.Create(TabControl1);
  NewTab.Parent := TabControl1;
  NewTab.Text := 'New Tab';
end;

Кажется, можно запускать. Крестик появился и даже подсвечивается, но ничего полезного не делает :)

Чтобы вкладка смогла закрываться, нужно добавить еще немножко кода. Мелочи вроде объявления переменных FOnCloseQuery и FOnClose опускаю.

Не придумал ничего лучше, чем присваивать обработчик OnClick в момент применения стиля. Судя по коду компонентов FireMonkey, кажется, это вполне нормальная практика.

procedure TClosableTabItem.ApplyStyle;
var
  CloseButton: TfmxObject;
begin
  inherited;
  CloseButton := FindStyleResource('close'); 

  if (CloseButton <> nil) and (CloseButton is TControl) then
    TControl(CloseButton).OnClick := CloseButtonClick;
end;

procedure TClosableTabItem.CloseButtonClick(Sender: TObject);
var
  CanClose: Boolean;
  TabControl: TTabControl;
begin
  CanClose := True;
  if Assigned(FOnCloseQuery) then
    FOnCloseQuery(Self, CanClose);

  if CanClose then
  begin
    if Assigned(FOnClose) then
      FOnClose(Self);

    TabControl := Self.Parent as TTabControl;
    Self.Free;
    TabControl.Realign;
  end;
end;

Простенько, но на данном этапе мне большего и не нужно. Полный код можно скачать здесь.

А теперь плохая новость. В рантайме все работает, но я не нашел никакого способа добавить свой новый тип вкладки в Items Designer. И похоже, что та же самая проблема есть у всех списковых контролов: TListBox, TGrid и т.п. Сначала мне подход к их реализации очень понравился, а вот теперь даже как-то сомневаюсь. Поиск в интернете показал, что я с этой проблемой не одинок.

Справка молчит, в коде я тоже не нашел ничего. Неужели никак? Это было бы крайне неприятно.

update: QC #107177

Delphi: Unable to install package <package name>

Обнаружил, что в Delphi невозможно установить пакет, в зависимостях которого есть runtime-пакет, путь к которому отсутствует в системной переменной окружения PATH. Присутствия в Library Path не достаточно. Во время установки путь Projects\Bpl услужливо добавляется в PATH, поэтому я о таком требовании даже не подозревал долгое время.

Сообщение об ошибке (см. сабж), конечно, «замечательное». Не дает абсолютно никакой информации о проблеме.

Об интерфейсах в Delphi

Интерфейсы в Delphi появились, когда понадобилось поддержать работу с COM и они не очень стройно вписались в язык. В итоге смешивать работу с классами и интерфейсами следует крайне осторожно, всему виной счетчик ссылок, значение которого в классах изначально равно нулю.

В качестве примера форма с одной кнопкой.

unit Unit1;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls;

type
  TMyClass = class(TInterfacedObject)
  public
    destructor Destroy; override;
  end;
  TForm1 = class(TForm)
    Button1: TButton;
    procedure Button1Click(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
    procedure Kill(Intf: IInterface);
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

{ TMyClass }

destructor TMyClass.Destroy;
begin
  ShowMessage('TMyClass.Destroy');
  inherited;
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  MyClass: TMyClass;
begin
  MyClass := TMyClass.Create;
  try
    Kill(MyClass);
  finally
    MyClass.Free;
  end;
end;

{$O-} // выключим оптимизатор, чтобы он не выбросил обращение к Intf
procedure TForm1.Kill(Intf: IInterface);
begin
  // Используем интерфейс
  // ...
  ShowMessage('TMyClass.Kill');
end;
{$O+}

end.

При нажатии на кнопку появляются сообщения:

  1. TMyClass.Kill
  2. TMyClass.Destroy
  3. TMyClass.Destroy
  4. Access violation at address 00403BD2 in module ‘Project1.exe’. Read of address FFFFFFF8.

Почему такое происходит?

Разберем ход выполнения процедуры Kill:

// Изначально Intf.RefCount = 0, это нормальное состояние для TInterfacedObject
// Выполняется Intf._AddRef, теперь RefCount = 1 
procedure TForm1.Kill(Intf: IInterface);
begin
  ShowMessage('TMyClass.Kill');

  // Интерфейс выходит из области видимости, выполняется Intf._Release
  // И, так как RefCount стал равень нулю, объект уничтожается: TMyClass.Destroy
  // Это и становится причиной того, что дальше все идет наперекосяк.
end;

Способ обойти такую проблему есть — переопределить методы _AddRef и _Release таким образом, чтобы обнуление счетчика ссылок не вызывало освобождение объекта. Но такой шаг увеличивает сложность, т.к. в коде, где часть интерфейсов использует счетчик ссылок, а часть нет, легко запутаться. Тем не менее, в VCL переопределение счетчика ссылок используется. У наследников TComponent счетчик ссылок то есть, то его нет :)

function TComponent._AddRef: Integer;
begin
  if FVCLComObject = nil then
    Result := -1   // -1 indicates no reference counting is taking place
  else
    Result := IVCLComObject(FVCLComObject)._AddRef;
end;

function TComponent._Release: Integer;
begin
  if FVCLComObject = nil then
    Result := -1   // -1 indicates no reference counting is taking place
  else
    Result := IVCLComObject(FVCLComObject)._Release;
end;

Врядли в языке без сборщика мусора можно было бы реализовать интерфейсы более удобно. Разве что принудить программиста явно вызывать _AddRef и _Release.

Update
Аналогичная проблема и попытка избежать уничтожения объекта при использовании функции Supports:
http://delphisorcery.blogspot.com/2011/10/supports-killing-objects.html

Update 2
Расширенная версия статьи на Хабре.