("MVVM"-Pattern bedeutet: "Model-View-Viewmodel")
Anbei mal eine Sample-Solution, die die grundlegende Architektur einer Wpf-Anwendung nach MVVM verdeutlichen soll - zumindest eine übliche Variante.
Die Dateistruktur weist eine klare Trennung von Viewmodel und View auf, zumal ja eh deren Datei-Endung verschieden ist.
Ein Prinzip ist auch, dass es ein MainViewmodel gibt, wo alles "zusammenfließt", und (meist) mehrere partielle Viewmodel-Klassen, die Teilstrukturen definieren - hier etwa einen Person-Datensatz.
Und weil im Bild noch Platz war hab ich gleich die Application.Xaml gezeigt, und wie da in den Resourcen eine
In diesem Beipiel spiegelt sich die Struktur
Zu beachten, dass hier bereits die Designer-Vorschau Test-Daten zeigt - für den Entwickler ein wertvolles Feedback, ob die Bindings stimmen.
Diese Designer-Daten-Preview ist nicht selbstverständlich: Legt man seine Anwendungs-Struktur ungeschickt an, oder generiert zur Designzeit keine TestDaten, dann bringt man sich um den Genuss dieser Unterstützung.
Auch gibt es Szenarien, in denen diese Unterstützung leider überhaupt nicht in Gang gebracht werden kann - Xaml ist im Grunde eine lausig zu programmierende Sprache voll von Kinderkrankheiten.
Dennoch sollte man von Anfang an gucken, sich diese Features zu erhalten, und nur notgedrungen drauf verzichten.
Sonst lernt man im Grunde "behindertes Xaml", vergleichbar dem "behinderten Vb.Net", was man lernt, wenn man die Visual Basic - Empfohlene Einstellungen nicht tätigt.
Ja, auch das
Die Sample-Anwendung
macht im Grunde nichts besonderes: eine Liste von Person-Datensätzen wird geladen, bearbeitet, rückgespeichert.
Dabei - wie obiges Bild andeutet - kann man eine Person links auswählen, und rechts dann in Detail-Ansicht bearbeiten - also ein Detail-View (wem der Begriff "DetailView" nicht sonnenklar ist, der folge bitte dem Link).
Eine kleine Besonderheit ist die
Hier mal das
Das schöne am MVVM ist, dass im Viewmodel-Code keinerlei
Mit hauptsächlich den vier Properties:
Aber es gibt noch 2 weitere Properties, und die sind speziell für Logik ausgelegt:
Das ist eine Wpf-Besonderheit, dass ein Viewmodel Logik oft als
Und so stellt
So kann Logik sehr gezielt zugeordnet werden: Etwa im
Man sieht vor allem Speichern und Laden, und wie das von den Commands ausgelöst wird.
Ausserdem gibts noch ein
Beachte auch die
Ein weiterer Unterschied ist, dass
Die zweite Methode hingegen ist eine
So kann man überaus elegant bewirken, dass der User den Delete-Button garnet betätigen kann, wenns zum Deleten nix gibt - also Deleted werden kann nur, wenn - hingeguckt:
Hier mal das Xaml des Delete-Buttons - es ist in
Also der Button bindet sein Command an
Ausserdem hat der Button einen ToolTip, und statt einer Aufschrift enthält er ein Image mit einem Icon - macht sich insgesamt ganz gut - gugge Bildle - es ist das zweite von oben.
TestDaten-Generierung
Ich hab ein eigenes kleines Helpers-Framework - siehe die Datei-Struktur im ersten Bildle. Darin befindet sich eine MainModelBase-Klasse, die eine Unterscheidungsmöglichkeit bereitstellt, ob das Mainmodel zur Designzeit instanziert wird oder zur Laufzeit. Das Mainmodel nutzt das folgendermassen:
Also falls es provisorisch erstellt wird (
Beachte auch
Im Xaml wird dann über den
Man sieht: das uclPerson hat seinen DataContext auf
Der
Die "Zuweisung" ist etwas indirekt, nämlich
Das MainWindow selbst hat seinen DataContext natürlich nicht mittels
Wo ist das Model?
Eigentlich gehören zum Model-View-Viewmodel-Pattern natürlich auch Model-Klassen, aber die gibt es hier gar nicht. View und Viewmodel sind wie gezeigt klar getrennt, aber Model und Viewmodel sind hier (noch) eins. Ist bei mir oft so, dass es sich als nicht sinnvoll erweist, Viewmodel und Model nochmal extra auseinanderzudividieren.
Zum
Und das
Aber ich kann die Ausdifferenzierung spasseshalber mal fiktiv vorführen - vergleiche:
Wie es derzeit ist
mit
Wie es mit ausdifferenziertem Model wäre
Wie gesagt: in meinen Augen keinerlei Gewinn, nur eine Klasse mehr würde herumfahren. Es bestünde sogar die Gefahr, dass direkt auf der
Derlei kann zu schwer zu debuggendem Fehlverhalten führen: oberflächlich liefe alles, nur zB Aktualisierungen würden ausbleiben. In Folge würde dann was anderes präsentiert, als was tatsächlich gegeben ist .
Wie dem auch sei.
Dieser Punkt - "Model-Segregation oder nicht" - wird höchst kontrovers diskutiert (weil Pattern-Abweichungen immer höchst kontrovers diskutiert werden - das ist selbst ein Pattern ) - jedenfalls ich stelle hier nochmal heraus, dass dieses Tutorial schon nicht mehr die ganz reine Lehre präsentiert - mag es als Vor- oder Nach-teil gelten.
Anbei mal eine Sample-Solution, die die grundlegende Architektur einer Wpf-Anwendung nach MVVM verdeutlichen soll - zumindest eine übliche Variante.
Die Dateistruktur weist eine klare Trennung von Viewmodel und View auf, zumal ja eh deren Datei-Endung verschieden ist.
Ein Prinzip ist auch, dass es ein MainViewmodel gibt, wo alles "zusammenfließt", und (meist) mehrere partielle Viewmodel-Klassen, die Teilstrukturen definieren - hier etwa einen Person-Datensatz.
Und weil im Bild noch Platz war hab ich gleich die Application.Xaml gezeigt, und wie da in den Resourcen eine
MainModel
-Instanz hinterlegt ist, an die im weiteren in der kompletten Anwendung in verschiedenster Weise angebunden werden kann.In diesem Beipiel spiegelt sich die Struktur
MainModel -> Person
auch im View wieder, als MainWindow -> uclPerson
, wobei uclPerson
, ein UserControl ist, was speziell eine Person
präsentiert:Zu beachten, dass hier bereits die Designer-Vorschau Test-Daten zeigt - für den Entwickler ein wertvolles Feedback, ob die Bindings stimmen.
Diese Designer-Daten-Preview ist nicht selbstverständlich: Legt man seine Anwendungs-Struktur ungeschickt an, oder generiert zur Designzeit keine TestDaten, dann bringt man sich um den Genuss dieser Unterstützung.
Auch gibt es Szenarien, in denen diese Unterstützung leider überhaupt nicht in Gang gebracht werden kann - Xaml ist im Grunde eine lausig zu programmierende Sprache voll von Kinderkrankheiten.
Dennoch sollte man von Anfang an gucken, sich diese Features zu erhalten, und nur notgedrungen drauf verzichten.
Sonst lernt man im Grunde "behindertes Xaml", vergleichbar dem "behinderten Vb.Net", was man lernt, wenn man die Visual Basic - Empfohlene Einstellungen nicht tätigt.
Ja, auch das
MainWindow
glänzt mit Test-Data-Preview, und zeigt mir an, dass die Listbox links richtig gebunden ist, denn sie zeigt korrekt die Namen meiner Person-Test-Daten:Die Sample-Anwendung
macht im Grunde nichts besonderes: eine Liste von Person-Datensätzen wird geladen, bearbeitet, rückgespeichert.
Dabei - wie obiges Bild andeutet - kann man eine Person links auswählen, und rechts dann in Detail-Ansicht bearbeiten - also ein Detail-View (wem der Begriff "DetailView" nicht sonnenklar ist, der folge bitte dem Link).
Eine kleine Besonderheit ist die
Person.Rate
-Property, die vorsieht, jeder Person eine "Bewertung" von 1 - 9 zuzuordnen.Hier mal das
Person
-Viewmodel:VB.NET-Quellcode
- Public Class Person : Inherits NotifyPropertyChanged
- Public Property cmdUpRate() As New RelayCommand(Sub() Rate += 1, Function() _Rate < 10)
- Public Property cmdDownRate() As New RelayCommand(Sub() Rate -= 1, Function() _Rate > 1)
- Private _Rate As Integer = 5
- Public Property Rate() As Integer
- Get
- Return _Rate
- End Get
- Set(ByVal value As Integer)
- ChangePropIfDifferent(value, _Rate)
- End Set
- End Property
- Private _Name As String
- Public Property Name() As String
- Get
- Return _Name
- End Get
- Set(value As String)
- ChangePropIfDifferent(value, _Name)
- End Set
- End Property
- Private _Phone As String
- Public Property Phone() As String
- Get
- Return _Phone
- End Get
- Set(value As String)
- ChangePropIfDifferent(value, _Phone)
- End Set
- End Property
- Private _BirthDay As DateTime
- Public Property BirthDay() As DateTime
- Get
- Return _BirthDay
- End Get
- Set(value As DateTime)
- ChangePropIfDifferent(value, _BirthDay)
- End Set
- End Property
- End Class
Button
, Listbox
, Combobox
oder whatever auftauchen, sondern der Datensatz bleibt genau das, was er ist: ein Datensatz.Mit hauptsächlich den vier Properties:
Name
, Phone
, BirthDay
und Rate
, und diese Properties enthalten im Setter auch keine weitere Logik, als nur das PropertyChanged
-Event zu senden, wenn die Property changed.Aber es gibt noch 2 weitere Properties, und die sind speziell für Logik ausgelegt:
cmdUpRate
, cmdDownRate
(ja - das sind Properties! )Das ist eine Wpf-Besonderheit, dass ein Viewmodel Logik oft als
Property
bereitstellt, anstatt wie üblich als Sub
- nämlich dann, wenn Xaml ins Viewmodel hineinwirken soll. Denn Xaml kann nur an Properties binden.Und so stellt
Person
diese beiden Properties bereit.So kann Logik sehr gezielt zugeordnet werden: Etwa im
MainModel
gibt es Commands zum Laden und Speichern, aber die Commands zum Up-/Down-Raten einer einzelnen Person hab ich ins Person-Viewmodel verfrachtet - das Mainmodel
muss ich damit nicht überfrachten. Hier mal das MainModel komplett:VB.NET-Quellcode
- Public Class MainModel : Inherits MainModelBase(Of MainModel)
- Private Const _DataSeparator As Char = Microsoft.VisualBasic.ChrW(&HF018)
- Private _DataFile As New FileInfo("..\..\PersonData.txt")
- Private __Persons As New ObservableCollection(Of Person)()
- Public Property Persons() As New ListCollectionView(__Persons) ' enables to access/observe the currently selected Person
- Public Property cmdFill() As New RelayCommand(AddressOf Load)
- Public Property cmdSave() As New RelayCommand(AddressOf Save)
- Public Property cmdDoSomething() As New RelayCommand(AddressOf DoSomethingWithAPerson, Function() Persons.CurrentPosition >= 0)
- Public Property cmdDelete() As New RelayCommand(Of Person)(Sub(p) Persons.Remove(p), Function() Persons.CurrentPosition >= 0)
- Public Property cmdAdd() As New RelayCommand(Sub() Persons.AddNewItem(New Person() With {.Name = "<aName>"}))
- Public Sub New()
- If IsProvisional Then ' TestData for Designer-Data-Preview
- Enumerable.Range(0, 10).ForEach(Sub(i) __Persons.Add(Person.FromInt(i)))
- Persons.MoveCurrentToFirst() ' Ensure a Record as selected - for Designer-Data-Preview
- Return
- End If
- Load()
- End Sub
- Private Sub Save()
- Using writer = _DataFile.CreateText
- For Each pers In __Persons
- With pers
- writer.WriteLine(_DataSeparator.Between(.Name, .Phone, .BirthDay.ToString(CultureInfo.InvariantCulture), .Rate))
- End With
- Next
- End Using
- System.Media.SystemSounds.Asterisk.Play()
- End Sub
- Private Sub Load()
- __Persons.Clear()
- Using reader = _DataFile.OpenText
- While Not reader.EndOfStream
- Dim data = reader.ReadLine.Split(_DataSeparator)
- __Persons.Add(New Person(data(0), data(1), Date.Parse(data(2), CultureInfo.InvariantCulture), Integer.Parse(data(3))))
- End While
- End Using
- Persons.MoveCurrentToFirst()
- End Sub
- Private Sub DoSomethingWithAPerson()
- Dim person = __Persons(Persons.CurrentPosition) ' access the currently selected Person
- Msg("Aktuell angewählte Person: ", person.Name)
- End Sub
- End Class
Ausserdem gibts noch ein
DoSomethingWithAPerson()
, um zu demonstrieren, dass Xaml bei Bedarf durchaus auch im MainModel Logik auslösen kann, die eine einzelne Person betrifft - nämlich die aktuell ausgewählte Person.Beachte auch die
cmdDelete
-Variante: Beim Delete muss ja ebenfalls das MainModel mit einer bestimmten Person agieren - aber anders als beim parameterlosen cmdDoSomething
wird beim Delete die zu deletende Person übergeben.Ein weiterer Unterschied ist, dass
cmdDoSomething
mittels AddressOf DoSomethingWithAPerson
den Aufruf weiterleitet, während cmdDelete
seinen Aufruf direkt in einer kleinen anonymen Sub
abhandelt - eine klassische, explizite Methode hinzuschreiben, und darauf umzuleiten hab ich mir einfach gespart .cmdDelete
und cmdDoSomething
zeigen übrigens noch eine weitere Eigenart des Command-Patterns: Wenn man genau guckt, sieht man, dass das RelayCommand sogar zwei Methoden enthält: Die erste Methode wird ausgeführt, wenn der Command aufgerufen wird - es ist eine Sub
, also ohne Rückgabewert. Die zweite Methode hingegen ist eine
Function
und ihr Rückgabewert ist ein Boolean
, nämlich die Aussage, ob das Command überhaupt ausführbar ist.So kann man überaus elegant bewirken, dass der User den Delete-Button garnet betätigen kann, wenns zum Deleten nix gibt - also Deleted werden kann nur, wenn - hingeguckt:
Persons.CurrentPosition >= 0
Hier mal das Xaml des Delete-Buttons - es ist in
uclPerson.Xaml
verortet:XML-Quellcode
Mainmodel.cmdDelete
, und bindet zusätzlich den CommandParameter, und zwar an den DataContext von sich selbst! - und der DataContext des Buttons selbst (innerhalb von uclPerson) ist natürlich genau die Person - was sonst?Ausserdem hat der Button einen ToolTip, und statt einer Aufschrift enthält er ein Image mit einem Icon - macht sich insgesamt ganz gut - gugge Bildle - es ist das zweite von oben.
TestDaten-Generierung
Ich hab ein eigenes kleines Helpers-Framework - siehe die Datei-Struktur im ersten Bildle. Darin befindet sich eine MainModelBase-Klasse, die eine Unterscheidungsmöglichkeit bereitstellt, ob das Mainmodel zur Designzeit instanziert wird oder zur Laufzeit. Das Mainmodel nutzt das folgendermassen:
IsProvisional
), befüllt es sich mit generierten TestDaten, und führt die für einen richtigen Startup vorgesehene Load()
-Methode nicht aus.Beachte auch
Persons.MoveCurrentToFirst()
, was die CollectionView dazu bringt, den ersten generierten Datensatz als CurrentItem zu "veröffentlichen" - andernfalls wäre kein Datensatz selected, es gäbe also kein DesignTime-CurrentItem, und ans CurrentItem gebundes Xaml würde folglich in der Vorschau auch keine TestDaten anzeigen.Im Xaml wird dann über den
d:
-Namespace an diese TestDaten gebunden:MainModel.Persons
- gesetzt, wobei das abschliessende /
das CurrentItem
der CollectionView bestimmt - nicht die ganze Auflistung. Dadurch wird der erste der im MainModel generierten TestDatensätze zum DesignTime-DataContext, und wird korrekt angezeigt, sofern die Bindings stimmen. Siehe nochmal das 2. Bildle von oben.Der
d:
-Namespace gilt nur zur DesignTime, und das ist auch gut so, denn zur Laufzeit wird der DataContext dem uclPerson
dann von aussen zugewiesen, im MainWindow
:XML-Quellcode
- <Window x:Class="MainWindow"
- ...
- DataContext="{Binding Source={StaticResource Mainmodel}}">
- <Grid> <-- 2 Columns, 2 Rows -->
- <Button Content="Add" Command="{Binding cmdAdd}"/>
- <ListBox Grid.Row="1" ItemsSource="{Binding Persons}" IsSynchronizedWithCurrentItem="True" DisplayMemberPath="Name" Padding="4"/>
- <my:uclPerson DataContext="{Binding Persons/}" Grid.Column="1" Grid.RowSpan="2" />
- </Grid>
- </Window>
Listbox.IsSynchronizedWithCurrentItem
definiert, dass die ListBox das CollectionView.CurrentItem
selektiert - und daran ist ja das uclPerson gebunden.Das MainWindow selbst hat seinen DataContext natürlich nicht mittels
d:
-Namespace gebunden, sondern normal. Denn beim MainWindow unterscheidet sich das Laufzeit-DataContext-Binding ja nicht vom DesignTime-DataContext-Binding.Wo ist das Model?
Eigentlich gehören zum Model-View-Viewmodel-Pattern natürlich auch Model-Klassen, aber die gibt es hier gar nicht. View und Viewmodel sind wie gezeigt klar getrennt, aber Model und Viewmodel sind hier (noch) eins. Ist bei mir oft so, dass es sich als nicht sinnvoll erweist, Viewmodel und Model nochmal extra auseinanderzudividieren.
Zum
Mainmodel
existiert grundsätzlich niemals eine Model-Klasse, denn es ist ja ein Composicum anderer Viewmodels, zuzüglich Startup- und meist noch weiterer Logik.Und das
Person
-Viewmodel ist bereits jetzt schon so schlank - wenn man da noch eine Person-Model-Klasse auslagern wollte - man würde absolut nichts gewinnen an Transparenz.Aber ich kann die Ausdifferenzierung spasseshalber mal fiktiv vorführen - vergleiche:
VB.NET-Quellcode
- Public Class Person : Inherits NotifyPropertyChanged
- Public Property cmdUpRate() As New RelayCommand(Sub() Rate += 1, Function() _Rate < 10)
- Public Property cmdDownRate() As New RelayCommand(Sub() Rate -= 1, Function() _Rate > 1)
- Private _Rate As Integer = 5
- Public Property Rate() As Integer
- Get
- Return _Rate
- End Get
- Set(ByVal value As Integer)
- ChangePropIfDifferent(value, _Rate)
- End Set
- End Property
- Private _Name As String
- Public Property Name() As String
- Get
- Return _Name
- End Get
- Set(value As String)
- ChangePropIfDifferent(value, _Name)
- End Set
- End Property
- Private _Phone As String
- Public Property Phone() As String
- Get
- Return _Phone
- End Get
- Set(value As String)
- ChangePropIfDifferent(value, _Phone)
- End Set
- End Property
- Private _BirthDay As DateTime
- Public Property BirthDay() As DateTime
- Get
- Return _BirthDay
- End Get
- Set(value As DateTime)
- ChangePropIfDifferent(value, _BirthDay)
- End Set
- End Property
- Public Sub New()
- End Sub
- Public Sub New(name As String, phone As String, birth As DateTime, rate As Integer)
- _Name = name
- _Phone = phone
- _BirthDay = birth
- _Rate = rate
- End Sub
- Public Shared Function FromInt(i As Integer) As Person
- 'to create TestData at DesignTime
- Return New Person("Person" & i, "54321" & i, DateTime.Today.AddYears(-60 + i), 1 + i Mod 9)
- End Function
- End Class
VB.NET-Quellcode
- Public Class PersonVm : Inherits NotifyPropertyChanged
- Public Property cmdUpRate() As New RelayCommand(Sub() Rate += 1, Function() _Person.Rate < 10)
- Public Property cmdDownRate() As New RelayCommand(Sub() Rate -= 1, Function() _Person.Rate > 1)
- Private _Person As Person
- Public Property Rate() As Integer
- Get
- Return _Person.Rate
- End Get
- Set(ByVal value As Integer)
- ChangePropIfDifferent(value, _Person.Rate)
- End Set
- End Property
- Public Property Name() As String
- Get
- Return _Person.Name
- End Get
- Set(value As String)
- ChangePropIfDifferent(value, _Person.Name)
- End Set
- End Property
- Public Property Phone() As String
- Get
- Return _Person.Phone
- End Get
- Set(value As String)
- ChangePropIfDifferent(value, _Person.Phone)
- End Set
- End Property
- Public Property BirthDay() As DateTime
- Get
- Return _Person.BirthDay
- End Get
- Set(value As DateTime)
- ChangePropIfDifferent(value, _Person.BirthDay)
- End Set
- End Property
- Public Sub New()
- _Person = New Person
- End Sub
- Public Sub New(name As String, phone As String, birth As DateTime, rate As Integer)
- Me.New()
- _Person.Name = name
- _Person.Phone = phone
- _Person.BirthDay = birth
- _Person.Rate = rate
- End Sub
- Public Shared Function FromInt(i As Integer) As PersonVm
- 'to create TestData at DesignTime
- Return New PersonVm("Person" & i, "54321" & i, DateTime.Today.AddYears(-60 + i), 1 + i Mod 9)
- End Function
- End Class
- Public Class Person
- Public Property Rate() As Integer
- Public Property Name() As String
- Public Property Phone() As String
- Public Property BirthDay() As DateTime
- End Class
Person
-Klasse agiert würde, unter Umgehung des Viewmodels. Derlei kann zu schwer zu debuggendem Fehlverhalten führen: oberflächlich liefe alles, nur zB Aktualisierungen würden ausbleiben. In Folge würde dann was anderes präsentiert, als was tatsächlich gegeben ist .
Wie dem auch sei.
Dieser Punkt - "Model-Segregation oder nicht" - wird höchst kontrovers diskutiert (weil Pattern-Abweichungen immer höchst kontrovers diskutiert werden - das ist selbst ein Pattern ) - jedenfalls ich stelle hier nochmal heraus, dass dieses Tutorial schon nicht mehr die ganz reine Lehre präsentiert - mag es als Vor- oder Nach-teil gelten.
Dieser Beitrag wurde bereits 19 mal editiert, zuletzt von „ErfinderDesRades“ ()