Sonntag, 30. September 2012

Beliebiges WPF-Control auf anderen Thread auslagern

In manchen Situationen ist es notwendig, dass man einzelne GUI Elemente vom Hauptthread entkoppelt und auf einen eigenen Thread aulagert.
Wichtig! Bevor man diesen Ansatz jedoch verfolgt, sollte man versuchen die "Arbeit", die hier den Hauptthread belastet, auszulagern.
Bei meiner Untersuchung zu dem Thema bin ich auf einen Post von Dwayne Need gestoßen, der die Probleme und einen Lösungsansatz sehr gut erklären: Multithreaded UI: HostVisual

Damit wäre eigentlich schon alles gesagt..., aber: muss ich wirklich für jedes UI-Element, dass ich auslagern will so viel Code schreiben, bzw. muss ich immer den gleichen Code (Erzeugen des Threads und Kaskadieren der Visual Elemente) duplizieren, wenn ich ein MediaElement und eine Progressbar in verschiedenen Threads laufen lassen will? Vielleicht möchte man auch mal mehrere Elemente zusammen in einem Thread laufen lassen. - Mit anderen Worten: Ich will flexibler sein.

Meine Vorstellung ist also, ein Frameworkelement zu haben, das, egal was ich einbette, dies auf einem anderen Thread laufen lässt:
<VisualWrapper>
   <Progressbar Value="{ProgressValue}" />
</VisualWrapper>

Und jetzt die Enttäuschung, für alle die bis hier gelesen haben: Eine saubere Lösung existiert meines Wissens dafür nicht... (jetzt wird's schmutzig),denn an und für sich wird der VisualTree rekursiv aufgebaut, d.h. der Content eines jeden Elements wird initialisiert, bevor dieser als Child an den Parent gehängt wird. Da wir diesen Mechanismus nicht kontrollieren können, müssen wir einen Weg finden, den Content zu übergeben, ohne diesen zu Initialisieren. Abhilfe schafft hier der XamlReader, dieser ermöglich es uns während der Programmlaufzet ein beliebiges Xaml zu instanziieren. Das Xaml übergeben wir dem Control dann als CDATA über ein Property:
<VisualWrapper>
  <VisualWrapper.Content>
    <![CDATA[
     <ProgressBar 
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      Height="20" Width="100" Value="{Binding ProgressValue}" />
    ]]>
  </VisualWrapper.Content>
</VisualWrapper>
Im VisualWrapper erfolgt dann die Initialisierung in einem eigenen Thread über den XamlReader:
 /// 
 /// Der Content als XAML-String, die Initialisierung erfolgt Postum
 /// 
 public string Content
 {
  set
  {
   CreateThreadedChild(value);
  }
 }

 /// 
 /// erstellt das Child-Element (aus dem Content-String) auf einem seperaten Thread
 /// 
 /// 
 void CreateThreadedChild(string xaml)
 {
  // erstelle einen Thread für das Content-Element
  Thread thread = new Thread(new ParameterizedThreadStart(ContentWorkerThread));
  thread.SetApartmentState(ApartmentState.STA);
  thread.Name = "GUI Thread";
  thread.IsBackground = true;
  thread.Start(xaml);

  // warte bis der Thread signalisiert, dass er fertig ist mit der Initialisierung
  s_event.WaitOne();
 }

 

 /// 
 /// XAML in WPF-Element umwandeln und auf die Oberfläche verlinken
 /// 
 /// XAML-String des Content
 void ContentWorkerThread(object arg)
 {
  // erstelle das WPF-Element aus dem übergebenen XAML-String
  StringReader stringReader = new StringReader(arg.ToString());
  XmlReader xmlReader = XmlReader.Create(stringReader);
  object content = XamlReader.Load(xmlReader);


  // nun wird die VisualTargetPresentationSource erzeugt (mit dem hier enthaltenen HostVisual als parent)
  VisualTargetPresentationSource visualTargetPS = new VisualTargetPresentationSource(_child);
  // der MainThread kann nun weiter arbeiten
  s_event.Set();

  // nun wird der DataContext von der obersten Ebene auf das aktuelle Element geschleift
  // -> per Invoke, da beide Elemente auf verschiedenen Threads liegen
  // (alternativ kann man den DataContext übergeben, bzw. sich auch an das entsprechende event hängen)
  object parentContext = null;
  
  
  this.Dispatcher.Invoke(new Action(() => { parentContext = this.DataContext; }));
  // die PresentationSource bekommt nun den DataContext 
  visualTargetPS.DataContext = parentContext;


  // hier wird nun der oberste Knoten im VisualTree gesetzt (unser Content)
  visualTargetPS.RootVisual = (Visual)content;



  // jetzt wird die (unendliche) Prozessschleife auf dem aktuellen Thread gestartet 
  System.Windows.Threading.Dispatcher.Run();
 }
Ich habe das ganze noch mit dem Code von Dwayne als Projekt zusammengefasst:
Visual Studio Solution: TestGenericThreadedContainer

Freitag, 28. September 2012

Code Snippet: Thread sicherer Zugriff auf WPF-Control

Motivation

Verwendet man in WPF konsequent das MVVM-Modell, so greift man immer auf das ViewModel zu, anstatt direkt mit irgendwelchen GUI-Elementen zu interagieren. Da der Zugriff auf das ViewModel wiederum threadunabhängig ist, kommt man eigentlich nie (oder eher selten) in die Lage folgenden Code einzusetzen. Aber wie immer bestätigen Ausnahmen die Regel - mir fällt zwar spontan kein einfacher Anwendungsfall ein, aber in der täglichen Praxis habe ich diesen Code schon einige male verwendet:

// nehmen wir an, wir haben eine TextBox und wollen threadsicher den Text verändern
 class ThreadSafeTextBox : TextBox
    {
        public void SetTextThreadSafe(string text)
        {
                // prüft, ob der aktuelle Thread dem Dispatcher 
                // dieses Controls zugeordnet ist
                if (this.Dispatcher.CheckAccess())
                {
                    // Ist dies der Fall, können wir den Text einfach setzen.
                    // Man sollte beachten, dass man sich jetzt in dem 
                    // Dispatcher Thread befindet, man sollte hier also nur 
                    // "schnell" auf die GUI zugreifen. Alles was lange dauert
                    // und nichts direkt mit der GUI zu un hat, gehört hier nicht rein!
                    base.Text = text;
                }
                else
                {
                    // anderenfalls müssen wir den Dispatcher "invoken"
                    this.Dispatcher.Invoke(new Action(
                        () =>
                        {
                            // jetzt können wir sicher auf den Text zugreifen
                            SetTextThreadSafe(text);
                        })
                        );
                }
            }
        }
    }

