Версия для печати


Окна, WinAPI, Delphi
http://www.delphikingdom.com/asp/viewitem.asp?catalogID=1433

Александр Бусаров
дата публикации 15-05-2011 12:28

Когда это все-таки нужно

Бывают случаи, когда программисту хочется отказаться от VCL. Чаще всего конечно это бывает из-за экономии, чтобы exe получался не такой большой (что в нынешний век уже практически бессмысленно, только если вы занимаетесь какой-либо особой областью, например демосценой). Но у VCL есть еще одно неприятное ограничение — он однопоточный. Сама Windows прекрасно поддерживает многопоточность, и когда хочется честной параллельной работы окошек, приходится использовать API.

Как этого добиваются

Что обычно делает программист, который всегда работал с VCL, и тут ему нужны окошки на API? Он идет в гугл за примерами, и в мсдн за описанием функций. А в интернете все примеры как назло используют процедурное программирование. И когда программист таки прикручивает подобный код — участок программы с этим кодом превращается в монстра. А самое главное — не совсем понятно как подобный код можно положить на концепцию ооп. Так, например, как это сделано в VCL. Хочется создать окошко через TMyWindow.Create, хочется работать с окошком внутри класса, как в VCL. Хочется, в конце концов, обрабатывать сообщения message методами.

А чего добьемся мы

В этой статье я изобрету велосипед постараюсь сделать что-то подобное VCL структуре, а именно — уложить наши окошки в концепцию ООП. Ну и опишу некоторые принципы работы Windows и оконных сообщений. Ну а поскольку я считаю, что проделывать любую работу надо с пользой, то мы постараемся сделать reused friendly код, который можно будет легко использовать и модифицировать в своих будущих проектах, и использовать, использовать, использовать.

Как окошки работают в Windows

Тот, кто хоть однажды делал окошко средствами WinAPI, знает, что нужно создать функцию, в которую будут приходить наши сообщения, зарегистрировать класс с этой функцией, после этого создать окошко на основе этого класса, и, наконец, в коде потока сделать бесконечный цикл, который бы только и занимался тем, что выбирал оконные сообщения. И это все очень неудобно. Во-первых, потому что непонятно, как красиво с точки зрения кода (а еще лучше с точки зрения ООП) создать 3, 4 или больше окошек. Цикл то у нас один, и оконная функция тоже одна.

Несмотря на то, что пишут, мол, Windows посылает сообщения окну — это не совсем так. Windows посылает сообщения потоку. Все созданные окна — привязываются к конкретному потоку, в котором вызывалась функция CreateWindow. Важно понимать, что если мы создали окно в текущем потоке, то и все сообщения будут обрабатываться только в контексте данного потока. Именно так Windows и реализует многопоточную работу с окнами. Итак, если у нас в одном потоке создано 10 окошек — то все эти сообщения придут в один поток, и выбирать их надо в одном цикле этого потока. Это далее, при выборке можно задать либо фильтр по хендлу (второй параметр GetMessage), либо в оконной функции становится видно, какому окошку пришло сообщение. Но обрабатывать нужно не сообщения окна, а сообщения потока, т.е. второй параметр GetMessage должен быть у нас 0. И это важно, т.к. мы должны обрабатывать сообщение WM_QUIT. Это сообщение никогда не попадет никакому окну, и единственный возможный вариант выбрать это сообщение — передать 0 вторым параметром функции GetMessage. Зачем же нужно это странное сообщение. А нужно оно чтобы корректно выйти из оконного цикла. Когда мы делаем выборку с GetMessage, то функция всегда возвращает нам true, но когда GetMessage выбирает из очереди WM_QUIT — возвращает false. Если мы захотели вдруг выйти из оконного цикла раз и навсегда — то нам нужно вызывать в этом же потоке PostQuitMessage(ExitCode); и сообщение WM_QUIT станет в очередь текущего потока.

Как работают окошки в VCL

В VCL есть глобальный объект Application. Именно он занимается выборкой оконных сообщений, а входим мы в оконный цикл, когда вызывается Application.Run; Фактически Application должен быть одним на поток, и реализовывать оконный цикл, а внутри оконной функции выбирать сообщения, и передавать их конкретному объекту TWinControl. Но главная проблема VCL в том, что объект один на все приложение. Это классический пример вот этой статьи: http://www.gunsmoker.ru/2011/04/blog-post.html

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

Размышления об архитектуре

