共用方式為


本文章是由機器翻譯。

Silverlight 已公開

使用 MEF 公開 Silverlight MVVM 應用程式中的介面

Sandrino Di Di

下載代碼示例

許多開發人員可能都將 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。

入門 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。

图 1 顯示了該行為。 我們來看一看這個 CompositionNavigationBehavior 都做些什麼。 首先,您可以看到,由於 Import 特性的緣故,該行為需要 CompositionContainer 和 CompositionNavigationLoader(後文將詳細介紹)。 隨後,構造函數將使用 CompositionInitializer 的 SatisfyImports 方法強制執行 Import。 請注意,僅當您別無選擇時,才應該使用此方法,因為它實際上會將您的代碼與 MEF 緊密耦合到一起。

圖 1 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 顯示了一個中繼資料特性示例。

圖 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 的具體實現。

圖 3 自訂 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 中繼資料中的資訊執行某些操作(請參見圖 4)。這再次證明 MEF 中的中繼資料概念真的非常有用。

圖 4 導航參數的擴展方法

最後的工作

如果 View 是 Page 或 ChildWindow,則此控制項的標題也應該從 IViewModel 物件中提取出來。這樣,您就可以根據當前客戶動態設置 Page 和 ChildWindow 的標題,如圖 5 所示。

圖 5 設置客戶視窗標題

在完成所有這些細微的工作之後,還有最後一步。如果 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 有多麼強大和靈活。

Sandrino Di Mattia 是 RealDolmen 的軟體工程師,熱愛 Microsoft 提供的每一項產品。他還參加使用者組,並且在其博客中撰寫文章,網址為 blog.sandrinodimattia.net

衷心感謝以下技術專家對本文的審閱:Glenn BlockDaniel Plaisted