PORADY

Porady > Programowanie > Delphi > Proszę czekać... - jak informować o trwających operacjach programu

Proszę czekać... - jak informować o trwających operacjach programu

Zadaniem każdej aplikacji użytkowej jest prowadzić obliczenia. Niekiedy te obliczenia są błyskawiczne, innym razem bardzo długie, a są sytuacje, w których czas trwania jest zróżnicowany i zależny od wielu czynników do tego stopnia, że programista nie jest w stanie z góry przewidzieć, ile dana operacja będzie mogła trwać. Jak zatem postępować, aby w każdej z tych sytuacji aplikacja pracowała poprawnie, a przede wszystkim nie doprowadzała użytkownika do irytacji?

Spis treści

Kiedy użytkownik się zirytuje...

Aby rozwiązać ten problem, na początek kilka faktów. Ludzkie oko rejestruje średnio ok. obrazów w ciągu sekundy. Z tego faktu wynika, iż obraz, który wyświetli się przez mniej niż 5 ms nie zostanie wychwycony przez użytkownika. Niemniej, pamiętać trzeba, że wciąż gro monitorów wyświetla obraz z częstotliwością zaledwie 60 Hz, więc w zasadzie każda klatka będzie widoczna, jednocześnie coś, co trwa mniej niż 16,6 ms nie zostanie nawet wyświetlone. Drugim istotnym faktem jest czas reakcji człowieka – ten średnio wynosi , choć osoba ćwicząca refleks (np. kierowcy F1, piloci samolotów albo zawodowi gracze e-sportowi) może zejść z tym czasem nawet do 120 ms. Niemniej, przeciętny użytkownik będzie odznaczał się tym pierwszym. Jest to o tyle istotne, że jego własny czas reakcji będzie oczekiwany wobec innych, a również wobec programu komputerowego. Jeśli program nie będzie reagował w ciągu tego czasu, to w użytkowniku zaczyna pojawiać się odczucie spowolnienia, „zacinania się” programu i niekomfortowej pracy. Jeśli czas ten przekroczy kilka sekund, to pojawia się już najczęściej podirytowanie, a kilkanaście sekund braku reakcji zostanie uznane za zawieszenie się programu, szczególnie w sytuacji, w której dana osoba się nie spodziewa, aby coś mogło trwać tak długo. Wtedy w najlepszym razie najczęściej po prostu „zabije” aplikację (a system może w tym chętnie pomóc), a w najgorszym spadający komputer może nawet zabić spacerowicza przechadzającego się pod oknem. ;)

Kiedy system się zirytuje...

W przekonaniu użytkownika, że aplikacja nie działa, bardzo chętnie pomaga też sam system Windows. Z pewnością każdy zetknął się sytuacją, w której okienko jaśnieje, a nagłówek rozszerza się o treść „(brak odpowiedzi)”.

zawieszona aplikacja z nagłówkiem brak odpowiedzi

Jak to się dzieje i czemu w tym momencie okienko można przesuwać? Przede wszystkim system Windows określa, czy aplikacja działa (i odpowiada) na podstawie komunikatów wysyłanych do tej aplikacji. Komunikatem może być m.in. naciśnięcie klawisza klawiatury lub myszki, ale też istnieje szereg innych. Komunikaty odkładane są do kolejki. Co bardzo istotne, system Windows nie sprawdza sam z siebie, czy aplikacja działa, to znaczy nie testuje jej w żaden sposób regularnie – zawsze inicjatorem jest komunikat. To dlatego, gdy w zajętą aplikację (a ściślej: jej główny wątek) się nie klika i nie rusza, nie będzie zasygnalizowane jej zawieszenie i będzie się wyświetlać zupełnie normalnie (np. odświeżać teksty w okienku). Drugą istotną rzeczą jest, że jeśli aplikacja nie ma swojego okna, system nie ma szans wykryć jej zawieszenia. Jeśli w tej kolejce najstarszy komunikat ma więcej niż 5 sekund i nie został w tym czasie zdjęcie przez aplikację (aplikacja go nie obsłużyła), wówczas system zaczyna ingerować. Co się dzieje? Całe okno jest ukrywane i zastępowane oknem-duchem – to normalnie okno, które jednak zawiera tylko ostatni obraz wyświetlany przez okno aplikacji, a jego tytuł rozszerzony jest o wspomniany tekst. To dlatego pozornie okna zawieszonych aplikacji można przeciągać, a interfejs przestaje się odświeżać, gdyż jest tylko obrazem. Próba zamknięcia tego okna wywołuje systemową formatkę umożliwiającą brutalne zakończenie procesu.

Zamykanie nieodpowiadającego okna w Windows 98, XP i nowszych

Wątek główny czy dodatkowy?

Gdy aplikacja tworzona jest na szybko, często wiele operacji wykonywanych jest w ramach wątku głównego. To sprawia, że w czasie wykonywania okno przestaje reagować. Oczywiście należy unikać takich sytuacji, ale czy za wszelką cenę? Trzeba mieć na uwadze, że samo utworzenie wątku jest bardzo kosztowne. W sukurs może przyjść klasa TTask dostępna w nowszych wersjach kompilatora (bodaj od XE7) w module System.Threading, ale nie za darmo. Pierwsze zadanie z jej użyciem będzie równie obciążone czasem, co w przypadku tradycyjnego wątku tworzonego za pośrednictwem TThread, ale kolejne wywołania już nie. Dzieje się tak, ponieważ wątek nie jest niszczony w tym przypadku i może być ponownie użyty na potrzeby kolejnych zadań. Niemniej, każdorazowo obsługa wątku i komunikacja z wątkiem głównym jest kosztowna i stosowanie przy każdych obliczeniach może być pozbawione sensu. Niestety, programista musi sam przewidzieć, ile czasu dane operacje mogą zająć i zadecydować, czy można je wykonać w wątku głównym, czy dodatkowym. Dolnym kryterium będzie po prostu czas reakcji. Jednocześnie obligacyjnie powinien być użyty wątek dla operacji, których czas trwania przekraczać będzie już ok. 3 sekund – wynika to ze specyfiki systemu Windows. Czemu 3, a nie 5? Zawsze trzeba przyjąć margines na wiele innych okoliczności, które spowolnią wykonywanie nawet pozornie szybkiego i mało zależnego od okoliczności kodu.

