📜 ⬆️ ⬇️

WPF + Caliburn.micro + Castle.Windsor Modular Application

First I want to define what is meant by a modular application in this article. So, the modular application will be considered such an application, which consists of the so-called. shell and a set of plug-ins. There is no direct relationship between them, only through contracts. This allows you to independently make changes to each of the components, change their composition, etc. I think everyone, and without me, is well aware of the advantages of modular architecture.

image

Perhaps the most famous framework for building WPF applications with this architecture is Prism . In this article I will not conduct a comparative analysis, because I have no experience using Prism. After reading the tutorial, Prism, with all its regions, mef and other artifacts, seemed to me very complicated. If a reader who knows Prism, reasonably indicates to me that I am wrong and the advantages of this framework, I will be grateful.
')
This article will look at the development of a simplest modular application using these tools.

Caliburn.Micro


Caliburn.Micro is a framework that greatly simplifies the description of View and ViewModel. In fact, he himself creates bandages based on naming conventions, thereby saving the developer from writing them manually and making the code smaller and cleaner. Here are a couple of examples from their site:

<ListBox x:Name="Products" /> 

 public BindableCollection<ProductViewModel> Products { get; private set; } public ProductViewModel SelectedProduct { get { return _selectedProduct; } set { _selectedProduct = value; NotifyOfPropertyChange(() => SelectedProduct); } } 

Here in XAML we do not specify either an ItemSource or a SelectedItem.

 <StackPanel> <TextBox x:Name="Username" /> <PasswordBox x:Name="Password" /> <Button x:Name="Login" Content="Log in" /> </StackPanel> 

 public bool CanLogin(string username, string password) { return !String.IsNullOrEmpty(username) && !String.IsNullOrEmpty(password); } public string Login(string username, string password) { ... } 

No Command and CommandParameter.

Agreements, if absolutely necessary, can be redefined.
Of course, Caliburn.Micro still has a lot of things. We will look at something further, the rest can be read in the documentation.

Castle.Windsor


Castle.Windsor is one of the most well-known and most functional DI containers for .net (it is assumed that the reader is aware of DI and IoC). Yes, Caliburn.Micro, as well as in many other frameworks, has its own DI-container - SimpleContainer, and it would be quite enough for a further example of its capabilities. But for more complex tasks it may not be suitable, so I will show how to use an arbitrary container using the example of Castle.Windsor.

Task


As an example, I propose to consider the process of creating a simple modular application. Its main part - the shell - will be a window, on the left side of which there will be a ListBox menu. When you select a menu item on the right side will display the corresponding form. The menu will be filled with modules when they are loaded or in the process. The modules can be loaded both at the start of the shell and during the operation (for example, a module can load other modules if necessary).

Contracts


All contracts will be located in the Assembly Contracts, to which the shell and modules should refer. Based on the task, we will write our shell contract.

 public interface IShell { IList<ShellMenuItem> MenuItems { get; } IModule LoadModule(Assembly assembly); } 

  public class ShellMenuItem { public string Caption { get; set; } public object ScreenViewModel { get; set; } } 

I think everything is clear. Shell allows modules to manage the menu, as well as load modules in the process. The menu item contains the display name and ViewModel, the type of which can be absolutely any. When you select a menu item on the right side of the window, the View corresponding to this ViewModel will be displayed. How to determine which View is appropriate? Caliburn.micro will take care of this. This approach is called ViewModel-first, because in the code we operate with view-models, and the creation of a view is pushed into the background and relegated to the framework. Details - further.

The module contract looks quite simple.

 public interface IModule { void Init(); } 

The Init () method is called by the party that initiated the loading of the module.

It is important to note that if the build project is signed, and in large projects it usually is, then you need to be sure that the shell and modules use assemblies with contracts of the same version.

We start to implement Shell


Create a project like WPF Application. Next, we need to connect to the project Caliburn.Micro and Castle.WIndsor. The easiest way to do this is through NuGet.

PM> Install-Package Caliburn.Micro -Version 2.0.2
PM> Install-Package Castle.Windsor


But you can download the assembly, or build yourself. Now we will create two folders in the project: Views and ViewModels. In the ViewModels folder, create a ShellViewModel class; We will inherit it from PropertyChangedBase from Caliburn. Micro not to implement INotifyPropertyChanged. This will be a view model of the main shell window.

 class ShellViewModel: PropertyChangedBase { public ShellViewModel() { MenuItems = new ObservableCollection<ShellMenuItem>(); } public ObservableCollection<ShellMenuItem> MenuItems { get; private set; } private ShellMenuItem _selectedMenuItem; public ShellMenuItem SelectedMenuItem { get { return _selectedMenuItem; } set { if(_selectedMenuItem==value) return; _selectedMenuItem = value; NotifyOfPropertyChange(() => SelectedMenuItem); NotifyOfPropertyChange(() => CurrentView); } } public object CurrentView { get { return _selectedMenuItem == null ? null : _selectedMenuItem.ScreenViewModel; } } } 

