MVVM zum Dritten: Eine Liedersammlung

1. Aufgabe

In dieser dritten und letzen Folge zum MVVM-Entwurfsmuster gehen wir auf das Thema „WPF-Steuerelemente und Kommandos“ ein. Nahezu jedes WPF-Steuerelement stellt zahlreiche Ereignisse zur Verfügung. Um sich im Logik-Teil der Anwendung vom Eintreffen eines Ereignisses informieren zu lassen, registriert sich ein Steuerelement am Ereignis im Code-Behind-Anteil der Klasse mit einer Ereignis-Handlermethode. Der Nachteil dieser Vorgehensweise besteht darin, dass auf diese Weise UI und Logik miteinander verschmolzen werden. Auch ist es so nicht ohne Weiteres möglich, automatisierte Tests gegen eine Anwendung zu erstellen, wenn Teile der Anwendung im UI-Anteil verborgen sind

Zur Lösung dieses Problems hat man die so genannten Commands eingeführt. Unter einem Command („Kommando“) versteht man die Möglichkeit, die Folgehandlungen beim Eintreffen eines Ereignisses von seinem auslösenden Steuerelement (Quelle des Ereignisses) zu separieren. Nach wie vor existieren Ereignishandlermethoden im Logik-Anteil der Anwendung, die auf das Ereignis reagieren. Mit Hilfe des Command-Konzeptes lassen diese sich jedoch mittels Binding mit dem UI-Element verknüpfen, also deklarativ! Auf diese Weise gibt es keine programmierte Verschmelzung der beiden Ereignispartner (Event-Source und -Sink).

Softwaretechnisch ist ein Command ein Objekt, das die ICommand-Schnittstelle implementiert:

public interface ICommand
{
    event EventHandler CanExecuteChanged;

    bool CanExecute (Object parameter);
    void Execute (Object parameter);
}

Ausgeführt wird ein Command von Objekten, die eine ICommand-Eigenschaft besitzen. Dies sind in der WPF typischerweise UI-Objekte, die man prinzipiell für das Auslösen von Aktionen einsetzen würde, wie etwa Button- oder MenuItem-Steuerelemente.

Als Beispielanwendung betrachten wir dieses Mal eine Liedersammlung. Aktionen können das Hinzufügen oder Löschen von Liedern in dieser Sammlung sein, also alles Aktivitäten, die den Charakter eines „Kommandos“ besitzen. Neben der Bedienung der Anwendung durch WPF-Standardsteuerelemente lassen sich die Methoden des ViewModels auch automatisiert aufrufen.

Das MVVM-Entwurfsmuster am Beispiel einer Liedersammlung.

Abbildung 1. Das MVVM-Entwurfsmuster am Beispiel einer Liedersammlung.


Die Anwendung in Abbildung 1 möge einen kleinen Eindruck vermitteln, wie die Oberfläche einer „My favorite Song Album“-Applikation aussehen kann. Mit der Schaltfläche „Add Song“ lassen sich Artist und Titel eines Lieblingslieds von der Oberfläche eingeben, über die Schaltfläche „Add Song from Database“ lässt sich das Eintippen von der Tastatur vermeiden und es werden einige Lieder von einer anwendungsinternen Datenbasis eingelesen. Neben diesen „Kommandos“ gibt es auch die Aktivitäten „Remove Song“ oder sogar „Remove all Songs“.

2. Lösung

Quellcode: Siehe auch https://github.com/peterloos/Wpf_MVVM_SongsAndAlbums.

Wir gehen nun auf die Realisierung eines Kommandos im Detail ein. Für die Implementierung des CanExecuteChanged-Ereignisses der ICommand-Schnittstelle ist nichts zu tun, das Ereignis ist einfach der implementierenden Klasse hinzuzufügen. Etwas anders sieht es mit den beiden Methoden CanExecute und Execute aus. Diese sind zum einen zu implementieren, zum anderen benötigen diese in der Regel auch den Zugang zu den Daten des ViewModels. Ein kleiner Kunstkniff liegt darin, deren Realisierung zwar in einer separaten Klasse zu bewerkstelligen, den Rumpf der beiden Methoden aber via Delegates aus dem ViewModel in das Kommando-Objekt einzuschleusen:

private Predicate<Object> canExecute;
private Action<Object> execute;

Für die Realisierung der ICommand-Schnittstelle hat sich im Netz eine Standardimplementierung eingebürgert, die unter dem Namen RelayCommand firmiert und deren Implementierung auf einen der Erfinder des MVVM-Entwurfsmusters, Josh Smith, zurückzuführen ist (Listing 1):

01: class RelayCommand : ICommand
02: {
03:     private Action<Object> execute;
04:     private Predicate<Object> canExecute;
05: 
06:     public RelayCommand(Action<Object> execute, Predicate<Object> canExecute)
07:     {
08:         if (execute == null)
09:         {
10:             throw new ArgumentNullException("execute");
11:         }
12: 
13:         if (canExecute == null)
14:         {
15:             throw new ArgumentNullException("canExecute");
16:         }
17: 
18:         this.execute = execute;
19:         this.canExecute = canExecute;
20:     }
21: 
22:     public RelayCommand(Action<Object> execute)
23:         : this(execute, DefaultCanExecute)
24:     {
25:     }
26: 
27:     public event EventHandler CanExecuteChanged
28:     {
29:         add
30:         {
31:             if (this.canExecute != null)
32:                 CommandManager.RequerySuggested += value;
33:         }
34:         remove
35:         {
36:             if (this.canExecute != null)
37:                 CommandManager.RequerySuggested -= value;
38:         }
39:     }
40: 
41:     public bool CanExecute(Object parameter)
42:     {
43:         return this.canExecute != null && this.canExecute(parameter);
44:     }
45: 
46:     public void Execute(Object parameter)
47:     {
48:         this.execute(parameter);
49:     }
50: 
51:     public void Destroy()
52:     {
53:         this.execute = param => { return; };
54:         this.canExecute = param => false;
55:     }
56: 
57:     private static bool DefaultCanExecute(Object parameter)
58:     {
59:         return true;
60:     }
61: }

Beispiel 1. Standardimplementierung der ICommand-Schnittstelle: Klasse RelayCommand.


Nun gehen wir der Reihe nach die relevanten Klassen der Anwendung durch. Dieses Mal sind alle Beteiligten, also ein Model, eine View und ein ViewModel mit an Bord. Die Model-Klasse Song ist mit Absicht trivial gehalten. Es soll erkenntlich sein, dass sie beispielsweise den Charakter eines DTOs (Data-Transfer-Objekts) haben kann:

01: public class Song
02: {
03:     private String artistName;
04:     private String songTitle;
05: 
06:     public String ArtistName
07:     {
08:         get { return this.artistName; }
09:         set { this.artistName = value; }
10:     }
11: 
12:     public String SongTitle
13:     {
14:         get { return this.songTitle; }
15:         set { this.songTitle = value; }
16:     }
17: }

Beispiel 2. Implementierung der Model-Klasse Song.


In Listing 3 geht es weiter mit dem ViewModel für Lieder. Im Gegensatz zur absichtlich einfach gehaltenen Song-Klasse erkennen wir an einer SongViewModel-Klasse, dass – beispielsweise zum Zwecke der Datenbindung – die INotifyPropertyChanged-Schnittstelle vorhanden sein muss:

01: public class SongViewModel : INotifyPropertyChanged
02: {
03:     public event PropertyChangedEventHandler PropertyChanged;
04: 
05:     private Song song;
06:         
07:     public SongViewModel()
08:     {
09:         this.song = new Song ()
10:         {
11:             ArtistName = "Unknown",
12:             SongTitle = "Unknown"
13:         };
14:     }
15: 
16:     public Song Song
17:     {
18:         set { this.song = value; }
19:         get { return this.song; }
20:     }
21: 
22:     public String ArtistName
23:     {
24:         set
25:         {
26:             if (this.Song.ArtistName != value)
27:             {
28:                 this.Song.ArtistName = value;
29:                 this.RaisePropertyChanged("ArtistName");
30:             }
31:         }
32:         get { return this.Song.ArtistName; }
33:     }
34: 
35:     public String SongTitle
36:     {
37:         set
38:         {
39:             if (this.Song.SongTitle != value)
40:             {
41:                 this.Song.SongTitle = value;
42:                 this.RaisePropertyChanged("SongTitle");
43:             }
44:         }
45:         get { return this.Song.SongTitle; }
46:     }
47: 
48:     private void RaisePropertyChanged(String propertyName)
49:     {
50:         // using a copy to prevent thread issues ...
51:         PropertyChangedEventHandler handler = this.PropertyChanged;
52:         if (handler != null)
53:         {
54:             handler(this, new PropertyChangedEventArgs(propertyName));
55:         }
56:     }
57: 
58:     // commands
59:     public ICommand CommandUpdateSongTitle
60:     {
61:         get
62:         {
63:             return new RelayCommand (
64:                 param =>
65:                 {
66:                     String songTitle = this.SongTitle;
67: 
68:                     if (songTitle.Contains(" ["))
69:                     {
70:                         int delimiter = songTitle.IndexOf(" [");
71:                         songTitle = songTitle.Substring(0, delimiter);
72:                     }
73: 
74:                     String newSongTitle = String.Format("{0} [{1}]",
75:                         songTitle, DateTime.Now.TimeOfDay);
76: 
77:                     this.SongTitle = newSongTitle;
78:                 },
79:                 param => { return true; }
80:             );
81:         }
82:     }
83: }

Beispiel 3. Implementierung der ViewModel-Klasse SongViewModel.


Neben den drei Eigenschaften Song (Zeilen 16 bis 20), ArtistName (Zeilen 22 bis 33) und SongTitle (Zeilen 35 bis 46) finden wir in den Zeilen 59 bis 82 auch ein Kommando für das Aktualisieren eines Liedernamens vor. Die ViewModel-Klasse SongViewModel aus Listing 3 ist nur der Repräsentant für ein einzelnes Lied in einer Liedersammlung. Für die Verwaltung aller Lieder entwerfen wir eine zweite ViewModel-Klasse namens AlbumViewModel in Listing 4:

01: public class AlbumViewModel
02: {
03:     private SongDatabase database;
04:     private ObservableCollection<SongViewModel> songs;
05: 
06:     public AlbumViewModel()
07:     {
08:         this.database = new SongDatabase();
09:         this.songs = new ObservableCollection<SongViewModel>();
10:     }
11: 
12:     public ObservableCollection<SongViewModel> Songs
13:     {
14:         get
15:         {
16:             return this.songs;
17:         }
18:         set
19:         {
20:             this.songs = value;
21:         }
22:     }
23: 
24:     // commands
25:     public ICommand CommandAddSongFromDatabase
26:     {
27:         get
28:         {
29:             return new RelayCommand (
30:                 param =>
31:                 {
32:                     String[] record = this.database.GetRecord();
33:                     Song song = new Song { ArtistName = record[0], SongTitle = record[1] };
34:                     this.songs.Add(new SongViewModel() { Song = song });
35:                 },
36:                 param => { return this.songs.Count <= 9; }
37:             );
38:         }
39:     }
40: 
41:     public ICommand CommandAddSongManually
42:     {
43:         get
44:         {
45:             return new RelayCommand(
46:                 param =>
47:                 {
48:                     String[] record = new String[2]
49:                     {
50:                         (String)((Object[])param)[0],
51:                         (String)((Object[])param)[1]
52:                     };
53:                     if (String.IsNullOrEmpty (record[0]) || String.IsNullOrEmpty (record[1]))
54:                         return;
55: 
56:                     Song song = new Song { ArtistName = record[0], SongTitle = record[1] };
57:                     this.songs.Add(new SongViewModel() { Song = song });
58:                 },
59:                 param => { return this.songs.Count <= 9; }
60:             );
61:         }
62:     }
63: 
64:     public ICommand CommandRemoveSong
65:     {
66:         get
67:         {
68:             return new RelayCommand(
69:                 param =>
70:                 {
71:                     int index = Int32.Parse(param.ToString()) - 1;
72:                     if (index >= this.songs.Count)
73:                         return;
74: 
75:                     this.songs.RemoveAt(index);
76:                 },
77:                 param => { return this.songs.Count > 0; }
78:             );
79:         }
80:     }
81: 
82:     public ICommand CommandRemoveAllSongs
83:     {
84:         get
85:         {
86:             return new RelayCommand(
87:                 param => { this.songs.Clear(); },
88:                 param => { return this.songs.Count > 0; }
89:             );
90:         }
91:     }
92: }

