65.9K
CodeProject 正在变化。 阅读更多。
Home

Gidon - 基于 Avalonia 的 MVVM 插件 IoC 容器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (17投票s)

2022 年 2 月 21 日

MIT

20分钟阅读

viewsIcon

27512

本文介绍了 Gidon - 首个为 Avalonia 创建的 IoC/MVVM 插件框架。

引言

Gidon IoC/MVVM 框架,专为 Avalonia 构建

在本文中,我将介绍一个正在为出色的多平台 WPF 类包 Avalonia 构建的新 Gidon IoC/MVVM 框架,它基于我自己的 IoCy 控制反转/依赖注入容器。据我所知,这是 Avalonia 的第一个 IoC/MVVM 框架,尽管我了解之前曾尝试(不确定是否成功)将 Prism/MEF 移植到 Avalonia。

那么,合理的问题是,为什么不直接移植 Prism(或使用其以前的移植版本),而是构建一个新的框架呢?

在我看来,Prism 及其经常搭配使用的 MEF 都过于复杂,允许使用一些不应该与 WPF 和 Avalonia 一起使用的旧范式(例如,事件聚合),并且文档非常不足。

Gidon 的目的是提供非常简单的 API 和实现,同时涵盖所有必要的功能。

请注意,Gidon 框架已经相当可操作(如本文示例所示)。未来还将增加许多新的强大功能。

MVVM(模型-视图-视图模型)模式回顾

什么是 MVVM

MVVM 模式由三个部分组成

  1. 模型 (Model) - 来自后端非可视化数据
  2. 视图模型 (View Model) - 也是非可视化对象,包含数据,但也提供非可视化属性以反映可视化功能和方法,供可视化按钮、菜单项等调用。
  3. 视图 (View) - 表示应用程序可视界面的可视化对象

视图模型知道模型,但反之则不然。

视图围绕视图模型构建,因此它对视图模型有一些了解,但视图模型不应该知道视图的任何信息。

MVVM:视图知道视图模型,视图模型知道模型,反之则不然。

视图通常是被动的——它只是模仿其视图模型并调用视图模型的方法。视图只知道它自己的视图模型——不同视图之间的所有通信通常都是通过它们各自的视图模型完成的。

MVVM:视图之间的通信仅通过其视图模型进行

重要提示:视图与其视图模型之间的双向通信并不意味着视图模型知道视图的任何信息:从视图模型到视图的通信是通过绑定或事件实现的。

MVVM 模式的主要优点是,视图中非常复杂的视觉对象只是模仿视图模型中更简单的非视觉对象。非视觉视图模型对象更容易创建、扩展、测试和调试,并且由于所有业务逻辑都位于视图模型中,MVVM 应用程序变得更容易构建和维护。

MVVM 模式最初是为 WPF 开发而发明的,因为它具有卓越的绑定能力,但后来也被其他工具和框架采用。当然,每个 XAML 框架(包括 Avalonia、UWP、Xamarin 等)都支持 MVVM,但 Angular 和 Knockout JavaScript 包本质上也是 MVVM 框架。

在您的代码中遵循 MVVM 模式,通常不需要任何控制反转或依赖注入。

重要提示:在我广泛的实践中,模型很少需要——后端数据可以直接反序列化到视图模型类中。因此,我主要实践不带模型的视图-视图模型(VVM)模式,但为了简化起见,我将两种方法都称为 MVVM。

要了解更多关于 MVVM 模式的信息,您可以阅读我的文章 MVVM 模式简单化数据模板和视图模型

Avalonia 的 MVVM 工具

在 Avalonia 中,将非可视化视图模型转换为可视化视图的最佳方式是使用 ContentPresenterItemsPresenter 控件(在 WPF 中,它们对应于 ContentControlItemsControl)。

ContentPresenter 非常适合将单个非可视化对象转换为可视化对象,通过将传递给其 Content 属性的视图模型对象与传递给其 ContentTemplate 属性的 DataTemplate “结合”起来。