The main MainWindow window itself is copied to View and renamed to ShellView. First of all, do not forget to rename not only the file, but also the class together with namespace. Those. instead of the class Shell.MainWindows should be Shell.Views.ShellView. It is important. Otherwise, Caliburn.Micro will not be able to determine that this particular view corresponds to the previously created view model. As mentioned earlier, Caliburn. Micro relies on naming conventions. In this case, the word “Model” is removed from the class name of the view model and the class name of the corresponding view is obtained (Shell.ViewModels.ShellViewModel - Shell.Views.ShellView). View can be Windows, UserControl, Page. In the modules we will use UserControl.
The XAMl markup of the main window will look like this:

 <Window x:Class="Shell.Views.ShellView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="200"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <ListBox x:Name="MenuItems" DisplayMemberPath="Caption" Grid.Column="0"/> <ContentControl x:Name="CurrentView" Grid.Column="1"/> </Grid> </Window> 

Launch Caliburn.Micro


To do this, first create a class Bootstraper with minimal content:

 public class ShellBootstrapper : BootstrapperBase { public ShellBootstrapper() { Initialize(); } protected override void OnStartup(object sender, StartupEventArgs e) { DisplayRootViewFor<ShellViewModel>(); } } 

It must inherit from BootstrapperBase. The OnStartup method is called when the program is started. DisplayRootViewFor () by default creates an instance of the class of view models with the default constructor, searches for the corresponding view of the algorithm described above, and displays it.

To make it work, you need to edit the entry point into the application - App.xaml.

 <Application x:Class="Shell.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:shell="clr-namespace:Shell"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary> <shell:ShellBootstrapper x:Key="bootstrapper" /> </ResourceDictionary> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources> </Application> 

We removed StartupUri (at the mercy of the bootstrapper) and added our bootstrapper to the resources. Such nesting is not just the case, otherwise the project will not gather.

Now when you start the application, a bootstrapper will be created, OnStartup will be called and the main application window will be displayed, linked to the view model.

Pay attention to the creation of a view model. It is created by the default constructor. And if she does not have this? If it has dependencies on other entities, or do other entities depend on it? I am summing up that the time has come to put the Castle.Windsor DI-container into action.

Run the Castle.Windsor


Create a class ShellInstaller.

 class ShellInstaller : IWindsorInstaller { public void Install(IWindsorContainer container, IConfigurationStore store) { container .Register(Component.For<IWindsorContainer>().Instance(container)) .Register(Component.For<ShellViewModel>() /*.LifeStyle.Singleton*/); } } 

In it, we will register all our components in the code using the fluent syntax. It is possible to do this through xml, see the documentation on the site. So far we have one component - a view model of the main window. We register it as a singleton (you can not explicitly specify, because it is LifeStyle by default). We will also register the container itself so that it is possible to access it. Looking ahead - we will need it when loading modules.

Next, make changes to our bootstrapper:

 public class ShellBootstrapper : BootstrapperBase { private readonly IWindsorContainer _container = new WindsorContain-er(); public ShellBootstrapper() { Initialize(); } protected override void OnStartup(object sender, StartupEventArgs e) { DisplayRootViewFor<ShellViewModel>(); } protected override void Configure() { _container.Install(new ShellInstaller()); } protected override object GetInstance(Type service, string key) { return string.IsNullOrWhiteSpace(key) ? _container.Kernel.HasComponent(service) ? _container.Resolve(service) : base.GetInstance(service, key) : _container.Kernel.HasComponent(key) ? _container.Resolve(key, service) : base.GetInstance(service, key); } } 

Create a container. In the overridden method Configure, we use our installer. Override the GetInstance method. Its base implementation uses the default constructor to create an object. We will try to get the object from the container.

Interaction with modules


First we need to learn how to load modules. And for this, let's define what a module is?

A module (in our case) is an assembly containing a set of classes that implement the required functionality. One of these classes must implement the IModule contract. In addition, like the shell, the module must have an installer registering the components (classes) of the module in the DI container.