Można by jednak zadać pytanie, czemu nie warto tworzyć zawsze już powyżej 0,25 sek? Jeśli aplikacja będzie poprawnie wykonana, a użytkownik dostanie informację lub będzie się spodziewał dłuższego czasu trwania operacji, którą wywołał, to jego tolerancja znacznie wzrasta i nie będzie mu przeszkadzać aż tak brak odpowiedzi programu przez nieco dłuższy czas.

Warto uwzględnić jeszcze jedną rzecz, która może zaskoczyć użytkowników Delphi w wersji 7 lub wcześniejszych – obecnie, gdy wątek główny jest zajęty, nie będą wyświetlane również animacje komponentu TAnimate. To również może skłonić do szybszego przeniesienia obliczeń na osobny wątek. Z kolei pamiętać trzeba, że każdorazowo odwołując się do komponentów VCL z poziomu wątku, trzeba to robić poprzez metodę Synchronize lub Queue klasy TThread, jednocześnie tak zaprojektować, aby integracja z interfejsem była przez możliwie najkrótszy czas – inaczej wątek pozbawiony będzie sensu.

Gdy wątek pracuje...

Ok., zadania obliczeniowe zostały przeniesione do wątku. Tylko teraz wątek główny reaguje na komunikaty i na przykład możliwe jest wielokrotne rozpoczęcie tego samego obliczenia, albo zamknięcie programu przed ukończeniem działania wątku. Jak temu zaradzić?

Można wyłączyć właściwość Enabled wszystkich widocznych kontrolek. Wygląda to bardzo estetycznie, ale jest niezwykle czasochłonne oraz powoduje problemy z przywróceniem ich pierwotnego stanu. Ponadto okno główne wciąż może wywoływać niechciany kod (choćby zamknięcia).

Drugą opcją jest wyłączenie właściwości Enabled dla całego okna. Niestety to rozwiązanie ma taką wadę, że wyłączy też obsługę wszelkich zdarzeń, które obsłużyć chcemy, np. przeniesienie lub zmiana rozmiaru okna.

Pewnym wybiegiem może być otwarcie okna modalnego na czas wykonywania obliczeń. Takie okno jest łatwiej zaprojektować i kontrolować pod względem dostępnych funkcji, ale tak naprawdę odbiera też dostęp do poprzedniego okna, więc tamto okno wciąż jest obarczone tymi samymi mankamentami, co przy jego wyłączeniu.

Każdorazowo, jeśli jakieś okno jest włączone, a nie może być zamknięte do czasu zakończenia wątku, należy pamiętać o obsłudze w zdarzeniu OnClose lub OnCloseQuery. Zdarzenie może reagować na ustawiane flagi, a może być permanentne, ale przypięcie funkcji sterowane odpowiednim miejscem w kodzie:

procedure FormClosePrevent(Sender: TObject; var CanClose: Boolean); begin CanClose := False; end; procedure StartCalcClick(Sender: TObject); begin Self.OnCloseQuery := FormClosePrevent; TTask.Create( procedure begin // obliczenia TThread.Synchronize(TThread.Current, procedure begin FormMain.OnCloseQuery := nil; end); end).Start; end;

Gdy zadania są wykonywane na wątku głównym

Dla prostoty spora część, głównie początkujących programistów Delphi, wykonuje zadania w wątku głównym, a żeby poradzić sobie z systemem, stosuje w trakcie przetwarzania polecenie pozwalające na obsłużenie aktualnej kolejki komunikatów: Application.ProcessMessages. Przy dobrze zaprojektowanej aplikacji jest to możliwe, by działać w ten sposób, ale poprawne wykorzystanie wcale nie jest tak proste, jak się wydaje.

Po pierwsze, należy – podobnie jak przy użyciu wątków – dobrze przemyśleć, które zdarzenia faktycznie mogą wywoływać swój pierwotnie założony kod, albo które z elementów formatki wymagają zablokowania. Tak naprawdę w tym zakresie wcale nie rozróżnia to ilości pracy, jaką trzeba włożyć w projektowanie i obsługę interfejsu.

Drugim problemem jest częstość wywołań podanej metody. Jeśli będzie wywoływana zbyt często – ograniczy wydajność obliczeń. Jeśli zbyt rzadko – nie przyniesie oczekiwanego rezultatu. Stąd może się sprawdzić tylko w tych sytuacjach, gdzie potencjalna częstość wywołań jest możliwa do przewidzenia, a ich interwał nie większy niż ok. 3 sekundy.

Komunikacja wątku z interfejsem

Aby wątek mógł wpłynąć na interfejs, możliwe są cztery podstawowe rozwiązania i są do siebie podobne. Możliwe jest wykonanie czegoś na interfejsie i zaczekanie na zakończenie zadania, albo zlecenie bez czekania na rezultat. Pierwszy przypadek wykorzystywany będzie, gdy aktualność informacji jest istotna. W drugim przypadku, gdy nie ma znaczenia, kiedy przesłane polecenia się wykonają, a z dużym prawdopodobieństwem zadania obliczeniowe będą trwać dłużej, niż praca związana z interfejsem (jest to istotna rzecz w kontekście zapchania kolejki komunikatami, które nie zdążą być obsłużone). Można do tego wykorzystać albo komunikaty systemowe, albo gotową metodę zgodnie z poniższą tabelką:

BezpośrednioKomunikatem
Oczekujące na wykonanieTThread.SynchronizeSendMessage
Nieoczekujące na wykonanieTThread.QueuePostMessage

Komunikaty sprawdzą się przy przesyłaniu najprostszych sygnałów (choć można ich użyć do przesyłania złożonych danych). Z kolei metody bezpośrednie pozwalają na pisanie prostego kodu operującego na wielu danych czy komponentach.

Poniższy przykład pokazuje użycie wszystkich czterech metod, wykorzystując przy okazji dobrodziejstwa procedur anonimowych:

const WM_MSG_FROM_THREAD = WM_USER + 1; type TForm1 = class(TForm) Button1: TButton; pnl1: TPanel; procedure Button1Click(Sender: TObject); private procedure OnMessageFromThread(var aMsg: TMessage); message WM_MSG_FROM_THREAD; end; implementation type TSynType = (stSynchronize = 0, stQueue = 1, stSendMessage = 2, stPostMessage = 3); TTest = class(TThread) strict private fSynType: TSynType; fMessageReceiver: THandle; protected procedure Execute; override; public constructor Create(const aSynType: TSynType; const aMessageReceiver: THandle); reintroduce; end; procedure TForm1.Button1Click(Sender: TObject); begin TTest.Create(stSynchronize, Self.Handle).Start; end; procedure TForm1.OnMessageFromThread(var aMsg: TMessage); begin pnl1.Caption := FormatFloat(',0', aMsg.WParam); end; { TTest } constructor TTest.Create(const aSynType: TSynType; const aMessageReceiver: THandle); begin Self.fSynType := aSynType; Self.fMessageReceiver := aMessageReceiver; inherited Create(True); Self.FreeOnTerminate := True; end; procedure TTest.Execute; var i: Integer; begin for i := 0 to 1000 do begin // obliczenia case Self.fSynType of stSynchronize: Self.Synchronize( procedure begin Form1.pnl1.Caption := FormatFloat(',0', i); end); stQueue: Self.Queue( procedure begin Form1.pnl1.Caption := FormatFloat(',0', i); end); stSendMessage: SendMessage(fMessageReceiver, WM_MSG_FROM_THREAD, i, 0); stPostMessage: PostMessage(fMessageReceiver, WM_MSG_FROM_THREAD, i, 0); end; end; // zapewnia, że wątek się nie zwolni do momentu, gdy nie zostanie przetworzona kolejka komunikatów Self.Synchronize( procedure begin end); end;

W tym miejscu warto przypomnieć, że o ile obiekt klasy TThread posiada własne metody, to w przypadku używania zadań lub pokrewnych rozwiązań z modułu System.Threading trzeba posłużyć się klasowymi odpowiednikami tych funkcji w sposób, jaki został zastosowany w poprzednim przykładzie.

W powyższym przykładzie użyto dodatkowo pewnej sztuczki przydatnej podczas używania wysyłania wiadomości bez czekania na ich wykonanie (PostMessage lub Queue). Choć nie jest to zawsze wymagane, to czasem może być przydatne. Aby osiągnąć taki efekt, należy użyć funkcji SendMessage lub, jak w powyższym przykładzie, metody Synchronize zawierającej pustą procedurę. W ten sposób wątek zakończy się na pewno dopiero po tym, jak wszystkie jego wiadomości będą obsłużone.

Jak często odświeżać informacje i jak ten czas wyegzekwować?

W powyższym przykładzie odświeżanie realizowane jest przy każdym przebiegu pętli. Czy jednak to dobre rozwiązanie? Jeśli jeden przebieg będzie trwał bardzo krótko, to nikt nie będzie w stanie odczytać przekazanych informacji, a jednocześnie będzie mocno rosło obciążenie wątku głównego – wszak wyświetlanie to jedne z najbardziej czasochłonnych operacji. Z drugiej strony, jeśli zostanie przekazana jakaś informacja, która nie będzie ulegała zmianie, użytkownik znów nabierze podejrzeć co do działania i postępów w operacji wykonywanej przez program. Warto więc tak dopilnować częstości odświeżania, aby użytkownik zarówno mógł bezproblemowo zapoznać się z informacją (dla prostych określeń składających się z dwóch lub trzech słów wystarczy pół sekundy; dla wartości numerycznej będzie to o połowę mniej), ale również się nią nie znudził.

Jak zatem uzyskać właściwą wartość? Podane wcześniej bezwarunkowe odświeżanie przy każdym przebiegu sprawdzi się w nielicznych przypadkach, gdy czas będzie zawierał się w pożądanym zakresie. Najczęściej jednak do czynienia mamy z dużo szybszymi przebiegami. W takich sytuacjach można ograniczyć częstość prostym warunkiem:

for i := 1 to 10000 do begin // operacja if i mod 100 = 0 then // przesłanie informacji o postępie end;

Powyższy kod będzie przekazywał informację zaledwie co setny przebieg pętli. Dodatkowy warunek najczęściej nie wpłynie znacząco na wydajność, a już z pewnością mniej, niż każdorazowe odświeżenie informacji. Ale jeśli zależy nam na minimalnym wpływie sprawdzania warunku, lepiej sprawdzi się operator binarny and:

for i := 1 to 10000 do begin // operacja if i and $7F = 0 then // przesłanie informacji o postępie end;

Powyższy warunek wywoła przesłanie informacji co 128 przebieg. Choć operator and ogranicza nieco spektrum użycia do biarnej maski kończącej się samymi jedynkami, to jest bardziej wydajny od operacji dzielenia.

Jednakże takie podejście nie sprawdzi się najlepiej. Będzie mocno uzależnione od wydajności komputera, na którym zostanie uruchomiona aplikacja oraz wyklucza operacje, których czas wykonania jest silnie zależny od innych czynników (np. pobieranie danych z internetu). Wydaje się, że rozwiązaniem byłoby sprawdzenie czasu choćby przy użyciu funkcji GetTickCount:

t := GetTickCount; for i := 1 to 10000 do begin // operacja if GetTickCount - t >= 250 then begin t := GetTickCount; // przesłanie informacji o postępie end; end;

Wracając do tematu użycia funkcji, okazuje się, że jej wywołanie i pobranie wyniku (który i tak jest zgrubny) w systemie przed Windows 10 zajmuje koszmarnie dużo czasu, co dyskwalifikuje jej użycie w sposób najbardziej intuicyjny, jeśli chcemy dostarczać optymalny program dla starszych wersji systemu. Jak zatem poradzić sobie z tym problemem? Mianowicie funkcję należy wykorzystać nie do obliczania czasu, lecz do jego szacowania i połączyć tą metodę z wcześniejszą poddając nieznacznej modyfikacji:

t := GetTickCount{64}; refresh_counter := 255; refresh_cycles := 255; for i := 0 to 1000000 do begin // operacja Dec(refresh_counter); if refresh_counter = 0 then begin t2 := GetTickCount + 1; refresh_cycles := ((250 * refresh_cycles) div (t2 - t)) + 1; refresh_counter := refresh_cycles; t := t2; // przesłanie informacji o postępie end; end;

Ten kod może wymagać nieco objaśnienia. Na początku wykonywania zadań w pętli ustala się niezerową wartość zmiennej refresh_cycles oraz tą samą wartość refresh_counter. Pierwsza z nich służy do zapamiętania, ile cykli się wykonało od ostatniego razu (a na początku – od początku działania). Jaka to powinna być wartość? Cóż, trzeba niestety strzelić w taką, która nie spowoduje, że na pierwsze przesłanie informacji o postępie będzie trzeba czekać zbyt długo. Jednocześnie nie może być na tyle mała, że czas będzie trwał mniej niż 16 ms co wynika z rozdzielczości, jaką oferuje funkcja GetTickCount. W przeciwnym razie szacunek będzie zupełnie nierzeczywisty. Druga to licznik, który wskaże, kiedy odświeżyć informacje o postępie zmieniający się do wartości 0 (porównanie z zerem jest najszybszą z operacji porównań – więcej na ten temat można znaleźć w poradzie Optymalny, wysokowydajny kod Delphi). Gdy nadchodzi czas przesłania informacji, na podstawie czasu minionego oraz liczby cykli jakie się odbyły, należy oszacować, za ile cykli przesłać kolejnym razem informację w ten sposób, aby nastąpiło to za mniej, więcej zadany czas (w przykładzie odpowiada za to liczba 250 z linii 11 kodu), dzieląc iloczyn założonego czasu i wykonanych przebiegów przez czas miniony. Można zadać pytanie, dlaczego w linii 10 i 11 kodu wartości są zwiększane o 1. W pierwszym przypadku eliminuje to możliwość wystąpienia dzielenia przez 0. W drugim przypadku powstrzymuje przed wskazaniem, że odświeżanie należałoby wykonać co 0 przebiegów – przynajmniej 1 musi się odbyć. Owszem, można by i jeden i drugi przypadek zabezpieczyć odpowiednimi warunkami. Niemniej, taka operacja dodania najczęściej nie zmieni w sposób znaczący wyniku, natomiast jest zapisem prostszym i wymagającym mniejszej liczby operacji ze strony procesora. Na koniec ponownie przypisuje się oszacowaną wartość do licznika (oraz wykonuje to, co wykonać się miało, czyli przesłanie informacji o postępie do okna) i cały algorytm się powtarza.

Ale przedstawione sposoby to nie jedyne rozwiązania. Zamiast zmuszać wątek to prowadzenia dodatkowych obliczeń czasu, można tej informacji od wątku zażądać, a o częstości może decydować mechanizm umieszczony w innym wątku czy – prościej – popularny systemowy timer implementowany przez obiekt klasy TTimer.

Pierwsza z tych możliwości polega na odczytaniu wartości jakiejś zmiennej, najlepiej pola wątku. Gdy wątek będzie zapisywał do tej zmiennej bieżący postęp, okno aplikacji, w odpowiedzi na zdarzenie wyzwalane czasowo, może taką wartość podejrzeć. Powinna to być prosta, atomowa zmienna – wówczas jest pewność, że po pierwsze jej zapis odbywa się pojedynczą operacją, a po drugie – zapis nie zrobi nic więcej, a odczyt będzie faktycznym odczytem. To sprawia, że w takim przypadku można pominąć kwestie związane z równoczesnym dostępem i nie ma potrzeby zakładania blokad czy używania sekcji krytycznych, przez co operacje mogą odbywać się szybko. Ważne jest tylko, aby wątek podczas kończenia pracy i zwalniania obiektu wyłączył aktywność timera i ewentualnie ostatni raz pobrać potrzebną informację. Trzeba też zwrócić uwagę na jeszcze jeden aspekt: Wyliczenie wartości przy każdym przebiegu i zapis do pola klasy nie jest najszybszy (musi odbyć się na pamięci), dlatego warto i tak ograniczyć częstość tej operacji.

interface TProgressInField = class(TThread) protected procedure Execute; override; public ThreadProgress: Int64; constructor Create; reintroduce; end; TForm1 = class(TForm) … tmrRefresh: TTimer; private fThread: TProgressInField; procedure ThreadEnd(Sender: TObject); end; implementation … procedure TForm1.Button1Click(Sender: TObject); begin fThread := TProgressInField.Create; fThread.OnTerminate := ThreadEnd; fThread.Start; tmrRefreshInfo.Enabled := True; end; procedure TForm1.tmrRefreshTimer(Sender: TObject); begin Label1.Caption := IntToStr(fThread.ThreadProgress) + '%'; end; procedure TForm1. ThreadEnd(Sender: TObject); begin tmrRefresh.Enabled := False; tmrRefreshTimer(Self); end; { TProgressInField } constructor TProgressInField.Create; begin inherited Create(True); Self.FreeOnTerminate := True; end; procedure TProgressInField.Execute; var i: Integer; begin for i := 0 to 1000000 do begin // operacja if i and $FFFF = 0 then // ograniczenie częstości dla lepszej wydajności Self.ThreadProgress := i div 10000; end; Self.ThreadProgress := 100; end;

Niestety, metoda może być problematyczna w użyciu, lub wręcz niemożliwa bez stosowania sekcji krytycznych, jeśli w ramach przekazywanych danych z wątku pojawiają się informacje choćby z dynamicznym przydziałem pamięci. Czy jest zatem rozwiązanie, aby to wciąż wątek wypychał informacje do interfejsu, ale tylko na żądanie? Tak! Można to osiągnąć korzystając z mechanizmu wiadomości, ale w nieco innej formie, niż ta najpowszechniej używana. Wysłanie wiadomości do wątku odbywa się przez funkcję PostThreadMessage, z kolei wątek może odczytywać te wiadomości wywołując już bardziej znaną PeekMessage. W odróżnieniu od GetMessage ta wersja nie czeka na wiadomość, więc nie wstrzymuje działania wątku. Usunąć wiadomość z kolejki można poprzez ustawienie odpowiedniej flagi w parametrze funkcji.

interface TPushOnRequest = class(TThread) protected procedure Execute; override; public constructor Create; reintroduce; end; TForm1 = class(TForm) … tmrRefresh: TTimer; private fThread: TPushOnRequest; procedure ThreadEnd(Sender: TObject); end; implementation … procedure TForm1.Button2Click(Sender: TObject); begin fThread := TPushOnRequest.Create; fThread.OnTerminate := ThreadEnd; fThread.Start; tmrRefreshInfo.Enabled := True; end; procedure TForm1.tmrRefreshTimer(Sender: TObject); begin PostThreadMessage(fThread.ThreadID, WM_USER, 0, 0); end; procedure TForm1.ThreadEnd(Sender: TObject); begin tmrRefresh.Enabled := False; end; { TPushOnRequest } constructor TPushOnRequest.Create; begin inherited Create(True); Self.FreeOnTerminate := True; end; procedure TPushOnRequest.Execute; var i: Integer; begin for i := 0 to 1000000 do begin // operacja if i and $FFFF = 0 then // ogranicza częstość wywołań poniższej funkcji dla zmniejszenia wpływu na wydajność if PeekMessage(Msg, 0, 0, 0, PM_REMOVE) then begin if Msg.Message = WM_USER then Synchronize( procedure begin Form1.Label1.Caption := IntToStr(i div 10000) + '%'; end); end; end; Synchronize( procedure begin Form1.Label1.Caption := '100%'; end); end;

Niestety, każdorazowe sprawdzenie kolejki wiadomości znów jest czasochłonne, dlatego ponownie w przypadku pętli o bardzo krótkim przebiegu, lepiej metodę tą połączyć z ograniczeniem częstości wywołania funkcji sprawdzającej. Natomiast niewątpliwie do zalet należy tu możliwość implementacji obsługi różnych komunikatów, które dodatkowo jeszcze mogą posiadać parametry, a wiadomości mogą być przekazywane bez dodatkowych mechanizmów z wielu wątków na raz.

Pomysł 1. Kursor

Przejdźmy do konkretnych rozwiązań interfejsowych. Najprostszą metodą jest zmiana kursora myszy na czas trwania operacji. Taki sposób zasygnalizowania zajęcia programu dobrze sprawdzi się zarówno do krótszych operacji, jak i może być uzupełnieniem tych dłuższych. Jednak, gdy operacja trwa mniej niż 250 ms, przełączający się kursor może drażnić, niż spełniać swoją rolę. Dlatego w przypadku bardzo szybkich operacji lepiej zostawić go w spokoju. Nieopatrznie taki efekt można też osiągnąć, gdy wykonujemy dwie lub trzy atomowe operacje w ramach obsługi zdarzenia, a każda z nich samodzielnie ustawia i przywraca kursor – wówczas również doprowadzimy do niepotrzebnego migania. Dlatego warto we wszystkich miejscach zmiany kursora nie przywracać sztywno crDefault, lecz poprzednią wartość. Wówczas takie operacje mogą dalej dla pewności i wygody pracować po swojemu, ale mogą być też współgrać z niezależnym ustawieniem kursora:

var oldCurr: TCursor; begin oldCurr := Screen.Cursor; Screen.Cursor := crHourGlass; try // operacja finally Screen.Cursor := oldCurr; end; end;

W celu zwiększenia atrakcyjności, można pokusić się o użycie jakiegoś własnego, ciekawego lub pasującego do stylu kursora (w tym animowanego). Aby dodać własny kursor i zintegrować go bezpośrednio z aplikacją, można w prosty sposób uczynić to wg następującej instrukcji:

  1. Z menu Project środowiska z otwartym projektem aplikacji, wybieramy pozycję Resources and images...
  2. W okienku klikamy przycisk Add...
  3. Wskazujemy plik z rozszerzeniem cur lub ani (dla kursorów animowanych)
  4. Wpisujemy własną nazwę oraz wybieramy typ zasobu (Resource type) jako CURSOR

Okno dodawania zasobów projektu

To wszystko, kursor będzie od teraz połączony z plikiem wynikowym projektu. Pozostaje już tylko załadować z zasobu kursor podczas uruchomienia, a później wykorzystać. Dla własnych kursorów należy posługiwać się dodatnim wartościami typu TCursor; ujemne zarezerwowane są na kursory systemowe:

Screen.Cursors[1] := LoadCursor(hInstance, 'FunnyCursor1'); Screen.Cursor := 1;

Do zalet użycia kursora należą praktycznie pomijalny wpływ na czas wykonywania, prostota użycia oraz bezobsługowość – nawet animowany kursor nie wymaga od nas żadnych dodatkowych czynności czy trosk, jak również nie przeszkadza mu zajętość głównego wątku programu.

Pomysł 2. Okno modalne

Najczęściej bogaty interfejs jest trudny do obsłużenia i zablokowania w momencie, gdy chcemy wykonać jakąś operacje na wątku, a jednocześnie nie chcemy, aby użytkownik w tym czasie mógł zrobić cokolwiek innego, z zamknięciem programu włącznie. Wyłączenie całego okna zrea unit FormWait; interface uses Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls; type TfrmWait = class(TForm) lblProgress: TLabel; procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean); procedure FormShow(Sender: TObject); procedure FormClose(Sender: TObject; var Action: TCloseAction); private fOldCursor: TCursor; public procedure Finish; procedure SetText(const s: String); function ShowModal: Integer; override; end; implementation {$R *.dfm} procedure TfrmWait.Finish; begin Self.OnCloseQuery := nil; Self.ModalResult := mrOk; end; procedure TfrmWait.FormClose(Sender: TObject; var Action: TCloseAction); begin Screen.Cursor := fOldCursor; Action := TCloseAction.caFree; end; procedure TfrmWait.FormCloseQuery(Sender: TObject; var CanClose: Boolean); begin CanClose := False; end; procedure TfrmWait.FormShow(Sender: TObject); begin fOldCursor := Screen.Cursor; Screen.Cursor := crHourGlass; end; procedure TfrmWait.SetText(const s: String); begin lblProgress.Caption := s; end; function TfrmWait.ShowModal: Integer; begin if Assigned(Self.OnCloseQuery) then Result := inherited else Result := Self.ModalResult; end; end.

