Delphi World - это проект, являющийся сборником статей и малодокументированных возможностей  по программированию в среде Delphi. Здесь вы найдёте работы по следующим категориям: delphi, delfi, borland, bds, дельфи, делфи, дэльфи, дэлфи, programming, example, программирование, исходные коды, code, исходники, source, sources, сорцы, сорсы, soft, programs, программы, and, how, delphiworld, базы данных, графика, игры, интернет, сети, компоненты, классы, мультимедиа, ос, железо, программа, интерфейс, рабочий стол, синтаксис, технологии, файловая система...
Hello, MiniProg 2

Автор: Иванов Петр ака Brodia@a
Специально для Королевства Delphi

Прошло некоторое время, клавиатура остыла после тестирования и писательства, можно продолжать.
Попробуем сделать так, что бы программа следила за тем, что бы она была запущена в единственном экземпляре. Пока мы не углубились в обсуждение деталей реализации, хотелось бы объяснить, для чего такое "суровое" требование единственности и неповторимости. Дело в том, что если пользователям удобнее использовать одновременно несколько копий одной и той же программы, то это верный признак того, что изначально был спроектирован неверный интерфейс, скорее всего больше подошел бы MDI. Это первое, второе - считается, что чаще всего запуск второй копии происходит по ошибке, когда приложение свернуто и его просто не видно.

Данная тема уже не раз поднималась на просторах Королевства, например, здесь или здесь. Огромное множество материала, на данную тематику, чьей то щедрой рукой, разбросано по интернету. Правда, все методы однотипные и сводятся к тому, что программа, при запуске, проверяет какой-нибудь признак, если он не обнаружен - то запускается, если же присутствует... В этом месте возможны самые различные реакции, от сообщений, с требованием ответа/нажатия кнопки, до коварнейших систем оповещения создателей (например, как у M$ XP :). Признаком может служить, либо проверка наличия определенного окна, либо отметка в конфигурационном файле/регистре, либо банальный файл, создаваемый при запуске приложения и удаляемый при выходе из него. Более сложные системы, профессионального уровня, обращаются за советом, "можно, или нельзя", к специализированным лицензионным серверам.
Мы пойдем другим путем, наверное, самым простым, будем проверять наличие определенного мьютекса, реакция же будет вежливая - просто активизация окна. Данный метод не нов. Определенно, он работоспособен, но не мешало бы создать тест, который бы нас убедил, что это так. И это еще одна рекомендация XtremeProgramming - не лениться и стараться тестировать как можно больше. Вообще, если бы программисты знали, как много ошибок может быть, в казалось бы, в надежном и простом коде: Откроем testMiniProg.dpr и в файле testAppl.pas создадим следующую процедуру:


Const
  StrFailedTest = 'failure test';
...
procedure TTestUnitAppl.TestFindPrevInstance;
var
  Test1, Test2: boolean;
  Temp: THandle; 
begin
  Temp := Mutex;
  Test1 := not FindPrevInstance('Test');
  Test2 := FindPrevInstance('Test');
  StopPrevInstance;
  Check(Test1 and Test2, strFailedTest);
  Mutex := Temp; 
end;


Сами функции располагаются в Appl.pas и выглядят так:


Var
  Mutex: THandle = 0;
...
function FindPrevInstance(Name: string): boolean;
var
  Temp: THandle;
begin
  Temp := CreateMutex(nil, False, PChar(Name));
  Result := (GetLastError = ERROR_ALREADY_EXISTS);
  if Result then
    CloseHandle(Temp)
  else
    Mutex := Temp;
end;

procedure StopPrevInstance;
begin
  if Mutex > 0 then
    CloseHandle(Mutex);
end;

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


Unit testAppl;
...
procedure TTestUnitAppl.TestShowPrevInstance;
begin
  Check(ShowPrevInstance('DUnit'), strFailedTest);
end;

unit Appl;
...
function ShowPrevInstance(Name: string): boolean;
var
  PrevInstance: HWND;
begin
  Result := False;
  PrevInstance := FindWindow('TApplication', PChar(Name));
  if PrevInstance <> 0 then
  begin
    if IsIconic(PrevInstance) then
      ShowWindow(PrevInstance, SW_RESTORE);
    SetForegroundWindow(PrevInstance);
    Result := True;
  end;
