使用 MEF、WPF 和 MVVM 构建可扩展应用程序






4.88/5 (44投票s)
本文献给所有对如何使用 WPF 和模型-视图-模型模式构建可扩展应用程序感兴趣的人。

引言
本文介绍如何使用 模型-视图-模型模式 编写 WPF 应用程序,并使其可扩展,以便您(或第三方)可以通过其他功能进行扩展。
背景
今年早些时候,我着手为家庭自动化领域编写一个类似 IDE 的应用程序。我有三个要求:
- 它必须可由第三方扩展
- 它必须使用 WPF 和模型-视图-模型模式(主要是因为我想学习它)
- 我将根据 GPL 发布它,因此所有代码都必须兼容 GPL。
当我找到 SharpDevelop Core 时,我非常兴奋。这涵盖了上述第 1 点和第 3 点,但只有最新版本 (4) 是用 WPF 编写的,而且它不使用 MVVM 模式。我很失望,但我花了许多个长夜深入研究 SharpDevelop 的代码,以了解可扩展性部分是如何工作的。我怎么强调都不为过,这个优秀的项目的启发有多大,我强烈推荐这本书 Dissecting a C# Application: Inside SharpDevelop,它解释了 SharpDevelop 团队是如何编写这个应用程序的。
将 SharpDevelop 剥离到基本仍然是一个选择,但我希望彻底。我开始寻找其他可扩展性框架。我在 .NET Framework 中找到了 System.Addin。这是一个用于可扩展性的非常稳健的框架,但它也很复杂。它为每个可扩展性点都有一个“7 阶段管道”,仅供您参考。如果我是一个团队,正在编写像 SAP 这样的大型企业应用程序,那么我希望我们使用类似 System.Addin
的东西。
然后,我大约在同一时间偶然发现了 Mono.Addins 和 托管可扩展性框架 (MEF)。我都很喜欢,但我最终选择 MEF 是有两个原因:
- MEF 将成为 .NET 4.0 的一部分,并且在可能的情况下,我更愿意尽可能地建立在现有的系统库之上。
- 在 MEF 中,您可以使用代码中的属性完成所有操作,但在 Mono.Addins 中,某些功能只能通过清单文件使用。
SoapBox Core
我将构建自己的类似 IDE 的可扩展应用程序所需的一切都放在一个我命名为 SoapBox Core 的框架中(SoapBox 只是对“自由如言论自由,而非免费酒”的致敬)。它已根据 LGPL 许可证开源,因此您可以在专有应用程序中使用它。该框架由以下组件组成:
- 宿主 (Host):引导您的应用程序,加载所有扩展,并显示主窗口。
- 日志记录 (Logging):包含流行 NLog 日志框架的包装器(或者您可以将其替换为您喜欢的日志记录库)。
- 工作区 (Workbench):提供一个主应用程序窗口,其中包含可扩展的主菜单、可扩展的工具栏托盘和可扩展的状态栏。
- 布局 (Layout):提供一个“类似 IDE”的布局管理器(AvalonDock 的包装器),它扩展了工作区,以提供标签式文档窗口和可停靠(或浮动)的工具窗口。
- 选项 (Options):通过可扩展的选项对话框扩展工作区,以便您的所有应用程序扩展都可以在一个地方提供设置和配置。
- 竞技场 (Arena):一个内置的 2D 物理模拟器(Physics2D.Net 的包装器),它允许您构建一个遵循重力、质量、速度和碰撞等规则的动态对象 2D 环境。
当您单独运行 SoapBox Core 而没有任何扩展时,您会得到类似这样的结果:

无聊?是的。但它是一个功能齐全的应用程序,而您看不到的是一个工具栏托盘、一个状态栏和一个菜单,它们正等待您接入您的扩展。
在 SoapBox Core 上构建应用程序
每个人都使用文本编辑器作为演示应用程序。我想既然我已经有了 2D 物理引擎,为什么不做一个更动态的东西呢?因此,演示应用程序是一个简单的弹球游戏。首先,我将解释弹球演示是如何作为 SoapBox Core 的扩展编写的,然后我将向您展示如何使用“高分”插件扩展弹球演示。
创建 SoapBox.Demo.PinBall 作为插件
我建议使用以下文件夹结构。如果您下载了源代码,它就是这样组织的:
- AvalonDock
- (AvalonDock 项目放在这里)
- NLog
- (NLog 项目放在这里)
- Physics2D
- (Physics2D 项目放在这里)
- 参考文献
- (像 MEF 库这样的 DLL 放在这里)
- SoapBox
- SoapBox.Core
- (所有 SoapBox.Core 项目都放在这里,每个项目都在自己的文件夹中)
- YourNamespace
- YourSubNamespace1
- (您的项目放在这里,每个项目都在自己的文件夹中)
- YourSubNamespace2
- (或放在这里)
- bin
- (`SoapBox.Core` 和您的项目都将编译到这里,以便它们可以相互找到)
以下是创建新的 Visual Studio 解决方案和 SoapBox Core 插件项目所需的步骤:
- 在根目录中为您的项目创建一个 Visual Studio 解决方案。(演示是 SoapBox.Demo.sln。)
- 您需要在此解决方案中包含所有 SoapBox.Core 项目。我建议将它们放在 SoapBox\SoapBox.Core 解决方案文件夹中。确保 SoapBox.Core.Host 是启动项目。
- SoapBox Core 将具有对 AvalonDock、NLog 和 Physics2D 的项目引用,因此您也需要将它们包含在您的解决方案中。
- 我建议为您的项目创建解决方案文件夹。首先,为 YourNamespace 创建一个顶级文件夹,然后为每个 YourSubNamespace 创建子文件夹。
- 创建一个新的 WPF 用户控件项目,并为其命名,例如 YourNameSpace.YourSubNamespace.AddInName。在位置框中,指定 \YourNameSpace\YourSubNamespace 目录。这将在此目录下方创建一个新文件夹,并将您的新项目放在其中。(演示有一个名为
SoapBox.Demo.PinBall
的项目。) - 您的新项目将有一个自动创建的用户控件,名为
UserControl1
。您可以删除它。 - 添加对 \References\System.ComponentModel.Composition.dll 的引用。
- 编辑此新项目的项目属性,然后转到“生成”选项卡。将“输出路径”更改为 ..\..\..\bin\,以便 DLL 放置在与 Host 可执行文件相同的 bin 目录中。
- 为您的新项目添加对
SoapBox.Core.Contracts
的项目引用。这使您可以访问添加菜单项、工具栏和工具栏项、状态栏、选项面板、文档和工具面板到工作区以及获取日志记录组件引用的接口和帮助类。 - 如果您想构建基于 Arena 模块(2D 模拟器)的内容,您还需要对
SoapBox.Core.Arena
的项目引用,但这只是可选的。(演示使用了此模块。) - 构建项目后,您应该会在 \bin 目录中看到新的插件 DLL。当 SoapBox.Core.Host.exe 运行时,它将扫描该 DLL 以查找扩展
SoapBox.Core
库模块的导出。当然,我们还没有编写任何内容,所以它什么也不会做……
文档和面板
SoapBox Core 的工作方式类似 IDE,您有“文档”(通常是可编辑的、位于窗口中间的内容)和“面板”(基本上是工具窗口,您可以让它们自由浮动,或将其停靠在工作区的侧面)。文档是任何实现 SoapBox.Core.IDocument
接口的对象,面板是任何实现 SoapBox.Core.IPad
接口的对象。在 MVVM 术语中,文档和面板对象都是“ViewModel”。您还可以定义一个 XAML 视图,它告诉 WPF 当它在视觉树中遇到文档或面板时,您希望如何渲染它们。SoapBox Core 提供了一些可以从中派生的帮助类,它们已经实现了这些接口:SoapBox.Core.AbstractDocument
和 SoapBox.Core.AbstractPad
。
恰好 Arena(2D 物理引擎)模块定义了一个 AbstractArena
类,它已经为我们实现了 IDocument
。事实上,AbstractArena
类已经为其定义了一个 DataTemplate
,它为我们提供了一个基本的视图。DataTemplate
将 Arena 渲染为 Canvas
,并将 2D 物理引擎中的对象渲染为 Canvas
上的相应位置的 PathGeometry
UI 元素(基于它们在物理引擎中计算出的“空间”位置)。因此,定义我们的第一个文档就像继承自 AbstractArena
并使用 MEF Export
属性告诉 Workbench
我们的存在一样简单。
using SoapBox.Core;
using System.ComponentModel.Composition;
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Workbench.Documents, typeof(IDocument))]
[Export(CompositionPoints.PinBall.PinBallTable, typeof(PinBallTable))]
[Document(Name = PinBallTable.DOCUMENT_NAME)]
public class PinBallTable : AbstractArena, IPartImportsSatisfiedNotification
{
public const string DOCUMENT_NAME = "PinBallTable";
public PinBallTable()
{
// IDocument properties
Name = DOCUMENT_NAME;
Title = Resources.Strings.Arena_PinBallTable_Title;
Gravity = new ArenaVector(0.0f, -800.0f);
Scale = 0.5f; // screen elements per physics unit
// Create 3 pinballs
PinBalls.Add(new PinBall(this, new Point(-100f, 0)));
PinBalls.Add(new PinBall(this, new Point(0, 0)));
PinBalls.Add(new PinBall(this, new Point(100f, 0)));
foreach (PinBall ball in PinBalls)
{
AddArenaBody(ball);
}
// Lots of other stuff removed here...
}
[Import(SoapBox.Core.Services.Logging.LoggingService, typeof(ILoggingService))]
private ILoggingService logger { get; set; }
[Import(SoapBox.Core.Services.Host.ExtensionService, typeof(IExtensionService))]
private IExtensionService extensionService { get; set; }
[ImportMany(ExtensionPoints.PinBall.GameOverCommands,
typeof(IExecutableCommand), AllowRecomposition=true)]
private IEnumerable<IExecutableCommand> gameOverCommands { get; set; }
private IList<IExecutableCommand> m_gameOverCommands = null;
public void OnImportsSatisfied()
{
m_gameOverCommands = extensionService.Sort(gameOverCommands);
}
// Lots of other stuff removed here...
public Collection<PinBall> PinBalls
{
get
{
return m_PinBalls;
}
}
private readonly Collection<PinBall> m_PinBalls =
new Collection<PinBall>();
}
}
这里发生了什么?首先,我们使用 MEF 将自己导出为 IDocument
类型,特别是针对合同名称 SoapBox.Core.ExtensionPoints.Workbench.Documents
。当宿主启动时,它首先查找一个导出自己为合同 SoapBox.Core.CompositionPoints.App.MainWindow
的 Window
。在我们的例子中,那就是 Workbench
。Workbench
构造函数导入一个文档列表。这意味着,当应用程序运行时,此类将被实例化并传递给 Workbench
的属性。MEF 中的这个过程称为组合 (Composition)。
我们还以不同的合同导出此类作为 PinBallTable
。这样,应用程序的其他部分就可以找到这个特定的扩展对象,而不仅仅是文档的集合。
在组合过程中,此类实际上导入了其他部分导出的对象。因此,我们定义了一些带有 Import
属性的属性。在我们的例子中,我们需要对日志记录服务的引用,所以其中一个导入是 [Import(SoapBox.Core.Services.Logging.LoggingService, typeof(ILoggingService))]
。现在,我们可以记录调试、错误和跟踪信息。
正如我之前提到的,我们也希望我们的弹球游戏是可扩展的。我们可以在项目中提供许多不同的扩展点。在这种情况下,我们导入了一个 IExecutableCommand
对象列表,我们将在游戏结束后执行它们(这就是高分插件如何接入的方式)。但是,在导入这些命令时,它们将按照 MEF 在组合过程中找到它们的顺序排列。SoapBox Core 提供了一个扩展服务,可以将扩展排序为您喜欢的顺序。要获取扩展服务的引用,我们使用此属性:[Import(SoapBox.Core.Services.Host.ExtensionService, typeof(IExtensionService))]
。然后,我们的类实现 IPartImportsSatisfiedNotification
,特别是 OnImportsSatisfied()
来排序扩展:m_gameOverCommands = extensionService.Sort(gameOverCommands);
。
如果您想知道它是如何排序的,我借鉴了 SharpDevelop 的一个想法。所有扩展都必须实现 SoapBox.Core.IExtension
。
namespace SoapBox.Core
{
public enum RelativeDirection
{
Before = 1,
After
}
public interface IExtension : IViewModel
{
string ID { get; }
string InsertRelativeToID { get; }
RelativeDirection BeforeOrAfter { get; }
}
}
有一个方便的 SoapBox.Core.AbstractExtension
,它为您实现了这个接口,如果您从中继承,您只需在扩展的构造函数中设置这些属性即可。如果您不关心排序顺序,则无需设置这些属性。要使用它们,请想象有三个正在导入的扩展(可能来自不同的 DLL):
ID
= "a"ID
= "b",InsertRelativeToID
= "a",BeforeOrAfter
=After
ID
= "c",InsertRelativeToID
= "b",BeforeOrAfter
=Before
在这种情况下,ExtensionService.Sort()
将按 a、c、b 的顺序排序它们。这适用于所有扩展,包括菜单项、工具栏、状态栏和选项对话框扩展,因此如果您想在“视图”和“工具”菜单之间插入一个新的主菜单项,您也可以这样做。
PinBallTable
类所做的最后一件事是向 Arena(2D 物理引擎)添加一些 PinBall
对象。正如您所见,这是通过调用 AddArenaBody
方法完成的。您可以使用此方法添加实现 SoapBox.Core.Arena.IArenaBody
的任何对象。以下是 PinBall
的外观:
namespace SoapBox.Demo.PinBall
{
public class PinBall : AbstractArenaDynamicBody
{
public const float PIN_BALL_RADIUS = 20.0f;
public PinBall(PinBallTable table, Point startingPoint)
{
Mass = 1.8f;
Friction = 0.0001f; // Between 0 and 1
Restitution = 0.5f; // Energy retained after a collision (0 to 1)
m_table = table;
InitialX = (float)startingPoint.X;
InitialY = (float)startingPoint.Y;
Sprite = new PinBallSprite();
}
private PinBallTable m_table = null;
// There's a bit more here, but I deleted it for simplicity
}
}
PinBall
类仅定义对象的物理属性,而不定义几何。这实际上是在一个名为 PinBallSprite
的单独类中定义的。
namespace SoapBox.Demo.PinBall
{
public class PinBallSprite : AbstractSprite
{
public PinBallSprite()
{
Geometry = new EllipseGeometry(new Point(0, 0),
PinBall.PIN_BALL_RADIUS, PinBall.PIN_BALL_RADIUS);
}
}
}
注意:看起来您可以使用的任何 Geometry
类,但实际上不能。您必须坚持使用简单的形状,如椭圆、矩形等,或者您可以使用 PathFigure
,但在这种情况下,您必须以逆时针方向定义图形的点。
所以我们还没有定义 PinBall
的样子。事实上,您不必这样做!如果您不为这个 ViewModel 定义视图,SoapBox Core 会提供一个默认视图,该视图会根据给定的 Geometry
将所有 AbstractSprite
对象渲染为实心黑色对象。这对于在担心它的外观之前弄清楚弹球游戏的物理原理非常有用。当然,最终您会希望球看起来像个球,所以我们实际上为此使用了一个 DataTemplate
。这是 PinBall
的视图:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SoapBox.Demo.PinBall"
xmlns:arena="clr-namespace:SoapBox.Core.Arena;assembly=SoapBox.Core.Arena"
x:Class="SoapBox.Demo.PinBall.PinBallView">
<!-- Make it a shiny ball -->
<DataTemplate DataType="{x:Type local:PinBallSprite}">
<Path Stroke="Black"
StrokeThickness="1"
Data="{Binding Path=Geometry}">
<Path.Fill>
<RadialGradientBrush GradientOrigin="0.33,0.33">
<GradientStop Offset="0" Color="White"/>
<GradientStop Offset="1" Color="Black"/>
</RadialGradientBrush>
</Path.Fill>
</Path>
</DataTemplate>
<!-- Orient the ball within the arena (doesn't rotate because it's a sphere and
we want the shiny spot in the same spot all the time) -->
<DataTemplate DataType="{x:Type local:PinBall}">
<ContentControl Content="{Binding Path=(arena:IArenaBody.Sprite)}">
<ContentControl.RenderTransform>
<TransformGroup>
<!-- scale it, including inverting the Y axis -->
<ScaleTransform ScaleX="{Binding State.Scale}"
ScaleY="{Binding State.Scale}"/>
<!-- offset it by it's physical position -->
<TranslateTransform X="{Binding State.ScreenX}"
Y="{Binding State.ScreenY}" />
</TransformGroup>
</ContentControl.RenderTransform>
</ContentControl>
</DataTemplate>
</ResourceDictionary>
通常,您只需要第一个 DataTemplate
(用于精灵),并且 AbstractArenaBody
的默认 DataTemplate
会根据其在 Arena 中的位置负责缩放、平移和旋转该对象。但是,在这种情况下,我们定义了一个带有闪亮斑点的球,并且我们不希望球旋转时闪亮斑点也旋转,因此我们用一个专门针对 PinBall
的视图覆盖了默认的 AbstractArenaBody
视图。这与默认视图相同,但它没有旋转变换(我们利用了球……嗯,是圆形的这一事实)。
将视图应用于 ViewModel
因此,我们在 ResourceDictionary
中定义了几个 DataTemplate
。通常,我们需要将此 ResourceDictionary
的引用包含在合并的应用程序字典中。但如果宿主事先不知道扩展的任何信息,我们就无法这样做。这就是 MEF 发挥作用的地方。宿主在启动时导入一组 ResourceDictionary
扩展,并手动将它们插入到应用程序资源中。
[ImportMany(ExtensionPoints.Host.Styles,
typeof(ResourceDictionary), AllowRecomposition=true)]
private IEnumerable<ResourceDictionary> Styles { get; set; }
[ImportMany(ExtensionPoints.Host.Views,
typeof(ResourceDictionary), AllowRecomposition=true)]
private IEnumerable<ResourceDictionary> Views { get; set; }
// Later in the OnImportsSatisfied method...
// Add the imported resource dictionaries
// to the application resources
foreach (ResourceDictionary r in Styles)
{
this.Resources.MergedDictionaries.Add(r);
}
foreach (ResourceDictionary r in Views)
{
this.Resources.MergedDictionaries.Add(r);
}
……但是我们如何“导出”PinBallView
ResourceDictionary
以便宿主找到它呢?事实证明,您可以手动为 ResourceDictionary
添加代码隐藏。只需添加一个与您的 .xaml 文件同名但扩展名为 .cs 的文件。例如,PinBallView
的 ResourceDictionary
是 PinBallView.xaml。以下是 PinBallView.xaml.cs 的内容:
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Host.Views, typeof(ResourceDictionary))]
public partial class PinBallView : ResourceDictionary
{
public PinBallView()
{
InitializeComponent();
}
}
}
这个模式在 SoapBox Core 和弹球演示中反复出现。这就是将视图应用于文档、面板、选项对话框面板或任何其他 ViewModel 的方法。
另外请注意,`Styles` 和 `Views` 的 `AllowRecomposition` 参数设置为 `true`。这意味着这些属性支持重新组合。在应用程序启动后,可以发现新的扩展,可以将扩展添加到部件目录,然后会发生重新组合。这意味着 MEF 将再次设置这些属性,并调用 `OnImportsSatisfied`。目前 SoapBox Core 没有在执行期间添加新扩展的机制,但将来会有的。
一切都是 ViewModel
在 SoapBox Core 中,(几乎)每个类都是 ViewModel。您可能会注意到我没有为所有 ViewModel 类使用标准的 ViewModel 后缀。我发现这变得太冗长了。取而代之的是,任何实现 SoapBox.Core.IViewModel
的东西都被认为是 ViewModel。IViewModel
只是 INotifyPropertyChanged
的代理。
显示文档
现在我们已经完成了创建要显示的文档的所有工作,需要有人告诉 LayoutManager
显示它。最简单的方法就是在启动时显示它。宿主导入一个 IExecutableCommand
扩展列表,这些扩展可以在应用程序启动时执行,我们可以利用这一点来显示我们的文档。
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Host.StartupCommands,
typeof(IExecutableCommand))]
class StartupCommand : AbstractExtension, IExecutableCommand
{
[Import(SoapBox.Core.CompositionPoints.Host.MainWindow, typeof(Window))]
private Lazy<Window> mainWindow { get; set; }
[Import(SoapBox.Core.Services.Layout.LayoutManager, typeof(ILayoutManager))]
private Lazy<ILayoutManager> layoutManager { get; set; }
[Import(CompositionPoints.PinBall.PinBallTable, typeof(PinBallTable))]
private Lazy<PinBallTable> pinBallTable { get; set; }
public void Run(params object[] args)
{
// Customize the Workbench title while we're here
mainWindow.Value.Title = Resources.Strings.Workbench_Title;
// Show the Pin Ball Table
layoutManager.Value.ShowDocument(pinBallTable.Value);
}
}
}
请注意此处使用了“惰性”导入。在这种情况下,导入的对象直到访问 .Value
属性时才实际实例化。由于文档和面板的实例化可能很昂贵,因此所有这些对象的导入都使用惰性导入,以避免在实际需要之前实例化它们。
现在,如果您运行应用程序,将显示 PinBallTable
。
扩展菜单
当然,用户可以关闭文档,而无需重新启动应用程序,他们将无法再次显示它。为了与其他 Microsoft Windows 应用程序保持一致,我们应该在“视图”菜单中添加一个项,让用户自行显示弹球桌。
namespace SoapBox.Demo.PinBall
{
/// <summary>
/// Add a menu item to the view menu to launch the PinBallTable "document"
/// </summary>
[Export(SoapBox.Core.ExtensionPoints.Workbench.MainMenu.ViewMenu, typeof(IMenuItem))]
class ViewMenuPinBallTable : AbstractMenuItem
{
public ViewMenuPinBallTable()
{
ID = "PinBallTable";
InsertRelativeToID = "ToolBars";
BeforeOrAfter = RelativeDirection.Before;
Header = Resources.Strings.Workbench_MainMenu_View_PinBallTable;
}
[Import(SoapBox.Core.Services.Layout.LayoutManager, typeof(ILayoutManager))]
private Lazy<ILayoutManager> layoutManager { get; set; }
[Import(CompositionPoints.PinBall.PinBallTable)]
private Lazy<PinBallTable> table { get; set; }
protected override void Run()
{
base.Run();
layoutManager.Value.ShowDocument(table.Value);
}
}
}
正如您所看到的,我们只需要继承自 AbstractMenuItem
并将类导出为合同 SoapBox.Core.ExtensionPoints.Workbench.MainMenu.ViewMenu
的 IMenuItem
。您可能已经注意到,我们将此菜单项插入到另一个名为“工具栏”的项之前。SoapBox Core 实际上定义了一个名为“工具栏”的视图菜单项,它显示系统中的所有工具栏扩展(实际上允许用户启用或禁用各个工具栏)。由于目前没有定义工具栏,因此您看不到它。
扩展状态栏
扩展状态栏的工作方式相同,只是状态栏中有许多不同类型的控件,如标签、按钮、单选按钮、分隔符等。每种控件都有一个不同的抽象类。以下是定义状态栏中标签的方法:
[Export(SoapBox.Core.ExtensionPoints.Workbench.StatusBar, typeof(IStatusBarItem))]
public class MyLabel : AbstractStatusBarLabel
{
public MyLabel()
{
ID = "MyLabel";
Text = Resources.Strings.Workbench_StatusBar_MyLabel;
}
}
添加工具栏
工具栏本身必须导入要显示的工具栏项。这是创建工具栏的方法:
[Export(SoapBox.Core.ExtensionPoints.Workbench.ToolBars, typeof(IToolBar))]
public class MyToolBar : AbstractToolBar, IPartImportsSatisfiedNotification
{
public MyToolBar()
{
Name = Resources.Strings.MyToolBar_Name;
Visible = true; // default to visible
}
[Import(SoapBox.Core.Services.Host.ExtensionService, typeof(IExtensionService))]
private IExtensionService extensionService { get; set; }
[ImportMany(ExtensionPoints.Workbench.ToolBars.MyToolBar,
typeof(IToolBarItem), AllowRecomposition=true)]
private IEnumerable<IToolBarItem> items { get; set; }
public void OnImportsSatisfied()
{
Items = extensionService.Sort(items);
}
}
这将把一个工具栏添加到“视图”->“工具栏”菜单中,并允许用户通过可勾选的菜单项控制其可见性。但是,然后您需要像这样添加项到工具栏,例如此按钮:
[Export(ExtensionPoints.Workbench.ToolBars.MyToolBar, typeof(IToolBarItem))]
public class MyToolBarButton : AbstractToolBarButton
{
public MyToolBarButton()
{
ID = "MyToolBarButton";
ToolTip = Resources.Strings.MyToolBarButton_Tooltip;
SetIconFromBitmap(Resources.Images.MyToolBarButton_Icon);
}
protected override void Run()
{
// Whatever you want to happen when the user clicks the button
}
}
扩展扩展
所以我完成了弹球游戏,它记分并有级别,但游戏结束后,它只是开始新游戏。我想创建一个插件来扩展弹球游戏并跟踪高分,这会很酷。
我按照与之前相同的程序创建了一个完全独立的名为 SoapBox.Demo.HighScores
的项目。我创建了一个名为 HighScores
的新面板,它继承自 SoapBox.Core.AbstractPad
。它负责加载、显示和保存高分。然后,我不得不编写一个 IExecutableCommand
扩展,它挂接到弹球游戏上的 GameOverCommands
可扩展点。
namespace SoapBox.Demo.HighScores
{
/// <summary>
/// This extends the basic pinball table by saving the score and
/// level attained to a log when the game is over.
/// </summary>
[Export(SoapBox.Demo.PinBall.ExtensionPoints.PinBall.GameOverCommands,
typeof(IExecutableCommand))]
class GameOverCommand : AbstractExtension, IExecutableCommand
{
[Import(CompositionPoints.Workbench.Pads.HighScores, typeof(HighScores))]
private Lazy<HighScores> highScores { get; set; }
/// <summary>
/// Registers the high scores with the HighScores ViewModel
/// </summary>
/// <param name="args"></param>
public void Run(params object[] args)
{
// arg 0 = PinBallTable
if (args.Length >= 1)
{
PinBallTable table = args[0] as PinBallTable;
if (table != null)
{
highScores.Value.LogNewHighScore(String.Empty,
table.Score, table.Level);
}
}
}
}
}
我还向“视图”菜单添加了另一个菜单项来显示 HighScores
面板。这就是全部。
SoapBox Core 的最新版本
关注点
- 请查看
SoapBox.Core.Workbench
项目中的 WorkBenchView.xaml,了解如何使用 MVVM 模式实现 WPF 菜单,包括菜单分隔符。 SoapBox.Core.Contracts
有一个名为NotifyPropertyChangedHelper
的帮助性static
类,它允许您在不使用硬编码的属性名字符串的情况下实现INotifyPropertyChanged
。- 任何实现
SoapBox.Core.IControl
的东西(几乎所有菜单项、状态栏项和工具栏项都是如此)都有一个VisibleCondition
属性。它可以设置为任何实现SoapBox.Core.ICondition
的东西,但我推荐使用SoapBox.Core.ConcreteCondition
。一个“条件”只是一个布尔条件的抽象,一个部分可以导出它,而其他部分可以导入它。 - 任何继承自
AbstractCommandControl
(通常是按钮)的东西都有一个EnableCondition
属性来控制按钮是否启用。如果未启用,它会自动将图标更改为灰度。 - 查看
PinBallOptionsItem
和PinBallOptionsPad
,了解如何扩展选项对话框并将可编辑选项存储在用户设置中。
历史
- 2009 年 11 月 7 日:文章发布(基于 SoapBox Core v2009.11.04)
- 2009 年 11 月 12 日:文章修改(基于 SoapBox Core v2009.11.11)