ItemsPresenter 适用于将非可视视图模型对象集合(存储在其 Items 属性中)转换为可视集合,方法是将其 ItemTemplate 属性中存储的 DataTemplate 应用于其中的每个对象。结果集合中的可视对象根据 ItemsPresenter.ItemsPanel 属性提供的 Avalonia Panel 进行排列(默认情况下,它们垂直堆叠,一个在另一个的顶部)。

IoC 容器(不含 MVVM)回顾

MVVM 和 IoC 不必一起使用。有许多与 MVVM 或任何可视化框架无关的纯控制反转(插件)容器。其中包括

  • MEF
  • Autofac
  • Unity
  • Ninject
  • Castle Windsor
  • IoCy - 我自己的简单 IoC 容器,可在 IoCy 获取。

这些框架的主要目的是促进将功能分解为松散耦合的插件(有些静态加载,有些动态加载),以改善应用程序中的关注点分离。

这将带来以下好处

  • 插件独立性 - 修改一个插件的实现不应触发其他插件的更改。
  • 更容易测试和调试 - 应该能够轻松地单独测试、调试和修改每个插件(以及它所依赖的插件),并且修复后的插件应该能够在不更改其他插件的情况下与应用程序的其余部分一起工作。
  • 产品可扩展性提高 - 当您需要新功能时,您知道要修改哪个插件来实现该特定扩展,或者如果需要,您可以在现有插件中添加新插件,只需在可能使用新 API 的地方进行修改。

我见过许多项目(不是我设计和启动的)只利用了上面列出的最后一个优点——通过向应用程序添加插件来扩展应用程序。另一方面,它们的插件如此交织和相互依赖,以至于无法在不影响其他插件的情况下删除其中一个。这是一个非常重要的错误——为了获得良好插件架构的益处,不同插件之间的相互依赖性应该最小化,确保这一点是架构师的任务。

从某种意义上说,插件类似于硬件卡,而插件接口则非常像插入卡的插槽。

插件

接口

重要提示:替换、添加或移除插件应该像在已拆开的计算机中替换、添加或移除计算机卡一样容易。如果不是这样,您的插件架构需要额外的工作。

测试插件应该像将卡插入硬件测试仪、发送一些输入并在测试仪中检查相应的输出一样容易。当然,首先应该为插件构建一个测试仪。

然而,一般来说,软件插件比硬件卡具有以下优势:

  • 在软件中生成插件对象的成本远低于硬件中生成卡的成本,因此使用多个相同类型的插件对象不会增加应用程序的成本。此外,相同类型的不同对象保证以相同的方式运行——软件缺陷是按类型而不是按对象发生的。
  • 插件可以是分层的,即,插件本身可以由不同的子插件组成(硬件插件也可以有一些子插件,但在软件中,层次结构可以包含所需数量的级别)。
  • 某些插件可以是单例——在许多不同地方使用相同的插件(这在硬件中当然是不可能的)。

请注意,虽然插件层次结构是可行的,但插件绝不应该是交叉依赖或对等依赖的。这意味着如果插件是逻辑对等体,它们就不应该相互依赖——公共功能应该被提取到不同的插件或非插件 DLL 中。

为什么 IoC 和 MVVM 一起使用?

前面已经提到,MVVM 可以不使用 IoC,IoC 也可以不使用 MVVM,那么为什么我们需要一个同时做这两件事的框架呢?原因是视图及其相应的视图模型是作为插件构建的良好候选。在这种情况下,每个开发人员可以开发自己的视图/视图模型组合,独立于团队其他成员进行测试,然后将它们作为插件整合在一起,理想情况下,一切都会正常工作。

当然,有时视图模型并不是完全独立的——它们需要相互通信。通信机制可以通过称为服务的非可视化单例插件连接,有时甚至内置到框架中。

