Auf Prism wurde ich zum ersten Mal bei einem Vortrag bei der .Net Usergroup Regensburg aufmerksam – vor locker 10 Jahren. Zunächst wusste ich gar nicht, um was es sich genau handelt. Nach dem Vortrag und weiterer Recherche hat Prism aber die Art und Weise, wie ich Desktop- und Mobile-Applikationen strukturiert habe, maßgeblich beeinflusst. Ein wichtiger Punkt für mich damals war die Aufteilung einer großen Applikation in mehrere, lose miteinander gekoppelte Module und die dafür bereitgestellten Best Practices. Prism folgt dabei den Grundsätzen des MVVM-Patterns und erweitert dieses um weitere Werkzeuge wie CompositeCommand, dem bereitgestellten EventAggregator oder eben Basisklassen für Module. Zusätzlich wird ein DI-Container integriert. Für die Desktop-Applikation RK GPXviewer [1], welche ich gerade für die Planungen meiner Wander- und Fahrrad-Touren im Sommer baue, verwende ich die aktuelle Version von Prism und möchte hier in diesem Artikel einige Erfahrungen damit teilen.
Inhaltsverzeichnis
Aufteilung in Module
Eine der für mich wichtigsten Grundsätze bei Prism ist die Aufteilung einer Applikation in kleine, übersichtliche Module [2]. Ein Modul soll dabei möglichst für sich selbst lauffähig sein und mit der Außenwelt nur über definierte Schnittstellen sprechen. Bei RK GPXviewer sind das nach aktuellem Stand die folgenden:
- Shell (Rahmen der Applikation, Hauptfenster)
- Core (Enthält Infrastruktur-Klassen, CompositeCommands und sonstige Utitlity-Methoden)
- GpxFiles (Kümmert sich um Gpx-Dateien im Allgemeinen. Anzeigen, auswählen, speichern, etc.)
- Map (Kümmert sich um die Map-Anzeige)
Später kommen noch weitere Module dazu, aktuell denke ich da etwa an eines, welches sich um die Darstellung des Höhenprofils von Routen kümmert. Bei den drei existierenden Modulen ist die Shell das Hauptprojekt. Hier wird das Hauptfenster mit verschiedenen Regionen definiert, in die sich andere Module einklinken können. Im Hintergrund wird das Konzept der Regions aus Prism verwendet. Hierbei können verschiedene Controls aus dem Framework als Region gekennzeichnet werden. Im nachfolgenden Code-Ausschnitt verwende ich dazu jeweils ein ContentControl (Zeilen 9 und 15). Regionen werden über einen Namen vom Typ String definiert, welche sich gut als Konstanten in einer gemeinsamen Bibliothek auslagern lassen. Die anderen Module können jeweils eigene UserControls in diese Regionen einfügen und müssen dazu lediglich den Namen der Regionen kennen.
<Grid> <Grid Grid.Column="0"> <Grid.RowDefinitions> <RowDefinition Height="*" /> <RowDefinition Height="3" /> <RowDefinition Height="125" /> </Grid.RowDefinitions> <ContentControl Grid.Row="0" prism:RegionManager.RegionName="{x:Static gpxvCore:GpxViewerConstants.REGION_MAP}" /> <GridSplitter Grid.Row="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" /> <ContentControl Grid.Row="2" prism:RegionManager.RegionName="{x:Static gpxvCore:GpxViewerConstants.REGION_TRACK_OR_ROUTE_DETAILS}" /> </Grid> </Grid>
Eine andere Aufgabe der Shell ist das Hochfahren der Applikation. Hierbei wird zum Beispiel definiert, wie überhaupt Module gefunden werden. Im Falle des RK GPXviewer verwende ich den einfachsten Weg per Code. In diesem Fall verweist die Shell auf alle Module und registriert diese über den ModulCatalog von Prism. Nachfolgender Code-Ausschnitt stammt aus der App.xaml.cs und zeigt genau das. In meinem Fall reicht das auch völlig, da der RK GPXviewer ohnehin als eine ganze Applikation (z. B. per Setup) deployed wird. Es gäbe aber auch alternative Wege, z. B. das Durchsuchen eines Ordners nach Modul-Dlls oder per Konfigurationsdatei.
protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { moduleCatalog.AddModule<CoreModule>(); moduleCatalog.AddModule<Modules.GpxFiles.GpxFilesModule>(); moduleCatalog.AddModule<Modules.Map.MapModule>(); }
Innerhalb der Module ist mir ein Prinzip besonders wichtig: Ein Modul gibt nur das nach außen preis, was für andere Module benötigt wird. Im Fall des RK GPXviewer sind das jeweils lediglich ein paar Interfaces, Messages und der Bootstrapper des Moduls. Alle anderen Dinge, also Klassen für Logik, ViewModels, Views etc. sind als internal definiert. Nachfolgender Screenshot zeigt das am Beispiel des Moduls GpxFiles – markiert sind die öffentlich sichtbaren Elemente, alle anderen sind internal. Dieses Prinzip ermöglicht, dass der Inhalt eines Moduls auch später beliebig angepasst werden kann, ohne dass der Rest der Applikation umgebaut werden muss. Wichtig dabei ist lediglich, dass man die definierte Schnittstelle auch weiterhin einhält.
Abhängigkeiten managen
Eine wichtige Frage bei der Aufteilung einer Applikation in Module ist der Platz, an dem die Schnittstellen zwischen den Modulen definiert werden. Eine einfache Möglichkeit ist, diese in ein gemeinsames Basis-Projekt zu packen, auf das von jedem Modul aus verwiesen werden kann. Dieser Ansatz funktioniert relativ lange sehr gut. Und zwar bis zu dem Zeitpunkt, an dem es so viele Schnittstellen gibt, dass die Entwickler schlicht den Überblick verlieren. Das gilt zum einen für die Fragestellung, wo welche Schnittstellen tatsächlich implementiert werden. Zum anderen auch für die Fragestellung, welche Module von welchen anderen Modulen abhängig sind. Module sind zwar lose über Schnittstellen miteinander gekoppelt, das bedeutet aber trotzdem, dass eine Abhängigkeit tatsächlich irgendwo implementiert sein muss.
Nach meiner Erfahrung ist es besser, die Schnittstellen eines Moduls direkt im gleichen Modul zu definieren, in dem auch die Implementierungen stecken. Die Konsequenz daraus ist natürlich, dass Module auf sich gegenseitig verweisen müssen. Ebenso sollte man wie weiter oben erklärt darauf achten, dass alle anderen Elemente des Moduls eben nicht öffentlich sind. Der große Vorteil daraus ist, man sieht sofort, welche Schnittstellen von welchen Modulen stammen und wie die Abhängigkeiten zwischen den Modulen aussehen.
Bei der Erstellung von Objekten arbeitet Prism stark mit dem integrierten DI-Container, in meinem Fall DryIoc. In den Bootstrappern der Module kann jedes Modul die von ihm bereitgestellten Logik-Klassen und Views registrieren. Jede Klasse kann dabei ihre jeweiligen Abhängigkeiten im Konstruktor angeben – Prism bzw. DryIoc erzeugt Objekte per Constructor Injection. Nachfolgender Code-Ausschnitt zeigt den Bootstrapper des Moduls GpxFiles. Die Methode RegisterTypes sieht hier etwas ungewöhnlich aus, da GpxFileRepository zwei mal registriert wird. Auf das Objekt vom Typ GpxFileRepository wird von anderen Modulen mit dem Interface IGpxFileRepository zugegriffen. Innerhalb des Moduls aber nicht über das Interface, sondern direkt über die echte Klasse. Dieser Weg folgt den oben beschriebenen Grundsatz, dass innerhalb des Moduls alle Implementierungen bekannt sein dürfen, nach außen hin aber nur über eine definierte Schnittstelle kommuniziert wird. Das im gleichen Modul definierte ViewModel für die Baumansicht der geladenen Gpx-Dateien greift beispielsweise direkt auf die Klasse GpxFileRepository zu und verwendet nicht das Interface. Für mich ist das so ein guter Kompromiss zwischen zwei gegensätzlichen Gedanken:
- Für Zugriffe von außen eine möglichst schlanke öffentliche Schnittstelle bereitstellen
- Für Zugriffe von innen maximale Flexibilität in der Entwicklung ermöglichen
public void OnInitialized(IContainerProvider containerProvider) { var regionManager = containerProvider.Resolve<IRegionManager>(); regionManager.RegisterViewWithRegion( GpxViewerConstants.REGION_FILE_TREE, typeof(FileTreeView)); regionManager.RegisterViewWithRegion( GpxViewerConstants.REGION_TRACK_OR_ROUTE_INFO, typeof(SelectedTracksAndRoutesView)); } public void RegisterTypes(IContainerRegistry containerRegistry) { var uiMessenger = FirLibMessenger.GetByName(FirLibConstants.MESSENGER_NAME_GUI); var gpxFileRepo = new GpxFileRepository(uiMessenger); containerRegistry.RegisterSingleton<IGpxFileRepository>( _ => gpxFileRepo); containerRegistry.RegisterSingleton<GpxFileRepository>( _ => gpxFileRepo); }
Nachrichtenaustausch per EventAggregator
Ein weiteres in meiner Sicht wichtiges Best Practice aus Prism ist der EventAggregator [3]. Dieser dient dazu, dass Ereignisse aus einem Modul eine Reaktion in einem anderen Modul hervorrufen können. Und zwar ohne, dass sich Event-Quelle und Event-Ziel untereinander kennen müssen. Lediglich das Event selbst muss beiden Seiten bekannt sein. Bezogen auf RK GPXviewer verwende ich das gleiche Pattern, allerdings nicht die Umsetzung in Prism. Hintergrund ist schlicht der, dass ich in meinen eigenen Basis-Bibliotheken eine andere Implementierung des Patterns habe, welche für mich ebenfalls sehr gut funktioniert (dort genannt “Messenger”). Dennoch ist dieses Thema eine Erwähnung in diesem Artikel wert, denn Prism zwingt den Entwickler nicht, alle Klasse direkt aus Prism zu verwenden. Stattdessen lässt sich Prism gut als Werkzeugkasten einsetzen, bei dem man sich nach Bedarf bedienen kann. Selbst andere Elemente wie beispielsweise die Basisklassen für ViewModels verwende ich aus einer anderen Bibliothek, und zwar ohne jegliche Konflikte oder Nachteile bezogen auf den Einsatz von Prism.
Mehrere Infos zu Prism
Neben den genannten Themen gibt es bei Prism noch sehr viel mehr Themen, die es mehr als Wert sind, sich mit ihnen zu beschäftigen. Das gilt auch unabhängig davon, ob Prism im eigenen Projekt eingesetzt wird oder nicht. Es geht darum, die Gedanken hinter den Patterns zu verstehen und zu verinnerlichen. In welcher Bibliothek diese am Ende implementiert sind, ist meiner Meinung nach eher zweitrangig. Zurück zu Prism: Im Artikel habe ich bereits an verschiedenen Stellen auf die Website von Prism verwiesen. Einen weiteren guten Einstieg bietet auch das Video “Introduction to Prism for WPF” aus Pluralsight [4].
Verweise
- Repository von RK GPXviewer auf GibHub
https://github.com/RolandKoenig/GpxViewer - Dokumentation zu Modulen in Prism
https://prismlibrary.com/docs/modules.html - Dokumentation zum EventAggregator in Prism
https://prismlibrary.com/docs/event-aggregator.html - Einführung in Prism von Brian Lagunas auf Pluralsight
https://www.pluralsight.com/courses/prism-wpf-introduction
Ebenfalls interessant
- Überwachen von Coding Conventions per Roslyn Analyzer
https://www.rolandk.de/wp-posts/2021/10/ueberwachen-von-coding-conventions-per-roslyn-analyzer/ - Hexagonale Architektur mit C#, .Net 6 und Blazor
https://www.rolandk.de/wp-posts/2022/09/hexagonale-architektur-mit-c-net-6-und-blazor/