Wtyczki i obsługa menu programu głównego.

Adam.Pilorz

Proponowany przeze mnie system oparty będzie na bibliotekach dynamicznych DLL, więc jeśli nie masz żadnego doświadczenia w ich używaniu, albo korzystałeś z nich bardzo dawno, zapraszam do zapoznania się z artykułem Adama Boducha pt. Biblioteki DLL z zasobów tego serwisu. Sugeruję zwrócić uwagę na zagadnienie Ładowanie dynamiczne, gdyż to z niego będziemy tutaj korzystać. Ładowanie statyczne na nic się nie zda, gdyż autor programu nie może przewidzieć konkretnie wszystkich wtyczek, które pojawią się w jego programie, a nawet jeżeli, to byłoby to wielce niewygodne.
Ale przejdźmy do konkretów. Przez ponad 2 miesiące męczyłem się z tym zagadnieniem, więc napisałem ten artykuł, by już nikt więcej nie miał problemów z tym zagadnieniem. Omawianie różnych dziwnych problemów, na które można natknąć się podczas pisania takiego programu zajęłoby dużo czasu (a właściwie głównie Tobie zajęłoby dużo czasu ich czytanie), więc jeśli jesteś zainteresowany, to odeślę tylko do tematu, w którym problem ten był dyskutowany: DLL - dodawanie elementów do menu i w skrócie napiszę, że bezpośrednie tworzenie elementów menu w bibliotece DLL i dodawanie ich do menu programu głównego nie wychodzi tak, jak powinno.
Naszym zadaniem będzie stworzenie uniwersalnej metody na przekazanie do programu głównego struktury menu wykorzystywanej przez konkretną wtyczkę, następnie w programie głównym odtworzenie tej struktury poprzez stworzenie odpowiednich elementów menu i znowu wewnątrz wtyczki "podpięcie" odpowiednich zdarzeń w sytuacji kliknięcia (i ew. innych).
W pierwszej kolejności zastanówmy się w jaki sposób najprościej przekazać informacje o takiej strukturze do programu głównego. Ja wybrałem najprostsze rozwiązanie, jakie przyszło mi do głowy a jednocześnie dość proste w implementacji, a mianowicie obiekt typu TStringList, przedstawiający "drzewo" menu. Każdy element podrzędny względem poprzedniego będzie miał na początku swojej linijki wcięcie o jedną spację większe od elementu sobie nadrzędnego. I tak oto powstaje na przykład następująca struktura:

Wtyczki
 Moja wtyczka nr.1
  Opcja 1
  Grupa opcji 1
   Opcja w podgrupie 1
   Opcja w podgrupie 2
  Opcja 2
  Opcja 3
 Moja wtyczka nr.1 - dodatki
  Dodatkowa opcja 1
itd...

Następnym zadaniem będzie odpowiednie zinterpretowanie tej struktury i dodanie odpowiedniej ilości odpowiednich elementów menu. Nie będę się chyba dużo rozpisywał, tylko wkleję kod z odpowiednimi komentarzami (oczywiście jest to jeden z wielu, nie koniecznie najlepszy, sposób na dokonanie tej operacji):

function Level(Str: String): Integer;
{Bardzo prosta funkcja, liczy ilość spacji - a zarazem zagłębienie w strukturze menu. Nie ma co wiele pisać}
var I: Integer;
begin
  I:=0;
  While Str[I+1]=' ' do I:=I+1;
  Result:=I;
  end;

function BezSpacji(Str: String): String;
{Funkcja obcinająca wszystkie spacje z przodu. Jedna linijka, ale rozjaśnia działanie kodu}
begin
  Result:=Copy(Str, Level(Str)+1, Length(Str)-Level(Str));
  end;

procedure IncludeMenu(Structure: TStringList; Where: TMenuItem);
{No i główna procedura dodawania menu... Structure - struktura zapisana w sposób jak powyżej, Where - element menu, wewnątrz którego mają być dodawane wszystkie opcje (jeśli chcesz dodać te opcje do menu głównego, podaj jako parametr Form.Menu.Items)}
var
  I, Sp: Integer;
  MenuItemAct, MenuItemNew: TMenuItem;