Donnerstag, 27. September 2012

Code Snippet: XAML Progress Indicator (Marquee)

Motivation

In Windows 95 haben wir gemerkt, dass Fortschrittsbalken nicht nur wachsen, sondern auch schrumpfen können. Progressive Fortschrittskorrektur nennt das der Klugscheißer, sogar die Überschreitung der 100% war in der Vergangenheit durchaus möglich.

Für den Anwender sind Fortschrittsbalken hauptsächlich ein Indikator dafür, dass ein Vorgang noch sehr lange dauert - Wenn der Balken nicht zuverlässig ist, sollte man ihn ganz lassen, oder eben nur durch "Bewegung" dem Anwender signalisieren, dass etwas bearbeitet wird. Bei diversen Videoplattformen kennt man ddies als "den Kreisel des Wartens". In Windows 8 jedoch sieht man oft eine Schlange von Punkten die von links nach rechts Pendeln, diese habe ich hier in XAML nachempfunden, viel Spaß damit:
 

<Canvas VerticalAlignment="Center" HorizontalAlignment="Center">
 <Path Fill="Black" Name="Path5">
  <Path.Data >
   <EllipseGeometry Center="15,0" RadiusX="3.8" RadiusY="3.8" x:Name="Ell5"></EllipseGeometry>
  </Path.Data>
 </Path>

 <Path Fill="Black" Name="Path4">
  <Path.Data >
   <EllipseGeometry Center="15,0" RadiusX="3.8" RadiusY="3.8" x:Name="Ell4"></EllipseGeometry>
  </Path.Data>
 </Path>
 <Path Fill="Black" Name="Path3">
  <Path.Data >
   <EllipseGeometry Center="15,0" RadiusX="3.8" RadiusY="3.8" x:Name="Ell3"></EllipseGeometry>
  </Path.Data>
 </Path>
 <Path Fill="Black" Name="Path2">
  <Path.Data >
   <EllipseGeometry Center="15,0" RadiusX="3.8" RadiusY="3.8" x:Name="Ell2"></EllipseGeometry>
  </Path.Data>
 </Path>
 <Path Fill="Black" Name="Path1">
  <Path.Data >
   <GeometryGroup>
    <EllipseGeometry Center="15,0" RadiusX="3.5" RadiusY="3.5" x:Name="Ell1"></EllipseGeometry>
   </GeometryGroup>

  </Path.Data>
  <Path.Triggers>
   <EventTrigger RoutedEvent="Path.Loaded">
    <BeginStoryboard>
     <Storyboard SpeedRatio="4">
      <PointAnimationUsingKeyFrames
        Storyboard.TargetName="Ell1" Storyboard.TargetProperty="Center"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever"
                >
       <EasingPointKeyFrame KeyTime="0:0:0" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:2" Value="32,0" />
       <EasingPointKeyFrame KeyTime="0:0:8" Value="42,0" />
       <EasingPointKeyFrame KeyTime="0:0:10" Value="100,0" />
       <EasingPointKeyFrame KeyTime="0:0:15" Value="100,0" />
      </PointAnimationUsingKeyFrames>
      <PointAnimationUsingKeyFrames
        Storyboard.TargetName="Ell2" Storyboard.TargetProperty="Center"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever"
                >
       <EasingPointKeyFrame KeyTime="0:0:0" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:1" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:3" Value="15,0" />
       <EasingPointKeyFrame KeyTime="0:0:9" Value="25,0" />
       <EasingPointKeyFrame KeyTime="0:0:11" Value="100,0" />
       <EasingPointKeyFrame KeyTime="0:0:15" Value="100,0" />
      </PointAnimationUsingKeyFrames>
      <PointAnimationUsingKeyFrames
        Storyboard.TargetName="Ell3" Storyboard.TargetProperty="Center"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever"
                >
       <EasingPointKeyFrame KeyTime="0:0:0" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:2" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:4" Value="0,0" />
       <EasingPointKeyFrame KeyTime="0:0:10" Value="10,0" />
       <EasingPointKeyFrame KeyTime="0:0:12" Value="100,0" />
       <EasingPointKeyFrame KeyTime="0:0:15" Value="100,0" />
      </PointAnimationUsingKeyFrames>
      <PointAnimationUsingKeyFrames
        Storyboard.TargetName="Ell4" Storyboard.TargetProperty="Center"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever"
                >
       <EasingPointKeyFrame KeyTime="0:0:0" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:3" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:5" Value="-15,0" />
       <EasingPointKeyFrame KeyTime="0:0:11" Value="-5,0" />
       <EasingPointKeyFrame KeyTime="0:0:13" Value="100,0" />
       <EasingPointKeyFrame KeyTime="0:0:15" Value="100,0" />
      </PointAnimationUsingKeyFrames>
      <PointAnimationUsingKeyFrames
        Storyboard.TargetName="Ell5" Storyboard.TargetProperty="Center"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever"
                >
       <EasingPointKeyFrame KeyTime="0:0:0" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:4" Value="-100,0" />
       <EasingPointKeyFrame KeyTime="0:0:6" Value="-30,0" />
       <EasingPointKeyFrame KeyTime="0:0:12" Value="-20,0" />
       <EasingPointKeyFrame KeyTime="0:0:14" Value="100,0" />
       <EasingPointKeyFrame KeyTime="0:0:15" Value="100,0" />
      </PointAnimationUsingKeyFrames>

      <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Path1" Storyboard.TargetProperty="Opacity"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever">
       <EasingDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:2" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:8" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:10" Value="0"/>
      </DoubleAnimationUsingKeyFrames>
      <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Path2" Storyboard.TargetProperty="Opacity"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever">
       <EasingDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:1" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:3" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:9" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:11" Value="0"/>
      </DoubleAnimationUsingKeyFrames>
      <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Path3" Storyboard.TargetProperty="Opacity"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever">
       <EasingDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:2" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:4" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:10" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:12" Value="0"/>
      </DoubleAnimationUsingKeyFrames>
      <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Path4" Storyboard.TargetProperty="Opacity"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever">
       <EasingDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:3" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:5" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:11" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:13" Value="0"/>
      </DoubleAnimationUsingKeyFrames>
      <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Path5" Storyboard.TargetProperty="Opacity"
        Duration="0:0:15" FillBehavior="Stop" RepeatBehavior="Forever">
       <EasingDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:4" Value="0"/>
       <EasingDoubleKeyFrame KeyTime="0:0:6" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:12" Value="1"/>
       <EasingDoubleKeyFrame KeyTime="0:0:14" Value="0"/>
      </DoubleAnimationUsingKeyFrames>
     </Storyboard>
    </BeginStoryboard>
   </EventTrigger>
  </Path.Triggers>
 </Path>
</Canvas>