使用 MEF 公开 Silverlight MVVM 应用程序中的接口
许多开发人员可能都将 Silverlight 视为以 Web 为中心的技术,但实际上,它已经成为构建任何应用程序的优秀平台。 Silverlight 本身就支持许多概念,例如数据绑定、值转换器、导航、浏览器外操作和 COM 互操作,因此它可以相对直观简便地创建任何种类的应用程序。 我说的是“任何种类”,其中当然也包括企业级应用程序。
利用 Model-View-ViewModel (MVVM) 模式来创建 Silverlight 应用程序,这使您除了能够使用 Silverlight 中已有的功能以外,还能获得更强的可维护性、可测试性以及用户界面与其背后的逻辑之间的可分离性。 当然,您不需要完全靠自己来解决所有问题。 有很多信息和工具可以帮助您入门。 例如,MVVM Light Toolkit (mvvmlight.codeplex.com) 是一款轻量级框架,用于通过 Silverlight 和 Windows Presentation Foundation (WPF) 来实现 MVVM;借助代码生成,WCF RIA 服务 (silverlight.net/getstarted/riaservices) 可帮助您轻松访问 Windows Communication Foundation (WCF) 服务和数据库。
利用托管可扩展性框架 (mef.codeplex.com)(简称为 MEF),您可以进一步扩展 Silverlight 应用程序。 此框架提供了探测功能,可利用组件和复合创建可扩展的应用程序。
在本文的其余部分,我将介绍如何使用 MEF 来集中管理 View 和 ViewModel 创建工作。 当您获得此工具之后,所能做到的就不只是将 ViewModel 放入 View 的 DataContext 中了。 所有这些都将通过自定义内置的 Silverlight 导航来实现。 当用户导航到给定的 URL 时,MEF 会拦截此请求,查看路线(有点类似于 ASP.NET MVC),查找匹配的 View 和 ViewModel,通知 ViewModel 发生了什么,然后显示 View。
Getting Started with MEF
由于 MEF 是将本示例中所有部分都连接起来的引擎,因此最好从它开始。 如果您还不熟悉 MEF,请先阅读 Glenn Block 的文章“在 .NET 4 中使用托管可扩展性框架构建可组合的应用程序”,该文章发表在 MSDN 杂志 的 2010 年 2 月号上 (msdn.microsoft.com/magazine/ee291628)。
首先,您需要处理 App 类的 Startup 事件,以便在应用程序启动时正确配置 MEF:
private void OnStart(object sender, StartupEventArgs e) {
// Initialize the container using a deployment catalog.
var catalog = new DeploymentCatalog();
var container = CompositionHost.Initialize(catalog);
// Export the container as singleton.
container.ComposeExportedValue<CompositionContainer>(container);
// Make sure the MainView is imported.
CompositionInitializer.SatisfyImports(this);
}
部署目录确保了所有程序集都被扫描以便导出,然后用于创建 CompositionContainer。 由于导航稍后还需要此容器来执行某些工作,因此务必将此容器的实例注册为导出的值。 这样,就可以随时根据需要导入同一个容器。
另一个选择是将容器保存为静态对象,但是这将在类之间创建紧耦合,而这并不是一种好的做法。
扩展 Silverlight 导航
Silverlight 导航应用程序是一个 Visual Studio 模板。利用该模板,您可以快速创建应用程序,使用承载了内容的 Frame 来支持导航。 Frame 所带来的最大好处是它可以与浏览器的“后退”和“前进”按钮集成,并且支持深度链接。 请看以下代码:
<navigation:Frame x:Name="ContentFrame"
Style="{StaticResource ContentFrameStyle}"
Source="Customers"
NavigationFailed="OnNavigationFailed">
<i:Interaction.Behaviors>
<fw:CompositionNavigationBehavior />
</i:Interaction.Behaviors>
</navigation:Frame>
这只是一个普通的框架,它从导航到 Customers 开始。 正如您看到的,此 Frame 不包含 UriMapper(您可以在其中将 Customers 链接到一个 XAML 文件,例如 /Views/Customers.aspx)。 它唯一包含的内容是我的自定义行为 CompositionNavigationBehavior。 利用行为(来自 System.Windows.Interactivity 程序集),您可以扩展现有的控件,例如本例中的 Frame。
我们来看一看这个 CompositionNavigationBehavior 都做些什么。 首先,您可以看到,由于 Import 特性的缘故,该行为需要 CompositionContainer 和 CompositionNavigationLoader(后文将详细介绍)。 随后,构造函数将使用 CompositionInitializer 的 SatisfyImports 方法强制执行 Import。 请注意,仅当您别无选择时,才应该使用此方法,因为它实际上会将您的代码与 MEF 紧密耦合到一起。
CompositionNavigationBehavior
public class CompositionNavigationBehavior : Behavior<Frame> {
private bool processed;
[Import]
public CompositionContainer Container {
get; set;
}
[Import]
public CompositionNavigationContentLoader Loader {
get; set;
}
public CompositionNavigationBehavior() {
if (!DesignerProperties.IsInDesignTool)
CompositionInitializer.SatisfyImports(this);
}
protected override void OnAttached() {
base.OnAttached();
if (!processed) {
this.RegisterNavigationService();
this.SetContentLoader();
processed = true;
}
}
private void RegisterNavigationService() {
var frame = AssociatedObject;
var svc = new NavigationService(frame);
Container.ComposeExportedValue<INavigationService>(svc);
}
private void SetContentLoader() {
var frame = AssociatedObject;
frame.ContentLoader = Loader;
frame.JournalOwnership = JournalOwnership.Automatic;
}
}
连接 Frame 时,将创建一个 NavigationService,并用其包装 Frame。 使用 ComposeExportedValue 时,此包装的实例会在容器内注册。
在创建容器时,此容器的实例也会在其自身内注册。 因此,CompositionContainer 的 Import 总是能为您提供相同的对象;这就是我在 App 类的 Startup 事件中使用 ComposeExportedValue 的原因。 现在,CompositionNavigationBehavior 使用 Import 特性请求 CompositionContainer,并且将在 SatisfyImports 运行后获得它。
在注册 INavigationService 的实例时,会发生同样的情况。 现在,就可以从应用程序内的任何地方请求 INavigationService(它包装了 Frame)了。 不需要将 ViewModel 耦合到框架,您就能访问以下内容:
public interface INavigationService {
void Navigate(string path);
void Navigate(string path, params object[] args);
}
现在,假设您有一个 ViewModel 显示您的所有客户,并且此 ViewModel 应该能够打开某个具体的客户。这可以通过以下代码完成:
[Import]
public INavigationService NavigationService {
get; set;
}
private void OnOpenCustomer() {
NavigationService.Navigate(
"Customer/{0}", SelectedCustomer.Id);
}
但是在继续之前,首先要讨论一下 CompositionNavigationBehavior 中的 SetContentLoader 方法。它用于更改 Frame 的 ContentLoader。这是在 Silverlight 中支持可扩展性的一个完美例子。您可以提供自己的 ContentLoader(实现 INavigationContentLoader 接口),从而真正提供一些内容以便在 Frame 中显示。
现在,您可以看到各个方面如何逐步到位,后面的主题(扩展 MEF)也将变得清晰起来。
继续扩展 MEF
这里的目标是:您可以导航到特定路径(从 ViewModel 或您的浏览器地址栏),然后 CompositionNavigationLoader 就会完成其余的工作。 它应该分析 URI,查找匹配的 ViewModel 和匹配的 View,然后将两者组合。
通常,您需要编写类似以下的代码:
[Export(typeof(IMainViewModel))]
public class MainViewModel
在本例中,将 Export 特性与一些额外配置(称为元数据)结合使用,会相当有趣。 图 2 显示了一个元数据特性示例。
创建 ViewModelExportAttribute
[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewModelExportAttribute :
ExportAttribute, IViewModelMetadata {
public Type ViewModelContract { get; set; }
public string NavigationPath { get; set; }
public string Key { get; set; }
public ViewModelExportAttribute(Type viewModelContract,
string navigationPath) : base(typeof(IViewModel)) {
this.NavigationPath = navigationPath;
this.ViewModelContract = viewModelContract;
if (NavigationPath != null &&
NavigationPath.Contains("/")) {
// Split the path to get the arguments.
var split = NavigationPath.Split(new char[] { '/' },
StringSplitOptions.RemoveEmptyEntries);
// Get the key.
Key = split[0];
}
else {
// No arguments, use the whole key.
Key = NavigationPath;
}
}
}
此特性没有任何特殊的地方。 除了 ViewModel 接口以外,它还允许您定义导航路径,例如 Customer/{Id}。 然后,它将使用 Customer 作为 Key,使用 {Id} 作为参数之一,对此路径进行处理。 下面是如何使用此特性的示例:
[ViewModelExport(typeof(ICustomerDetailViewModel), "Customer/{id}")]
public class CustomerDetailViewModel : ICustomerDetailViewModel
在继续之前,有几点重要事项需要注意。 首先,您的特性应该使用 [MetadataAttribute] 进行修饰,才能正常工作。 其次,您的特性应该实现一个接口,其中包含您希望公开为元数据的值。 最后,注意特性的构造函数,它会向基构造函数传递类型。 用此特性修饰的类将使用这种类型公开。 在我的示例中,此类型是 IViewModel。
它用于导出 ViewModel。 如果您希望在某些地方导入它们,应该编写类似以下的代码:
[ImportMany(typeof(IViewModel))]
public List<Lazy<IViewModel, IViewModelMetadata>> ViewModels {
get;
set;
}
这将为您提供一个列表,其中包含所有导出的 ViewModels 及其相应的元数据,因此您可以枚举该列表,并从中选出您感兴趣的项(基于元数据)。 事实上,Lazy 对象将确保只有您感兴趣的项才会真正实例化。
View 将需要类似以下的内容:
[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewExportAttribute :
ExportAttribute, IViewMetadata {
public Type ViewModelContract { get; set; }
public ViewExportAttribute() : base(typeof(IView)) {
}
}
此示例中也没有什么特殊的地方。 利用此特性,您可以设置 View 应该链接到的 ViewModel 的合约。
以下是 AboutView 的示例:
[ViewExport(ViewModelContract = typeof(IAboutViewModel))]
public partial class AboutView : Page, IView {
public AboutView() {
InitializeComponent();
}
}
自定义 INavigationContentLoader
现在,整体框架已经搭建好,我们来看一看如何控制用户导航时加载的内容。 若要创建自定义的内容加载器,需要实现以下接口:
public interface INavigationContentLoader {
IAsyncResult BeginLoad(Uri targetUri, Uri currentUri,
AsyncCallback userCallback, object asyncState);
void CancelLoad(IAsyncResult asyncResult);
bool CanLoad(Uri targetUri, Uri currentUri);
LoadResult EndLoad(IAsyncResult asyncResult);
}
此接口中最重要的部分是 BeginLoad 方法,因为此方法应该返回一个 AsyncResult,其中包含将要显示在 Frame 中的内容项。 图 3 显示了自定义 INavigationContentLoader 的具体实现。
自定义 INavigationContentLoader
[Export] public class CompositionNavigationContentLoader :
INavigationContentLoader {
[ImportMany(typeof(IView))]
public IEnumerable<ExportFactory<IView, IViewMetadata>>
ViewExports { get; set; }
[ImportMany(typeof(IViewModel))]
public IEnumerable<ExportFactory<IViewModel, IViewModelMetadata>>
ViewModelExports { get; set; }
public bool CanLoad(Uri targetUri, Uri currentUri) {
return true;
}
public void CancelLoad(IAsyncResult asyncResult) {
return;
}
public IAsyncResult BeginLoad(Uri targetUri, Uri currentUri,
AsyncCallback userCallback, object asyncState) {
// Convert to a dummy relative Uri so we can access the host.
var relativeUri = new Uri("http://" + targetUri.OriginalString,
UriKind.Absolute);
// Get the factory for the ViewModel.
var viewModelMapping = ViewModelExports.FirstOrDefault(o =>
o.Metadata.Key.Equals(relativeUri.Host,
StringComparison.OrdinalIgnoreCase));
if (viewModelMapping == null)
throw new InvalidOperationException(
String.Format("Unable to navigate to: {0}. "
+
"Could not locate the ViewModel.",
targetUri.OriginalString));
// Get the factory for the View.
var viewMapping = ViewExports.FirstOrDefault(o =>
o.Metadata.ViewModelContract ==
viewModelMapping.Metadata.ViewModelContract);
if (viewMapping == null)
throw new InvalidOperationException(
String.Format("Unable to navigate to: {0}. "
+
"Could not locate the View.",
targetUri.OriginalString));
// Resolve both the View and the ViewModel.
var viewFactory = viewMapping.CreateExport();
var view = viewFactory.Value as Control;
var viewModelFactory = viewModelMapping.CreateExport();
var viewModel = viewModelFactory.Value as IViewModel;
// Attach ViewModel to View.
view.DataContext = viewModel;
viewModel.OnLoaded();
// Get navigation values.
var values = viewModelMapping.Metadata.GetArgumentValues(targetUri);
viewModel.OnNavigated(values);
if (view is Page) {
Page page = view as Page;
page.Title = viewModel.GetTitle();
}
else if (view is ChildWindow) {
ChildWindow window = view as ChildWindow;
window.Title = viewModel.GetTitle();
}
// Do not navigate if it's a ChildWindow.
if (view is ChildWindow) {
ProcessChildWindow(view as ChildWindow, viewModel);
return null;
}
else {
// Navigate because it's a Control.
var result = new CompositionNavigationAsyncResult(asyncState, view);
userCallback(result);
return result;
}
}
private void ProcessChildWindow(ChildWindow window,
IViewModel viewModel) {
// Close the ChildWindow if the ViewModel requests it.
var closableViewModel = viewModel as IClosableViewModel;
if (closableViewModel != null) {
closableViewModel.CloseView += (s, e) => { window.Close(); };
}
// Show the window.
window.Show();
}
public LoadResult EndLoad(IAsyncResult asyncResult) {
return new LoadResult((asyncResult as
CompositionNavigationAsyncResult).Result);
}
}
正如您看到的,此类中执行了很多操作,但它实际上相当简单。 首先,请注意 Export 特性。 要想在 CompositionNavigationBehavior 中导入此类,此特性是必需的。
此类中最重要的部分是 ViewExports 和 ViewModelExports 属性。 这些枚举包含 View 和 ViewModel 的所有导出内容,包括其元数据。 我没有使用 Lazy 对象,而是使用了 ExportFactory。 两者的区别非常大! 两个类都只有在必要时才会实例化对象,但区别是:如果使用 Lazy 类,您只能为该对象创建一个实例。 而 ExportFactory 类(按照 Factory 模式命名)允许您随时根据需要,请求为该类型的对象创建一个新实例。
最后,还要注意 BeginLoad 方法。 奇妙的事情就要发生了。 此方法将向 Frame 提供内容,以便在导航到给定的 URI 之后显示出来。
创建和处理对象
假设您让 Frame 导航到 Customers。 这就是您将在 BeginLoad 方法的 targetUri 参数中发现的内容。 一旦您获得了此内容,就可以开始工作了。
要做的第一件事是找到正确的 ViewModel。 ViewModelExports 属性是一个枚举,它包含所有导出项及其元数据。 使用 lambda 表达式,您可以根据其键值找到正确的 ViewModel。 请记住以下几点:
[ViewModelExport(typeof(ICustomersViewModel), "Customers")]
public class CustomersViewModel : ContosoViewModelBase, ICustomersViewModel
好吧,假设您导航到 Customers。 然后,以下代码将找到正确的 ViewModel:
var viewModelMapping = ViewModelExports.FirstOrDefault(o => o.Metadata.Key.Equals("Customers", StringComparison.OrdinalIgnoreCase));
一旦定位 ExportFactory 之后,也会为 View 进行同样的操作。 但是,您不需要查找导航键,而是要按照 ViewModelExportAttribute 和 ViewModelAttribute 中的定义查找 ViewModelContract:
[ViewExport(ViewModelContract = typeof(IAboutViewModel))
public partial class AboutView : Page
一旦找到了这两个 ExportFactory,最困难的部分就完成了。 现在,CreateExport 方法允许您为 View 和 ViewModel 创建新实例:
var viewFactory = viewMapping.CreateExport();
var view = viewFactory.Value as Control;
var viewModelFactory = viewModelMapping.CreateExport();
var viewModel = viewModelFactory.Value as IViewModel;
在创建 View 和 ViewModel 之后,ViewModel 将存储到 View 的 DataContext 中,从而开始必要的数据绑定。 并调用 ViewModel 的 OnLoaded 方法,通知 ViewModel:所有繁重的工作都已完成,所有 Import(如果存在)都已导入。
当您使用 Import 和 ImportMany 特性时,不应低估这最后一步的重要性。 在许多情况下,您都希望在创建 ViewModel 时执行某些操作,但只有在所有内容都正确加载时才能这么做。 如果您使用了 ImportingConstructor,您肯定知道何时导入了所有 Import(就是调用该构造函数的时候)。 但是在处理 Import/ImportMany 特性时,您应该开始在所有属性中编写代码来设置标记,以便了解何时导入了所有属性。
在本例中,OnLoaded 方法为您解决了这个问题。
向 ViewModel 传递参数
看一下 IViewModel 接口,并且要注意 OnNavigated 方法:
public interface IViewModel {
void OnLoaded();
void OnNavigated(NavigationArguments args);
string GetTitle();
}
例如,当您导航到 Customers/1 时,系统会分析此路径,并在 NavigationArguments 类(这不过是一个具有 GetInt、GetString 等额外方法的 Dictionary)中组合参数。 由于每个 ViewModel 都必须实现 IViewModel 接口,因此可以在解析 ViewModel 之后调用 OnNavigated:
// Get navigation values.
var values = viewModelMapping.Metadata.GetArgumentValues(targetUri); viewModel.OnNavigated(values);
当 CustomersViewModel 希望打开 CustomerDetailViewModel 时,将发生以下情况:
NavigationService.Navigate("Customer/{0}", SelectedCustomer.Id);
然后,这些参数将传送至 CustomerDetailViewModel,并可用于传递给 DataService。例如:
public override void OnNavigated(NavigationArguments args) {
var id = args.GetInt("Id");
if (id.HasValue) {
Customer = DataService.GetCustomerById(id.Value);
}
}
为了查找参数,我编写了一个类,其中包含两个扩展方法,用于根据 ViewModel 元数据中的信息执行某些操作。 这再次证明 MEF 中的元数据概念真的非常有用。
导航参数的扩展方法
最后的工作
如果 View 是 Page 或 ChildWindow,则此控件的标题也应该从 IViewModel 对象中提取出来。 这样,您就可以根据当前客户动态设置 Page 和 ChildWindow 的标题。
设置客户窗口标题
在完成所有这些细微的工作之后,还有最后一步。 如果 View 是 ChildWindow,则应该显示窗口。 但如果 ViewModel 实现 IClosableViewModel,此 ViewModel 的 CloseView 事件则应该链接到 ChildWindow 的 Close 方法。
IClosableViewModel 接口非常简单:
public interface IClosableViewModel : IViewModel {
event EventHandler CloseView;
}
对 ChildWindow 的处理也非常简单。 当 ViewModel 引发 CloseView 事件时,就会调用 ChildWindow 的 Close 方法。 因此,您可以间接将 ViewModel 连接到 View:
// Close the ChildWindow if the ViewModel requests it.
var closableViewModel = viewModel as IClosableViewModel;
if (closableViewModel != null) {
closableViewModel.CloseView += (s, e) => {
window.Close();
};
}
// Show the window.
window.Show();
如果 View 不是 ChildWindow,则应该直接在 IAsyncResult 中提供它。 这将在 Frame 中显示 View。
好了。 现在,您已经看到了构造 View 和 ViewModel 的整个过程。
使用示例代码
本文的代码下载包含一个 MVVM 应用程序,该应用程序利用 MEF 实现了这种自定义导航。 该解决方案包含以下示例:
- 导航到普通的 UserControl
- 通过传递参数 (.../#Employee/DiMattia) 导航到普通的 UserControl
- 通过传递参数 (.../#Customer/1) 导航到普通的 ChildWindow
- INavigationService、IDataService 等的 Import
- ViewExport 和 ViewModelExport 配置的示例
本文应该已经为如何让示例运转起来提供了一个不错的思路。 为了加深理解,请研究该代码,并对其进行自定义,以便创建您自己的应用程序。 您将会看到 MEF 有多么强大和灵活。