begin
  MenuItemAct:=Where; {Ustawiamy aktualną "pozycję"}
  Sp:=0;
  if Structure[0][1]=' ' then ShowMessage('Poważny błąd w strukturze menu! Aplikacja zostaje przerwana!'); {Jeśli pierwsza linijka ma na początku spację, to odrazu wywalamy błąd. Nie powinno tak być.}
  For I:=0 to Structure.Count-1 do begin {naturalnie tą samą operację wykonujemy dla każdej linijki - czyli dla każdego elementu menu}
    While Level(Structure[I])<Sp do begin {Dopóki wcięcie jest mniejsze, niż aktualne zagłębienie, to ustawiamy się na rodzicu aktualnego elementu menu (elemencie nadrzędnym) i zmniejszamy zmienną zagłębienia o 1}
      MenuItemAct:=MenuItemAct.Parent;
      Sp:=Sp-1;
      end;
    If Level(Structure[I])>Sp then begin {Jeśli jest więcej spacji, niż zagłębienie, to "wchodzimy" w ostatni element i tworzymy elementy podrzędne}
      Sp:=Sp+1;
      If (Level(Structure[I])>Sp) or (I=0) then begin {Jeśli jest więcej niż jeden poziom różnicy, to wyskakujemy z błędem.}
        ShowMessage('Poważny błąd w strukturze menu! Aplikacja zostaje przerwana!');
        Application.Terminate;
        end;
      MenuItemAct:=MenuItemAct.Find(BezSpacji(Structure[I-1]));
      end;
    If MenuItemAct.Find(BezSpacji(Structure[I]))=nil then begin {Jeśli taki element jeszcze nie istnieje, to tworzymy go}
      MenuItemNew:=TMenuItem.Create(Form1);
      MenuItemNew.Caption:=BezSpacji(Structure[I]);
      MenuItemAct.Add(MenuItemNew);
      end;
    end;
  end;

Taki kod powinien działać (działał u mnie :) ), więc zabierzmy się za etap następny: rozpoczynamy pisanie programu jakotakiego.
W pierwszej kolejności należy stworzyć aplikację główną i pierwszą wtyczkę, która posłuży nam potem jako szablon tworzenia wtyczek. W tym celu uruchom Delphi (jeśli jeszcze tego nie zrobiłeś ;) ), stwórz nowy projekt (jeśli dopiero co włączyłeś Delphi, to masz go przed sobą) i dodaj nowy projekt (DLL) do grupy projektów (Menu Project -> Add New Project -> DLL Wizard). Ułatwi to nawigację, gdyż nie będziesz musiał cały czas przełączać się z wtyczki do programu głównego, by coś sprawdzić lub zmienić. W celu przełączania się między projektami możesz od razu otworzyć "Project Menager" (z menu View). Następnie nadszedł czas by to wszystko zapisać. Ponazywaj wszystko jak chcesz, Twoja sprawa :). Teraz okaże się, czy przeczytałeś artykuł o bibliotekach DLL, do którego odsyłałem na początku :>. W DLL'u (wtyczce) stwórz procedurę i funkcję. Funkcja będzie zwracała strukturę menu, a wyglądać może następująco:

function QueryMenu: TStringList; stdcall;
begin
  Result:=TStringList.Create;
  Result.Add('Wtyczka Submenu');
  Result.Add(' Opcja1');
  Result.Add(' Opcja2');
  Result.Add('Opcja3'); {ta opcja będzie na poziomie submenu, nie wejdzie w jego skład, w przeciwieństwie do opcji 1 i 2}
  end;