Итак, нам нужно много TApplication-ов. Мы напишем свои TApplication-ы, пусть это будут TApp (такое укороченное название от TApplication, нам ведь не нужна вся функциональность этого монстра). TApp-ов нам надо по одному на поток. WinAPI предоставляет нам функцию GetCurrentThreadId, которая возвращает Id потока. Значит в TApp можно хранить этот Id, а все TApp можно хранить в списке, и выбирать из списка только тот, у которого Id совпадает с Id текущего потока. Выборку сообщений можно сделать также, через TApp.Run, а внутри цикла уже находить окошко, для которого сообщение. Класс окошек, пусть у нас будет TWindow (в отличие от TForm) будет построен примерно так же, как в VCL, и все окошки будут хранится в списке у TApp. Таким образом, TApp будет иметь доступ ко всем окошкам и когда нужно вызывать Dispatch для конкретного окошка. Тем, кто не знает что такое message методы и TObject.Dispatch сюда: http://www.delphikingdom.com/asp/viewitem.asp?catalogid=1390

Это все в общих чертах. Если какие-то детали неясны — надеюсь в реализации станет яснее.

Реализация TApp

Поскольку TApp-ов у нас по одному на поток — то нужно хранить список, у нас это будет массив, назовем его: Applications: array of TApp; Поскольку обращения к этому массиву будут из нескольких потоков — важно синхронизировать доступ к нему. Для этого заведем критическую секцию: AppCS: TRTLCriticalSection;

Все это расположим в implementation секции модуля, чтобы другие модули не имели доступ к нашим данным.

В реализации же класса:

  TApp = class (TObject)
  private
    FThreadID: Cardinal;
  public
    property ThreadID: Cardinal read FThreadID;
    constructor Create;
    destructor Destroy; override;
  end;

Нам понадобится поле FThreadID, в котором будем хранить ID потока, в котором создавался TApp, а в конструкторе и деструкторе нам надо будет реализовать добавление и удаление объекта из нашего массива. Я подготовил для этого 2 функции, которые находятся только в implementation модуля:

procedure AddApp(const app: TApp);
begin
  EnterCriticalSection(AppCS);
  SetLength(Applications, Length(Applications)+1);
  Applications[Length(Applications)-1]:=app;
  LeaveCriticalSection(AppCS);
end;

procedure DelApp(const app: TApp);
var i, j: integer;
begin
  EnterCriticalSection(AppCS);
  for i := 0 to Length(Applications) - 1 do
    if Applications[i]=app then
    begin
      for j := i to Length(Applications) - 2 do Applications[j]:=Applications[j+1];
      SetLength(Applications, Length(Applications)-1);
      Break;
    end;
  LeaveCriticalSection(AppCS);
end;

И через них в конструкторе добавляю self, а в деструкторе удаляю:

constructor TApp.Create;
begin
  FThreadID:=GetCurrentThreadId;
  AddApp(self);
end;

destructor TApp.Destroy;
begin
  DelApp(self);
  inherited;
end;

Получить указатель на TApp для текущего потока в любом месте программы можно через функцию:

function GetApp: TApp;
var i: integer;
    id: Cardinal;
begin
  id:=GetCurrentThreadId;
  Result:=nil;
  EnterCriticalSection(AppCS);
  for i := 0 to Length(Applications) - 1 do
    if Applications[i].ThreadID=id then
    begin
      Result:=Applications[i];
      break;
    end;
  LeaveCriticalSection(AppCS);
end;

Как видно — пока все банально просто.

Реализация TWindow

Теперь добавим TWindow аналогичным образом, но так, чтобы при создании окошка оно добавлялось в список текущего TApp. Поскольку с каждым TApp мы будем работать только в одном потоке, то для списка окошек нам синхронизация не нужна. Изменяем класс TApp вот так:

  TApp = class (TObject)
  private
    FThreadID: Cardinal;

    FWindows: array of TWindow;
    procedure AddWindow(wnd: TWindow);
    procedure DelWindow(wnd: TWindow);
  public
    property ThreadID: Cardinal read FThreadID;

    constructor Create;
    destructor Destroy; override;
  end;

Реализация новых методов тривиальна:

procedure TApp.AddWindow(wnd: TWindow);
begin
  SetLength(FWindows, Length(FWindows)+1);
  FWindows[Length(FWindows)-1]:=wnd;
end;

procedure TApp.DelWindow(wnd: TWindow);
var i, j: integer;
begin
  for i := 0 to Length(FWindows) - 1 do
    if FWindows[i]=wnd then
    begin
      for j := i to Length(FWindows) - 2 do FWindows[j]:=FWindows[j+1];
      SetLength(FWindows, Length(FWindows)-1);
      Break;
    end;
