05 sierpnia 2011

BackgroundWorker kontra Dispatcher

Co robić gdy użytkownik jest niecierpliwy?


Pracując nad nową wersją Replication Explorer'a postanowiłem ulepszyć UX (User Expirience) dialogu do łączenia się z serwerem (Dystrybutorem replikacji). W aktualnej wersji programu, w momencie gdy użytkownik wciśnie przycisk 'Connect' cały UI przestaje odpowiadać do momentu połączenia z Dystrybutorem. Nie można anulować akcji łączenia, trzeba czekać 30 sekund żeby komunikat o niemożności zlokalizowania serwera został wyświetlony itp.


Rozwiązanie problemu "wiszącego" dialogu musi spełniać następujące warunki:
  • asynchroniczna operacja  w prosty sposób musi zwrócić dane do głównego okna programu
  • przerwanie operacji połączenia powinno być możliwe w każdym momencie, 
  • na czas wykonywania operacji połączenia z serwerem, UI powinno być dalej gotowe na interakcję z użytkownikiem, 
  • w czasie ustanawiania połączenia z Dystrybutorem, UI powinno wyświetlać prostą animację sugerującą, że akcja łącznia trwa,
  • operacja połączenia w prosty sposób musi przekazać informację o ewentualnym błędzie,  



AsyncDialog - najprościej wyjaśnić na przykładzie

W ramach testów różnych rozwiązań zaimplemntowałem przykładową aplikację AsyncDialog (źródła dostępne są tutaj). AsyncDialog pobiera informacje o wersji buildu SQL Servera i wyświetla je w głównym oknie.


W oknie ConnectDialog użytkownik moze pobierać informacje o SQL Serverze za pomocą czterech mechanizmów.

  1. Dispatcher On Complete -wykorzystuje Dispatcher'a i właściwości klasy DispatcherOperation.
  2. Simple Dispatcher - prezentuje najprostsze wywołanie Dispatcher'a.
  3. Background worker - używa klasy BackgroundWorker, która umożliwia uruchomienie animacji w czasie połączenia
  4. Thread Pool - do wykonania asynchronicznej operacji wykorzystuje metodę
    ThreadPool.QueueUserWorkItem.

Poniżej klasy pomocnicze wykorzystywane przez AsyncDialog do pobrania danych z instacji MS SQL Servera.

public class SqlServerMetaMan
{
    private readonly string _serverName;

    public SqlServerMetaMan(string serverName)
    {
        _serverName = serverName;
    }

    public ServerInfo GetSqlServerVersion()
    {
        var connectionStringBuilder = new SqlConnectionStringBuilder();
        connectionStringBuilder.InitialCatalog = "master";
        connectionStringBuilder.DataSource = _serverName;
        connectionStringBuilder.IntegratedSecurity = true;
        using(var connection = new SqlConnection(connectionStringBuilder.ConnectionString))
        {
            connection.Open();
            return new ServerInfo{Name = connection.DataSource
              , Version = connection.ServerVersion};
        }
    }
}

public class ServerInfo
{
    public string Name { get; set; }
    public string Version { get; set; }
}


Poniżej przykładowa animacja obrazka przedstawiającego ster, która wykonuje obrót obrazka o 360 stopni. Kod źródłowy był inspirowany artykułem 'Beginner's WPF Animation Tutorial'.  Animacja jest uruchamiana przed nawiązaniem połączenia do serwera i jest zatrzymywana po otrzymaniu danych lub anulowaniu akcji.

private void StartAnimation()
{
    TimeSpan animationDuration = TimeSpan.FromSeconds(5);
    DoubleAnimation animation = new DoubleAnimation(360, 0, new Duration(animationDuration));
    RotateTransform rotateTransform = new RotateTransform();
    progressIcon.RenderTransform = rotateTransform;
    progressIcon.RenderTransformOrigin = new Point(0.5, 0.5);
    animation.RepeatBehavior = RepeatBehavior.Forever;
    rotateTransform.BeginAnimation(RotateTransform.AngleProperty, animation);
}


Asynchronicznie do dzieła


Istnieje co najmniej kilka opcji do uruchamiania operacji asynchronicznych na WPF. Jako że nie miałem z nimi żadnego szczególnego doświadczenia, zacząłem od Dispatcher'a, który sprawiał wrażenie najprostszego w implementacji. Jest to klasa, która jest bazową dla praktycznie wszystkich obiektów wizulanych WPF'a, aczkolwiek instacja Dispatcher'a jest tylko jedna (singleton). Z braku podstawowej wiedzy o WPF'ie oczekiwałem, że jakikolwiek kod uruchomiony za pomocą Dispatcher'a uruchomi się na oddzielnym wątku. Tak oczywiście nie jest, ponieważ delegaty przekazywane do metod Invoke i BeginInvoke są kolejkowane i uruchamiane w kolejności zgodnej z podanym priorytetem przez jeden-jedyny wątek UI (architektura WPF jest też oparta na STA czy Single-Threaded Apartment, tak samo jak Win32 i Windows Forms). Poniżej przykładowy kod wywołania metody Dispatcher.BeginInvoke.

private void CallUsingDispatcher()
{
    Dispatcher.BeginInvoke(
        new Action(DispatcherDisplayServerInfo),
        DispatcherPriority.Normal);
}