有几个著名的 IoC/MVVM 框架通常围绕微软的 MEF 或 Unity IoC 容器构建,有些甚至可以同时使用两者。所有这些最初都是为 WPF 创建的,但后来也适用于 Xamarin 和 UWP。其中包括

  • Prism
  • Caliburn/Caliburn.Micro
  • Cinch

IoCy 容器回顾

在这里,我将介绍我的 IoCy 简单而强大的容器的功能。我将 MEF、Autofac 和 Ninject 中我喜欢的所有功能都添加到了其中,同时跳过了那些不常用的功能。

IoC 和 DI(依赖注入)实现的主要原则是可注入对象不是通过调用其构造函数创建的,而是通过调用容器上的一些方法来创建或查找要返回的对象。容器在返回对象的正确实现之前创建。每个可注入对象可能都有一些也是可注入的属性。在这种情况下,这些属性也从同一个容器中填充,依此类推递归。

以下是 IoCy 最重要的功能

创建一个容器

IoCContainer container = new IoCContainer();

您可以将唯一的容器名称传递给构造函数,否则它将生成一个唯一的名称。

在接口(或超类)和实现(或子类)之间创建映射

container.RegisterType<IPerson, Person>();

设置 IPerson 接口与 Person 接口实现之间的映射,以便每次

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

调用该方法时,它将创建并返回一个新的 Person 对象。

请注意,也可以为 IPerson 接口创建不同的映射,例如映射到 SuperPerson 类,但为了使两个映射同时存在,应该将某个对象作为映射 id 传递给 Map(object id = null) 方法,然后也将相同的 id 传递给相应的 Resolve(object id = null) 方法。

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

在上面的代码中,id 是一个整数,等于 1

请注意,所有其他映射和解析方法也接受 id 参数。

在接口(或超类)与实现(或子类)之间创建单例映射

与之前每次调用 Resolve<...>() 方法都会获得新创建的对象不同,单例映射每次都会返回同一个对象。

以下是执行单例映射的方法

container.RegisterSingletonType<ILog, FileLog>();

解析 Singleton 与之前完全相同,只是每次都返回相同的对象。

还有另一种设置单例映射的方法,如果您希望一个已经存在的对象成为 Singleton,您只需将其传递给 RegisterSingletonInstance(...) 方法。

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

创建多重映射

多重映射创建一组特定类型的项目,并将其映射到一个键。每次调用 MapMultiType(...) 都会向集合中添加一个项目。例如:

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

将一个 FileLog 类型的对象和另一个 ConsoleLog 类型的对象添加到容器内部集合中。相应地,在容器上调用 MultiResolve() 方法将返回此两个项目集合作为 IEnumerable<ILog>

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

使用属性进行组合

与 MEF 相同,IoCy 允许使用属性在容器内组合对象。例如

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

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

顶部的 [RegisterType] 属性表示此实现映射到某种类型。由于未将确切的映射类型指定为属性参数,因此默认情况下,它映射到当前类的基类(如果基类不是 object);如果基类是 object,则映射到该类实现的第一个接口。由于我们的 Person 类没有基类,它将映射到第一个接口 (IPerson)。因此,上面的代码等效于 container.RegisterType<IPerson, Person>();。但是,建议您将要映射的类作为第一个参数 TypeToResolve 传递给该属性,这样类的更改(例如,类实现的接口顺序的更改)就不会影响组合。

以下属性声明:[RegisterType(typeof(IPerson))],比上面使用的更好。

Address 属性上方的 [Inject] 属性表示 Address 对象也是可注入的(来自容器)。请注意,注入的属性不知道它注入的对象是单例还是每次都重新创建——如何填充它取决于容器。

对于多重实现,应在类上方使用 [RegisterMultiCellType(...)] 属性,并在属性上方使用常规 [Inject] 属性,例如:

[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; }
}