Krótkie omówienie: Publikowanymi metodami są Finish służąca do sygnalizowania konieczności zamknięcia okna, SetText pozwalająca zmienić treść elementu opisowego na oknie oraz przedefiniowana ShowModal, o czym więcej za chwilę.

Całe okno dodatkowo ma obsługę zdarzeń OnClose, które przywraca pierwotny kursor zapamiętany podczas otwierania okna poprzez zdarzenie OnShow. Ponadto to ostatnie ustawia kursor na klepsydrę. Procedura zdarzenia OnCloseQuery uniemożliwia jakiekolwiek zamknięcie okna.

Ważne jest, aby po utworzeniu okna uruchomić wątek, który na zakończenie wywoła metodę Finish tego okna. Dopiero po uruchomieniu wątku wywołujemy metodę ShowModal formatki, gdyż w tym punkcie przetwarzanie dalszego kodu wątku głównego zostanie wstrzymane. Metoda Finish wykonuje dwie rzeczy: najpierw odpina procedurę obsługi zdarzenia OnCloseQuery dzięki czemu okno może zostać zamknięte. Druga rzecz to zamknięcie tego okna, które – w przypadku okna modalnego – wykona się po ustawieniu własności ModalResult (nie jest to niezbędne, ale może być praktyczne).

A co, jeśli wątek w jakichś dziwnych okolicznościach wywoła metodę Finish zanim zdąży się wywołać ShowModal? Przed tym właśnie ma zabezpieczyć redefinicja tej standardowej metody – jeśli zdarzenie zamknięcia nie jest już przypięte, to znaczy, że okno ma być zamknięte. Wówczas nie jest wywołana standardowa procedura ShowModal.