private void DispatcherDisplayServerInfo()
{
    ServerInfo serverInfo = GetSqlServerVersion(tbServerName.Text);
    OwnerWindow.DataContext = serverInfo;
    this.Close();
} 

W momencie wywołania metody GetSqlServerVersion i próby połączenia do SQL Servera całe UI "zastygnie" w oczekiwaniu na ustanowienie komunikacji z serwerem. Jest to oczywiście zachowanie niepożądane i dlatego w moim senariuszu Dispatcher nie może być brany pod uwagę. 

Niespełnione warunki:
  • nie można anulować operacji w czasie jej wykonywania,
  • UI przestaje odpowiadać na czas trwania operacji połączenia,
  • nie można obsłużyć animacji w czasie wykonania operacji.

Elastyczny BackgroundWorker


Nie tracąc nadziei sprawdziłem następną klasę o nazwie BackgroundWorker. Umożliwia ona asynchroniczne uruchamianie operacji na odzielnym wątku, anulowanie akcji (właściwość WorkerSupportsCancellation), przekazanie informacji o błędzie oraz przekazywanie informacji o postępie prac. Aczkolwiek, nie wszystko złoto co się świeci,  interakcję z WPF'owym UI możemy uzyskać poprzez obiekt wyżej wspomnianego
Dispatcher'a. Inaczej otrzymamy następujący komunikat:

InvalidOperationException : The calling thread cannot access this object because a different thread owns it.

Poniżej fragmenty implementacji scenariusza wykorzystującego klasę BackgroundWorker'a.

private void CallUsingBackgroundWorker()
{
    worker = new BackgroundWorker();
    worker.WorkerSupportsCancellation = true;
    worker.DoWork += WorkerDoWork;
    worker.RunWorkerCompleted += WorkerRunCompleted;
    worker.RunWorkerAsync(tbServerName.Text);
}
        
private void WorkerDoWork(object sender, DoWorkEventArgs e)
{
    string serverName = (string) e.Argument;
    BackgroundWorker backgroundWorker = (BackgroundWorker) sender;
    try
    {
        ServerInfo serverInfo = GetSqlServerVersion(serverName);
        e.Result = serverInfo;
        Dispatcher.BeginInvoke(new Action(() =>
            {
                OwnerWindow.Title += "Background Worker update";
            }), DispatcherPriority.Normal
            );

                
    }
    catch (Exception)
    {
        if (backgroundWorker.CancellationPending)
        {
            //Suppress exception if user submitted cancellation
            e.Cancel = true;
        }
        else
        {
            throw;    
        }
                
    }
}

private void WorkerRunCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    StopAnimation();
    if(e.Error != null && !e.Cancelled)
    {
        MessageBox.Show(e.Error.Message, "Error occured");
    }
    else if(e.Cancelled)
    {
        MessageBox.Show("Connection attempt cancelled by the user", "User action");
    }
    else if (e.Result != null)
    {
        OwnerWindow.DataContext = e.Result as ServerInfo;
        this.Close();
    }
}


ThreadPool do pomocy


Z czystej ciekawości postanowiłem sprawdzić standardowe klasy wykorzystywane w .NET-towych aplikacjach wielowątkowych. Oczywiście, stara dobra, metdoa ThreadPool.QueueUserWorkItem spisała się dobrze. Nie zablokowała wątku UI, pozwoliła przekazać parametr z UI (nazwę serwera), aczkolwiek z przekazywaniem informacji o błędach było już gorzej. 

private void CallUsingThreadPool()
{
    ThreadPool.QueueUserWorkItem(UserWorkItemCallback, 
        tbServerName.Text);
}

private void UserWorkItemCallback(object state)
{
    string serverName = (string) state;
    ServerInfo serverInfo = GetSqlServerVersion(serverName);

    Dispatcher.BeginInvoke(new Action(() =>
    {
        this.StopAnimation();
        OwnerWindow.Title += "UserWorkItem Callback update";
        OwnerWindow.DataContext = serverInfo;
        this.Close();
    }), DispatcherPriority.Normal);
}



Niespełnione warunki:
  • nie można w prosty sposób anulować operacji
  • nie można przekazać informacji o błędach które wystąpiły podczas wykonania operacji połączenia

And the winner is


Rozwiązanie, które wybrałem do implementacji dialogu połącznia opiera się na wykorzystaniu klasy BackgroundWorker. Dostarcza ona proste mechanizmy do obsługi przerywania akcji, przekazywania błędów i wyników do interfejsu użytkownika.

Hope this helps.

4 komentarze:

  1. Dostaję You need permission to access this item.
    przy próbie pobranie źródeł AsyncDialoga.

    OdpowiedzUsuń
  2. Całkiem ciekawy artykuł, jednak zachęcam do zapoznania się z TPL (Task Parallel Library)

    OdpowiedzUsuń
  3. Ja bym raczej proponował zapoznać się z async callback. Wyciąganie armaty do strzelania do wróbli nie jest humanitarne.

    OdpowiedzUsuń
  4. Oby zylo Ci sie dostatnie, dlugo i szczesliwie! ;)

    Szukalem takiego rozwiazania kilka dni i wszystko zawodzilo, a tu rach prach ciach i gotowe. Szacun i ogromne dzieki za te przyklady! :)

    OdpowiedzUsuń

Uwaga: tylko uczestnik tego bloga może przesyłać komentarze.