Beispiel 4. Implementierung der ViewModel-Klasse AlbumViewModel.


Betrachten Sie in Listing 4 vor allem die Zeilen 29, 45, 68 und 86: Für die vier Kommandos CommandAddSongFromDatabase, CommandAddSongManually, CommandRemoveSong und CommandRemoveAllSongs werden hier vier RelayCommand-Objekte angelegt. Mit Hilfe von Lambda-Ausdrücken werden für die beiden Methoden CanExecute und Execute Implementierungen injiziert, die den vollen Zugriff auf das ViewModel-Objekt haben.

Die ViewModel-Klasse der Liedersammlung steht in der Pflicht, eine Liste mit allen Liedern bereitzustellen. Natürlich könnte man die Lieder jedes Mal nach dem Starten der Anwendung manuell in der Oberfläche der Anwendung eingegeben. Ein anderer und einfacherer Weg besteht darin, der ViewModel-Klasse eine kleine Datenbank mit einer Liederliste zur Seite zu stellen. Eine echte Datenbank verwenden wir in diesem Beispiel natürlich nicht, als Ersatz kommt die Klasse SongDatabase aus Listing 5 zum Einsatz:

01: public class SongDatabase
02: {
03:     private int index;
04: 
05:     public SongDatabase()
06:     {
07:         this.index = 0;
08:     }
09: 
10:     private String[] artists = {
11:         "Marcin Wasilewski", 
12:         "Gwilym Simcock",
13:         "Alan Pasqua", 	
14:         "George Duke", 	
15:         "Brad Mehldau", 		
16:         "Joshua Redman", 	
17:         "Antonio Farao"
18:     };
19: 
20:     private String[] songTitles = {
21:         "Three Reflections", 
22:         "Affinity", 
23:         "My New Old Friend", 
24:         "You Never Know", 
25:         "Song-Song", 
26:         "Faith", 
27:         "Dormi"
28:     };
29: 
30:     public String[] GetRecord()
31:     {
32:         String[] nextSong = new String[]
33:         {
34:             this.artists[this.index],
35:             this.songTitles[this.index]
36:         };
37: 
38:         this.index++;
39:         if (this.index == this.artists.Length)
40:             this.index = 0;
41: 
42:         return nextSong;
43:     }
44: }

Beispiel 5. Implementierung der Hilfsklasse SongDatabase.


In Listing 6 fahren wir fort mit einer View-Klasse namens MainView (XAML-Anteil):

