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:
- Model - the non-visual data that comes from the backend
- 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.
- 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.
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.
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:
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).
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.
Plugins
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
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)
container.RegisterType<IPerson, Person>();
Sets the mapping between IPerson
interface and Person
implementation of IPerson
, so that every time
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:
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:
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:
ConsoleLog consoleLog = new ConsoleLog();
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:
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>
:
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:
[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.,
[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:
public class IoCContainer
{
...
public void InjectAssembly(Assembly assembly){...}
public void InjectDynamicAssemblyByFullPath(string assemblyPath){...}
public void InjectPluginsFromFolder
(string assemblyFolderPath, Regex? matchingFileName = null){...}
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:
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
Try to run the application, here is what you should see:
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:
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:
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:
public class App : Application
{
public static PluginManager ThePluginManager { get; } =
new PluginManager
(
"Plugins/Services",
"Plugins/ViewModelPlugins",
"Plugins/ViewPlugins");
public static IoCContainer TheContainer => ThePluginManager.TheContainer;
public App()
{
ThePluginManager.InjectType(typeof(NLogWrapper));
ThePluginManager.CompleteConfiguration();
}
...
}
App.axaml includes the styles from the default theme and also styles for the UniDock framework to work:
<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:
<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:
<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:
<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
:
<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:
[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:
<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:
<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:
np:PluginAttachedProperties.TheContainer="{x:Static local:App.TheContainer}"
Then we set the attached property PluginAttachedProperties.PluginVmInfo
to:
<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:
<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:
[RegisterType(typeof(IPlugin), resolutionKey: "AuthenticationVM", isSingleton: true)]
public class AuthenticationViewModel : VMBase, IPlugin
{
[Inject(typeof(IAuthenticationService))]
public IAuthenticationService? TheAuthenticationService
{
get;
private set;
}
...
public string? UserName { get {...} set {...} }
...
public string? Password { get {...} set {...} }
public bool CanAuthenticate =>
(!string.IsNullOrEmpty(UserName)) && (!string.IsNullOrEmpty(Password));
public void Authenticate()
{
TheAuthenticationService?.Authenticate(UserName, Password);
OnPropertyChanged(nameof(IsAuthenticated));
}
public void ExitApplication()
{
Environment.Exit(0);
}
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:
[RegisterType(typeof(IAuthenticationService), IsSingleton = true)]
public class MockAuthenticationService : VMBase, IAuthenticationService
{
...
public string? CurrentUserName { get {...} set {...} }
public bool IsAuthenticated => CurrentUserName != null;
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:
<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
string
s 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:
[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
:
[RegisterType(typeof(IPlugin), partKey: nameof(EnterTextViewModel), isSingleton: true)]
public class EnterTextViewModel : VMBase, IPlugin
{
[Inject(typeof(ITextService))]
public ITextService? TheTextService { get; private set; }
#region Text Property
private string? _text;
public string? Text { ... }
#endregion Text Property
public bool CanSendText => !string.IsNullOrWhiteSpace(this._text);
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:
<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:
[RegisterType(typeof(IPlugin), resolutionKey: nameof(ReceiveTextViewModel), isSingleton: true)]
public class ReceiveTextViewModel : VMBase, IPlugin
{
ITextService? _textService;
[Inject(typeof(ITextService))]
public ITextService? TheTextService
{
get => _textService;
private set
{
if (_textService == value)
return;
if (_textService != null)
{
_textService.SentTextEvent -= _textService_SentTextEvent;
}
_textService = value;
if (_textService != null)
{
_textService.SentTextEvent += _textService_SentTextEvent;
}
}
}
private void _textService_SentTextEvent(string text)
{
Text = text;
}
#region Text Property
private string? _text;
public string? Text { get {...} private set {...} }
#endregion Text Property
}
Receive Text View Plugin
<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:
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:
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