Now we will start implementation of the loader. The load will be called at the start of the shell, and may also be caused during the work, so we will create a separate class.

 class ModuleLoader { private readonly IWindsorContainer _mainContainer; public ModuleLoader(IWindsorContainer mainContainer) { _mainContainer = mainContainer; } public IModule LoadModule(Assembly assembly) { try { var moduleInstaller = FromAssembly.Instance(assembly); var modulecontainer = new WindsorContainer(); _mainContainer.AddChildContainer(modulecontainer); modulecontainer.Install(moduleInstaller); var module = modulecontainer.Resolve<IModule>(); if (!AssemblySource.Instance.Contains(assembly)) AssemblySource.Instance.Add(assembly); return module; } catch (Exception ex) { //TODO: good exception handling return null; } } } 

Through the designer, the shell container will be injected (remember, we registered it specifically for this?). In the LoadModule method we get the installer from the module assembly. Create a separate container for the components of the loadable module. We register it as a child of the shell container. We use the installer module. We are trying to return an instance of IModule. We inform Caliburn.Micro about the assembly, so that it applies naming conventions for the components in it.

And do not forget to register our module loader in ShellInstaller.

 .Register(Component.For<ModuleLoader>() 

A little bit about the "child container". The bottom line is that all its components "see" components from the parent container, in addition to its own, but not vice versa. Components of different child containers also know nothing about each other. We get shell isolation from modules and modules from each other, but not modules from a shell — they see it.

Next, we implement the IShell contract, through which the modules will access the shell.

  class ShellImpl: IShell { private readonly ModuleLoader _loader; private readonly ShellViewModel _shellViewModel; public ShellImpl(ModuleLoader loader, ShellViewModel shellViewModel) { _loader = loader; _shellViewModel = shellViewModel; } public IList<ShellMenuItem> MenuItems { get { return _shellViewModel.MenuItems; } } public IModule LoadModule(Assembly assembly) { return _loader.LoadModule(assembly); } } 

Register.

 .Register(Component.For<IShell>().ImplementedBy<ShellImpl>()) 


Now we need to make the modules load when the shell starts. Where do they come from? In our example, the shell will look for assemblies with modules next to Shell.exe.

This functionality should be implemented in the OnStartup method:

  protected override void OnStartup(object sender, StartupEventArgs e) { var loader = _container.Resolve<ModuleLoader>(); var exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); var pattern = "*.dll"; Directory .GetFiles(exeDir, pattern) .Select(Assembly.LoadFrom) .Select(loader.LoadModule) .Where(module => module != null) .ForEach(module => module.Init()); DisplayRootViewFor<ShellViewModel>(); } 

All shell ready!

Writing a module


When loading our test module, we’ll add two items to the shell menu. The first item will display on the right side a very simple form with an inscription. The second is a form with a button, with which you can load the module by selecting its assembly in the file selection dialog that opens. Following the naming convention, create 2 Views and ViewModels folders. Then fill them up.

The first view and view model are trivial:

 <UserControl x:Class="Module.Views.FirstView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300"> <Grid> <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="60">Hello, I'm first !</TextBlock> </Grid> </UserControl> 

  class FirstViewModel { } 

The second view is also not difficult.

 <UserControl x:Class="Module.Views.SecondView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300"> <Grid> <Button x:Name="Load" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="50">Load Module</Button> </Grid> </UserControl> 

In the second view model, we implement the loading of the selected module.

 class SecondViewModel { private readonly IShell _shell; public SecondViewModel(IShell shell) { _shell = shell; } public void Load() { var dlg = new OpenFileDialog (); if (dlg.ShowDialog().GetValueOrDefault()) { var asm = Assembly.LoadFrom(dlg.FileName); var module = _shell.LoadModule(asm); if(module!=null) module.Init(); } } } 

We implement the IModule contract. In the Init method, add items to the shell menu.

 class ModuleImpl : IModule { private readonly IShell _shell; private readonly FirstViewModel _firstViewModel; private readonly SecondViewModel _secondViewModel; public ModuleImpl(IShell shell, FirstViewModel firstViewModel, SecondViewModel secondViewModel) { _shell = shell; _firstViewModel = firstViewModel; _secondViewModel = secondViewModel; } public void Init() { _shell.MenuItems.Add(new ShellMenuItem() { Caption = "First", ScreenViewModel = _firstViewModel }); _shell.MenuItems.Add(new ShellMenuItem() { Caption = "Second", ScreenViewModel = _secondViewModel }); } } 

And the final touch is the installer.

 public class ModuleInstaller:IWindsorInstaller { public void Install(IWindsorContainer container, IConfigurationStore store) { container .Register(Component.For<FirstViewModel>()) .Register(Component.For<SecondViewModel>()) .Register(Component.For<IModule>().ImplementedBy<ModuleImpl>()); } } 

Done!

Sources - on the git hub .

Conclusion


In this article, we looked at creating the simplest modular WPF application using the Castle.Windwsor and Caliburn.Micro frameworks. Of course, many aspects were not covered, some details were omitted, etc., otherwise the book would have turned out, but not the article. And more detailed information can be found on official resources, and not only.

I will try to answer all your questions with pleasure.

Thanks for attention!

Source: https://habr.com/ru/post/271655/


All Articles