WPF Over Data mit Caliburn.Micro – die Shell

 

Prolog

Für die Pflege von Daten scheint Forms Over Data die erste Wahl zu sein – aber wo bleiben dann die Freiheiten der Datenbindung, das Abkoppeln des Codes vom Oberflächendesign, das MVVM-Muster?

Alternativ könnte man mit Microsoft Visual Studio Lightswitch eine Oberfläche gestalten. Nun, vielleicht später mal. Inzwischen schaue ich, ob ich mir ein modulares Backend mit Hilfe der folgenden Zutaten zusammenbauen kann:

  • Caliburn.Micro zum Verwalten der WPF-Oberfläche
  • Castle Windsor als DI-Container
  • NHibernate als ORM
  • FluentNHibernate zur Konfiguration von NHibernate
  • Machine.Specifications als Testumgebung, in Verbindung mit Machine.Fakes und RhinoMocks.
  • log4net fürs Logging

Alle genannten Pakete sind über NuGet abrufbar.

 

Die Oberfläche

Wie soll nun dieses Backend genau aufgebaut sein? – Nun, das Hauptprogramm gibt den Rahmen vor. Es sollten sich verschiedene Module einklinken können, die wiederum aus Submodulen bestehen.

Die Icons zur Auswahl der Module sollen links untereinander aufgelistet werden (im Bild unten als A). Zusätzlich zum Icon soll noch ein Modulname eingeblendet werden. Wird eines dieser Elemente aktiviert, sollen die Submodule zur Auswahl angeboten werden (im Bild als B). Zusätzlich kann man noch einen kleinen Tooltip einblenden.

image

 

Das Projekt

Im ersten Step wird das Projekt nur aus 3 Komponenten bestehen:

  • Das Hauptprogramm Lucifer.Office mit der Shell und dem Bootstrapper für Caliburn.Micro
  • Eine Klassenbibliothek Lucifer.Caliburn mit den Kontrakten und dem Logging-Mechanismus für CM
  • Dem Theme der Anwendung als Klassenbibliothek Lucifer.Theme

Wie der Screenshot beweist, könnte ich vor allem im letzten Punkt noch durchaus Hilfe gebrauchen 🙂

 

Lucifer.Caliburn – Interfaces

Als erstes braucht CM natürlich die Definition einer Shell. Glücklicherweise ist die überschaubar:

using Caliburn.Micro;

namespace Lucifer.Caliburn
{
    public interface IShell : IConductor
    {
    }
}

Dann hatten wir gesagt, was wir alles von einem einklinkbaren Modul wissen müssen. Daraus ergibt sich folgendes Interface:

using Caliburn.Micro;

namespace Lucifer.Caliburn
{
    public interface IModule : IScreen
    {
        string ModuleName { get; }
        string IconFileName { get; }
        string ToolTip { get; }
    }
}

Ein Screen ist es, weil CM einem durch das Aktivieren und Deaktivieren schon jede Menge Arbeit abnimmt.

Damit sind die nötigen Verträge schon abgearbeitet. Was noch fehlt, ist noch ein wenig Hilfe bei der Fehlersuche. Caliburn.Micro hat eine Logging-Schnittstelle, die einfach nur aktiviert werden muß. Man könnte sie mit dem normalen Logging zusammenlegen, hilfreicher dürfte jedoch die direkte Ausgabe im Debug-Fenster von Visual Studio sein. Deshalb folgende Implementierung:

using System;
using System.Diagnostics;
using System.Globalization;
using Caliburn.Micro;

namespace Lucifer.Caliburn
{
    public class CaliburnLogger : ILog
    {
        Type _type;

        public CaliburnLogger(Type type)
        {
            _type = type;
        }

        public void Info(string format, params object[] args)
        {
            Debug.WriteLine(CreateLogMessage(format, args), "INFO");
        }

        public void Warn(string format, params object[] args)
        {
            Debug.WriteLine(CreateLogMessage(format, args), "WARN");
        }

        public void Error(Exception exception)
        {
            Debug.WriteLine(CreateLogMessage(exception.ToString()), "ERROR");
        }