001: <Window x:Class="WpfApplication.MainView"
002:         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
003:         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
004:         xmlns:local="clr-namespace:WpfApplication.ViewModels"
005:         xmlns:conv="clr-namespace:WpfApplicationConverters"
006:         Title="My favorite Song Album" Height="350" Width="525">
007:     <Window.DataContext>
008:         <local:AlbumViewModel/>
009:     </Window.DataContext>
010: 
011:     <Window.Resources>
012: 
013:         <conv:PassThroughConverter x:Key="PassThroughConverter"/>
014:         
015:         <Style x:Key="LabelStyleArtist" TargetType="{x:Type Label}">
016:             <Setter Property="Width" Value="160" />
017:             <Setter Property="FontWeight" Value="Bold" />
018:             <Setter Property="FontSize" Value="14" />
019:             <Setter Property="FontFamily" Value="Arial" />
020:         </Style>
021:         <Style x:Key="LabelStyleTitle" TargetType="{x:Type Label}">
022:             <Setter Property="FontStyle" Value="Italic" />
023:             <Setter Property="FontSize" Value="12" />
024:             <Setter Property="FontFamily" Value="Arial" />
025:         </Style>
026:     </Window.Resources>
027: 
028:     <DockPanel LastChildFill="True">
029:         
030:         <Menu DockPanel.Dock="Top">
031:             <MenuItem Header="Test">
032:                 <MenuItem Header="Add new Artist"
033:                           Command="{Binding CommandAddSongFromDatabase}" />
034:                 <MenuItem Header="Remove all Songs"
035:                           Command="{Binding CommandRemoveAllSongs}" />
036:             </MenuItem>
037:         </Menu>
038: 
039:         <GroupBox Header="Standard Application" DockPanel.Dock="Top" Margin="3">
040:             <UniformGrid DockPanel.Dock="Top" Rows="0" Columns="3">
041:                 <Label Margin="3">Artist Name:</Label>
042:                 <TextBox Name="TextBoxArtistName" Margin="3"></TextBox>
043:                 <Button
044:                     Content="Add Song"  Margin="3"
045:                     Command="{Binding CommandAddSongManually}">
046:                     <Button.CommandParameter>
047:                         <MultiBinding StringFormat="[{0} {1}]"
048:                             Converter="{StaticResource PassThroughConverter}">
049:                                 <Binding ElementName="TextBoxArtistName" Path="Text"/>
050:                                 <Binding ElementName="TextBoxSongTitle" Path="Text"/>
051:                         </MultiBinding>
052:                     </Button.CommandParameter>
053:                 </Button>
054:                 <Label Margin="3">Song Title:</Label>
055:                 <TextBox Name="TextBoxSongTitle" Margin="3"></TextBox>
056:                 <Button
057:                     Content="Add Song from Database"
058:                     Command="{Binding CommandAddSongFromDatabase}" Margin="3"/>
059:                 <Button
060:                     Content="Remove Song"   Margin="3"
061:                     CommandParameter="{Binding ElementName=ComboBoxRemoveSong, Path=SelectedItem.Content}"
062:                     Command="{Binding CommandRemoveSong}"/>
063:                 <ComboBox SelectedIndex="0" Name="ComboBoxRemoveSong" Margin="3">
064:                     <ComboBoxItem>1</ComboBoxItem>
065:                     <ComboBoxItem>2</ComboBoxItem>
066:                     <ComboBoxItem>3</ComboBoxItem>
067:                     <ComboBoxItem>4</ComboBoxItem>
068:                     <ComboBoxItem>5</ComboBoxItem>
069:                 </ComboBox>
070:                 <Button
071:                     Content="Remove all Songs"
072:                     Command="{Binding CommandRemoveAllSongs}" Margin="3" />
073:             </UniformGrid>
074:         </GroupBox>
075: 
076:         <GroupBox Header="Unit Test" DockPanel.Dock="Top" Margin="3">
077:             <StackPanel Orientation="Vertical">
078:                 <Button Name="ButtonTestAddSong"
079:                     Content="Add new Song (Test)"
080:                     Click="Button_Click" Margin="3"/>
081:                 <Button Name="ButtonTestUpdateArtist"
082:                     Content="Update Name of Song (Test)"
083:                     Click="Button_Click" Margin="3"/>
084:             </StackPanel>
085:         </GroupBox>
086: 
087:         <GroupBox Header="Song Album" Margin="3">
088:             <ListView Name="ListViewSongs"
089:                       ItemsSource="{Binding Songs}"
090:                       SelectionChanged="ListView_SelectionChanged">
091:                 <ListView.ItemTemplate>
092:                     <DataTemplate>
093:                         <DockPanel LastChildFill="True">
094:                             <Label Content="{Binding ArtistName}"
095:                                    Style="{StaticResource LabelStyleArtist}"
096:                                    DockPanel.Dock="Left" />
097:                             <Label Content="{Binding SongTitle}"
098:                                    Style="{StaticResource LabelStyleTitle}"/>
099:                         </DockPanel>
100:                     </DataTemplate>
101:                 </ListView.ItemTemplate>
102:             </ListView>
103:         </GroupBox>
104:     </DockPanel>
105: </Window>

