Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Gidon - Avalonia based MVVM Plugin IoC Container

5.00/5 (18 votes)
27 Feb 2023MIT20 min read 26.6K  
This article describes Gidon - the first IoC/MVVM plugin framework created for Avalonia.
This article describes Gidon - the first IoC/MVVM framework created for Avalonia. I explain and give samples of best MVVM/IoC practices and show how you can use Gidon to create an application as a set of many, independent plugins.

Introduction

Gidon IoC/MVVM Framework for Avalonia

In this article, I present a new Gidon IoC/MVVM framework being built for a great multiplatform WPF-like package Avalonia on top of my own IoCy inversion-of-control/dependency-injection container. To the best of my knowledge, it is the first IoC/MVVM framework for Avalonia even though I understand that there were some previous attempts (not sure successful or not) to port Prism/MEF for Avalonia.

So the reasonable question is why don't you just port Prism (or use its previous port) instead of building a new framework?

In my view, Prism and MEF which it is often used with are too complex, allow using some older paradigms that should never be used with WPF and Avalonia (for example, the Event Aggregation) and very underdocumented.

The purpose of Gidon is to provide very simple API and implementation, yet covering all the needed features.

Note, that Gidon framework is already quite operable (as the samples of this article are going to show). Still many new and great features will be added to it in the near future.

Refresher on Model-View-ViewModel (MVVM) Pattern

What is MVVM

MVVM patterns consists of three parts:

  1. Model - the non-visual data that comes from the backend
  2. View Model - also non-visual objects that contain the data but also provide non-visual properties to reflect the visual functionality and method to be called by visual buttons, menu items, etc.
  3. View - visual objects representing the visuals of your application

View Model is aware of the model, but not vice versa.

View is built around the view model and so it has some knowledge about it but view model should not know anything about the view.

Image 1

MVVM: View knows about the View Model which in turn know about the Model, not vice versa

View is usually passive - it simply mimics its View Model and calls the View Model's methods. View is only aware about its own View Model - all communications between different Views are usually done via their respective View Models.

Image 2

MVVM: Communications between the Views are done only via their View Models

Important Note: Two sided communications between the View and its View Model do not mean that the View Model knows anything about the View: communication from the View Model to the View are achieved via a binding or an event.

The main advantage of the MVVM pattern is that very complex visual objects of the view are simply mimicking much simpler non-visual objects of the View Model. The non-visual View Model objects are much easier to create, extend, test and debug and since all the business logic is located within the View Model, an MVVM application becomes much easier to build and maintain.

MVVM pattern was originally invented for WPF development because of WPF's superb binding capabilities, but later was adopted also by other tools and frameworks. Of course, every XAML framework (including Avalonia, UWP, Xamarin and others) are MVVM enabled, but also Angular and Knockout JavaScript packages are essentially MVVM frameworks.

Adhering to the MVVM pattern in your code, in general does not require any Inversion of Control or Dependency Injection.

Important Note: In my extensive practice, the models are needed very rarely - the backend data can be deserialized straight into the View Model classes. So I mostly practice View-View Model (VVM) pattern without the models, but for simplicity sake, I'll be calling both approaches as MVVM.

To find out more about MVVM pattern, you can read my article MVVM Pattern Made Simple or Data Templates and View Models.

Avalonia Tools for MVVM

The best way in Avalonia to turn a non-visual View Model into a visual View is by using ContentPresenter and ItemsPresenter controls (in WPF, that would be ContentControl and ItemsControl correspondingly).

ContentPresenter is ideal for turning a single non-visual object into a visual object by so to say 'marrying' a View Model object passed to its Content property to a DataTemplate passed to its ContenTemplate property:

Image 3

ItemsPresenter is good for turning a collection of non-visual View Model objects (stored within its Items property) into a collection of visuals by applying a DataTemplate stored in its ItemTemplate property to each one of them. The visuals in the resulting collection are arranged according to an Avalonia Panel provided by ItemsPresenter.ItemsPanel property (by default, they are stacked vertically, one on top of the next one).

Image 4

Refresher on the Inversion of Control (IoC) Containers (without MVVM)

MVVM and IoC do not have to go together. There are many plain Inversion of Control (plugin) containers which have nothing to do with the MVVM or any visual frameworks. Among them are:

  • MEF
  • Autofac
  • Unity
  • Ninject
  • Castle Windsor
  • IoCy - my own simple IoC container available at IoCy.

The main purpose of such frameworks is to facilitate splitting the functionality into loosely coupled plugins (some statically and some dynamically loaded) in order to improve the separation of concerns within the application.