相应地,有几个 IoCContainer 方法允许从静态或动态加载的整个程序集,甚至从位于特定路径下的所有 DLL 程序集来组合容器。以下是列表:

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 示例

代码位置

此时,要运行 Gidon 示例,您需要从 Gidon 下载完整的 Gidon 代码。

为此,您应该使用带递归子模块的 git 命令

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

或者,如果您在克隆时忘记了用户“--recursive”选项,您始终可以在克隆后在存储库内使用以下命令:

git submodule update --init  

以下是存储库基本目录的子文件夹

  • Prototypes - 包含 Gidon 的示例,其中一些将在本文下面讨论。
  • src - 包含 Gidon 的代码。
  • SubModules - 包含从其他存储库作为子模块拉取的 Gidon 所依赖的代码。
  • Tests - 包含在多个原型中使用的代码。特别是,我将测试插件和服务放在这里。

PluginsTest

解决方案位置和结构

PluginsTest 解决方案位于 Prototypes/PluginsTest 文件夹下。打开解决方案(您将需要 VS2022)。

PluginsTest 项目设置为解决方案的启动项目。

这是解决方案的文件夹/项目结构:

右键点击 PluginsTest 项目,选择 Rebuild。请注意,插件是动态加载到应用程序中的,主项目不直接依赖它们。为了构建所有插件,您必须右键点击解决方案中的“TestAndMocks”解决方案文件夹,然后选择 Rebuild

插件/服务项目将重新构建,其编译后的程序集将(通过构建后事件)复制到当前解决方案下的 bin/Debug/net6.0 文件夹中。Gidon 框架将从该文件夹动态加载插件。以下是安装在 <CurrentSolution>/bin/Debug/net6.0 下的插件的文件夹结构:

运行 PluginsTest 项目

尝试运行应用程序,您应该会看到以下内容:

按下“Exit”按钮将退出应用程序。

用户名为“nick”,密码为“1234”。按下“Login”按钮(它应该变为可用),您将看到:

这是两个可停靠/浮动窗格,您可以拖动它们的标题将其从主窗口中拉出。它们之间通过服务连接——如果您在“Enter TextTextBox 中输入任何内容并按下“Send”按钮(它将变为启用状态),文本将出现在另一个可停靠窗格中。

PluginsTest 主项目代码

代码解释

现在让我们来看看代码。

Gidon 加载插件的代码

加载插件的代码位于 App 构造函数中的 App.axaml.cs 文件中。

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 包含了默认主题的样式,以及 UniDock 框架所需的样式。

<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>  

最有趣的代码位于 MainWindow.axaml 文件中。

首先,我们需要在 Window 标签中定义一些 XML 命名空间

<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 命名空间 np: 是最重要的一个——用于所有与 Avalonia 相关的功能,包括 Gidon 的代码。

然后为了使用 UniDock 框架,我们需要将 DockManager 定义为 Avalonia XAML 资源。

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

然后,我们有一个 Grid 面板,其中包含一个用于显示认证插件的 PluginControl,以及一个用于显示包含其他插件的可停靠面板的 Grid,用于发送和显示一些文本。

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

这些项中一次只能有一个可见:如果用户未经过身份验证,则身份验证 PluginControl 可见,否则包含用于发送和显示文本的插件的可停靠面板可见。

让我们首先关注认证 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 来自 Gidon 框架。它派生自 ContentPresenter(就是我们上面解释的将视图模型转换为视图的 ContentPresenter)。在派生功能的基础上,PluginControl 定义了几个有用的 Styled 属性(Avalonia 中的 Styled 属性与 WPF 中的依赖属性非常相似)。

  • TheContainer Styled 属性允许指定 IoCContainerPluginControl 需要从其中获取其视图模型和视图插件(以及它们所依赖的所有插件)。
  • PluginInfo Styled 属性允许传递信息,该信息指定如何从容器中检索视图模型对象(用于填充 PluginControl.Content 属性)和视图对象(用于填充 PluginControl.ContentTemplate 属性)。此 PluginInfo 类型为 NP.Utilities 包中定义的 VisualPluginInfo