Ważne jest, by nazwa funkcji jednoznacznie określała, co funkcja ma robić, gdyż będzie nazywać się tak samo w każdej wtyczce. Zwróć uwagę na deklarację stdcall;. Jeśli zdecydujesz się jej używać (a jest to polecane ze względu na zgodność między językami, dzięki czemu ktoś będzie mógł napisać wtyczkę do twojego programu na przykład w C++, kiedy twój program będzie stworzony w Delphi), to musisz się tego trzymać i podawać ją przy deklaracji funkcji i procedur zarówno we wtyczce jak i w programie głównym.
Procedura będzie odpowiadała za wszelkie akcje podjęte przy uruchomieniu wtyczki, między innymi za przypisanie wszystkich zdarzeń do opcji. Osobiście proponuję podanie w parametrach tej procedury wskaźnika do głównej formy programu i elementu Menu odpowiadającego elementowi nadrzędnemu dla wszystkich opcji z wtyczek (patrz: komentarz do procedury IncludeMenu), by móc prosto odwołać się do ich zdarzeń. Przykładowa procedura inicjująca (właściwie nie powinienem tak jej nazwać, gdyż procedura inicjująca, taka prawdziwa, wywołana byłaby wcześniej, ale tak ją nazywałem, nazywam i nazywać będę :) ) wygląda następująco:

procedure Initialize(Form: TForm; PluginsMenu: TMenuItem); stdcall;
var
  MenuItem: TMenuItem;
begin
  MenuItem:=PluginsMenu.Find('Wtyczka Submenu').Find('Opcja1');
  If MenuItem<>nil then MenuItem.OnClick:=Obiekt.Menu1Click;

  MenuItem:=PluginsMenu.Find('Wtyczka Submenu').Find('Opcja2');
  If MenuItem<>nil then MenuItem.OnClick:=Obiekt.Menu2Click;

  MenuItem:=PluginsMenu.Find('Opcja3');
  If MenuItem<>nil then MenuItem.OnClick:=Obiekt.Menu3Click;
  end;

Zapewne rzuciło Ci się w oczy odwołanie do nie istniejącego jeszcze obiektu o nazwie Obiekt. Służy on do przechowywania wszystkich procedur obsługi zdarzeń, a jego deklaracja (dla powyższego przykładu) wyglądałaby następująco:

type TObiekt = object
procedure Menu1Click(Sender: TObject);
procedure Menu2Click(Sender: TObject);
procedure Menu3Click(Sender: TObject);
end;

var Obiekt: TObiekt;

procedure TObiekt.Menu1Click(Sender: TObject);
begin
  {Tu obsługuje się tylko kliknięcia w pierwszą opcję ;) }
  end;

procedure TObiekt.Menu2Click(Sender: TObject);
begin
  {Tu obsługuje się tylko kliknięcia w drugą opcję ;) }
  end;

procedure TObiekt.Menu3Click(Sender: TObject);
begin
  {Tu obsługuje się tylko kliknięcia w trzecią opcję ;) }
  end;