This will lead to the following benefits:

  • Plugin independence - modifying implementation of one plugin should not trigger changes in other plugins
  • Easier testability and debugging - one should be able to easily test, debug and modify each plugin individually (together with the plugins that it depends on) and the fixed plugin should be working with the rest of the application without any changes to other plugins.
  • Improved extensibility of the product - when you need new functionality, you know which plugin to modify for that particular extension, or if needed you can add a new plugin to the already existing plugins with modifications only in the places where the new APIs might be used.

I saw many projects (not designed and started by me) using only the last advantage from those listed above - extending the application by adding plugins to it. On the other hand, their plugins were so intermingled and interdependent that one could not take out one of them without affecting the others. This is a very important error - in order to reap the benefits of the good plugin architecture the interdependence between different plugins should be minimal and it is the architect's task to make sure that this is the case.

In a sense, a plugin is similar to a hardware card, while the plugin interface is very much like a slot for inserting a card.

Image 5

Plugins

Image 6

Interfaces

Important Note: Replacing, adding or removing a plugin should be as easy as replacing, adding or removing a computer card in an already uncovered computer. If this is not the case, your plugin architecture, needs additional work.

Testing a plugin should be as easy as placing a card into a hardware Tester, sending some inputs and checking the corresponding outputs within the Tester. Of course, one should build a tester for the plugin first.

In general, however, software plugins have the following advantages over the hardware cards:

  • The cost of producing a plugin object in software is much smaller than of a card in hardware, because of that using multiple plugin objects of the same type will not increase the cost of the application. Also, different objects of the same type are guaranteed to behave in the same way - the software defects are per type not per object.
  • Plugins can be hierarchical, i.e., a plugin itself can be composed of different sub-plugins (hardware plugins can also have some sub-plugins, but in software the hierarchy can consist of as many levels as needed).
  • Some plugins can be singletons - same plugin used in many different places (which is of course impossible in hardware).

Note that while plugin hierarchy is ok, the plugins should never be cross dependent or peer dependent. Meaning if plugins are logical peers, they should not depend on each other - a common functionality should be factored out into a different plugin or a non-plugin DLL.

Why IoC and MVVM Together?

It was already stated above that MVVM can be practiced without IoC and IoC can be practiced without MVVM so why do we need a framework that would be doing both? The reason is that the Views and their corresponding View Models are good candidates for being built as plugins. In that case, each developer can work on his own View/View Model combination, test them separately from the rest of the team, then bring them together as plugins and ideally everything will work.

Of course, sometimes View Models are not completely independent - they need to communicate with each other. The communication mechanism can be wired via non-visual singleton plugins called services or sometimes even built into the framework.

There are several well known IoC/MVVM frameworks usually built around Microsoft's MEF or Unity IoC containers some can even work with both. All were originally created for WPF but then also adapted for Xamarin and UWP. Among them are:

  • Prism
  • Caliburn/Caliburn.Micro
  • Cinch

Refresher on the IoCy Container

Here, I am describing the functionality of my IoCy simple, and powerful container. I added to it all the features I liked from MEF, Autofac and Ninject while at the same time skipping the features which are not widely used.

The main principle of IoC and DI (dependency injection) implementation is that injectable objects are not created by calling their constructor but instead by calling some method on the container that creates or finds the objects to return. The container is created before to return the correct implementations of the objects. Every injectable object might have some properties that are also injectable. In that case, those properties are also populated from the same container and so on recursively.

Here is the most important functionality of IoCy:

Creating a Container

C#
IoCContainer container = new IoCContainer();

You can pass a unique container name to the constructor, otherwise, it will generate a unique name.

Create a Mapping between an Interface (or Superclass) and an Implementation (or a Subclass)

C#
container.RegisterType<IPerson, Person>();

Sets the mapping between IPerson interface and Person implementation of IPerson, so that every time

C#
IPerson person = container.Resolve<IPerson>();

method is called, it will create and return a new Person object.

Note that one can also create a different mapping for IPerson interface, e.g., to class SuperPerson, but in order for both mappings to exist at the same time, one should pass some object as mapping id to Map(object id = null) method and then also pass the same id to the corresponding Resolve(object id = null) method:

C#
container.RegisterType<IPerson, Person>(1);  
IPerson superPerson = container.Resolve<IPerson>(1);  

In the above code, the id is an integer and equals 1.

Note that all other mapping and resolving methods also accept id arguments.

Create a Singleton Mapping between an Interface (or Superclass) and an Implementation (or a Subclass)

Unlike the previous case when for every Resolve<...>() method invocation you are getting a newly created object, Singleton mapping will return the same object every time.

Here is how you do the Singleton mapping:

C#
container.RegisterSingletonType<ILog, FileLog>();

Resolving a Singleton is exactly the same as before, only the same object is returned every time.

There is another way to set up a singleton mapping, if you want an already existing object to be the Singleton, you simply pass it to RegisterSingletonInstance(...) method:

C#
ConsoleLog consoleLog = new ConsoleLog();
// change the mapping of ILog to ConsoleLog (instead of FileLog)
childContainer.RegisterSingletonInstance<ILog, ConsoleLog>(consoleLog);

Creating a MultiMapping

Multimapping creates a collection of items of certain type mapped to a key. For each call to MapMultiType(...) it adds an item to the collection. For example:

C#
container.RegisterMultiCellType<ILog, FileLog>("MyLogs");
container.RegisterMultiCellType<ILog, ConsoleLog>("MyLogs");  

will add two objects one of type FileLog and the other of type ConsoleLog to the container internal collection. Correspondingly, calling MultiResolve() method on the container will return this two item collection as IEnumerable<ILog>:

C#
IEnumerable<ILog> logs = container.MultiResolve<ILog>("MyLogs");

Using Attributes for Composition

Same as in MEF, IoCy allows using attributes for composing objects within the container. For example:

C#
[RegisterType]
public class Person : IPerson
{
    public string PersonName { get; set; }

    [Inject]
    public IAddress Address { get; set; }
}  

[RegisterType] attribute at the top means that this implementation maps into some type. Since exact type into which it maps is not specified as a parameter to the attribute, by default, it maps into the base class of the current class (if the base class is NOT object); if the base class is object, it maps to the first interface that the class implements. Since there is no base class to our class Person, it will map into the first interface (IPerson). So the above code would be equivalent to container.RegisterType<IPerson, Person>();. It is however recommended that you pass the class to map to as first parameter TypeToResolve to the attribute so that the changes to the class (e.g., change in the order of the interfaces that the class implements) will not affect the composition.

The following attribute declaration: [RegisterType(typeof(IPerson))], is better than the one used above.

[Inject] attribute above Address property means that the Address object is also injectable (coming from the container). Note, the injected property does not have any idea whether the object it injects is a singleton or is created each time anew - it is up to the container how to populate it.

For Multi implementations, one should use [RegisterMultiCellType(...)] attribute above the class and usual [Inject] attribute above the property, e.g.,

C#
[RegisterMultiCellType(typeof(IPlugin), "ThePlugins")]
public class PluginOne : IPlugin
{
    public void PrintMessage()
    {
        Console.WriteLine("I am PluginOne!!!");
    }
}
  
[RegisterMultiCellType(typeof(IPlugin), "ThePlugins")]
public class PluginTwo : IPlugin
{
    public void PrintMessage()
    {
        Console.WriteLine("I am PluginTwo!!!");
    }
}

[RegisterType(typeof(IPluginAccumulator))]
public class PluginAccumulator : IPluginAccumulator
{
    [Inject(typeof(IEnumerable<IPlugin>)]  
    public IEnumerable<IPlugin> Plugins { get; set; }
}

Correspondingly, there are several IoCContainer methods that allow composing the container from whole assemblies statically or dynamically loaded, or even from all DLL assemblies located under a certain path. Here is the list:

C#
public class IoCContainer
{
    ...

    // injects an already loaded assembly
    public void InjectAssembly(Assembly assembly){...}

    // loads and injects a dynamic assembly by path to its dll
    public void InjectDynamicAssemblyByFullPath(string assemblyPath){...}

    // loads and injects all dll files located at assemblyFolderPath 
    // whose name matches the regex
    public void InjectPluginsFromFolder
           (string assemblyFolderPath, Regex? matchingFileName = null){...}

    // loads and injects assemblies that match the rejex 
    // from all direct sub-folders of folder specified
    // by baseFolderPath argument.
    public void InjectPluginsFromSubFolders
           (string baseFolderPath, Regex? matchingFileName = null){...}    
}

Gidon Samples

Code Location

At this point, in order to run Gidon samples, you have to download the whole Gidon code from Gidon.

To achieve that, you should be using the git command with recursive submodules:

git clone https://github.com/npolyak/NP.Avalonia.Gidon.git --recursive NP.Avalonia.Gidon

Or, if you forgot the user '--recursive' option during cloning, you can always use the following command within the repository after the clone:

git submodule update --init  

The following are the subfolders of the repository's base directory:

  • Prototypes - contains the Gidon's samples, some of which will be discussed in this article below
  • src - contains Gidon's code
  • SubModules - contain code from other repositories pulled as sub-modules that Gidon depends on
  • Tests - contain the code that is being used across multiple prototypes. In particular, I place test plugins and services here.

PluginsTest

Solution Location and Structure

PluginsTest solution is located under Prototypes/PluginsTest folder. Open the solution (you will need VS2022 for that).

Make PluginsTest project to be the startup project of the solution.

Here is the solution folder/project structure:

Image 7

Right click on PluginsTest project and choose Rebuild. Note that the plugins are dynamically loaded into the application, and the main project does not depend on them directly. In order to build all the plugins, you have to right click on "TestAndMocks" solution folder within the solution and choose Rebuild.

The plugins/services projects will be re-built and their compiled assemblies will be copied (via post build events) into bin/Debug/net6.0 folder under current solution. From that folder, the plugins will be dynamically loaded by Gidon framework. Here is the folder structure of the plugins installed under <CurrentSolution>/bin/Debug/net6.0:

Running the PluginsTest Project

Image 8

Try to run the application, here is what you should see:

Image 9

Pressing "Exit" button will exit the application.

User "nick" for the user name and "1234" for the password. Press "Login" button (it should become enabled) and here is what you will see:

Image 10

These are two dockable/floating panes which you can pull by their headers out of the main window. There is a connection between them via a service - if you type anything within "Enter Text" TextBox and press button "Send" (it will become enabled), the test will appear in the other dockable pane:

Image 11

Code of the Main Project PluginsTest

Explanation of the Code

Now let us take a look at the code.

Gidon Code to Load the Plugins

The code to load the plugins is located within App.axaml.cs file within the App constructor:

C#
public class App : Application
{
    /// defined the Gidon plugin manager
    /// use the following paths (relative to the PluginsTest.exe executable)
    /// to dynamically load the plugins and services:
    /// "Plugins/Services" - to load the services (non-visual singletons)
    /// "Plugins/ViewModelPlugins" - to load view model plugins
    /// "Plugins/ViewPlugins" - to load view plugins
    public static PluginManager ThePluginManager { get; } = 
        new PluginManager
        (
            "Plugins/Services", 
            "Plugins/ViewModelPlugins", 
            "Plugins/ViewPlugins");

    // expose the IoC container
    public static IoCContainer TheContainer => ThePluginManager.TheContainer;

    public App()
    {
        // inject a type from a statically loaded project NLogAdapter
        ThePluginManager.InjectType(typeof(NLogWrapper));

        // inject all dynamically loaded assemblies
        ThePluginManager.CompleteConfiguration();
    }
    ...
}

App.axaml includes the styles from the default theme and also styles for the UniDock framework to work:

XAML
<Application.Styles>
    <StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
    <StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
    <StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/CustomWindowStyles.axaml"/>
    <StyleInclude Source="avares://NP.Avalonia.UniDock/Themes/DockStyles.axaml"/>
</Application.Styles>  

The most interesting code is located within MainWindow.axaml file.

First of all, we need to define some of the XML namespaces within the Window tag:

XAML
<Window ...
		xmlns:utils="clr-namespace:NP.Utilities.PluginUtils;assembly=NP.Utilities"
		xmlns:basicServices="clr-namespace:NP.Utilities.BasicServices;assembly=NP.Utilities"
		xmlns:np="https://np.com/visuals"
		xmlns:local="clr-namespace:PluginsTest"
        ...
        > 

XML namespace np: is the most important one - used for all Avalonia related functionality including Gidon's code.

Then in order to use the UniDock framework, we need to define a DockManager as an Avalonia XAML resource:

XAML
<Window.Resources>
    <np:DockManager x:Key="TheDockManager"/>
</Window.Resources>  

Then, we have a Grid panel that a PluginControl for displaying the Authentication plugin and a Grid for displaying dockable panels containing the other plugins for sending and displaying some text:

XAML
<Grid>
    <np:PluginControl x:Name="AuthenticationPluginControl"
                      TheContainer="{x:Static local:App.TheContainer}">
        ...
    </np:PluginControl>
    <Grid x:Name="DockContainer" 
          .../>
</Grid>  

Only one of those items can be visible at a time: if the user is not authenticated, than the authentication PluginControl is visible, otherwise the dockable panels containing plugins for sending and displaying text are visible.

Let us focus first on the Authentication PluginControl:

XAML
<np:PluginControl x:Name="AuthenticationPluginControl"
                  TheContainer="{x:Static local:App.TheContainer}">
    <np:PluginControl.PluginInfo>
        <utils:VisualPluginInfo ViewModelType="{x:Type utils:IPlugin}"
                                ViewModelKey="AuthenticationVM"
                                ViewDataTemplateResourcePath=
                               "avares://AuthenticationViewPlugin/Views/AuthenticationView.axaml"
                                ViewDataTemplateResourceKey="AuthenticationViewDataTemplate"/>
    </np:PluginControl.PluginInfo>
</np:PluginControl>  

PluginControl coming from Gidon framework. It is derived from ContentPresenter (the same ContentPresenter that turns a View Model into a View as we explained above). On top of the derived functionality, the PluginControl has several useful Styled Properties defined (Styled Property in Avalonia is very similar to the Dependency Property in WPF):