end;

Компилируем, запускаем, проверяем - все работает, как требуется. Следует отметить, что в данном случае, тестировалось только возвращаемое ShowPrevInstance значение, сам эффект 'показа' незаметен. По этому, что бы ни уподобляться тому сапожнику, который без сапог, внесем в testMiniProg.dpr изменения, добавим в секцию uses модуль Appl и следующий код:


program testMiniProg;

uses
  Appl in '..\SOURCE\Appl.pas', 
  Forms, TestFrameWork, GUITestRunner,
  testAppl in 'testAppl.pas',
  testUnit in 'testUnit.pas' {testForm};

{$R *.res}

begin
  if FindAndShowPrevInstance('DUnit') then
    Halt
  else
  try
    Application.Initialize;
    Application.Title := 'DUnit';
    GUITestRunner.RunRegisteredTests;
  finally
    StopPrevInstance;
  end;
end.

В модуль Appl.pas, поместим функцию FindAndShowPrevInstance, которая будет искать и активизировать предыдущую копию программы. Её тестирование проведем на функциональном уровне, так как технологическое тестирование, хоть и возможно, но реализовывать его будет обременительно. Впрочем, желающие могут попробовать, не забудьте только мне показать, очень интересно.


function FindAndShowPrevInstance(Name: string): boolean;
begin
  Result := FindPrevInstance(Name);
  if Result then
    ShowPrevInstance(Name);
end;

Компилируем, запускаем, пробуем запустить вторую копию - у меня всё, как и предполагалось. Ну что же, можем считать, что функциональные тесты данная функция прошла. Есть один момент, который нужно учитывать. Не очень удобно то, что 'DUnit', или какое-то другое, милое вашему сердцу заветное слово, приходится писать два раза. Мне, к сожалению, так и не удалось приравнять Application.Title ни константе, ни переменной. Все время возникала ошибка dcc32.exe, по-видимому, из-за того, что данное значение используется самим Delphi. Возможно изменение, в виде переноса проверки FindAndShowPrevInstance в секцию initialization модуля Appl.pas, StopPrevInstance в секцию finalization, а сам unit прописать в uses dpr вашей программы самым ПЕРВЫМ. В принципе, я обычно так и делаю, в данном же случае пример просто показательный, потому и несколько упрощенный. Не сомневаюсь, даже данный подход можно улучшить. Особенность передаваемого FindAndShowPrevInstance значения в том, что оно должно быть такое же, как и имя главной формы программы, в противном случае невозможно будет правильное выполнение StopPrevInstance. Конечно, проверка мьютекса будет выполнена, и 'лишнее' приложение буде закрыто, но активизации первой копии не произойдет. Если кого-то не устраивает такое положение дел, например, этот кто-то, всегда дает одно и тоже имя главному модулю своих программ, то всё можно поправить. Просто расширьте число предаваемых функции параметров - отдельно имя мьютекса, отдельно имя главного окна.

Как видите, с помощью довольно бесхитростных средств нам удалось избежать атаки клонов собственных программ. Посмотрим, что можно сделать дальше.

Способность сохранять в конфигурационном файле какие-нибудь значения, например, положение и размеры окна, так же была освещена в интернет очень широко. В RxLib, есть неплохой компонент, умеющий многое, я сам пользовался им когда-то. По этому не будем изобретать ничего нового, но просто воспользуемся уже известными приемами. Почему именно ini-файл, а не регистр, или не способ хранение свойств компонентов, так как это делает Delphi? Свои плюсы и минусы есть у всех подходов, но для наших целей вполне хватит возможностей ini-файла. Будем считать, что ini-файл располагается в директории вместе с программой и имеет такое же имя, но другое расширение, например "ini" :). Традиционно, свойства окна хранят в отдельной секции ini-файла, с уникальным, для данного приложения именем. Используем для этого имя формы. И так, тесты:


procedure TTestUnitAppl.TestGetIniFileName;
begin
  Check(ExtractFileName(GetIniFileName) = 'testMiniProg' + cfgFileExt,
    strFailedTest);
end;

procedure TTestUnitAppl.TestGetSectionName;
begin
  Check(GetSectionName(Screen.Forms[0]) = 'GUITestRunner', strFailedTest);