        string CreateLogMessage(string format, params object[] args)
        {
            return string.Format(CultureInfo.CurrentCulture, 
                "[{0}] {1} - [{2}]",
                DateTime.Now.ToString("o", CultureInfo.CurrentCulture),
                string.Format(format, args),
                _type);
        }
    }
}

Das war die Klassenbibliothek Lucifer.Caliburn, es folgt bereits das Hauptprogramm.

 

Lucifer.Office

Das Hauptprogramm ist ebenso schnell aufgebaut, da man sich nur um die folgenden Elemente kümmern muß:

  • Der Bootstrapper
  • Eine ShellView zur Definition des Hauptfensters
  • Das Model zur ShellView für den korrekten Aufbau der Elemente
  • Eine App.Xaml für das Bekanntmachen des Boostrappers und das Zusammenführen der Resource-Dictionaries

 

Der Bootstrapper

Wie eingangs erwähnt, soll Castle Windsor diesen Part übernehmen. Hierfür muß der von Caliburn.Micro erwartete Bootstrapper implementiert bzw 3 Methoden überschrieben werden: GetInstance, GetAllInstances sowie Configure. Darüber hinaus erweitern wir Configure derart, daß (später) alle benötigten Module eingelesen werden.

Daraus ergibt sich folgende Klasse:

namespace Lucifer.Office { public class CastleBootstrapper : Bootstrapper<IShell> { IWindsorContainer _container; protected override object GetInstance(Type serviceType, string key) { var export = string.IsNullOrEmpty(key)

? _container.Resolve(serviceType) : _container.Resolve(serviceType, key); if (export != null) return export; var contract = string.IsNullOrEmpty(key) ? serviceType.Name : key; throw new Exception(string.Format("Could not locate any instances of contract {0}.", contract)); } protected override IEnumerable<object> GetAllInstances(Type service) { yield return _container.ResolveAll(service).GetEnumerator(); } protected override void Configure() { _container = new WindsorContainer(); _container.AddFacility<ArrayDependencyFacility>(); GetCaliburnRegistrations().ForEach(x => _container.Register(x)); //GetPersistenceRegistrations().ForEach(x => _container.Register(x)); GetModuleRegistrations().ForEach(x => _container.Register(x)); GetShellRegistrations().ForEach(x => _container.Register(x)); _container.Install(_container.ResolveAll<IWindsorInstaller>()); LogManager.GetLog = type => new CaliburnLogger(type); base.Configure(); } } }

Es werden also alle Caliburn-relevanten Komponenten registriert, später alle Persistenz-relevanten Komponenten, die einzelnen Module, sowie die Shell selbst. Also, der Reihe nach:

Die Shell:

static IEnumerable<IRegistration> GetShellRegistrations()
{
   yield return Component
      .For<IShell>()
      .ImplementedBy<ShellViewModel>();
}

Caliburn-Komponenten Window-Manager und Event-Aggregator (ob man den Kernel selbst dazuzählen mag, ist Geschmackssache):

IEnumerable<IRegistration> GetCaliburnRegistrations()
{
    yield return Component
        .For<IWindowManager>()
        .ImplementedBy<WindowManager>();
    yield return Component
       .For<IEventAggregator>()
       .ImplementedBy<EventAggregator>();
    yield return Component
       .For<IWindsorContainer>()
       .Instance(_container);
}

Und letzten Endes die einzelnen Module des Office. Diese können (theoretisch) in der ausgeführten Assembly liegen, oder in einer der DLLs des aktuellen Verzeichnisses. Zusätzlich werden in diesen DLLs noch zusätzliche Windsor-Installer gesucht, falls für die nachgeladenen Module noch zusätzliche Kernel-Registrierungen vorzunehmen sind:

static IEnumerable<IRegistration> GetModuleRegistrations()
{
   yield return AllTypes
      .FromAssembly(Assembly.GetExecutingAssembly())
      .BasedOn<IModule>()
      .WithService.FromInterface(typeof(IModule));

   var directoryPath = AppDomain.CurrentDomain.BaseDirectory;
   foreach (var dllPath in Directory.GetFiles(directoryPath, "*.dll"))
   {
      Assembly assembly;
      try
      {
         assembly = Assembly.LoadFrom(dllPath);
         AssemblySource.Instance.Add(assembly);
      }
      catch (BadImageFormatException ex)
      {
         System.Diagnostics.Trace.TraceError(ex.ToString());
         continue;
      }
      catch (FileNotFoundException ex)
      {
         System.Diagnostics.Trace.TraceError(ex.ToString());
         continue;
      }
      yield return AllTypes
         .FromAssembly(assembly)
         .BasedOn<IModule>()
         .WithService.FromInterface(typeof(IModule));
      yield return AllTypes
         .FromAssembly(assembly)
         .BasedOn<IWindsorInstaller>()
         .WithService.FromInterface(typeof(IWindsorInstaller));
   }
}

Im wesentlichen war das der Bootstrapper, mit einer kleinen Ausnahme: es wurde noch eine ArrayDependencyFacility registriert, die bislang noch unresolved ist. Der Hintergrund ist der, daß man von Castle Windsor gerne ein komplettes Array aufgelöst bekommen möchte, hierfür aber eine eigene Facility registriert werden muß. Diese ist wie folgt implementiert:

using Castle.MicroKernel.Facilities;

namespace Lucifer.Office.Container
{
    public class ArrayDependencyFacility : AbstractFacility
    {
        protected override void Init()
        {
            Kernel.Resolver.AddSubResolver(new ArraySubDependencyResolver(Kernel));
        }
    }
}

was ziemlich unspektakulär ist, da an eine weitere unbekannte Klasse delegiert wird, die wie folgt aussieht:

using Castle.Core; using Castle.MicroKernel; using Castle.MicroKernel.Context; namespace Lucifer.Office.Container { public class ArraySubDependencyResolver : ISubDependencyResolver { readonly IKernel _kernel; public ArraySubDependencyResolver(IKernel kernel) { _kernel = kernel; } public object Resolve(CreationContext context, ISubDependencyResolver contextHandlerResolver

, ComponentModel model, DependencyModel dependency) { return _kernel.ResolveAll(dependency.TargetType.GetElementType(), null); } public bool CanResolve(CreationContext context, ISubDependencyResolver contextHandlerResolver

, ComponentModel model, DependencyModel dependency) { return dependency.TargetType != null && dependency.TargetType.IsArray && dependency.TargetType.GetElementType().IsInterface && !model.Parameters.Contains(dependency.DependencyKey); } } }

Damit ist der Bootstrapper abgearbeitet, es folgt etwas Sichtbares.

 

Die ShellView

Die ShellView soll einfach nur “gut” aussehen, mit einem Applikationsnamen, einem “sinngebenden” Icon, der Auswahlspalte links und dem Platzhalter des aktiven Moduls. Und damit das Fenster auch ein Icon bekommt, deklarieren wir ein Window:

<Window x:Class="Lucifer.Office.View.ShellView"
             Icon="..\Lucifer.Office.ico"
             Style="{DynamicResource ShellViewSkin}">

    <DockPanel Margin="20" LastChildFill="True">

        <StackPanel Style="{DynamicResource AppTitleStackPanelSkin}" DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,20">
            <Rectangle x:Name="rectangle" Height="100" Width="100" Margin="0" HorizontalAlignment="Left" Opacity="0.35" 
                       ToolTipService.ToolTip="{x:Static Resources:Strings.AppToolTip}">
                <Rectangle.Fill>
                    <ImageBrush Stretch="Uniform" ImageSource="/Lucifer.Office;component/Resources/Lucifer.Office.png" />
                </Rectangle.Fill>
            </Rectangle>
            <TextBlock x:Name="DisplayName" TextWrapping="Wrap" VerticalAlignment="Center" FontSize="48" Opacity="0.345"
                       Text="Lucifer Office">
                <TextBlock.Effect>
                    <DropShadowEffect BlurRadius="1" Direction="343" ShadowDepth="2" />
                </TextBlock.Effect>
            </TextBlock>
        </StackPanel>

        <ListBox x:Name="Items" Style="{DynamicResource AppNavigationItemSkin}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Button Margin="8" ToolTip="{Binding ToolTip}" HorizontalAlignment="Center" Width="140" cal:Message.Attach="ActivateItem($datacontext)">
                        <StackPanel>
                            <Image Source="{Binding IconFileName}" HorizontalAlignment="Center" Width="48" Height="48" />
                            <TextBlock Text="{Binding ModuleName}" HorizontalAlignment="Center" />
                        </StackPanel>
                    </Button>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ListBox>
        
        <ContentControl x:Name="ActiveItem" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch" />
        
    </DockPanel>
</Window>

Das ShellView-Model

Die Aufgabe des Shellview-Models ist es, alle gefundenen Module zu sammeln und klickbar zu machen. Für den Rest sorgt Caliburn.Micro, insbesondere durch die Bindung des Listbox-Buttons mit der CM-Nachricht ActivateItem in obiger XAML-Datei. Gleichzeitig sorgt die Ableitung von Conductor<IScreen>.Collection.OneCtive, daß jeweils nur eine View aktiv ist:

using System.Collections.Generic; using System.Linq; using Caliburn.Micro; using Castle.Windsor; using Lucifer.Caliburn; using Lucifer.Office.Resources; namespace Lucifer.Office.ViewModel { public class ShellViewModel : Conductor<IScreen>.Collection.OneActive, IShell { readonly IWindsorContainer _container; IEnumerable<IModule> _modules; public IEnumerable<IModule> Modules

{ get { return _modules ?? (_modules = _container.ResolveAll<IModule>()); } } public ShellViewModel(IWindsorContainer container) { _container = container; } protected override void OnInitialize() { base.OnInitialize(); DisplayName = Strings.AppTitle; Items.AddRange(Modules); ActivateItem(Items.FirstOrDefault()); } } }

Ob man sich an dieser Stelle den Windsor-Container über den Konstruktor hineinreichen läßt, oder ihn durch IoC.Get ermittelt… es geht beides.

 

Die App.xaml

Wie bereits erwähnt, werden hier nur die Resource-Dictionaries aufgeführt, sowie der Bootstrapper bekanntgemacht. Wichtig ist noch, das von Visual Studio eingefügte Startfenster zu löschen:

<Application x:Class="Lucifer.Office.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
             xmlns:local="clr-namespace:Lucifer.Office">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/PresentationFramework.Aero;V4.0.0.0;31bf3856ad364e35;component\themes/aero.normalcolor.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/Colors.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/Button.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/General.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/MainApp.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/IcsModule.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/PmsModule.xaml" />
                <ResourceDictionary Source="/Lucifer.Theme;component/UmsModule.xaml" />
                <ResourceDictionary>
                    <local:CastleBootstrapper x:Key="bootstrapper" />
                </ResourceDictionary>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

 

Lucifer.Theme

Auf das Theme selbst braucht an dieser Stelle nicht näher eingegangen zu werden (siehe Screenshot oben, auch “entwicklerschön” genannt). Geneigte Leser mögen sich die entsprechenden Stellen auf Github anschauen.

 

Ausblick

So ist in wenigen Schritten eine (leere) Hülle entstanden, die man zu allen möglichen Projekten ausbauen kann. Da an die Module keinerlei Anforderungen gestellt werden bis auf die Angabe der zur Darstellung benötigen Informationen, sind der Phantasie keinerlei Grenzen gesetzt.

Im nächsten Schritt wird der Zugriff auf die Daten erfolgen, um letztendlich über Listen und Dialoge die Bearbeitung zu ermöglichen.

DotNetKicks-DE Image
Advertisements
Dieser Beitrag wurde unter .NET, C#, Caliburn.Micro, WPF veröffentlicht. Setze ein Lesezeichen auf den Permalink.

2 Antworten zu WPF Over Data mit Caliburn.Micro – die Shell

  1. Kev schreibt:

    Hu,
    schaut ja alles schon ganz schick aus. Allerdings wuerd ich mir im ShellVM nicht den Container ansich injecten lassen sondern direkt die Module. Das was du machst ist eigentlich nen Anti-Pattern 😉

  2. joergpreiss schreibt:

    Stimmt, bei näherer Betrachtung macht es auch gar keinen Sinn, sich erst den Container injecten zu lassen, nur um die Module zu bekommen. Ich bau das mal bei Gelegenheit um… Danke 🙂

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden /  Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden /  Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden /  Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden /  Ändern )

Verbinde mit %s