  • TheContainer Styled Property allows to specify the IoCContainer that the PluginControl needs to get its View Model and View plugins from (as well as all the plugins that they depend on).
  • PluginInfo Styled Property allows to pass the information that specifies how to retrieve the View Model object (used to populate PluginControl.Content property) and the View object (used to populate PluginControl.ContentTemplate property) from the container. This PluginInfo is of type VisualPluginInfo defined within NP.Utilities packages.

TheContainer property on our authentication plugin control is connected to the App.TheContainer static property via x:Static markup extension.

The two first properties ViewModelType and ViewModelKey of our VisualPluginInfo object are used for retrieving the View Model plugin. ViewModelType equals to typeof(IPlugin). Because IPlugin is very common (almost every view model plugin implements it), we also use ViewModelKey set to "AuthenticationVM" string to identify specifically the authentication View Model singleton plugin.

Here is how the AuthenticationViewModel plugin defined in the corresponding AuthenticationViewModelPlugin project:

C#
[RegisterType(typeof(IPlugin), resolutionKey:"AuthenticationVM", isSingleton:true)]
public class AuthenticationViewModel : VMBase, IPlugin
{
...
}

The last two properties of VisualPluginInfo are used to specify the View (which in case of Gidon should be simply a DataTemplate.

Property ViewDataTemplateResourcePath specifies the URL to the XAML Resource file that contains the DataTemplate (in our case, it is "avares://AuthenticationViewPlugin/Views/AuthenticationView.axaml"). Property ViewDataTemplateResourceKey specifies the resource key for the View DataTemplate (in our case, it is "AuthenticationViewDataTemplate"). And indeed you can check the file "AuthenticationView.axaml" located within "Views" project folder of AuthenticationViewPlugin project and you will see the "AuthenticationViewDataTemplate" defined there.

The visibility of our authentication PluginControl is managed via its View (in a sense, it is whatever is inside the PluginControl that become invisible if a user is authenticated and not the PluginControl itself.

Now take a look at the <Grid x:Name="DockContainer" ... />. It contains the UniDock docking hierarchy with two DockItems - one on the left containing a View Model/View plugins for entering and sending a text and one on the right for displaying the text that had been sent:

XAML
<Grid x:Name="DockContainer"
      IsVisible="{Binding Path=(np:PluginAttachedProperties.PluginDataContext).IsAuthenticated, 
                          RelativeSource={RelativeSource Self}}"
      np:PluginAttachedProperties.TheContainer="{x:Static local:App.TheContainer}">
    <np:PluginAttachedProperties.PluginVmInfo>
        <utils:ViewModelPluginInfo ViewModelType=
                                   "{x:Type basicServices:IAuthenticationService}"/>
    </np:PluginAttachedProperties.PluginVmInfo>
    <np:RootDockGroup TheDockManager="{StaticResource TheDockManager}">
        <np:StackDockGroup TheOrientation="Horizontal">
            <np:DockItem Header="Enter Text">
                <np:PluginControl x:Name="EnterTextPluginControl"
                                  TheContainer="{x:Static local:App.TheContainer}">
                    <np:PluginControl.PluginInfo>
                        <utils:VisualPluginInfo ViewModelType="{x:Type utils:IPlugin}"
                                                ViewModelKey="EnterTextViewModel"
                                                ViewDataTemplateResourcePath=
                                      "avares://EnterTextViewPlugin/Views/EnterTextView.axaml"
                                                ViewDataTemplateResourceKey="EnterTextView"/>
                    </np:PluginControl.PluginInfo>
                </np:PluginControl>
            </np:DockItem>
            <np:DockItem Header="Received Text">
                <np:PluginControl x:Name="ReceiveTextPluginControl"
                                  TheContainer="{x:Static local:App.TheContainer}">
                    <np:PluginControl.PluginInfo>
                        <utils:VisualPluginInfo ViewModelType="{x:Type utils:IPlugin}"
                                                ViewModelKey="ReceiveTextViewModel"
                                                ViewDataTemplateResourcePath=
                                   "avares://ReceiveTextViewPlugin/Views/ReceiveTextView.axaml"
                                                ViewDataTemplateResourceKey="ReceiveTextView"/>
                    </np:PluginControl.PluginInfo>
                </np:PluginControl>
            </np:DockItem>
        </np:StackDockGroup>
    </np:RootDockGroup>
</Grid>  

The PluginControls within the DockItems are set in a way very similar to how the authentication PluginControl had been set, only they point to different View Model and View plugins, so we are not going to spend time discussing them here (although we shall explain those plugins below). RootDockGroup, StackDockGroup and DockItem are UniDock framework objects:

  • RootDockGroup is an docking group at the top of every docking hierarchy.
  • StackDockGroup arranges its children vertically or horizontally (in our case horizontally, because its TheOrientantion property is set to Horizontal.
  • DockItem are actually the docking/floating panes with header and content.

What we need to explain about our <Grid x:Name="DockContainer" ...> panel is how we toggle its visibility depending on whether the user is authenticated or not. Here is the relevant code:

XAML
<Grid x:Name="DockContainer"
      IsVisible="{Binding Path=(np:PluginAttachedProperties.PluginDataContext).IsAuthenticated, 
                          RelativeSource={RelativeSource Self}}"
      np:PluginAttachedProperties.TheContainer="{x:Static local:App.TheContainer}">
    <np:PluginAttachedProperties.PluginVmInfo>
        <utils:ViewModelPluginInfo ViewModelType=
                                   "{x:Type basicServices:IAuthenticationService}"/>
    </np:PluginAttachedProperties.PluginVmInfo>
    ...
</Grid>  

Here, we rely on Avalonia attached properties defined within PluginAttachedProperties class of NP.Avalonia.Gidon project. We set the PluginAttachedProperties.TheContainer Attached Property to our App.TheContainer static property by using x:Static markup extension:

C#
np:PluginAttachedProperties.TheContainer="{x:Static local:App.TheContainer}"  

Then we set the attached property PluginAttachedProperties.PluginVmInfo to:

XAML
<utils:ViewModelPluginInfo ViewModelType="{x:Type basicServices:IAuthenticationService}"/>

ViewModelPluginInfo contains only the View Model part of VisualPluginInfo. In our case, we are retrieving the implementation of IAuthenticationService which is a singleton of type MockAuthenticationService (we do not need the ViewModelKey since there is only one object of type IAuthenticationService within our container).

Once both attached properties are set on our Grid, the attached property PluginAttachedProperties.PluginDataContext on the same Grid will be set to contain the object of type IAuthenticationService retrieved from the container. This object has IsAuthenticated property with change notification (firing INotifyPropertyChanged.PropertyChanged event on property change).

Now all we need to do is to bind IsVisible property on our Grid to the path to IsAuthenticated property defined on the object contained by our attached PluginAttachedProperties.PluginDataContext property:

XAML
<Grid x:Name="DockContainer"
      IsVisible="{Binding Path=(np:PluginAttachedProperties.PluginDataContext).IsAuthenticated, 
                          RelativeSource={RelativeSource Self}}"
  ...
  >

Authentication Plugins and Services

Authentication View Model Plugin

Authentication View Model Plugin is defined within AuthenticationViewModelPlugin project:

C#
[RegisterType(typeof(IPlugin), resolutionKey: "AuthenticationVM", isSingleton: true)]
public class AuthenticationViewModel : VMBase, IPlugin
{
    [Inject(typeof(IAuthenticationService))]
    // Authentication service that comes from the container
    public IAuthenticationService? TheAuthenticationService
    {
        get;
        private set;
    }

    ...
    // notifiable property
    public string? UserName { get {...}  set {...} }

    ...
    // notifiable property
    public string? Password { get {...}  set {...} }

    // change notification fires when either UserName or Password change
    public bool CanAuthenticate =>
        (!string.IsNullOrEmpty(UserName)) && (!string.IsNullOrEmpty(Password));

    // method to call in order to try to authenticate a user
    public void Authenticate()
    {
        TheAuthenticationService?.Authenticate(UserName, Password);

        OnPropertyChanged(nameof(IsAuthenticated));
    }

    // method to exit the application
    public void ExitApplication()
    {
        Environment.Exit(0);
    }

    // IsAuthenticated property 
    // whose change notification fires within Authenticate() method
    public bool IsAuthenticated => TheAuthenticationService?.IsAuthenticated ?? false;
}  
MockAuthenticationService

The IAuthenticationService that Authentication View Model Plugin uses, is implemented as MockAuthenticationService within MockAuthentication project and is also very simple:

C#
[RegisterType(typeof(IAuthenticationService), IsSingleton = true)]
public class MockAuthenticationService : VMBase, IAuthenticationService
{
    ...
    // notifiable property
    public string? CurrentUserName { get {...} set {...} }

    // Is authenticated is true if and only if the CurrentUserName is not zero
    public bool IsAuthenticated => CurrentUserName != null;
 
    // will only authenticate if userName="nick" and password="1234"
    public bool Authenticate(string userName, string password)
    {
        if (IsAuthenticated)
        {
            throw new Exception("Already Authenticated");
        }
        
        CurrentUserName =
                (userName == "nick" && password == "1234") ? userName : null;

        ...

        return IsAuthenticated;
    }

    public void Logout()
    {
        if (!IsAuthenticated)
        {
            throw new Exception("Already logged out");
        }

        CurrentUserName = null;
    }
}  
Authentication View

As was mentioned above, views in Gidon should be defined as DataTemplates. Authentication View is defined as a DataTemplate resource within Views/AuthenticationView.axaml file inside AuthenticationViewPlugin project:

XAML
<DataTemplate x:Key="AuthenticationViewDataTemplate">
    <Grid Background="{DynamicResource WindowBackgroundBrush}"
            RowDefinitions="*, Auto"
            IsVisible="{Binding Path=IsAuthenticated, 
                        Converter={x:Static np:BoolConverters.Not}}">
        <Control.Styles>
            <StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/ThemeStyles.axaml"/>
        </Control.Styles>
        <StackPanel HorizontalAlignment="Center"
                    VerticalAlignment="Center"
                    Margin="10">
            <np:LabeledControl x:Name="EnterUserNameControl"
                                Text="Enter User Name: "
                                Classes="Bla"
                                HorizontalAlignment="Center">
                <np:LabeledControl.ContainedControlTemplate>
                    <ControlTemplate>
                        <TextBox Width="150"
                                    Text="{Binding Path=UserName, Mode=TwoWay}"/>
                    </ControlTemplate>
                </np:LabeledControl.ContainedControlTemplate>
            </np:LabeledControl>

            <np:LabeledControl x:Name="EnterPasswordControl"
                                Text="Enter Password: "
                                HorizontalAlignment="Center"
                                Margin="0,15,0,0">
                <np:LabeledControl.ContainedControlTemplate>
                    <ControlTemplate>
                        <TextBox Width="150"
                                    Text="{Binding Path=Password, Mode=TwoWay}"/>
                    </ControlTemplate>
                </np:LabeledControl.ContainedControlTemplate>
            </np:LabeledControl>
        </StackPanel>
        <StackPanel Orientation="Horizontal"
                    Margin="10"
                    Grid.Row="1"
                    HorizontalAlignment="Right"
                    VerticalAlignment="Center">
            <Button Content="Exit"
                    np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
                    np:CallAction.MethodName="ExitApplication"/>
            <Button Content="Login"
                    Margin="10,0,0,0"
                    IsEnabled="{Binding Path=CanAuthenticate}"
                    np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
                    np:CallAction.MethodName="Authenticate"/>
        </StackPanel>
    </Grid>
</DataTemplate>

It has two LabeledControl objects arranged one on top of the other - one for entering the user name and the other - password. The Text properties inside their TextBoxes are two-way bound to correspondingly UserName and Password strings defined on the View Model plugin.

The buttons "Exit" and "Login" are using CallAction behavior from NP.Avalonia.Visuals project to call correspondingly ExitApplication() and Authenticate() View Model methods when the button is clicked.

Enter and Receive Text Plugins and Services

TextService

Enter and Receive text View Model plugins communicate with each other via the TextService that implements ITextService interface:

C#
[RegisterType(typeof(ITextService), IsSingleton = true)]
public class TextService : ITextService
{
    public event Action<string>? SentTextEvent;

    public void Send(string text)
    {
        SentTextEvent?.Invoke(text);
    }
}  

Its implementation is very simple - it has one method Send(string text) which fires SendTextEvent passing to it the text. Enter text View Model calls the Send(string text) method and Receive text View Model handles the SentTextEvent getting the text and assigning it to its own notifiable Text property.

Enter Text View Model Plugin

This plugin consists of one simple class - EnterTextViewModel:

C#
[RegisterType(typeof(IPlugin), partKey: nameof(EnterTextViewModel), isSingleton: true)]
public class EnterTextViewModel : VMBase, IPlugin
{
    // ITextService implementation
    [Inject(typeof(ITextService))]
    public ITextService? TheTextService { get; private set; }

    #region Text Property
    private string? _text;

    // notifiable property with getter and setter
    public string? Text { ... }
    #endregion Text Property

    // change notified the Text changes
    public bool CanSendText => !string.IsNullOrWhiteSpace(this._text);