我们认证插件控件上的 TheContainer 属性通过 x:Static 标记扩展连接到 App.TheContainer static 属性。

我们的 VisualPluginInfo 对象的两个首个属性 ViewModelTypeViewModelKey 用于检索视图模型插件。ViewModelType 等于 typeof(IPlugin)。因为 IPlugin 非常常见(几乎每个视图模型插件都实现它),我们还使用 ViewModelKey 设置为 "AuthenticationVM" 字符串来专门标识认证视图模型单例插件。

以下是相应的 AuthenticationViewModelPlugin 项目中定义的 AuthenticationViewModel 插件:

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

VisualPluginInfo 的最后两个属性用于指定视图(在 Gidon 的情况下,它应该只是一个 DataTemplate)。

属性 ViewDataTemplateResourcePath 指定包含 DataTemplate 的 XAML 资源文件的 URL(在我们的例子中是 "avares://AuthenticationViewPlugin/Views/AuthenticationView.axaml")。属性 ViewDataTemplateResourceKey 指定视图 DataTemplate 的资源键(在我们的例子中是 "AuthenticationViewDataTemplate")。您可以查看 AuthenticationViewPlugin 项目的 "Views" 项目文件夹中名为 "AuthenticationView.axaml" 的文件,您会看到在那里定义的 "AuthenticationViewDataTemplate"。

我们的认证 PluginControl 的可见性通过其视图进行管理(从某种意义上说,如果用户已认证,则 PluginControl 内部的内容会变为不可见,而不是 PluginControl 本身)。

现在看看 <Grid x:Name="DockContainer" ... />。它包含一个 UniDock 对接层次结构,其中有两个 DockItem——一个在左侧,包含用于输入和发送文本的视图模型/视图插件,另一个在右侧,用于显示已发送的文本。

<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>  

DockItems 中的 PluginControls 的设置方式与认证 PluginControl 的设置方式非常相似,只是它们指向不同的视图模型和视图插件,因此我们在此不花时间讨论它们(尽管我们将在下面解释这些插件)。RootDockGroupStackDockGroupDockItem 是 UniDock 框架对象。

  • RootDockGroup 是每个停靠层次结构顶部的停靠组。
  • StackDockGroup 将其子项垂直或水平排列(在我们的例子中是水平,因为其 TheOrientantion 属性设置为 Horizontal)。
  • DockItem 实际上是带有标题和内容的可停靠/浮动窗格。

关于我们的 <Grid x:Name="DockContainer" ...> 面板,我们需要解释的是我们如何根据用户是否经过身份验证来切换其可见性。以下是相关代码:

<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>  

这里,我们依赖于在 NP.Avalonia.Gidon 项目的 PluginAttachedProperties 类中定义的 Avalonia 附加属性。我们通过使用 x:Static 标记扩展将 PluginAttachedProperties.TheContainer 附加属性设置为我们的 App.TheContainer static 属性。

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

然后我们设置附加属性 PluginAttachedProperties.PluginVmInfo

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

ViewModelPluginInfo 仅包含 VisualPluginInfo 的视图模型部分。在我们的例子中,我们正在检索 IAuthenticationService 的实现,它是一个类型为 MockAuthenticationService 的单例(我们不需要 ViewModelKey,因为我们的容器中只有一个 IAuthenticationService 类型的对象)。

一旦在我们的 Grid 上设置了两个附加属性,同一 Grid 上的附加属性 PluginAttachedProperties.PluginDataContext 将被设置为包含从容器中检索到的 IAuthenticationService 类型的对象。该对象具有 IsAuthenticated 属性,并带有更改通知(在属性更改时触发 INotifyPropertyChanged.PropertyChanged 事件)。