end;

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


Const
  cfgFileExt = '.ini';
...
function GetIniFileName: string;
begin
  Result := ChangeFileExt(Application.ExeName, cfgFileExt);
end;

function GetSectionName(Component: TComponent): string;
begin
  Result := Component.Name;
end;

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

1) отказа от восстановления параметров окна и установка значений по умолчанию,
2) изменение этих параметров в соответствии с изменением используемого шрифта.

Мне, больше по душе метод 'нумбер 2'. Что нужно сделать? вроде бы совсем не многое - всегда хранить размеры окна приведенными в соответствие с PPI времени создания, и при восстановлении проводить коррекцию, в соответствии с PPI времени выполнения. Положение левого верхнего угла формы изменять не следует, этого не делает Delphi, не будем делать и мы. В первой части статьи, говорилось, что величина масштабирования размеров окна зависит от отношения PPI's времен создания и выполнения, и такого понимания, тогда, было достаточно. Настало время все уточнить. На самом деле все обстоит несколько сложнее. Отношение PPI's используется для масштабирования высоты шрифта, после этого вычисляется высота образцового текста (у Delphi это строка '0' :). Ну а далее, для масштабирования, используется отношение старой и новой высот текста. Это отношение будет равно отношению PPI's в случае использования стандартных, для Windows, установок 'Крупный/Мелкий шрифт'. Размеры обычных экранных шрифтов строго фиксированы, по этому, использование нестандартных значений PPI' s может приводить к возникновению неприятных эффектов. В таких случаях, иногда, способен помочь шрифт TTF, например, как предлагается здесь. Следует отметить еще одну особенность масштабирования форм: непосредственно изменяются не сами размеры формы, а размеры клиентской части.

Вооружившись этими знаниями можно придти к выводу, что придется вносить изменения в функцию RtmPPI и DsgnPPI, и вычислять их результат иначе, чем было сделано ранее. Идея проста, использовать для масштабирования высоту текста, времени создания формы и времени выполнения приложения. Судя по всему - это более корректный способ, однако, в названиях переменных и процедур сохранена аббревиатура PPI. Остается вопрос, где, во время исполнения, взять высоту текста времени создания, ведь при создании окна все размеры изменяются. В принципе, все интересующие нас числа хранятся в ресурсах программы и можно попробовать прочитать их оттуда. Но, все попытки обратиться к ресурсам формы в программе, использую стандартные и рекомендованные для этого средства, ни к чему не привели. Точнее, нужные ресурсы программы успешно читаются, но уже в измененном виде, так уж устроен метод TCustomForm.ReadState :(. По этому, попытаемся прочитать данные из ресурса, так же, как это делает Delphi, но в сильно упрощенном варианте. Если вы загляните в исходный код VCL и просмотрите всё, что хоть как-то касается загрузки ресурсов программы, то поймете, зачем эти упрощения. Сведений, в литературе и интернете, связанных с вопросами чтения ресурсов во время исполнения программы, без создания самих компонентов, очень мало. К моему сожалению, практически, я ничего не нашел, и если кто-то знает, где есть подобного рода информация - поделитесь ссылкой. Текст функции, которая читает ресурсы определенной формы, выглядит так:


unit Appl;
...
function ReadFormRes(ResName: string; List: TStringList): boolean;
var
  Prop, Value: string;
  Stream: TResourceStream;
  Reader: TReader;
  HRsrc: THandle;
begin
  List.Clear;
  HRsrc := FindResource(HInstance, PChar(ResName), RT_RCDATA);
  Result := HRsrc <> 0;
  if not Result then Exit;
  Stream := TResourceStream.Create(HInstance, ResName, RT_RCDATA);
  Reader := TReader.Create(Stream, 4096);
  try
    Reader.ReadSignature;
    Reader.ReadStr;
    Reader.ReadStr;
    while not Reader.EndOfList do
    begin
      Prop := Reader.ReadStr;
      Value := strNil;
      case Reader.NextValue of
        vaInt8, vaInt16, vaInt32:
          Value := IntToStr(Reader.ReadInteger);
        vaString:
          Value := Reader.ReadString;
        else
          Reader.SkipValue;
      end;
      if Value <> strNil then
        List.Add(Format('%s = %s',[Prop,Value]));
    end;
    Reader.CheckValue(vaNull);
  finally
    Reader.Free;
    Stream.Free;
  end;
end;

Как я уже говорил, здесь представлен упрощенный вариант, который ищет определенный ресурс в программе, и сообщает в результате найден он или нет, а так же заполняет List набором строк найденных свойств и их значений. В список записываются не все свойства, а только те, которые определены в ресурсе и имеют тип, либо целого числа, либо строки, и принадлежат самой форме. При желании, можно организовать рекурсивный обход всех компонентов окна и чтение их свойств. Полный тест для данной функции не приводится, по той простой причине, что он довольно велик и явно выходит за рамки данной статьи. Может быть в другой статье :). Скажу лишь что, при построении такого рода функции, вряд ли стоит эмулировать полностью весь процесс загрузки ресурсов программы. В нашем случае, необходимо прочитать лишь некоторые свойства окна, что мы и сделаем. Конечно, можно поступить и так; создать пустую форму, у которой будет известна высота текста времени создания, но во время выполнения программы нам будет известно её масштабированное значение, что, собственно говоря, и нужно. Но, лично мне такой путь не нравиться, как по стилю решения проблемы, так и по тому, что при таком подходе возможна проблема с Constraints. Тестирующая функция довольно проста, хотя, конечно же, при тестировании полного варианта она выглядит иначе:


Procedure TtestUnitAppl.TestReadFormRes;
var
  List: TStringList;
  Test: boolean;
begin
  List := TStringList.Create;
  try
    ReadFormRes('TGUITestRunner', List);
    Test := List.Values['Caption'] = 'DUnit: An Xtreme testing framework';
  finally
    List.Free;
    Check(Test, strFailedTest);
  end;
end;

Изменим функцию RtmPPI таким образом, что бы она вычисляла, во время выполнения программы, высоту текста, для определенного нами окна. Соответственно DsgnPPI, изменится так, что вычисление её результата будет происходить с использованием ReadFormRes. Дополнительно, что бы избежать ошибок при определении RtmPPI, в ситуации, когда окно еще не создано, нам понадобится функция, которая по имени окна будет искать его в списке созданных форм и возвращать указатель на найденную форму, иначе nil.


Unit testAppl;
...
procedure TTestUnitAppl.TestFindForm;
var
  Test1, Test2, Test3: boolean;
begin
  Test1 := FindForm('testForm') = nil;
  testForm := TtestForm.Create(Application);
  try
    Test2 := FindForm('testForm') <> nil;
  finally
    testForm.Free;
    Test3 := FindForm('testForm') = nil;
  end;
  Check(Test1 and Test2 and Test3, strFailedTest);
end;

unit Appl;
...
function FindForm(FormName: string): TCustomForm;
var
  I: integer;
begin
  Result := nil;
  for I := 0 to Screen.FormCount - 1 do
    if Screen.Forms[I].Name = FormName then
    begin
      Result := Screen.Forms[I];
      Break;
    end;
end;

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


unit Appl;
...
const
  strDelphiMagicText = '0';
  strResTextHeight = 'TextHeight';
...
function RtmPPI(FormName: string): integer;
var
  Form: TCustomForm;
begin
  Result := 0;
  Form := FindForm(FormName);
  if Form <> nil then
    Result := Form.Canvas.TextHeight(strDelphiMagicText);
end;

function DsgnPPI(FormName: string): integer;
var
  List: TStringList;
  Form: TCustomForm;
begin
  List := TStringList.Create;
  try
    Form := FindForm(FormName);
    if Form <> nil then
    begin
      ReadFormRes(Form.ClassName, List);
      Result := StrToInt(List.Values[strResTextHeight]);
    end;
  finally
    List.Free;
  end;
end;

Эти функции проверяют наличие определенного окна . Если значение не равно nil, то считается, что форма уже создана, и у неё можно определить PPI's. Если форма еще не создана, то возвращается 0. В случае успешного выполнения функции, результатом будет значение высоты текста, отличное от 0. Так как сами функции несколько усложнились, то необходимо расширить их тестирование. Изменится так же, процедура TestDsgnVsRtmPPI, но функциональность её сохраниться, и даже несколько расшириться. Функция IsChangePPI удалена, из-за её несоответствия текущему моменту.