Oczywiście w celu uzyskania kodu mającego jakiś sens należałoby komentarze zastąpić jakimiś konkretnymi poleceniami. Do sekcji uses dopisz Forms i Menus, a na koniec należy oczywiście wyeksportować zarówno QueryMenu jak i Initialize. No dobra, pierwszą część mamy już za sobą. Stworzyliśmy szablon wtyczki, teraz pora zabrać się za program główny.
W pierwszej kolejności należy przygotować sobie pole do popisu i postawić na pustą formę komponent TMainMenu, następnie (choć nie koniecznie) jakoś rozsądnie go nazwać i (również opcjonalnie) wcisnąć w niego jakieś elementy. Oczywiście jeśli program ma za zadanie tylko i wyłącznie obsługiwać wtyczki, to nie jest to nam do niczego potrzebne, gdyż program będzie sam generował elementy menu, ale możemy na przykład dodać jakiś element o nazwie Wtyczki i wymusić dodawanie wszelkich opcji wtyczek akurat tam.
Jak już przygotujemy menu, klikamy dwukrotnie na formę i piszemy obsługę wtyczek. W świeżo otwartym okienku kodu mamy przed sobą pustą procedurę zdarzenia OnCreate formy, które należałoby jakoś wypełnić. I tym właśnie się zajmiemy. Pierwszą rzeczą, jaką nasz program będzie musiał zrobić, to znalezienie wszystkich wtyczek, które są dostępne. Dla ułatwienia przeszukamy po prostu katalog programu (choć mógłby to być na przykład specjalny katalog zawierający wtyczki, albo katalog ze wszystkimi danymi programu. W tym celu użyjemy procedur FindFirst, FindNext i FindClose. Załóżmy, że szukamy wszelkich plików dll znajdujących się w katalogu programu. Kod na tym etapie wyglądałby następująco:

procedure TForm1.FormCreate(Sender: TObject);
var
  sr: TSearchRec;
begin
  if FindFirst(ExtractFilePath(Application.ExeName)+'*.dll', faAnyFile, sr) = 0 then repeat
    {tu wstawimy obsługę każdego dll'ka w katalogu}
    until FindNext(sr) <> 0;
  FindClose(sr);
  end;

Teraz pozostaje najważniejsze zadanie, czyli odpowiednie załadowanie i uruchomienie wtyczek. W tym celu stworzymy dynamiczną tablicę odnośników do bibliotek DLL, a jej deklaracja będzie następująca:

var DLLe: array of THandle;

W procedurze TForm1.FormCreate natomiast dodamy deklarację funkcji QueryMenu i procedury Initialize. Deklaracje te powinne mieć postać następującą:

var
  QueryMenu: function: TStringList; stdcall;
  Initialize: procedure(Form: TForm; PluginsMenu: TMenuItem); stdcall;

Kolejnym naszym zadaniem będzie odpowiednie załadowanie bibliotek i wywołanie funkcji QueryMenu i procedury Initialize. W tym celu wstawimy poniższy kod w miejsce komentarza {tu wstawimy obsługę każdego dll'ka w katalogu}.

    SetLength(DLLe, Length(DLLe)+1); // Zwiększamy ilość DLL'ków
    DLLe[Length(DLLe)-1] := LoadLibrary(PChar(sr.Name)); // Ładuj bibliotekę
    try
      @QueryMenu := GetProcAddress(DLLe[Length(DLLe)-1], 'QueryMenu');
      if @QueryMenu = nil then raise Exception.Create('Bład - nie mogę znaleźć proceudry w bibliotece!');
      IncludeMenu(QueryMenu, Self.Menu.Items);
      @Initialize := GetProcAddress(DLLe[Length(DLLe)+1], 'Initialize');
      if @Initialize = nil then raise Exception.Create('Bład - nie mogę znaleźć proceudry w bibliotece!');
      Initialize(Self, Self.Menu.Items);
    finally
      end;

Oczywiście jeśli zdecudujesz się na zmianę "głównego" elementu menu, trzeba zmienić Self.Menu.Items na ten właśnie element. W celu objaśnienia poszczególnych etapów ładowania dynamicznego odsyłam do artykułu Adama Boducha, ja ograniczę się do opisania linijki "IncludeMenu(QueryMenu, Self.Menu.Items);". Następuje tu w pierwszej kolejności wywołanie funkcji QueryMenu pochodzącej z wtyczki, następnie zaś wykonana jest procedura z samego początku kursu wykorzystująca rezultat funkcji QueryMenu w tworzeniu odpowiednich elementów menu. Wywołana po niej procedura Initialize podpina pod stworzoną w ten sposób strukturę konkretne zdarzenia.
Na koniec należy dodać na początku tej procedury linijkę

SetLength(DLLe, 0); //tak dla bezpieczeństwa

i obsłużyć zwalnianie pamięci w procedurze obsługującej event OnClose, która może wyglądać mastępująco:

procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  While Length(DLLe)>0 do begin
    FreeLibrary(DLLe[Length(DLLe)-1]);
    SetLength(DLLe, Length(DLLe)-1);
    end;
  end;

Jeśli jeszcze nie przekopiowałeś kodu z początku artykułu (funkcje Level i BezSpacji oraz procedura IncludeMenu), to zrób to teraz (kod ten powinien znaleźć się zaraz pod słowem implementation w bibliotece obsługującej formę w głównym programie.
Gotowy przykładowy program stworzony dokładnie krok po kroku tak, jak to opisałem dostępny jest w załączniku. Mam nadzieję, że artykuł przybliży trochę problem wtyczek i pozwoli uniknąć katorgi związanej z problemem opisanym w temacie przytoczonym na początku artykułu.

6 komentarzy

Swojego czasu bawiłem się z tym. Zacząłem od prostej rzeczy, skutki widać tu: http://4programmers.net/file.php?id=1125, potem bawiłem się z COM i interfejsami, ale już tak ładnie nie szło, więc zabawa się skończyła :)

Na końcu wg. mnie powinien być listing kodu

taa przydal by sie listing...

Hmm... Cały kod był w załączniku razem z różnymi innymi rzeczami (nie samym kodem żyje człowiek, to był program z wtyczką gotowy do kompilacji), ale coś się zwaliło. Spróbuję coś z tym zrobić, a jak nie pomoże, to zgłoszę to komuś z Adminów.
//Dodałem raz jeszcze, tyle że teraz są dwa linki do tego samego pliku. Coś nie do końca działa...

tak na moje oko to trochę nie czytelne. Chodzi mi o odstępy między kodem a tekstem oraz po akapitach bo wszystko się tak zlało w i nie wygląda za ciekawie. Jakbyś zrobił odstęp linijki to by to lepiej wyglądało.

Ale poza tym to art jest ok :)

Witam, jestem tu nowy.
Mam problem, chcę napisać program obsługujący wtyczki według tego artykułu, ale nie wiem co robię nie tak, ponieważ pojawia się błąd po uruchomieniu (Błąd - nie mogę znaleźć procedury w bibliotece!).
Linku do pliku już dawno nie ma, może wkleję kod.

Unit1.pas:

unit Unit1;

interface

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

type
TForm1 = class(TForm)
MainMenu1: TMainMenu;
procedure FormCreate(Sender: TObject);
procedure FormClose(Sender: TObject; var Action: TCloseAction);
private
{ Private declarations }
public
{ Public declarations }
end;

var
Form1: TForm1;

implementation

{$R *.dfm}

function Level(Str: String): Integer;
{Bardzo prosta funkcja, liczy ilość spacji - a zarazem zagłębienie w strukturze menu. Nie ma co wiele pisać}
var I: Integer;
begin
I:=0;
While Str[I+1]=' ' do I:=I+1;
Result:=I;
end;

function BezSpacji(Str: String): String;
{Funkcja obcinająca wszystkie spacje z przodu. Jedna linijka, ale rozjaśnia działanie kodu}
begin
Result:=Copy(Str, Level(Str)+1, Length(Str)-Level(Str));
end;

procedure IncludeMenu(Structure: TStringList; Where: TMenuItem);
{No i główna procedura dodawania menu... Structure - struktura zapisana w sposób jak powyżej, Where - element menu, wewnątrz którego mają być dodawane wszystkie opcje (jeśli chcesz dodać te opcje do menu głównego, podaj jako parametr Form.Menu.Items)}
var
I, Sp: Integer;
MenuItemAct, MenuItemNew: TMenuItem;
begin
MenuItemAct:=Where; {Ustawiamy aktualną "pozycję"}
Sp:=0;
if Structure[0][1]=' ' then ShowMessage('Poważny błąd w strukturze menu! Aplikacja zostaje przerwana!'); {Jeśli pierwsza linijka ma na początku spację, to odrazu wywalamy błąd. Nie powinno tak być.}
For I:=0 to Structure.Count-1 do begin {naturalnie tą samą operację wykonujemy dla każdej linijki - czyli dla każdego elementu menu}
While Level(Structure[I])<Sp do begin {Dopóki wcięcie jest mniejsze, niż aktualne zagłębienie, to ustawiamy się na rodzicu aktualnego elementu menu (elemencie nadrzędnym) i zmniejszamy zmienną zagłębienia o 1}
MenuItemAct:=MenuItemAct.Parent;
Sp:=Sp-1;
end;
If Level(Structure[I])>Sp then begin {Jeśli jest więcej spacji, niż zagłębienie, to "wchodzimy" w ostatni element i tworzymy elementy podrzędne}
Sp:=Sp+1;
If (Level(Structure[I])>Sp) or (I=0) then begin {Jeśli jest więcej niż jeden poziom różnicy, to wyskakujemy z błędem.}
ShowMessage('Poważny błąd w strukturze menu! Aplikacja zostaje przerwana!');
Application.Terminate;
end;
MenuItemAct:=MenuItemAct.Find(BezSpacji(Structure[I-1]));
end;
If MenuItemAct.Find(BezSpacji(Structure[I]))=nil then begin {Jeśli taki element jeszcze nie istnieje, to tworzymy go}
MenuItemNew:=TMenuItem.Create(Form1);
MenuItemNew.Caption:=BezSpacji(Structure[I]);
MenuItemAct.Add(MenuItemNew);
end;
end;
end;

procedure TForm1.FormCreate(Sender: TObject);
var
sr: TSearchRec;
DLLe: array of THandle;
QueryMenu: function: TStringList; stdcall;
Initialize: procedure(Form: TForm; PluginsMenu: TMenuItem); stdcall;
begin
SetLength(DLLe, 0); //tak dla bezpieczeństwa
if FindFirst(ExtractFilePath(Application.ExeName)+'*.dll', faAnyFile, sr) = 0 then repeat
{tu wstawimy obsługę każdego dll'ka w katalogu}
SetLength(DLLe, Length(DLLe)+1); // Zwiększamy ilość DLL'ków
DLLe[Length(DLLe)-1] := LoadLibrary(PChar(sr.Name)); // Ładuj bibliotekę
try
@QueryMenu := GetProcAddress(DLLe[Length(DLLe)-1], 'QueryMenu');
if @QueryMenu = nil then raise Exception.Create('Bład - nie mogę znaleźć proceudry w bibliotece!');
IncludeMenu(QueryMenu, Self.Menu.Items);
@Initialize := GetProcAddress(DLLe[Length(DLLe)+1], 'Initialize');
if @Initialize = nil then raise Exception.Create('Bład - nie mogę znaleźć proceudry w bibliotece!');
Initialize(Self, Self.Menu.Items);
finally
end;
until FindNext(sr) <> 0;
FindClose(sr);
end;

procedure TForm1.FormClose(Sender: TObject; var Action: TCloseAction);
var
DLLe: array of THandle;
begin
While Length(DLLe)>0 do begin
FreeLibrary(DLLe[Length(DLLe)-1]);
SetLength(DLLe, Length(DLLe)-1);
end;
end;

end.

plugin.dpr

library plugin;

uses
SysUtils,
Classes,
Forms,
Menus;

type TObiekt = object
procedure Menu1Click(Sender: TObject);
procedure Menu2Click(Sender: TObject);
procedure Menu3Click(Sender: TObject);
end;

var Obiekt: TObiekt;

procedure TObiekt.Menu1Click(Sender: TObject);
begin
{Tu obsługuje się tylko kliknięcia w pierwszą opcję ;) }
end;

procedure TObiekt.Menu2Click(Sender: TObject);
begin
{Tu obsługuje się tylko kliknięcia w drugą opcję ;) }
end;

procedure TObiekt.Menu3Click(Sender: TObject);
begin
{Tu obsługuje się tylko kliknięcia w trzecią opcję ;) }
end;

function QueryMenu: TStringList; stdcall;
begin
Result:=TStringList.Create;
Result.Add('Wtyczka Submenu');
Result.Add(' Opcja1');
Result.Add(' Opcja2');
Result.Add('Opcja3'); {ta opcja będzie na poziomie submenu, nie wejdzie w jego skład, w przeciwieństwie do opcji 1 i 2}
end;

procedure Initialize(Form: TForm; PluginsMenu: TMenuItem); stdcall;
var
MenuItem: TMenuItem;
begin
MenuItem:=PluginsMenu.Find('Wtyczka Submenu').Find('Opcja1');
If MenuItem<>nil then MenuItem.OnClick:=Obiekt.Menu1Click;

MenuItem:=PluginsMenu.Find('Wtyczka Submenu').Find('Opcja2');
If MenuItem<>nil then MenuItem.OnClick:=Obiekt.Menu2Click;

MenuItem:=PluginsMenu.Find('Opcja3');
If MenuItem<>nil then MenuItem.OnClick:=Obiekt.Menu3Click;
end;

exports
QueryMenu,
Initialize;

begin
end.