end;

А вот и сам TWindow:

  TWindow = class (TObject)
  private
    FOwnerApp: TApp;
  public
    constructor Create; virtual;
    destructor Destroy; override;
  end;

Чтобы каждый раз не "ездить" в критическую секцию я завел поле FOwnerApp, которое заполняется в конструкторе:

constructor TWindow.Create;
begin
  FOwnerApp:=GetApp;
  FOwnerApp.AddWindow(self);
end;

а в деструкторе уже используется:

destructor TWindow.Destroy;
begin
  FOwnerApp.DelWindow(self);
  inherited;
end;

В этом коде не хватает одной детали. А именно — мы не возбуждаем исключения если что-то пойдет не так. В TWindow.Create мы полагаем, что в FOwnerApp всегда будет записан корректный указатель, но по коду GetApp видно, что функция может вернуть нам nil. Кроме того у нас в TApp.Create всегда добавляется новый TApp в список, даже если он для данного потока уже существует, что не логично. Поскольку одна из наших целей — минимизировать размер exe — я не стану подключать SysUtils, а просто сделаю пустые классы для исключений:

  EAppCreationFailed = class (TObject);
  EWindowCreationFailed = class (TObject);

И добавлю наши классы в конструторы:

constructor TApp.Create;
begin
  if GetApp<>nil then raise EAppCreationFailed.Create;
  FThreadID:=GetCurrentThreadId;
  AddApp(self);
end;

constructor TWindow.Create;
begin
  FOwnerApp:=GetApp;
  if FOwnerApp=nil then raise EWindowCreationFailed.Create;
  FOwnerApp.AddWindow(self);
end;

Внедрение WinAPI

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

Функция обработки оконных сообщений одна на все приложение:

function WndProc(handle: HWND; Msg: Cardinal; wPrm: WPARAM; lPrm: LPARAM): integer; stdcall;
var app: TApp;
    wnd: TWindow;
    wndMsg: TWindowMsg;
begin
  wndMsg.Msg:=Msg;
  wndMsg.wParam:=wPrm;
  wndMsg.lParam:=lPrm;
  wndMsg.Result:=-1;

  app:=GetApp;
  if assigned(app) then
  begin
    wnd:=app.FindByHandle(handle);
    if assigned(wnd) then wnd.Dispatch(wndMsg);
  end;

  if wndMsg.Result<>0 then
    Result:=DefWindowProc(handle, msg, wPrm, lPrm)
  else
    Result:=0;
end;

Видно что я добавил TWindowMsg, вот эта структура:

  TWindowMsg = packed record
    Msg   : Cardinal;
    wParam: WPARAM;
    lParam: LPARAM;
    Result: Integer;
  end;

Если сообщение обработано — то Result должен быть равен 0, как для винапи.

Так же я добавил метод поиска окошка по хендлу: app.FindByHandle(handle);

Так же для TApp я добавил метод:

procedure TApp.Run;
var msg: TMsg;
begin
  while GetMessage(msg, 0, 0, 0) do
    begin
      TranslateMessage(msg);
      DispatchMessage(msg);
      if Length(FWindows)=0 then PostQuitMessage(0);
    end;
end;

Просто оконный цикл, как видим, но с возможностью выхода если у TApp ниодного окошка не осталось.

Пришло время TWindow обрасти мясом кодом.

  TWindow = class (TObject)
  private
    FOwnerApp: TApp;

    FHandle: HWND;

    procedure RegClass;
    procedure UnregClass;
    procedure CreateWND;
    procedure DestroyWND;
  protected
    function GetWndClassInfo: TWndClassEx; virtual;
    function GetExStyle: DWORD; virtual;
    function GetStyle: DWORD; virtual;

    procedure WMDestroy(var msg: TWindowMsg); message WM_DESTROY;
  public
    property Handle: HWND read FHandle;

    constructor Create; virtual;
    destructor Destroy; override;
  end;

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

Вот так регистрируем класс, если он еще не зарегистрирован:

procedure TWindow.RegClass;
var wndClassEx: TWndClassEx;
begin
  if not GetClassInfoEx(HInstance, PChar(ClassName), wndClassEx) then
  begin
    wndClassEx:=GetWndClassInfo;
    if RegisterClassEx(wndClassEx)=0 then raise ERegisterClass.Create;
  end;
end;

Вот так создаем окошко:

procedure TWindow.CreateWND;
begin
  FHandle:=CreateWindowEx(GetExStyle, PChar(ClassName), PChar(ClassName),
    GetStyle, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
    0, 0, HInstance, nil);
  if FHandle=0 then raise EWindowCreation.Create;
end;

Простое винапи, которое легко уживается в наших классах.

Все. Теперь если мы напишем вот такой вот dpr файл:

program BlaBla;

uses
  untWndOOP; 
var app: TApp;
    wnd: TWindow;

begin
  app:=TApp.Create;
  wnd:=TWindow.Create;
  app.Run;
  app.Free;
end.

То в принципе наша программа будет работать… Вечно… :)

А все потому что мы не предусмотрели уничтожение наших TWindow, и код вечно находится в app.Run цикле. Итак, нам надо обрабатывать WM_DESTROY для окошек, но этот WM_DESTROY придет внутрь объекта TWindow, а уничтожать объект из его метода нельзя. Поэтому нам придется создать еще один список у TApp объектов, которые ждут удаления, и в конце оконного цикла внутри TApp.Run нам нужно будет удалить наши TWindow.

Реализуем:

Добавляем для TWindow message метод:

procedure TWindow.WMDestroy(var msg: TWindowMsg);
begin
  FOwnerApp.DestroyMe(self);
  msg.Result:=0;
end;

Соответственно реализуем DestroyMe у TApp:

procedure TApp.DestroyMe(wnd: TWindow);
var i: integer;
begin
  for i := 0 to Length(FDestroyArr) - 1 do
    if FDestroyArr[i]=wnd then exit;

  SetLength(FDestroyArr, Length(FDestroyArr)+1);
  FDestroyArr[Length(FDestroyArr)-1]:=wnd;
end;

Ну и чуть-чуть меняем метод с циклом Run:

procedure TApp.Run;
var msg: TMsg;
    i: integer;
begin
  while GetMessage(msg, 0, 0, 0) do
    begin
      TranslateMessage(msg);
      DispatchMessage(msg);

      for i := 0 to Length(FDestroyArr) - 1 do FDestroyArr[i].Free;
      SetLength(FDestroyArr, 0);

      if Length(FWindows)=0 then PostQuitMessage(0);
    end;
end;

Теперь если мы напишем вот такой код:

program BlaBla;
uses
  untWndOOP;

var app: TApp;
    wnd: TWindow;

begin
  app:=TApp.Create;
  wnd:=TWindow.Create;
  app.Run;
  app.Free;
end.

Он корректно отработает, и по закрытии окошка наша программа завершится.

А как же многопоточность?

Нам осталось реализовать простенький пример работы окошек в несколько потоков. Для примера я воспользуюсь классом TThread, который находится в Classes.pas. Я понимаю, что этот модуль убивает в корне наш минимализм, но моя задача показать как удобно работают наши окошки. Быть может, у меня найдется время, и я напишу подобную статью по потокам и ООП. Итак, вот код простейшего многопоточного приложения с двумя окошками:

program ThreadedBlaBla;

uses
  Classes,
  untWndOOP;

type
  TMyThread = class (TThread)
  protected
    procedure Execute; override;
  end;

{ TMyThread }

procedure TMyThread.Execute;
begin
  inherited;
  TApp.Create;
  TWindow.Create;
  GetApp.Run;
  GetApp.Free;
end;

var thr: TThread;

begin
  thr:=TMyThread.Create(false);

  TApp.Create;
  TWindow.Create;
  GetApp.Run;
  GetApp.Free;

  thr.Free;
end.

Все. Запускаем, появляется 2 окошка. По закрытии двух окошек работа приложения заканчивается.

И это все?

Выше я говорил о reuse friendly модуле. К сожалению, функционал модуля очень маленький, он как минимум не умеет создавать дочерние окошки. Так же нет никакой работы с глобальными классами Windows. Мы не можем без модификации модуля создать даже кнопку. Этот модуль следует рассматривать лишь как базовый, показывающий, как можно обычное WinAPI красиво обернуть в классы, используя возможности Delphi и этот модуль уже можно легко дорабатывать до удобного полноценного модуля. Связка оконной функции с классами реализована, а значит, мою задачу можно считать решенной. Стандартная VCL Delphi действует очень похожим образом, вот только с многопоточностью у окошек VCL беда.

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

Надеюсь моя статья оказалась полезной, и кто-то по-другому теперь взглянет на обычные вещи ;)



Специально для Королевства Delphi


Внимание!
Вышло продолжение данного материала "Окна, WinAPI, Delphi. Продолжение".



К материалу прилагаются файлы: