LernMoment / WpfAsyncProgressBar

Zeigt wie eine ProgressBar in WPF asynchron aus der Geschäftslogik verwendet werden kann.
1 stars 0 forks source link

Wie kann eine ProgressBar aus einem separaten Thread aus einer anderen Klasse bedient werden? #1

Open suchja opened 3 years ago

suchja commented 3 years ago

Hier gilt es folgende Frage die ich per Mail (am 21.06.21) bekommen habe zu beantworten.

Wenn ich ein Progressbar mittels BackgroundWorker asynchron bedienen muss, bekomme ich Schwierigkeiten. Alle Tutorials im Netz bedienen den Progressbar aus der .xaml.cs Datei heraus, nicht aus der .cs Datei (Programmlogik) heraus, wo ich den Progressbar bräuchte. Nun ist mit nicht klar, wie ich aus der .cs Datei den Progressbar.Value in der .xaml.cs Datei bediene. Wo ist in der .xaml.cs Datei der Einstiegspunkt und wie rufe ich diesen aus der .cs Datei auf?

Dazu gab es folgenden Code:

XAML

<Window x:Class="WpfTutorialSamples.Misc_controls.ProgressBarTaskOnUiThread"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="ProgressBarTaskOnUiThread" Height="100" Width="300"
        ContentRendered="Window_ContentRendered">
    <Grid Margin="20">
        <ProgressBar Minimum="0" Maximum="100" Name="pbStatus" />
    </Grid>
</Window>

Code-Behind (xaml.cs)

using System;
using System.ComponentModel;
using System.Threading;
using System.Windows;

namespace WpfTutorialSamples.Misc_controls
{
    public partial class ProgressBarTaskOnWorkerThread : Window { public ProgressBarTaskOnWorkerThread()     {
        InitializeComponent();
    }

    private void Window_ContentRendered(object sender, EventArgs e)
    {
        BackgroundWorker worker = new BackgroundWorker();
        worker.WorkerReportsProgress = true;
        worker.DoWork += worker_DoWork;
        worker.ProgressChanged += worker_ProgressChanged;
        worker.RunWorkerAsync();
    }

    void worker_DoWork(object sender, DoWorkEventArgs e)
    {
        for(int i = 0; i < 100; i++) // this was not my intent. 
                                     // Where ist the entry point for the .cs file control?
        {
            (sender as BackgroundWorker).ReportProgress(i);
            Thread.Sleep(100);
        }
    }
    void worker_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        pbStatus.Value = e.ProgressPercentage;
    }
    }
}

Programmlogik (beliebige .cs Datei)

// this is only draft code
. . .
    pbStatus.Minimum = 0;
    pbStatus.Maximum = zimax;
    window.ShowDialog();
    for(int zi = 0; zi < zimax; zi++)
    {
        //long code
        pbStatus.Value++;??   // this should control ProgressBar
        Thread.Sleep(100);
    }
suchja commented 3 years ago

Grundlegende Überlegungen

  1. der Einsatz von BackgroundWorker wird nicht mehr empfohlen von Microsoft. Dort hat sich in den letzten Jahren einiges geändert. In den aktuellen Versionen von WPF und C# werden async/await und Task.Run verwendet um Dinge im Hintergrund zu erledigen. Das ist dann auch häufig etwas einfacher zu realisieren.
  2. ein wichtiger Aspekt von WPF ist die Trennung von Oberfläche und Programmlogik. Es geht darum, dass im View (also XAML und Code-behind) wirklich nur die Dinge passieren, die für die Visualisierung unbedingt notwendig sind. Alles andere sollte davon in separate Klassen getrennt werden. Auch wenn es sehr üblich ist, dass ein Muster namens MVVM (Model View ViewModel) verwendet wird, ist es gerade für Einsteiger schwierig zu verstehen und auch nicht unbedingt sinnvoll es kompromisslos zu verfolgen.
  3. Für die Lösung des Problems ist ein grundlegendes Verständnis von ObjektOrientierung hilfreich. Eine der Grundideen ist, dass das Fenster ProgressBarTaskOnWorkerThread (siehe Beispielcode oben) eine Klasse ist. Diese Klasse bietet viel Funktionalität die sie durch Vererbung aus den WPF-Klassen bekommt, aber es ist letztlich eine "ganz normale" Klasse. Die Definition dieser Klasse im Code-Behind (public partial class ProgressBarTaskOnWorkerThread : Window) sagt, dass diese Klasse in mehreren Dateien (deshalb das Schlüsselwort partial) "lebt". Über : Window wird angegeben, dass die Klasse von der WPF-Klasse Window ableitet und somit deren Funktionalität erbt.
suchja commented 3 years ago

Verwendung von Progress im code-behind

Die von Microsoft in WPF vorgeschlagene Lösung zum melden von Fortschritt ist in der Klasse Progress<T> und dem Interface IProgress<T> zu finden. Eine (leider nicht sehr umfangreiche) Beschreibung findet sich hier. Die grundlegende Idee ist, dass im Kontext der Oberfläche (also üblicherweise im code-behind) eine Instanz von Progress<T> angelegt wird. Da Progress<T> ein generischer Typ ist (zu erkennen an <T>) kann beim Anlegen entschieden werden welche Daten später als Fortschritt zur Oberfläche kommuniziert werden. In diesem Beispiel soll es ein ganzzahliger Wert zwischen 0 und 100 sein.

In dem (über diesem Issue) verlinkten Commit 9d4c976 habe ich beispielhaft gezeigt wie eine Instanz angelegt wird. Dabei ist der wichtigste Teil dieser:

    IProgress<int> progress = new Progress<int>(value => { myProgress.Value = value; });

Wie du siehst erstelle ich eine Instanz von Progress<T> mit dem Teil new Progress<int>. Dabei definiere ich mit (value => { myProgress.Value = value; }) was passieren soll, wenn die Eigenschaft Value einen neuen Wert zugewiesen bekommt. Sie soll diesen neuen Wert nämlich direkt dem ProgressBar übergeben (also myProgress.Value = value;). Um zu sehen, dass das auch tatsächlich funktioniert, löse ich dann mit progress?.Report(_currentProgress); eine Benachrichtigung aus.

Das tolle an Progress<T> ist, dass es jegliche Synchronisation selbstständig erledigt. Wichtig zu verstehen ist nämlich, dass es bei der Verwendung von BackgroundWorker und/oder Task schnell zu einem Problem kommen kann. Die Aktualisierung der Steuerelemente auf der Oberfläche darf nur durch den Thread erfolgen, der die Oberfläche erstellt hat (auch UI-Thread genannt). Da Progress<T> durch den UI-Thread erstellt wird, kann nun die Aktualisierung der Oberfläche darin erfolgen. Kommt der Aufruf von progress?.Report(_currentProgress); aus einem anderen Thread, dann kümmert sich die Implementierung von Progress<T> um die notwendige Synchronisation.

Und jetzt?

Somit sollte erstmal einigermaßen klar sein wie Progress<T> funktioniert. Allerdings ist ja nun immer noch alles im code-behind und es läuft auch noch nichts asynchron (also ausser dem Benutzer, der jetzt den Button klicken muss ;). Also schauen wir uns die nächste Änderung einmal an.

suchja commented 3 years ago

Progress automatisch aus einem Task aktualisieren

Okay, wie du in 4705ce8 siehst, sind wir immer noch im code-behind, aber haben schon mal den Schritt gemacht, dass der Fortschritt aus einem Task automatisch bis 100 aktualisiert wird.

Ein wichtiger Punkt für die Umsetzung ist dieser Teil im Quellcode:

        IProgress<int> _progress;

        public MainWindow()
        {
            InitializeComponent();
            myProgress.Value = _currentProgress;
            _progress = new Progress<int>(value => { myProgress.Value = value; });
        }

Im Konstruktor public MainWindow() wird nun das Progress<int> Objekt angelegt und dem Feld _progress zugewiesen. Damit kann jede Methode aus der Klasse darauf zugreifen. Fürs Verständnis wichtig ist, dass hier eigentlich noch nichts passiert. Es geht erstmal darum die Infrastruktur vorzubereiten. Wie du nämlich siehst, wird bisher _progress.Report() noch gar nicht aufgerufen.

Das erfolgt erst im Event-Händler für den StartButton:

        private async void StartButton_Click(object sender, RoutedEventArgs e)
        {
            await Task.Run(async () => {
                while (_currentProgress < 100)
                {
                    _currentProgress += 10;
                    _progress?.Report(_currentProgress);
                    await Task.Delay(500);
                }
            });
        }

Mithilfe von Task.Run wird die innerhalb der gescheiten Klammer definierte Aufgabe (das Ganze inkl. dem => nennt man Lambda) nun von einem separaten Task (und dieser wiederum von einem anderem Thread) ausgeführt. Daher kannst du nun auf der Oberfläche beobachten, dass sich die ProgressBar "füllt" bis sie am rechten Ende angekommen ist.

suchja commented 3 years ago

Schlussspurt

Die ursprüngliche Aufgabe war den Fortschritt aus der Geschäftslogik (also hauptsächlich einer anderen Klassen) zu erhöhen und dieses nicht im code-behind zu machen. Mit den Änderungen aus 119ee48 ist dieses nun möglich. Der Einsprungpunkt für die Geschäftslogik (hier "simuliert" durch die klasse BusinessLogic) ist im StartButton_Click-Handler:

    private async void StartButton_Click(object sender, RoutedEventArgs e)
    {
        await Task.Run(async() => {
            string result = await _logic.DoSomething(_progress);
        });
    }

Wie du sehen kannst wird die BusinessLogic.DoSomething aufgerufen und dieser Methode wird eine Referenz auf IProgress<int> übergeben. Damit kann die Geschäftslogik nun entscheiden wann sie soviel geschafft hat, dass ein weiterer Fortschritt zur Oberfläche geschickt werden kann.

Erstes Fazit

Zwar habe ich nicht den gewünschten BackgroundWorker genommen, aber mithilfe der für diesen Zweck gedachten Progress<T> und IProgress<T> Typen ist es relativ einfach (wenn man weiß wie es geht ;) den Fortschritt aus anderen Klassen als dem code-behind zu berichten.

Wichtig ist hier insbesondere das Verständnis von ObjektOrientierung. Letztlich müssen sich die Oberfläche und die Geschäftslogik in irgend einer Art und Weise kennen. Schließlich muss die Geschäftslogik irgendwie an eine Instanz von IProgress<int> kommen, damit sie es nutzen kann um den Fortschritt zu berichten.

In diesem Beispiel habe ich es so gelöst, dass die Oberfläche die Geschäftslogik anlegt (BusinessLogic _logic = new BusinessLogic(); im MainWindow). Wenn du MVVM einhalten möchtest oder sagst, dass im code-behind möglichst nichts stehen soll (wie das viele Entwickler fordern), dann ist der hier gezeigt Weg noch nicht optimal. Allerdings spielen beim Thema MVVM und gute Separierung von Oberfläche und Logik noch so viele Aspekte eine Rolle, dass es mehr ein ganzes Buch werden kann.

suchja commented 3 years ago

Abschluss

Zum Abschluss habe ich in 903f727 noch 2 Veränderungen vorgenommen. Aus meiner Sicht das wichtigste ist, dass IProgress<int> progress = new Progress<int>(value => { myProgress.Value = value; }); jetzt nur noch lokal im StartButton_Click ausgeführt wird. D.h. dadurch, dass der Fortschritt nur an dieser einen Stelle benötigt wird, brauchen wir kein extra Feld mehr im MainWindow. So ist es aus meiner Sicht noch etwas aufgeräumter.

Zum Schluss hast du vielleicht noch die Frage, warum Task.Run im MainWindow gemacht wird und nicht in BusinessLogic.DoSomething (das würde nämlich auch gehen). Task.Run ist der Aufruf der die ihm übergebene Methode (Aufgabe) im Hintergrund ausführt. Natürlich wäre es möglich diesen Aufruf auch in BusinessLogic zu verlagern. Damit hätten wir noch weniger code-behind. Außerdem könnte man argumentieren, dass die BusinessLogic vielleicht besser weiß wie lange ihre Aufgabe dauert und ob sie somit besser im Hintergrund ausgeführt wird. Allerdings gibt es eine ganz klare Empfehlung von Microsoft. Eine Bibliothek (oder in diesem Fall auch eine einzelne Klasse) sollte möglichst selber nicht Task.Run verwenden, sondern dem Aufrufer die Entscheidung überlassen, ob das notwendig ist, oder nicht. Dieses Vorgehen ermöglicht ein breiteres Einsatzgebiet der Bibliothek. Multithreading ist nämlich sehr anwendungsspezifisch und sollte somit von der Anwendung dort eingesetzt werden, wo es notwendig ist.