Artykuł

gru 31 2011
0

Implementacja BackgroundWorker w WPF

Czy kiedykolwiek zdarzyło się Wam spotkać z aplikacją, która podczas przetwarzania jakiś bardziej złożonych i czasochłonnych operacji, zamrażała swoje okno w sposób, który skutecznie blokował Waszą pracę?

Czy nie zdarzało się Wam również w takich sytuacjach ujrzeć pełny optymizmu napis Brak odpowiedzi?

Podejrzewam że każdy z Was, przynajmniej raz spotkał się z taką sytuacją. Być może część z Was przeklinała system operacyjny, choć w istocie to nie była do końca jego zasługa. Bardziej winny w takiej sytuacji był niestety programista, który z niewiedzy/braku czasu/cwaniactwa (niepotrzebne skreślić) nie zaimplementował wielowątkowości w swojej aplikacji.

Być może wielu programistów, boi się po prostu tego zagadnienia, choć w gruncie rzeczy jest ono całkiem proste w implementacji. W przypadku gdy korzystamy z aplikacji napisanych w WPF/Silverlight i chcemy skorzystać z dodatkowego wątku, który nie będzie blokować naszego interfejsu UI, to w takim celu, świetnie sprawdzi się tytułowy BackgroundWorker.

Co daje BackgroundWorker i dlaczego warto go stosować?

Sytuacja którą opisałem we wstępie, to klasyczny przykład okienkowej aplikacji pozbawionej wątków. W takiej sytuacji istnieje jeden wątek główny, co może rodzić wiele nieprzyjemnych konsekwencji.

Jeśli nasza aplikacja, wykonuje proste i niezłożone operacje, które trwają ułamki sekund (lub po prostu bardzo krótko), to w gruncie rzeczy możemy sobie poradzić nawet bez wielowątkowości (choć separacja wątków jest zawsze mile widziana). Jednak jeśli operacje wykonywane przez nas program, trwają kilka albo kilkanaście, czy też nawet więcej sekund, to separacja wątków staje się koniecznością.

Dlaczego jest to takie ważne? Aplikacja domyślnie działa jednowątkowo i choć z pozoru wydaje się nam, że powinna ona realizować tylko to co my jej każemy, to w istocie robi ona znacznie więcej. Jednym z jej zadań, jest np. odświeżanie widoku intefejsu. Jeśli my damy naszej aplikacji, jakieś duże złożone zadanie, to automatycznie przestanie ona wykonywać inne zadania, które realizowała w ramach tego wątku np. wspomniane wyżej odświeżanie interfejsu. To właśnie stąd pojawiają się te zamrożenia, o których wspominałem wcześniej.

Z tej sytuacji można wybrnąć naprawdę na wiele sposobów (zagadnienie wielowątkowości jest dość rozległe i posiada wiele możliwych opcji), jednak czasem nie warto sobie przesadnie komplikować życia.

W większości prostych sytuacji, w którym korzystamy z graficznego interfejsu, a operacje wykonujemy w jednym wątku, do realizacji naszego zadania znakomicie nada się BackgroundWorker.

Tak zrobiłem np. w mojej aplikacji Multime, gdzie BackgroundWorker jest wykorzystywany do zmiany nazw plików i katalogów. Dodatkowo, aktualizuje on również TextBox logu oraz odświeża pasek postępu. O tym jak tego dokonać, również postaram się powiedzieć w dalszej części wpisu.

Aplikacja testowa

Żeby dobrze zrozumieć zagadnienie, potrzebna nam będzie aplikacja testowa. Napiszemy zatem prostą aplikację w WPF, która po kliknięciu przycisku uruchamiać będzie wątek w BackgroundWorker, który będzie się wykonywać przez zadany okres czasu, bądź też do zatrzymania przez użytkownika.

Dodatkowo, aplikacja będzie raportować swój postęp na pasku postępu oraz logować informacje do TextBoxa. Od aplikacji testowej, wymaga się również by nie była podatna zamrażaniu.

Docelowo, powinniśmy osiągnąć rezultat taki jak na screenie 1:

Implementacja

Implementację rozpoczniemy od XAMLa. Najpierw musimy jednak utworzyć nową aplikację WPF (jeśli macie jakieś problemy z tym jak to zrobić, zapraszam do tutoriala WPF). Następnie, podmieniamy Grida utworzonego przez Visual Studio na tego z poniższego listingu:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>
    <Button Name="btnStart" Content="Start" Grid.Row="0" Grid.Column="0" 
		Margin="5" Click="btnStart_Click" />
    <Button Name="btnStop" Content="Stop" Grid.Row="0" Grid.Column="1" 
		Margin="5" Click="btnStop_Click" />
    <ProgressBar Name="pbProgress" Height="20" Margin="5" Grid.Row="1" 
		Grid.ColumnSpan="2" />
    <TextBox Name="txtLog" Margin="5" Grid.Row="2" Grid.ColumnSpan="2" />
</Grid>

Kod XAML, jest jak widać raczej prosty (jeśli coś jest dla Was niezrozumiałe, ponownie zapraszam do tutoriala, ewentualnie do komentarzy). Obsługę kliknięcia przycisków Start i Stop dodamy oczywiście w pliku code-behind. Przejdźmy zatem do niego.

Na początku, dodajemy składową klasy:

private BackgroundWorker m_oBackgroundWorker = null;

Obiekt klasy BackgroundWorker będziemy tworzyć dopiero wtedy, kiedy będzie on potrzeby (Lazy loading). Stosowne przestrzenie nazw możecie dodać za pomocą Visual Studio.

W kolejnym kroku dodajemy obsługę kliknięcia przycisku Start:

private void btnStart_Click(object sender, RoutedEventArgs e)
{
    if (null == m_oBackgroundWorker)
    {
        m_oBackgroundWorker = new BackgroundWorker();
        m_oBackgroundWorker.DoWork += 
			new DoWorkEventHandler(m_oBackgroundWorker_DoWork);
        m_oBackgroundWorker.RunWorkerCompleted += 
			new RunWorkerCompletedEventHandler(
			m_oBackgroundWorker_RunWorkerCompleted);
        m_oBackgroundWorker.ProgressChanged += 
			new ProgressChangedEventHandler(m_oBackgroundWorker_ProgressChanged);
        m_oBackgroundWorker.WorkerReportsProgress = true;
        m_oBackgroundWorker.WorkerSupportsCancellation = true;
    }
    pbProgress.Value = 0;
    txtLog.Text = "Uruchomiono zadanie.\n";
    m_oBackgroundWorker.RunWorkerAsync();
}

Tutaj dzieje się bardzo dużo różnych ciekawych rzeczy. Po pierwsze, w liniach 3-15 korzystamy z wspomnianego wcześniej Lazy loading (wzorzec projektowy). Sprawdzamy zatem czy obiekt BackgroundWorker został utworzony, jeśli nie, tworzymy go.

Następnie, podpinamy do obiektu trzy metody obsługujące określone jego zdarzenia:

  • DoWork - zdarzenie wywoływane po uruchomieniu wątku, w nim powinien znaleźć się właściwy kod, który ma zostać przetworzony
  • RunWorkerCompleted - zdarzenie wywoływane po zakończeniu przetwarzania. Tutaj uwaga: zdarzenie może być wywołane zarówno po przerwaniu jak i właściwym zakończeniu pracy wątku
  • ProgressChanged - zdarzenie wywoływane w wyniku zgłoszenia postępu wykonania zadania przez wątek

Metoda przeznaczona do obsługi każdego z powyższych zdarzeń, zostanie opisana szerzej w dalszej części wpisu.

W liniach 13-14, ustawiamy również dwie właściwości obiektu. Pierwsza (13) z nich, mówi nam że wątek może raportować postęp. Druga (14) informuje o tym, że wątek może zostać przerwany w trakcie przetwarzania.

Cały powyżej omówiony kod, zostanie wykonany tylko raz - przy pierwszym naciśnięciu przycisku Start.

W dalszych krokach powyższego listingu, zerujemy pasek postępu oraz ustawiamy odpowiedni tekst w naszym logu. Na koniec uruchamiamy asynchronicznie przetwarzanie.

Skoro obsłużyliśmy przycisk Start powinniśmy obsłużyć również przycisk Stop. Tym razem, kod będzie znacznie prostszy:

private void btnStop_Click(object sender, RoutedEventArgs e)
{
    if ((null != m_oBackgroundWorker) && m_oBackgroundWorker.IsBusy)
    {
        m_oBackgroundWorker.CancelAsync();
    }
}

W tym przypadku, sprawdzamy czy obiekt został utworzony i jeśli tak, to czy aktywny jest aktualnie jakiś wątek. Jeśli oba warunki zostaną spełnione , to możemy zasygnalizować przerwanie wątku. Zwróćcie uwagę, że napisałem zasygnalizować, bo sam wątek musi być przerwany z poziomu metody obsługującej zdarzenie DoWork.

Mamy już w tym momencie metody obsługujące przycisk Start i Stop. Zwróćcie w tym momencie uwagę na fakt, że nie zaimplementowałem tutaj żadnej funkcjonalności blokowania przycisków, obsługi błędów itp. - pamiętajcie o takich rzeczach w rozwiązaniu produkcyjnym.

Teraz pozostaje nam napisać trzy metody obsługujące zdarzenia BackgroundWorkera. Zanim jednak to zrobimy, stworzymy metodę pomocniczą, która będzie aktualizować nasz log i przewijać go na koniec. Dzięki temu, po dodaniu jakiegokolwiek tekstu, zawsze będzie on widoczny bez konieczności interakcji użytkownika z oknem.

private void AppendLog(string sText)
{
    txtLog.AppendText(sText);
    txtLog.ScrollToEnd();
}

Do metody przekazujemy zmienną tekstową, która zawiera tekst, który ma zostać zapisany do logu (reset logu następuje dla każdego zadania w metodzie Start poprzez ustawienie właściwości Text).

Teraz pozostały nam już do opisania ostatnie trzy metody związane z obiektem BackgroundWorker. Zacznijmy od metody obsługującej zdarzenie DoWork:

void m_oBackgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
    for (int nCounter = 1; nCounter <= 100; ++nCounter)
    {
        if (m_oBackgroundWorker.CancellationPending)
        {
            e.Cancel = true;
            break;
        }
        Thread.Sleep(1000);
        m_oBackgroundWorker.ReportProgress(nCounter);
    }
}

Ponieważ jest to rozwiązanie testowe, to nasz kod nie będzie robić właściwie nic sensownego. Całość metody, zawiera się we wnętrzu pętli, która wykonuje 100 kroków. W każdej iteracji pętli sprawdzamy najpierw, czy nie została wywołana akcja przerwania. Jeśli tak, sygnalizujemy przerwanie zdarzenia (7) i przerywamy pętlę. W przeciwnym przypadku, odsypiamy sekundę (dzięki tej instrukcji, wątek nie skończy się za szybko), a następnie raportujemy wykonanie pewnego etapu zadania poprzez podanie wartości procentowej do metody ReportProgress (wartość ta powinna mieścić się w przedziale 0 do 100). Metoda ReportProgress aktywuje zdarzenie ProgressChanged, dla którego również napisaliśmy naszą metodę i której kod możecie znaleźć poniżej:

void m_oBackgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
    if (0 == (e.ProgressPercentage % 5))
    {
        AppendLog(e.ProgressPercentage.ToString() + "%\n");
    }
    pbProgress.Value = e.ProgressPercentage;
}

W tej metodzie, wykonujemy dwie rzeczy. Po pierwsze: aktualizujemy log, ale tylko w przypadku gdy aktualny procent jest podzielny przez 5 (wykorzystujemy do tego dzielenie z resztą (3)). Po drugie, aktualizujemy wartość paska postępu. Tą czynność, wykonujemy zawsze.

Pozostała nam teraz ostatnia metoda związana z obsługą zdarzenia RunWorkerCompleted. Tak jak wspomniałem wcześniej, jest ona wywoływana zawsze na zakończenie wątku, niezależnie czy został on zakończony w sposób naturalny, czy też przerwany wskutek naszej interwencji.

void m_oBackgroundWorker_RunWorkerCompleted(object sender, 
    RunWorkerCompletedEventArgs e)
{
    if (e.Cancelled)
    {
        AppendLog("Przerwano.");
    }
    else
    {
        AppendLog("Zakończono.");
    }
}

Kod tej metody jest prosty. Przy wykorzystaniu argumentów dostarczonych przez metodę, sprawdzamy czy wątek został anulowany (przerwany), czy też zakończony. W zależności od zaistniałej sytuacji, dodajemy odpowiedni wpis do logu.

To na tyle co miałem zasadniczo dziś do przekazania. Mam nadzieję, że udało mi się zachęcić Was do stosowania BackgroundWorkera. To naprawdę stosunkowo proste i przyjemne rozwiązanie, które powinno pomóc Waszym przyszłym i obecnym aplikacjom w kwestii prostych problemów z wątkami:)

P.S. Gotowy projekt, możecie pobrać z działu download.

Podoba Ci się ten wpis? Powiedz o tym innym!

Send to Kindle

Komentarze

blog comments powered by Disqus