const
  testPPI = 16;
...
procedure TTestUnitAppl.TestRtmPPI;
var
  Test: boolean;
begin
  testForm := TtestForm.Create(Application);
  try
    Test := RtmPPI('testForm') = testForm.Canvas.TextHeight(strDelphiMagicText);
  finally
    testForm.Free;
    Check(Test, strFailedTest);
  end;
end;

procedure TTestUnitAppl.TestDsgnPPI;
var
  OldPPI, PPI: integer;
begin
  testForm := TtestForm.Create(Application);
  try
    OldPPI := DsgnPPI('testForm');
  finally
    testForm.Free;
    Check(OldPPI = testPPI, Format('DsgnPPI=%d, not %d', [OldPPI, testPPI]));
  end;
end;

procedure TTestUnitAppl.TestDsgnVsRtmPPI;
var
  Test: boolean;
  Text: string;
  OldPPI, NewPPI: integer;
begin
  Test := False;
  Text := strFailedTest;
  testForm := TtestForm.Create(Application);
  try
    OldPPI := RtmPPI('testForm');
    NewPPI := DsgnPPI('testForm');
    if (OldPPI > 0) and (NewPPI > 0) then
    begin
      Test := OldPPI = NewPPI;
      if not Test then
        Text := Format('DsgnPPI=%d not equal RtmPPI=%d DPI', [OldPPI, NewPPI]);
    end;
  finally
    testForm.Free;
    Check(Test, Text);
  end;
end;

Вроде бы все подготовительные действия выполнены, можно попытаться сохранить/восстановить состояние формы. Текст тестовой функции TestSaveLoadFormState можно посмотреть в testAppl.pas. Логика проверки следующая, создается окно, с некоторой задержкой демонстрируется, запоминается состояние окна в локальной переменной и сохраняется в ini-файле. Устанавливаются другие значения состояния окна, перемещается, сворачивается в левый нижний угол, выжидается некоторое время. Восстанавливается состояние окна, сохраненное в ini-файле. Дополнительно, проводится проверка значений состояния окна, до и после сохранения/восстановления. Если же вас не убедят результаты тестов, то всегда можно будет заглянуть в файл ini и посмотреть всё своими глазами. Сами процедуры сохранения/восстановления, и все процедуры к которым они обращаются, приведены поименно ниже:


...
procedure WriteIniShowCmd;
procedure ReadIniShowCmd;
procedure WriteIniFlags;
procedure ReadIniFlags;
procedure WriteIniWidth;
procedure ReadIniWidth;
procedure WriteIniHeight;
procedure ReadIniHeight;
procedure WriteIniLeft;
procedure ReadIniLeft;
procedure WriteIniTop;
procedure ReadIniTop;
procedure ScaleFormConstraints;
procedure SaveFormState;
procedure LoadFormState;
...

Полный текст процедур довольно велик по своим размерам, по этому здесь не приводится, но его можно посмотреть в Appl.pas, тестовые процедуры в testAppl.pas. Следует отметить, что при загрузке положения формы выполняется ScaleFormConstraints, которая корректирует значения Constraints окна, но другие элементы формы остаются без изменения. Желающие могут расширить её по своему усмотрению.

Те, кто смотрел исходники MiniProg1, заметят, что в исходных файлах MiniProg2 проведены некоторые 'косметические' изменения.

Продолжение следует ...

Declaimer aka Отмазка.

Я надеюсь, что люди, привыкшие читать академические труды, или слушать классические оперы, не станут осуждать автора, за его простую и незатейливую песнь кочевника. Что делал - о том и пел.
Исходную партитуру и ноты можно взять здесь: MiniProg2.zip (43K).

Любые претензии и предложения принимаются в обсуждение и/или мылом.
Предложения будут рассмотрены, претензии - проигнорированы.
С особым вниманием будут рассмотрены уточнения списка требований и новые тесты.

Все копирайты, если они известны, указаны. Иначе, автор не известен или копирайт утерян.

Проект Delphi World © Выпуск 2002 - 2004
Автор проекта: ___Nikolay