Beispiel 6. Implementierung einer View-Klasse MainView: XAML.


Bei Kommandos steht man manchmal vor dem Problem, dass zum Auslösen des Kommandos Informationen (Daten) mehrerer beteiligter Steuerelemente an die Execute-Methode zu reichen sind. Dies ist nicht ganz trivial möglich, man benötigt zu diesem Zweck eine Implementierung der IMultiValueConverter-Schnittstelle. In unserem Beispiel sieht diese so aus:

public class PassThroughConverter : IMultiValueConverter 
{
    public Object Convert(Object[] values, Type targetType,
        Object parameter, CultureInfo culture)
    {
        return values.Clone();
    }

    public Object[] ConvertBack(Object value, Type[] targetTypes,
        Object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Die Verknüpfung des PassThroughConverter-Objekts mit dem Button-Objekt finden Sie in Listing 6 in den Zeilen 46 bis 52 vor - der besseren Übersichtlichkeit halber hier noch einmal kurz zusammengestellt:

<Button
    Content="Add Song"  Margin="3"
    Command="{Binding CommandAddSongManually}">
    <Button.CommandParameter>
        <MultiBinding StringFormat="[{0} {1}]"
            Converter="{StaticResource PassThroughConverter}">
                <Binding ElementName="TextBoxArtistName" Path="Text"/>
                <Binding ElementName="TextBoxSongTitle" Path="Text"/>
        </MultiBinding>
    </Button.CommandParameter>
</Button>

Es fehlt noch die Codebehind-Datei zur MainView-Klasse (Listing 7):

01: public partial class MainView : Window
02: {
03:     public MainView()
04:     {
05:         this.InitializeComponent();
06:     }
07: 
08:     private void Button_Click (Object sender, RoutedEventArgs e)
09:     {
10:         AlbumViewModel viewModel = (AlbumViewModel) base.DataContext;
11: 
12:         if (sender == this.ButtonTestAddSong)
13:         {
14:             ICommand cmdAddSong =
15:                 viewModel.CommandAddSongFromDatabase;
16: 
17:             if (cmdAddSong.CanExecute(null))
18:                 cmdAddSong.Execute(null);
19:         }
20:         else if (sender == this.ButtonTestUpdateArtist)
21:         {
22:             int numberOfSongs = viewModel.Songs.Count;
23:             if (numberOfSongs == 0)
24:                 return;
25: 
26:             int randomIndex = (new Random()).Next() % numberOfSongs;
27:             SongViewModel songViewModel = viewModel.Songs[randomIndex];
28: 
29:             ICommand cmdUpdateSongTitle =
30:                 songViewModel.CommandUpdateSongTitle;
31: 
32:             if (cmdUpdateSongTitle.CanExecute(null))
33:                 cmdUpdateSongTitle.Execute(null);
34:         }
35:     }
36: 
37:     private void ListView_SelectionChanged (
38:         Object sender, SelectionChangedEventArgs e)
39:     {
40:         SongViewModel songViewModel =
41:             (SongViewModel) this.ListViewSongs.SelectedItem;
42: 
43:         if (songViewModel != null)
44:         {
45:             ICommand cmd = songViewModel.CommandUpdateSongTitle;
46:             if (cmd.CanExecute(null))
47:                 cmd.Execute(null);
48:         }
49:     }
50: }

Beispiel 7. Implementierung einer View-Klasse MainView: Codebehind.


Beachten Sie bitte in Listing 7: Die zwei Methoden Button_Click und ListView_SelectionChanged enthalten C#-Anweisungen zum automatisierten Testen der Anwendung. Davon abgesehen wäre die Anwendung mit einem leeren Codebehind-Anweisungsteil ausgestattet.