现在我们需要做的就是将我们的 Grid 上的 IsVisible 属性绑定到由我们的附加 PluginAttachedProperties.PluginDataContext 属性包含的对象上定义的 IsAuthenticated 属性的路径。

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

认证插件和服务

认证视图模型插件

认证视图模型插件定义在 AuthenticationViewModelPlugin 项目中。

[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

认证视图模型插件使用的 IAuthenticationServiceMockAuthentication 项目中实现为 MockAuthenticationService,它也非常简单。

[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;
    }
}  
认证视图

如上所述,Gidon 中的视图应定义为 DataTemplates。认证视图在 AuthenticationViewPlugin 项目的 Views/AuthenticationView.axaml 文件中定义为一个 DataTemplate 资源。

<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>

它有两个 LabeledControl 对象,一个在另一个上面排列——一个用于输入用户名,另一个用于输入密码。它们 TextBoxes 中的 Text 属性双向绑定到视图模型插件中定义的相应 UserNamePassword 字符串。

Exit”和“Login”按钮使用 NP.Avalonia.Visuals 项目中的 CallAction 行为,在按钮点击时分别调用 ExitApplication()Authenticate() 视图模型方法。

输入和接收文本插件和服务

文本服务

输入和接收文本视图模型插件通过实现 ITextService 接口的 TextService 相互通信。

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

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

其实现非常简单——它有一个方法 Send(string text),该方法触发 SendTextEvent 并将文本传递给它。输入文本视图模型调用 Send(string text) 方法,接收文本视图模型处理 SentTextEvent,获取文本并将其分配给它自己的可通知 Text 属性。

输入文本视图模型插件

该插件由一个简单的类组成——EnterTextViewModel

[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!);
    }
}  
输入文本视图插件

此插件位于 EnterTextViewPlugin 项目的 Views/EnterTextView.axaml 文件中。

<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>  

有一个 TextBox 用于输入文本,双向绑定到视图模型上的 Text 属性。还有一个按钮用于在点击时调用视图模型上的 SentText() 方法。

接收文本视图模型插件

位于 ReceiveTextViewModel 项目中

[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
}  
接收文本视图插件
<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>  

本质上,它只包含一个 TextBox,其 Text 属性双向绑定到视图模型上的 Text 属性。

使用 Gidon 框架实践原型驱动开发

我最近在 原型驱动开发(PDD) 一文中描述了原型驱动开发(PDD)。

这是一种开发类型,您首先创建一个包含所需功能的原型。然后将原型中可重用的功能转移到通用项目中,最后在您的主应用程序项目中使用该功能。

插件架构非常适合 PDD。事实上,主项目通常不静态依赖于插件(而是动态加载)。这对于运行时灵活性很方便,但对于开发则不然。

请看 Prototypes/AuthenticationPluginTest 文件夹下的 AuthenticationPluginTest.sln 解决方案。

它只包含与认证相关的插件和 MockAuthentication 服务。

但现在主项目依赖于 AuthenticationViewPluginAuthenticationViewModelPluginMockAuthentication 项目。

这将允许您只重新编译一次,而不是单独重新编译插件和主项目。此外,它将使项目轻便得多,因为您只需处理三个插件(视图模型、视图和服务),而不是应用程序中的所有插件)。此外,它将避免处理可能由于某些动态程序集未按预期更改而导致的错误。

通常,遵循 PDD,可以首先在原型的主项目中创建认证插件功能。然后将视图模型移到 AuthenticationViewModelPlugin,并将视图移到 AuthenticationViewPlugin 项目,主项目原型依赖于这些项目。

最后,在完善功能并确保其正常工作后,您可以将插件程序集设置为复制到 plugin 文件夹,并在另一个原型或主应用程序中将它们作为动态加载的插件进行测试。

此外,当您需要修改或调试插件时,您可以在插件静态加载的原型中进行操作,并且修改将自动适用于动态加载插件的项目。

历史

  • 2022 年 2 月 21 日:初始版本
© . All rights reserved.