Na koniec wystarczy okno zwolnić. Nie może ono zrobić tego samodzielnie, bo nie wiemy, czy zamknięcie okna będzie zainicjowane z wątku zadania czy spoza niego.

var fModalForm: TfrmWait; begin fModalForm := TfrmWait.Create(Self); TTask.Create( procedure begin // operacja TThread.Synchronize(TThread.Current, procedure begin fModalForm.Finish; end); end).Start; fModalForm.ShowModal; fModalForm.Free; end;

Trzeba jednak pamiętać, że takie okno używane dla krótkich operacji, które po prostu mignie na moment, będzie znów bardziej powodem do irytacji, niż spełniało swoją funkcję. Kiedy więc stosować okno? Najlepiej wtedy, kiedy wyświetlona informacja będzie mogła być spokojnie odczytana. Czyli użytkownik musi mieć czas na reakcję na pojawienie się okna i musi zdążyć przeczytać tekst. W praktyce, w zależności od długości tekstu, może to być od ok. pół sekundy do nawet kilku sekund.

Pomysł 3. Pasek postępu

Ten element interfejsu jest znany wszystkim i najbardziej kojarzony z wykonywaniem operacji. Jest chyba też najlepiej odbieranym, o ile tylko nie wprowadza w błąd bądź to nie dochodząc do końca, bądź – co gorsza – po dojściu do końca zaczynając ponownie od początku lub przekraczając swój zakres.

Kiedy więc najlepiej zastosować? Po pierwsze trzeba znać aktualny postęp wykonywanych zdań. Po drugie dobrze by było, aby czas trwania przez cały przebieg był w miarę jednorodny. Wówczas to rozwiązanie sprawdzi się doskonale dla zadań trwających od kilkunastu sekund do kilkunastu minut. Czemu nie dłużej? Jeśli ktoś pamięta proces instalacji wczesnych wersji Windowsa, gdzie pasek postępu potrafił stać w miejscu przez dłuższy czas, ten pamięta też pewnie najeżdżanie kursorem w celu dostrzeżenia – przesunął się, czy nie? Działa, czy się zawiesiło? Postęp na pasku musi być zauważalny. Dlatego dobrze jest dobrać też jego wielkość do przewidywanego czasu trwania.

Standardowy komponent TProgressBar dostępny w Delphi i będący odzwierciedleniem komponentu systemu Windows ma jeszcze jedną zaletę, która pretenduje go do wykorzystywania – jest całkowicie bezpieczny wątkowo. A to za sprawą, że sterowani nim odbywa się poprzez system wiadomości. Zatem odwołując się do niego z wątku i zwiększając jego wskazanie nie trzeba się martwić o synchronizację z wątkiem głównym. Można zarówno wywołać śmiało funkcję StepIt, ale można też wysłać bezpośrednio wiadomość, jeśli maksymalnie zależy na oszczędzeniu zbędnej pracy:

uses WinAPI.CommCtrl; // Zawiera definicję wartości PBM_ … procedure TMyThread.Execute; begin Form1.ProgressBar1.StepIt; SendMessage(Form1.ProgressBar1.Handle, PBM_STEPIT, 0, 0); PostMessage(Form1.ProgressBar1.Handle, PBM_STEPIT, 0, 0); end;

Wszystkie trzy sposoby przedstawione wyżej są poprawne. Należy tylko zwrócić uwagę na różnicę między działaniem SendMessage a PostMessage – w tym drugim przypadku nie będzie oczekiwania na przetworzenie komunikatu. Pozwala to znacznie szybciej działać zadaniu obliczeniowemu, ale wskazanie postępu może być opóźnione względem faktycznego stanu. Niemniej, sam komponent ma zawartą w sobie animację i opóźnienie, więc tak naprawdę i tak jego wskazanie nie jest dokładne w danym momencie, stąd polecam korzystanie właśnie z takiego pozostawienia wiadomości.

Pomysł 4. Okno z listą etapów

Okno z listą etapów

Takie okno nadaje się wszędzie tam, gdzie cała operacja składa się z kilku odrębnych etapów. W odróżnieniu od prostego okna z jednym tekstem, takie okno z etapami ma szereg zalet. Po pierwsze można w nim umieszczać nawet bardzo krótkotrwałe etapy, gdyż użytkownik i tak zdąży zapoznać się z treścią. Po drugie daje dobry pogląd na ilość zadań, jakie zostały wykonane, a jakie zostały jeszcze do wykonania. W przypadku wystąpienia błędu na którymś z etapów, okno daje bardzo czytelną informację. Takie okno może być też połączone z paskami postępu w przypadku etapów dłużej trwających, ale mających swój postęp pracy.

Powyżej pokazane okno ukazuje postęp korzystając z następującej metody:

procedure Tfrm11.Step(const aNo: Integer; const aStatus: TStepStatus); var img: TImage; lbl: TLabel; begin img := TImage(FindComponent('imgStep' + IntToStr(aNo))); lbl := TLabel(FindComponent('lblStep' + IntToStr(aNo))); lbl.Enabled := aStatus in [ssRunning, ssOk, ssError]; if aStatus = ssRunning then lbl.Font.Style := [fsBold] else lbl.Font.Style := []; if aStatus >< ssNo then ImageList1.GetIcon(Integer(aStatus) - 1, img.Picture.Icon) else img.Picture.Icon.Assign(nil); end;

Warto w powyższym kodzie zwrócić uwagę na sposób podmieniania grafiki – jest to realizowane przez metodę GetIcon komponentu TImageList ze wskazaniem obiektu ikony na danym komponencie TImage. Dlaczego ikona, a nie na przykład bitmapa? Ponieważ nie wymaga wcześniejszego zerowania (poprzez przypisanie nil) wartości obiektu – w przypadku bitmapy taka dodatkowa operacja jest konieczna.

Teraz wystarczy tylko taką metodę wykorzystać bądź to bezpośrednio wywołując w wątku (poprzez metodę Synchronize), bądź wywołując ją w odpowiedzi na wiadomość z wątku.

Pomysł 5. Wyświetlanie częściowych wyników

Poprzednie pomysły (z wyjątkiem kursora) opierały się na znajomości ilości jednorodnych lub zróżnicowanych operacji do wykonania oraz aktualnym postępie prac. Dzięki temu zawsze użytkownik nie tylko widzi, że program działa, ale też może oszacować (lub nawet można mu przedstawić wprost taką informację), jak długo jeszcze program będzie zajęty. Jednak co w sytuacji, gdy nie jest znana ilość operacji, jakie jeszcze oczekują na wykonanie lub nie znany jest postęp wykonywania? Można z takim problemem spotkać się na przykład pobierając wyniki zapytania z bazy danych, podczas przesyłania danych w postaci strumieniowej lub gdy wynik poprzedniej operacji wpływa na ilość kolejnych (np. losowanie liczb do momentu, gdy dwie kolejne będą takie same).

Rozwiązaniem takiej sytuacji może być cykliczne (co ok. 0,2 do 1,0  sek.) odświeżanie aktualnych wyników lub statystyk tych wyników. W ten sposób nie tylko użytkownik ma poczucie, że program cały czas działa. Dodatkowo może on obserwować, w jakim tempie wyniki napływają lub są generowane, a ponadto może on z otrzymanymi już się zapoznać i przykładowo na tej podstawie zadecydować, czy chce oczekiwać dalszych, czy może przerwie operację, gdyż już przedstawione wyniki są wystarczające, albo uświadamiają użytkownika o braku ich wartości i niezgodność z założeniami.

Dla zobrazowania tej metody posłużę się operacją pobierania danych z bazy. W tym celu na oknie umieszczone zostają komponenty TDBGrid, TJvMemoryData (innym podobnym komponentem jest TkbmMemTable; oba dziedziczą po TDataSet) służący do przechowywania danych w pamięci w postaci, jakiej dostarczają bazy danych oraz TDataSource, który łączy oba wcześniej wymienione umożliwiając prezentację danych na formatce oraz kilka pomocniczych. Ciągłe wyświetlanie każdej jednej pobranej danej będzie bardzo skutecznie ograniczać wydajność działania, zatem kluczowe w tej metodzie będzie, aby dane wyświetlane były tylko co pewien interwał. Poniżej znajduje się esencja z kodu wątku, który ma za zadanie pobrać dane z bazy danych oraz zapisać je w lokalnej tabeli:

procedure TGetData.Execute; begin Self.fToExecute := True; while not ds.EOF do // pętla trwająca do momentu dojścia do końca pobranych danych begin ReadData; // Funkcja odczytująca dane i zapisująca je tymczasowo w obszarze pamięci Self.Synchronize(InsertRecord); // Funkcja przekładająca dane z pamięci do komponentu TJvMemoryData if Self.fToExecute and ({ czas od odświeżenia > 200}) then // Jeśli można wykonać ponowne odświeżenie oraz minął czas 200ms… begin Self.fToExecute := False; // Blokowanie próby ponownego odświeżania Self.Queue(procedure begin Form1.memTable.EnableControls; // Pozwala na odświeżenie zawartości komponentu TDBGrid Form1.lblRecCnt.Caption := 'Pobranych rekordów: ' + IntToStr(Form1.memTable.RecordCount); Form1.memTable.DisableControls; // Ponowne zablokowanie odświeżania Self.fToExecute := True; // Wskazanie, że może nastąpić zlecenie odświeżenia end); end; end; Self.RemoveQueuedEvents(Self); // Usunięcie niepotrzebnie oczekującego zlecenia odświeżenia end;

Przed uruchomieniem wątku oczywiście należy przygotować odpowiednio tabelę czyszcząc ją oraz wyłączając aktywność kontrolek. Pętla wątku pobiera kolejne rekordy. Te odczytywane są i zapisywane lokalnie w pamięci. Dopiero później następuje przekazanie ich do TJvMemoryTable. Dlaczego tak? Ponieważ pobranie danych z bazy może zająć czas. Niepotrzebne byłoby obciążanie wątku głównego tą operacją. Dopiero, gdy już dane są gotowe, zostaną – dla bezpieczeństwa z użyciem Synchronize odłożone w komponencie formatki. Następnie następuje sprawdzenie, czy wyniki powinny zostać odświeżone. Tu istotna jest flaga fToExecute, która zapewnia, że tylko jedno zadanie na raz będzie mogło być zlecone. Jest to niezbędne w sytuacji, gdy zadanie odświeżenia nie jest synchronizowane, a kolejkowane, aby nie obciążać dodatkowo wątku. Drugim warunkiem jest sprawdzenie częstości. Aby nie komplikować przykładu, zostało to pominięte, ale należy użyć jednej z metod, które przedstawione były wcześniej w tekście, aby wyznaczyć, jak często wyniki mają być odświeżone na formatce. W ramach odświeżenia włączana jest na moment aktywność TDataSet. Dzięki temu wyświetlane przez TDBGrid dane zostaną zaktualizowane. Dodatkowo użytkownik otrzyma też informację o liczbie pobranych dotychczas rekordów. Na koniec odblokowywana jest możliwość ponownego zlecenia odświeżenia. Na zakończenie działania wątku usuwane jest polecenie odświeżenia, gdyby takowe pozostało jeszcze niezrealizowane – nie ma ono sensu, gdyż po zakończeniu pobierania permanentnie zostanie włączona aktywność kontrolek. Warto również wówczas zasygnalizować użytkownikowi, że pobieranie zostało zakończone.

Na zakończenie

Powyżej przedstawiono 5 pomysłów, których zastosowanie zależy zarówno od przewidywanego czasu trwania operacji, jak i posiadanych informacji. Podsumujmy zatem, w jakich sytuacjach dane rozwiązanie sprawdza się najlepiej.

Kursor
  • Zawsze dla operacji >250 ms
Proste okno
  • Dla operacji od kilku do kilkudziesięciu sekund
  • Możliwość wzbogacenia o opis aktualnej czynności, co umożliwia użycie do operacji trwających kilka minut
Pasek postępu
  • Od kilkunastu sekund do kilkunastu minut
  • Głównie dla operacji o jednorodnym czasie postępu
Okno z listą etapów
  • Gdy łączny czas przekracza kilka sekund, a operacje nie są jednorodne
  • Gdy informacja o etapie może być istotna
Wyświetlanie części wyników
  • Gdy łączny czas jest nieprzewidywalny
  • Gdy chcemy pokazać szczegółowe informacje o już dostępnych wynikach

Oczywiście nie wyczerpuje to wszystkich możliwości i sposobów prezentacji postępu prac, a co więcej część z rozwiązań może być dowolnie łączona ze sobą. Najważniejszym jest wzbudzenie u użytkownika właściwej tolerancji na długotrwałość wykonywanych operacji, dobranie właściwego rozwiązania oraz unikanie sytuacji, gdy będzie on zarówno dociekał, co przed chwilą program zakomunikował, lecz nie zdążył z informacją się zapoznać, jak również wzbudzania poczucia zawieszenia się i braku działania programu. Warto pamiętać również, że wyświetlane informacje, często nie tylko mogą zaspokoić ciekawość i uspokoić użytkownika, ale także być źródłem wiedzy dla programisty o działaniu jego programu.

Informacje o poradzie
Poradę czytano:187 razy
Ocena porady:
6,00 (1 oceniających)
(Kliknij właściwą gwiazdkę, by oddać głos)

Wróć

Komentarze (0)


Ładowanie komentarzy... Trwa ładowanie komentarzy...

Uwaga! Wszystkie porady na tej stronie są mojego autorstwa i objęte są prawami autorskimi! Kopiowanie, publikowanie lub cytowanie całego tekstu bez wiedzy autora - ZABRONIONE! Dowiedz się więcej o prawach autorskich

Strona istnieje od 25.01.2001
Ta strona używa plików Cookie.
Korzystając z niej wyrażasz zgodę na przetwarzanie danych a zakresie podanym w Polityce Prywatności.
Reklama  
archive To tylko kopia strony wykonana przez robota internetowego! Aby wyświetlić aktualną zawartość przejdź do strony.

Optymalizowane dla przeglądarki Firefox
© Copyright 2001-2024 Dawid Najgiebauer. Wszelkie prawa zastrzeżone.
Ostatnia aktualizacja podstrony: 11.07.2023 21:16
Wszystkie czasy dla strefy czasowej: Europe/Warsaw