    // method to send the text via TextService
    public void SendText()
    {
        if (!CanSendText)
        {
            throw new Exception("Cannot send text, this method should not have been called.");
        }

        TheTextService!.Send(Text!);
    }
}  
Enter Text View Plugin

This plugin is located within Views/EnterTextView.axaml file of EnterTextViewPlugin project:

XAML
<DataTemplate x:Key="EnterTextView">
    <Grid RowDefinitions="*, Auto">
        <Control.Styles>
            <StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/ThemeStyles.axaml"/>
        </Control.Styles>
        <np:LabeledControl Text="Enter Text: ">
            <ControlTemplate>
                <TextBox Text="{Binding Path=Text, Mode=TwoWay}"
                         Width="150"/>
            </ControlTemplate>
        </np:LabeledControl>
        <Button Content="Send"
                Grid.Row="1"
                IsEnabled="{Binding Path=CanSendText}"
                np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
                np:CallAction.MethodName="SendText"
                ...
                />
    </Grid>
</DataTemplate>  

There is a TextBox for entering the text two way bound to the Text property on the View Model. There is also a button for calling SentText() method on the View Model when it is clicked.

Receive Text View Model Plugin

Located in ReceiveTextViewModel project:

C#
[RegisterType(typeof(IPlugin), resolutionKey: nameof(ReceiveTextViewModel), isSingleton: true)]
public class ReceiveTextViewModel : VMBase, IPlugin
{
    ITextService? _textService;

    // ITextService implementation
    [Inject(typeof(ITextService))]
    public ITextService? TheTextService
    {
        get => _textService;
        private set
        {
            if (_textService == value)
                return;

            if (_textService != null)
            {
                // disconnect old service's SentTextEvent
                _textService.SentTextEvent -= _textService_SentTextEvent;
            }

            _textService = value;

            if (_textService != null)
            {   // connect the handler to the service's
                // SentTextEvent
                _textService.SentTextEvent += _textService_SentTextEvent;
            }
        }
    }

    // set Text property when receives it from TheTextService
    // via SentTextEvent
    private void _textService_SentTextEvent(string text)
    {
        Text = text;
    }

    #region Text Property
    private string? _text;
    // notifiable property
    public string? Text { get {...} private set {...} }
    #endregion Text Property
}  
Receive Text View Plugin
XAML
<DataTemplate x:Key="ReceiveTextView">
    <Grid>
        <Control.Styles>
            <StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/ThemeStyles.axaml"/>
        </Control.Styles>
        <np:LabeledControl Text="The Received Text is:"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Margin="10">
            <ControlTemplate>
                <TextBlock Text="{Binding Path=Text, Mode=OneWay}" 
                           FontWeight="Bold"/>
            </ControlTemplate>
        </np:LabeledControl>
    </Grid>
</DataTemplate>  

Essentially - it only contains a TextBox with its Text property two-way bound to the Text property on the View Model.

Practicing Prototype Driven Development with Gidon Framework

I recently described Prototype Driven Development or PDD in Prototype Driven Development (PDD) article.

This is a type of development where you first create a prototype containing the functionality that you need. Then move the re-usable functionality from this prototype to the generic projects and finally use that functionality in your main application project:

Image 12

Plugin architecture is ideally suited for PDD. Indeed the main project is usually not statically dependent on the plugins (which are dynamically loaded instead). This is convenient for the run time flexibility, but not for the development.

Take a look at AuthenticationPluginTest.sln solution located under Prototypes/AuthenticationPluginTest folder.

It contains only Authentication related plugins and MockAuthentication service:

Image 13

But the main project now depends on AuthenticationViewPlugin, AuthenticationViewModelPlugin and MockAuthentication projects.

This will allow you to recompile only once instead of recompiling plugins and the main project separately. Also, it will make the project much lighter since you'll be dealing only with three plugins (View Model, View and a service) and not with all the plugins within the application). Furthermore, it will avoid dealing with possible mistakes because some dynamic assemblies did not change when you expected them to change..

In general following the PDD, one can first create the Authentication plugin functionality within the main project of the prototype. Then move the View Model to the AuthenticationViewModelPlugin and the View over to the AuthenticationViewPlugin projects which the main project of the prototype is dependent from.

Finally, after polishing the functionality and making sure that it works properly, you can set the plugin assemblies to be copied to the plugin folders and test them as dynamically loaded plugins within another prototype or within the main application.

Moreover, when you need to modify or debug the plugins, you'll be able to do it within the prototype where the plugins are statically loaded and the modifications will be working automatically for the projects where the plugins are loaded dynamically.

History

  • 21st February, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License